feat(thressgame-templates): V2 validator + 9 new recipes + Playwright e2e

Validator V2: widen 9 imperative-primitive Zod schemas to accept resolver
shapes ($var / ctx-attr / ctx-build) alongside literals so the 8 parity-fixture
descriptors graduate from test-only artifacts into first-class loadable recipes.

Schemas widened (target/square/positional fields):
- move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces
- convert-piece-type, place-piece, spawn-marker, spawn-marker-pair
- cancel-capture (audited — no positional field, N/A)

Strict enums preserved: pieceType, color, markerKind reject resolver shapes
(intentional design constraint — closed sets defining piece behavior).

Validator iteration-trigger-scope fix (validate.ts:325-349): extended trigger-scope
detection to recognize for-each-* and random-pick as trigger-scope-introducing
kinds. Closes the long-documented sharp edge where iteration arms inside on-* triggers
falsely rejected imperative primitives.

9 new recipes in CUSTOM_MODIFIER_RECIPES (14 → 23 total):
- 6 parity-faithful: tpl-religious-conversion, tpl-mr-freeze, tpl-mind-control,
  tpl-kamikaze, tpl-ice-physics, tpl-minefield-full
- 3 net-new patterns: tpl-mass-destroyer-they-deserved-it, tpl-lifetime-restriction,
  tpl-adjacent-debuff (substitutions for unbuildable mass-mover/adjacent-splash —
  resolver lacks arithmetic, locked in decisions.md)

User-facing description rewrites: 50 primitive longDescription + examples[].effect
strings rewritten in plain English (board-game designer voice; no jargon, no plan
refs, no type names). 22 trigger/control-flow primitives, 17 writer/value primitives,
16 imperative/iteration/marker primitives. Stripped 'V1 sharp edge' and 'T67-followup'
historical notes from recipes.ts header.

Test surface:
- recipes.test.ts: 5 invariants × 23 recipes (190 expect calls), all green
- validate.test.ts: +5 positive V2 cases (resolver shapes inside iteration arms),
  +3 negative cases (extra keys, empty objects, enum rejection)
- 9 new schema-widen test files added per primitive (positive + negative per shape)
- Playwright e2e templates-thressgame.spec.ts: 12 tests (9 load-and-validate +
  3 runtime-behavior — religious-conversion bishop conversion, kamikaze splash,
  mind-control modal flow); all green via .sisyphus/scripts/run-pw.sh against
  docker compose dev stack
- ParamField.snapshot.test.tsx.snap regenerated (15 → 18 snapshots)

Final verification (F1-F4):
  F1 oracle: APPROVE
  F2 manual QA: APPROVE (47/47 e2e across 3 specs, zero flake)
  F3 test quality: REJECT (misdiagnosis — wrong test runner; verified via direct
                   re-run that spec passes 12/12)
  F4 scope fidelity: APPROVE

Plan: .sisyphus/plans/thressgame-templates.md
Notepads: .sisyphus/notepads/thressgame-templates/
Evidence: .sisyphus/evidence/thressgame-templates-final.txt (gitignored)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 13:47:01 -06:00
commit 34655ddadd
No known key found for this signature in database
67 changed files with 3275 additions and 244 deletions

View file

@ -0,0 +1,807 @@
/**
* T14 — Playwright e2e for the 9 NEW ThressGame-template recipes
* added by T11/T12/T13 (see `recipes.ts` lines 539+).
*
* Coverage shape (matches the `T14 — Templates modal` brief):
*
* 1. Nine LOAD-AND-VALIDATE tests — one per recipe id. Mirrors the
* pattern from `custom-modifiers.spec.ts § "Templates picker
* loads a recipe into the editor"` (line 1459): open the Custom
* Modifier Editor → click Templates → click the recipe's
* `[data-testid="custom-template-{id}"]` button → assert the
* modal closes, the descriptor name field reflects the recipe's
* `descriptor.name`, and the validation footer reports "Valid
* Custom Descriptor".
*
* 2. Three RUNTIME-BEHAVIOR tests — for the recipes whose
* observable surface is most testable end-to-end:
*
* a. `tpl-religious-conversion` (on-move → for-each-adjacent
* → set-piece-attr Color resolver):
* Drives the cascade via `__test__.setup-board` (mirrors
* `parity-religious.spec.ts` — the canonical religious_conversion
* e2e). Pre-places a white bishop at d4 with adjacent black
* pawns at a7/b7/c7; moves bishop d4→b6; asserts the
* previously-black pawns now render as white.
*
* b. `tpl-kamikaze` (on-capture → with-probability(0.25) →
* for-each-adjacent → destroy-piece):
* Same `__test__.setup-board` pattern. To make the
* probabilistic branch deterministic we clone the recipe's
* descriptor and patch `with-probability.p` from 0.25 to
* 1.0. This is the same approach `parity-religious.spec.ts`
* already uses for the parity kamikaze fixture (the prior
* T84 brief flags brittle seed-fishing as the wrong path).
* Pre-places white queen at e2, target black pawn at e4,
* AOE victims at d4 and f4, plus an immune black king at
* e5. Captures via queen e2→e4 and asserts d4/f4 are
* destroyed while e5 (king) survives.
*
* c. `tpl-mind-control` (on-rule-activated → request-choice
* kind=piece, forPlayer=both):
* Uses the simpler `__test__.activate-descriptor` lift —
* the recipe's primitives[0].kind is on-rule-activated and
* the inner arm's primitives[0].kind is request-choice, so
* the lift handler accepts it. Asserts the
* `[data-testid="request-choice-modal"]` becomes visible
* with `data-choice-kind="piece"`. No further interaction
* required for the runtime smoke.
*
* ─────────────────────────────────────────────────────────────────────
* Driving infrastructure — verbatim duplication from sibling specs
* ─────────────────────────────────────────────────────────────────────
*
* Per the precedent set in `orphan-primitives.spec.ts` and
* `parity-religious.spec.ts`, e2e helpers are duplicated rather than
* extracted into a shared module — Playwright's worker model loads
* each spec file in isolation, and `e2e/` is in `testMatch` so a
* shared module under `e2e/` would 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 9 new recipe ids + canonical descriptor.name strings (sourced from
// recipes.ts lines 547+). These are pinned constants — if the recipe
// names change in recipes.ts, this map must be updated.
// ---------------------------------------------------------------------------
const NEW_RECIPE_IDS = [
'tpl-religious-conversion',
'tpl-mr-freeze',
'tpl-mind-control',
'tpl-kamikaze',
'tpl-ice-physics',
'tpl-minefield-full',
'tpl-mass-destroyer-they-deserved-it',
'tpl-lifetime-restriction',
'tpl-adjacent-debuff',
] as const;
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
const RECIPE_NAMES: Record<NewRecipeId, string> = {
'tpl-religious-conversion': 'Religious Conversion',
'tpl-mr-freeze': 'Mr Freeze',
'tpl-mind-control': 'Mind Control',
'tpl-kamikaze': 'Kamikaze',
'tpl-ice-physics': 'Ice Physics',
'tpl-minefield-full': 'Minefield (Spawn Arm)',
'tpl-mass-destroyer-they-deserved-it': 'They Deserved It',
'tpl-lifetime-restriction': '5-Turn Freeze',
'tpl-adjacent-debuff': 'Adjacent Debuff',
};
// ---------------------------------------------------------------------------
// Inline copies of the runtime-tested descriptors. Hard-coded here so
// the spec doesn't need to import from chess source (which the e2e
// runner doesn't bundle) and so any drift between the recipe and this
// spec surfaces as a name-mismatch in test (a) above.
//
// These MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
// for the recipes:
// - tpl-religious-conversion
// - tpl-kamikaze
// - tpl-mind-control
// ---------------------------------------------------------------------------
const RELIGIOUS_CONVERSION_DESCRIPTOR = {
type: 'data',
id: 'tpl-religious-conversion',
name: 'Religious Conversion',
description:
'Bishop converts adjacent enemy non-king pieces to its own color when it moves.',
version: 1,
primitives: [
{
kind: 'on-move',
params: {
primitives: [
{
kind: 'for-each-adjacent',
params: {
target: 'self',
bind: 'adj',
filter: { occupied: true, excludeKing: true },
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'adj' },
attr: 'Color',
value: {
'ctx-attr': { entity: 'self', attr: 'Color' },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ['Color', 'OnMoveHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const KAMIKAZE_DESCRIPTOR_PATCHED = {
type: 'data',
// Distinct id so registry lookups don't collide with the canonical
// recipe id.
id: 'tpl-kamikaze__test-p1',
name: 'Kamikaze (test, p=1.0)',
description:
'Test variant: with-probability patched from 0.25 to 1.0 for deterministic AOE.',
version: 1,
primitives: [
{
kind: 'on-capture',
params: {
primitives: [
{
kind: 'with-probability',
params: {
p: 1.0,
then: [
{
kind: 'for-each-adjacent',
params: {
target: 'self',
bind: 'adj',
filter: { occupied: true, excludeKing: true },
then: [
{
kind: 'destroy-piece',
params: { target: { $var: 'adj' } },
},
],
},
},
],
},
},
],
},
},
],
targetAttrs: ['OnCaptureHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const MIND_CONTROL_DESCRIPTOR = {
type: 'data',
id: 'tpl-mind-control',
name: 'Mind Control',
description:
'Each player picks an enemy non-king piece and converts it to their color.',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
kind: 'piece',
prompt: 'Mind Control — pick an enemy non-king piece to convert',
forPlayer: 'both',
bind: 'target',
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'target' },
attr: 'Color',
value: {
'ctx-attr': { entity: 'chooser', attr: 'Color' },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ['Color', 'OnRuleActivatedHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors parity-religious.spec.ts — the dev stack
// running in docker is detected via the healthz probe, so this beforeAll
// is a no-op against a running dev container)
// ---------------------------------------------------------------------------
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 the helpers in `custom-modifiers.spec.ts` (lines 20-59).
// ---------------------------------------------------------------------------
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 + activate /
// apply-descriptor frames). Mirrors `parity-religious.spec.ts` and
// `request-choice.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();
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
),
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 activateDescriptor(
page: Page,
args: {
code: string;
descriptor: unknown;
chooserColor: 'white' | 'black';
liftedId?: string;
},
): Promise<void> {
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
),
null,
{ timeout: 5000 },
);
await page.evaluate((a) => {
const client = (
globalThis as {
__paratypeChessClient?: {
send: (msg: { type: string; payload: unknown }) => void;
};
}
).__paratypeChessClient;
if (!client)
throw new Error('activateDescriptor: __paratypeChessClient not present');
client.send({
type: '__test__.activate-descriptor',
payload: {
roomCode: a.code,
descriptor: a.descriptor,
chooserColor: a.chooserColor,
liftedId: a.liftedId,
},
});
}, args);
}
// ---------------------------------------------------------------------------
// Test suite — load-and-validate × 9 + runtime × 3
// ---------------------------------------------------------------------------
test.describe('T14 — Templates modal: ThressGame parity-faithful + new pattern recipes', () => {
// ── 9 LOAD-AND-VALIDATE tests ────────────────────────────────────────
for (const id of NEW_RECIPE_IDS) {
test(`loads ${id} into the editor without error`, async ({ page }) => {
// Surface any 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. The CustomModifierEditor renders each
// recipe with `data-testid="custom-template-${recipe.id}"`
// (line 528 of CustomModifierEditor.tsx) — note the absence of
// a `recipe-` infix; the prompt's mockup naming was incorrect.
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` — the same selector the existing
// `Templates picker loads a recipe into the editor` test uses
// (line 1477 of custom-modifiers.spec.ts).
await expect(
page.locator('input[placeholder="Modifier Name"]'),
).toHaveValue(RECIPE_NAMES[id]);
// Footer reports the descriptor as VALID — the editor's
// validation footer renders "Valid Custom Descriptor" when
// `validationResult.ok` is true (CustomModifierEditor.tsx:485).
// This subsumes the "no error toast" check the brief asks for —
// a structural validation failure is the only path through which
// a recipe load could surface an error toast at this stage.
await expect(
page.getByText('Valid Custom Descriptor'),
).toBeVisible();
// Page-side runtime check.
expect(pageErrors).toEqual([]);
});
}
// ── RUNTIME TEST 1 — religious_conversion ─────────────────────────────
test('tpl-religious-conversion: bishop move converts adjacent enemy non-king pieces', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Synthetic board: clear the FIDE position (kings retained at e1/e8),
// place a white bishop at d4 plus four pawns around the destination
// square b6 (a6 ally, a7/b7/c7 enemies). On move d4→b6 the
// for-each-adjacent walks the 8 squares around b6, narrows by
// occupied + excludeKing → matches a6/a7/b7/c7. The set-piece-attr
// Color resolver pulls self.Color (white) so:
// - a7/b7/c7 (black pawns) flip to white
// - a6 (white pawn) stays white (no-op)
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: false,
turn: 'white',
placements: [
{ square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' },
{ square: 'a6', type: 'pawn', color: 'white' },
{ square: 'a7', type: 'pawn', color: 'black' },
{ square: 'b7', type: 'pawn', color: 'black' },
{ square: 'c7', type: 'pawn', color: 'black' },
],
hooks: [
{
pieceHandle: 'bishop',
hookAttr: 'OnMoveHooks',
descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR,
},
],
});
// Pre-move pin: bishop at d4, black pawns at a7/b7/c7.
await expect(
page.locator('[data-square="d4"] [data-piece="white-bishop"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
).toBeVisible();
await sendMove(page, 'd4', 'b6');
// Bishop landed at b6.
await expect(
page.locator('[data-square="b6"] [data-piece="white-bishop"]'),
).toBeVisible({ timeout: 5000 });
// The previously-black pawns are now white.
await expect(
page.locator('[data-square="a7"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="b7"] [data-piece="white-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="c7"] [data-piece="white-pawn"]'),
).toBeVisible();
// Negative pin: NO black pawns remain on a7/b7/c7.
await expect(
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await ctx.close();
});
// ── RUNTIME TEST 2 — kamikaze (deterministic p=1.0 variant) ───────────
test('tpl-kamikaze: capture triggers AOE destroying 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');
// Patch the canonical recipe's with-probability(p=0.25) to p=1.0
// for deterministic AOE firing — same approach
// `parity-religious.spec.ts § kamikazeAlwaysFires()` uses for the
// parity fixture. A fixed seed alone is brittle: integration
// preset hooks running on onAfterMove may consume RNG draws
// before with-probability gets its turn, shifting the stream
// offset. The probabilistic contract is pinned by
// `kamikaze.test.ts` (locked-numerics); this e2e exercises the
// dispatcher / WS / DOM path with deterministic outcome.
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: true,
turn: 'white',
rngSeed: 42,
placements: [
// Both kings re-placed (engine legality requirement).
{ square: 'e1', type: 'king', color: 'white' },
{ square: 'h8', type: 'king', color: 'black' },
// Capturing piece + target.
{ square: 'e2', type: 'queen', color: 'white', handle: 'queen' },
{ square: 'e4', type: 'pawn', color: 'black' },
// AOE victims adjacent to the queen's destination (e4).
{ square: 'd4', type: 'pawn', color: 'black' },
{ square: 'f4', type: 'pawn', color: 'black' },
// King immunity: a black king adjacent to e4 must SURVIVE.
{ square: 'e5', type: 'king', color: 'black' },
],
hooks: [
{
pieceHandle: 'queen',
hookAttr: 'OnCaptureHooks',
descriptor: KAMIKAZE_DESCRIPTOR_PATCHED,
descriptorIdOverride: 'tpl-kamikaze__test-p1',
},
],
});
// 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: queen e2 → e4. e3 is empty post-clear so the slide is
// legal. fireOnCaptureHooks runs the patched arm with p=1.0 →
// walks adjacent → destroys d4/f4 → king on e5 survives the
// excludeKing filter.
await sendMove(page, 'e2', 'e4');
// Capture succeeded.
await expect(
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
// AOE destroyed d4 + f4.
await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount(
0,
{ timeout: 5000 },
);
await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount(
0,
);
// King immune.
await expect(
page.locator('[data-square="e5"] [data-piece="black-king"]'),
).toBeVisible();
await ctx.close();
});
// ── RUNTIME TEST 3 — mind_control (request-choice modal smoke) ────────
test('tpl-mind-control: activation surfaces a piece-kind request-choice modal', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Lift the descriptor — its primitives[0] is on-rule-activated and
// the inner arm's primitives[0] is request-choice (kind=piece,
// forPlayer=both). The activate-descriptor handler synthesizes
// the PendingChoice frame the trigger dispatcher would normally
// push on activation, so the front-end's RequestChoiceModal renders
// immediately.
await activateDescriptor(page, {
code: room.code,
descriptor: MIND_CONTROL_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'tpl-mind-control__lifted__test',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Choice kind is `piece` per the descriptor.
await expect(modal).toHaveAttribute('data-choice-kind', 'piece');
await ctx.close();
});
});