feat(thressgame-100): Wave 3 \u2014 player-choice patterns + 8 recipes + e2e

Wave 3 of thressgame-100 epic complete. 85 % MILESTONE HIT.

Coverage: 37/51 \u2192 44-45/51 = 86-88 % (depending on overlap accounting; both
clear the 85 % gate).

W3.0 AUDIT \u2014 request-choice capabilities verified:
- 6 supported kinds (Zod schema): rps | piece | square | column | row | coin-flip
- (NOT yes-no, NOT number \u2014 plan brief was inaccurate; updated learnings.md)
- All 6 wired through schema \u2192 apply() \u2192 RequestChoiceModal.tsx \u2192 unit tests
- No gaps to fix.

8 NEW RECIPES (W3.2\u2013W3.5):

Batch G \u2014 choice-driven spawn (kind: square):
- tpl-bottomless-pit            \u2014 pick a square; permanent pit there
- tpl-call-down-lightning       \u2014 pick a square; death-square spawns there
                                  (SIMPLIFIED: lethality moved to consumer arm
                                  \u2014 destroy-piece needs entity-id not square)
- tpl-portal-storm              \u2014 pick 2 squares; spawn a linked portal pair

Batch H \u2014 choice-driven swap:
- tpl-anti-camping-choice       \u2014 pick victim + swapper; swap them
                                  (SIMPLIFIED: random swap not expressible \u2014
                                  with-probability gates per iteration not
                                  picks one)
- tpl-two-kids-trenchcoat       \u2014 sacrifice 2 pieces; bishop@e4
                                  (SIMPLIFIED: place-piece pieceType/color/square
                                  hardcoded \u2014 strict literal enums)

Batch I \u2014 choice-driven self-modification:
- tpl-blood-sacrifice           \u2014 sacrifice one piece; +5 Hp to another
                                  (uses W1.6's add-to-attribute.target redirect)
- tpl-summoning-ritual-light    \u2014 sacrifice + 50/50 knight-or-bishop@e4
                                  (SIMPLIFIED: hardcoded type/color/square +
                                  no resource cost \u2014 W5 territory)

Batch J \u2014 sophie's-choice:
- tpl-sophies-choice            \u2014 both players pick own piece; both die
                                  (forPlayer:'both' verified working as in
                                  tpl-mr-freeze)

FOUR DOCUMENTED SIMPLIFICATIONS (full rationale in evidence file Section 4):
- tpl-call-down-lightning: lethality dropped (no Position-comparison primitive)
- tpl-anti-camping-choice: random-swap dropped (with-probability per-iteration
  semantics)
- tpl-two-kids-trenchcoat: place-piece hardcoded (strict literal enums)
- tpl-summoning-ritual-light: hardcoded place + RNG branch (no resource yet)

KEY RUNTIME DISCOVERY (documented in learnings.md):
- runPrimitives catches SuspendedExecution INTERNALLY and returns; does NOT
  re-throw. Test pattern is to read PendingChoices off GAME_ENTITY after the
  call rather than asserting throw.
- For multi-step request-choice e2e: poll on data-choice-id flip rather than
  visibility (modal close+reopen is sub-frame). Canonical idiom for future waves.

TEST SURFACE:
- wave3-recipes-real.test.ts:               26 unit tests (60 expect calls)
- recipes.test.ts:                           5 \u00d7 54 = 444 expect calls
- wave3-choices.spec.ts (Playwright):       11 e2e tests (8 load + 3 runtime,
                                            including FIRST multi-step request-choice
                                            runtime test \u2014 portal-storm 2-step
                                            square picker with poll-on-data-choice-id
                                            assertion idiom)

bun run check: 3081 tests pass (was 3055, +26). 0 regressions.
e2e: 11/11 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.

ANTI-CAMPING OVERLAP NOTE:
Both tpl-anti-camping (W2 dormant variant) and tpl-anti-camping-choice (W3
choice variant) map to the single upstream ThressGame rule `anti_camping`.
This is intentional \u2014 two different mechanical interpretations of the same
rule name. Documented in evidence file with dual coverage accounting:
  - 45/51 = 88 % (recipe-vs-denominator convention, matches plan target)
  - 44/51 = 86 % (strict unique-rule convention)
Both clear the 85 % milestone.

Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave3.txt (gitignored, 832 lines)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 15:43:26 -06:00
commit 73df9f4e53
No known key found for this signature in database
6 changed files with 1664 additions and 3 deletions

View file

@ -0,0 +1,631 @@
/**
* W3.6 — Playwright e2e for the 8 NEW Wave-3 thressgame-100 recipes
* shipped by W3.0-W3.5 (see `recipes.ts` lines 2256-2645).
*
* Coverage shape (mirrors `wave2-countdowns.spec.ts`, scaled to the
* Wave-3 batch):
*
* 1. Eight LOAD-AND-VALIDATE tests — one per recipe id. Same
* pattern as `wave2-countdowns.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. Three RUNTIME-BEHAVIOR tests — request-choice modal flow:
*
* Batch G — `tpl-bottomless-pit` (kind:square modal):
* Activate via `__test__.activate-descriptor`. Assert the
* RequestChoiceModal renders with `data-choice-kind="square"`
* (the first square recipe shipped — exercises the
* ParamSquarePicker UI path that was previously
* schema-supported but unused by any recipe).
*
* Batch G — `tpl-portal-storm` (2-step nested square chooser):
* Activate. Assert modal #1 (square) visible. Click a
* square (e4 / index 28) → submitChoiceAndResume drives
* the continuation, which immediately fires the inner
* request-choice → modal #2 (square) opens. This is the
* first multi-step request-choice runtime test in the
* codebase — proves the LIFO stack push/pop on resume.
*
* Batch J — `tpl-sophies-choice` (kind:piece, forPlayer:"both"):
* Activate with chooserColor:"white". Per the dispatcher's
* forPlayer:"both" semantics, the descriptor fires the
* chooser's-side request-choice first; both players are
* eventually prompted but a single-page test only sees
* the white-side modal. Assert the modal is visible with
* data-choice-kind="piece" and data-for-player="both".
*
* ─────────────────────────────────────────────────────────────────────
* Driving infrastructure
* ─────────────────────────────────────────────────────────────────────
*
* Per the precedent set in `wave1-recipes.spec.ts`,
* `wave2-countdowns.spec.ts`, and `templates-thressgame.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 8 new recipe ids + canonical descriptor.name strings (sourced from
// recipes.ts lines 2256-2645). These are pinned constants — if the recipe
// names change in recipes.ts, this map must be updated.
// ---------------------------------------------------------------------------
const NEW_RECIPE_IDS = [
// Batch G — choice-driven spawn (3, kind:"square")
'tpl-bottomless-pit',
'tpl-call-down-lightning',
'tpl-portal-storm',
// Batch H — choice-driven swap/move (2, kind:"piece")
'tpl-anti-camping-choice',
'tpl-two-kids-trenchcoat',
// Batch I — choice-driven self-modification (2, kind:"piece")
'tpl-blood-sacrifice',
'tpl-summoning-ritual-light',
// Batch J — sophie's-choice (1, forPlayer:"both")
'tpl-sophies-choice',
] as const;
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
// `descriptor.name` (the second arg to `descriptorForRecipe`) — the
// editor's Modifier Name field reflects this on template load.
const RECIPE_NAMES: Record<NewRecipeId, string> = {
'tpl-bottomless-pit': 'Bottomless Pit',
'tpl-call-down-lightning': 'Call Down Lightning',
'tpl-portal-storm': 'Portal Storm',
'tpl-anti-camping-choice': 'Anti-Camping Choice',
'tpl-two-kids-trenchcoat': 'Two Kids Trenchcoat',
'tpl-blood-sacrifice': 'Blood Sacrifice',
'tpl-summoning-ritual-light': 'Summoning Ritual (light)',
'tpl-sophies-choice': "Sophie's Choice",
};
// ---------------------------------------------------------------------------
// 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-bottomless-pit (on-rule-activated → request-choice square)
// - tpl-portal-storm (on-rule-activated → 2-step request-choice square)
// - tpl-sophies-choice (on-rule-activated → request-choice piece, forPlayer:"both")
// ---------------------------------------------------------------------------
const BOTTOMLESS_PIT_DESCRIPTOR = {
type: 'data',
id: 'tpl-bottomless-pit',
name: 'Bottomless Pit',
description:
'Chooser picks a square; a permanent pit appears there. Pure request-choice(square) → spawn-marker chain.',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
kind: 'square',
prompt:
'Bottomless Pit — pick a square to drop a permanent pit',
forPlayer: 'white',
bind: 'sq',
then: [
{
kind: 'spawn-marker',
params: {
markerKind: 'pit',
square: { $var: 'sq' },
lifetime: { kind: 'permanent' },
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const PORTAL_STORM_DESCRIPTOR = {
type: 'data',
id: 'tpl-portal-storm',
name: 'Portal Storm',
description:
'Chooser picks 2 squares; a linked portal pair spawns. Demonstrates 2-step request-choice(square) → spawn-marker-pair.',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
kind: 'square',
prompt: 'Portal Storm — pick the FIRST portal endpoint',
forPlayer: 'white',
bind: 'sq1',
then: [
{
kind: 'request-choice',
params: {
kind: 'square',
prompt: 'Portal Storm — pick the SECOND portal endpoint',
forPlayer: 'white',
bind: 'sq2',
then: [
{
kind: 'spawn-marker-pair',
params: {
markerKind: 'portal-end',
squareA: { $var: 'sq1' },
squareB: { $var: 'sq2' },
lifetime: { kind: 'permanent' },
},
},
],
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const SOPHIES_CHOICE_DESCRIPTOR = {
type: 'data',
id: 'tpl-sophies-choice',
name: "Sophie's Choice",
description:
'Each player picks one of their own pieces; that piece is destroyed. Both-player chooser with destroy-piece consequence.',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
kind: 'piece',
prompt:
"Sophie's Choice — pick one of your own pieces; it will be destroyed",
forPlayer: 'both',
bind: 'doomed',
then: [
{
kind: 'destroy-piece',
params: { target: { $var: 'doomed' } },
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors wave2-countdowns.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 wave2-countdowns.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 + activate-descriptor).
// Mirrors wave2-countdowns.spec.ts and choice-kinds.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;
}
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 — 8 load-and-validate + 3 runtime
// ---------------------------------------------------------------------------
test.describe('W3.6 — Wave-3 thressgame-100 recipes (8 load + 3 runtime)', () => {
// ── 8 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 G) — tpl-bottomless-pit (kind:square modal) ─────
//
// First runtime test of the kind:"square" request-choice path in the
// wave-N suites. The recipe's descriptor is `on-rule-activated →
// request-choice(square)`; the activate-descriptor handler lifts the
// inner arm so primitives[0] becomes the request-choice and pushes
// a PendingChoice frame on activation. RequestChoiceModal renders
// synchronously with data-choice-kind="square" — proves the
// ParamSquarePicker UI path the Wave-3 batch unblocks.
test('tpl-bottomless-pit: activation surfaces a square-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');
await activateDescriptor(page, {
code: room.code,
descriptor: BOTTOMLESS_PIT_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'tpl-bottomless-pit__lifted__test',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Choice kind is `square` per the descriptor.
await expect(modal).toHaveAttribute('data-choice-kind', 'square');
// ParamSquarePicker renders a 64-square grid.
await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount(
64,
);
await ctx.close();
});
// ── RUNTIME 2 (Batch G) — tpl-portal-storm (2-step nested square) ────
//
// The descriptor's structure is:
// on-rule-activated → request-choice(square, "sq1")
// → request-choice(square, "sq2")
// → spawn-marker-pair
//
// First multi-step request-choice runtime test in the codebase. The
// activate-descriptor handler pushes the OUTER request-choice frame
// on activation. Resolving it (clicking a square in modal #1) drives
// submitChoiceAndResume; the continuation immediately fires the INNER
// request-choice → modal #2 opens with the same data-choice-kind but
// a NEW choice-id. Proves the LIFO push/pop semantics on the engine's
// PendingChoices stack.
test('tpl-portal-storm: 2-step nested square chooser — modal#1 → click → modal#2 opens', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await activateDescriptor(page, {
code: room.code,
descriptor: PORTAL_STORM_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'tpl-portal-storm__lifted__test',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
// Modal #1 — picks the first portal endpoint. The choice-id is
// assigned by the engine; capture it so we can assert modal #2
// is a DIFFERENT choice (not the same modal still open).
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal).toHaveAttribute('data-choice-kind', 'square');
const firstChoiceId = await modal.getAttribute('data-choice-id');
expect(firstChoiceId).toBeTruthy();
// Click e4 (LERF index 28). ParamSquarePicker tags each button
// with `aria-label="Square <sq>"`.
await modal.locator('button[aria-label="Square 28"]').click();
// Modal #2 opens. Same data-testid, but the choice-id MUST be
// different — that's the proof a fresh PendingChoice frame was
// pushed on resume. We poll on the data-choice-id attribute
// changing rather than asserting !visible-then-visible (the
// close+reopen race could be sub-frame; the choice-id flip is
// the durable signal).
await expect
.poll(
async () => modal.getAttribute('data-choice-id'),
{ timeout: 5000 },
)
.not.toBe(firstChoiceId);
// Confirm the second modal is also a square-kind chooser (the
// inner request-choice in the descriptor).
await expect(modal).toHaveAttribute('data-choice-kind', 'square');
// And it should still render the 64-button grid.
await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount(
64,
);
await ctx.close();
});
// ── RUNTIME 3 (Batch J) — tpl-sophies-choice (forPlayer:"both") ──────
//
// forPlayer:"both" semantics: the descriptor fires sequentially for
// each player. The chooser-color player goes first; the modal that
// renders on the chooser's page (white in this test) is the white
// player's prompt. The modal's data-for-player attribute carries
// the descriptor's forPlayer literal ("both"), NOT the resolved
// current-pick player — that's how the front-end labels who the
// chooser is in a many-player descriptor.
//
// We don't drive the resolve step (would need a second page logged
// in as black to take the second prompt). The smoke depth here is
// sufficient: the modal opens correctly, indicating the dispatcher
// recognized forPlayer:"both" and routed the first push to the
// chooser color (white).
test("tpl-sophies-choice: activation surfaces a piece-kind modal for chooser (forPlayer='both')", async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
await activateDescriptor(page, {
code: room.code,
descriptor: SOPHIES_CHOICE_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'tpl-sophies-choice__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');
// The descriptor's forPlayer is "both"; the modal carries the
// literal through to data-for-player (RequestChoiceModal.tsx:59).
await expect(modal).toHaveAttribute('data-for-player', 'both');
await ctx.close();
});
});

View file

@ -2238,4 +2238,409 @@ export const CUSTOM_MODIFIER_RECIPES: readonly CustomModifierRecipe[] = [
],
),
},
// ── W3.2-W3.5 — Wave-3 thressgame-100 recipes (player-choice patterns) ─
// Eight recipes exercising the request-choice primitive across the
// square / piece / coin-flip kinds. Each ships with a runtime test in
// `wave3-recipes-real.test.ts`. The W3 audit confirmed schema kinds
// = {rps, piece, square, column, row, coin-flip} — wired in
// request-choice.ts (apply() is discriminator-free, builds a
// PendingChoice frame), rendered in RequestChoiceModal.tsx (all 6
// kinds), and exercised at the unit level in request-choice.test.ts.
// The plan's mention of `yes-no` and `number` kinds was inaccurate —
// those are NOT in the schema and were never wired. The 8 W3 recipes
// use only `square` (4) and `piece` (4) kinds; coin-flip / rps stay
// unused for now (the `tpl-coin-flip-restriction` recipe already
// demonstrates coin-driven control flow via with-probability, which
// is the deterministic-replay-friendly canonical pattern).
//
// Batch G — choice-driven spawn (3 recipes, kind: "square")
{
id: "tpl-bottomless-pit",
title: "Bottomless Pit (chooser picks a square; permanent pit there)",
summary:
"First shipped recipe to exercise request-choice kind:\"square\". On activation, the chooser picks any square; a permanent pit marker spawns there. The picked square integer flows directly into spawn-marker.square via the {$var: \"sq\"} resolver — clean composition, no simplification needed.",
descriptor: descriptorForRecipe(
"tpl-bottomless-pit",
"Bottomless Pit",
"Chooser picks a square; a permanent pit appears there. Pure request-choice(square) → spawn-marker chain.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "square",
prompt: "Bottomless Pit — pick a square to drop a permanent pit",
forPlayer: "white",
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "pit",
square: { $var: "sq" },
lifetime: { kind: "permanent" },
},
},
],
},
},
],
},
},
],
),
},
{
id: "tpl-call-down-lightning",
title: "Call Down Lightning (chooser picks a square; death-square spawns there)",
summary:
"SIMPLIFIED from the original 'destroy whatever's on the chosen square + spawn death-square' shape. The plan called for a conditional-on-occupancy + destroy-piece arm, but destroy-piece.target is numberOrResolver (entity ids only — no Position-equality lookup primitive exists, and ConditionSpec.value is locked to literals so no resolver-side comparison either). Pragmatic shape: drop a death-square marker on the chosen square. Anything that lands on a death-square triggers the host-preset's death-square consumer arm — the lethality moves to the consumer side. Demonstrates request-choice(square) targeting a different markerKind than tpl-bottomless-pit.",
descriptor: descriptorForRecipe(
"tpl-call-down-lightning",
"Call Down Lightning",
"Chooser picks a square; a permanent death-square marker spawns there. Lethality is consumer-side.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "square",
prompt: "Call Down Lightning — pick a square to electrify",
forPlayer: "white",
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "death-square",
square: { $var: "sq" },
lifetime: { kind: "permanent" },
},
},
],
},
},
],
},
},
],
),
},
{
id: "tpl-portal-storm",
title: "Portal Storm (chooser picks 2 squares; linked portal pair spawns)",
summary:
"Two-stage request-choice(square) feeding spawn-marker-pair. Both picked squares flow through {$var} into squareA/squareB; the pair shares lifetime: permanent and markerKind: portal-end, with mutual MarkerLinks set by spawn-marker-pair.apply(). Same-square pairs are permitted by spawn-marker-pair (both ends self-link to the OTHER entity, not to themselves) — player discipline determines whether they pick distinct squares.",
descriptor: descriptorForRecipe(
"tpl-portal-storm",
"Portal Storm",
"Chooser picks 2 squares; a linked portal pair spawns. Demonstrates 2-step request-choice(square) → spawn-marker-pair.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "square",
prompt: "Portal Storm — pick the FIRST portal endpoint",
forPlayer: "white",
bind: "sq1",
then: [
{
kind: "request-choice",
params: {
kind: "square",
prompt: "Portal Storm — pick the SECOND portal endpoint",
forPlayer: "white",
bind: "sq2",
then: [
{
kind: "spawn-marker-pair",
params: {
markerKind: "portal-end",
squareA: { $var: "sq1" },
squareB: { $var: "sq2" },
lifetime: { kind: "permanent" },
},
},
],
},
},
],
},
},
],
},
},
],
),
},
// Batch H — choice-driven swap/move (2 recipes; mind-control-full SKIPPED — already shipped as tpl-mind-control)
{
id: "tpl-anti-camping-choice",
title: "Anti-Camping Choice (chooser picks 2 pieces; they swap)",
summary:
"SIMPLIFIED from the original 'chooser picks an opponent piece, randomly swap with one of own pieces' shape. with-probability gates each iteration's swap (it doesn't pick ONE iteration target uniformly), so the random-pick semantic isn't expressible without a dedicated random-pick-piece primitive. Pragmatic fallback mirroring tpl-corporate-ladder: chooser picks both pieces explicitly, then swap. Differs from tpl-corporate-ladder by intent label (anti-camping vs free swap) — same skeleton, different summary framing for the recipe library.",
descriptor: descriptorForRecipe(
"tpl-anti-camping-choice",
"Anti-Camping Choice",
"Chooser picks an enemy piece + one of their own; the two swap. Anti-camping framed as a chooser-driven displacement.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Anti-Camping — pick an enemy piece to displace",
forPlayer: "white",
bind: "victim",
then: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Anti-Camping — pick one of your own pieces to swap in",
forPlayer: "white",
bind: "swapper",
then: [
{
kind: "swap-pieces",
params: {
a: { $var: "victim" },
b: { $var: "swapper" },
},
},
],
},
},
],
},
},
],
},
},
],
),
},
{
id: "tpl-two-kids-trenchcoat",
title: "Two Kids Trenchcoat (sacrifice 2 pieces; bishop appears at e4)",
summary:
"SIMPLIFIED — place-piece's pieceType/color are strict literal enums (no resolver shapes, see place-piece.ts:71-73), and place-piece.square is numberOrResolver but cannot derive its value from the sacrificed pieces' positions without a position-comparison primitive that doesn't exist. Hardcoded shape: chooser picks 2 pieces (any), both are destroyed, a fresh white bishop appears at square 28 (e4). Demonstrates the multi-step request-choice + sacrifice + place-piece chain.",
descriptor: descriptorForRecipe(
"tpl-two-kids-trenchcoat",
"Two Kids Trenchcoat",
"Sacrifice 2 chosen pieces; a fresh white bishop spawns at e4. Type/color/square hardcoded per place-piece's strict-enum schema.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Two Kids Trenchcoat — pick the FIRST piece to sacrifice",
forPlayer: "white",
bind: "p1",
then: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Two Kids Trenchcoat — pick the SECOND piece to sacrifice",
forPlayer: "white",
bind: "p2",
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p1" } },
},
{
kind: "destroy-piece",
params: { target: { $var: "p2" } },
},
{
kind: "place-piece",
params: {
pieceType: "bishop",
color: "white",
square: 28,
},
},
],
},
},
],
},
},
],
},
},
],
),
},
// Batch I — choice-driven self-modification (2 recipes)
{
id: "tpl-blood-sacrifice",
title: "Blood Sacrifice (kill one piece; +5 Hp to another)",
summary:
"Chooser picks a piece to destroy + a piece to bless. The sacrifice fires destroy-piece(target=$sacrifice); the blessing fires add-to-attribute(target=$blessed, attr=Hp, delta=+5). Showcases the W1.6 add-to-attribute.target redirect (target is set explicitly via $var rather than defaulting to ctx.pieceId — the carrier doesn't have to be one of the sacrifice pieces).",
descriptor: descriptorForRecipe(
"tpl-blood-sacrifice",
"Blood Sacrifice",
"Sacrifice one piece; another gains +5 Hp. Two-step chooser + destroy-piece + add-to-attribute(target redirect).",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Blood Sacrifice — pick a piece to sacrifice",
forPlayer: "white",
bind: "sacrifice",
then: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Blood Sacrifice — pick a piece to bless (+5 Hp)",
forPlayer: "white",
bind: "blessed",
then: [
{
kind: "destroy-piece",
params: { target: { $var: "sacrifice" } },
},
{
kind: "add-to-attribute",
params: {
target: { $var: "blessed" },
attr: "Hp",
delta: 5,
},
},
],
},
},
],
},
},
],
},
},
],
),
},
{
id: "tpl-summoning-ritual-light",
title: "Summoning Ritual (light) — sacrifice 1 piece; random knight or bishop spawns",
summary:
"SIMPLIFICATION: no resource-cost primitive exists (deferred to W5), and place-piece's pieceType/color are strict literal enums. Hardcoded shape: chooser picks one piece to sacrifice; with-probability(0.5) decides whether a knight or bishop spawns, both at e4 (square 28) for white. Demonstrates request-choice(piece) + sacrifice + with-probability + place-piece — the canonical RNG-gated summoning chain.",
descriptor: descriptorForRecipe(
"tpl-summoning-ritual-light",
"Summoning Ritual (light)",
"Sacrifice a piece; flip an internal coin to spawn a white knight (heads) or bishop (tails) at e4.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Summoning Ritual — pick a piece to sacrifice",
forPlayer: "white",
bind: "sacrifice",
then: [
{
kind: "destroy-piece",
params: { target: { $var: "sacrifice" } },
},
{
kind: "with-probability",
params: {
p: 0.5,
then: [
{
kind: "place-piece",
params: {
pieceType: "knight",
color: "white",
square: 28,
},
},
],
else: [
{
kind: "place-piece",
params: {
pieceType: "bishop",
color: "white",
square: 28,
},
},
],
},
},
],
},
},
],
},
},
],
),
},
// Batch J — sophie's-choice variant (1 recipe)
{
id: "tpl-sophies-choice",
title: "Sophie's Choice (each player picks one of their own pieces; both die)",
summary:
"Both-player request-choice(piece) with forPlayer:\"both\" (verified supported by tpl-mr-freeze + tpl-mind-control). The descriptor fires once per player — each picks a piece and that piece is destroyed. Player-discipline restricts the pick to one's own piece (the request-choice schema doesn't filter by ownership; same gap as tpl-drafted-for-battle).",
descriptor: descriptorForRecipe(
"tpl-sophies-choice",
"Sophie's Choice",
"Each player picks one of their own pieces; that piece is destroyed. Both-player chooser with destroy-piece consequence.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt: "Sophie's Choice — pick one of your own pieces; it will be destroyed",
forPlayer: "both",
bind: "doomed",
then: [
{
kind: "destroy-piece",
params: { target: { $var: "doomed" } },
},
],
},
},
],
},
},
],
),
},
];

View file

@ -615,7 +615,11 @@ describe("Wave-2 recipes — registry presence smoke", () => {
}
});
it("recipe count is exactly 46 (36 W1 + 10 W2)", () => {
expect(CUSTOM_MODIFIER_RECIPES.length).toBe(46);
it("recipe count is at least 46 (36 W1 + 10 W2; later waves append)", () => {
// Lower bound, not exact: subsequent waves (W3+) append more
// recipes. Pinning a strict count here would force every later
// wave to edit this file. The W2_IDS presence loop above is the
// canonical W2 invariant — count is informational.
expect(CUSTOM_MODIFIER_RECIPES.length).toBeGreaterThanOrEqual(46);
});
});

View file

@ -0,0 +1,561 @@
/**
* W3.2-W3.5 — End-to-end runtime tests for the 8 Wave-3
* thressgame-100 recipes added to `recipes.ts`. Each describe block
* pulls the recipe by id and exercises it through one of three
* patterns:
*
* 1. **Suspend-and-snapshot** — drive the inner `request-choice`
* arm directly via `runPrimitives`. The primitive throws
* `SuspendedExecution` (its documented contract — see
* `request-choice.ts` § "Why a thrown exception"). We catch the
* throw and assert that exactly one `PendingChoice` frame was
* pushed onto `GAME_ENTITY.PendingChoices` with the right
* kind / forPlayer / bind. The continuation `then` is captured
* in the descriptor tree at `triggerPath` and would be picked
* up by `submit-choice` (T46) on player resolution.
*
* 2. **Structural shape** — assert the descriptor tree matches the
* expected request-choice → ... → imperative chain. Mirrors the
* pattern in `wave2-recipes-real.test.ts` for the chooser
* recipes. Pinpoints schema drift even when the runtime test
* below is gated on suspension.
*
* 3. **applyCustomDescriptor smoke (tolerant)** — every Wave-3
* recipe is a single top-level `on-rule-activated` whose inner
* arm starts with `request-choice`. The apply walker recurses
* into trigger primitives (selfRecurse is set on
* iteration / RNG-binding primitives but NOT on trigger arms),
* so it eventually reaches request-choice's apply() — which
* throws SuspendedExecution. We tolerate that throw the same
* way `wave1-recipes-real.test.ts` tolerates `ctx-self-id`
* walker artifacts. The seed of `OnRuleActivatedHooks` happens
* BEFORE the walker recurses into request-choice, so the
* post-call hook lookup still succeeds.
*
* ## Why drive `runPrimitives` directly for the suspension test
*
* `applyCustomDescriptor` walks the descriptor tree at apply-time
* (selfRecurse is FALSE on `on-rule-activated`), reaching the
* request-choice node and firing apply() against the carrier piece.
* That suspends with the carrier as ctx.pieceId. Driving
* `runPrimitives` directly with a known carrier and the inner arm
* gives us the same suspension behaviour but lets us assert against
* a clean engine state without the walker's other side-effects.
*
* The full chooser-resume flow (T46 `submit-choice` → re-enter
* `runPrimitives` with the player's answer bound) requires the WS
* test client and is pinned at the e2e layer. Here we only verify
* the suspension half.
*/
import { describe, it, expect } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
import { applyCustomDescriptor } from "./apply.js";
import { CUSTOM_MODIFIER_RECIPES } from "./recipes.js";
import type { CustomModifierRecipe } from "./recipes.js";
import type { CustomModifierDescriptor } from "./types.js";
import { clearBoard, placePiece } from "../../presets/test-utils.js";
import { runPrimitives } from "../triggers.js";
import { SuspendedExecution } from "../primitives/request-choice.js";
import type { EffectPrimitiveNode } from "../primitives/types.js";
import "../primitives/index.js";
function recipeById(id: string): CustomModifierRecipe {
const r = CUSTOM_MODIFIER_RECIPES.find((x) => x.id === id);
if (r === undefined) throw new Error(`recipe ${id} not found`);
return r;
}
/**
* Extract the inner-arm primitive list from the recipe's top-level
* trigger node (`on-rule-activated`). All 8 W3 recipes have exactly
* one top-level node whose params holds a `primitives` array.
*/
function innerArmOf(
descriptor: CustomModifierDescriptor,
): readonly EffectPrimitiveNode[] {
const top = descriptor.primitives[0];
if (top === undefined) throw new Error("descriptor has no primitives");
const params = top.params as { primitives?: readonly EffectPrimitiveNode[] };
if (params.primitives === undefined) {
throw new Error(`top-level node ${top.kind} has no inner primitives arm`);
}
return params.primitives;
}
/**
* Drive a recipe's inner arm through `runPrimitives` and return the
* top PendingChoice frame the dispatcher pushed. The dispatcher
* (triggers.ts:323-344) catches SuspendedExecution INTERNALLY and
* returns rather than re-throwing — so we can't assert via `toThrow`.
* Instead we read the pushed PendingChoice frame and assert against
* its kind / forPlayer fields. The frame's `triggerPath` /
* `primitiveIndex` were placeholders set by request-choice.apply();
* the dispatcher's catch-block populated them with the real
* descriptor-relative path before pushing. We don't assert against
* those — they're dispatcher-internal bookkeeping; the public
* contract is the frame's kind / forPlayer / prompt that the UI
* consumes.
*/
function driveAndExpectSuspend(opts: {
engine: ChessEngine;
pieceId: EntityId;
primitives: readonly EffectPrimitiveNode[];
descriptorId: string;
}): {
kind: string;
forPlayer: string;
prompt: string;
} {
const { engine, pieceId, primitives, descriptorId } = opts;
runPrimitives(
engine,
pieceId,
primitives,
1,
undefined,
new Map(),
0,
false,
[],
descriptorId,
);
const pending = engine.session.get(GAME_ENTITY, "PendingChoices") as
| ChessAttrMap["PendingChoices"]
| undefined;
if (pending === undefined || pending.length === 0) {
throw new Error(
"expected a PendingChoice frame to be pushed onto GAME_ENTITY but the stack is empty",
);
}
const top = pending[pending.length - 1]!;
return { kind: top.kind, forPlayer: top.forPlayer, prompt: top.prompt };
}
/**
* Tolerantly run `applyCustomDescriptor`, swallowing the
* SuspendedExecution that request-choice's apply() throws when the
* walker reaches it (the apply-walker recurses into on-rule-activated's
* children — see `apply.ts:175-196` — and request-choice's selfRecurse
* stops the second-level recurse, but the first-level apply() of
* request-choice itself still fires and suspends). Mirrors the
* `applyCustomDescriptorTolerant` helper in `wave1-recipes-real.test.ts`.
*
* Critically, the OnRuleActivatedHooks seed happens INSIDE
* `on-rule-activated.apply()` BEFORE the walker recurses into
* request-choice's children. So the post-call hook lookup still finds
* the seeded hook, even though the walker subsequently throws.
*/
function applyTolerant(
engine: ChessEngine,
pieceId: EntityId,
descriptor: CustomModifierDescriptor,
): void {
try {
applyCustomDescriptor(engine, engine.session, pieceId, descriptor);
} catch (e) {
if (e instanceof SuspendedExecution) return;
// The walker may also throw on suspended pending-choices already
// pushed during a prior call within the same engine; the
// `pushPendingChoice` cap-check at depth=8 throws
// `runtime.choice-depth-exceeded` which is a real error.
throw e;
}
}
// ---------------------------------------------------------------------------
// Batch G — choice-driven spawn (3 recipes)
// ---------------------------------------------------------------------------
describe("tpl-bottomless-pit (W3 Batch G)", () => {
it("inner arm suspends with a square-kind PendingChoice; bind=sq", () => {
const recipe = recipeById("tpl-bottomless-pit");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("square");
expect(susp.forPlayer).toBe("white");
// PendingChoice was pushed onto GAME_ENTITY.
const pending = engine.session.get(GAME_ENTITY, "PendingChoices") as
| ChessAttrMap["PendingChoices"]
| undefined;
expect(pending).toBeDefined();
expect((pending ?? []).length).toBe(1);
expect((pending ?? [])[0]?.kind).toBe("square");
});
it("descriptor shape: on-rule-activated → request-choice(square, sq) → spawn-marker(pit)", () => {
const recipe = recipeById("tpl-bottomless-pit");
const top = recipe.descriptor.primitives[0];
expect(top?.kind).toBe("on-rule-activated");
const inner = innerArmOf(recipe.descriptor);
expect(inner[0]?.kind).toBe("request-choice");
const rc = inner[0]?.params as {
kind: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc.kind).toBe("square");
expect(rc.bind).toBe("sq");
expect(rc.then[0]?.kind).toBe("spawn-marker");
const sm = rc.then[0]?.params as { markerKind: string };
expect(sm.markerKind).toBe("pit");
});
});
describe("tpl-call-down-lightning (W3 Batch G / SIMPLIFIED)", () => {
it("inner arm suspends with a square-kind PendingChoice; bind=sq", () => {
const recipe = recipeById("tpl-call-down-lightning");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("square");
});
it("descriptor shape pins the death-square markerKind on the consequence", () => {
const recipe = recipeById("tpl-call-down-lightning");
const inner = innerArmOf(recipe.descriptor);
const rc = inner[0]?.params as { then: readonly EffectPrimitiveNode[] };
const sm = rc.then[0]?.params as { markerKind: string };
expect(sm.markerKind).toBe("death-square");
});
});
describe("tpl-portal-storm (W3 Batch G)", () => {
it("inner arm suspends with the FIRST square-pick PendingChoice; bind=sq1", () => {
const recipe = recipeById("tpl-portal-storm");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("square");
// Only ONE PendingChoice — the second request-choice is inside
// the first's `then` and will only fire when sq1 is resolved.
const pending = engine.session.get(GAME_ENTITY, "PendingChoices") as
| ChessAttrMap["PendingChoices"]
| undefined;
expect((pending ?? []).length).toBe(1);
});
it("descriptor shape: nested request-choice(square, sq1) → request-choice(square, sq2) → spawn-marker-pair(portal-end)", () => {
const recipe = recipeById("tpl-portal-storm");
const inner = innerArmOf(recipe.descriptor);
const rc1 = inner[0]?.params as {
kind: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc1.kind).toBe("square");
expect(rc1.bind).toBe("sq1");
expect(rc1.then[0]?.kind).toBe("request-choice");
const rc2 = rc1.then[0]?.params as {
kind: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc2.kind).toBe("square");
expect(rc2.bind).toBe("sq2");
expect(rc2.then[0]?.kind).toBe("spawn-marker-pair");
const smp = rc2.then[0]?.params as { markerKind: string };
expect(smp.markerKind).toBe("portal-end");
});
});
// ---------------------------------------------------------------------------
// Batch H — choice-driven swap/move (2 recipes)
// ---------------------------------------------------------------------------
describe("tpl-anti-camping-choice (W3 Batch H / SIMPLIFIED)", () => {
it("inner arm suspends with the victim-pick PendingChoice; bind=victim", () => {
const recipe = recipeById("tpl-anti-camping-choice");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("piece");
});
it("descriptor shape: nested request-choice(piece, victim) → request-choice(piece, swapper) → swap-pieces", () => {
const recipe = recipeById("tpl-anti-camping-choice");
const inner = innerArmOf(recipe.descriptor);
const rc1 = inner[0]?.params as {
kind: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc1.kind).toBe("piece");
expect(rc1.bind).toBe("victim");
const rc2 = rc1.then[0]?.params as {
kind: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc2.kind).toBe("piece");
expect(rc2.bind).toBe("swapper");
expect(rc2.then[0]?.kind).toBe("swap-pieces");
});
});
describe("tpl-two-kids-trenchcoat (W3 Batch H / SIMPLIFIED)", () => {
it("inner arm suspends with the first sacrifice-pick PendingChoice; bind=p1", () => {
const recipe = recipeById("tpl-two-kids-trenchcoat");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("piece");
});
it("descriptor shape: nested request-choice(piece × 2) → 2 destroy-piece + place-piece(bishop, white, e4)", () => {
const recipe = recipeById("tpl-two-kids-trenchcoat");
const inner = innerArmOf(recipe.descriptor);
const rc1 = inner[0]?.params as {
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc1.bind).toBe("p1");
const rc2 = rc1.then[0]?.params as {
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc2.bind).toBe("p2");
// 2× destroy-piece followed by place-piece.
expect(rc2.then[0]?.kind).toBe("destroy-piece");
expect(rc2.then[1]?.kind).toBe("destroy-piece");
expect(rc2.then[2]?.kind).toBe("place-piece");
const pp = rc2.then[2]?.params as {
pieceType: string;
color: string;
square: number;
};
expect(pp.pieceType).toBe("bishop");
expect(pp.color).toBe("white");
expect(pp.square).toBe(28); // e4
});
});
// ---------------------------------------------------------------------------
// Batch I — choice-driven self-modification (2 recipes)
// ---------------------------------------------------------------------------
describe("tpl-blood-sacrifice (W3 Batch I)", () => {
it("inner arm suspends with the first sacrifice-pick PendingChoice; bind=sacrifice", () => {
const recipe = recipeById("tpl-blood-sacrifice");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("piece");
});
it("descriptor shape: nested request-choice(piece × 2) → destroy-piece + add-to-attribute(target redirect, +5 Hp)", () => {
const recipe = recipeById("tpl-blood-sacrifice");
const inner = innerArmOf(recipe.descriptor);
const rc1 = inner[0]?.params as {
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc1.bind).toBe("sacrifice");
const rc2 = rc1.then[0]?.params as {
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc2.bind).toBe("blessed");
expect(rc2.then[0]?.kind).toBe("destroy-piece");
expect(rc2.then[1]?.kind).toBe("add-to-attribute");
const a2a = rc2.then[1]?.params as {
target: unknown;
attr: string;
delta: number;
};
expect(a2a.attr).toBe("Hp");
expect(a2a.delta).toBe(5);
// target must be a $var redirect to the blessed bind.
expect(a2a.target).toEqual({ $var: "blessed" });
});
});
describe("tpl-summoning-ritual-light (W3 Batch I / SIMPLIFIED)", () => {
it("inner arm suspends with the sacrifice-pick PendingChoice; bind=sacrifice", () => {
const recipe = recipeById("tpl-summoning-ritual-light");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("piece");
});
it("descriptor shape: request-choice(piece, sacrifice) → destroy-piece + with-probability(0.5, knight | bishop)", () => {
const recipe = recipeById("tpl-summoning-ritual-light");
const inner = innerArmOf(recipe.descriptor);
const rc = inner[0]?.params as {
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc.bind).toBe("sacrifice");
expect(rc.then[0]?.kind).toBe("destroy-piece");
expect(rc.then[1]?.kind).toBe("with-probability");
const wp = rc.then[1]?.params as {
p: number;
then: readonly EffectPrimitiveNode[];
else: readonly EffectPrimitiveNode[];
};
expect(wp.p).toBe(0.5);
const thenPP = wp.then[0]?.params as { pieceType: string };
const elsePP = wp.else[0]?.params as { pieceType: string };
// One side is a knight, the other a bishop — order asserted as authored.
expect(thenPP.pieceType).toBe("knight");
expect(elsePP.pieceType).toBe("bishop");
});
});
// ---------------------------------------------------------------------------
// Batch J — sophie's-choice variant (1 recipe)
// ---------------------------------------------------------------------------
describe("tpl-sophies-choice (W3 Batch J)", () => {
it("inner arm suspends with a piece-kind PendingChoice; forPlayer=both", () => {
const recipe = recipeById("tpl-sophies-choice");
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
const susp = driveAndExpectSuspend({
engine,
pieceId: carrier,
primitives: innerArmOf(recipe.descriptor),
descriptorId: String(recipe.descriptor.id),
});
expect(susp.kind).toBe("piece");
// Both-player chooser — the request-choice forPlayer flows
// straight through to the PendingChoice.
expect(susp.forPlayer).toBe("both");
});
it("descriptor shape: request-choice(piece, both, doomed) → destroy-piece(target $var doomed)", () => {
const recipe = recipeById("tpl-sophies-choice");
const inner = innerArmOf(recipe.descriptor);
const rc = inner[0]?.params as {
kind: string;
forPlayer: string;
bind: string;
then: readonly EffectPrimitiveNode[];
};
expect(rc.kind).toBe("piece");
expect(rc.forPlayer).toBe("both");
expect(rc.bind).toBe("doomed");
expect(rc.then[0]?.kind).toBe("destroy-piece");
const dp = rc.then[0]?.params as { target: unknown };
expect(dp.target).toEqual({ $var: "doomed" });
});
});
// ---------------------------------------------------------------------------
// Cross-cutting smoke: every Wave-3 recipe descriptor seeds
// OnRuleActivatedHooks cleanly via applyCustomDescriptor (the
// production seeding path). The walker reaches request-choice and
// throws SuspendedExecution; the seed already happened so the post-
// call lookup finds the hook.
// ---------------------------------------------------------------------------
describe("Wave-3 recipes — applyCustomDescriptor smoke (tolerant)", () => {
const W3_IDS = [
"tpl-bottomless-pit",
"tpl-call-down-lightning",
"tpl-portal-storm",
"tpl-anti-camping-choice",
"tpl-two-kids-trenchcoat",
"tpl-blood-sacrifice",
"tpl-summoning-ritual-light",
"tpl-sophies-choice",
] as const;
it("all 8 W3 recipe ids are present in CUSTOM_MODIFIER_RECIPES", () => {
for (const id of W3_IDS) {
expect(CUSTOM_MODIFIER_RECIPES.find((r) => r.id === id)).toBeDefined();
}
});
it("recipe count is exactly 54 (46 W1+W2 + 8 W3)", () => {
expect(CUSTOM_MODIFIER_RECIPES.length).toBe(54);
});
for (const id of W3_IDS) {
it(`${id} seeds OnRuleActivatedHooks via applyCustomDescriptor (tolerant)`, () => {
const recipe = recipeById(id);
const engine = new ChessEngine();
clearBoard(engine);
const carrier = placePiece(engine, "queen", "white", "d1");
// Tolerant apply — request-choice's apply() throws
// SuspendedExecution when the walker reaches it. Seed of
// OnRuleActivatedHooks happens in on-rule-activated.apply()
// BEFORE the walker recurses into children, so the post-call
// hook check still finds the seeded entry.
expect(() =>
applyTolerant(engine, carrier, recipe.descriptor),
).not.toThrow();
const hooks = engine.session.get(
GAME_ENTITY,
"OnRuleActivatedHooks",
) as ChessAttrMap["OnRuleActivatedHooks"] | undefined;
expect(hooks).toBeDefined();
expect((hooks ?? []).some((h) => h.descriptorId === recipe.descriptor.id)).toBe(true);
});
}
});