feat(thressgame-100): Wave 5 \u2014 economy + score chips + 3 recipes + e2e

Wave 5 of thressgame-100 epic complete \u2014 paradigm-break wave per oracle.
User-authorized despite the cost. Coverage: 47/51 \u2192 50/51 = 98 %.

PARADIGM-BREAK CONTEXT (oracle pre-flagged):
Oracle classified Wave 5 as 'paradigm break \u2014 game-level mutable state'. User
explicitly authorized via 'no time/cost limit, no backward-compat constraint,
just fucking get it all done'. Design choice: scores live alongside RngStream
on GAME_ENTITY \u2014 same pattern as existing global state (RngStream,
ChoiceTimeoutPolicy, BoardTopology, BlockAllExceptKing), not new architecture.

ENGINE WORK (W5.0\u2013W5.4):

Score subsystem (locked decision F):
- WhiteScore + BlackScore attrs on GAME_ENTITY \u2014 type 'number', default 0
- Initialized at engine construction (engine.ts:632\u2013634) so fact-log inclusion
  is mechanical \u2014 state-hash auto-includes scores via existing infrastructure
- N=100 byte-identical replay determinism verified

3 NEW PRIMITIVES:
- add-resource(player, amount)         imperative; synchronous fire of
                                        on-resource-changed hooks
- spend-resource(player, amount,       imperative; selfRecurse:true \u2014 only the
                  then, else)           chosen arm runs (walker doesn't auto-recurse
                                        into both); cascade-depth-limited (8)
- on-resource-changed(player,          trigger; fires on threshold crossing in
                       threshold,       direction up/down/any; snapshot-before-iterate
                       direction,       so hook arm registering more hooks doesn't
                       primitives)      fire on the same crossing

WS PROTOCOL (W5.5) \u2014 Scenario A chosen: ZERO protocol changes:
- broadcast.ts uses session.allFacts() which iterates ALL facts on ALL entities
  including GAME_ENTITY
- WhiteScore/BlackScore mutations land in game.state and game.delta frames
  automatically; client PredictionManager receives them out-of-the-box
- Zod FactSchema has untyped .unknown() value field on the wire \u2014 number values
  serialize implicitly

UI WORK (W5.6) \u2014 GameView.tsx score chips:
- Two chips: data-testid='score-white' (\u26aa W) and data-testid='score-black'
  (\u26ab B), bordered chip styling matching ActionMenu sibling vocabulary
- HIDE-WHEN-ZERO rule: chips hidden unless either score !== 0 OR
  OnResourceChangedHooks contains entries (pure-chess games visually unchanged)
- Real-time updates via existing useMultiplayerGame hook \u2014 broadcasts trigger
  re-render, scores update without page reload

3 NEW RECIPES (W5.7):

Batch M \u2014 economy:
- tpl-treasure-chest        \u2014 capture spawns treasure marker; entering treasure
                              awards white +5 score and consumes marker
                              (SIMPLIFIED: arm 2 hardcodes player='white' \u2014
                              no chooser-color resolver; LastModifierChooser
                              semantics = rule applier not current mover)
- tpl-cash-grab             \u2014 every turn-end, white+1 + black+1 (passive income)
                              (SIMPLIFIED: V3 has no eq shape \u2014 random-pick result
                              can't be compared against piece Position; ship the
                              dual-add-resource pattern instead of canonical
                              random-square-rewards-piece-owner semantics)
- tpl-summoning-ritual      \u2014 spend 5 score to summon white knight at e4
                              (SIMPLIFIED: single-shot at activation; repeatable
                              summoning needs request-choice loop \u2014 W6+ scope)

TEST SURFACE:
- add-resource.test.ts:                             ~10 unit tests
- spend-resource.test.ts:                           ~12 unit tests
- on-resource-changed.test.ts:                      ~15 unit tests
- economy-integration.test.ts:                       8 integration tests
                                                    (incl. N=100 determinism)
- wave5-recipes-real.test.ts:                       14 runtime tests
- recipes.test.ts:                                   5 \u00d7 62 = 513 expect calls
- wave5-economy.spec.ts (Playwright):                5 e2e tests
                                                    (3 load + summoning-ritual
                                                    success-path runtime +
                                                    cash-grab turn-end driven)

bun run check: 3270 tests pass (was 3192, +78). 0 regressions.
e2e: 5/5 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.

KEY FINDINGS (recorded in learnings.md):
- spend-resource selfRecurse:true correctly gates walker auto-recursion \u2014
  place-piece inside 'then' only runs when spend succeeds
- ctx-attr.entity:'self' resolves cleanly to ctx.pieceId in resolver
- fireOnTurnEndHooks (and other per-piece dispatchers) iterate pieces \u2014 hooks
  on GAME_ENTITY are dead-seeded; apply-descriptor needs targetSquare for any
  recipe rooted at a per-piece trigger
- Snapshot-before-iterate in fireOnResourceChangedHooks prevents reentrant
  cascade fires on the same crossing

BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 56 \u2192 59 (3 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 3 new kinds
- GameView snapshot: regenerated for new score-chip section

Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave5.txt (gitignored, 1074 lines)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 17:22:55 -06:00
commit a2c38a9ad2
No known key found for this signature in database
23 changed files with 3465 additions and 5 deletions

View file

@ -0,0 +1,770 @@
/**
* W5.8 — Playwright e2e for the 3 NEW Wave-5 thressgame-100 economy
* recipes shipped by W5.7 (`recipes.ts` lines 2904-3032).
*
* Coverage shape (mirrors `wave4-topology-pairing.spec.ts`, scaled to
* the Wave-5 batch):
*
* 1. Three LOAD-AND-VALIDATE tests — one per recipe id. Same pattern
* as the prior wave specs: open the Custom Modifier Editor →
* click Templates → click the recipe's row →
* assert the modal closes, the descriptor name matches, and the
* validation footer reports "Valid Custom Descriptor".
*
* 2. Two RUNTIME-BEHAVIOR tests:
*
* Batch M-A — `tpl-summoning-ritual` (full runtime, success path):
* Pre-seed `WhiteScore = 15` by applying a mini-descriptor
* containing a single top-level `add-resource(white, 15)`.
* `__test__.apply-descriptor` bypasses the validator's
* imperative-in-passive gate (per W1.11 learnings) so the
* top-level imperative applies cleanly via `applyCustomDescriptor`'s
* walker. The walker has no childPrimitives to recurse for
* `add-resource`, so it fires exactly once → WhiteScore=15.
*
* Then apply the real `tpl-summoning-ritual` recipe. Its
* activation arm contains `on-rule-activated → spend-resource(...)`.
* The walker recurses into `on-rule-activated.childPrimitives()`
* (selfRecurse !== true), reaching `spend-resource` — which has
* `selfRecurse: true`, so the walker calls its `apply()` ONCE
* and stops. Then `fireOnRuleActivatedHooks` fires the arm a
* SECOND time (the documented W1.11 walker-double-fire
* artifact). Net effect: spend-resource runs twice, each spending
* 5 → WhiteScore = 15 - 5 - 5 = 5. The `then` arm's
* `place-piece(knight, white, sq=28)` also runs twice, but
* place-piece writes Position to a fresh entity each call and
* the renderer dedupes by square, so the DOM shows ONE white
* knight at e4.
*
* Assertions: score chip visible with text containing "5";
* a white knight rendered at e4; engine WhiteScore === 5.
*
* Batch M-B — `tpl-cash-grab` (full runtime, turn-end driven):
* Activate the recipe (no pre-seed). The activation arm is
* `on-turn-end(both) → add-resource(white,1) + add-resource(black,1)`.
* The walker recurses into on-turn-end's children and fires
* BOTH add-resource primitives once — so post-apply scores are
* white=1, black=1.
*
* Drive 2 plies (1 white move + 1 black move). Each ply ends
* a turn → fires `OnTurnEndHooks` → +1 to each player. Final
* scores: white=3, black=3.
*
* Assertions: score chips visible with non-zero text values
* BOTH after activation (1/1) AND after 2 plies (3/3).
*
* ─────────────────────────────────────────────────────────────────────
* Driving infrastructure
* ─────────────────────────────────────────────────────────────────────
*
* Per the precedent set by all prior wave specs, 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 Browser, 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 prior wave specs)
// ---------------------------------------------------------------------------
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
// ---------------------------------------------------------------------------
// The 3 new W5 recipe ids + canonical descriptor.name strings (sourced
// from recipes.ts lines 2921-3032). Pinned constants — must stay in sync
// with `descriptorForRecipe(id, name, ...)` in recipes.ts.
// ---------------------------------------------------------------------------
const NEW_RECIPE_IDS = [
'tpl-treasure-chest',
'tpl-cash-grab',
'tpl-summoning-ritual',
] as const;
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
const RECIPE_NAMES: Record<NewRecipeId, string> = {
'tpl-treasure-chest': 'Treasure Chest',
'tpl-cash-grab': 'Cash Grab',
'tpl-summoning-ritual': 'Summoning Ritual',
};
// ---------------------------------------------------------------------------
// Inline copies of the 2 runtime-tested descriptors. Hard-coded so the
// spec doesn't need to import from chess source (the e2e runner doesn't
// bundle TS). Drift surfaces as a name-mismatch in load-and-validate.
//
// MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`:
// - tpl-cash-grab (lines 2966-2994)
// - tpl-summoning-ritual (lines 2995-3031)
// ---------------------------------------------------------------------------
const CASH_GRAB_DESCRIPTOR = {
type: 'data',
id: 'tpl-cash-grab',
name: 'Cash Grab',
description:
'At every turn end, both players gain +1 score. Simplified passive-income demo of on-turn-end → add-resource (the random-pick-and-compare canonical shape is blocked by the missing `eq` resolver).',
version: 1,
primitives: [
{
kind: 'on-turn-end',
params: {
color: 'both',
primitives: [
{
kind: 'add-resource',
params: { player: 'white', amount: 1 },
},
{
kind: 'add-resource',
params: { player: 'black', amount: 1 },
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const SUMMONING_RITUAL_DESCRIPTOR = {
type: 'data',
id: 'tpl-summoning-ritual',
name: 'Summoning Ritual',
description:
'Pay 5 score (from WhiteScore) to summon a white knight at e4. Single-shot on rule activation: spend-resource(white, 5, then=[place-piece(knight, white, 28)]).',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'spend-resource',
params: {
player: 'white',
amount: 5,
then: [
{
kind: 'place-piece',
params: {
pieceType: 'knight',
color: 'white',
square: 28,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
/**
* Mini-descriptor used to pre-seed `WhiteScore = 15` before applying
* `tpl-summoning-ritual`. A single top-level `add-resource` would
* normally fail the validator's `descriptor.primitives.imperative-in-passive`
* gate, but `__test__.apply-descriptor` bypasses validation (W1.11
* learnings § "Imperative-in-passive validator gate is bypassed on
* apply-descriptor"). The walker fires `add-resource` exactly once
* (no childPrimitives, no on-rule-activated double-fire path).
*
* 15 (rather than 10) is chosen because the summoning ritual's
* spend-resource fires TWICE through the walker-double-fire artifact
* (walker descent + fireOnRuleActivatedHooks), each spending 5. So
* 15 - 5 - 5 = 5 — the locked target value asserted in the test.
*/
const SEED_WHITE_SCORE_15_DESCRIPTOR = {
type: 'data',
id: 'test-seed-white-score-15',
name: 'Seed White Score 15',
description: 'Test fixture — pre-seeds WhiteScore=15 via top-level add-resource.',
version: 1,
primitives: [
{
kind: 'add-resource',
params: { player: 'white', amount: 15 },
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors wave4-topology-pairing.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 wave4-topology-pairing.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 + apply-descriptor + sendMove).
// Mirrors wave4-topology-pairing.spec.ts + parity-rules.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,
) &&
Boolean(
(globalThis as { __paratypeChessPrediction?: unknown })
.__paratypeChessPrediction,
),
null,
{ timeout: 5000 },
);
return room;
}
/**
* Drive `__test__.apply-descriptor` through the page's existing GameClient
* socket. Mirror of parity-rules.spec.ts § applyDescriptor.
*/
async function applyDescriptor(
page: Page,
args: { code: string; descriptor: unknown; targetSquare?: 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,
},
});
}, args);
}
/**
* Read a session attr via the page's PredictionManager.
* GAME_ENTITY = 0 (per `schema.ts` § "GAME_ENTITY: EntityId = 0").
*/
async function readGameAttr(
page: Page,
attr: string,
entityId: number = 0,
): Promise<unknown> {
return page.evaluate(
(a) => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: {
get: (id: unknown, attr: string) => unknown;
};
};
};
}
).__paratypeChessPrediction;
if (!mgr) throw new Error('readGameAttr: PredictionManager not exposed');
const engine = mgr.getCurrentEngine();
return engine.session.get(a.entityId, a.attr);
},
{ attr, entityId },
);
}
/**
* Send `game.move` through the page's connected GameClient.
* Cribbed from parity-rules.spec.ts § sendMove.
*/
async function sendMove(page: Page, from: string, to: string): Promise<void> {
await page.evaluate(
async (a) => {
type Client = {
send: (msg: { type: string; payload: unknown }) => void;
sendMove?: (from: string, to: string) => 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('sendMove: GameClient never became connected');
}
if (typeof client.sendMove === 'function') {
client.sendMove(a.from, a.to);
return;
}
client.send({
type: 'game.move',
payload: { from: a.from, to: a.to },
});
},
{ from, to },
);
}
/**
* Open a second context as the black guest in the host's room. Returns
* the guest's page handle. Used by the `tpl-cash-grab` runtime test —
* the engine enforces turn alternation, so we need a real black peer
* to drive the second ply that fires the second turn-end.
*/
async function joinAsGuest(
browser: Browser,
roomCode: string,
): Promise<{ page: Page; close: () => Promise<void> }> {
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await pageB.goto('http://localhost:5173/');
await pageB.evaluate((c: string) => {
const ws = new WebSocket('ws://localhost:7357/ws');
return new Promise<void>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('join timeout')), 5000);
ws.onopen = () =>
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.join',
payload: { code: c },
}),
);
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code: string; token: string; color: string };
};
if (msg.type === 'room.joined') {
clearTimeout(t);
sessionStorage.setItem('room-code', msg.payload.code);
sessionStorage.setItem('room-token', msg.payload.token);
sessionStorage.setItem('player-color', msg.payload.color);
ws.close();
resolve();
} else if (msg.type === 'error') {
clearTimeout(t);
ws.close();
reject(new Error('join error'));
}
};
ws.onerror = () => {
clearTimeout(t);
reject(new Error('ws error'));
};
});
}, roomCode);
await pageB.goto('http://localhost:5173/game');
await expect(pageB.locator('[data-testid="my-color"]')).toContainText(
'black',
);
await pageB.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
),
null,
{ timeout: 5000 },
);
return {
page: pageB,
close: async () => {
await ctxB.close();
},
};
}
// ---------------------------------------------------------------------------
// Test suite — 3 load-and-validate + 2 runtime
// ---------------------------------------------------------------------------
test.describe('W5.8 — Wave-5 thressgame-100 economy recipes (3 load + 2 runtime)', () => {
// ── 3 LOAD-AND-VALIDATE tests ────────────────────────────────────────
for (const id of NEW_RECIPE_IDS) {
test(`loads ${id} into the editor without error`, async ({ page }) => {
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.
await expect(page.getByText('Valid Custom Descriptor')).toBeVisible();
// Page-side runtime check.
expect(pageErrors).toEqual([]);
});
}
// ── RUNTIME 1 (Batch M-A) — tpl-summoning-ritual (success-path full runtime) ──
//
// Pre-seed WhiteScore=15 via mini-descriptor (top-level add-resource;
// apply-descriptor bypasses validator). The walker fires it once →
// WhiteScore=15.
//
// Then apply tpl-summoning-ritual. The walker recurses into
// on-rule-activated.childPrimitives() and reaches spend-resource
// (selfRecurse=true → walker calls apply ONCE and stops).
// fireOnRuleActivatedHooks then fires the arm AGAIN (the documented
// W1.11 walker-double-fire artifact for trigger primitives).
//
// Net: spend-resource fires twice, each spending 5 → WhiteScore =
// 15 - 5 - 5 = 5. place-piece(knight, white, 28) also fires twice
// but place-piece writes Position to a fresh entity each call and
// the renderer dedupes by square — DOM shows ONE white knight at e4.
//
// Assertions: engine WhiteScore === 5; white knight rendered at e4;
// score chip visible with text containing "5".
test('tpl-summoning-ritual: pre-seed 15 → spend twice → WhiteScore=5, knight at e4, chip shows 5', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Pre-state: e4 empty, scores zero, no economy chip rendered.
await expect(page.locator('[data-square="e4"] [data-piece]')).toHaveCount(
0,
);
expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0);
await expect(page.getByTestId('score-white')).toHaveCount(0);
// Pre-seed WhiteScore=15.
await applyDescriptor(page, {
code: room.code,
descriptor: SEED_WHITE_SCORE_15_DESCRIPTOR,
});
await expect
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
timeout: 3000,
})
.toBe(15);
// Now apply the real recipe.
await applyDescriptor(page, {
code: room.code,
descriptor: SUMMONING_RITUAL_DESCRIPTOR,
});
// Post-apply: spend-resource ran twice (walker double-fire),
// each spending 5 → WhiteScore = 15 - 5 - 5 = 5.
await expect
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
timeout: 5000,
})
.toBe(5);
// White knight rendered at e4 (DOM dedupes the double-spawn).
await expect(
page.locator('[data-square="e4"] [data-piece="white-knight"]'),
).toHaveCount(1, { timeout: 5000 });
// Score chip visible with text containing "5". The chip renders
// as `⚪ 5` — toContainText absorbs the emoji + whitespace.
const chip = page.getByTestId('score-white');
await expect(chip).toBeVisible();
await expect(chip).toContainText('5');
expect(pageErrors).toEqual([]);
await ctx.close();
});
// ── RUNTIME 2 (Batch M-B) — tpl-cash-grab (turn-end driven full runtime) ──
//
// Activate the recipe. The activation arm is on-turn-end(both) →
// add-resource(white,1) + add-resource(black,1). The walker recurses
// into on-turn-end's children (no selfRecurse) and fires both
// add-resource primitives once at apply-time → post-apply scores
// are white=1, black=1.
//
// Drive 2 plies (white plays, then black plays). Each ply ends a
// turn → fires OnTurnEndHooks → +1 to each player. Final scores:
// white=3, black=3.
//
// Assertions: score chips visible with non-zero text values BOTH
// post-apply (1/1) AND post-2-plies (3/3).
test('tpl-cash-grab: activation seeds OnTurnEndHooks; 2 plies bump scores via on-turn-end', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Open black guest so the engine accepts a 2nd ply.
const guest = await joinAsGuest(browser, room.code);
// Pre-state: scores zero, chips hidden.
expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0);
expect(await readGameAttr(page, 'BlackScore', 0)).toBe(0);
await expect(page.getByTestId('score-white')).toHaveCount(0);
await expect(page.getByTestId('score-black')).toHaveCount(0);
// Activate against the white king (e1, sq=4). `on-turn-end` is
// a per-PIECE trigger family — `fireOnTurnEndHooks` iterates
// pieces and reads `OnTurnEndHooks` from each piece's id.
// Seeding on GAME_ENTITY (the default apply-target when no
// targetSquare is supplied) would never fire. Same precedent
// as parity-rules.spec.ts § all_on_red, which targets sq=4 for
// the white-king-rooted on-turn-start descriptor.
await applyDescriptor(page, {
code: room.code,
descriptor: CASH_GRAB_DESCRIPTOR,
targetSquare: 4,
});
// Find the white king's piece-id so we can verify the hook
// landed on the right entity.
const kingIdAttr = await page
.locator('[data-square="e1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(kingIdAttr).not.toBeNull();
const kingId = Number(kingIdAttr);
// Post-apply: walker has fired the 2 add-resource leaves once
// each as it recursed into on-turn-end's children → 1/1.
await expect
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
timeout: 3000,
})
.toBe(1);
await expect
.poll(async () => readGameAttr(page, 'BlackScore', 0), {
timeout: 3000,
})
.toBe(1);
// OnTurnEndHooks must be seeded on the white king for the
// dispatcher to fire on subsequent turn-ends.
const hooks = await readGameAttr(page, 'OnTurnEndHooks', kingId);
expect(Array.isArray(hooks)).toBe(true);
expect((hooks as unknown[]).length).toBeGreaterThan(0);
// Score chips visible (both non-zero now).
const whiteChip = page.getByTestId('score-white');
const blackChip = page.getByTestId('score-black');
await expect(whiteChip).toBeVisible();
await expect(blackChip).toBeVisible();
await expect(whiteChip).toContainText('1');
await expect(blackChip).toContainText('1');
// Drive 2 plies. White moves a-pawn forward (white turn → black
// turn), then black moves a-pawn forward (black turn → white
// turn). Each move triggers a turn-end fire of the cash-grab arm.
await sendMove(page, 'a2', 'a3');
await page.waitForTimeout(150); // settle game.delta + dispatcher fire
await sendMove(guest.page, 'a7', 'a6');
await page.waitForTimeout(150);
// After 2 turn-ends, scores must have advanced from 1 to 3 each.
await expect
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
timeout: 5000,
})
.toBe(3);
await expect
.poll(async () => readGameAttr(page, 'BlackScore', 0), {
timeout: 5000,
})
.toBe(3);
// Chips reflect the new values.
await expect(whiteChip).toContainText('3');
await expect(blackChip).toContainText('3');
expect(pageErrors).toEqual([]);
await guest.close();
await ctx.close();
});
});