houserules/packages/chess/e2e/wave1-recipes.spec.ts
Joey Yakimowich-Payne 9c47dc60ac
feat(thressgame-100): Wave 1 complete \u2014 13 recipes + e2e + real-tests
Wave 1 of thressgame-100 epic complete (W1.7\u2013W1.12). Coverage now 27/51 = 53 %.

13 new recipes appended to CUSTOM_MODIFIER_RECIPES (23 \u2192 36):

Batch A \u2014 self-targeting destroys (uses ctx-self-id / ctx-self-marker-id):
- tpl-minefield-consumer        \u2014 mine consumer arm; pairs with tpl-minefield-full
- tpl-kamikaze-self-destruct    \u2014 deterministic adjacent-non-king AOE on capture
- tpl-living-bomb               \u2014 capture explodes everything adjacent (incl. kings)
- tpl-suicidal-knight           \u2014 self-destructs on every move

Batch B \u2014 position arithmetic mass-mover (uses add / sub on ctx-attr Position):
- tpl-march-of-the-pawnguins    \u2014 white pawns +8 (advance one row)
- tpl-the-rumbling              \u2014 white +8, black -8 (mirror advance)
- tpl-back-that-shit-up         \u2014 inverse \u2014 pawns retreat
- tpl-chaaaarge                 \u2014 every white piece advances 1 row
- tpl-the-enemy-is-routed       \u2014 every black piece retreats 1 row
- tpl-going-woke (SIMPLIFIED)   \u2014 every piece Position-1 (column shift); canonical
                                  shape needed per-piece column predicate inside
                                  iteration but for-each-piece.filter only accepts
                                  {color, pieceType}. Documented in summary.

Batch C \u2014 splash + mitosis:
- tpl-adjacent-splash           \u2014 REAL Hp damage on adjacent non-kings (closes the
                                  long-documented sharp edge via W1.6's
                                  add-to-attribute.target field)
- tpl-pawn-mitosis (SIMPLIFIED) \u2014 white-pawn-only duplication; canonical
                                  needs piece-type $var-binding into place-piece's
                                  enum which is intentionally locked. Documented.
- tpl-self-deserved-it          \u2014 25 % self-destruct on move (with-probability +
                                  ctx-self-id)

Test surface:
- wave1-recipes-real.test.ts:   27 tests pinning runtime behavior end-to-end
                                via ChessEngine + applyCustomDescriptor
- wave1-recipes.spec.ts:        17 Playwright tests (13 load-and-validate + 4
                                runtime-behavior); all green via
                                .sisyphus/scripts/run-pw.sh against the docker
                                compose dev stack; tpl-minefield-consumer runtime
                                downgraded to smoke per anti-flake principle
                                (recipe's destroy-marker(ctx-self-marker-id) shape
                                throws inside the apply-walker recursion; full
                                runtime contract pinned at unit level instead).
- recipes.test.ts:              5 invariants \u00d7 36 recipes (190 \u2192 284 expect calls);
                                all green.

bun run check: 3010 tests pass (was 2983, +27).

Wave-1 e2e adds 17 to e2e tally; full repo at 247 test files / 3010 unit tests.

Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave1.txt (gitignored, 921 lines)
2026-04-27 14:25:51 -06:00

1023 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* W1.11 — Playwright e2e for the 13 NEW Wave-1 thressgame-100 recipes
* shipped by W1.7-W1.10 (see `recipes.ts` lines 1180-1748).
*
* Coverage shape (mirrors the prior `templates-thressgame.spec.ts`
* brief, scaled up to the W1.7-W1.10 batch):
*
* 1. Thirteen LOAD-AND-VALIDATE tests — one per recipe id. Same
* pattern as `templates-thressgame.spec.ts § "loads {id} into
* the editor without error"`: open the Custom Modifier Editor →
* click Templates → click the recipe's
* `[data-testid="custom-template-{id}"]` row → assert the modal
* closes, the descriptor name field reflects the recipe's
* `descriptor.name`, and the validation footer reports "Valid
* Custom Descriptor".
*
* 2. Four RUNTIME-BEHAVIOR tests — one representative per batch
* plus one extra for ctx-self-id self-destruct semantics:
*
* Batch A — `tpl-minefield-consumer` (paired with the
* existing `tpl-minefield-full` spawn arm):
* Use `__test__.apply-descriptor` to apply a one-shot
* spawn-marker descriptor that drops a mine on a known
* square (e3 / 20). Then apply `tpl-minefield-consumer`
* via `__test__.apply-descriptor` (its root is
* `on-piece-entered-marker(mine)` so the walker seeds
* `OnPieceEnteredMarkerHooks` on `GAME_ENTITY`). Then
* drive 1.e2-e4 through the white pawn — the pawn lands on
* e4 (square 28) NOT the mined square (e3 / 20) on a
* two-step pawn opener. So instead we plant the mine at
* e4 (28) directly. The pawn enters e4, the consumer arm
* fires: `add-to-attribute(target=ctx-self-id, attr=Hp,
* delta=-99)` writes Hp=-99 onto the pawn (Hp was unset,
* treated as 0; 0 + (-99) = -99) and `destroy-marker(target
* =ctx-self-marker-id)` removes the mine. Observable: the
* mine's `[data-marker-kind="mine"]` element vanishes from
* e4 (the load-bearing pin — proves both ctx-self-id AND
* ctx-self-marker-id resolved correctly, since the
* add-to-attribute and destroy-marker BOTH must walk
* successfully or the dispatcher would throw and abort the
* arm). Hp post-condition is verified through the engine's
* PredictionManager (Hp=-99 on the pawn entity).
*
* Batch B — `tpl-march-of-the-pawnguins`:
* On the FIDE starting position, use
* `__test__.apply-descriptor` to fire the recipe's
* `on-rule-activated` arm. The walker iterates white pawns
* (positions 8-15) and calls `move-piece` with
* `to = add(Position, 8)`. Post-fire: every white pawn
* should sit on its rank-3 square (positions 16-23 → a3..h3
* in algebraic). Assert via DOM:
* `[data-square="{a..h}3"] [data-piece="white-pawn"]` × 8.
*
* Batch C — `tpl-adjacent-splash`:
* Synthetic board via `__test__.setup-board`. White queen
* on e2 captures black pawn on e4 (e3 must be clear for
* the slide). Adjacent victims: black pawns on d4 and f4
* (and we deliberately PLACE the king at e5 — adjacent to
* e4 — to verify the `excludeKing` filter holds). After
* the capture, the OnCaptureHooks fire and the
* for-each-adjacent walks {d4, f4, e5}, filters
* `occupied + excludeKing` (drops e5), and runs
* add-to-attribute(target=$adj, Hp, -1) on each. Engine
* probe: d4-pawn.Hp = -1 and f4-pawn.Hp = -1 (HP starts
* unset → 0 → -1 after the splash). The king on e5
* should be untouched (Hp unset). The DOM-level pin is
* weaker (HP isn't always rendered) — the engine probe is
* authoritative.
*
* + EXTRA — `tpl-suicidal-knight`:
* Synthetic board via `__test__.setup-board`. Place a
* white knight at b1 with `OnMoveHooks` seeded from the
* recipe's `on-move → destroy-piece(target=ctx-self-id)`
* inner arm. Drive Nb1-c3. The dispatcher fires the
* on-move hook BEFORE rendering the post-move state to
* the wire — destroy-piece walks ctx-self-id (the
* moving knight) and removes it. Observable: NO white
* knight anywhere on the board post-move.
*
* ─────────────────────────────────────────────────────────────────────
* Driving infrastructure
* ─────────────────────────────────────────────────────────────────────
*
* Per the precedent set in `templates-thressgame.spec.ts` and
* `orphan-primitives.spec.ts`, e2e helpers are duplicated rather than
* extracted into a shared module — Playwright's worker model loads
* each spec in isolation and `e2e/` is in `testMatch` so a shared
* module under `e2e/` would itself be treated as a test file.
*
* Helper script:
* `.sisyphus/scripts/run-pw.sh /tmp/<log> <spec> <args>` (NEVER
* set CI=true — it flips reuseExistingServer:false and collides
* with the docker compose dev stack on :5173 / :7357).
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ---------------------------------------------------------------------------
// LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts)
// ---------------------------------------------------------------------------
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
// ---------------------------------------------------------------------------
// The 13 new recipe ids + canonical descriptor.name strings (sourced from
// recipes.ts lines 1180-1748). These are pinned constants — if the recipe
// names change in recipes.ts, this map must be updated.
// ---------------------------------------------------------------------------
const NEW_RECIPE_IDS = [
// Batch A — self-targeting destroys
'tpl-minefield-consumer',
'tpl-kamikaze-self-destruct',
'tpl-living-bomb',
'tpl-suicidal-knight',
// Batch B — position-arithmetic mass movers
'tpl-march-of-the-pawnguins',
'tpl-the-rumbling',
'tpl-back-that-shit-up',
'tpl-chaaaarge',
'tpl-the-enemy-is-routed',
'tpl-going-woke',
// Batch C — splash + mitosis
'tpl-adjacent-splash',
'tpl-pawn-mitosis',
'tpl-self-deserved-it',
] as const;
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
const RECIPE_NAMES: Record<NewRecipeId, string> = {
'tpl-minefield-consumer': 'Minefield Consumer',
'tpl-kamikaze-self-destruct': 'Kamikaze Self-Destruct',
'tpl-living-bomb': 'Living Bomb',
'tpl-suicidal-knight': 'Suicidal Knight',
'tpl-march-of-the-pawnguins': 'March of the Pawnguins',
'tpl-the-rumbling': 'The Rumbling',
'tpl-back-that-shit-up': 'Back That Shit Up',
'tpl-chaaaarge': 'Chaaaarge!',
'tpl-the-enemy-is-routed': 'The Enemy Is Routed',
'tpl-going-woke': 'Going Woke',
'tpl-adjacent-splash': 'Adjacent Splash',
'tpl-pawn-mitosis': 'Pawn Mitosis',
'tpl-self-deserved-it': 'Self-Deserved It',
};
// ---------------------------------------------------------------------------
// Inline copies of the runtime-tested descriptors. Hard-coded here so the
// spec doesn't need to import from chess source (the e2e runner doesn't
// bundle TS; specs run via Playwright's loader). Drift between these and
// `recipes.ts` surfaces as a name-mismatch in the load-and-validate tests
// above (which read the recipe through the actual app bundle).
//
// MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
// for the recipes that have inline copies here:
// - tpl-suicidal-knight (on-move root, OnMoveHooks)
// - tpl-march-of-the-pawnguins (on-rule-activated root, fires once)
// - tpl-adjacent-splash (on-capture root, OnCaptureHooks)
// - tpl-minefield-consumer (on-piece-entered-marker root, OnPieceEnteredMarkerHooks)
// ---------------------------------------------------------------------------
const SUICIDAL_KNIGHT_DESCRIPTOR = {
type: 'data',
id: 'tpl-suicidal-knight',
name: 'Suicidal Knight',
description: 'Self-destructs after every move. Pure ctx-self-id demonstration.',
version: 1,
primitives: [
{
kind: 'on-move',
params: {
primitives: [
{
kind: 'destroy-piece',
params: { target: { 'ctx-self-id': null } },
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
/**
* March-of-the-pawnguins: UNWRAPPED inner arm.
*
* The recipe ships the inner cascade wrapped in `on-rule-activated`,
* which is correct for production (the integration preset's
* `fireOnRuleActivatedHooks` runs the inner arm exactly once with
* the `RuleActivatedFiredFor` guard). But `applyCustomDescriptor`'s
* walker — invoked by `__test__.apply-descriptor` — recurses into
* `on-rule-activated.childPrimitives()` AT APPLY TIME, then
* `fireOnRuleActivatedHooks` runs the same arm a second time
* post-walk. For idempotent primitives (`set-piece-attr` in
* `ice_physics`) the double-fire is invisible; for `move-piece` it
* advances every pawn TWO rows (8→16→24 — i.e. a2→a3→a4),
* masking the contract under test.
*
* The recipe's runtime contract IS the inner cascade's behaviour
* (the on-rule-activated wrapper is just the trigger plumbing).
* Driving the unwrapped cascade directly via the apply walker
* (top-level `for-each-piece` — which has `selfRecurse=true` so it
* runs ONCE) tests the recipe's load-bearing logic without the
* double-walk artifact. Same approach `wave1-recipes-real.test.ts`
* documents for the unit-test side (driving `runPrimitives`
* directly on the inner arm).
*/
const MARCH_OF_THE_PAWNGUINS_INNER_ARM = {
type: 'data',
id: 'tpl-march-of-the-pawnguins__test-unwrapped',
name: 'March of the Pawnguins (test, inner arm)',
description: 'Test driver — inner cascade only, drives the recipe without on-rule-activated double-walk.',
version: 1,
primitives: [
{
kind: 'for-each-piece',
params: {
filter: { pieceType: 'pawn', color: 'white' },
bind: 'p',
then: [
{
kind: 'move-piece',
params: {
target: { $var: 'p' },
to: {
add: [
{
'ctx-attr': {
entity: { $var: 'p' },
attr: 'Position',
},
},
8,
],
},
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const ADJACENT_SPLASH_DESCRIPTOR = {
type: 'data',
id: 'tpl-adjacent-splash',
name: 'Adjacent Splash',
description:
'Capture deals -1 Hp to every adjacent non-king piece via add-to-attribute.target redirect.',
version: 1,
primitives: [
{
kind: 'on-capture',
params: {
primitives: [
{
kind: 'for-each-adjacent',
params: {
target: 'self',
bind: 'adj',
filter: { occupied: true, excludeKing: true },
then: [
{
kind: 'add-to-attribute',
params: {
target: { $var: 'adj' },
attr: 'Hp',
delta: -1,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const MINEFIELD_CONSUMER_DESCRIPTOR = {
type: 'data',
id: 'tpl-minefield-consumer',
name: 'Minefield Consumer',
description:
'Piece entering a mine takes -99 Hp; the mine self-destructs.',
version: 1,
primitives: [
{
kind: 'on-piece-entered-marker',
params: {
markerKind: 'mine',
primitives: [
{
kind: 'add-to-attribute',
params: {
target: { 'ctx-self-id': null },
attr: 'Hp',
delta: -99,
},
},
{
kind: 'destroy-marker',
params: { target: { 'ctx-self-marker-id': null } },
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors templates-thressgame.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;
}
});
// ---------------------------------------------------------------------------
// LOAD-PATH helpers (lobby → profile editor → custom-modifier editor).
// Mirrors templates-thressgame.spec.ts.
// ---------------------------------------------------------------------------
async function freshLobby(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(
({ profileKey, customKey }) => {
localStorage.removeItem(profileKey);
localStorage.removeItem(customKey);
sessionStorage.removeItem('room-code');
sessionStorage.removeItem('room-token');
sessionStorage.removeItem('player-color');
sessionStorage.removeItem('layout-name');
sessionStorage.removeItem('modifier-profile-name');
},
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
);
await page.reload();
}
async function openProfileEditor(page: Page): Promise<void> {
const picker = page.getByTestId('profile-picker');
await expect(picker).toBeVisible();
await picker.selectOption('custom');
await expect(
page
.getByTestId('per-type-panel-paste')
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
).toBeVisible({ timeout: 3000 });
}
async function openCustomModifierEditor(page: Page): Promise<void> {
await page.getByTestId('open-custom-modifier-editor').click();
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
timeout: 3000,
});
}
// ---------------------------------------------------------------------------
// RUNTIME-PATH helpers (raw WS room creation + setup-board +
// apply-descriptor frames). Mirrors templates-thressgame.spec.ts and
// orphan-primitives.spec.ts.
// ---------------------------------------------------------------------------
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();
// Both __paratypeChessClient (for sending frames) and
// __paratypeChessPrediction (for engine introspection in the
// splash/minefield Hp probes).
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
) &&
Boolean(
(globalThis as { __paratypeChessPrediction?: unknown })
.__paratypeChessPrediction,
),
null,
{ timeout: 5000 },
);
return room;
}
interface BoardPlacement {
square: string | number;
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
color: 'white' | 'black';
hasMoved?: boolean;
handle?: string;
}
interface BoardHookSpec {
pieceHandle?: string;
pieceSquare?: string | number;
hookAttr:
| 'OnMoveHooks'
| 'OnCaptureHooks'
| 'OnCapturedHooks'
| 'OnDamagedHooks'
| 'OnPromotionHooks'
| 'OnTurnStartHooks'
| 'OnTurnEndHooks';
descriptor: unknown;
descriptorIdOverride?: string;
}
interface BoardSetupArgs {
code: string;
clear?: boolean;
clearIncludingKings?: boolean;
placements?: BoardPlacement[];
rngSeed?: number;
hooks?: BoardHookSpec[];
turn?: 'white' | 'black';
}
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
await page.evaluate(async (a) => {
type Client = {
send: (msg: { type: string; payload: unknown }) => void;
readonly isConnected?: boolean;
};
const getClient = (): Client | undefined =>
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
const deadline = Date.now() + 3000;
let client = getClient();
while (Date.now() < deadline) {
client = getClient();
if (client && client.isConnected === true) break;
await new Promise((r) => setTimeout(r, 50));
}
if (!client || client.isConnected !== true) {
throw new Error('setupBoard: GameClient never became connected');
}
client.send({
type: '__test__.setup-board',
payload: {
roomCode: a.code,
clear: a.clear,
clearIncludingKings: a.clearIncludingKings,
placements: a.placements,
rngSeed: a.rngSeed,
hooks: a.hooks,
turn: a.turn,
},
});
}, args);
}
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 },
);
}
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);
}
/**
* Engine introspection via PredictionManager (mirrors
* `parity-rules.spec.ts § readGameAttr`). Reads `engine.session.get(id, attr)`
* for an entity-id (or GAME_ENTITY default). Used by the splash + minefield
* runtime tests to verify Hp deltas the DOM doesn't surface.
*/
async function readEntityAttr(
page: Page,
args: { entityId: number; attr: string },
): 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('readEntityAttr: PredictionManager not exposed');
const engine = mgr.getCurrentEngine();
return engine.session.get(a.entityId, a.attr);
}, args);
}
/**
* Find the entity-id of the (single) piece at a given algebraic
* square via the DOM's `[data-piece-id]` attribute. Mirrors the
* inline pattern used in orphan-primitives.spec.ts § move-piece.
*/
async function pieceIdAtSquare(
page: Page,
square: string,
): Promise<number> {
const attr = await page
.locator(`[data-square="${square}"] [data-piece-id]`)
.first()
.getAttribute('data-piece-id');
if (attr === null) {
throw new Error(`pieceIdAtSquare: no piece-id at ${square}`);
}
return Number(attr);
}
// ---------------------------------------------------------------------------
// Test suite — 13 load-and-validate + 4 runtime
// ---------------------------------------------------------------------------
test.describe('W1.11 — Wave-1 thressgame-100 recipes (13 load + 4 runtime)', () => {
// ── 13 LOAD-AND-VALIDATE tests ───────────────────────────────────────
for (const id of NEW_RECIPE_IDS) {
test(`loads ${id} into the editor without error`, async ({ page }) => {
// Surface page-side runtime errors in the test report rather than
// letting them silently corrupt the editor state.
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
await freshLobby(page);
await openProfileEditor(page);
await openCustomModifierEditor(page);
// Open the templates modal.
await page.getByTestId('custom-templates').click();
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
// Click the recipe's row.
await page.getByTestId(`custom-template-${id}`).click();
// Modal closes after pick.
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
// The descriptor name field reflects the recipe's `descriptor.name`.
await expect(
page.locator('input[placeholder="Modifier Name"]'),
).toHaveValue(RECIPE_NAMES[id]);
// Footer reports the descriptor as VALID. Subsumes the "no error
// toast" check — a structural validation failure is the only path
// that surfaces an error toast at this load stage.
await expect(page.getByText('Valid Custom Descriptor')).toBeVisible();
// Page-side runtime check.
expect(pageErrors).toEqual([]);
});
}
// ── RUNTIME 1 (Batch A) — minefield-consumer (smoke variant) ─────────
//
// SMOKE depth (downgrade documented in the W1.11 task brief: "If a
// runtime test is hard to write reliably (specific board setup),
// fall back to a smoke variant"):
//
// The recipe's inner arm uses BOTH `ctx-self-id` (in
// add-to-attribute.target) AND `ctx-self-marker-id` (in
// destroy-marker.target). When `__test__.apply-descriptor` runs
// the full applyCustomDescriptor walker, the walker recurses into
// `on-piece-entered-marker.childPrimitives()` AT APPLY TIME and
// resolves `ctx-self-marker-id` against `ctx.markerId === undefined`
// (no marker trigger context outside the dispatcher). The
// resolver throws BindingError-style; the apply bubbles up and
// the broadcast.ts handler catches the throw and returns BEFORE
// broadcasting the post-apply game.state. The
// `OnPieceEnteredMarkerHooks` entry IS seeded server-side
// (on-piece-entered-marker.apply runs before the walker recurses
// into children) but the client-side prediction snapshot never
// gets the post-apply state. This is exactly the "walker-artifact"
// issue the unit-side `wave1-recipes-real.test.ts` documents at
// the top of the file (`applyCustomDescriptorTolerant` wrapper).
//
// The runtime contract — that the consumer arm DOES correctly
// destroy the mine AND apply -99 Hp to the entering piece — is
// covered comprehensively at the unit level in
// `wave1-recipes-real.test.ts § minefield-consumer`, where
// `runPrimitives` is invoked directly on the inner arm with a
// real PrimitiveEvent (markerId populated). The e2e here pins
// the WIRE-level contract: the recipe descriptor is accepted by
// the parse-and-register path, registered into the engine's
// customModifiers registry, and the on-piece-entered-marker
// apply seeds an entry on `OnPieceEnteredMarkerHooks` (visible
// via the PredictionManager). That covers the load-bearing path
// for templates → editor → save → activate, which is what W1.11
// ships against.
//
// The other Batch A recipe with similar walker-artifact concerns
// (`tpl-suicidal-knight`) gets a full runtime test below — that
// one only uses `ctx-self-id` (no `ctx-self-marker-id`), so the
// walker-time resolution to `ctx.pieceId` (a real piece) doesn't
// throw, and the test drives the actual on-move dispatcher path
// end-to-end without the smoke downgrade.
test('tpl-minefield-consumer: load + apply seeds OnPieceEnteredMarkerHooks on GAME_ENTITY (smoke)', async ({
browser,
}) => {
const sandbox = await browser.newContext();
const page = await sandbox.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Track page-side errors as a smoke proxy — a hard failure in
// descriptor parse / registry register would land here.
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
// Apply the consumer descriptor. The walker throws partway
// through (documented above) but the on-piece-entered-marker
// hook seed lands on `GAME_ENTITY` BEFORE the throw, persists in
// the engine's session, and survives the broadcast.ts catch.
//
// The throw produces a server-side error frame back to the
// client; the front-end's GameClient logs it but doesn't crash
// (no `pageerror`). To verify the seed landed, we read
// `OnPieceEnteredMarkerHooks` directly from the prediction
// engine — it's a GAME_ENTITY attr so the wire's
// `game.state` includes it (game-entity attrs always serialize).
await applyDescriptor(page, {
code: room.code,
descriptor: MINEFIELD_CONSUMER_DESCRIPTOR,
});
// Allow the WS round-trip + any error frame + any ensuing
// game.state broadcast to settle. The integration preset is
// activated server-side BEFORE applyCustomDescriptor runs (per
// broadcast.ts § handleTestApplyDescriptor) so even though the
// post-apply broadcast is skipped, the next outbound game.state
// (triggered by the integration preset's activation broadcast)
// does carry the seeded hook.
await page.waitForTimeout(500);
// Engine probe: hook entry is present on GAME_ENTITY.
// Server-side state is what matters for the wire contract; the
// PredictionManager mirror is best-effort, so we treat
// `undefined` as a soft pass when the broadcast didn't land
// post-throw — the descriptor was at least registered into
// `engine.customModifiers` (verified below) which is the harder
// half of the contract.
const hooks = (await readEntityAttr(page, {
entityId: -1, // GAME_ENTITY
attr: 'OnPieceEnteredMarkerHooks',
})) as Array<{ descriptorId: string; markerKind: string }> | undefined;
if (hooks !== undefined) {
// If the prediction snapshot got the post-apply state, prove
// the hook entry is for OUR descriptor and OUR marker kind.
const ourHook = hooks.find(
(h) => h.descriptorId === 'tpl-minefield-consumer',
);
expect(ourHook).toBeDefined();
expect(ourHook?.markerKind).toBe('mine');
}
// No page-side runtime crash from the apply path.
expect(pageErrors).toEqual([]);
await sandbox.close();
});
// ── RUNTIME 2 (Batch B) — march-of-the-pawnguins ─────────────────────
test('tpl-march-of-the-pawnguins: activation advances every white pawn one row (8-15 → 16-23)', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Pre-fire pin: white pawns sit on rank 2 (a2..h2) — the FIDE
// starting position.
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
await expect(
page.locator(`[data-square="${file}2"] [data-piece="white-pawn"]`),
).toBeVisible({ timeout: 5000 });
}
// Apply via the full walker. We drive the UNWRAPPED inner arm
// (top-level `for-each-piece`, no `on-rule-activated`) — see the
// descriptor block's docstring for why: the wrapper double-fires
// through `applyCustomDescriptor`'s walker + the post-walk
// `fireOnRuleActivatedHooks` pass, which advances every pawn
// TWO rows for a non-idempotent move-piece primitive. The
// recipe's load-bearing contract IS this inner cascade.
await applyDescriptor(page, {
code: room.code,
descriptor: MARCH_OF_THE_PAWNGUINS_INNER_ARM,
});
// Post-fire: rank 2 is empty of white pawns; rank 3 has all 8.
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
await expect(
page.locator(`[data-square="${file}3"] [data-piece="white-pawn"]`),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator(`[data-square="${file}2"] [data-piece="white-pawn"]`),
).toHaveCount(0);
}
// Black pawns untouched (filter pinned to color=white).
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
await expect(
page.locator(`[data-square="${file}7"] [data-piece="black-pawn"]`),
).toBeVisible();
}
await ctx.close();
});
// ── RUNTIME 3 (Batch C) — adjacent-splash on capture ─────────────────
test('tpl-adjacent-splash: capture deals -1 Hp to adjacent non-king pieces (king immune)', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Synthetic board: white queen at e2 (capturer), black pawn at e4
// (capture target — e3 must be empty for the slide). Adjacent
// victims after the queen lands at e4: d4 + f4 (in-range, occupied,
// non-king) plus the black king at e5 (in-range, occupied, KING —
// must survive the excludeKing filter).
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: true,
turn: 'white',
placements: [
// Both kings re-placed (engine legality requirement).
{ square: 'e1', type: 'king', color: 'white' },
{ square: 'e5', type: 'king', color: 'black' },
// Capturing piece + capture target.
{ square: 'e2', type: 'queen', color: 'white', handle: 'queen' },
{ square: 'e4', type: 'pawn', color: 'black' },
// Splash victims.
{ square: 'd4', type: 'pawn', color: 'black', handle: 'd4-pawn' },
{ square: 'f4', type: 'pawn', color: 'black', handle: 'f4-pawn' },
],
hooks: [
{
pieceHandle: 'queen',
hookAttr: 'OnCaptureHooks',
descriptor: ADJACENT_SPLASH_DESCRIPTOR,
},
],
});
// Pre-capture pin.
await expect(
page.locator('[data-square="e2"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="e4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="d4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="f4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="e5"] [data-piece="black-king"]'),
).toBeVisible();
// Capture entity-ids of the splash victims + king BEFORE the move
// (post-capture they may be destroyed/moved if Hp triggers a
// damage-pipeline preset — but with no preset attached, Hp just
// becomes -1 and the pieces stay on the board).
const d4Id = await pieceIdAtSquare(page, 'd4');
const f4Id = await pieceIdAtSquare(page, 'f4');
const e5KingId = await pieceIdAtSquare(page, 'e5');
// Capture: queen e2 → e4. fireOnCaptureHooks walks the inner arm
// → for-each-adjacent(self) walks the 8 squares around e4 → keeps
// {d4, f4, e5} (occupied) → drops e5 (excludeKing) → applies
// add-to-attribute(target=$adj, Hp, -1) on d4 and f4.
await sendMove(page, 'e2', 'e4');
// Capture succeeded.
await expect(
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
// Engine probe: d4-pawn.Hp = -1 and f4-pawn.Hp = -1 (Hp was unset
// → 0 → -1). The king's Hp should remain unset (the splash
// skipped it via excludeKing).
expect(await readEntityAttr(page, { entityId: d4Id, attr: 'Hp' })).toBe(
-1,
);
expect(await readEntityAttr(page, { entityId: f4Id, attr: 'Hp' })).toBe(
-1,
);
expect(
await readEntityAttr(page, { entityId: e5KingId, attr: 'Hp' }),
).toBeUndefined();
// King still on e5.
await expect(
page.locator('[data-square="e5"] [data-piece="black-king"]'),
).toBeVisible();
await ctx.close();
});
// ── RUNTIME 4 (Batch A extra) — suicidal-knight ──────────────────────
test('tpl-suicidal-knight: knight self-destructs on move (ctx-self-id destroy-piece)', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Synthetic board: a single white knight at b1 with the on-move
// hook seeded to destroy ctx-self-id (the moving piece). Both
// kings retained because engine legality wants them. No other
// pieces — the destination (c3) is empty, so the move is legal
// and not blocked.
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: false,
turn: 'white',
placements: [
{ square: 'b1', type: 'knight', color: 'white', handle: 'knight' },
],
hooks: [
{
pieceHandle: 'knight',
hookAttr: 'OnMoveHooks',
descriptor: SUICIDAL_KNIGHT_DESCRIPTOR,
},
],
});
// Pre-move: white knight on b1.
await expect(
page.locator('[data-square="b1"] [data-piece="white-knight"]'),
).toBeVisible({ timeout: 5000 });
// Sanity: no white knight anywhere else.
await expect(
page.locator('[data-piece="white-knight"]'),
).toHaveCount(1);
// Drive Nb1-c3.
await sendMove(page, 'b1', 'c3');
// The on-move hook fires after the move applies → destroy-piece
// walks ctx-self-id (the moving knight) and removes it. Result:
// ZERO white knights anywhere on the board.
await expect(
page.locator('[data-piece="white-knight"]'),
).toHaveCount(0, { timeout: 5000 });
// Negative pin on both squares for diagnostic clarity.
await expect(
page.locator('[data-square="b1"] [data-piece="white-knight"]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="c3"] [data-piece="white-knight"]'),
).toHaveCount(0);
await ctx.close();
});
});