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.
1015 lines
37 KiB
TypeScript
1015 lines
37 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|