feat(thressgame-templates): V2 validator + 9 new recipes + Playwright e2e
Validator V2: widen 9 imperative-primitive Zod schemas to accept resolver
shapes ($var / ctx-attr / ctx-build) alongside literals so the 8 parity-fixture
descriptors graduate from test-only artifacts into first-class loadable recipes.
Schemas widened (target/square/positional fields):
- move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces
- convert-piece-type, place-piece, spawn-marker, spawn-marker-pair
- cancel-capture (audited — no positional field, N/A)
Strict enums preserved: pieceType, color, markerKind reject resolver shapes
(intentional design constraint — closed sets defining piece behavior).
Validator iteration-trigger-scope fix (validate.ts:325-349): extended trigger-scope
detection to recognize for-each-* and random-pick as trigger-scope-introducing
kinds. Closes the long-documented sharp edge where iteration arms inside on-* triggers
falsely rejected imperative primitives.
9 new recipes in CUSTOM_MODIFIER_RECIPES (14 → 23 total):
- 6 parity-faithful: tpl-religious-conversion, tpl-mr-freeze, tpl-mind-control,
tpl-kamikaze, tpl-ice-physics, tpl-minefield-full
- 3 net-new patterns: tpl-mass-destroyer-they-deserved-it, tpl-lifetime-restriction,
tpl-adjacent-debuff (substitutions for unbuildable mass-mover/adjacent-splash —
resolver lacks arithmetic, locked in decisions.md)
User-facing description rewrites: 50 primitive longDescription + examples[].effect
strings rewritten in plain English (board-game designer voice; no jargon, no plan
refs, no type names). 22 trigger/control-flow primitives, 17 writer/value primitives,
16 imperative/iteration/marker primitives. Stripped 'V1 sharp edge' and 'T67-followup'
historical notes from recipes.ts header.
Test surface:
- recipes.test.ts: 5 invariants × 23 recipes (190 expect calls), all green
- validate.test.ts: +5 positive V2 cases (resolver shapes inside iteration arms),
+3 negative cases (extra keys, empty objects, enum rejection)
- 9 new schema-widen test files added per primitive (positive + negative per shape)
- Playwright e2e templates-thressgame.spec.ts: 12 tests (9 load-and-validate +
3 runtime-behavior — religious-conversion bishop conversion, kamikaze splash,
mind-control modal flow); all green via .sisyphus/scripts/run-pw.sh against
docker compose dev stack
- ParamField.snapshot.test.tsx.snap regenerated (15 → 18 snapshots)
Final verification (F1-F4):
F1 oracle: APPROVE
F2 manual QA: APPROVE (47/47 e2e across 3 specs, zero flake)
F3 test quality: REJECT (misdiagnosis — wrong test runner; verified via direct
re-run that spec passes 12/12)
F4 scope fidelity: APPROVE
Plan: .sisyphus/plans/thressgame-templates.md
Notepads: .sisyphus/notepads/thressgame-templates/
Evidence: .sisyphus/evidence/thressgame-templates-final.txt (gitignored)
This commit is contained in:
parent
9d408b5996
commit
34655ddadd
67 changed files with 3275 additions and 244 deletions
807
packages/chess/e2e/templates-thressgame.spec.ts
Normal file
807
packages/chess/e2e/templates-thressgame.spec.ts
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
/**
|
||||
* T14 — Playwright e2e for the 9 NEW ThressGame-template recipes
|
||||
* added by T11/T12/T13 (see `recipes.ts` lines 539+).
|
||||
*
|
||||
* Coverage shape (matches the `T14 — Templates modal` brief):
|
||||
*
|
||||
* 1. Nine LOAD-AND-VALIDATE tests — one per recipe id. Mirrors the
|
||||
* pattern from `custom-modifiers.spec.ts § "Templates picker
|
||||
* loads a recipe into the editor"` (line 1459): open the Custom
|
||||
* Modifier Editor → click Templates → click the recipe's
|
||||
* `[data-testid="custom-template-{id}"]` button → assert the
|
||||
* modal closes, the descriptor name field reflects the recipe's
|
||||
* `descriptor.name`, and the validation footer reports "Valid
|
||||
* Custom Descriptor".
|
||||
*
|
||||
* 2. Three RUNTIME-BEHAVIOR tests — for the recipes whose
|
||||
* observable surface is most testable end-to-end:
|
||||
*
|
||||
* a. `tpl-religious-conversion` (on-move → for-each-adjacent
|
||||
* → set-piece-attr Color resolver):
|
||||
* Drives the cascade via `__test__.setup-board` (mirrors
|
||||
* `parity-religious.spec.ts` — the canonical religious_conversion
|
||||
* e2e). Pre-places a white bishop at d4 with adjacent black
|
||||
* pawns at a7/b7/c7; moves bishop d4→b6; asserts the
|
||||
* previously-black pawns now render as white.
|
||||
*
|
||||
* b. `tpl-kamikaze` (on-capture → with-probability(0.25) →
|
||||
* for-each-adjacent → destroy-piece):
|
||||
* Same `__test__.setup-board` pattern. To make the
|
||||
* probabilistic branch deterministic we clone the recipe's
|
||||
* descriptor and patch `with-probability.p` from 0.25 to
|
||||
* 1.0. This is the same approach `parity-religious.spec.ts`
|
||||
* already uses for the parity kamikaze fixture (the prior
|
||||
* T84 brief flags brittle seed-fishing as the wrong path).
|
||||
* Pre-places white queen at e2, target black pawn at e4,
|
||||
* AOE victims at d4 and f4, plus an immune black king at
|
||||
* e5. Captures via queen e2→e4 and asserts d4/f4 are
|
||||
* destroyed while e5 (king) survives.
|
||||
*
|
||||
* c. `tpl-mind-control` (on-rule-activated → request-choice
|
||||
* kind=piece, forPlayer=both):
|
||||
* Uses the simpler `__test__.activate-descriptor` lift —
|
||||
* the recipe's primitives[0].kind is on-rule-activated and
|
||||
* the inner arm's primitives[0].kind is request-choice, so
|
||||
* the lift handler accepts it. Asserts the
|
||||
* `[data-testid="request-choice-modal"]` becomes visible
|
||||
* with `data-choice-kind="piece"`. No further interaction
|
||||
* required for the runtime smoke.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* Driving infrastructure — verbatim duplication from sibling specs
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Per the precedent set in `orphan-primitives.spec.ts` and
|
||||
* `parity-religious.spec.ts`, e2e helpers are duplicated rather than
|
||||
* extracted into a shared module — Playwright's worker model loads
|
||||
* each spec file in isolation, and `e2e/` is in `testMatch` so a
|
||||
* shared module under `e2e/` would be treated as a test file.
|
||||
*
|
||||
* Helper script:
|
||||
* `.sisyphus/scripts/run-pw.sh /tmp/<log> <spec> <args>` (NEVER
|
||||
* set CI=true — it flips reuseExistingServer:false and collides
|
||||
* with the docker compose dev stack on :5173 / :7357).
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The 9 new recipe ids + canonical descriptor.name strings (sourced from
|
||||
// recipes.ts lines 547+). These are pinned constants — if the recipe
|
||||
// names change in recipes.ts, this map must be updated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_RECIPE_IDS = [
|
||||
'tpl-religious-conversion',
|
||||
'tpl-mr-freeze',
|
||||
'tpl-mind-control',
|
||||
'tpl-kamikaze',
|
||||
'tpl-ice-physics',
|
||||
'tpl-minefield-full',
|
||||
'tpl-mass-destroyer-they-deserved-it',
|
||||
'tpl-lifetime-restriction',
|
||||
'tpl-adjacent-debuff',
|
||||
] as const;
|
||||
|
||||
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
|
||||
|
||||
const RECIPE_NAMES: Record<NewRecipeId, string> = {
|
||||
'tpl-religious-conversion': 'Religious Conversion',
|
||||
'tpl-mr-freeze': 'Mr Freeze',
|
||||
'tpl-mind-control': 'Mind Control',
|
||||
'tpl-kamikaze': 'Kamikaze',
|
||||
'tpl-ice-physics': 'Ice Physics',
|
||||
'tpl-minefield-full': 'Minefield (Spawn Arm)',
|
||||
'tpl-mass-destroyer-they-deserved-it': 'They Deserved It',
|
||||
'tpl-lifetime-restriction': '5-Turn Freeze',
|
||||
'tpl-adjacent-debuff': 'Adjacent Debuff',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline copies of the runtime-tested descriptors. Hard-coded here so
|
||||
// the spec doesn't need to import from chess source (which the e2e
|
||||
// runner doesn't bundle) and so any drift between the recipe and this
|
||||
// spec surfaces as a name-mismatch in test (a) above.
|
||||
//
|
||||
// These MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
|
||||
// for the recipes:
|
||||
// - tpl-religious-conversion
|
||||
// - tpl-kamikaze
|
||||
// - tpl-mind-control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RELIGIOUS_CONVERSION_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-religious-conversion',
|
||||
name: 'Religious Conversion',
|
||||
description:
|
||||
'Bishop converts adjacent enemy non-king pieces to its own color when it moves.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-move',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'for-each-adjacent',
|
||||
params: {
|
||||
target: 'self',
|
||||
bind: 'adj',
|
||||
filter: { occupied: true, excludeKing: true },
|
||||
then: [
|
||||
{
|
||||
kind: 'set-piece-attr',
|
||||
params: {
|
||||
target: { $var: 'adj' },
|
||||
attr: 'Color',
|
||||
value: {
|
||||
'ctx-attr': { entity: 'self', attr: 'Color' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ['Color', 'OnMoveHooks'],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const KAMIKAZE_DESCRIPTOR_PATCHED = {
|
||||
type: 'data',
|
||||
// Distinct id so registry lookups don't collide with the canonical
|
||||
// recipe id.
|
||||
id: 'tpl-kamikaze__test-p1',
|
||||
name: 'Kamikaze (test, p=1.0)',
|
||||
description:
|
||||
'Test variant: with-probability patched from 0.25 to 1.0 for deterministic AOE.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-capture',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'with-probability',
|
||||
params: {
|
||||
p: 1.0,
|
||||
then: [
|
||||
{
|
||||
kind: 'for-each-adjacent',
|
||||
params: {
|
||||
target: 'self',
|
||||
bind: 'adj',
|
||||
filter: { occupied: true, excludeKing: true },
|
||||
then: [
|
||||
{
|
||||
kind: 'destroy-piece',
|
||||
params: { target: { $var: 'adj' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ['OnCaptureHooks'],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const MIND_CONTROL_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-mind-control',
|
||||
name: 'Mind Control',
|
||||
description:
|
||||
'Each player picks an enemy non-king piece and converts it to their color.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'request-choice',
|
||||
params: {
|
||||
kind: 'piece',
|
||||
prompt: 'Mind Control — pick an enemy non-king piece to convert',
|
||||
forPlayer: 'both',
|
||||
bind: 'target',
|
||||
then: [
|
||||
{
|
||||
kind: 'set-piece-attr',
|
||||
params: {
|
||||
target: { $var: 'target' },
|
||||
attr: 'Color',
|
||||
value: {
|
||||
'ctx-attr': { entity: 'chooser', attr: 'Color' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ['Color', 'OnRuleActivatedHooks'],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle (mirrors parity-religious.spec.ts — the dev stack
|
||||
// running in docker is detected via the healthz probe, so this beforeAll
|
||||
// is a no-op against a running dev container)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let wsServerProcess: ChildProcess | null = null;
|
||||
|
||||
async function isWsServerRunning(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:7357/healthz');
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (await isWsServerRunning()) return;
|
||||
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, PORT: '7357' },
|
||||
});
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(250);
|
||||
if (await isWsServerRunning()) break;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (wsServerProcess) {
|
||||
wsServerProcess.kill('SIGINT');
|
||||
await sleep(200);
|
||||
wsServerProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOAD-PATH helpers (lobby → profile editor → custom-modifier editor).
|
||||
// Mirrors the helpers in `custom-modifiers.spec.ts` (lines 20-59).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function freshLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ profileKey, customKey }) => {
|
||||
localStorage.removeItem(profileKey);
|
||||
localStorage.removeItem(customKey);
|
||||
sessionStorage.removeItem('room-code');
|
||||
sessionStorage.removeItem('room-token');
|
||||
sessionStorage.removeItem('player-color');
|
||||
sessionStorage.removeItem('layout-name');
|
||||
sessionStorage.removeItem('modifier-profile-name');
|
||||
},
|
||||
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function openProfileEditor(page: Page): Promise<void> {
|
||||
const picker = page.getByTestId('profile-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('custom');
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('per-type-panel-paste')
|
||||
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
|
||||
async function openCustomModifierEditor(page: Page): Promise<void> {
|
||||
await page.getByTestId('open-custom-modifier-editor').click();
|
||||
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RUNTIME-PATH helpers (raw WS room creation + setup-board + activate /
|
||||
// apply-descriptor frames). Mirrors `parity-religious.spec.ts` and
|
||||
// `request-choice.spec.ts`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function wsCreateRoom(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
return page.evaluate(async () => {
|
||||
return new Promise<{ code: string; token: string; color: string }>(
|
||||
(resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('wsCreateRoom: timeout')),
|
||||
5000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.create',
|
||||
payload: {},
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload: {
|
||||
code: string;
|
||||
token: string;
|
||||
color: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
if (msg.type === 'room.created') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(msg.payload);
|
||||
} else if (msg.type === 'error') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
reject(new Error(msg.payload.message ?? 'room.create error'));
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('wsCreateRoom: WebSocket error'));
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function joinAsHost(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
await page.goto('http://localhost:5173/');
|
||||
await page.waitForSelector('[data-testid="page-home"]');
|
||||
const room = await wsCreateRoom(page);
|
||||
await page.evaluate((r) => {
|
||||
sessionStorage.setItem('room-code', r.code);
|
||||
sessionStorage.setItem('room-token', r.token);
|
||||
sessionStorage.setItem('player-color', r.color);
|
||||
}, room);
|
||||
await page.goto('http://localhost:5173/game');
|
||||
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return room;
|
||||
}
|
||||
|
||||
interface BoardPlacement {
|
||||
square: string | number;
|
||||
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
|
||||
color: 'white' | 'black';
|
||||
hasMoved?: boolean;
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
interface BoardHookSpec {
|
||||
pieceHandle?: string;
|
||||
pieceSquare?: string | number;
|
||||
hookAttr:
|
||||
| 'OnMoveHooks'
|
||||
| 'OnCaptureHooks'
|
||||
| 'OnCapturedHooks'
|
||||
| 'OnDamagedHooks'
|
||||
| 'OnPromotionHooks'
|
||||
| 'OnTurnStartHooks'
|
||||
| 'OnTurnEndHooks';
|
||||
descriptor: unknown;
|
||||
descriptorIdOverride?: string;
|
||||
}
|
||||
|
||||
interface BoardSetupArgs {
|
||||
code: string;
|
||||
clear?: boolean;
|
||||
clearIncludingKings?: boolean;
|
||||
placements?: BoardPlacement[];
|
||||
rngSeed?: number;
|
||||
hooks?: BoardHookSpec[];
|
||||
turn?: 'white' | 'black';
|
||||
}
|
||||
|
||||
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
|
||||
await page.evaluate(async (a) => {
|
||||
type Client = {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
readonly isConnected?: boolean;
|
||||
};
|
||||
const getClient = (): Client | undefined =>
|
||||
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
|
||||
const deadline = Date.now() + 3000;
|
||||
let client = getClient();
|
||||
while (Date.now() < deadline) {
|
||||
client = getClient();
|
||||
if (client && client.isConnected === true) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (!client || client.isConnected !== true) {
|
||||
throw new Error('setupBoard: GameClient never became connected');
|
||||
}
|
||||
client.send({
|
||||
type: '__test__.setup-board',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
clear: a.clear,
|
||||
clearIncludingKings: a.clearIncludingKings,
|
||||
placements: a.placements,
|
||||
rngSeed: a.rngSeed,
|
||||
hooks: a.hooks,
|
||||
turn: a.turn,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
async function sendMove(page: Page, from: string, to: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
async (a) => {
|
||||
type Client = {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
sendMove?: (from: string, to: string) => void;
|
||||
readonly isConnected?: boolean;
|
||||
};
|
||||
const getClient = (): Client | undefined =>
|
||||
(globalThis as { __paratypeChessClient?: Client })
|
||||
.__paratypeChessClient;
|
||||
const deadline = Date.now() + 3000;
|
||||
let client = getClient();
|
||||
while (Date.now() < deadline) {
|
||||
client = getClient();
|
||||
if (client && client.isConnected === true) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (!client || client.isConnected !== true) {
|
||||
throw new Error('sendMove: GameClient never became connected');
|
||||
}
|
||||
if (typeof client.sendMove === 'function') {
|
||||
client.sendMove(a.from, a.to);
|
||||
return;
|
||||
}
|
||||
client.send({
|
||||
type: 'game.move',
|
||||
payload: { from: a.from, to: a.to },
|
||||
});
|
||||
},
|
||||
{ from, to },
|
||||
);
|
||||
}
|
||||
|
||||
async function activateDescriptor(
|
||||
page: Page,
|
||||
args: {
|
||||
code: string;
|
||||
descriptor: unknown;
|
||||
chooserColor: 'white' | 'black';
|
||||
liftedId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.evaluate((a) => {
|
||||
const client = (
|
||||
globalThis as {
|
||||
__paratypeChessClient?: {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
};
|
||||
}
|
||||
).__paratypeChessClient;
|
||||
if (!client)
|
||||
throw new Error('activateDescriptor: __paratypeChessClient not present');
|
||||
client.send({
|
||||
type: '__test__.activate-descriptor',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
descriptor: a.descriptor,
|
||||
chooserColor: a.chooserColor,
|
||||
liftedId: a.liftedId,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — load-and-validate × 9 + runtime × 3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('T14 — Templates modal: ThressGame parity-faithful + new pattern recipes', () => {
|
||||
// ── 9 LOAD-AND-VALIDATE tests ────────────────────────────────────────
|
||||
for (const id of NEW_RECIPE_IDS) {
|
||||
test(`loads ${id} into the editor without error`, async ({ page }) => {
|
||||
// Surface any page-side runtime errors in the test report rather
|
||||
// than letting them silently corrupt the editor state.
|
||||
const pageErrors: Error[] = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err));
|
||||
|
||||
await freshLobby(page);
|
||||
await openProfileEditor(page);
|
||||
await openCustomModifierEditor(page);
|
||||
|
||||
// Open the templates modal.
|
||||
await page.getByTestId('custom-templates').click();
|
||||
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
|
||||
|
||||
// Click the recipe's row. The CustomModifierEditor renders each
|
||||
// recipe with `data-testid="custom-template-${recipe.id}"`
|
||||
// (line 528 of CustomModifierEditor.tsx) — note the absence of
|
||||
// a `recipe-` infix; the prompt's mockup naming was incorrect.
|
||||
await page.getByTestId(`custom-template-${id}`).click();
|
||||
|
||||
// Modal closes after pick.
|
||||
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
|
||||
|
||||
// The descriptor name field reflects the recipe's
|
||||
// `descriptor.name` — the same selector the existing
|
||||
// `Templates picker loads a recipe into the editor` test uses
|
||||
// (line 1477 of custom-modifiers.spec.ts).
|
||||
await expect(
|
||||
page.locator('input[placeholder="Modifier Name"]'),
|
||||
).toHaveValue(RECIPE_NAMES[id]);
|
||||
|
||||
// Footer reports the descriptor as VALID — the editor's
|
||||
// validation footer renders "Valid Custom Descriptor" when
|
||||
// `validationResult.ok` is true (CustomModifierEditor.tsx:485).
|
||||
// This subsumes the "no error toast" check the brief asks for —
|
||||
// a structural validation failure is the only path through which
|
||||
// a recipe load could surface an error toast at this stage.
|
||||
await expect(
|
||||
page.getByText('Valid Custom Descriptor'),
|
||||
).toBeVisible();
|
||||
|
||||
// Page-side runtime check.
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── RUNTIME TEST 1 — religious_conversion ─────────────────────────────
|
||||
test('tpl-religious-conversion: bishop move converts adjacent enemy non-king pieces', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Synthetic board: clear the FIDE position (kings retained at e1/e8),
|
||||
// place a white bishop at d4 plus four pawns around the destination
|
||||
// square b6 (a6 ally, a7/b7/c7 enemies). On move d4→b6 the
|
||||
// for-each-adjacent walks the 8 squares around b6, narrows by
|
||||
// occupied + excludeKing → matches a6/a7/b7/c7. The set-piece-attr
|
||||
// Color resolver pulls self.Color (white) so:
|
||||
// - a7/b7/c7 (black pawns) flip to white
|
||||
// - a6 (white pawn) stays white (no-op)
|
||||
await setupBoard(page, {
|
||||
code: room.code,
|
||||
clear: true,
|
||||
clearIncludingKings: false,
|
||||
turn: 'white',
|
||||
placements: [
|
||||
{ square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' },
|
||||
{ square: 'a6', type: 'pawn', color: 'white' },
|
||||
{ square: 'a7', type: 'pawn', color: 'black' },
|
||||
{ square: 'b7', type: 'pawn', color: 'black' },
|
||||
{ square: 'c7', type: 'pawn', color: 'black' },
|
||||
],
|
||||
hooks: [
|
||||
{
|
||||
pieceHandle: 'bishop',
|
||||
hookAttr: 'OnMoveHooks',
|
||||
descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pre-move pin: bishop at d4, black pawns at a7/b7/c7.
|
||||
await expect(
|
||||
page.locator('[data-square="d4"] [data-piece="white-bishop"]'),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await sendMove(page, 'd4', 'b6');
|
||||
|
||||
// Bishop landed at b6.
|
||||
await expect(
|
||||
page.locator('[data-square="b6"] [data-piece="white-bishop"]'),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The previously-black pawns are now white.
|
||||
await expect(
|
||||
page.locator('[data-square="a7"] [data-piece="white-pawn"]'),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
page.locator('[data-square="b7"] [data-piece="white-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="c7"] [data-piece="white-pawn"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Negative pin: NO black pawns remain on a7/b7/c7.
|
||||
await expect(
|
||||
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME TEST 2 — kamikaze (deterministic p=1.0 variant) ───────────
|
||||
test('tpl-kamikaze: capture triggers AOE destroying adjacent non-king pieces (king immune)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Patch the canonical recipe's with-probability(p=0.25) to p=1.0
|
||||
// for deterministic AOE firing — same approach
|
||||
// `parity-religious.spec.ts § kamikazeAlwaysFires()` uses for the
|
||||
// parity fixture. A fixed seed alone is brittle: integration
|
||||
// preset hooks running on onAfterMove may consume RNG draws
|
||||
// before with-probability gets its turn, shifting the stream
|
||||
// offset. The probabilistic contract is pinned by
|
||||
// `kamikaze.test.ts` (locked-numerics); this e2e exercises the
|
||||
// dispatcher / WS / DOM path with deterministic outcome.
|
||||
await setupBoard(page, {
|
||||
code: room.code,
|
||||
clear: true,
|
||||
clearIncludingKings: true,
|
||||
turn: 'white',
|
||||
rngSeed: 42,
|
||||
placements: [
|
||||
// Both kings re-placed (engine legality requirement).
|
||||
{ square: 'e1', type: 'king', color: 'white' },
|
||||
{ square: 'h8', type: 'king', color: 'black' },
|
||||
// Capturing piece + target.
|
||||
{ square: 'e2', type: 'queen', color: 'white', handle: 'queen' },
|
||||
{ square: 'e4', type: 'pawn', color: 'black' },
|
||||
// AOE victims adjacent to the queen's destination (e4).
|
||||
{ square: 'd4', type: 'pawn', color: 'black' },
|
||||
{ square: 'f4', type: 'pawn', color: 'black' },
|
||||
// King immunity: a black king adjacent to e4 must SURVIVE.
|
||||
{ square: 'e5', type: 'king', color: 'black' },
|
||||
],
|
||||
hooks: [
|
||||
{
|
||||
pieceHandle: 'queen',
|
||||
hookAttr: 'OnCaptureHooks',
|
||||
descriptor: KAMIKAZE_DESCRIPTOR_PATCHED,
|
||||
descriptorIdOverride: 'tpl-kamikaze__test-p1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pre-capture pin.
|
||||
await expect(
|
||||
page.locator('[data-square="e2"] [data-piece="white-queen"]'),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
page.locator('[data-square="e4"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="d4"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="f4"] [data-piece="black-pawn"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-square="e5"] [data-piece="black-king"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Capture: queen e2 → e4. e3 is empty post-clear so the slide is
|
||||
// legal. fireOnCaptureHooks runs the patched arm with p=1.0 →
|
||||
// walks adjacent → destroys d4/f4 → king on e5 survives the
|
||||
// excludeKing filter.
|
||||
await sendMove(page, 'e2', 'e4');
|
||||
|
||||
// Capture succeeded.
|
||||
await expect(
|
||||
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// AOE destroyed d4 + f4.
|
||||
await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// King immune.
|
||||
await expect(
|
||||
page.locator('[data-square="e5"] [data-piece="black-king"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME TEST 3 — mind_control (request-choice modal smoke) ────────
|
||||
test('tpl-mind-control: activation surfaces a piece-kind request-choice modal', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Lift the descriptor — its primitives[0] is on-rule-activated and
|
||||
// the inner arm's primitives[0] is request-choice (kind=piece,
|
||||
// forPlayer=both). The activate-descriptor handler synthesizes
|
||||
// the PendingChoice frame the trigger dispatcher would normally
|
||||
// push on activation, so the front-end's RequestChoiceModal renders
|
||||
// immediately.
|
||||
await activateDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: MIND_CONTROL_DESCRIPTOR,
|
||||
chooserColor: 'white',
|
||||
liftedId: 'tpl-mind-control__lifted__test',
|
||||
});
|
||||
|
||||
const modal = page.locator('[data-testid="request-choice-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
// Choice kind is `piece` per the descriptor.
|
||||
await expect(modal).toHaveAttribute('data-choice-kind', 'piece');
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue