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