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.
633 lines
25 KiB
TypeScript
633 lines
25 KiB
TypeScript
/**
|
|
* T84 — Playwright e2e for `religious_conversion` + `kamikaze` parity rules.
|
|
*
|
|
* Both descriptors use trigger-rooted cascades that the existing
|
|
* `__test__.activate-descriptor` lifter (T79) cannot drive — that lifter
|
|
* requires `on-rule-activated` as the descriptor root so the inner arm
|
|
* surfaces as a registerable top-level primitive list. religious_conversion
|
|
* uses `on-move`, kamikaze uses `on-capture` — both seed per-piece hook
|
|
* facts (OnMoveHooks / OnCaptureHooks) at apply-time and only fire when
|
|
* the engine's onAfterMove dispatcher reaches `fireOn*Hooks` for the
|
|
* moving / capturing piece.
|
|
*
|
|
* Driving the cascade end-to-end therefore requires a SECOND test-debug
|
|
* frame that:
|
|
* 1. Wipes the FIDE starting position.
|
|
* 2. Places a deterministic minimal set of pieces.
|
|
* 3. Seeds the descriptor's INNER arm directly onto the relevant
|
|
* piece's hook fact (mirrors what `applyCustomDescriptor` would
|
|
* produce on a fixed dispatcher — see religious_conversion-real.test.ts
|
|
* and kamikaze-real.test.ts § "seeding On{Move,Capture}Hooks").
|
|
* 4. Optionally pins `RngSeed` for descriptors with `with-probability`
|
|
* (kamikaze).
|
|
* 5. Broadcasts a fresh `game.state` so the client renders the
|
|
* synthetic board.
|
|
*
|
|
* That frame is `__test__.setup-board` (T84, packages/server/src/broadcast.ts).
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Test 1 — religious_conversion (on-move + for-each-adjacent + set-piece-attr)
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
*
|
|
* Setup (after clear-board, kings preserved at e1/e8):
|
|
* - white bishop at d4 (will move to b6, the on-move hook target)
|
|
* - white pawn at a6 (adjacent to b6 destination — already white,
|
|
* Color set is a no-op, pin: ally unchanged)
|
|
* - black pawns at a7, b7, c7 (adjacent to b6 destination — should
|
|
* flip to white after the move)
|
|
*
|
|
* The bishop's path d4→c5→b6 is along an empty diagonal (c5 is the
|
|
* only square traversed; cleared). After applyMove(d4→b6) fires
|
|
* fireOnMoveHooks(bishop), the inner arm (for-each-adjacent target=self,
|
|
* filter={occupied:true, excludeKing:true}, then=set-piece-attr Color)
|
|
* walks the 8 squares around b6 and flips the Color of every adjacent
|
|
* non-king piece to the bishop's Color (white).
|
|
*
|
|
* Pin:
|
|
* - a7/b7/c7 render `data-piece="white-pawn"` (was black).
|
|
* - a6 stays `data-piece="white-pawn"` (ally — Color set was a no-op).
|
|
* - bishop at b6.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Test 2 — kamikaze (on-capture + with-probability + for-each-adjacent + destroy-piece)
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
*
|
|
* The on-disk fixture has `p: 0.25`. Driving an e2e against that
|
|
* stochastic branch is brittle — the first applyMove may or may not
|
|
* consume earlier RNG draws (the integration preset's onAfterMove
|
|
* runs preset hooks BEFORE fireOnCaptureHooks, and any preset that
|
|
* calls `engine.rng().next()` shifts the with-probability draw's
|
|
* stream offset). Instead we use the task-suggested workaround:
|
|
* a per-test descriptor variant with `p: 1.0` so AOE fires
|
|
* deterministically on every capture. The kamikaze-real.test.ts
|
|
* locked-numerics test (seed=42 → 32 hits at exact stream offsets) is
|
|
* what pins the stochastic contract; this e2e pins the WIRE / UI /
|
|
* dispatcher path — same cascade, deterministic outcome.
|
|
*
|
|
* Setup (after clear-board with kings retracted, then re-placed by us):
|
|
* - white queen at e2 (will capture the e4 pawn)
|
|
* - black pawn at e4 (the capture target)
|
|
* - black pawns at d4, f4 (adjacent to e4 destination — AOE victims)
|
|
* - black king at e5 (adjacent to e4 destination — IMMUNE)
|
|
* - white king at e1, black king at e8 (re-placed for legality of the
|
|
* game state — checking against d4/f4 etc
|
|
* doesn't matter for hook-firing)
|
|
*
|
|
* Queen path e2→e3→e4 (e3 is empty post-clear). The capture fires
|
|
* fireOnCaptureHooks(queen) — the inner arm:
|
|
* with-probability(p=1.0) → for-each-adjacent(filter excludeKing) → destroy-piece
|
|
* always enters the for-each branch, walks {d3,e3,f3,d4,f4,d5,e5,f5},
|
|
* narrows by occupied + excludeKing → matches d4 (black pawn), f4
|
|
* (black pawn). e5 is a king → IMMUNE. destroy-piece retracts every
|
|
* piece-identity fact for d4 + f4.
|
|
*
|
|
* Pins:
|
|
* - queen at e4 (capture succeeded; e4 black pawn gone).
|
|
* - d4 + f4 squares render no piece (AOE destroyed them).
|
|
* - e5 black king STILL renders `data-piece="black-king"`
|
|
* (king-immunity invariant from kamikaze.test.ts § "king never
|
|
* destroyed").
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Why we don't extend `parity-rules.spec.ts`
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
*
|
|
* Per the T84 brief: T83 owns parity-rules (parry, all_on_red,
|
|
* ice_physics). Keeping religious + kamikaze in a sibling spec file
|
|
* avoids merge conflicts on the shared describe-block scaffolding and
|
|
* lets each spec own its own server fixture lifecycle.
|
|
*/
|
|
|
|
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` / `request-choice.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;
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures + screenshot helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EVIDENCE_DIR = join(process.cwd(), '.sisyphus/evidence/wave15-t84-screenshots');
|
|
if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
|
|
const RELIGIOUS_CONVERSION_DESCRIPTOR = JSON.parse(
|
|
readFileSync(
|
|
join(process.cwd(), 'packages/chess/src/__fixtures__/parity/religious_conversion.json'),
|
|
'utf8',
|
|
),
|
|
) as Record<string, unknown>;
|
|
|
|
const KAMIKAZE_DESCRIPTOR = JSON.parse(
|
|
readFileSync(
|
|
join(process.cwd(), 'packages/chess/src/__fixtures__/parity/kamikaze.json'),
|
|
'utf8',
|
|
),
|
|
) as Record<string, unknown>;
|
|
|
|
/**
|
|
* Build a per-test variant of the kamikaze descriptor where
|
|
* with-probability's `p` is forced to 1.0. This is the
|
|
* "always-fires" variant the T84 brief flags as the simpler path
|
|
* compared to brittle seed-fishing for a "first draw < 0.25" RNG
|
|
* vector. The original 0.25 contract is pinned by
|
|
* kamikaze.test.ts (locked-stream-offsets) — we don't need to
|
|
* reproduce its statistical outcome here.
|
|
*/
|
|
function kamikazeAlwaysFires(): Record<string, unknown> {
|
|
const cloned = JSON.parse(JSON.stringify(KAMIKAZE_DESCRIPTOR)) as {
|
|
id: string;
|
|
primitives: Array<{
|
|
kind: string;
|
|
params: { primitives: Array<{ kind: string; params: { p: number } }> };
|
|
}>;
|
|
};
|
|
// Walk on-capture → with-probability and patch p in place.
|
|
const onCapture = cloned.primitives[0]!;
|
|
const withProb = onCapture.params.primitives[0]!;
|
|
if (withProb.kind !== 'with-probability') {
|
|
throw new Error(
|
|
`parity fixture drift: expected on-capture > with-probability, got ${withProb.kind}`,
|
|
);
|
|
}
|
|
withProb.params.p = 1.0;
|
|
// Use a distinct id so registry entries don't collide with the
|
|
// canonical id. Brand-coercion is the same `asCustomModifierId`
|
|
// path the server uses internally.
|
|
cloned.id = 'parity:kamikaze__test-p1';
|
|
return cloned as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async function snapshot(page: Page, label: string): Promise<void> {
|
|
await page.screenshot({
|
|
path: join(EVIDENCE_DIR, `${label}.png`),
|
|
fullPage: true,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Room helpers (raw WS — no Lobby UI involvement)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function wsCreateRoom(
|
|
page: Page,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
return page.evaluate(async () => {
|
|
return new Promise<{ code: string; token: string; color: string }>(
|
|
(resolve, reject) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
const timer = setTimeout(
|
|
() => reject(new Error('wsCreateRoom: timeout')),
|
|
5000,
|
|
);
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.create',
|
|
payload: {},
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload: {
|
|
code: string;
|
|
token: string;
|
|
color: string;
|
|
message?: string;
|
|
};
|
|
};
|
|
if (msg.type === 'room.created') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
resolve(msg.payload);
|
|
} else if (msg.type === 'error') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
reject(new Error(msg.payload.message ?? 'room.create error'));
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
clearTimeout(timer);
|
|
reject(new Error('wsCreateRoom: WebSocket error'));
|
|
};
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function joinAsHost(
|
|
page: Page,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
await page.goto('http://localhost:5173/');
|
|
await page.waitForSelector('[data-testid="page-home"]');
|
|
const room = await wsCreateRoom(page);
|
|
await page.evaluate((r) => {
|
|
sessionStorage.setItem('room-code', r.code);
|
|
sessionStorage.setItem('room-token', r.token);
|
|
sessionStorage.setItem('player-color', r.color);
|
|
}, room);
|
|
await page.goto('http://localhost:5173/game');
|
|
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
|
|
// Wait for the GameClient to install on window — the setup-board frame
|
|
// travels through it.
|
|
await page.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
(globalThis as { __paratypeChessClient?: unknown })
|
|
.__paratypeChessClient,
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
);
|
|
return room;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `__test__.setup-board` driver
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BoardPlacement {
|
|
square: string | number;
|
|
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
|
|
color: 'white' | 'black';
|
|
hasMoved?: boolean;
|
|
handle?: string;
|
|
}
|
|
|
|
interface BoardHookSpec {
|
|
pieceHandle?: string;
|
|
pieceSquare?: string | number;
|
|
hookAttr:
|
|
| 'OnMoveHooks'
|
|
| 'OnCaptureHooks'
|
|
| 'OnCapturedHooks'
|
|
| 'OnDamagedHooks'
|
|
| 'OnPromotionHooks'
|
|
| 'OnTurnStartHooks'
|
|
| 'OnTurnEndHooks'
|
|
| 'OnCheckReceivedHooks'
|
|
| 'OnCheckDeliveredHooks'
|
|
| 'OnMovedOntoSquareHooks';
|
|
descriptor: unknown;
|
|
descriptorIdOverride?: string;
|
|
}
|
|
|
|
interface BoardSetupArgs {
|
|
code: string;
|
|
clear?: boolean;
|
|
clearIncludingKings?: boolean;
|
|
placements?: BoardPlacement[];
|
|
rngSeed?: number;
|
|
hooks?: BoardHookSpec[];
|
|
turn?: 'white' | 'black';
|
|
}
|
|
|
|
/**
|
|
* Send `__test__.setup-board` through the page's existing GameClient
|
|
* socket. The handler runs server-side, mutates the engine, and emits
|
|
* a fresh `game.state` snapshot — so the page's own MultiplayerGame
|
|
* view re-renders the synthetic board automatically. The handle→id
|
|
* echo (`__test__.board-ready`) is intentionally a no-op at the
|
|
* client layer (the GameClient's `dispatchServerMessage` ignores
|
|
* unknown types for forward-compat) — DOM-level assertions are what
|
|
* the e2e relies on for verification.
|
|
*/
|
|
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
|
|
await page.evaluate(async (a) => {
|
|
type Client = {
|
|
send: (msg: { type: string; payload: unknown }) => void;
|
|
readonly isConnected?: boolean;
|
|
};
|
|
const getClient = (): Client | undefined =>
|
|
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
|
|
const deadline = Date.now() + 3000;
|
|
let client = getClient();
|
|
// Wait for an OPEN GameClient socket. `isConnected` is a getter
|
|
// (NOT a function) — invoke as a property read. The
|
|
// `__paratypeChessClient` reference itself can ALSO churn during
|
|
// React StrictMode unmount→remount, so re-fetch every poll.
|
|
while (Date.now() < deadline) {
|
|
client = getClient();
|
|
if (client && client.isConnected === true) break;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
if (!client || client.isConnected !== true) {
|
|
throw new Error('setupBoard: GameClient never became connected');
|
|
}
|
|
client.send({
|
|
type: '__test__.setup-board',
|
|
payload: {
|
|
roomCode: a.code,
|
|
clear: a.clear,
|
|
clearIncludingKings: a.clearIncludingKings,
|
|
placements: a.placements,
|
|
rngSeed: a.rngSeed,
|
|
hooks: a.hooks,
|
|
turn: a.turn,
|
|
},
|
|
});
|
|
}, args);
|
|
}
|
|
|
|
/**
|
|
* Send `game.move` straight through the page's existing GameClient
|
|
* socket. The drag-based UI path used by `multiplayer.spec.ts` works
|
|
* fine when the engine state is the FIDE starting position (which
|
|
* is what the prediction layer's BaseEngine matches at mount time),
|
|
* but on a synthetic board produced via `__test__.setup-board` the
|
|
* prediction-layer's getAllLegalMoves can disagree with the
|
|
* server-side engine on edge cases (e.g. a bishop spawned without
|
|
* the integration preset's spawn hooks may carry default
|
|
* `OnMoveHooks` that the prediction's WeakMap-keyed onBeforeMove
|
|
* snapshot doesn't recognise as the same identity). Going around
|
|
* the prediction layer eliminates that variability — the test
|
|
* exercises the SERVER's applyMove + fireOnMoveHooks pipeline,
|
|
* which is the actual contract under test.
|
|
*/
|
|
async function sendMove(
|
|
page: Page,
|
|
from: string,
|
|
to: string,
|
|
): Promise<void> {
|
|
// Poll: GameClient.send is a NO-OP when the underlying WebSocket
|
|
// isn't OPEN ("Messages sent while the socket is not OPEN are
|
|
// silently dropped" — client.ts § send). React StrictMode +
|
|
// reconnect on `/game` mount can leave a ~200ms window where
|
|
// `__paratypeChessClient` is fresh but the socket is still
|
|
// connecting. Poll `isConnected` (a GETTER, not a function) until
|
|
// OPEN, then send.
|
|
await page.evaluate(
|
|
async (a) => {
|
|
type Client = {
|
|
send: (msg: { type: string; payload: unknown }) => void;
|
|
sendMove?: (from: string, to: string) => void;
|
|
readonly isConnected?: boolean;
|
|
};
|
|
const getClient = (): Client | undefined =>
|
|
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
|
|
const deadline = Date.now() + 3000;
|
|
let client = getClient();
|
|
while (Date.now() < deadline) {
|
|
client = getClient();
|
|
if (client && client.isConnected === true) break;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
if (!client || client.isConnected !== true) {
|
|
throw new Error('sendMove: GameClient never became connected');
|
|
}
|
|
if (typeof client.sendMove === 'function') {
|
|
client.sendMove(a.from, a.to);
|
|
return;
|
|
}
|
|
client.send({
|
|
type: 'game.move',
|
|
payload: { from: a.from, to: a.to },
|
|
});
|
|
},
|
|
{ from, to },
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1 — religious_conversion (on-move → for-each-adjacent → set-piece-attr)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('T84/religious_conversion: bishop move converts adjacent enemy non-king pieces to its color', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Configure the synthetic board. Kings preserve their FIDE
|
|
// starting positions (e1 / e8); we add our own minimal piece set
|
|
// around the bishop's destination square b6. Server's setup-board
|
|
// handler emits a fresh `game.state` after applying the placement
|
|
// set; the client's existing snapshot subscription re-renders the
|
|
// board, so we don't need to wait for an explicit ack.
|
|
await setupBoard(page, {
|
|
code: room.code,
|
|
clear: true,
|
|
clearIncludingKings: false,
|
|
turn: 'white',
|
|
placements: [
|
|
{ square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' },
|
|
{ square: 'a6', type: 'pawn', color: 'white', handle: 'allyA6' },
|
|
{ square: 'a7', type: 'pawn', color: 'black', handle: 'enemyA7' },
|
|
{ square: 'b7', type: 'pawn', color: 'black', handle: 'enemyB7' },
|
|
{ square: 'c7', type: 'pawn', color: 'black', handle: 'enemyC7' },
|
|
],
|
|
hooks: [
|
|
{
|
|
pieceHandle: 'bishop',
|
|
hookAttr: 'OnMoveHooks',
|
|
descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR,
|
|
},
|
|
],
|
|
});
|
|
|
|
// Wait for the snapshot broadcast to land — the bishop should
|
|
// appear at d4 (its starting FIDE square is c1/f1, so a fresh
|
|
// d4 placement confirms the broadcast hit).
|
|
await expect(
|
|
page.locator('[data-square="d4"] [data-piece="white-bishop"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
await expect(
|
|
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await snapshot(page, 'religious-pre-move');
|
|
|
|
// Drive the move via the GameClient's `game.move` send (skipping
|
|
// the prediction layer — see `sendMove` rationale). The server's
|
|
// applyMove fires fireOnMoveHooks, which walks the seeded arm,
|
|
// and broadcasts the resulting game.delta / game.state.
|
|
await sendMove(page, 'd4', 'b6');
|
|
|
|
// Bishop landed.
|
|
await expect(
|
|
page.locator('[data-square="b6"] [data-piece="white-bishop"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
|
|
// Black pawns adjacent to b6 (a7, b7, c7) are NOW white.
|
|
await expect(
|
|
page.locator('[data-square="a7"] [data-piece="white-pawn"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
await expect(
|
|
page.locator('[data-square="b7"] [data-piece="white-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="c7"] [data-piece="white-pawn"]'),
|
|
).toBeVisible();
|
|
|
|
// The white ally at a6 (also adjacent) is unchanged — still a
|
|
// white pawn. The set-piece-attr Color was a no-op for ally
|
|
// squares because the descriptor's value resolver pulls
|
|
// self.Color (white) and the ally was already white.
|
|
await expect(
|
|
page.locator('[data-square="a6"] [data-piece="white-pawn"]'),
|
|
).toBeVisible();
|
|
|
|
// Negative pin: NO black pawns remain on a7/b7/c7.
|
|
await expect(
|
|
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
|
|
).toHaveCount(0);
|
|
|
|
await snapshot(page, 'religious-post-conversion');
|
|
await ctx.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2 — kamikaze (on-capture → with-probability → for-each-adjacent → destroy-piece)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('T84/kamikaze: capture triggers AOE destroying adjacent non-king pieces; king immune', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Setup: full clear (kings retracted too) so we can re-place
|
|
// both kings explicitly — the test's "AOE-victim" set sits next
|
|
// to a black king on e5, and we need a clean board to assert
|
|
// king-immunity without ambiguity from the FIDE starting kings.
|
|
const descriptor = kamikazeAlwaysFires();
|
|
await setupBoard(page, {
|
|
code: room.code,
|
|
clear: true,
|
|
clearIncludingKings: true,
|
|
turn: 'white',
|
|
rngSeed: 42, // fixed; with p=1.0 the draw is moot but pinning the
|
|
// seed makes the test order-independent w.r.t. unrelated
|
|
// preset RNG draws on the integration onAfterMove path.
|
|
placements: [
|
|
// Re-place both kings — required for engine legality (in-check
|
|
// detection walks both kings; absent kings throw).
|
|
{ square: 'e1', type: 'king', color: 'white', handle: 'wKing' },
|
|
{ square: 'h8', type: 'king', color: 'black', handle: 'bKing' },
|
|
// The capturing piece + its target.
|
|
{ square: 'e2', type: 'queen', color: 'white', handle: 'queen' },
|
|
{ square: 'e4', type: 'pawn', color: 'black', handle: 'target' },
|
|
// AOE victims — adjacent to e4 (the queen's destination).
|
|
{ square: 'd4', type: 'pawn', color: 'black', handle: 'aoeD4' },
|
|
{ square: 'f4', type: 'pawn', color: 'black', handle: 'aoeF4' },
|
|
// King-immunity test: a SECOND black king adjacent to e4. The
|
|
// descriptor's filter excludes kings unconditionally, so this
|
|
// king must survive the AOE.
|
|
{ square: 'e5', type: 'king', color: 'black', handle: 'aoeKing' },
|
|
],
|
|
hooks: [
|
|
{
|
|
pieceHandle: 'queen',
|
|
hookAttr: 'OnCaptureHooks',
|
|
descriptor,
|
|
descriptorIdOverride: 'parity:kamikaze__test-p1',
|
|
},
|
|
],
|
|
});
|
|
|
|
// Pre-capture board pin.
|
|
await expect(
|
|
page.locator('[data-square="e2"] [data-piece="white-queen"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
await expect(
|
|
page.locator('[data-square="e4"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="d4"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="f4"] [data-piece="black-pawn"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-square="e5"] [data-piece="black-king"]'),
|
|
).toBeVisible();
|
|
await snapshot(page, 'kamikaze-pre-capture');
|
|
|
|
// Drive the capture: queen e2 → e4 (e3 is empty). The server's
|
|
// applyMove fires fireOnCaptureHooks(queen) on the post-move
|
|
// pass; the inner arm enters with-probability(p=1.0) — always
|
|
// fires — walks adjacent squares of the queen's NEW position
|
|
// (e4) with excludeKing+occupied filters, and destroy-piece
|
|
// retracts every matched id.
|
|
await sendMove(page, 'e2', 'e4');
|
|
|
|
// Capture succeeded: queen on e4, the target pawn is gone (its
|
|
// Position fact retracted by the standard capture pipeline before
|
|
// the AOE arm runs).
|
|
await expect(
|
|
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
|
|
// AOE victims destroyed — d4 and f4 squares no longer hold a piece
|
|
// (destroy-piece retracts the full piece-identity attribute set).
|
|
await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount(
|
|
0,
|
|
{ timeout: 5000 },
|
|
);
|
|
await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount(0);
|
|
|
|
// King immunity — the black king on e5 is STILL there. This is
|
|
// the load-bearing pin from kamikaze.test.ts § "king never
|
|
// destroyed".
|
|
await expect(
|
|
page.locator('[data-square="e5"] [data-piece="black-king"]'),
|
|
).toBeVisible();
|
|
|
|
await snapshot(page, 'kamikaze-post-aoe');
|
|
await ctx.close();
|
|
});
|