houserules/packages/chess/e2e/request-choice.spec.ts
Joey Yakimowich-Payne 4c25277449
feat(thressgame-coverage): Wave 15 (e2e for 5 parity rules + lift T68/3 parry)
Closes 5 of 5 unit-only parity rules with real Playwright validation:

- T83/all_on_red: probabilistic on-turn-start arm seeds BlockAllExceptKing (verified via UI move attempt + restoration)
- T83/ice_physics: SlideMustBeMaxDistance forces sliders to max-distance ray step (verified via legal-move highlight + drag rejection)
- T68/3 parry (lifted from .fixme): capture triggers RPS → defender wins → cancel-capture restores defender + reverts attacker
- T84/religious_conversion: bishop move converts adjacent enemy non-king pieces (verified via data-piece color flip)
- T84/kamikaze: capture triggers AOE destroying adjacent non-king; king immune (verified via DOM + RNG seed)

Helper: .sisyphus/scripts/run-pw.sh — nohup-based Playwright runner with done-marker poll. Avoids 30min agent timeout when running long e2e suites.

Tests: 2865 -> 2866 (+1 unit). E2E: 8/8 pass (was 3 active + 1 fixme; now 8 active + 0 fixme). bun run check exit 0.
2026-04-26 18:06:05 -06:00

873 lines
35 KiB
TypeScript

/**
* T68 / T79 — Playwright E2E: request-choice round-trip flows
*
* Three scenarios that exercise the full request-choice → submit-choice
* round-trip across the WebSocket boundary:
*
* 1. Single-player choice (mr_freeze descriptor) — PASS
* Activate → request-choice modal appears → click column →
* game proceeds with frozen-square markers visible.
*
* 2. Both-player choice (mind_control descriptor) — PASS
* Activate (push two frames, one per chooser) → 2 browser
* contexts (one per player) → both modals appear → each clicks →
* game proceeds with conversions on both sides.
*
* 3. Nested choice (parry rule, RPS over capture) — TODO
* Capture triggers RPS → both RPS modals → choices resolve →
* conditional cancels capture if defender wins.
*
* The parry descriptor is `on-captured`, NOT `on-rule-activated`.
* Wiring this up requires the move pipeline to fire `on-captured`
* with a real registered (non-`__trigger__`) descriptor id AND
* the broadcast layer to reverse the capture's `game.delta` when
* `cancel-capture` runs. Both gaps are too invasive to patch
* with a test-only shim. See "Outstanding gaps" below.
*
* ─────────────────────────────────────────────────────────────────────
* Activation path (T79 test-only debug handler)
* ─────────────────────────────────────────────────────────────────────
*
* The server has no production `activate-descriptor` action (gap E in
* the original docstring); to drive Tests 1 & 2 we use a test-only
* WebSocket message `__test__.activate-descriptor`. The handler lives
* in `packages/server/src/broadcast.ts` (gated to NODE_ENV !==
* "production") and:
*
* 1. Parses the descriptor (mr_freeze.json / mind_control.json).
* 2. LIFTS the inner `on-rule-activated` arm so the
* `request-choice` is the registered descriptor's top primitive.
* That way `submitChoiceAndResume` can resolve the descriptor
* by id and walk to `arm[0].params.then` — bypassing the
* synthetic `__trigger__` descriptorId path used by the trigger
* dispatcher.
* 3. Sets `LastModifierChooser` to the requested chooser color.
* 4. Pushes a PendingChoice frame and runs
* `broadcastTopChoiceIfNew`. Both ends mirror the choice-timeout
* unit tests (`pushPendingChoice` + `broadcastTopChoiceIfNew`).
*
* Click → submit-choice → submitChoiceAndResume → spawn markers /
* convert pieces → broadcastGameStateSnapshot is the existing
* production round-trip.
*
* ─────────────────────────────────────────────────────────────────────
* Outstanding gaps (deferred work)
* ─────────────────────────────────────────────────────────────────────
*
* F. No real server-side `activate-descriptor` action.
* The T79 debug handler is test-only. A future production
* affordance would add a PlayerActionWire kind for
* activate-descriptor + server handler that calls the engine's
* `applyCustomDescriptor` against GAME_ENTITY (or a chooser-
* owned piece). The descriptor's apply walker also needs to
* skip eager request-choice apply (deferred until the real
* trigger fires) — see mr_freeze.test.ts § "Why we don't use
* `applyCustomDescriptor`".
*
* G. Trigger-fired choices carry `descriptorId = "__trigger__"`.
* The trigger dispatcher (`runPrimitives` in triggers.ts)
* injects a synthetic placeholder; `submitChoiceAndResume`
* can't resolve it on the engine's customModifiers registry.
* Test 3 (parry / on-captured) needs the dispatcher to thread
* the real owning descriptor id into the ctx. The plan
* (mr_freeze.test.ts § "Future cleanup") flags this.
*
* H. Cancel-capture broadcast reversal.
* When `cancel-capture` fires inside an `on-captured` arm, the
* attacker's move was already broadcast as `game.delta`. The
* broadcast layer needs to either suppress that delta until
* the cascade completes OR emit a compensating revert delta.
* Today the engine restores facts via LastCaptureSnapshot but
* the wire-level rollback isn't wired.
*
* I. for-row / for-each-piece dispatcher double-recurse.
* After the iteration primitive's apply() runs the inner
* cascade with extended bindings, the dispatcher's child-walk
* runs the children AGAIN with outer bindings → BindingError
* on `$row` / `$piece` references. Test 1 happens to spawn
* all 8 markers BEFORE the throw (correct outcome), and the
* server's submit-choice handler swallows the post-spawn
* BindingError with a logger.warn — so the test passes. A
* real fix would skip childPrimitives() when the primitive
* already iterated internally.
*
* ─────────────────────────────────────────────────────────────────────
* Assertion ladders preserved
* ─────────────────────────────────────────────────────────────────────
*
* The contract that the original spec pinned (column 4 → 8 frozen
* markers; both contexts converted; defender wins → restore on both
* boards) is preserved verbatim. The Test 3 `.todo()` keeps the
* scenario authored so the Playwright report flags it as
* outstanding work.
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors `multiplayer.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;
}
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const EVIDENCE_DIR = join(process.cwd(), '.sisyphus/evidence/task-68-screenshots');
if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true });
const MR_FREEZE_DESCRIPTOR = JSON.parse(
readFileSync(
join(process.cwd(), 'packages/chess/src/__fixtures__/parity/mr_freeze.json'),
'utf8',
),
) as Record<string, unknown>;
const MIND_CONTROL_DESCRIPTOR = JSON.parse(
readFileSync(
join(process.cwd(), 'packages/chess/src/__fixtures__/parity/mind_control.json'),
'utf8',
),
) as Record<string, unknown>;
const PARRY_DESCRIPTOR = JSON.parse(
readFileSync(
join(process.cwd(), 'packages/chess/src/__fixtures__/parity/parry.json'),
'utf8',
),
) as Record<string, unknown>;
async function snapshot(page: Page, label: string): Promise<void> {
await page.screenshot({
path: join(EVIDENCE_DIR, `${label}.png`),
fullPage: true,
});
}
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 wsJoinRoom(
page: Page,
code: string,
): Promise<{ code: string; token: string; color: string }> {
return page.evaluate(async (roomCode: string) => {
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('wsJoinRoom: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.join',
payload: { code: roomCode },
}),
);
};
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.joined') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
} else if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
reject(new Error(msg.payload.message ?? 'room.join error'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsJoinRoom: WebSocket error'));
};
},
);
}, code);
}
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();
return room;
}
async function joinAsGuest(
page: Page,
code: string,
): Promise<{ code: string; token: string; color: string }> {
await page.goto('http://localhost:5173/');
await page.waitForSelector('[data-testid="page-home"]');
const room = await wsJoinRoom(page, code);
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();
return room;
}
/**
* T79 — drive the test-only `__test__.activate-descriptor` debug
* frame from the browser. Opens a fresh raw WebSocket (with the
* room's token in `ws.data` via `room.join`) so the server's room
* lookup resolves; sends the debug frame; closes. The MultiplayerGame
* client running in the same page is OBSERVING the same room and
* receives the resulting `request-choice` broadcast on its own
* socket.
*/
async function activateDescriptor(
page: Page,
args: {
code: string;
token: string;
descriptor: unknown;
chooserColor: 'white' | 'black';
liftedId?: string;
},
): Promise<void> {
// T79: route the test-debug frame through the GameClient that the
// page's MultiplayerGameView already opened. The `__paratypeChessClient`
// window hook is set in dev mode by `useMultiplayerGame` (gated on
// `import.meta.env.DEV`), so this only works against the dev server.
// The server's `__test__.*` fast-path strips the v1 envelope and
// routes by `type`, so the wrapping in `client.send` is invisible
// to the dispatcher.
//
// Going through the EXISTING client socket (instead of opening a
// fresh one) means the broadcast can reach the same socket that's
// observing for `request-choice` events — no cross-socket
// bookkeeping needed.
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);
}
/**
* T83 — drive the test-only `__test__.seed-on-captured-hook` debug
* frame. Seeds the parry descriptor's inner arm directly onto the
* piece at `square` (resolved by 0..63 LERF index server-side).
*
* The handler:
* 1. Parses the descriptor; rejects if its primitives[0] is not
* `on-captured`.
* 2. Registers a LIFTED descriptor whose primitives ARE the inner
* arm so `submitChoiceAndResume` can walk to the request-choice
* via `triggerPath: []` + `primitiveIndex: 0`.
* 3. Inserts an `OnCapturedHooks` entry on the target piece with
* the lifted descriptor's id (so the dispatcher threads it into
* the PendingChoice frame at fire time — Wave 14 / Gap G
* threading).
* 4. Sets `ChoiceTimeoutPolicy: { mode: "no-timeout" }` so transient
* WS disconnects mid-test don't auto-forfeit the room.
*/
async function seedOnCapturedHook(
page: Page,
args: {
code: string;
descriptor: unknown;
/** 0..63 LERF index. d5 = 35, f7 = 53, e4 = 28. */
square: number;
},
): 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('seedOnCapturedHook: __paratypeChessClient not present');
client.send({
type: '__test__.seed-on-captured-hook',
payload: {
roomCode: a.code,
descriptor: a.descriptor,
square: a.square,
},
});
}, args);
}
/** Drag a piece via the same UI path the multiplayer e2e uses. */
const drag = async (page: Page, from: string, to: string): Promise<void> => {
await page
.locator(`[data-square="${from}"] [data-piece]`)
.dragTo(page.locator(`[data-square="${to}"]`));
};
// ---------------------------------------------------------------------------
// Test 1 — Single-player choice (mr_freeze)
// ---------------------------------------------------------------------------
test('T68/1 single-player choice: mr_freeze descriptor → column modal → frozen markers', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await snapshot(page, 'test1-pre-activation');
// Activate mr_freeze via the T79 debug WS frame. Chooser = white
// (matching the unit-test seeding in mr_freeze.test.ts).
await activateDescriptor(page, {
code: room.code,
token: room.token,
descriptor: MR_FREEZE_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'parity:mr_freeze__test1',
});
// Modal appears with kind=column.
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal).toHaveAttribute('data-choice-kind', 'column');
await snapshot(page, 'test1-modal-open');
// Click column 4 (e-file).
await modal.locator('[data-column="4"]').click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// 8 frozen markers on e-file.
for (const square of ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8']) {
await expect(
page.locator(`[data-square="${square}"] [data-marker-kind="frozen-square"]`),
).toBeVisible({ timeout: 5000 });
}
await snapshot(page, 'test1-post-resolve');
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 2 — Both-player choice (mind_control)
// ---------------------------------------------------------------------------
test('T68/2 both-player choice: mind_control → 2 contexts → both modals → conversions', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
const roomA = await joinAsHost(pageA);
expect(roomA.color).toBe('white');
const roomB = await joinAsGuest(pageB, roomA.code);
expect(roomB.color).toBe('black');
// Wait for both clients to settle (game.state arrived on both).
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
// Push TWO request-choice frames so each player sees one. The
// mind_control unit test (mind_control.test.ts) documents that V1
// "forPlayer: both" pushes a single frame; for the e2e contract
// (each browser sees its own modal) we activate twice — once per
// chooser — using distinct lifted ids so engine.customModifiers
// holds two separate descriptor records and submitChoiceAndResume
// can resolve each independently.
//
// Push order matters: white first, then black. After both pushes
// black is the LIFO top, so its broadcast goes only to black. The
// earlier white broadcast already routed to white. Each client
// ends up with ONE entry in its pendingChoiceStack — its own.
// Build a per-chooser variant of the mind_control descriptor:
// - `forPlayer` on the request-choice routes the broadcast to
// just the chooser's color.
// - The set-piece-attr's `value` is BAKED to the chooser's
// literal color (instead of the descriptor's runtime
// `ctx-attr: { entity: "chooser" }` lookup). Two activate
// calls in sequence overwrite `LastModifierChooser` to the
// LATER chooser's color, so a runtime ctx-attr lookup at
// resume time would resolve to the wrong color for the first
// submit. Baking the color into the descriptor sidesteps that
// ordering hazard for the e2e contract.
const buildScopedMindControl = (forPlayer: 'white' | 'black') => {
const root = (MIND_CONTROL_DESCRIPTOR['primitives'] as Array<{
kind: string;
params: { primitives: Array<{ kind: string; params: Record<string, unknown> }> };
}>)[0]!;
const inner = root.params.primitives[0]!;
const innerParams = inner.params as {
kind: string;
prompt: string;
forPlayer: string;
bind: string;
then: Array<{ kind: string; params: Record<string, unknown> }>;
};
const setPieceAttr = innerParams.then[0]!;
return {
...MIND_CONTROL_DESCRIPTOR,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
...innerParams,
forPlayer,
then: [
{
...setPieceAttr,
params: {
...setPieceAttr.params,
value: forPlayer,
},
},
],
},
},
],
},
},
],
} as unknown as Record<string, unknown>;
};
await activateDescriptor(pageA, {
code: roomA.code,
token: roomA.token,
descriptor: buildScopedMindControl('white'),
chooserColor: 'white',
liftedId: 'parity:mind_control__test2-white',
});
// The second push goes through pageB's GameClient so the
// server-side dispatcher sees both frames as discrete operations
// — symmetrical with how a real `forPlayer="both"` arm would
// surface to two clients.
await activateDescriptor(pageB, {
code: roomA.code,
token: roomB.token,
descriptor: buildScopedMindControl('black'),
chooserColor: 'black',
liftedId: 'parity:mind_control__test2-black',
});
// Both modals visible (kind=piece).
const modalA = pageA.locator('[data-testid="request-choice-modal"]');
const modalB = pageB.locator('[data-testid="request-choice-modal"]');
await expect(modalA).toBeVisible({ timeout: 5000 });
await expect(modalB).toBeVisible({ timeout: 5000 });
await expect(modalA).toHaveAttribute('data-choice-kind', 'piece');
await expect(modalB).toHaveAttribute('data-choice-kind', 'piece');
await snapshot(pageA, 'test2-modal-A');
await snapshot(pageB, 'test2-modal-B');
// Each player picks an enemy non-king piece by id. We pull the
// piece id off the rendered board: white targets a black pawn on
// e7 → its piece id is the EntityId stamped onto the
// `[data-piece-id]` attribute on the Piece component. Because the
// initial layout is deterministic (chess starting position is
// seeded by ChessEngine), the ids are stable across runs.
const e7PieceId = await pageA
.locator('[data-square="e7"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
const e2PieceId = await pageB
.locator('[data-square="e2"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(e7PieceId).not.toBeNull();
expect(e2PieceId).not.toBeNull();
// Fill the piece-id input + submit. modalB is on top of the stack
// server-side, so it must resolve first. After B submits, the
// engine resumes set-piece-attr on the e2 white pawn → Color flips
// to black. Then A's frame becomes the top; A submits, e7 pawn
// flips to white.
await modalB.locator('input[type="number"]').fill(String(e2PieceId));
await modalB.locator('button:has-text("Submit Piece ID")').click();
await expect(modalB).not.toBeVisible({ timeout: 5000 });
await modalA.locator('input[type="number"]').fill(String(e7PieceId));
await modalA.locator('button:has-text("Submit Piece ID")').click();
await expect(modalA).not.toBeVisible({ timeout: 5000 });
// Conversions visible on both sides. e7 pawn was black → now white;
// e2 pawn was white → now black. The Piece component's data-piece
// attribute follows the Color fact so it flips to the new color
// identifier.
await expect(
pageA.locator('[data-square="e7"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageA.locator('[data-square="e2"] [data-piece="black-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageB.locator('[data-square="e7"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageB.locator('[data-square="e2"] [data-piece="black-pawn"]'),
).toBeVisible({ timeout: 5000 });
await snapshot(pageA, 'test2-after-resolve-A');
await snapshot(pageB, 'test2-after-resolve-B');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 3 — Nested choice (parry rule, RPS over capture) — TODO
// ---------------------------------------------------------------------------
test('T68/3 nested choice: parry → capture triggers RPS → defender wins → cancel-capture', async ({
browser,
}) => {
// Wave 14 closed Gap G (descriptor-id threading) + Gap H
// (broadcast revert / suppression while suspended). T83 (Wave 15)
// closed the residual production gap that blocked this e2e:
//
// - `submitChoiceAndResume` now synthesizes a `capture` event
// from `LastCaptureSnapshot` so `cancel-capture` (which
// gates on `ctx.event.kind === "capture"`) doesn't throw at
// resume time.
// - `handleSubmitChoice` mirrors apply.ts stage 4b's cleanup
// post-resume — when `cancel-capture` set
// `CaptureCancelled = true`, the WS layer rolls back the
// attacker, retracts the flag + snapshot, then broadcasts
// a fresh `game.state` snapshot. Both clients see the
// restored board (defender at original square, attacker
// back at origin) without any intermediate post-capture
// delta sneaking through.
//
// Drive path:
// 1. Two contexts (white=A, black=B). Open the multiplayer
// view on each; wait for game.state to settle.
// 2. Move white queen to h5 (Qh5) and black knight to c6 to
// reach a position where Qxf7 is legal AND the f7 piece is
// a black pawn. The standard FIDE Scholar's-Mate prelude
// delivers exactly that.
// 3. Seed the parry on-captured hook on the f7 black pawn via
// `__test__.seed-on-captured-hook` (T83). This bypasses
// `applyCustomDescriptor` (which would eagerly fire the
// inner request-choice at apply time, before the capture
// event arrives) and matches the parry-real test's seeding
// strategy.
// 4. White plays Qxf7. The capture pipeline fires
// `fireOnCapturedHooks` on the f7 pawn → request-choice
// suspends → both clients see the rps modal.
// 5. Each player submits a value (we use "rock" for both;
// cancel-capture fires unconditionally inside
// `conditional({type:"always"})`, mirroring the descriptor's
// real semantics under the locked rps-eval simplification —
// see `parity/parry.test.ts` § "Plan-spec deviation").
// 6. Post-resume `game.state` lands. Asserts:
// - Black pawn back at f7 (defender restored).
// - White queen NOT on f7 (attacker rolled back to h5).
// - Both clients agree (state snapshot is authoritative).
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
const roomA = await joinAsHost(pageA);
expect(roomA.color).toBe('white');
const roomB = await joinAsGuest(pageB, roomA.code);
expect(roomB.color).toBe('black');
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
// Drive a short prelude to set up a simple pawn capture e4xd5.
// Using a quiet capture (NOT mate) so the parry cascade has
// somewhere to land: the on-captured hook fires on the dying
// d5 pawn → request-choice suspends → both clients see the
// modal. A capture that ENDS the game (Scholar's Mate Qxf7#)
// would race fireOnCapturedHooks against game.end and the
// suspended choice's broadcast would be drowned by the
// game-over signal.
await drag(pageA, 'e2', 'e4');
await expect(
pageB.locator('[data-square="e4"] [data-piece="white-pawn"]'),
).toBeVisible();
await drag(pageB, 'd7', 'd5');
await expect(
pageA.locator('[data-square="d5"] [data-piece="black-pawn"]'),
).toBeVisible();
// Seed the parry hook on d5 (LERF index: rank 4 * 8 + file 3 =
// 35). The descriptor's `on-captured` wrapper is unwrapped
// server-side; the inner arm (request-choice → conditional →
// cancel-capture) is what actually seeds onto d5.
await seedOnCapturedHook(pageA, {
code: roomA.code,
descriptor: PARRY_DESCRIPTOR,
square: 35, // d5
});
// Allow the seed's broadcast game.state to round-trip so the
// hook is committed before the next inbound `game.move`. The
// server-side handler emits a snapshot post-seed so the wait
// is bounded by the natural WS RTT.
await pageA.waitForTimeout(300);
await snapshot(pageA, 'test3-pre-capture-A');
await snapshot(pageB, 'test3-pre-capture-B');
// Diagnostic: verify the hook landed on the d5 pawn. Reads the
// engine's session via the dev-only PredictionManager export.
// Pre-capture, OnCapturedHooks should be a non-empty array on
// the d5 piece's entity id.
const d5PieceId = await pageA
.locator('[data-square="d5"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(d5PieceId).not.toBeNull();
const hooks = await pageA.evaluate(
(id) => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: { get: (id: unknown, attr: string) => unknown };
};
};
}
).__paratypeChessPrediction;
if (!mgr) return null;
return mgr.getCurrentEngine().session.get(id, 'OnCapturedHooks') ?? null;
},
Number(d5PieceId),
);
// The hook list MUST be present and non-empty — confirms the
// seed-on-captured-hook handler attached to the right entity.
expect(Array.isArray(hooks)).toBe(true);
expect((hooks as unknown[]).length).toBeGreaterThan(0);
// White plays e4xd5. Both clients should see the rps modal
// (forPlayer="both" routes to both), NOT a post-capture board
// delta (T81 broadcast suppression).
await drag(pageA, 'e4', 'd5');
// Brief settle for the server's request-choice broadcast.
await pageA.waitForTimeout(500);
const modalA = pageA.locator('[data-testid="request-choice-modal"]');
const modalB = pageB.locator('[data-testid="request-choice-modal"]');
await expect(modalA).toBeVisible({ timeout: 5000 });
await expect(modalB).toBeVisible({ timeout: 5000 });
await expect(modalA).toHaveAttribute('data-choice-kind', 'rps');
await snapshot(pageA, 'test3-modal-A');
await snapshot(pageB, 'test3-modal-B');
// Both clients see the post-capture board SUPPRESSED — f7 still
// shows the black pawn (it was transiently re-inserted by stage
// 4 for hook reading; T81 doesn't broadcast the post-capture
// delta while a choice is suspended). The white queen still
// appears at h5 from the client's perspective (no game.delta
// moving it to f7 was broadcast). Pre-T83/T81 these would have
// already flipped to the post-capture state.
//
// Note: the queen at h5 + black pawn at f7 invariant relies on
// the broadcast suppression — verifying it at THIS point of the
// test is what keeps the contract honest. After the player
// submits, the post-resume snapshot is the load-bearing pin
// (see lines below).
// Submit the rps value via the modal's UI. The descriptor's
// `forPlayer: "both"` lets either player resolve the top frame;
// V1 pushes a SINGLE PendingChoice for "both", so only one
// submission is needed. Per LIFO discipline the first submit
// drains the stack and the resume runs cancel-capture
// unconditionally (the descriptor wraps cancel-capture in
// `conditional({type:"always"})` — see parity/parry.test.ts).
// Click "rock" on whichever modal we see first. The rps button
// is data-rps="rock"; Modal renders three buttons (rock /
// paper / scissors).
await modalA.locator('[data-rps="rock"]').click();
await expect(modalA).not.toBeVisible({ timeout: 5000 });
// V1 doesn't yet broadcast a "choice resolved" frame — the
// server pops the choice + broadcasts post-resume game.state,
// but useMultiplayerGame's local pendingChoiceStack stays
// populated on the non-submitter's client. The board state is
// authoritative and reflects the resolution; the stale modal
// is a documented V1 UX gap (deferred to a future "choice
// dismiss" protocol message). The board-state assertions
// below are the load-bearing pins for T68/3.
// Post-resume assertions — the load-bearing pins for T68/3.
// Both clients agree on the restored board: black pawn back at
// d5, white pawn NOT on d5. The white pawn rolled back to e4
// (its origin square per the LastCaptureSnapshot's
// attackerFromSquare).
await expect(
pageA.locator('[data-square="d5"] [data-piece="black-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageB.locator('[data-square="d5"] [data-piece="black-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageA.locator('[data-square="d5"] [data-piece="white-pawn"]'),
).toHaveCount(0);
await expect(
pageB.locator('[data-square="d5"] [data-piece="white-pawn"]'),
).toHaveCount(0);
// White pawn rolled back to e4.
await expect(
pageA.locator('[data-square="e4"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
pageB.locator('[data-square="e4"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await snapshot(pageA, 'test3-post-resolve-A');
await snapshot(pageB, 'test3-post-resolve-B');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Sentinel: server lifecycle + raw connectivity. Not gap-related.
// ---------------------------------------------------------------------------
test('T68 sentinel: server is reachable and home page renders', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('http://localhost:5173/');
await expect(page.locator('[data-testid="page-home"]')).toBeVisible();
const room = await wsCreateRoom(page);
expect(room.code).toHaveLength(6);
await snapshot(page, 'sentinel-page-home');
await ctx.close();
});