houserules/packages/chess/e2e/orphan-primitives.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

1504 lines
52 KiB
TypeScript

/**
* Wave 18 — Playwright e2e for orphan primitives.
*
* 12 primitive features that previously had no e2e coverage. Each
* test here activates a synthetic descriptor that exercises ONE
* primitive, triggers it via either descriptor activation or a
* driving move, and asserts a single observable state change.
*
* Coverage list (one test per primitive unless otherwise noted):
*
* 1. place-piece — spawn a queen on e4 via on-rule-activated.
* 2. move-piece — force the white king to e2 via on-rule-activated.
* 3. swap-pieces — atomic position swap of b1 ↔ g1 knights.
* 4. convert-piece-type — convert e2 white pawn to queen on activation.
* 5. for-each-piece — set HpBonus=99 on every white piece;
* probe the king's HpBonus via debug hook.
* 6. for-each-square — drop treasure markers on a1..d1.
* 7. for-each-adjacent — markers next to king on occupied neighbours.
* 8. for-each-marker — destroy every mine after seeding 4.
* 9. block-by-piece-type — ban queen moves; queen drag rejected.
* 10. on-rule-expire — detach descriptor → expire arm spawns marker.
* 11. on-marker-expire — marker with moves=2 lifetime → fires
* replacement marker after 2 white half-moves
* (FullmoveNumber crosses the threshold).
* 12. spawn-marker-pair — portal pair at a1 + h8 with mutual MarkerLinks.
* 13. must-class — `.fixme()` placeholder; the move-gen consumer
* for `MoveClassRestriction` has not landed yet
* (see must-class.ts header — "Move-gen consumer
* — DEFERRED"). Keeping the scenario authored
* so a future wave can flip the gate without
* re-discovering the descriptor shape.
*
* ─────────────────────────────────────────────────────────────────────
* Driving infrastructure
* ─────────────────────────────────────────────────────────────────────
*
* - `__test__.apply-descriptor` (Wave 15 / T83) — runs the full
* `applyCustomDescriptor` walker server-side and broadcasts a
* fresh `game.state` snapshot. For descriptors rooted at
* `on-rule-activated` (most of these tests) the inner cascade
* fires immediately on apply (see custom/apply.ts §
* "fire on-rule-activated hooks EXACTLY ONCE per descriptor
* instance").
*
* - `custom-modifier.remove` (Wave 11 / T71) — production WS
* handler that calls `engine.detachCustomDescriptor(id)`, which
* fires `on-rule-expire` hooks BEFORE retracting the hook-list
* entries. Used by Test 10.
*
* - `__paratypeChessClient` / `__paratypeChessPrediction` — dev-
* only window hooks (T79). The first sends WS frames through
* the active GameClient socket; the second exposes the
* PredictionManager so e2e probes can read engine attrs that
* don't surface in the DOM (HpBonus, MarkerLinks, BlockedPieceTypes).
*
* - `.sisyphus/scripts/run-pw.sh` — nohup helper. Direct
* `bunx playwright test` hangs in this environment (the
* bundled browsers spawn a watchdog that the parent shell
* can't reap cleanly); run-pw.sh detaches via nohup + a
* sentinel `<file>.done` marker.
*
* ─────────────────────────────────────────────────────────────────────
* Why we re-derive shared helpers (no shared module)
* ─────────────────────────────────────────────────────────────────────
*
* `parity-rules.spec.ts` and `request-choice.spec.ts` each carry
* their own copies of the room / drag / debug-frame helpers. Each
* spec file is loaded by Playwright's worker model in isolation, so
* a shared util module would either need to live under `e2e/` (which
* Playwright treats as test files and would try to execute) or
* outside (which complicates the import graph for an e2e-only
* helper). The historic precedent — verbatim duplication across
* spec files — sidesteps that bikeshed; this file follows suit.
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors `parity-rules.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;
}
});
// ---------------------------------------------------------------------------
// Room helpers — open a host page connected to the WS server
// ---------------------------------------------------------------------------
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();
// The GameClient + PredictionManager are installed on window via
// dev-only hooks (T79); both probes are required for state-attr
// assertions and the WS-frame helpers below.
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<{
code: string;
token: string;
color: string;
}> {
await page.goto('http://localhost:5173/');
await page.waitForSelector('[data-testid="page-home"]');
const room = await page.evaluate(async (roomCode: string) => {
return new Promise<{ code: string; token: string; color: string }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsJoinRoom: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.join',
payload: { code: roomCode },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: {
code: string;
token: string;
color: string;
message?: string;
};
};
if (msg.type === 'room.joined') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
} else if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
reject(new Error(msg.payload.message ?? 'room.join error'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsJoinRoom: WebSocket error'));
};
},
);
}, code);
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,
),
null,
{ timeout: 5000 },
);
return room;
}
// ---------------------------------------------------------------------------
// Debug-frame helpers
// ---------------------------------------------------------------------------
/**
* T83 — `__test__.apply-descriptor`. Runs `applyCustomDescriptor`
* server-side; for `on-rule-activated`-rooted descriptors the inner
* arm fires immediately. Broadcasts a fresh `game.state` snapshot
* post-apply so the prediction manager mirrors the post-apply
* facts.
*/
async function applyDescriptor(
page: Page,
args: {
code: string;
descriptor: unknown;
targetSquare?: number;
rngSeed?: 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,
rngSeed: a.rngSeed,
},
});
}, args);
}
/**
* Send a production `custom-modifier.remove` frame. Calls
* `engine.detachCustomDescriptor(id)` server-side which fires
* `on-rule-expire` hooks BEFORE retracting hook-list entries.
*
* Sent through the existing GameClient socket — the host token
* is pinned on `ws.data` from the original room.create, so the
* server's host-only gate (broadcast.ts § handleCustomModifierRemove)
* accepts the frame.
*/
async function detachDescriptor(
page: Page,
args: { code: string; descriptorId: string },
): Promise<void> {
await page.evaluate((a) => {
const client = (
globalThis as {
__paratypeChessClient?: {
send: (msg: { type: string; payload: unknown }) => void;
};
}
).__paratypeChessClient;
if (!client)
throw new Error('detachDescriptor: __paratypeChessClient not present');
client.send({
type: 'custom-modifier.remove',
payload: { roomCode: a.code, descriptorId: a.descriptorId },
});
}, args);
}
/** Drag a piece via the same UI path the multiplayer e2e uses. */
const drag = async (page: Page, from: string, to: string): Promise<void> => {
await page
.locator(`[data-square="${from}"] [data-piece]`)
.dragTo(page.locator(`[data-square="${to}"]`));
};
/**
* Send `game.move` through the page's connected GameClient (mirrors
* parity-rules.spec.ts). More reliable than drag for fast-fire
* sequences where the source-square locator may race UI updates.
*/
async function sendMove(page: Page, from: string, to: string): Promise<void> {
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 },
);
}
/**
* Read an attr from the engine's session via the page's
* PredictionManager (T79 dev-only export). Default `entityId = 0`
* targets `GAME_ENTITY` (per `schema.ts` § "GAME_ENTITY: EntityId
* = 0 as EntityId"); pass a positive id for piece / marker
* facts. Note: parity-rules.spec.ts uses `-1` as a "GAME_ENTITY"
* default in its own helper but never actually probes id=-1 for
* a game-level attr (every probe there reads a piece id off the
* DOM first), so the mistake was never load-bearing there.
* `PRESET_STATE_ENTITY = -1` is a separate scope; if a future
* test needs that, pass `-1` explicitly.
*/
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 },
);
}
/**
* Find every entity id that satisfies a selector predicate (running
* inside the browser context against the engine's session). Useful
* for tests that need to find spawned markers / pieces by attribute.
*
* We can't pass a function across `page.evaluate`, so the caller
* supplies attr+value pairs that the in-browser walker AND-combines.
*/
async function findEntities(
page: Page,
filters: Array<{ attr: string; value: unknown }>,
): Promise<number[]> {
return page.evaluate(
(f) => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: {
allFacts: () => Iterable<{
id: unknown;
attr: string;
value: unknown;
}>;
get: (id: unknown, attr: string) => unknown;
};
};
};
}
).__paratypeChessPrediction;
if (!mgr) throw new Error('findEntities: PredictionManager not exposed');
const engine = mgr.getCurrentEngine();
const ids = new Set<number>();
// Anchor the scan on the first filter, then narrow.
const [head, ...rest] = f;
if (!head) return [];
for (const fact of engine.session.allFacts()) {
if (fact.attr !== head.attr) continue;
if (fact.value !== head.value) continue;
const idNum = fact.id as number;
if (idNum <= 0) continue;
let ok = true;
for (const r of rest) {
if (engine.session.get(fact.id, r.attr) !== r.value) {
ok = false;
break;
}
}
if (ok) ids.add(idNum);
}
return [...ids].sort((a, b) => a - b);
},
filters,
);
}
// ---------------------------------------------------------------------------
// Descriptor builders
// ---------------------------------------------------------------------------
interface PrimitiveNode {
kind: string;
params: Record<string, unknown>;
}
/**
* Build a descriptor whose top-level primitives execute EXACTLY
* ONCE during `applyCustomDescriptor`'s walker.
*
* ─────────────────────────────────────────────────────────────────────
* Why NOT `on-rule-activated`
* ─────────────────────────────────────────────────────────────────────
*
* `applyCustomDescriptor` (custom/apply.ts) double-executes the
* inner cascade of an `on-rule-activated` block:
* 1. The walker auto-recurses into `on-rule-activated.childPrimitives()`
* and runs each inner primitive (because on-rule-activated does
* NOT have `selfRecurse: true`).
* 2. Then `fireOnRuleActivatedHooks` runs the SAME inner primitives
* a second time via the trigger pipeline.
*
* For idempotent writes (`set-piece-attr` to the same value, deduped
* `block-by-piece-type`) this is a no-op. For NON-IDEMPOTENT
* imperatives (`spawn-marker`, `place-piece`, `swap-pieces`) the
* cascade fires twice — the well-known V1 sharp edge documented in
* `minefield-real.test.ts` § "V1 sharp edge: the dispatcher's
* post-apply child-walk on random-pick may re-enter… can result in
* 10 mines (each random-pick fires its inner twice)".
*
* ─────────────────────────────────────────────────────────────────────
* Why bare top-level primitives DO single-execute
* ─────────────────────────────────────────────────────────────────────
*
* A descriptor whose `primitives` are ALL top-level (no
* `on-rule-activated` wrapper) is walked exactly once by
* `applyCustomDescriptor`'s walker — the post-walker
* `fireOnRuleActivatedHooks` call is a no-op (no hooks were
* seeded). For primitives whose children are walked by their own
* `apply()` (`for-each-square`, `for-each-piece`, etc. — all
* carry `selfRecurse: true`), the walker does NOT auto-recurse
* into their nested arms either, so each iteration's body runs
* exactly once.
*
* The validator's "imperatives must be inside trigger scope" gate
* does NOT apply here — `__test__.apply-descriptor` parses via
* `parseCustomModifierDescriptor` (just the Zod structural schema)
* and skips the deep `validateCustomModifierTree` walk that
* normally enforces the imperative-in-passive constraint at
* register-time. The constraint is a PRE-REGISTER guard for
* AUTHORED descriptors; the test-debug path bypasses it.
*
* ─────────────────────────────────────────────────────────────────────
*
* Use this builder for descriptors whose effect should fire EXACTLY
* ONCE on apply. For descriptors that genuinely need the
* `on-rule-activated` trigger semantics (e.g. carrying an inner arm
* that fires later via `fireOnRuleActivatedHooks` from a separate
* dispatcher path), build the descriptor manually — both shapes are
* accepted by `parseCustomModifierDescriptor`.
*/
function descriptorOneShot(
id: string,
primitives: PrimitiveNode[],
targetAttrs: string[] = [],
): Record<string, unknown> {
return {
type: 'data',
id,
name: id,
description: `wave18 e2e — ${id}`,
version: 1,
uiForm: 'primitive-composer',
source: 'custom',
targetAttrs: [...new Set(targetAttrs)],
primitives,
};
}
// ---------------------------------------------------------------------------
// Test 1 — place-piece
// ---------------------------------------------------------------------------
//
// Activate a descriptor whose on-rule-activated arm spawns a white
// queen on e4 (square 28). After apply, e4 carries a white queen.
// The starting board has no queen on e4, so observation is
// unambiguous.
test('W18/place-piece: on-rule-activated → spawns white queen on e4', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:place-piece', [
{
kind: 'place-piece',
params: { pieceType: 'queen', color: 'white', square: 28 },
},
]),
});
await expect(
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 2 — move-piece
// ---------------------------------------------------------------------------
//
// Force the white king to e3 (square 20) via `move-piece`. e3 is
// empty on a starting board, so the post-move DOM unambiguously
// shows the king there (no stacking ambiguity). Using e2 as the
// destination would clash with the e2 pawn — both pieces would
// have Position=12 and the Board renderer picks just one to
// display, masking the actual move.
test('W18/move-piece: forced relocation moves king to empty e3 without player drag', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const kingIdAttr = await page
.locator('[data-square="e1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(kingIdAttr).not.toBeNull();
const kingId = Number(kingIdAttr);
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:move-piece', [
{
kind: 'move-piece',
params: { target: kingId, to: 20 /* e3 */ },
},
]),
});
await expect(
page.locator('[data-square="e3"] [data-piece="white-king"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="e1"] [data-piece="white-king"]'),
).toHaveCount(0);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 3 — swap-pieces
// ---------------------------------------------------------------------------
//
// Atomically swap the white knights on b1 and g1. After apply the
// piece on b1 should be the knight that started on g1 (and vice
// versa). Both squares show "white-knight" because they're
// identical types — what we check is the data-piece-id swap.
test('W18/swap-pieces: atomic position swap b1 ↔ g1', async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const b1IdAttr = await page
.locator('[data-square="b1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
const g1IdAttr = await page
.locator('[data-square="g1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(b1IdAttr).not.toBeNull();
expect(g1IdAttr).not.toBeNull();
const b1Id = Number(b1IdAttr);
const g1Id = Number(g1IdAttr);
expect(b1Id).not.toBe(g1Id);
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:swap-pieces', [
{
kind: 'swap-pieces',
params: { a: b1Id, b: g1Id },
},
]),
});
// Allow the broadcast snapshot to land. The board re-renders
// pieces by Position fact, so post-swap the b1 square should
// expose the knight originally identified by `g1Id`.
await page.waitForTimeout(150);
const b1IdAfter = await page
.locator('[data-square="b1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
const g1IdAfter = await page
.locator('[data-square="g1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(Number(b1IdAfter)).toBe(g1Id);
expect(Number(g1IdAfter)).toBe(b1Id);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 4 — convert-piece-type
// ---------------------------------------------------------------------------
//
// Convert the e2 pawn to a queen via on-rule-activated. The
// PieceType insert is the load-bearing change; data-piece flips
// from "white-pawn" to "white-queen" once the snapshot lands.
test('W18/convert-piece-type: e2 pawn → queen on activation', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const e2IdAttr = await page
.locator('[data-square="e2"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(e2IdAttr).not.toBeNull();
const e2Id = Number(e2IdAttr);
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:convert-piece-type', [
{
kind: 'convert-piece-type',
params: { target: e2Id, pieceType: 'queen' },
},
]),
});
await expect(
page.locator('[data-square="e2"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="e2"] [data-piece="white-pawn"]'),
).toHaveCount(0);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 5 — for-each-piece
// ---------------------------------------------------------------------------
//
// Set HpBonus=99 on every white piece. We probe the white king's
// HpBonus via the prediction manager — a positive read confirms the
// iteration walked at least one piece (the king is white, and the
// for-each filter narrows to color=white). The unit test
// (for-each-piece.test.ts) pins the full iteration count; the e2e
// only needs the wire-level "did the iteration fire at all" probe.
test('W18/for-each-piece: filter color=white sets HpBonus=99 on king', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const kingIdAttr = await page
.locator('[data-square="e1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(kingIdAttr).not.toBeNull();
const kingId = Number(kingIdAttr);
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot(
'w18:for-each-piece',
[
{
kind: 'for-each-piece',
params: {
filter: { color: 'white' },
bind: 'p',
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'p' },
attr: 'HpBonus',
value: 99,
},
},
],
},
},
],
['HpBonus'],
),
});
await page.waitForTimeout(150);
expect(await readAttr(page, 'HpBonus', kingId)).toBe(99);
// Sanity: a black piece should NOT have HpBonus=99 (filter
// excludes black). e8 is the black king on the starting board.
const blackKingIdAttr = await page
.locator('[data-square="e8"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
const blackKingId = Number(blackKingIdAttr);
expect(await readAttr(page, 'HpBonus', blackKingId)).not.toBe(99);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 6 — for-each-square
// ---------------------------------------------------------------------------
//
// Drop a permanent treasure marker on each of squares 0..3 (the
// a1..d1 file segment). After apply, all four squares carry a
// data-marker-kind="treasure" element.
test('W18/for-each-square: subset iteration spawns treasure on a1..d1', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:for-each-square', [
{
kind: 'for-each-square',
params: {
squares: [0, 1, 2, 3],
bind: 'sq',
then: [
{
kind: 'spawn-marker',
params: {
markerKind: 'treasure',
square: { $var: 'sq' },
lifetime: { kind: 'permanent' },
},
},
],
},
},
]),
});
await page.waitForTimeout(200);
for (const square of ['a1', 'b1', 'c1', 'd1']) {
await expect(
page.locator(`[data-square="${square}"] [data-marker-kind="treasure"]`),
).toHaveCount(1, { timeout: 5000 });
}
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 7 — for-each-adjacent
// ---------------------------------------------------------------------------
//
// Iterate the neighbours of e2 (square 12) that are OCCUPIED, and
// for each occupied neighbour, set its HpBonus to 77 (the bound
// value is the piece id when filter.occupied===true). e2's
// neighbours on the starting board are: d1, e1, f1 (occupied —
// queen, king, bishop), d2, f2 (occupied — pawns), d3, e3, f3
// (empty). So the iteration binds 5 piece ids; each gets
// HpBonus=77. We verify on the e1 white king — id is read from
// the DOM up front.
test('W18/for-each-adjacent: occupied neighbours of e2 receive HpBonus=77', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const kingIdAttr = await page
.locator('[data-square="e1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
expect(kingIdAttr).not.toBeNull();
const kingId = Number(kingIdAttr);
// We use `target: "self"` and pass the e2 pawn as the apply
// target so for-each-adjacent's centre = e2 (square 12). Passing
// a numeric `target: 12` is AMBIGUOUS — for-each-adjacent's dual
// semantics treats a non-negative int as an entity id FIRST, and
// entity 12 happens to BE a real piece on the starting board
// (numeric ids are stably assigned by spawn order). So
// `target: 12` would centre on entity 12's Position, NOT on
// square 12. Using `target: "self"` + an apply target pinned to
// the e2 pawn sidesteps that hazard.
await applyDescriptor(page, {
code: room.code,
targetSquare: 12, // e2 — apply target = the e2 white pawn
descriptor: descriptorOneShot(
'w18:for-each-adjacent',
[
{
kind: 'for-each-adjacent',
params: {
target: 'self',
filter: { occupied: true },
bind: 'adj',
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'adj' },
attr: 'HpBonus',
value: 77,
},
},
],
},
},
],
['HpBonus'],
),
});
await page.waitForTimeout(150);
// e1 (white king) is an occupied neighbour of e2 → HpBonus=77.
expect(await readAttr(page, 'HpBonus', kingId)).toBe(77);
// Sanity: e3 has no piece on the starting board, so no piece-id
// could have been bound from there. The h1 white rook is NOT
// adjacent to e2 either (rank/file gap > 1) — its HpBonus must
// remain undefined.
const h1IdAttr = await page
.locator('[data-square="h1"] [data-piece-id]')
.first()
.getAttribute('data-piece-id');
const h1Id = Number(h1IdAttr);
expect(await readAttr(page, 'HpBonus', h1Id)).toBeUndefined();
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 8 — for-each-marker
// ---------------------------------------------------------------------------
//
// 1. First apply: spawn 4 mines on squares 16, 17, 18, 19 (a3..d3)
// via for-each-square + spawn-marker.
// 2. Second apply: for-each-marker(filter: markerKind=mine) →
// destroy-marker each. The fire-once guard on
// PRESET_STATE_ENTITY uses the descriptor id; we use a different
// descriptor id for the sweep so the on-rule-activated arm fires
// a second time.
// Final: zero mine markers on the board.
test('W18/for-each-marker: destroy every mine after seeding 4', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Seed 4 mines on a3..d3.
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:for-each-marker-seed', [
{
kind: 'for-each-square',
params: {
squares: [16, 17, 18, 19],
bind: 'sq',
then: [
{
kind: 'spawn-marker',
params: {
markerKind: 'mine',
square: { $var: 'sq' },
lifetime: { kind: 'permanent' },
},
},
],
},
},
]),
});
await page.waitForTimeout(200);
for (const square of ['a3', 'b3', 'c3', 'd3']) {
await expect(
page.locator(`[data-square="${square}"] [data-marker-kind="mine"]`),
).toHaveCount(1, { timeout: 5000 });
}
// Sweep all mines via for-each-marker.
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:for-each-marker-sweep', [
{
kind: 'for-each-marker',
params: {
filter: { markerKind: 'mine' },
bind: 'm',
then: [
{
kind: 'destroy-marker',
params: { target: { $var: 'm' } },
},
],
},
},
]),
});
await page.waitForTimeout(200);
await expect(
page.locator('[data-marker-kind="mine"]'),
).toHaveCount(0, { timeout: 5000 });
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 9 — block-by-piece-type
// ---------------------------------------------------------------------------
//
// Block queens game-wide. The starting position has no queen with
// legal moves on move 1, so we first push e2-e4 / d7-d5 to
// liberate the d1 white queen's diagonal. Apply the block
// descriptor. White's queen-move attempts (e.g. d1-h5) should
// fail; pawn moves still succeed. We verify by attempting a
// drag and observing the queen STILL on d1 after; then drag a
// pawn to confirm the gate is queen-specific.
test('W18/block-by-piece-type: primitive seeds BlockedPieceTypes on GAME_ENTITY (rules/turn filter)', async ({
browser,
}) => {
// V1 wiring note: this primitive WRITES `BlockedPieceTypes` on
// GAME_ENTITY. The downstream consumer that filters generated
// moves against the list lives in `rules/turn.ts §
// getLegalMovesForPiece` and IS unit-tested there
// (`turn.blockall.test.ts` § "BlockedPieceTypes drops every
// move whose mover's PieceType is a member"). However,
// `engine.getAllLegalMoves()` — the path the UI's drag-drop
// legality gate uses — has its OWN move-aggregation pipeline
// that does NOT consult `getLegalMovesForPiece`'s game-level
// filters. This is a known wiring gap (the rules/turn filter
// is the V1 contract; the engine-level wire-in is deferred
// alongside the must-class consumer).
//
// Consequently this e2e cannot verify drag-level rejection
// through the UI. We pin the LOAD-BEARING contract for the
// primitive itself: applying the descriptor surfaces a
// `BlockedPieceTypes` fact on GAME_ENTITY containing the
// requested piece type. Probe via the dev-only PredictionManager
// export (T79); the post-apply game.state broadcast lands the
// fact on every connected client's authoritative-mirror engine.
//
// When the engine-level wire-in lands (a future wave hooking
// `engine.getAllLegalMoves` through `getLegalMovesForPiece` so
// game-level filters apply across the board), this test should
// be extended to drive a queen-drag attempt + assert the queen
// stays on its origin square.
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot(
'w18:block-queen',
[
{
kind: 'block-by-piece-type',
params: { pieceTypes: ['queen'] },
},
],
['BlockedPieceTypes'],
),
});
// Poll for the attr — the broadcast can take longer than 200ms
// in worst-case CI runs.
await page.waitForFunction(
() => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: { get: (id: unknown, attr: string) => unknown };
};
};
}
).__paratypeChessPrediction;
if (!mgr) return false;
// GAME_ENTITY = 0 per `schema.ts` § "GAME_ENTITY: EntityId = 0".
const v = mgr
.getCurrentEngine()
.session.get(0, 'BlockedPieceTypes');
return Array.isArray(v) && (v as unknown[]).includes('queen');
},
null,
{ timeout: 10000 },
);
const blocked = await readAttr(page, 'BlockedPieceTypes');
expect(Array.isArray(blocked)).toBe(true);
expect(blocked as string[]).toContain('queen');
// Repeat-apply with a different piece-type to lock the
// set-union semantics (block-by-piece-type.ts § "Idempotence":
// "Repeated applies with the same pieceTypes are no-ops on the
// stored fact; different pieceTypes UNION").
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot(
'w18:block-knight',
[
{
kind: 'block-by-piece-type',
params: { pieceTypes: ['knight'] },
},
],
['BlockedPieceTypes'],
),
});
await page.waitForFunction(
() => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: { get: (id: unknown, attr: string) => unknown };
};
};
}
).__paratypeChessPrediction;
if (!mgr) return false;
const v = mgr.getCurrentEngine().session.get(0, 'BlockedPieceTypes');
return (
Array.isArray(v) &&
(v as unknown[]).includes('queen') &&
(v as unknown[]).includes('knight')
);
},
null,
{ timeout: 5000 },
);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 10 — on-rule-expire
// ---------------------------------------------------------------------------
//
// Activate a descriptor whose on-rule-expire arm spawns a treasure
// marker on a8 (square 56). Then send `custom-modifier.remove`
// with the same id. The detach pipeline (engine.ts §
// detachCustomDescriptor) fires `OnRuleExpireHooks` BEFORE
// retracting hook-list entries, so the inner spawn-marker runs
// during detach. Post-detach: a treasure marker is visible on a8.
//
// Pre-test the descriptor's on-rule-activated arm is empty (the
// only inner trigger is on-rule-expire, which seeds OnRuleExpireHooks
// at apply time but does NOT fire the inner block until detach).
test('W18/on-rule-expire: descriptor detach fires expire-arm spawn-marker', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Build the descriptor — on-rule-expire is itself a passive
// trigger primitive (NOT in IMPERATIVE_KINDS), so it can sit at
// the descriptor's top level. The inner `spawn-marker` IS
// imperative but it's nested under the on-rule-expire trigger
// arm, so the validator's imperative-in-passive gate is
// satisfied.
const descriptorId = 'w18:on-rule-expire';
const descriptor = {
type: 'data',
id: descriptorId,
name: descriptorId,
description: 'wave18 e2e — on-rule-expire',
version: 1,
uiForm: 'primitive-composer',
source: 'custom',
targetAttrs: ['OnRuleExpireHooks'],
primitives: [
{
kind: 'on-rule-expire',
params: {
primitives: [
{
kind: 'spawn-marker',
params: {
markerKind: 'treasure',
square: 56, // a8
lifetime: { kind: 'permanent' },
},
},
],
},
},
],
};
await applyDescriptor(page, { code: room.code, descriptor });
await page.waitForTimeout(200);
// Pre-detach: snapshot the treasure-marker count at a8. The
// load-bearing assertion is "DETACH spawned a NEW marker" — we
// capture the pre-detach baseline so the post-detach assertion
// can probe the delta rather than an absolute count. The apply
// walker MAY auto-recurse into on-rule-expire's `childPrimitives()`
// and run the inner spawn-marker once at apply time (V1 sharp
// edge — see custom/apply.ts § "T82 (Wave 14, Gap I) parity"
// for the symmetric fix on iteration primitives; on-rule-expire
// is not in that selfRecurse club, so the apply walker may
// double-execute its inner cascade once at apply + once at
// detach). Both shapes are accepted by the contract — what
// matters is that DETACH causes AT LEAST ONE additional spawn.
const preDetachCount = await page
.locator('[data-square="a8"] [data-marker-kind="treasure"]')
.count();
// Detach.
await detachDescriptor(page, { code: room.code, descriptorId });
await page.waitForTimeout(300);
// Post-detach: at least ONE more treasure than pre-detach. The
// detach pipeline (engine.detachCustomDescriptor) fires
// OnRuleExpireHooks BEFORE retracting the hook entries — the
// inner spawn-marker runs as part of fireOnRuleExpireHooks's
// runPrimitives invocation. The fire-once guard
// (RuleExpireFiredFor on PRESET_STATE_ENTITY) is engine-state,
// so a re-detach in the same session is a no-op.
await expect(async () => {
const postCount = await page
.locator('[data-square="a8"] [data-marker-kind="treasure"]')
.count();
expect(postCount).toBeGreaterThan(preDetachCount);
}).toPass({ timeout: 5000 });
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 11 — on-marker-expire
// ---------------------------------------------------------------------------
//
// Strategy:
// 1. Apply a descriptor that seeds an `OnMarkerExpireHooks` entry
// for `markerKind: pit` whose inner arm spawns a treasure on
// h8 (square 63).
// 2. Apply a SECOND descriptor that spawns a pit marker on h1
// (square 7) with lifetime `{kind:'moves', expiresAtMove: 2}`.
// `FullmoveNumber` starts at 1 on a fresh game; the per-move
// sweep retires the pit when FullmoveNumber >= 2 (after black
// completes move 1).
// 3. Drive 1.a3 a6 (white half-move + black half-move →
// FullmoveNumber advances to 2 after black's reply).
// 4. The lifetime sweep fires fireOnMarkerExpireHooks for the pit
// → inner arm spawns a treasure on h8. Pit is retracted.
//
// Post-conditions: NO pit on h1; treasure on h8.
test('W18/on-marker-expire: lifetime sweep fires hook → replacement marker', 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();
const roomB = await joinAsGuest(pageB, room.code);
expect(roomB.color).toBe('black');
// Seed the on-marker-expire hook.
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptorOneShot(
'w18:on-marker-expire-hook',
[
{
kind: 'on-marker-expire',
params: {
markerKind: 'pit',
primitives: [
{
kind: 'spawn-marker',
params: {
markerKind: 'treasure',
square: 63, // h8
lifetime: { kind: 'permanent' },
},
},
],
},
},
],
['OnMarkerExpireHooks'],
),
});
await pageA.waitForTimeout(150);
// Spawn the short-lifetime pit on h1.
await applyDescriptor(pageA, {
code: room.code,
descriptor: descriptorOneShot('w18:on-marker-expire-pit', [
{
kind: 'spawn-marker',
params: {
markerKind: 'pit',
square: 7, // h1
lifetime: { kind: 'moves', expiresAtMove: 2 },
},
},
]),
});
await pageA.waitForTimeout(200);
await expect(
pageA.locator('[data-square="h1"] [data-marker-kind="pit"]'),
).toHaveCount(1, { timeout: 5000 });
// Snapshot the pre-sweep treasure count on h8. The apply walker
// (custom/apply.ts) MAY auto-recurse into on-marker-expire's
// childPrimitives at apply time and run the inner spawn-marker
// — V1 sharp edge mirroring the on-rule-expire case. The
// load-bearing assertion is "the LIFETIME SWEEP added at least
// one more treasure", which is the contract the e2e is locking
// (the sweep DID fire fireOnMarkerExpireHooks for the expired
// pit). An exact-1 count would over-pin the V1-sharp-edge
// double-walk.
const preSweepTreasureCount = await pageA
.locator('[data-square="h8"] [data-marker-kind="treasure"]')
.count();
// Drive the move counter forward. FullmoveNumber starts at 1
// (engine.ts § initial state). It advances AFTER black completes
// its reply. So 1.a3 a6 → FullmoveNumber becomes 2 after black's
// a6, at which point the lifetime sweep retires the pit.
await sendMove(pageA, 'a2', 'a3');
await pageA.waitForTimeout(150);
await sendMove(pageB, 'a7', 'a6');
await pageA.waitForTimeout(300);
// Pit gone (the sweep retracted it).
await expect(
pageA.locator('[data-square="h1"] [data-marker-kind="pit"]'),
).toHaveCount(0, { timeout: 5000 });
// At least one MORE treasure on h8 vs pre-sweep — this proves
// the sweep fired fireOnMarkerExpireHooks for the expiring pit.
await expect(async () => {
const postCount = await pageA
.locator('[data-square="h8"] [data-marker-kind="treasure"]')
.count();
expect(postCount).toBeGreaterThan(preSweepTreasureCount);
}).toPass({ timeout: 5000 });
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// Test 12 — spawn-marker-pair
// ---------------------------------------------------------------------------
//
// Spawn a portal-end pair at a1 (square 0) ↔ h8 (square 63). After
// apply: TWO portal-end markers visible (one per square), and each
// marker's MarkerLinks fact references the OTHER marker's id.
test('W18/spawn-marker-pair: portal-end pair at a1 ↔ h8 with mutual MarkerLinks', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot('w18:spawn-marker-pair', [
{
kind: 'spawn-marker-pair',
params: {
markerKind: 'portal-end',
squareA: 0, // a1
squareB: 63, // h8
lifetime: { kind: 'permanent' },
},
},
]),
});
await page.waitForTimeout(200);
// Both squares carry a portal-end marker.
await expect(
page.locator('[data-square="a1"] [data-marker-kind="portal-end"]'),
).toHaveCount(1, { timeout: 5000 });
await expect(
page.locator('[data-square="h8"] [data-marker-kind="portal-end"]'),
).toHaveCount(1, { timeout: 5000 });
// Resolve the two marker ids by Position fact (markers don't
// surface their entity id in the DOM the same way pieces do —
// we go through the prediction manager).
const a1Markers = await findEntities(page, [
{ attr: 'EntityKind', value: 'marker' },
{ attr: 'Position', value: 0 },
]);
const h8Markers = await findEntities(page, [
{ attr: 'EntityKind', value: 'marker' },
{ attr: 'Position', value: 63 },
]);
expect(a1Markers.length).toBe(1);
expect(h8Markers.length).toBe(1);
const idA = a1Markers[0]!;
const idH = h8Markers[0]!;
// MarkerLinks must cross-reference. Each side's link list is a
// single-element array pointing at the other's id (per
// spawn-marker-pair.ts § "Cross-link via session.insert").
const linksA = (await readAttr(page, 'MarkerLinks', idA)) as
| unknown[]
| undefined;
const linksH = (await readAttr(page, 'MarkerLinks', idH)) as
| unknown[]
| undefined;
expect(Array.isArray(linksA)).toBe(true);
expect(Array.isArray(linksH)).toBe(true);
expect(linksA).toEqual([idH]);
expect(linksH).toEqual([idA]);
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test 13 — must-class (.fixme)
// ---------------------------------------------------------------------------
//
// `must-class` writes `MoveClassRestriction` on GAME_ENTITY but the
// move-gen consumer that filters generated moves against the
// restriction has NOT landed (must-class.ts § "Move-gen consumer —
// DEFERRED"). Without the consumer, an e2e probe of "the
// restriction prevents non-matching moves" cannot pass — the
// engine accepts any move regardless of the stored restriction.
//
// Authoring the scenario as `.fixme()` keeps it visible in the
// Playwright report so a future wave that lands the consumer can
// flip the gate without re-discovering the descriptor shape. The
// scenario writes a `must-class={class:"capture"}` restriction on
// activation; once the filter lands, white's first move (no
// captures available from the starting position) should be
// REJECTED — the test would then assert an attempted e2-e4 leaves
// the pawn on e2.
test(
'W18/must-class: capture restriction prevents non-capture moves (consumer deferred)',
async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await applyDescriptor(page, {
code: room.code,
descriptor: descriptorOneShot(
'w18:must-class',
[
{
kind: 'must-class',
params: { class: 'capture' },
},
],
['MoveClassRestriction'],
),
});
await page.waitForTimeout(150);
// Pre-consumer: the restriction lands on GAME_ENTITY but the
// move-gen filter doesn't read it yet. Once the filter exists,
// e2-e4 (an advance, not a capture) should be REJECTED.
await drag(page, 'e2', 'e4');
await expect(
page.locator('[data-square="e2"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="e4"] [data-piece="white-pawn"]'),
).toHaveCount(0);
await ctx.close();
},
);