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:
parent
17d8afa1f5
commit
4c25277449
14 changed files with 2822 additions and 79 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue