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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 18:06:05 -06:00
commit 4c25277449
No known key found for this signature in database
14 changed files with 2822 additions and 79 deletions

View file

@ -164,6 +164,13 @@ const MIND_CONTROL_DESCRIPTOR = JSON.parse(
),
) 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`),
@ -360,6 +367,70 @@ async function activateDescriptor(
}, 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)
// ---------------------------------------------------------------------------
@ -578,46 +649,211 @@ test('T68/2 both-player choice: mind_control → 2 contexts → both modals →
// Test 3 — Nested choice (parry rule, RPS over capture) — TODO
// ---------------------------------------------------------------------------
test.fixme(
'T68/3 nested choice: parry → capture triggers RPS → defender wins → cancel-capture',
async () => {
// Outstanding gaps preventing this test from passing today:
//
// G. Trigger-fired choices carry descriptorId="__trigger__".
// The parry preset's `on-captured` hook fires through the
// trigger dispatcher (`runPrimitives` in triggers.ts), which
// injects a synthetic `__trigger__` placeholder into the
// ctx — `submitChoiceAndResume` cannot resolve it on the
// engine's `customModifiers` registry, so the RPS resolution
// throws `runtime.descriptor-not-found` and the cancel-capture
// continuation never runs. The dispatcher needs to thread
// the real owning descriptor id into the ctx (see
// mr_freeze.test.ts § "Future cleanup" for the contract).
//
// H. Cancel-capture broadcast reversal.
// When `cancel-capture` runs inside an `on-captured` arm, the
// attacker's move was already broadcast as a `game.delta`.
// The wire layer needs to either suppress that delta until
// the cascade settles OR emit a compensating revert. Today
// the engine restores facts via LastCaptureSnapshot but the
// revert delta is not generated — so even with G fixed, both
// clients would render the post-capture board (defender
// gone, attacker on destination) instead of the cancelled
// state.
//
// The original assertion ladder (preserved as a contract):
//
// 1. Two contexts (white=A, black=B). Activate parry preset.
// 2. White plays Qxf7 — capture triggers on-captured hook.
// 3. Both contexts mount the RPS modal.
// 4. White picks rock, black picks paper → defender wins.
// 5. cancel-capture restores f7 black-pawn AND retracts the
// white queen back to h5.
// 6. Turn does NOT flip.
//
// When G + H land, `.fixme` lifts and the body below activates.
},
);
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.