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