houserules/packages/chess/e2e/wave3-choices.spec.ts
Joey Yakimowich-Payne 73df9f4e53
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)
2026-04-27 15:43:26 -06:00

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();
});
});