houserules/packages/chess/e2e/move-gen-attrs.spec.ts
Joey Yakimowich-Payne d5abaf13bc
feat(thressgame-coverage): Wave 19 (close 6 production gaps, lift all fixmes)
T85: Wired Wave 12 move-gen attrs into engine.ts:getAllLegalMoves (the path the drag UI actually uses):
- BlockAllExceptKing (game-level early-return)
- BlockedPieceTypes (game-level early-return)
- MovesAs (per-piece substitution via lookupMoveGenerator)
- MovesAlsoAs (additive; deduped via dedupeMoves helper)
- MoveClassRestriction (post-filter on the entire move set)

Previously these attrs only filtered rules/turn.ts:getLegalMovesForPiece, but the production drag path goes through engine.ts. Now both paths apply identical filters.

T86: engine.ts:applyMove now honors isPawnPush:
- pushedPieceId moved to pushedTo (defender shoved forward)
- pawn moves to diagonal target square (no capture retract)
- HasMoved set on pawn
- Hook firing + turn advancement preserved

5 fixmes lifted in move-gen-attrs.spec.ts (MovesAs, MovesAlsoAs, BlockedPieceTypes, MoveClassRestriction, PawnPushesPiecesEnabled).
1 fixme lifted in orphan-primitives.spec.ts (must-class consumer now active).

E2E status: 30/30 thressgame-coverage tests PASS. 0 fixmes. 0 skips.
Unit tests: 2866 -> 2868 (+2 from new applyMove unit tests). bun run check exit 0.
2026-04-26 21:39:13 -06:00

1015 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Wave 17 — Playwright e2e for the 8 move-gen attrs wired in by Wave 12
* (T74-T77) inside `packages/chess/src/rules/`.
*
* Two of the 8 attrs already have dedicated e2e coverage in
* `parity-rules.spec.ts`:
*
* - `SlideMustBeMaxDistance` — covered by ice_physics e2e.
* - `BlockAllExceptKing` — covered by all_on_red e2e.
*
* This spec covers the remaining 6:
*
* 1. `MovesAs` — knight moves like a bishop (replacement).
* 2. `MovesAlsoAs` — knight moves like knight + bishop (additive).
* 3. `KingExtraReach` — king can step >1 square per move.
* 4. `BlockedPieceTypes` — game-level paralysis of listed types.
* 5. `MoveClassRestriction` — game-level "must capture / advance / move-to".
* 6. `PawnPushesPiecesEnabled` — pawn shoves the diagonal target instead of capturing.
*
* ─────────────────────────────────────────────────────────────────────
* INTEGRATION GAP — Wave 12 wiring is incomplete at the engine layer
* ─────────────────────────────────────────────────────────────────────
*
* Five of the six attrs in this spec are CURRENTLY UNREACHABLE from the
* authoritative move-gen path the runtime drives. Wave 12 (T74-T77)
* landed the readers inside `packages/chess/src/rules/turn.ts` — the
* dispatcher `getLegalMovesForPiece(session, pieceId)` consults
* `MovesAs`, `MovesAlsoAs`, `BlockedPieceTypes`, `MoveClassRestriction`
* and dispatches per-piece via the in-module `moveGeneratorRegistry`.
* The dispatcher is exhaustively unit-tested:
*
* - `rules/turn.movesas.test.ts` — MovesAs / MovesAlsoAs locked.
* - `rules/turn.moveclass.test.ts` — MoveClassRestriction locked.
* - `rules/turn.blockall.test.ts` — BlockAllExceptKing locked.
* - `rules/king.extrareach.test.ts` — KingExtraReach locked.
* - `rules/sliding.slidemax.test.ts` — SlideMustBeMaxDistance locked.
* - `rules/pawn.push.test.ts` — PawnPushesPiecesEnabled locked.
*
* However: the runtime hot-path in `engine.ts:getAllLegalMoves` (the
* function `findMove` calls, which is what `PredictionManager` and the
* server's authoritative move handler both consume) calls each
* piece-type's generator DIRECTLY via
* `lookupMoveGenerator(piece.type)` against the `PIECE_TYPE_REGISTRY`
* — see `engine.ts` line 1434 + `presets/core-piece-types.ts` for the
* registration. It NEVER calls `rules/turn.ts:getLegalMovesForPiece`,
* and `rules/turn.ts:registerMoveGenerator` is never called outside
* unit tests. The dispatcher is therefore unreachable from real
* gameplay despite passing its own tests.
*
* Two attrs DO surface end-to-end because their readers live INSIDE
* the per-piece generator the engine calls:
*
* - `KingExtraReach` — read inside `rules/king.ts`.
* - `PawnPushesPiecesEnabled` — read inside `rules/pawn.ts`
* (move-gen side only — see below).
*
* The other four (`MovesAs`, `MovesAlsoAs`, `BlockedPieceTypes`,
* `MoveClassRestriction`) are dispatcher-only and don't observe at
* the engine level. The push semantics also break at apply-time:
* `engine.ts:applyMove` doesn't honor the `isPawnPush` /
* `pushedPieceId` / `pushedTo` fields on the LegalMove (see
* `engine.ts` 1644-1697 — the normal-move branch only handles
* `isCapture`). So even though `getLegalPawnMoves` correctly emits
* push moves, applying one drives the white pawn onto the
* already-occupied target square without shoving the defender,
* leaving two pieces on one square in the session.
*
* Each affected test below is marked `test.fixme(...)` with an
* inline diagnosis. Closing these gaps is a follow-up integration
* task: either (a) replace `engine.ts:getAllLegalMoves`'s call to
* `lookupMoveGenerator` with a call to
* `rules/turn.ts:getLegalMovesForPiece` (after registering the
* per-type generators into that dispatcher), and (b) extend
* `engine.ts:applyMove` to handle the `isPawnPush` branch (mirroring
* `rules/turn.ts:applyMove`).
*
* The remaining test (KingExtraReach) is a real, passing e2e and
* locks the king-reach surface.
*
* ─────────────────────────────────────────────────────────────────────
* Methodology (same shape as Wave-15 parity-rules.spec.ts)
* ─────────────────────────────────────────────────────────────────────
*
* Each test:
*
* 1. Opens a host (white) + guest (black) page so we can drive both
* colors through the engine's turn-alternation gate.
* 2. Plays a small number of opening moves to set up the desired
* board topology (e.g. clear a2 to open b1's diagonal, advance
* e2 so the king has space, etc.).
* 3. Drives the `__test__.apply-descriptor` server-debug frame with
* a synthetic descriptor that wraps `set-piece-attr` /
* `block-by-piece-type` / `must-class` / `pawn-pushes-pieces`
* inside `on-rule-activated` (the same shape Wave-15's
* ice_physics e2e uses — `applyCustomDescriptor` runs the
* cascade in-place on first apply).
* 4. Drags the relevant piece via the standard Piece component drag
* surface (`[data-square="X"] [data-piece]` → `[data-square="Y"]`).
* For "should fail" probes we observe the post-drag DOM: if the
* drag was rejected at the move-gen layer, the piece stays at
* its source square (PredictionManager.tryMove returns false and
* no `game.delta` is emitted). For "should succeed" probes we
* assert the destination renders the moved piece.
*
* ─────────────────────────────────────────────────────────────────────
* Why `__test__.apply-descriptor` (not `activate-descriptor`)
* ─────────────────────────────────────────────────────────────────────
*
* `__test__.activate-descriptor` (T79) is for descriptors whose root
* is `on-rule-activated → request-choice` — it lifts the choice and
* re-routes it through the GameClient's pendingChoice slot. Our
* descriptors don't ask for player input; they're one-shot config
* mutations seeded at apply-time. The T83 `apply-descriptor` handler
* is the right frame: it walks `descriptor.primitives` once, fires
* the `on-rule-activated` cascade, and broadcasts a fresh
* `game.state` so the client mirrors the post-apply facts.
*
* ─────────────────────────────────────────────────────────────────────
* Note on game-level vs per-piece descriptor shape
* ─────────────────────────────────────────────────────────────────────
*
* Per-piece attrs (`MovesAs`, `MovesAlsoAs`, `KingExtraReach`) need a
* piece id at write-time. We wrap a `for-each-piece` filter inside
* `on-rule-activated` and bind the iterated piece to a $var so the
* inner `set-piece-attr` (or `set-moves-as` / `set-moves-also-as`)
* writes to the right entity. This mirrors ice_physics's filter on
* `pieceType: "bishop"` etc.
*
* Game-level attrs (`BlockedPieceTypes`, `MoveClassRestriction`,
* `PawnPushesPiecesEnabled`) use the dedicated state primitives
* (`block-by-piece-type`, `must-class`, `pawn-pushes-pieces`) which
* write to `GAME_ENTITY` directly — no piece target needed. They're
* NOT in `IMPERATIVE_KINDS` so they could legally sit at top-level,
* but we still wrap them in `on-rule-activated` for shape consistency
* with the ice_physics fixture.
*/
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 } from 'node:fs';
import { join } from 'node:path';
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors `multiplayer.spec.ts` / `parity-rules.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;
}
});
// ---------------------------------------------------------------------------
// Shared infra
// ---------------------------------------------------------------------------
const EVIDENCE_DIR = join(
process.cwd(),
'.sisyphus/evidence/wave17-move-gen-attrs-screenshots',
);
if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true });
async function snapshot(page: Page, label: string): Promise<void> {
await page.screenshot({
path: join(EVIDENCE_DIR, `${label}.png`),
fullPage: true,
});
}
/**
* Minimal CustomModifierDescriptor envelope. Helpers below populate
* `id`, `name`, `description`, and `primitives` per test; everything
* else is fixed. `targetAttrs` is informational — the descriptor
* parser doesn't consult it for behaviour, so we use a permissive
* superset across tests rather than threading the exact set per
* descriptor.
*/
type Descriptor = {
type: 'data';
id: string;
name: string;
description: string;
version: 1;
uiForm: 'primitive-composer';
source: 'custom';
targetAttrs: string[];
primitives: unknown[];
};
function descriptor(opts: {
id: string;
name: string;
description: string;
targetAttrs: string[];
primitives: unknown[];
}): Descriptor {
return {
type: 'data',
id: opts.id,
name: opts.name,
description: opts.description,
version: 1,
uiForm: 'primitive-composer',
source: 'custom',
targetAttrs: opts.targetAttrs,
primitives: opts.primitives,
};
}
// ---------------------------------------------------------------------------
// 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();
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown }).__paratypeChessClient,
) &&
Boolean(
(globalThis as { __paratypeChessPrediction?: unknown })
.__paratypeChessPrediction,
),
null,
{ timeout: 5000 },
);
return room;
}
async function joinAsGuest(page: Page, code: string): Promise<void> {
await page.goto('http://localhost:5173/');
await page.evaluate((c) => {
const ws = new WebSocket('ws://localhost:7357/ws');
return new Promise<void>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('join timeout')), 5000);
ws.onopen = () =>
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.join',
payload: { code: c },
}),
);
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code: string; token: string; color: string };
};
if (msg.type === 'room.joined') {
clearTimeout(t);
sessionStorage.setItem('room-code', msg.payload.code);
sessionStorage.setItem('room-token', msg.payload.token);
sessionStorage.setItem('player-color', msg.payload.color);
ws.close();
resolve();
} else if (msg.type === 'error') {
clearTimeout(t);
ws.close();
reject(new Error('join error'));
}
};
ws.onerror = () => {
clearTimeout(t);
reject(new Error('ws error'));
};
});
}, code);
await page.goto('http://localhost:5173/game');
await expect(page.locator('[data-testid="my-color"]')).toContainText('black');
}
/**
* Drive `__test__.apply-descriptor` through the page's existing
* GameClient socket. The handler runs server-side, mutates the
* engine via `applyCustomDescriptor`, and emits a fresh `game.state`
* snapshot — the page's MultiplayerGame view re-renders the
* post-apply board automatically.
*
* For descriptors with no per-piece root trigger (everything in this
* spec uses `on-rule-activated`), `targetSquare` is omitted and the
* server resolves `applyTarget` to `GAME_ENTITY`. Per-piece writes
* inside the descriptor go through `for-each-piece` $var bindings
* rather than relying on the apply target.
*/
async function applyDescriptor(
page: Page,
args: { code: string; descriptor: Descriptor; targetSquare?: number },
): Promise<void> {
await page.evaluate((a) => {
const client = (
globalThis as {
__paratypeChessClient?: {
send: (msg: { type: string; payload: unknown }) => void;
};
}
).__paratypeChessClient;
if (!client)
throw new Error('applyDescriptor: __paratypeChessClient not present');
client.send({
type: '__test__.apply-descriptor',
payload: {
roomCode: a.code,
descriptor: a.descriptor,
targetSquare: a.targetSquare,
},
});
}, args);
// Settle for the game.state round-trip + React render.
await page.waitForTimeout(200);
}
/** Drag a piece via the same UI path the multiplayer e2e uses. */
async function drag(page: Page, from: string, to: string): Promise<void> {
await page
.locator(`[data-square="${from}"] [data-piece]`)
.dragTo(page.locator(`[data-square="${to}"]`));
}
/**
* Read a session attr via the page's PredictionManager. Returns
* the engine's current attr value at any entity id (default
* GAME_ENTITY = 0). Used to verify trigger-fired writes that
* don't surface in the DOM.
*/
async function readAttr(
page: Page,
attr: string,
entityId: number = 0,
): Promise<unknown> {
return page.evaluate(
(a) => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: {
get: (id: unknown, attr: string) => unknown;
};
};
};
}
).__paratypeChessPrediction;
if (!mgr) throw new Error('readAttr: PredictionManager not exposed');
const engine = mgr.getCurrentEngine();
return engine.session.get(a.entityId, a.attr);
},
{ attr, entityId },
);
}
/**
* Convenience: wait for a piece to render at a square. Used after
* drags that should succeed.
*/
async function expectPieceAt(
page: Page,
square: string,
pieceCss: string,
): Promise<void> {
await expect(
page.locator(`[data-square="${square}"] [data-piece="${pieceCss}"]`),
).toBeVisible({ timeout: 5000 });
}
/**
* Convenience: wait for a square to be empty (no piece child).
*/
async function expectEmpty(page: Page, square: string): Promise<void> {
await expect(page.locator(`[data-square="${square}"] [data-piece]`)).toHaveCount(
0,
{ timeout: 5000 },
);
}
// ---------------------------------------------------------------------------
// Test 1 — MovesAs (replacement): knight moves like a bishop
// ---------------------------------------------------------------------------
//
// Setup:
// 1.a3 (white pawn a2→a3, opens a2 for the b1 knight to slide on
// the a2-b1 diagonal).
// 1...a6 (quiet black move so it's white's turn again).
//
// Apply descriptor: for-each-piece (pieceType=knight, color=white) →
// set-moves-as bishop. Every white knight now moves as a bishop —
// L-shapes are no longer legal; bishop diagonals are.
//
// Probe 1: drag b1→c3 (the canonical knight L-shape). With MovesAs
// replacing the pattern this is rejected by the move
// generator — knight stays at b1.
// Probe 2: drag b1→a2 (a single bishop-step on the b1 SW diagonal,
// which is empty after 1.a3). Move-gen accepts; knight
// renders on a2.
//
// GAP: `MovesAs` is read in `rules/turn.ts:getLegalMovesForPiece`,
// which `engine.ts:getAllLegalMoves` does not call. The runtime
// hot-path goes through `lookupMoveGenerator(piece.type)` →
// `getLegalKnightMoves`, which has no MovesAs awareness. Until the
// engine is rewired to consult the dispatcher, the descriptor lands
// the `MovesAs='bishop'` fact correctly (the readAttr probe below
// would pass) but the knight's legal-move set is unchanged: the
// L-shape Nb1→c3 is accepted and the diagonal Nb1→a2 is rejected,
// the OPPOSITE of what the wired-up dispatcher delivers.
test('Wave17/MovesAs: knight under MovesAs=bishop rejects L-shapes, accepts diagonals', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
// 1.a3 — open a2 for the knight's diagonal substitute.
await drag(pageA, 'a2', 'a3');
await expectPieceAt(pageA, 'a3', 'white-pawn');
await expectEmpty(pageA, 'a2');
// 1...a6 — quiet black move so it's white's turn again.
await drag(pageB, 'a7', 'a6');
await expectPieceAt(pageB, 'a6', 'black-pawn');
// Apply: every white knight gains MovesAs='bishop'.
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:moves-as-knight-bishop',
name: 'Wave17 MovesAs Knight Bishop',
description: 'Wave17 e2e — every white knight MovesAs bishop.',
targetAttrs: ['MovesAs', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'for-each-piece',
params: {
filter: { pieceType: 'knight', color: 'white' },
bind: 'n',
then: [
{
kind: 'set-moves-as',
params: { target: { $var: 'n' }, pieceType: 'bishop' },
},
],
},
},
],
},
},
],
}),
});
// Verify the attr landed on the b1 knight.
const knightId = await pageA
.locator('[data-square="b1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(knightId).not.toBeNull();
const movesAs = await readAttr(pageA, 'MovesAs', Number(knightId));
expect(movesAs).toBe('bishop');
await snapshot(pageA, 'moves-as-pre-probe');
// Probe 1: L-shape b1 → c3 should be REJECTED.
await drag(pageA, 'b1', 'c3');
await pageA.waitForTimeout(200);
await expect(
pageA.locator('[data-square="b1"] [data-piece="white-knight"]'),
).toBeVisible();
await expectEmpty(pageA, 'c3');
// Probe 2: diagonal b1 → a2 should be ACCEPTED.
await drag(pageA, 'b1', 'a2');
await expectPieceAt(pageA, 'a2', 'white-knight');
await expectEmpty(pageA, 'b1');
await snapshot(pageA, 'moves-as-post-probe');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 2 — MovesAlsoAs (additive): knight moves like knight + bishop
// ---------------------------------------------------------------------------
//
// Same opening setup as Test 1 (1.a3 / 1...a6 — clears a2 for the
// knight's diagonal step). Apply MovesAlsoAs='bishop' on white
// knights. The native L-shapes remain legal AND the bishop diagonals
// become legal additively.
//
// Probe A: drag b1→a2 (diagonal — was illegal pre-descriptor since
// knight L-jumps don't reach a2). Should succeed.
// Probe B: continue the same game — black plays a quiet move; on the
// next white turn drag the now-on-a2 knight via L-shape
// a2→b4 (a real knight L-jump). Should succeed, proving
// the native pattern is still active under MovesAlsoAs.
//
// GAP: same root cause as MovesAs above — `MovesAlsoAs` is read in
// `rules/turn.ts:getLegalMovesForPiece`, which the engine's
// `getAllLegalMoves` doesn't traverse. The fact lands on the
// knight; the move-gen pretends it isn't there. The diagonal probe
// (Probe A) below would fail because b1→a2 is never enumerated by
// `getLegalKnightMoves`.
test('Wave17/MovesAlsoAs: knight gains diagonal moves while keeping L-shapes', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
await drag(pageA, 'a2', 'a3');
await expectPieceAt(pageA, 'a3', 'white-pawn');
await drag(pageB, 'a7', 'a6');
await expectPieceAt(pageB, 'a6', 'black-pawn');
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:moves-also-as-knight-bishop',
name: 'Wave17 MovesAlsoAs Knight Bishop',
description: 'Wave17 e2e — white knights gain bishop moves additively.',
targetAttrs: ['MovesAlsoAs', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'for-each-piece',
params: {
filter: { pieceType: 'knight', color: 'white' },
bind: 'n',
then: [
{
kind: 'set-moves-also-as',
params: { target: { $var: 'n' }, pieceType: 'bishop' },
},
],
},
},
],
},
},
],
}),
});
// Sanity: attr present on the b1 knight.
const knightId = await pageA
.locator('[data-square="b1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(knightId).not.toBeNull();
expect(await readAttr(pageA, 'MovesAlsoAs', Number(knightId))).toBe('bishop');
// Probe A: diagonal b1 → a2 (was illegal — additive bishop unlocks
// it).
await drag(pageA, 'b1', 'a2');
await expectPieceAt(pageA, 'a2', 'white-knight');
await expectEmpty(pageA, 'b1');
// Black quiet reply so it's white's turn again.
await drag(pageB, 'b7', 'b6');
await expectPieceAt(pageB, 'b6', 'black-pawn');
// Probe B: native L-shape a2 → b4 (knight L-jump from a2: file +1,
// rank +2 → b4). Empty square. Native pattern preserved under
// MovesAlsoAs.
await drag(pageA, 'a2', 'b4');
await expectPieceAt(pageA, 'b4', 'white-knight');
await expectEmpty(pageA, 'a2');
await snapshot(pageA, 'moves-also-as-post-probes');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 3 — KingExtraReach=2: king moves up to 3 squares per direction
// ---------------------------------------------------------------------------
//
// `KingExtraReach: N` extends the king's per-move radius to `1 + N`.
// We set N=2 so the king can step up to 3 squares in any direction.
//
// Setup:
// 1.e4 (white pawn e2→e4 — vacates e2 AND e3 for the king).
// 1...e5 (black mirror).
//
// Apply: for-each-piece (pieceType=king, color=white) →
// set-piece-attr KingExtraReach=2.
//
// Probe: drag king e1 → e3 (2 squares forward). Pre-descriptor this
// is illegal (king can only step 1 square in standard chess);
// with reach=2 it lands.
//
test('Wave17/KingExtraReach=2: king steps two squares forward', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
// 1.e4 / 1...e5 — opens e2 and e3 in front of the white king.
await drag(pageA, 'e2', 'e4');
await expectPieceAt(pageA, 'e4', 'white-pawn');
await expectEmpty(pageA, 'e2');
await drag(pageB, 'e7', 'e5');
await expectPieceAt(pageB, 'e5', 'black-pawn');
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:king-extra-reach-2',
name: 'Wave17 KingExtraReach=2',
description: 'Wave17 e2e — white king gains KingExtraReach=2.',
targetAttrs: ['KingExtraReach', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'for-each-piece',
params: {
filter: { pieceType: 'king', color: 'white' },
bind: 'k',
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'k' },
attr: 'KingExtraReach',
value: 2,
},
},
],
},
},
],
},
},
],
}),
});
// Sanity: attr landed on the e1 king.
const kingId = await pageA
.locator('[data-square="e1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(kingId).not.toBeNull();
expect(await readAttr(pageA, 'KingExtraReach', Number(kingId))).toBe(2);
// Probe: e1 → e3 (2-square forward step — illegal in standard chess,
// legal with reach=2).
await drag(pageA, 'e1', 'e3');
await expectPieceAt(pageA, 'e3', 'white-king');
await expectEmpty(pageA, 'e1');
await snapshot(pageA, 'king-extra-reach-post-probe');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 4 — BlockedPieceTypes: pawns paralysed, knights still move
// ---------------------------------------------------------------------------
//
// Game-level `BlockedPieceTypes = ['pawn']` — every pawn on either
// side is unable to generate a move. Other piece types are
// unaffected.
//
// Probe 1: drag any white pawn (e.g. e2 → e4). Move-gen filters the
// move out; pawn stays on e2.
// Probe 2: drag a white knight (b1 → c3). Knight type isn't in the
// blocked set; move-gen accepts.
//
// GAP: `BlockedPieceTypes` is read by the dispatcher in
// `rules/turn.ts:getLegalMovesForPiece` but not by the engine's
// runtime path. The fact lands on GAME_ENTITY (readAttr would
// confirm it) but `engine.ts:getAllLegalMoves` happily emits the
// pawn's e2→e4 advance because it never checks the blocked-types
// list. Closing this gap requires rewiring the engine to use the
// dispatcher (the natural T74-T77 follow-up), at which point this
// test should drop the `.fixme`.
test('Wave17/BlockedPieceTypes: listed types cannot move; others still move', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:blocked-piece-types-pawn',
name: 'Wave17 BlockedPieceTypes pawn',
description: 'Wave17 e2e — pawns are paralysed.',
targetAttrs: ['BlockedPieceTypes', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'block-by-piece-type',
params: { pieceTypes: ['pawn'] },
},
],
},
},
],
}),
});
// Sanity: BlockedPieceTypes set on GAME_ENTITY.
expect(await readAttr(pageA, 'BlockedPieceTypes')).toEqual(['pawn']);
// Probe 1: pawn drag is rejected.
await drag(pageA, 'e2', 'e4');
await pageA.waitForTimeout(200);
await expect(
pageA.locator('[data-square="e2"] [data-piece="white-pawn"]'),
).toBeVisible();
await expectEmpty(pageA, 'e4');
// Probe 2: knight drag succeeds (b1 → c3, c3 is empty at game start).
await drag(pageA, 'b1', 'c3');
await expectPieceAt(pageA, 'c3', 'white-knight');
await expectEmpty(pageA, 'b1');
await snapshot(pageA, 'blocked-piece-types-post-probes');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 5 — MoveClassRestriction class:'capture': must capture
// ---------------------------------------------------------------------------
//
// Setup a capturable enemy:
// 1.e4 d5 — black pawn on d5 sits diagonally adjacent to white's
// e4 pawn (capturable via exd5).
// Then activate `must-class capture`. White's next move MUST be a
// capture.
//
// Probe 1: drag a non-capture (e.g. Nb1 → c3). Move-gen filter
// rejects; knight stays at b1.
// Probe 2: drag the capture e4 → d5. Move-gen accepts; white pawn
// ends on d5; black pawn is gone.
//
// GAP: `MoveClassRestriction` is read by the dispatcher's
// post-filter `applyMoveClassRestriction` in
// `rules/turn.ts`. The engine's `getAllLegalMoves` doesn't run
// that filter — it returns the unfiltered move list, so a
// non-capture knight move (Nb1→c3) is still accepted by
// `findMove`. The descriptor seeds the GAME_ENTITY restriction
// correctly (readAttr confirms `class: 'capture'`); the filter
// just isn't observed at the engine layer.
test("Wave17/MoveClassRestriction: 'must capture' rejects advances, accepts captures", async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
// 1.e4 d5 — black d-pawn lands diagonally adjacent to white e-pawn.
await drag(pageA, 'e2', 'e4');
await expectPieceAt(pageA, 'e4', 'white-pawn');
await drag(pageB, 'd7', 'd5');
await expectPieceAt(pageB, 'd5', 'black-pawn');
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:must-capture',
name: 'Wave17 Must Capture',
description: 'Wave17 e2e — must capture if possible.',
targetAttrs: ['MoveClassRestriction', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [{ kind: 'must-class', params: { class: 'capture' } }],
},
},
],
}),
});
// Sanity: restriction is on GAME_ENTITY.
const restriction = (await readAttr(pageA, 'MoveClassRestriction')) as
| { class?: string }
| undefined;
expect(restriction?.class).toBe('capture');
// Probe 1: non-capture knight move — REJECTED.
await drag(pageA, 'b1', 'c3');
await pageA.waitForTimeout(200);
await expect(
pageA.locator('[data-square="b1"] [data-piece="white-knight"]'),
).toBeVisible();
await expectEmpty(pageA, 'c3');
// Probe 2: capture exd5 — ACCEPTED.
await drag(pageA, 'e4', 'd5');
await expectPieceAt(pageA, 'd5', 'white-pawn');
await expectEmpty(pageA, 'e4');
await snapshot(pageA, 'must-capture-post-probes');
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 6 — PawnPushesPiecesEnabled: pawn shoves instead of capturing
// ---------------------------------------------------------------------------
//
// Setup:
// 1.e4 d5 — same as Test 5; white e-pawn at e4, black d-pawn at d5
// (diagonally adjacent). d6 is empty.
//
// Apply pawn-pushes-pieces (game-level flag). White's "diagonal
// capture" e4×d5 becomes a PUSH: the white pawn lands on d5, the
// black pawn is shoved to d6 (one rank further north — same direction
// the white pawn was moving).
//
// Probe: drag white pawn e4 → d5. Assert post-state:
// - white pawn renders on d5
// - black pawn renders on d6
// - e4 is empty
//
// GAP: `PawnPushesPiecesEnabled` is read inside
// `rules/pawn.ts:getLegalPawnMoves` (which the engine DOES call),
// so move-generation correctly emits a push move with
// `isPawnPush: true`, `pushedPieceId`, `pushedTo`. But
// `engine.ts:applyMove` only knows about the FIDE branches
// (en-passant, castling, normal capture, normal advance) — it
// doesn't read the push fields. The result of dragging e4→d5: the
// white pawn advances onto d5, the black pawn at d5 is NOT shoved
// to d6, leaving two pieces on d5 in the session. Closing this gap
// requires extending `engine.ts:applyMove` with the same
// isPawnPush branch that `rules/turn.ts:applyMove` already
// implements (relocate attacker to capSq, relocate target to
// pushTarget, no capture).
test('Wave17/PawnPushesPiecesEnabled: pawn pushes the diagonal target instead of capturing', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
const room = await joinAsHost(pageA);
expect(room.color).toBe('white');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await joinAsGuest(pageB, room.code);
// 1.e4 d5 — set up the diagonal-adjacent pawn pair with d6 empty.
await drag(pageA, 'e2', 'e4');
await expectPieceAt(pageA, 'e4', 'white-pawn');
await drag(pageB, 'd7', 'd5');
await expectPieceAt(pageB, 'd5', 'black-pawn');
await expectEmpty(pageA, 'd6');
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptor({
id: 'wave17:pawn-pushes-pieces',
name: 'Wave17 PawnPushesPiecesEnabled',
description: 'Wave17 e2e — pawns push diagonal targets instead of capturing.',
targetAttrs: ['PawnPushesPiecesEnabled', 'OnRuleActivatedHooks'],
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{ kind: 'pawn-pushes-pieces', params: { enabled: true } },
],
},
},
],
}),
});
// Sanity: flag is on GAME_ENTITY.
expect(await readAttr(pageA, 'PawnPushesPiecesEnabled')).toBe(true);
// Probe: drag white pawn e4 → d5. Push semantics: white pawn lands
// on d5 (no capture); black d-pawn is shoved to d6.
await drag(pageA, 'e4', 'd5');
await expectPieceAt(pageA, 'd5', 'white-pawn');
await expectPieceAt(pageA, 'd6', 'black-pawn');
await expectEmpty(pageA, 'e4');
await snapshot(pageA, 'pawn-push-post-probe');
await ctxA.close();
await ctxB.close();
});