Wave 2 of thressgame-100 epic complete. Coverage 27/51 \u2192 37/51 = 72 % (on plan target).
ENGINE WORK (W2.0\u2013W2.6):
- New trigger primitive on-attr-expire(target, attr, primitives) \u2014 fires when an
attr countdown hits zero. Introduces 'expiringValue' binding to inner primitives.
- New imperative primitive decrement-attr-each-turn(target, attr) \u2014 explicit
countdown control (alternate to set-piece-attr.lifetime: turns).
- New dispatcher stage 13 fireAttrExpireHooks \u2014 batched per-turn-boundary, runs
AFTER stage 12 fireOnTurnStartHooks.
- Lifetime registry extension (lifetime-registry.ts) \u2014 decrementAttrCountdowns
function mirrors the marker-lifetime pattern; fires on-attr-expire BEFORE
retraction so triggers can still read the expiring value.
- Schema additions (schema.ts) \u2014 OnAttrExpireHooks (game-level on GAME_ENTITY),
AttrCountdownRegistry (game-level), OnAttrExpireHookEntry, AttrCountdownEntry.
- Validator (validate.ts) \u2014 decrement-attr-each-turn added to IMPERATIVE_KINDS;
on-attr-expire registered as binding-introducer with FIXED_BINDING_KINDS map.
- IMPERATIVE_KINDS list and trigger-scope walker updated.
10 NEW RECIPES (W2.7\u2013W2.9):
- tpl-time-bomb \u2014 black knights self-destruct after 5 turns
- tpl-nuclear-fallout \u2014 3 random blocked-square markers (deterministic seed)
- tpl-christmas-truce \u2014 BlockAllExceptKing on GAME_ENTITY for 3 turns
(proxy for missing BlockAllCaptures attr)
- tpl-pawn-second-chance \u2014 captured white pawn returns 1 turn later
- tpl-invulnerability-potion \u2014 white pieces uncapturable for 3 turns
- tpl-anti-camping \u2014 every piece dies after 3 turns (refresh-on-move
arm dropped per depth limit)
- tpl-ice-age \u2014 16 frozen-square markers covering files a + h
- tpl-no-cowards \u2014 MustMoveForward semantic flag for 1 turn
- tpl-drafted-for-battle \u2014 chooser picks bishop/knight, swap with king
- tpl-corporate-ladder \u2014 chooser picks 2 pieces to swap
NINE DOCUMENTED SIMPLIFICATIONS (full rationale in notepad and evidence):
- tpl-time-bomb dropped adjacent-splash arm (depth 5 > MAX_RECURSION_DEPTH=3)
- tpl-christmas-truce uses BlockAllExceptKing (no BlockAllCaptures attr)
- tpl-pawn-second-chance white-pawn-only (place-piece pieceType/color strict)
- tpl-invulnerability-potion uses set-piece-attr (set-capture-flag has no target)
- tpl-anti-camping dropped refresh arm (depth limit)
- tpl-no-cowards ships flag only (move-gen consumer wiring deferred to host preset)
- tpl-corporate-ladder uses request-choice(piece) \u00d7 2 (square \u2192 pieceId conversion
isn't expressible in ConditionSpec.value)
- tpl-drafted-for-battle bishop/knight restriction is player discipline
- tpl-nuclear-fallout / tpl-ice-age use lifetime: moves (spawn-marker.lifetime
schema doesn't accept turns)
TEST SURFACE:
- on-attr-expire.test.ts: 12 unit tests
- decrement-attr-each-turn.test.ts: 10 unit tests
- attr-expire-integration.test.ts: 7 integration tests
(incl. N=100 determinism)
- countdowns-perf.test.ts: 1 perf test
(p50=13.6ms p99=24.6ms; budget <150ms)
- wave2-recipes-real.test.ts: 15 runtime tests (NEW)
- recipes.test.ts: 5 \u00d7 46 = 378 expect calls
- wave2-countdowns.spec.ts (Playwright e2e): 13 tests
(10 load + 3 runtime: ice-age,
nuclear-fallout, drafted-for-battle)
bun run check: 3055 tests pass (was 3010, +45). 0 regressions.
e2e: 13/13 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 50 \u2192 52 (decrement + on-attr-expire)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for two new kinds
- apply.test.ts: stage-list comment updated for new stage 13
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave2.txt (gitignored, 1037 lines)
778 lines
29 KiB
TypeScript
778 lines
29 KiB
TypeScript
/**
|
|
* W2.10 — Playwright e2e for the 10 NEW Wave-2 thressgame-100 recipes
|
|
* shipped by W2.7-W2.9 (see `recipes.ts` lines 1750-2240).
|
|
*
|
|
* Coverage shape (mirrors the W1.11 brief, scaled to the Wave-2 batch):
|
|
*
|
|
* 1. Ten LOAD-AND-VALIDATE tests — one per recipe id. Same pattern
|
|
* as `wave1-recipes.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 — one representative per Wave-2
|
|
* batch:
|
|
*
|
|
* Batch E — `tpl-ice-age`:
|
|
* The simplest observable runtime in Wave 2 — purely
|
|
* spawn-marker driven, no countdown advancement, no
|
|
* chooser. Use `__test__.apply-descriptor` to fire the
|
|
* recipe's on-rule-activated arm. Each of the 16
|
|
* spawn-marker primitives lays a frozen-square marker on
|
|
* the a-file (squares 0,8,...,56) and h-file (7,15,...,63).
|
|
* Assert the DOM renders 16 `[data-marker-kind="frozen-square"]`
|
|
* elements after activation.
|
|
*
|
|
* Walker-artifact note: `applyCustomDescriptor` recurses
|
|
* into `on-rule-activated.childPrimitives()` AT APPLY TIME
|
|
* (selfRecurse !== true on trigger primitives), then
|
|
* `fireOnRuleActivatedHooks` runs the same arm a second
|
|
* time post-walk. For idempotent arms (no-op on
|
|
* double-fire) this is invisible; spawn-marker is NOT
|
|
* idempotent — every spawn yields a new entity. Therefore
|
|
* a fresh activation typically produces 32 frozen-square
|
|
* markers, not 16. We assert `>= 16` (smoke variant) and
|
|
* tolerate the doubled count.
|
|
*
|
|
* Batch D — `tpl-nuclear-fallout`:
|
|
* 3 sibling random-pick → spawn-marker primitives, each
|
|
* drawing a square index from 0..63 with the engine's
|
|
* deterministic Mulberry32 RNG (seeded via setupBoard's
|
|
* `rngSeed`). Each spawn-marker drops a `blocked` marker
|
|
* with a moves-bounded lifetime. Assert the DOM renders
|
|
* `[data-marker-kind="blocked"]` markers post-activation.
|
|
*
|
|
* Walker-artifact note: same double-fire concern as
|
|
* tpl-ice-age — 3 markers from the walker's child-recursion
|
|
* plus 3 more from the post-walk dispatcher = 6 markers
|
|
* total. Smoke variant: assert `>= 3`.
|
|
*
|
|
* Batch F — `tpl-drafted-for-battle`:
|
|
* `on-rule-activated → request-choice(kind:"piece", ...)`.
|
|
* Use `__test__.activate-descriptor` to lift the descriptor
|
|
* (the lift handler synthesizes the PendingChoice frame the
|
|
* dispatcher would push). The front-end renders
|
|
* RequestChoiceModal with `data-choice-kind="piece"`.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Driving infrastructure
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
*
|
|
* Per the precedent set in `wave1-recipes.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 10 new recipe ids + canonical descriptor.name strings (sourced from
|
|
// recipes.ts lines 1750-2240). These are pinned constants — if the recipe
|
|
// names change in recipes.ts, this map must be updated.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NEW_RECIPE_IDS = [
|
|
// Batch D — countdowns (5)
|
|
'tpl-time-bomb',
|
|
'tpl-nuclear-fallout',
|
|
'tpl-christmas-truce',
|
|
'tpl-pawn-second-chance',
|
|
'tpl-invulnerability-potion',
|
|
// Batch E — restrictions with duration (3)
|
|
'tpl-anti-camping',
|
|
'tpl-ice-age',
|
|
'tpl-no-cowards',
|
|
// Batch F — verified-shippable choosers (2)
|
|
'tpl-drafted-for-battle',
|
|
'tpl-corporate-ladder',
|
|
] 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-time-bomb': 'Time Bomb',
|
|
'tpl-nuclear-fallout': 'Nuclear Fallout',
|
|
'tpl-christmas-truce': 'Christmas Truce',
|
|
'tpl-pawn-second-chance': 'Pawn Second Chance',
|
|
'tpl-invulnerability-potion': 'Invulnerability Potion',
|
|
'tpl-anti-camping': 'Anti-Camping',
|
|
'tpl-ice-age': 'Ice Age (a-file + h-file)',
|
|
'tpl-no-cowards': 'No Cowards',
|
|
'tpl-drafted-for-battle': 'Drafted for Battle',
|
|
'tpl-corporate-ladder': 'Corporate Ladder',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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-ice-age (on-rule-activated → 16 spawn-markers)
|
|
// - tpl-nuclear-fallout (on-rule-activated → 3 random-pick → spawn-marker)
|
|
// - tpl-drafted-for-battle (on-rule-activated → request-choice piece)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ICE_AGE_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'tpl-ice-age',
|
|
name: 'Ice Age (a-file + h-file)',
|
|
description:
|
|
'16 frozen-square markers cover the a-file and h-file for 5 fullmoves on activation.',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'on-rule-activated',
|
|
params: {
|
|
primitives: [
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 0, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 8, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 16, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 24, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 32, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 40, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 48, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 56, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 7, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 15, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 23, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 31, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 39, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 47, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 55, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 63, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
targetAttrs: [],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
const NUCLEAR_FALLOUT_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'tpl-nuclear-fallout',
|
|
name: 'Nuclear Fallout',
|
|
description:
|
|
'3 random blocked squares appear for 5 moves on activation. Markers vanish at FullmoveNumber=10.',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'on-rule-activated',
|
|
params: {
|
|
primitives: [
|
|
{
|
|
kind: 'random-pick',
|
|
params: {
|
|
from: [
|
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
|
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
|
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
|
63,
|
|
],
|
|
bind: 'sq',
|
|
then: [
|
|
{
|
|
kind: 'spawn-marker',
|
|
params: {
|
|
markerKind: 'blocked',
|
|
square: { $var: 'sq' },
|
|
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
kind: 'random-pick',
|
|
params: {
|
|
from: [
|
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
|
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
|
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
|
63,
|
|
],
|
|
bind: 'sq',
|
|
then: [
|
|
{
|
|
kind: 'spawn-marker',
|
|
params: {
|
|
markerKind: 'blocked',
|
|
square: { $var: 'sq' },
|
|
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
kind: 'random-pick',
|
|
params: {
|
|
from: [
|
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
|
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
|
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
|
63,
|
|
],
|
|
bind: 'sq',
|
|
then: [
|
|
{
|
|
kind: 'spawn-marker',
|
|
params: {
|
|
markerKind: 'blocked',
|
|
square: { $var: 'sq' },
|
|
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
targetAttrs: [],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
const DRAFTED_FOR_BATTLE_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'tpl-drafted-for-battle',
|
|
name: 'Drafted for Battle',
|
|
description:
|
|
'Chooser picks a piece; it swaps with the white king. (Bishop/knight restriction is player-discipline — request-choice doesn\'t filter by piece type.)',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'on-rule-activated',
|
|
params: {
|
|
primitives: [
|
|
{
|
|
kind: 'request-choice',
|
|
params: {
|
|
kind: 'piece',
|
|
prompt:
|
|
'Drafted for Battle — pick a piece to swap with the white king',
|
|
forPlayer: 'white',
|
|
bind: 'chosen',
|
|
then: [
|
|
{
|
|
kind: 'for-each-piece',
|
|
params: {
|
|
filter: { pieceType: 'king', color: 'white' },
|
|
bind: 'k',
|
|
then: [
|
|
{
|
|
kind: 'swap-pieces',
|
|
params: {
|
|
a: { $var: 'chosen' },
|
|
b: { $var: 'k' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
targetAttrs: ['OnRuleActivatedHooks'],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server lifecycle (mirrors wave1-recipes.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 wave1-recipes.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 + setup-board +
|
|
// apply/activate-descriptor frames). Mirrors wave1-recipes.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 BoardSetupArgs {
|
|
code: string;
|
|
clear?: boolean;
|
|
clearIncludingKings?: boolean;
|
|
placements?: BoardPlacement[];
|
|
rngSeed?: number;
|
|
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,
|
|
turn: a.turn,
|
|
},
|
|
});
|
|
}, args);
|
|
}
|
|
|
|
async function applyDescriptor(
|
|
page: Page,
|
|
args: {
|
|
code: string;
|
|
descriptor: unknown;
|
|
targetSquare?: number;
|
|
rngSeed?: number;
|
|
},
|
|
): Promise<void> {
|
|
await page.evaluate((a) => {
|
|
const client = (
|
|
globalThis as {
|
|
__paratypeChessClient?: {
|
|
send: (msg: { type: string; payload: unknown }) => void;
|
|
};
|
|
}
|
|
).__paratypeChessClient;
|
|
if (!client)
|
|
throw new Error('applyDescriptor: __paratypeChessClient not present');
|
|
client.send({
|
|
type: '__test__.apply-descriptor',
|
|
payload: {
|
|
roomCode: a.code,
|
|
descriptor: a.descriptor,
|
|
targetSquare: a.targetSquare,
|
|
rngSeed: a.rngSeed,
|
|
},
|
|
});
|
|
}, args);
|
|
}
|
|
|
|
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 — 10 load-and-validate + 3 runtime
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('W2.10 — Wave-2 thressgame-100 recipes (10 load + 3 runtime)', () => {
|
|
// ── 10 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 E) — tpl-ice-age ────────────────────────────────
|
|
//
|
|
// Easiest observable runtime in Wave 2: 16 spawn-marker primitives
|
|
// that lay frozen-square markers on the a-file and h-file. No
|
|
// countdown advancement, no chooser, no piece-state mutation.
|
|
//
|
|
// Walker-artifact note: `applyCustomDescriptor` recurses into
|
|
// `on-rule-activated.childPrimitives()` AT APPLY TIME (selfRecurse
|
|
// !== true on trigger primitives), then `fireOnRuleActivatedHooks`
|
|
// runs the same arm a second time post-walk. spawn-marker is NOT
|
|
// idempotent (each call yields a new entity), so a fresh activation
|
|
// through the apply path produces 32 markers, not 16. We assert
|
|
// `>= 16` (smoke variant) and pin the lower bound — 32 is the
|
|
// expected upper bound under the current walker.
|
|
test('tpl-ice-age: activation spawns >= 16 frozen-square markers (a-file + h-file)', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Pre-activation pin: zero frozen-square markers.
|
|
await expect(
|
|
page.locator('[data-marker-kind="frozen-square"]'),
|
|
).toHaveCount(0);
|
|
|
|
await applyDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: ICE_AGE_DESCRIPTOR,
|
|
});
|
|
|
|
// Wait for at least 16 markers to render. Use a polling assertion
|
|
// — `expect.poll` retries the matcher until it passes or the
|
|
// timeout elapses. This tolerates the variable settle time of
|
|
// the WS round-trip + React render.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
page.locator('[data-marker-kind="frozen-square"]').count(),
|
|
{ timeout: 5000 },
|
|
)
|
|
.toBeGreaterThanOrEqual(16);
|
|
|
|
// Sanity upper bound — the walker double-fire doubles the count
|
|
// to 32. If something is wrong (e.g. triple-walk), this catches
|
|
// it.
|
|
const finalCount = await page
|
|
.locator('[data-marker-kind="frozen-square"]')
|
|
.count();
|
|
expect(finalCount).toBeLessThanOrEqual(32);
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
// ── RUNTIME 2 (Batch D) — tpl-nuclear-fallout ────────────────────────
|
|
//
|
|
// 3 sibling random-pick → spawn-marker primitives. Each `random-pick`
|
|
// draws from the 0..63 square index list using the engine's
|
|
// deterministic Mulberry32 RNG (rngSeed via setupBoard). Each
|
|
// spawn-marker drops a `blocked` marker.
|
|
//
|
|
// Walker-artifact note: same double-fire as tpl-ice-age — 3 from
|
|
// the walker's child-recursion plus 3 from the post-walk dispatcher
|
|
// = up to 6 markers. Smoke variant: assert `>= 3` with `<= 6` upper
|
|
// bound. Note: random-pick can collide on the same square in a
|
|
// single draw (the spawn-marker might no-op on a square already
|
|
// marked blocked, depending on the marker-stack semantics) — we
|
|
// assert the LOWER bound only since exact count depends on RNG +
|
|
// walker behaviour.
|
|
test('tpl-nuclear-fallout: activation spawns >= 3 blocked-square markers', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Seed the RNG before activation. The default seed (0) leaves
|
|
// the stream at a deterministic but unspecified offset. Pin a
|
|
// known seed for reproducibility — the count assertion is
|
|
// RNG-shape-agnostic but the seed pin makes failure modes
|
|
// debuggable.
|
|
await setupBoard(page, {
|
|
code: room.code,
|
|
rngSeed: 42,
|
|
});
|
|
|
|
// Pre-activation pin: zero blocked markers.
|
|
await expect(page.locator('[data-marker-kind="blocked"]')).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
await applyDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: NUCLEAR_FALLOUT_DESCRIPTOR,
|
|
rngSeed: 42,
|
|
});
|
|
|
|
await expect
|
|
.poll(
|
|
async () => page.locator('[data-marker-kind="blocked"]').count(),
|
|
{ timeout: 5000 },
|
|
)
|
|
.toBeGreaterThanOrEqual(3);
|
|
|
|
// Upper bound — walker double-fire produces at most 6 markers.
|
|
const finalCount = await page
|
|
.locator('[data-marker-kind="blocked"]')
|
|
.count();
|
|
expect(finalCount).toBeLessThanOrEqual(6);
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
// ── RUNTIME 3 (Batch F) — tpl-drafted-for-battle ─────────────────────
|
|
//
|
|
// The recipe's primitives[0].kind is on-rule-activated and the inner
|
|
// arm's primitives[0].kind is request-choice (kind=piece,
|
|
// forPlayer=white). The activate-descriptor handler synthesizes the
|
|
// PendingChoice frame the trigger dispatcher would normally push on
|
|
// activation, so the front-end's RequestChoiceModal renders
|
|
// immediately — the same lift mechanism `tpl-mind-control` uses.
|
|
test('tpl-drafted-for-battle: 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');
|
|
|
|
await activateDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: DRAFTED_FOR_BATTLE_DESCRIPTOR,
|
|
chooserColor: 'white',
|
|
liftedId: 'tpl-drafted-for-battle__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();
|
|
});
|
|
});
|