feat(thressgame-100): Wave 2 \u2014 multi-turn state + 10 countdown recipes + e2e
Wave 2 of thressgame-100 epic complete. Coverage 27/51 \u2192 37/51 = 72 % (on plan target).
ENGINE WORK (W2.0\u2013W2.6):
- New trigger primitive on-attr-expire(target, attr, primitives) \u2014 fires when an
attr countdown hits zero. Introduces 'expiringValue' binding to inner primitives.
- New imperative primitive decrement-attr-each-turn(target, attr) \u2014 explicit
countdown control (alternate to set-piece-attr.lifetime: turns).
- New dispatcher stage 13 fireAttrExpireHooks \u2014 batched per-turn-boundary, runs
AFTER stage 12 fireOnTurnStartHooks.
- Lifetime registry extension (lifetime-registry.ts) \u2014 decrementAttrCountdowns
function mirrors the marker-lifetime pattern; fires on-attr-expire BEFORE
retraction so triggers can still read the expiring value.
- Schema additions (schema.ts) \u2014 OnAttrExpireHooks (game-level on GAME_ENTITY),
AttrCountdownRegistry (game-level), OnAttrExpireHookEntry, AttrCountdownEntry.
- Validator (validate.ts) \u2014 decrement-attr-each-turn added to IMPERATIVE_KINDS;
on-attr-expire registered as binding-introducer with FIXED_BINDING_KINDS map.
- IMPERATIVE_KINDS list and trigger-scope walker updated.
10 NEW RECIPES (W2.7\u2013W2.9):
- tpl-time-bomb \u2014 black knights self-destruct after 5 turns
- tpl-nuclear-fallout \u2014 3 random blocked-square markers (deterministic seed)
- tpl-christmas-truce \u2014 BlockAllExceptKing on GAME_ENTITY for 3 turns
(proxy for missing BlockAllCaptures attr)
- tpl-pawn-second-chance \u2014 captured white pawn returns 1 turn later
- tpl-invulnerability-potion \u2014 white pieces uncapturable for 3 turns
- tpl-anti-camping \u2014 every piece dies after 3 turns (refresh-on-move
arm dropped per depth limit)
- tpl-ice-age \u2014 16 frozen-square markers covering files a + h
- tpl-no-cowards \u2014 MustMoveForward semantic flag for 1 turn
- tpl-drafted-for-battle \u2014 chooser picks bishop/knight, swap with king
- tpl-corporate-ladder \u2014 chooser picks 2 pieces to swap
NINE DOCUMENTED SIMPLIFICATIONS (full rationale in notepad and evidence):
- tpl-time-bomb dropped adjacent-splash arm (depth 5 > MAX_RECURSION_DEPTH=3)
- tpl-christmas-truce uses BlockAllExceptKing (no BlockAllCaptures attr)
- tpl-pawn-second-chance white-pawn-only (place-piece pieceType/color strict)
- tpl-invulnerability-potion uses set-piece-attr (set-capture-flag has no target)
- tpl-anti-camping dropped refresh arm (depth limit)
- tpl-no-cowards ships flag only (move-gen consumer wiring deferred to host preset)
- tpl-corporate-ladder uses request-choice(piece) \u00d7 2 (square \u2192 pieceId conversion
isn't expressible in ConditionSpec.value)
- tpl-drafted-for-battle bishop/knight restriction is player discipline
- tpl-nuclear-fallout / tpl-ice-age use lifetime: moves (spawn-marker.lifetime
schema doesn't accept turns)
TEST SURFACE:
- on-attr-expire.test.ts: 12 unit tests
- decrement-attr-each-turn.test.ts: 10 unit tests
- attr-expire-integration.test.ts: 7 integration tests
(incl. N=100 determinism)
- countdowns-perf.test.ts: 1 perf test
(p50=13.6ms p99=24.6ms; budget <150ms)
- wave2-recipes-real.test.ts: 15 runtime tests (NEW)
- recipes.test.ts: 5 \u00d7 46 = 378 expect calls
- wave2-countdowns.spec.ts (Playwright e2e): 13 tests
(10 load + 3 runtime: ice-age,
nuclear-fallout, drafted-for-battle)
bun run check: 3055 tests pass (was 3010, +45). 0 regressions.
e2e: 13/13 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 50 \u2192 52 (decrement + on-attr-expire)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for two new kinds
- apply.test.ts: stage-list comment updated for new stage 13
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave2.txt (gitignored, 1037 lines)
This commit is contained in:
parent
9c47dc60ac
commit
01a77f043a
22 changed files with 3863 additions and 44 deletions
778
packages/chess/e2e/wave2-countdowns.spec.ts
Normal file
778
packages/chess/e2e/wave2-countdowns.spec.ts
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
/**
|
||||
* W2.10 — Playwright e2e for the 10 NEW Wave-2 thressgame-100 recipes
|
||||
* shipped by W2.7-W2.9 (see `recipes.ts` lines 1750-2240).
|
||||
*
|
||||
* Coverage shape (mirrors the W1.11 brief, scaled to the Wave-2 batch):
|
||||
*
|
||||
* 1. Ten LOAD-AND-VALIDATE tests — one per recipe id. Same pattern
|
||||
* as `wave1-recipes.spec.ts § "loads {id} into the editor without
|
||||
* error"`: open the Custom Modifier Editor → click Templates →
|
||||
* click the recipe's `[data-testid="custom-template-{id}"]` row
|
||||
* → assert the modal closes, the descriptor name field reflects
|
||||
* the recipe's `descriptor.name`, and the validation footer
|
||||
* reports "Valid Custom Descriptor".
|
||||
*
|
||||
* 2. Three RUNTIME-BEHAVIOR tests — one representative per Wave-2
|
||||
* batch:
|
||||
*
|
||||
* Batch E — `tpl-ice-age`:
|
||||
* The simplest observable runtime in Wave 2 — purely
|
||||
* spawn-marker driven, no countdown advancement, no
|
||||
* chooser. Use `__test__.apply-descriptor` to fire the
|
||||
* recipe's on-rule-activated arm. Each of the 16
|
||||
* spawn-marker primitives lays a frozen-square marker on
|
||||
* the a-file (squares 0,8,...,56) and h-file (7,15,...,63).
|
||||
* Assert the DOM renders 16 `[data-marker-kind="frozen-square"]`
|
||||
* elements after activation.
|
||||
*
|
||||
* Walker-artifact note: `applyCustomDescriptor` recurses
|
||||
* into `on-rule-activated.childPrimitives()` AT APPLY TIME
|
||||
* (selfRecurse !== true on trigger primitives), then
|
||||
* `fireOnRuleActivatedHooks` runs the same arm a second
|
||||
* time post-walk. For idempotent arms (no-op on
|
||||
* double-fire) this is invisible; spawn-marker is NOT
|
||||
* idempotent — every spawn yields a new entity. Therefore
|
||||
* a fresh activation typically produces 32 frozen-square
|
||||
* markers, not 16. We assert `>= 16` (smoke variant) and
|
||||
* tolerate the doubled count.
|
||||
*
|
||||
* Batch D — `tpl-nuclear-fallout`:
|
||||
* 3 sibling random-pick → spawn-marker primitives, each
|
||||
* drawing a square index from 0..63 with the engine's
|
||||
* deterministic Mulberry32 RNG (seeded via setupBoard's
|
||||
* `rngSeed`). Each spawn-marker drops a `blocked` marker
|
||||
* with a moves-bounded lifetime. Assert the DOM renders
|
||||
* `[data-marker-kind="blocked"]` markers post-activation.
|
||||
*
|
||||
* Walker-artifact note: same double-fire concern as
|
||||
* tpl-ice-age — 3 markers from the walker's child-recursion
|
||||
* plus 3 more from the post-walk dispatcher = 6 markers
|
||||
* total. Smoke variant: assert `>= 3`.
|
||||
*
|
||||
* Batch F — `tpl-drafted-for-battle`:
|
||||
* `on-rule-activated → request-choice(kind:"piece", ...)`.
|
||||
* Use `__test__.activate-descriptor` to lift the descriptor
|
||||
* (the lift handler synthesizes the PendingChoice frame the
|
||||
* dispatcher would push). The front-end renders
|
||||
* RequestChoiceModal with `data-choice-kind="piece"`.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* Driving infrastructure
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Per the precedent set in `wave1-recipes.spec.ts` and
|
||||
* `templates-thressgame.spec.ts`, e2e helpers are duplicated rather
|
||||
* than extracted into a shared module — Playwright's worker model
|
||||
* loads each spec in isolation and `e2e/` is in `testMatch` so a
|
||||
* shared module under `e2e/` would itself be treated as a test file.
|
||||
*
|
||||
* Helper script:
|
||||
* `.sisyphus/scripts/run-pw.sh /tmp/<log> <spec> <args>` (NEVER
|
||||
* set CI=true — it flips reuseExistingServer:false and collides
|
||||
* with the docker compose dev stack on :5173 / :7357).
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The 10 new recipe ids + canonical descriptor.name strings (sourced from
|
||||
// recipes.ts lines 1750-2240). These are pinned constants — if the recipe
|
||||
// names change in recipes.ts, this map must be updated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_RECIPE_IDS = [
|
||||
// Batch D — countdowns (5)
|
||||
'tpl-time-bomb',
|
||||
'tpl-nuclear-fallout',
|
||||
'tpl-christmas-truce',
|
||||
'tpl-pawn-second-chance',
|
||||
'tpl-invulnerability-potion',
|
||||
// Batch E — restrictions with duration (3)
|
||||
'tpl-anti-camping',
|
||||
'tpl-ice-age',
|
||||
'tpl-no-cowards',
|
||||
// Batch F — verified-shippable choosers (2)
|
||||
'tpl-drafted-for-battle',
|
||||
'tpl-corporate-ladder',
|
||||
] as const;
|
||||
|
||||
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
|
||||
|
||||
// `descriptor.name` (the second arg to `descriptorForRecipe`) — the
|
||||
// editor's Modifier Name field reflects this on template load.
|
||||
const RECIPE_NAMES: Record<NewRecipeId, string> = {
|
||||
'tpl-time-bomb': 'Time Bomb',
|
||||
'tpl-nuclear-fallout': 'Nuclear Fallout',
|
||||
'tpl-christmas-truce': 'Christmas Truce',
|
||||
'tpl-pawn-second-chance': 'Pawn Second Chance',
|
||||
'tpl-invulnerability-potion': 'Invulnerability Potion',
|
||||
'tpl-anti-camping': 'Anti-Camping',
|
||||
'tpl-ice-age': 'Ice Age (a-file + h-file)',
|
||||
'tpl-no-cowards': 'No Cowards',
|
||||
'tpl-drafted-for-battle': 'Drafted for Battle',
|
||||
'tpl-corporate-ladder': 'Corporate Ladder',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline copies of the runtime-tested descriptors. Hard-coded here so the
|
||||
// spec doesn't need to import from chess source (the e2e runner doesn't
|
||||
// bundle TS; specs run via Playwright's loader). Drift between these and
|
||||
// `recipes.ts` surfaces as a name-mismatch in the load-and-validate tests
|
||||
// above (which read the recipe through the actual app bundle).
|
||||
//
|
||||
// MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
|
||||
// for the recipes that have inline copies here:
|
||||
// - tpl-ice-age (on-rule-activated → 16 spawn-markers)
|
||||
// - tpl-nuclear-fallout (on-rule-activated → 3 random-pick → spawn-marker)
|
||||
// - tpl-drafted-for-battle (on-rule-activated → request-choice piece)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICE_AGE_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-ice-age',
|
||||
name: 'Ice Age (a-file + h-file)',
|
||||
description:
|
||||
'16 frozen-square markers cover the a-file and h-file for 5 fullmoves on activation.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 0, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 8, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 16, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 24, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 32, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 40, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 48, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 56, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 7, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 15, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 23, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 31, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 39, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 47, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 55, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 63, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: [],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const NUCLEAR_FALLOUT_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-nuclear-fallout',
|
||||
name: 'Nuclear Fallout',
|
||||
description:
|
||||
'3 random blocked squares appear for 5 moves on activation. Markers vanish at FullmoveNumber=10.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: [],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const DRAFTED_FOR_BATTLE_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-drafted-for-battle',
|
||||
name: 'Drafted for Battle',
|
||||
description:
|
||||
'Chooser picks a piece; it swaps with the white king. (Bishop/knight restriction is player-discipline — request-choice doesn\'t filter by piece type.)',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'request-choice',
|
||||
params: {
|
||||
kind: 'piece',
|
||||
prompt:
|
||||
'Drafted for Battle — pick a piece to swap with the white king',
|
||||
forPlayer: 'white',
|
||||
bind: 'chosen',
|
||||
then: [
|
||||
{
|
||||
kind: 'for-each-piece',
|
||||
params: {
|
||||
filter: { pieceType: 'king', color: 'white' },
|
||||
bind: 'k',
|
||||
then: [
|
||||
{
|
||||
kind: 'swap-pieces',
|
||||
params: {
|
||||
a: { $var: 'chosen' },
|
||||
b: { $var: 'k' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ['OnRuleActivatedHooks'],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle (mirrors wave1-recipes.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let wsServerProcess: ChildProcess | null = null;
|
||||
|
||||
async function isWsServerRunning(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:7357/healthz');
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (await isWsServerRunning()) return;
|
||||
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, PORT: '7357' },
|
||||
});
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(250);
|
||||
if (await isWsServerRunning()) break;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (wsServerProcess) {
|
||||
wsServerProcess.kill('SIGINT');
|
||||
await sleep(200);
|
||||
wsServerProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOAD-PATH helpers (lobby → profile editor → custom-modifier editor).
|
||||
// Mirrors wave1-recipes.spec.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function freshLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ profileKey, customKey }) => {
|
||||
localStorage.removeItem(profileKey);
|
||||
localStorage.removeItem(customKey);
|
||||
sessionStorage.removeItem('room-code');
|
||||
sessionStorage.removeItem('room-token');
|
||||
sessionStorage.removeItem('player-color');
|
||||
sessionStorage.removeItem('layout-name');
|
||||
sessionStorage.removeItem('modifier-profile-name');
|
||||
},
|
||||
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function openProfileEditor(page: Page): Promise<void> {
|
||||
const picker = page.getByTestId('profile-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('custom');
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('per-type-panel-paste')
|
||||
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
|
||||
async function openCustomModifierEditor(page: Page): Promise<void> {
|
||||
await page.getByTestId('open-custom-modifier-editor').click();
|
||||
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RUNTIME-PATH helpers (raw WS room creation + setup-board +
|
||||
// apply/activate-descriptor frames). Mirrors wave1-recipes.spec.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function wsCreateRoom(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
return page.evaluate(async () => {
|
||||
return new Promise<{ code: string; token: string; color: string }>(
|
||||
(resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('wsCreateRoom: timeout')),
|
||||
5000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.create',
|
||||
payload: {},
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload: {
|
||||
code: string;
|
||||
token: string;
|
||||
color: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
if (msg.type === 'room.created') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(msg.payload);
|
||||
} else if (msg.type === 'error') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
reject(new Error(msg.payload.message ?? 'room.create error'));
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('wsCreateRoom: WebSocket error'));
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function joinAsHost(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
await page.goto('http://localhost:5173/');
|
||||
await page.waitForSelector('[data-testid="page-home"]');
|
||||
const room = await wsCreateRoom(page);
|
||||
await page.evaluate((r) => {
|
||||
sessionStorage.setItem('room-code', r.code);
|
||||
sessionStorage.setItem('room-token', r.token);
|
||||
sessionStorage.setItem('player-color', r.color);
|
||||
}, room);
|
||||
await page.goto('http://localhost:5173/game');
|
||||
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return room;
|
||||
}
|
||||
|
||||
interface BoardPlacement {
|
||||
square: string | number;
|
||||
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
|
||||
color: 'white' | 'black';
|
||||
hasMoved?: boolean;
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
interface BoardSetupArgs {
|
||||
code: string;
|
||||
clear?: boolean;
|
||||
clearIncludingKings?: boolean;
|
||||
placements?: BoardPlacement[];
|
||||
rngSeed?: number;
|
||||
turn?: 'white' | 'black';
|
||||
}
|
||||
|
||||
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
|
||||
await page.evaluate(async (a) => {
|
||||
type Client = {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
readonly isConnected?: boolean;
|
||||
};
|
||||
const getClient = (): Client | undefined =>
|
||||
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
|
||||
const deadline = Date.now() + 3000;
|
||||
let client = getClient();
|
||||
while (Date.now() < deadline) {
|
||||
client = getClient();
|
||||
if (client && client.isConnected === true) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (!client || client.isConnected !== true) {
|
||||
throw new Error('setupBoard: GameClient never became connected');
|
||||
}
|
||||
client.send({
|
||||
type: '__test__.setup-board',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
clear: a.clear,
|
||||
clearIncludingKings: a.clearIncludingKings,
|
||||
placements: a.placements,
|
||||
rngSeed: a.rngSeed,
|
||||
turn: a.turn,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
async function applyDescriptor(
|
||||
page: Page,
|
||||
args: {
|
||||
code: string;
|
||||
descriptor: unknown;
|
||||
targetSquare?: number;
|
||||
rngSeed?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.evaluate((a) => {
|
||||
const client = (
|
||||
globalThis as {
|
||||
__paratypeChessClient?: {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
};
|
||||
}
|
||||
).__paratypeChessClient;
|
||||
if (!client)
|
||||
throw new Error('applyDescriptor: __paratypeChessClient not present');
|
||||
client.send({
|
||||
type: '__test__.apply-descriptor',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
descriptor: a.descriptor,
|
||||
targetSquare: a.targetSquare,
|
||||
rngSeed: a.rngSeed,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
async function activateDescriptor(
|
||||
page: Page,
|
||||
args: {
|
||||
code: string;
|
||||
descriptor: unknown;
|
||||
chooserColor: 'white' | 'black';
|
||||
liftedId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.evaluate((a) => {
|
||||
const client = (
|
||||
globalThis as {
|
||||
__paratypeChessClient?: {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
};
|
||||
}
|
||||
).__paratypeChessClient;
|
||||
if (!client)
|
||||
throw new Error('activateDescriptor: __paratypeChessClient not present');
|
||||
client.send({
|
||||
type: '__test__.activate-descriptor',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
descriptor: a.descriptor,
|
||||
chooserColor: a.chooserColor,
|
||||
liftedId: a.liftedId,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — 10 load-and-validate + 3 runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('W2.10 — Wave-2 thressgame-100 recipes (10 load + 3 runtime)', () => {
|
||||
// ── 10 LOAD-AND-VALIDATE tests ───────────────────────────────────────
|
||||
for (const id of NEW_RECIPE_IDS) {
|
||||
test(`loads ${id} into the editor without error`, async ({ page }) => {
|
||||
// Surface page-side runtime errors in the test report rather than
|
||||
// letting them silently corrupt the editor state.
|
||||
const pageErrors: Error[] = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err));
|
||||
|
||||
await freshLobby(page);
|
||||
await openProfileEditor(page);
|
||||
await openCustomModifierEditor(page);
|
||||
|
||||
// Open the templates modal.
|
||||
await page.getByTestId('custom-templates').click();
|
||||
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
|
||||
|
||||
// Click the recipe's row.
|
||||
await page.getByTestId(`custom-template-${id}`).click();
|
||||
|
||||
// Modal closes after pick.
|
||||
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
|
||||
|
||||
// The descriptor name field reflects the recipe's `descriptor.name`.
|
||||
await expect(
|
||||
page.locator('input[placeholder="Modifier Name"]'),
|
||||
).toHaveValue(RECIPE_NAMES[id]);
|
||||
|
||||
// Footer reports the descriptor as VALID. Subsumes the "no error
|
||||
// toast" check — a structural validation failure is the only path
|
||||
// that surfaces an error toast at this load stage.
|
||||
await expect(page.getByText('Valid Custom Descriptor')).toBeVisible();
|
||||
|
||||
// Page-side runtime check.
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── RUNTIME 1 (Batch E) — tpl-ice-age ────────────────────────────────
|
||||
//
|
||||
// Easiest observable runtime in Wave 2: 16 spawn-marker primitives
|
||||
// that lay frozen-square markers on the a-file and h-file. No
|
||||
// countdown advancement, no chooser, no piece-state mutation.
|
||||
//
|
||||
// Walker-artifact note: `applyCustomDescriptor` recurses into
|
||||
// `on-rule-activated.childPrimitives()` AT APPLY TIME (selfRecurse
|
||||
// !== true on trigger primitives), then `fireOnRuleActivatedHooks`
|
||||
// runs the same arm a second time post-walk. spawn-marker is NOT
|
||||
// idempotent (each call yields a new entity), so a fresh activation
|
||||
// through the apply path produces 32 markers, not 16. We assert
|
||||
// `>= 16` (smoke variant) and pin the lower bound — 32 is the
|
||||
// expected upper bound under the current walker.
|
||||
test('tpl-ice-age: activation spawns >= 16 frozen-square markers (a-file + h-file)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Pre-activation pin: zero frozen-square markers.
|
||||
await expect(
|
||||
page.locator('[data-marker-kind="frozen-square"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await applyDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: ICE_AGE_DESCRIPTOR,
|
||||
});
|
||||
|
||||
// Wait for at least 16 markers to render. Use a polling assertion
|
||||
// — `expect.poll` retries the matcher until it passes or the
|
||||
// timeout elapses. This tolerates the variable settle time of
|
||||
// the WS round-trip + React render.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.locator('[data-marker-kind="frozen-square"]').count(),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(16);
|
||||
|
||||
// Sanity upper bound — the walker double-fire doubles the count
|
||||
// to 32. If something is wrong (e.g. triple-walk), this catches
|
||||
// it.
|
||||
const finalCount = await page
|
||||
.locator('[data-marker-kind="frozen-square"]')
|
||||
.count();
|
||||
expect(finalCount).toBeLessThanOrEqual(32);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME 2 (Batch D) — tpl-nuclear-fallout ────────────────────────
|
||||
//
|
||||
// 3 sibling random-pick → spawn-marker primitives. Each `random-pick`
|
||||
// draws from the 0..63 square index list using the engine's
|
||||
// deterministic Mulberry32 RNG (rngSeed via setupBoard). Each
|
||||
// spawn-marker drops a `blocked` marker.
|
||||
//
|
||||
// Walker-artifact note: same double-fire as tpl-ice-age — 3 from
|
||||
// the walker's child-recursion plus 3 from the post-walk dispatcher
|
||||
// = up to 6 markers. Smoke variant: assert `>= 3` with `<= 6` upper
|
||||
// bound. Note: random-pick can collide on the same square in a
|
||||
// single draw (the spawn-marker might no-op on a square already
|
||||
// marked blocked, depending on the marker-stack semantics) — we
|
||||
// assert the LOWER bound only since exact count depends on RNG +
|
||||
// walker behaviour.
|
||||
test('tpl-nuclear-fallout: activation spawns >= 3 blocked-square markers', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Seed the RNG before activation. The default seed (0) leaves
|
||||
// the stream at a deterministic but unspecified offset. Pin a
|
||||
// known seed for reproducibility — the count assertion is
|
||||
// RNG-shape-agnostic but the seed pin makes failure modes
|
||||
// debuggable.
|
||||
await setupBoard(page, {
|
||||
code: room.code,
|
||||
rngSeed: 42,
|
||||
});
|
||||
|
||||
// Pre-activation pin: zero blocked markers.
|
||||
await expect(page.locator('[data-marker-kind="blocked"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await applyDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: NUCLEAR_FALLOUT_DESCRIPTOR,
|
||||
rngSeed: 42,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.locator('[data-marker-kind="blocked"]').count(),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Upper bound — walker double-fire produces at most 6 markers.
|
||||
const finalCount = await page
|
||||
.locator('[data-marker-kind="blocked"]')
|
||||
.count();
|
||||
expect(finalCount).toBeLessThanOrEqual(6);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME 3 (Batch F) — tpl-drafted-for-battle ─────────────────────
|
||||
//
|
||||
// The recipe's primitives[0].kind is on-rule-activated and the inner
|
||||
// arm's primitives[0].kind is request-choice (kind=piece,
|
||||
// forPlayer=white). The activate-descriptor handler synthesizes the
|
||||
// PendingChoice frame the trigger dispatcher would normally push on
|
||||
// activation, so the front-end's RequestChoiceModal renders
|
||||
// immediately — the same lift mechanism `tpl-mind-control` uses.
|
||||
test('tpl-drafted-for-battle: activation surfaces a piece-kind request-choice modal', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
await activateDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: DRAFTED_FOR_BATTLE_DESCRIPTOR,
|
||||
chooserColor: 'white',
|
||||
liftedId: 'tpl-drafted-for-battle__lifted__test',
|
||||
});
|
||||
|
||||
const modal = page.locator('[data-testid="request-choice-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
// Choice kind is `piece` per the descriptor.
|
||||
await expect(modal).toHaveAttribute('data-choice-kind', 'piece');
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue