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.
1504 lines
52 KiB
TypeScript
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();
|
|
},
|
|
);
|