feat(thressgame-coverage): Waves 16-18 (Playwright e2e for choice-kinds + move-gen attrs + orphan primitives)

Wave 16 — 3/3 untested choice-kinds covered (choice-kinds.spec.ts):
- square: 8x8 grid → click e4 → treasure marker spawns
- row: row picker → click row 3 → 8 treasure markers along row 3
- coin-flip: heads/tails buttons → click heads → CoinFlipResult set on GAME_ENTITY

Wave 17 — 1 active + 5 fixme (move-gen-attrs.spec.ts):
- KingExtraReach=2 PASSES (king steps 2 squares)
- MovesAs/MovesAlsoAs/BlockedPieceTypes/MoveClassRestriction/PawnPushesPiecesEnabled fixme'd: drag library doesn't reject illegal moves at UI layer (move-gen filter is engine-side; UI is permissive). Documented limitation; engine behavior verified at unit level (Wave 12 tests).

Wave 18 — 11 active + 1 fixme (orphan-primitives.spec.ts):
- place-piece, move-piece, swap-pieces, convert-piece-type
- for-each-piece, for-each-square, for-each-adjacent, for-each-marker
- block-by-piece-type primitive (consumer attr Wave 12)
- on-rule-expire (descriptor detach fires arm)
- on-marker-expire (lifetime sweep fires hook)
- spawn-marker-pair (mutual MarkerLinks)
- must-class fixme: consumer deferred (descriptor seeds attr; rules/turn filter not yet wired for must-class specifically)

E2E total: 27 active passing + 7 fixme. Unit tests: 2866 passing. bun run check exit 0.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 18:43:01 -06:00
commit f762b6d207
No known key found for this signature in database
4 changed files with 2978 additions and 1 deletions

View file

@ -96,7 +96,9 @@
"ses_23413e9bdffemN8WkabmXJVK5t",
"ses_233f787b9ffeYWzzTLHpG5VJks",
"ses_233f7318effe2R0Vt2ad27KzEZ",
"ses_233cc34d1ffe9ys7V39oRNCcO0"
"ses_233cc34d1ffe9ys7V39oRNCcO0",
"ses_233bcb2b3ffeI06xdt1zAAeqig",
"ses_233bc366effeMrTyc60acFDGv1"
],
"plan_name": "thressgame-coverage",
"agent": "atlas"

View file

@ -0,0 +1,456 @@
/**
* Wave 16 Playwright E2E: request-choice round-trips for the
* three remaining choice kinds (square, row, coin-flip).
*
* Coverage matrix (T58 supports 6 kinds; rps/piece/column already
* covered by `request-choice.spec.ts`):
*
* 1. square modal renders the 8x8 ParamSquarePicker grid;
* click e4 (LERF index 28) continuation spawns
* a permanent treasure marker on e4.
*
* 2. row modal renders 8 row buttons; click row 3
* continuation iterates `for-column` over all 8
* columns and spawns a treasure marker at every
* square in row 3 (LERF squares 24..31).
*
* 3. coin-flip modal renders heads/tails buttons; click heads
* continuation writes the bound value onto
* GAME_ENTITY (id 0) under the `CoinFlipResult`
* attribute. Verified via the dev-only
* `__paratypeChessPrediction` global (mirroring
* the assertion strategy of T68/3).
*
* Activation path
* ----------------
* Same pattern as `request-choice.spec.ts`:
*
* - `__test__.activate-descriptor` debug WS frame routed through
* the existing `__paratypeChessClient` socket. The handler
* lifts the inner `on-rule-activated` arm so primitives[0]
* becomes the request-choice (T79).
*
* - The PendingChoice frame is pushed onto the LIFO stack with
* the lifted descriptor's id; submitChoiceAndResume resolves
* it on the engine's customModifiers registry.
*
* - `forPlayer: "white"` keeps the broadcast on the host's socket
* only single-context tests below.
*
* Continuation gaps documented in `request-choice.spec.ts` (gap I
* `for-column` double-recurse) apply to scenario 2 below: the inner
* arm spawns all 8 markers BEFORE the post-iteration child-walk
* trips a BindingError on `$row`, which the server's submit-choice
* handler swallows with a logger.warn. The post-resolve board state
* is correct (8 treasure markers), so the test passes.
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors `request-choice.spec.ts` / `multiplayer.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;
}
});
// ---------------------------------------------------------------------------
// Helpers (copied from request-choice.spec.ts — kept inline to keep this
// spec independently runnable in CI sharding).
// ---------------------------------------------------------------------------
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();
return room;
}
/**
* T79 drive the test-only `__test__.activate-descriptor` frame
* over the existing __paratypeChessClient socket. The lifted
* descriptor's primitives[0] must be `request-choice` (the lift
* helper unwraps the on-rule-activated arm before registering).
*/
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);
}
/**
* Build a minimal CustomModifierDescriptor envelope around an
* `on-rule-activated` arm. Saves the per-test boilerplate of
* declaring `type/version/source/uiForm/targetAttrs`.
*/
function makeDescriptor(
id: string,
arm: ReadonlyArray<{ kind: string; params: Record<string, unknown> }>,
): Record<string, unknown> {
return {
type: 'data',
id,
name: id,
description: `Wave 16 e2e descriptor (${id}).`,
version: 1,
uiForm: 'primitive-composer',
source: 'custom',
targetAttrs: [],
primitives: [
{
kind: 'on-rule-activated',
params: { primitives: arm },
},
],
};
}
// ---------------------------------------------------------------------------
// Test A — square choice-kind
// ---------------------------------------------------------------------------
test('Wave16/A square choice-kind: modal grid → click e4 → treasure marker spawns', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
const descriptor = makeDescriptor('wave16:square-choice', [
{
kind: 'request-choice',
params: {
kind: 'square',
prompt: 'Pick a square',
forPlayer: 'white',
bind: 'sq',
then: [
{
kind: 'spawn-marker',
params: {
markerKind: 'treasure',
square: { $var: 'sq' },
lifetime: { kind: 'permanent' },
},
},
],
},
},
]);
await activateDescriptor(page, {
code: room.code,
descriptor,
chooserColor: 'white',
liftedId: 'wave16:square-choice__lifted',
});
// Modal opens with kind=square. ParamSquarePicker renders an
// 8x8 grid of buttons (64 total).
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal).toHaveAttribute('data-choice-kind', 'square');
await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount(64);
// Click e4 (LERF index 28). ParamSquarePicker tags each button
// with `aria-label="Square <sq>"`; we click via that label.
await modal.locator('button[aria-label="Square 28"]').click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Treasure marker visible at e4. Default marker rendering in
// Board.tsx puts data-marker-kind={kind} on the absolutely-
// positioned label div inside the [data-square=…] cell.
await expect(
page.locator('[data-square="e4"] [data-marker-kind="treasure"]'),
).toBeVisible({ timeout: 5000 });
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test B — row choice-kind
// ---------------------------------------------------------------------------
test('Wave16/B row choice-kind: modal row buttons → click row 3 → 8 treasure markers along row 3', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Continuation: for-column iterates 0..7, binding `col`; inner
// spawn-marker resolves square via {ctx-build:{col,row}} → drops
// a treasure marker on every square in the chosen row.
const descriptor = makeDescriptor('wave16:row-choice', [
{
kind: 'request-choice',
params: {
kind: 'row',
prompt: 'Pick a row',
forPlayer: 'white',
bind: 'row',
then: [
{
kind: 'for-column',
params: {
columns: [0, 1, 2, 3, 4, 5, 6, 7],
bind: 'col',
then: [
{
kind: 'spawn-marker',
params: {
markerKind: 'treasure',
square: {
'ctx-build': {
col: { $var: 'col' },
row: { $var: 'row' },
},
},
lifetime: { kind: 'permanent' },
},
},
],
},
},
],
},
},
]);
await activateDescriptor(page, {
code: room.code,
descriptor,
chooserColor: 'white',
liftedId: 'wave16:row-choice__lifted',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal).toHaveAttribute('data-choice-kind', 'row');
// Modal shows 8 row buttons (data-row="0".."7"); same picker
// layout as the column kind.
await expect(modal.locator('button[data-row]')).toHaveCount(8);
// Click row 3 (LERF rank 3 → squares 24..31, files a4..h4).
await modal.locator('button[data-row="3"]').click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// 8 treasure markers along row 3.
for (const square of ['a4', 'b4', 'c4', 'd4', 'e4', 'f4', 'g4', 'h4']) {
await expect(
page.locator(`[data-square="${square}"] [data-marker-kind="treasure"]`),
).toBeVisible({ timeout: 5000 });
}
await ctx.close();
});
// ---------------------------------------------------------------------------
// Test C — coin-flip choice-kind
// ---------------------------------------------------------------------------
test('Wave16/C coin-flip choice-kind: modal heads/tails → click heads → CoinFlipResult set on GAME_ENTITY', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Continuation writes the bound choice value onto GAME_ENTITY
// (id 0) under a debug attr (CoinFlipResult). set-piece-attr's
// `attr` field is free-form (string-min-1), and `target=0`
// targets GAME_ENTITY per its longDescription.
const descriptor = makeDescriptor('wave16:coin-flip-choice', [
{
kind: 'request-choice',
params: {
kind: 'coin-flip',
prompt: 'Heads or tails?',
forPlayer: 'white',
bind: 'flip',
then: [
{
kind: 'set-piece-attr',
params: {
target: 0,
attr: 'CoinFlipResult',
value: { $var: 'flip' },
},
},
],
},
},
]);
await activateDescriptor(page, {
code: room.code,
descriptor,
chooserColor: 'white',
liftedId: 'wave16:coin-flip-choice__lifted',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal).toHaveAttribute('data-choice-kind', 'coin-flip');
// Modal renders exactly 2 buttons — heads + tails.
await expect(modal.locator('button[data-coin]')).toHaveCount(2);
await expect(modal.locator('button[data-coin="heads"]')).toBeVisible();
await expect(modal.locator('button[data-coin="tails"]')).toBeVisible();
// Click heads → submitChoiceAndResume runs the continuation
// → set-piece-attr writes CoinFlipResult="heads" on GAME_ENTITY.
await modal.locator('button[data-coin="heads"]').click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Verify the continuation ran by reading the engine session via
// the dev-only PredictionManager hook (same pattern as T68/3's
// OnCapturedHooks assertion).
await expect
.poll(
async () =>
page.evaluate(() => {
const mgr = (
globalThis as {
__paratypeChessPrediction?: {
getCurrentEngine: () => {
session: { get: (id: number, attr: string) => unknown };
};
};
}
).__paratypeChessPrediction;
if (!mgr) return null;
return mgr.getCurrentEngine().session.get(0, 'CoinFlipResult') ?? null;
}),
{ timeout: 5000 },
)
.toBe('heads');
await ctx.close();
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff