feat(thressgame-100): Wave 5 \u2014 economy + score chips + 3 recipes + e2e
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)
This commit is contained in:
parent
3a4bd394aa
commit
a2c38a9ad2
23 changed files with 3465 additions and 5 deletions
770
packages/chess/e2e/wave5-economy.spec.ts
Normal file
770
packages/chess/e2e/wave5-economy.spec.ts
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue