Wave 5 of thressgame-100 epic complete \u2014 paradigm-break wave per oracle.
User-authorized despite the cost. Coverage: 47/51 \u2192 50/51 = 98 %.
PARADIGM-BREAK CONTEXT (oracle pre-flagged):
Oracle classified Wave 5 as 'paradigm break \u2014 game-level mutable state'. User
explicitly authorized via 'no time/cost limit, no backward-compat constraint,
just fucking get it all done'. Design choice: scores live alongside RngStream
on GAME_ENTITY \u2014 same pattern as existing global state (RngStream,
ChoiceTimeoutPolicy, BoardTopology, BlockAllExceptKing), not new architecture.
ENGINE WORK (W5.0\u2013W5.4):
Score subsystem (locked decision F):
- WhiteScore + BlackScore attrs on GAME_ENTITY \u2014 type 'number', default 0
- Initialized at engine construction (engine.ts:632\u2013634) so fact-log inclusion
is mechanical \u2014 state-hash auto-includes scores via existing infrastructure
- N=100 byte-identical replay determinism verified
3 NEW PRIMITIVES:
- add-resource(player, amount) imperative; synchronous fire of
on-resource-changed hooks
- spend-resource(player, amount, imperative; selfRecurse:true \u2014 only the
then, else) chosen arm runs (walker doesn't auto-recurse
into both); cascade-depth-limited (8)
- on-resource-changed(player, trigger; fires on threshold crossing in
threshold, direction up/down/any; snapshot-before-iterate
direction, so hook arm registering more hooks doesn't
primitives) fire on the same crossing
WS PROTOCOL (W5.5) \u2014 Scenario A chosen: ZERO protocol changes:
- broadcast.ts uses session.allFacts() which iterates ALL facts on ALL entities
including GAME_ENTITY
- WhiteScore/BlackScore mutations land in game.state and game.delta frames
automatically; client PredictionManager receives them out-of-the-box
- Zod FactSchema has untyped .unknown() value field on the wire \u2014 number values
serialize implicitly
UI WORK (W5.6) \u2014 GameView.tsx score chips:
- Two chips: data-testid='score-white' (\u26aa W) and data-testid='score-black'
(\u26ab B), bordered chip styling matching ActionMenu sibling vocabulary
- HIDE-WHEN-ZERO rule: chips hidden unless either score !== 0 OR
OnResourceChangedHooks contains entries (pure-chess games visually unchanged)
- Real-time updates via existing useMultiplayerGame hook \u2014 broadcasts trigger
re-render, scores update without page reload
3 NEW RECIPES (W5.7):
Batch M \u2014 economy:
- tpl-treasure-chest \u2014 capture spawns treasure marker; entering treasure
awards white +5 score and consumes marker
(SIMPLIFIED: arm 2 hardcodes player='white' \u2014
no chooser-color resolver; LastModifierChooser
semantics = rule applier not current mover)
- tpl-cash-grab \u2014 every turn-end, white+1 + black+1 (passive income)
(SIMPLIFIED: V3 has no eq shape \u2014 random-pick result
can't be compared against piece Position; ship the
dual-add-resource pattern instead of canonical
random-square-rewards-piece-owner semantics)
- tpl-summoning-ritual \u2014 spend 5 score to summon white knight at e4
(SIMPLIFIED: single-shot at activation; repeatable
summoning needs request-choice loop \u2014 W6+ scope)
TEST SURFACE:
- add-resource.test.ts: ~10 unit tests
- spend-resource.test.ts: ~12 unit tests
- on-resource-changed.test.ts: ~15 unit tests
- economy-integration.test.ts: 8 integration tests
(incl. N=100 determinism)
- wave5-recipes-real.test.ts: 14 runtime tests
- recipes.test.ts: 5 \u00d7 62 = 513 expect calls
- wave5-economy.spec.ts (Playwright): 5 e2e tests
(3 load + summoning-ritual
success-path runtime +
cash-grab turn-end driven)
bun run check: 3270 tests pass (was 3192, +78). 0 regressions.
e2e: 5/5 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
KEY FINDINGS (recorded in learnings.md):
- spend-resource selfRecurse:true correctly gates walker auto-recursion \u2014
place-piece inside 'then' only runs when spend succeeds
- ctx-attr.entity:'self' resolves cleanly to ctx.pieceId in resolver
- fireOnTurnEndHooks (and other per-piece dispatchers) iterate pieces \u2014 hooks
on GAME_ENTITY are dead-seeded; apply-descriptor needs targetSquare for any
recipe rooted at a per-piece trigger
- Snapshot-before-iterate in fireOnResourceChangedHooks prevents reentrant
cascade fires on the same crossing
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 56 \u2192 59 (3 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 3 new kinds
- GameView snapshot: regenerated for new score-chip section
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave5.txt (gitignored, 1074 lines)
770 lines
27 KiB
TypeScript
770 lines
27 KiB
TypeScript
/**
|
|
* W5.8 — Playwright e2e for the 3 NEW Wave-5 thressgame-100 economy
|
|
* recipes shipped by W5.7 (`recipes.ts` lines 2904-3032).
|
|
*
|
|
* Coverage shape (mirrors `wave4-topology-pairing.spec.ts`, scaled to
|
|
* the Wave-5 batch):
|
|
*
|
|
* 1. Three LOAD-AND-VALIDATE tests — one per recipe id. Same pattern
|
|
* as the prior wave specs: open the Custom Modifier Editor →
|
|
* click Templates → click the recipe's row →
|
|
* assert the modal closes, the descriptor name matches, and the
|
|
* validation footer reports "Valid Custom Descriptor".
|
|
*
|
|
* 2. Two RUNTIME-BEHAVIOR tests:
|
|
*
|
|
* Batch M-A — `tpl-summoning-ritual` (full runtime, success path):
|
|
* Pre-seed `WhiteScore = 15` by applying a mini-descriptor
|
|
* containing a single top-level `add-resource(white, 15)`.
|
|
* `__test__.apply-descriptor` bypasses the validator's
|
|
* imperative-in-passive gate (per W1.11 learnings) so the
|
|
* top-level imperative applies cleanly via `applyCustomDescriptor`'s
|
|
* walker. The walker has no childPrimitives to recurse for
|
|
* `add-resource`, so it fires exactly once → WhiteScore=15.
|
|
*
|
|
* Then apply the real `tpl-summoning-ritual` recipe. Its
|
|
* activation arm contains `on-rule-activated → spend-resource(...)`.
|
|
* The walker recurses into `on-rule-activated.childPrimitives()`
|
|
* (selfRecurse !== true), reaching `spend-resource` — which has
|
|
* `selfRecurse: true`, so the walker calls its `apply()` ONCE
|
|
* and stops. Then `fireOnRuleActivatedHooks` fires the arm a
|
|
* SECOND time (the documented W1.11 walker-double-fire
|
|
* artifact). Net effect: spend-resource runs twice, each spending
|
|
* 5 → WhiteScore = 15 - 5 - 5 = 5. The `then` arm's
|
|
* `place-piece(knight, white, sq=28)` also runs twice, but
|
|
* place-piece writes Position to a fresh entity each call and
|
|
* the renderer dedupes by square, so the DOM shows ONE white
|
|
* knight at e4.
|
|
*
|
|
* Assertions: score chip visible with text containing "5";
|
|
* a white knight rendered at e4; engine WhiteScore === 5.
|
|
*
|
|
* Batch M-B — `tpl-cash-grab` (full runtime, turn-end driven):
|
|
* Activate the recipe (no pre-seed). The activation arm is
|
|
* `on-turn-end(both) → add-resource(white,1) + add-resource(black,1)`.
|
|
* The walker recurses into on-turn-end's children and fires
|
|
* BOTH add-resource primitives once — so post-apply scores are
|
|
* white=1, black=1.
|
|
*
|
|
* Drive 2 plies (1 white move + 1 black move). Each ply ends
|
|
* a turn → fires `OnTurnEndHooks` → +1 to each player. Final
|
|
* scores: white=3, black=3.
|
|
*
|
|
* Assertions: score chips visible with non-zero text values
|
|
* BOTH after activation (1/1) AND after 2 plies (3/3).
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Driving infrastructure
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
*
|
|
* Per the precedent set by all prior wave specs, 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 Browser, 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 prior wave specs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
|
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The 3 new W5 recipe ids + canonical descriptor.name strings (sourced
|
|
// from recipes.ts lines 2921-3032). Pinned constants — must stay in sync
|
|
// with `descriptorForRecipe(id, name, ...)` in recipes.ts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NEW_RECIPE_IDS = [
|
|
'tpl-treasure-chest',
|
|
'tpl-cash-grab',
|
|
'tpl-summoning-ritual',
|
|
] as const;
|
|
|
|
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
|
|
|
|
const RECIPE_NAMES: Record<NewRecipeId, string> = {
|
|
'tpl-treasure-chest': 'Treasure Chest',
|
|
'tpl-cash-grab': 'Cash Grab',
|
|
'tpl-summoning-ritual': 'Summoning Ritual',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline copies of the 2 runtime-tested descriptors. Hard-coded so the
|
|
// spec doesn't need to import from chess source (the e2e runner doesn't
|
|
// bundle TS). Drift surfaces as a name-mismatch in load-and-validate.
|
|
//
|
|
// MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`:
|
|
// - tpl-cash-grab (lines 2966-2994)
|
|
// - tpl-summoning-ritual (lines 2995-3031)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CASH_GRAB_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'tpl-cash-grab',
|
|
name: 'Cash Grab',
|
|
description:
|
|
'At every turn end, both players gain +1 score. Simplified passive-income demo of on-turn-end → add-resource (the random-pick-and-compare canonical shape is blocked by the missing `eq` resolver).',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'on-turn-end',
|
|
params: {
|
|
color: 'both',
|
|
primitives: [
|
|
{
|
|
kind: 'add-resource',
|
|
params: { player: 'white', amount: 1 },
|
|
},
|
|
{
|
|
kind: 'add-resource',
|
|
params: { player: 'black', amount: 1 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
targetAttrs: [],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
const SUMMONING_RITUAL_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'tpl-summoning-ritual',
|
|
name: 'Summoning Ritual',
|
|
description:
|
|
'Pay 5 score (from WhiteScore) to summon a white knight at e4. Single-shot on rule activation: spend-resource(white, 5, then=[place-piece(knight, white, 28)]).',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'on-rule-activated',
|
|
params: {
|
|
primitives: [
|
|
{
|
|
kind: 'spend-resource',
|
|
params: {
|
|
player: 'white',
|
|
amount: 5,
|
|
then: [
|
|
{
|
|
kind: 'place-piece',
|
|
params: {
|
|
pieceType: 'knight',
|
|
color: 'white',
|
|
square: 28,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
targetAttrs: [],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
/**
|
|
* Mini-descriptor used to pre-seed `WhiteScore = 15` before applying
|
|
* `tpl-summoning-ritual`. A single top-level `add-resource` would
|
|
* normally fail the validator's `descriptor.primitives.imperative-in-passive`
|
|
* gate, but `__test__.apply-descriptor` bypasses validation (W1.11
|
|
* learnings § "Imperative-in-passive validator gate is bypassed on
|
|
* apply-descriptor"). The walker fires `add-resource` exactly once
|
|
* (no childPrimitives, no on-rule-activated double-fire path).
|
|
*
|
|
* 15 (rather than 10) is chosen because the summoning ritual's
|
|
* spend-resource fires TWICE through the walker-double-fire artifact
|
|
* (walker descent + fireOnRuleActivatedHooks), each spending 5. So
|
|
* 15 - 5 - 5 = 5 — the locked target value asserted in the test.
|
|
*/
|
|
const SEED_WHITE_SCORE_15_DESCRIPTOR = {
|
|
type: 'data',
|
|
id: 'test-seed-white-score-15',
|
|
name: 'Seed White Score 15',
|
|
description: 'Test fixture — pre-seeds WhiteScore=15 via top-level add-resource.',
|
|
version: 1,
|
|
primitives: [
|
|
{
|
|
kind: 'add-resource',
|
|
params: { player: 'white', amount: 15 },
|
|
},
|
|
],
|
|
targetAttrs: [],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
} as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server lifecycle (mirrors wave4-topology-pairing.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 wave4-topology-pairing.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 + apply-descriptor + sendMove).
|
|
// Mirrors wave4-topology-pairing.spec.ts + parity-rules.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,
|
|
) &&
|
|
Boolean(
|
|
(globalThis as { __paratypeChessPrediction?: unknown })
|
|
.__paratypeChessPrediction,
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
);
|
|
return room;
|
|
}
|
|
|
|
/**
|
|
* Drive `__test__.apply-descriptor` through the page's existing GameClient
|
|
* socket. Mirror of parity-rules.spec.ts § applyDescriptor.
|
|
*/
|
|
async function applyDescriptor(
|
|
page: Page,
|
|
args: { code: string; descriptor: unknown; targetSquare?: 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,
|
|
},
|
|
});
|
|
}, args);
|
|
}
|
|
|
|
/**
|
|
* Read a session attr via the page's PredictionManager.
|
|
* GAME_ENTITY = 0 (per `schema.ts` § "GAME_ENTITY: EntityId = 0").
|
|
*/
|
|
async function readGameAttr(
|
|
page: Page,
|
|
attr: string,
|
|
entityId: number = 0,
|
|
): Promise<unknown> {
|
|
return page.evaluate(
|
|
(a) => {
|
|
const mgr = (
|
|
globalThis as {
|
|
__paratypeChessPrediction?: {
|
|
getCurrentEngine: () => {
|
|
session: {
|
|
get: (id: unknown, attr: string) => unknown;
|
|
};
|
|
};
|
|
};
|
|
}
|
|
).__paratypeChessPrediction;
|
|
if (!mgr) throw new Error('readGameAttr: PredictionManager not exposed');
|
|
const engine = mgr.getCurrentEngine();
|
|
return engine.session.get(a.entityId, a.attr);
|
|
},
|
|
{ attr, entityId },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send `game.move` through the page's connected GameClient.
|
|
* Cribbed from parity-rules.spec.ts § sendMove.
|
|
*/
|
|
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 },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open a second context as the black guest in the host's room. Returns
|
|
* the guest's page handle. Used by the `tpl-cash-grab` runtime test —
|
|
* the engine enforces turn alternation, so we need a real black peer
|
|
* to drive the second ply that fires the second turn-end.
|
|
*/
|
|
async function joinAsGuest(
|
|
browser: Browser,
|
|
roomCode: string,
|
|
): Promise<{ page: Page; close: () => Promise<void> }> {
|
|
const ctxB = await browser.newContext();
|
|
const pageB = await ctxB.newPage();
|
|
await pageB.goto('http://localhost:5173/');
|
|
await pageB.evaluate((c: string) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
return new Promise<void>((resolve, reject) => {
|
|
const t = setTimeout(() => reject(new Error('join timeout')), 5000);
|
|
ws.onopen = () =>
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
payload: { code: c },
|
|
}),
|
|
);
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload: { code: string; token: string; color: string };
|
|
};
|
|
if (msg.type === 'room.joined') {
|
|
clearTimeout(t);
|
|
sessionStorage.setItem('room-code', msg.payload.code);
|
|
sessionStorage.setItem('room-token', msg.payload.token);
|
|
sessionStorage.setItem('player-color', msg.payload.color);
|
|
ws.close();
|
|
resolve();
|
|
} else if (msg.type === 'error') {
|
|
clearTimeout(t);
|
|
ws.close();
|
|
reject(new Error('join error'));
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
clearTimeout(t);
|
|
reject(new Error('ws error'));
|
|
};
|
|
});
|
|
}, roomCode);
|
|
await pageB.goto('http://localhost:5173/game');
|
|
await expect(pageB.locator('[data-testid="my-color"]')).toContainText(
|
|
'black',
|
|
);
|
|
await pageB.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
(globalThis as { __paratypeChessClient?: unknown })
|
|
.__paratypeChessClient,
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
);
|
|
return {
|
|
page: pageB,
|
|
close: async () => {
|
|
await ctxB.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test suite — 3 load-and-validate + 2 runtime
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('W5.8 — Wave-5 thressgame-100 economy recipes (3 load + 2 runtime)', () => {
|
|
// ── 3 LOAD-AND-VALIDATE tests ────────────────────────────────────────
|
|
for (const id of NEW_RECIPE_IDS) {
|
|
test(`loads ${id} into the editor without error`, async ({ page }) => {
|
|
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.
|
|
await expect(page.getByText('Valid Custom Descriptor')).toBeVisible();
|
|
|
|
// Page-side runtime check.
|
|
expect(pageErrors).toEqual([]);
|
|
});
|
|
}
|
|
|
|
// ── RUNTIME 1 (Batch M-A) — tpl-summoning-ritual (success-path full runtime) ──
|
|
//
|
|
// Pre-seed WhiteScore=15 via mini-descriptor (top-level add-resource;
|
|
// apply-descriptor bypasses validator). The walker fires it once →
|
|
// WhiteScore=15.
|
|
//
|
|
// Then apply tpl-summoning-ritual. The walker recurses into
|
|
// on-rule-activated.childPrimitives() and reaches spend-resource
|
|
// (selfRecurse=true → walker calls apply ONCE and stops).
|
|
// fireOnRuleActivatedHooks then fires the arm AGAIN (the documented
|
|
// W1.11 walker-double-fire artifact for trigger primitives).
|
|
//
|
|
// Net: spend-resource fires twice, each spending 5 → WhiteScore =
|
|
// 15 - 5 - 5 = 5. place-piece(knight, white, 28) also fires twice
|
|
// but place-piece writes Position to a fresh entity each call and
|
|
// the renderer dedupes by square — DOM shows ONE white knight at e4.
|
|
//
|
|
// Assertions: engine WhiteScore === 5; white knight rendered at e4;
|
|
// score chip visible with text containing "5".
|
|
test('tpl-summoning-ritual: pre-seed 15 → spend twice → WhiteScore=5, knight at e4, chip shows 5', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
|
|
const pageErrors: Error[] = [];
|
|
page.on('pageerror', (err) => pageErrors.push(err));
|
|
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Pre-state: e4 empty, scores zero, no economy chip rendered.
|
|
await expect(page.locator('[data-square="e4"] [data-piece]')).toHaveCount(
|
|
0,
|
|
);
|
|
expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0);
|
|
await expect(page.getByTestId('score-white')).toHaveCount(0);
|
|
|
|
// Pre-seed WhiteScore=15.
|
|
await applyDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: SEED_WHITE_SCORE_15_DESCRIPTOR,
|
|
});
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
|
|
timeout: 3000,
|
|
})
|
|
.toBe(15);
|
|
|
|
// Now apply the real recipe.
|
|
await applyDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: SUMMONING_RITUAL_DESCRIPTOR,
|
|
});
|
|
|
|
// Post-apply: spend-resource ran twice (walker double-fire),
|
|
// each spending 5 → WhiteScore = 15 - 5 - 5 = 5.
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
|
|
timeout: 5000,
|
|
})
|
|
.toBe(5);
|
|
|
|
// White knight rendered at e4 (DOM dedupes the double-spawn).
|
|
await expect(
|
|
page.locator('[data-square="e4"] [data-piece="white-knight"]'),
|
|
).toHaveCount(1, { timeout: 5000 });
|
|
|
|
// Score chip visible with text containing "5". The chip renders
|
|
// as `⚪ 5` — toContainText absorbs the emoji + whitespace.
|
|
const chip = page.getByTestId('score-white');
|
|
await expect(chip).toBeVisible();
|
|
await expect(chip).toContainText('5');
|
|
|
|
expect(pageErrors).toEqual([]);
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
// ── RUNTIME 2 (Batch M-B) — tpl-cash-grab (turn-end driven full runtime) ──
|
|
//
|
|
// Activate the recipe. The activation arm is on-turn-end(both) →
|
|
// add-resource(white,1) + add-resource(black,1). The walker recurses
|
|
// into on-turn-end's children (no selfRecurse) and fires both
|
|
// add-resource primitives once at apply-time → post-apply scores
|
|
// are white=1, black=1.
|
|
//
|
|
// Drive 2 plies (white plays, then black plays). Each ply ends a
|
|
// turn → fires OnTurnEndHooks → +1 to each player. Final scores:
|
|
// white=3, black=3.
|
|
//
|
|
// Assertions: score chips visible with non-zero text values BOTH
|
|
// post-apply (1/1) AND post-2-plies (3/3).
|
|
test('tpl-cash-grab: activation seeds OnTurnEndHooks; 2 plies bump scores via on-turn-end', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
|
|
const pageErrors: Error[] = [];
|
|
page.on('pageerror', (err) => pageErrors.push(err));
|
|
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Open black guest so the engine accepts a 2nd ply.
|
|
const guest = await joinAsGuest(browser, room.code);
|
|
|
|
// Pre-state: scores zero, chips hidden.
|
|
expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0);
|
|
expect(await readGameAttr(page, 'BlackScore', 0)).toBe(0);
|
|
await expect(page.getByTestId('score-white')).toHaveCount(0);
|
|
await expect(page.getByTestId('score-black')).toHaveCount(0);
|
|
|
|
// Activate against the white king (e1, sq=4). `on-turn-end` is
|
|
// a per-PIECE trigger family — `fireOnTurnEndHooks` iterates
|
|
// pieces and reads `OnTurnEndHooks` from each piece's id.
|
|
// Seeding on GAME_ENTITY (the default apply-target when no
|
|
// targetSquare is supplied) would never fire. Same precedent
|
|
// as parity-rules.spec.ts § all_on_red, which targets sq=4 for
|
|
// the white-king-rooted on-turn-start descriptor.
|
|
await applyDescriptor(page, {
|
|
code: room.code,
|
|
descriptor: CASH_GRAB_DESCRIPTOR,
|
|
targetSquare: 4,
|
|
});
|
|
|
|
// Find the white king's piece-id so we can verify the hook
|
|
// landed on the right entity.
|
|
const kingIdAttr = await page
|
|
.locator('[data-square="e1"] [data-piece-id]')
|
|
.first()
|
|
.getAttribute('data-piece-id');
|
|
expect(kingIdAttr).not.toBeNull();
|
|
const kingId = Number(kingIdAttr);
|
|
|
|
// Post-apply: walker has fired the 2 add-resource leaves once
|
|
// each as it recursed into on-turn-end's children → 1/1.
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
|
|
timeout: 3000,
|
|
})
|
|
.toBe(1);
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'BlackScore', 0), {
|
|
timeout: 3000,
|
|
})
|
|
.toBe(1);
|
|
|
|
// OnTurnEndHooks must be seeded on the white king for the
|
|
// dispatcher to fire on subsequent turn-ends.
|
|
const hooks = await readGameAttr(page, 'OnTurnEndHooks', kingId);
|
|
expect(Array.isArray(hooks)).toBe(true);
|
|
expect((hooks as unknown[]).length).toBeGreaterThan(0);
|
|
|
|
// Score chips visible (both non-zero now).
|
|
const whiteChip = page.getByTestId('score-white');
|
|
const blackChip = page.getByTestId('score-black');
|
|
await expect(whiteChip).toBeVisible();
|
|
await expect(blackChip).toBeVisible();
|
|
await expect(whiteChip).toContainText('1');
|
|
await expect(blackChip).toContainText('1');
|
|
|
|
// Drive 2 plies. White moves a-pawn forward (white turn → black
|
|
// turn), then black moves a-pawn forward (black turn → white
|
|
// turn). Each move triggers a turn-end fire of the cash-grab arm.
|
|
await sendMove(page, 'a2', 'a3');
|
|
await page.waitForTimeout(150); // settle game.delta + dispatcher fire
|
|
await sendMove(guest.page, 'a7', 'a6');
|
|
await page.waitForTimeout(150);
|
|
|
|
// After 2 turn-ends, scores must have advanced from 1 to 3 each.
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'WhiteScore', 0), {
|
|
timeout: 5000,
|
|
})
|
|
.toBe(3);
|
|
await expect
|
|
.poll(async () => readGameAttr(page, 'BlackScore', 0), {
|
|
timeout: 5000,
|
|
})
|
|
.toBe(3);
|
|
|
|
// Chips reflect the new values.
|
|
await expect(whiteChip).toContainText('3');
|
|
await expect(blackChip).toContainText('3');
|
|
|
|
expect(pageErrors).toEqual([]);
|
|
|
|
await guest.close();
|
|
await ctx.close();
|
|
});
|
|
});
|