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.
873 lines
35 KiB
TypeScript
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();
|
|
});
|