feat(thressgame-coverage): Wave 15 (e2e for 5 parity rules + lift T68/3 parry)

Closes 5 of 5 unit-only parity rules with real Playwright validation:

- T83/all_on_red: probabilistic on-turn-start arm seeds BlockAllExceptKing (verified via UI move attempt + restoration)
- T83/ice_physics: SlideMustBeMaxDistance forces sliders to max-distance ray step (verified via legal-move highlight + drag rejection)
- T68/3 parry (lifted from .fixme): capture triggers RPS → defender wins → cancel-capture restores defender + reverts attacker
- T84/religious_conversion: bishop move converts adjacent enemy non-king pieces (verified via data-piece color flip)
- T84/kamikaze: capture triggers AOE destroying adjacent non-king; king immune (verified via DOM + RNG seed)

Helper: .sisyphus/scripts/run-pw.sh — nohup-based Playwright runner with done-marker poll. Avoids 30min agent timeout when running long e2e suites.

Tests: 2865 -> 2866 (+1 unit). E2E: 8/8 pass (was 3 active + 1 fixme; now 8 active + 0 fixme). bun run check exit 0.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 18:06:05 -06:00
commit 4c25277449
No known key found for this signature in database
14 changed files with 2822 additions and 79 deletions

View file

@ -63,20 +63,26 @@ import {
hasPendingChoice,
} from "./choice-timeout.js";
import {
algebraicToSquare,
applyCustomDescriptor,
asCustomModifierId,
GAME_ENTITY,
parseCustomModifierDescriptor,
peekPendingChoice,
PRESET_STATE_ENTITY,
pushPendingChoice,
rollbackAttackerFromSnapshot,
submitChoiceAndResume,
validateProfile,
type ActionResult,
type ChessAttrKey,
type CustomModifierDescriptor,
type EffectPrimitiveNode,
type ModifierProfile,
type ModifierValidationErrorCode,
type PendingChoice,
type PieceColor,
type PieceType,
type PlayerAction,
} from "@paratype/chess";
@ -854,6 +860,44 @@ function handleSubmitChoice(
}
forgetBroadcastedChoiceId(roomCode, frame.choiceId);
// T83 — post-resume capture-cleanup. When the resume's
// continuation fired `cancel-capture` (set CaptureCancelled =
// true and re-inserted defender facts), the dispatcher's stage
// 4b is no longer on the call stack to roll back the attacker
// and clear the snapshot — the move tick already returned
// before the player submitted. Mirror stage 4b's cleanup here:
// poll CaptureCancelled, roll back attacker Position +
// HasMoved, then retract the flag and the snapshot. Without
// this, a parry-style "defender wins" leaves the attacker on
// the destination square even though `cancel-capture` "fired"
// (only the defender side would be visible to clients).
//
// The poll is harmless when the continuation didn't fire
// cancel-capture (CaptureCancelled is undefined) — the
// snapshot retract is still safe (idempotent) so suspended
// non-capture flows that happen to share GAME_ENTITY don't
// leak state across moves. We only retract the snapshot when
// the stack drained, mirroring stage 4b's "clear when
// synchronously settled" rule (the resume IS the
// suspended-flow's settle step from the player's POV).
const engineAfterResume = session.getEngine();
const stackAfterResume = (
(engineAfterResume.session.get(GAME_ENTITY, "PendingChoices") as
| readonly unknown[]
| undefined) ?? []
).length;
if (stackAfterResume === 0) {
const cancelled =
engineAfterResume.session.get(GAME_ENTITY, "CaptureCancelled") === true;
if (cancelled) {
rollbackAttackerFromSnapshot(engineAfterResume);
engineAfterResume.session.retract(GAME_ENTITY, "CaptureCancelled");
}
if (engineAfterResume.session.contains(GAME_ENTITY, "LastCaptureSnapshot")) {
engineAfterResume.session.retract(GAME_ENTITY, "LastCaptureSnapshot");
}
}
// Surface the resulting state to clients so any continuation
// side-effect (HpBonus writes, fact mutations, terminal-state
// detection) becomes visible without waiting for the next
@ -906,6 +950,71 @@ function handleSubmitChoice(
*/
const TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE = "__test__.activate-descriptor";
const TEST_DEBUG_PUSH_CHOICE_TYPE = "__test__.push-pending-choice";
/**
* T83 — seed an `OnCapturedHooks` entry on a piece (resolved by
* algebraic square) so the e2e parry test can drive a real
* `on-captured` trigger via the engine's `applyMove` capture path
* without going through `applyCustomDescriptor` (which fires the
* inner `request-choice` eagerly at apply time, before the capture
* event arrives — incompatible with the capture-suspension contract).
*
* Mirrors the unit-test seeding strategy in
* `packages/chess/src/__fixtures__/parity/parry-real.test.ts` and
* `packages/server/src/ws.cancel-capture-revert.test.ts`: the
* descriptor's outer `on-captured` is ignored; we read its inner
* arm and write it directly onto the piece's `OnCapturedHooks`
* fact, with the descriptor's real id threaded through so
* `submitChoiceAndResume` can resolve the descriptor at resume
* time (Wave 14 / Gap G).
*/
const TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE = "__test__.seed-on-captured-hook";
/**
* T84 — broader test-only board-setup frame. Used by parity-religious /
* parity-rules e2e specs to:
* 1. Wipe the FIDE starting position (optionally preserving kings)
* so the test has full control over which pieces sit where.
* 2. Insert a deterministic placement set (`{ square, type, color }`)
* and return their assigned EntityIds keyed by handle.
* 3. Optionally pin `RngSeed` for descriptors with `with-probability`.
* 4. Optionally seed per-piece trigger hooks
* (OnMoveHooks / OnCaptureHooks / OnTurnEndHooks / …) with a
* descriptor's INNER arm — the same shape that on-move.apply() /
* on-capture.apply() would seed via the canonical seeder
* primitives. Mirrors the hand-seeding used in
* religious_conversion-real.test.ts and kamikaze-real.test.ts.
*
* Distinct from `__test__.seed-on-captured-hook` (T83): that handler
* is hard-coded to the on-captured primitive + writes to
* OnCapturedHooks. This one is generic across the 10 per-piece hook
* attrs and combines clear-board + place-pieces + seed-hooks +
* RngSeed in one round-trip — the e2e doesn't have to interleave
* three frames + three replies.
*
* Gated to NODE_ENV !== "production" alongside the other __test__.*
* frames.
*/
const TEST_DEBUG_SETUP_BOARD_TYPE = "__test__.setup-board";
/**
* T83 (Wave 15) — apply a custom descriptor in-place via
* `applyCustomDescriptor`. Drives parity rules whose root is NOT
* `on-rule-activated` wrapping a request-choice (e.g. `all_on_red`
* starts with `on-turn-start`; `ice_physics` starts with
* `on-rule-activated` wrapping `for-each-piece`). Distinct from
* `__test__.activate-descriptor` (T79) which only handles
* `on-rule-activated → request-choice` and synthesizes a
* PendingChoice frame; this one runs the full descriptor walker
* AND fires `on-rule-activated` hooks so a descriptor's
* activation cascade (seed `BlockAllExceptKing`,
* `SlideMustBeMaxDistance` on every slider, …) lands on the
* engine before the e2e's first move.
*
* Apply target is `GAME_ENTITY` (chosen for its symmetry with
* `applyCustomDescriptor`'s use in the integration preset). The
* walker writes per-piece hook seeds via `for-each-piece` arms;
* GAME_ENTITY is the canonical "no-particular-piece" target the
* other parity tests use (see ice_physics-real.test.ts).
*/
const TEST_DEBUG_APPLY_DESCRIPTOR_TYPE = "__test__.apply-descriptor";
const TEST_DEBUG_ENABLED = process.env["NODE_ENV"] !== "production";
interface TestActivateDescriptorPayload {
@ -930,7 +1039,11 @@ function isTestDebugFrame(parsed: unknown): parsed is { type: string; payload: u
const t = (parsed as { type: unknown }).type;
return (
typeof t === "string" &&
(t === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE || t === TEST_DEBUG_PUSH_CHOICE_TYPE)
(t === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE ||
t === TEST_DEBUG_PUSH_CHOICE_TYPE ||
t === TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE ||
t === TEST_DEBUG_SETUP_BOARD_TYPE ||
t === TEST_DEBUG_APPLY_DESCRIPTOR_TYPE)
);
}
@ -1108,6 +1221,753 @@ function handleTestActivateDescriptor(
void GAME_ENTITY; // silence unused import in case the lints get strict
}
/**
* T83 — payload shape for `__test__.seed-on-captured-hook`.
*
* `square`: 0..63 LERF index of the piece to receive the hook.
* The handler walks `session.allFacts()` to resolve the
* piece-id whose `Position` matches; if no piece is at the
* square the request returns INVALID_MESSAGE (non-fatal).
*
* `descriptor`: a custom-modifier descriptor whose top-level
* primitive MUST be `on-captured` — the handler reads the
* inner arm (`params.primitives`) and writes it onto the
* target piece's `OnCapturedHooks` fact.
*
* `roomCode`: same load-bearing reason as in
* `__test__.activate-descriptor` — the test frame travels on
* the existing authenticated client socket but the handler
* doesn't trust ws.data.roomCode (which the e2e doesn't
* populate via the multiplayer view's auth path).
*/
interface TestSeedOnCapturedHookPayload {
descriptor: unknown;
square: number;
roomCode: string;
}
function handleTestSeedOnCapturedHook(
ws: ServerWebSocket<ClientData>,
payload: unknown,
): void {
if (!TEST_DEBUG_ENABLED) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"test-debug frames disabled in production",
false,
),
);
return;
}
let parsedPayload: TestSeedOnCapturedHookPayload;
try {
if (typeof payload !== "object" || payload === null) {
throw new Error("payload must be an object");
}
parsedPayload = payload as TestSeedOnCapturedHookPayload;
if (typeof parsedPayload.roomCode !== "string") {
throw new Error("roomCode must be a string");
}
if (
typeof parsedPayload.square !== "number" ||
!Number.isInteger(parsedPayload.square) ||
parsedPayload.square < 0 ||
parsedPayload.square > 63
) {
throw new Error("square must be an integer in [0, 63]");
}
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.seed-on-captured-hook: ${(err as Error).message}`,
false,
),
);
return;
}
const session = sessionRegistry.get(parsedPayload.roomCode);
if (!session) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.seed-on-captured-hook: no session for room ${parsedPayload.roomCode}`,
false,
),
);
return;
}
let descriptor: CustomModifierDescriptor;
try {
descriptor = parseCustomModifierDescriptor(parsedPayload.descriptor);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.seed-on-captured-hook: descriptor parse failed: ${(err as Error).message}`,
false,
),
);
return;
}
const onCapturedNode = descriptor.primitives[0];
if (onCapturedNode === undefined || onCapturedNode.kind !== "on-captured") {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"__test__.seed-on-captured-hook: descriptor.primitives[0].kind must be 'on-captured'",
false,
),
);
return;
}
const innerArm = (onCapturedNode.params as {
primitives: EffectPrimitiveNode[];
}).primitives;
const engine = session.getEngine();
// T83 — register a LIFTED descriptor whose `primitives` ARE the
// inner arm (request-choice + then-continuation). This mirrors
// the activate-descriptor handler's `liftOnRuleActivatedArm`
// strategy and is load-bearing for the resume path:
//
// `submitChoiceAndResume` walks `descriptor.primitives` via
// `walkTriggerPath(triggerPath)`. The on-captured-fired
// PendingChoice frame carries `triggerPath: []` and
// `primitiveIndex: 0` (the request-choice's position inside
// the inner arm — that's what `runPrimitives` sees when the
// dispatcher invokes the hook with the inner arm as its node
// list). With the unlifted descriptor, `walkTriggerPath` would
// return `[on-captured]` and `arm[0]` would resolve to the
// wrapper node, NOT the request-choice — so the resume's
// `params.then` continuation walk would fail to find
// `cancel-capture`.
//
// The lifted descriptor's id is the original id with a stable
// suffix so re-seeding the same descriptor on a different
// square doesn't collide. The HOOK entry stores the LIFTED id
// (the one the dispatcher threads into the PendingChoice
// frame), so resume looks up the lifted descriptor and finds
// the request-choice at primitives[0] as expected.
const liftedId = `${String(descriptor.id)}__lifted-on-captured`;
const liftedDescriptor: CustomModifierDescriptor = {
...descriptor,
id: asCustomModifierId(liftedId),
primitives: innerArm,
};
engine.customModifiers.register(liftedDescriptor);
// Locate the piece at the given square. `pieceId` filtering
// mirrors the convention used in ws.cancel-capture-revert.test.ts:
// a Position fact with id > 0 is a piece (game-level entities
// use negative ids).
let targetId: number | null = null;
for (const f of engine.session.allFacts()) {
if (
f.attr === "Position" &&
f.value === parsedPayload.square &&
(f.id as number) > 0
) {
targetId = f.id as number;
break;
}
}
if (targetId === null) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.seed-on-captured-hook: no piece at square ${String(parsedPayload.square)}`,
false,
),
);
return;
}
// Cast through the session.insert binding's branded EntityId.
const targetEntity = targetId as unknown as Parameters<
typeof engine.session.insert
>[0];
const existing =
(engine.session.get(targetEntity, "OnCapturedHooks") as
| ReadonlyArray<{
descriptorId: string;
target: unknown;
primitives: EffectPrimitiveNode[];
}>
| undefined) ?? [];
engine.session.insert(targetEntity, "OnCapturedHooks", [
...existing,
{
descriptorId: liftedId,
target: "self",
primitives: innerArm,
},
]);
// T83: ensure the modifier-integration preset is active so the
// engine's `onAfterMove` dispatcher actually runs on the next
// applyMove tick. Without it, fireOnCapturedHooks never fires
// (the integration preset is what wires the trigger pipeline
// into the engine's move tick — see engine.ts § auto-activate
// when a profile is present). Default e2e rooms init the
// engine WITHOUT a profile (no `room.create` profile arg), so
// the preset isn't active by default. Idempotent: skipping the
// prepend when already present.
const presetList = engine.activePresets.list();
if (!presetList.some((p) => p.id === "__modifier-profile-integration__")) {
engine.activePresets.replaceAll([
{
id: "__modifier-profile-integration__",
scope: "both" as const,
turnsRemaining: null,
},
...presetList.map((p) => ({
id: p.id,
scope: p.scope,
turnsRemaining: p.turnsRemaining,
})),
]);
}
// T79: same choice-timeout relaxation as activate-descriptor —
// the e2e churns multiple sockets during setup and the default
// timeout-with-default policy auto-forfeits on transient
// disconnects. Set no-timeout so the suspended choice survives
// the test's timing variance.
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
mode: "no-timeout",
});
// T83: broadcast a fresh game.state so the e2e can `await` the
// round-trip via the client's snapshot listener (or simply via
// a brief settle delay) — without this, the seed insert is
// server-only and there's no observable signal the seed
// committed before the next inbound `game.move`.
broadcastGameStateSnapshot(parsedPayload.roomCode, session);
}
/**
* T83 (Wave 15) — payload + handler for `__test__.apply-descriptor`.
*
* Parses the supplied descriptor, registers it on the engine's
* customModifiers registry (so subsequent trigger fires can resolve
* the id), and runs `applyCustomDescriptor(engine, session,
* GAME_ENTITY, descriptor)`. The walker:
* - Walks every primitive in `descriptor.primitives` once,
* applying each via the registered primitive's `apply()`. For
* trigger primitives (`on-turn-start`, `on-rule-activated`,
* `for-each-piece`, …) `apply()` either seeds a hook fact (the
* pre-iteration triggers) or runs the inner cascade in-place
* (the `on-rule-activated` family — see
* `on-rule-activated.test.ts` § "Integration-level" for the
* fire-once contract).
* - Fires `on-rule-activated` hooks for any `on-rule-activated`
* blocks discovered in the tree, ONCE per descriptor instance.
*
* For descriptors like `all_on_red` (root = `on-turn-start`) the
* walker simply seeds `OnTurnStartHooks` on `GAME_ENTITY` — the
* trigger fires on subsequent applyMove() calls. For descriptors
* like `ice_physics` (root = `on-rule-activated → for-each-piece`)
* the walker runs the cascade in-place, setting
* `SlideMustBeMaxDistance = true` on every slider.
*
* After apply, broadcasts a fresh `game.state` snapshot so the
* client mirrors the post-apply engine facts.
*
* Optional `rngSeed` pins the engine's RNG — load-bearing for
* `with-probability` descriptors (`all_on_red`) where the e2e
* needs deterministic hits.
*/
interface TestApplyDescriptorPayload {
descriptor: unknown;
roomCode: string;
rngSeed?: number;
/**
* T83: optional square (0..63 LERF) to apply the descriptor to.
* Resolves to the piece-id at that square. Used by descriptors
* whose root trigger fires per-piece (e.g. `on-turn-start`,
* `on-move`) — the dispatcher's `fire*Hooks` iterators walk
* pieces, so a hook seeded on `GAME_ENTITY` would never fire.
* Defaults to `GAME_ENTITY` (-1) for descriptors whose root is
* `on-rule-activated` (one-shot game-level cascade — e.g.
* `ice_physics`).
*/
targetSquare?: number;
}
function handleTestApplyDescriptor(
ws: ServerWebSocket<ClientData>,
payload: unknown,
): void {
if (!TEST_DEBUG_ENABLED) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"test-debug frames disabled in production",
false,
),
);
return;
}
let parsedPayload: TestApplyDescriptorPayload;
try {
if (typeof payload !== "object" || payload === null) {
throw new Error("payload must be an object");
}
parsedPayload = payload as TestApplyDescriptorPayload;
if (typeof parsedPayload.roomCode !== "string") {
throw new Error("roomCode must be a string");
}
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.apply-descriptor: ${(err as Error).message}`,
false,
),
);
return;
}
const session = sessionRegistry.get(parsedPayload.roomCode);
if (!session) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.apply-descriptor: no session for room ${parsedPayload.roomCode}`,
false,
),
);
return;
}
let descriptor: CustomModifierDescriptor;
try {
descriptor = parseCustomModifierDescriptor(parsedPayload.descriptor);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.apply-descriptor: descriptor parse failed: ${(err as Error).message}`,
false,
),
);
return;
}
const engine = session.getEngine();
if (typeof parsedPayload.rngSeed === "number") {
engine.setRngSeed(parsedPayload.rngSeed);
}
// Register so trigger-fired choices (if any) can resolve the
// descriptor by id. Idempotent — re-applying the same descriptor
// re-runs the walker but `RuleActivatedFiredFor` guards against
// double-firing the activated cascade.
engine.customModifiers.register(descriptor);
// T83: ensure the modifier-integration preset is active. Same
// reasoning as in `handleTestSeedOnCapturedHook`: the trigger
// pipeline (fireOnTurnStartHooks, fireOnCapturedHooks, etc.)
// only runs when this preset is in the active set. Idempotent.
const presetList = engine.activePresets.list();
if (!presetList.some((p) => p.id === "__modifier-profile-integration__")) {
engine.activePresets.replaceAll([
{
id: "__modifier-profile-integration__",
scope: "both" as const,
turnsRemaining: null,
},
...presetList.map((p) => ({
id: p.id,
scope: p.scope,
turnsRemaining: p.turnsRemaining,
})),
]);
}
// Resolve the apply target: a piece-id at `targetSquare` if
// supplied, otherwise GAME_ENTITY. Per-piece hook descriptors
// (`on-turn-start`, `on-move`, …) need a real piece-id because
// the dispatchers iterate pieces only.
let applyTarget: typeof GAME_ENTITY = GAME_ENTITY;
if (typeof parsedPayload.targetSquare === "number") {
let resolved: number | null = null;
for (const f of engine.session.allFacts()) {
if (
f.attr === "Position" &&
f.value === parsedPayload.targetSquare &&
(f.id as number) > 0
) {
resolved = f.id as number;
break;
}
}
if (resolved === null) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.apply-descriptor: no piece at targetSquare ${String(parsedPayload.targetSquare)}`,
false,
),
);
return;
}
applyTarget = resolved as unknown as typeof GAME_ENTITY;
}
try {
applyCustomDescriptor(engine, engine.session, applyTarget, descriptor);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.apply-descriptor: apply threw: ${(err as Error).message}`,
false,
),
);
return;
}
// T79: same choice-timeout relaxation reasoning as the other
// test-debug handlers — the e2e churns sockets during setup; the
// default timeout-with-default policy auto-forfeits the room on a
// transient disconnect, which makes multi-second waits flaky.
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
mode: "no-timeout",
});
// Broadcast fresh game.state so the client renders the post-apply
// facts (e.g. `SlideMustBeMaxDistance = true` on every slider).
broadcastGameStateSnapshot(parsedPayload.roomCode, session);
}
// T84 — board setup payload + helpers ---------------------------------------
interface TestSetupBoardPlacement {
/** Algebraic square ("e4") OR numeric square index (0..63). */
square: string | number;
type: PieceType;
color: PieceColor;
/** Optional: stamp `HasMoved` on placement (default false). */
hasMoved?: boolean;
/**
* Optional handle so the e2e can later refer to "the bishop we
* placed" by name. Returned in the `__test__.board-ready` reply
* keyed by handle for piece-id-based assertions.
*/
handle?: string;
}
interface TestSeedHookSpec {
/** Handle (from a placement) OR algebraic / numeric square. */
pieceHandle?: string;
pieceSquare?: string | number;
/**
* Hook attribute on the schema. Caller is responsible for matching
* the descriptor's root primitive (e.g. `on-move` → OnMoveHooks,
* `on-capture` → OnCaptureHooks).
*/
hookAttr:
| "OnMoveHooks"
| "OnCaptureHooks"
| "OnCapturedHooks"
| "OnDamagedHooks"
| "OnPromotionHooks"
| "OnTurnStartHooks"
| "OnTurnEndHooks"
| "OnCheckReceivedHooks"
| "OnCheckDeliveredHooks"
| "OnMovedOntoSquareHooks";
/** Descriptor whose root primitive's `params.primitives` is the inner arm. */
descriptor: unknown;
/** Override the descriptor id used in the registry / hook entry. */
descriptorIdOverride?: string;
}
interface TestSetupBoardPayload {
roomCode: string;
/** Wipe FIDE starting position before placing. Default: true. */
clear?: boolean;
/** When clearing, retract kings too. Default: false (preserve kings). */
clearIncludingKings?: boolean;
placements?: TestSetupBoardPlacement[];
rngSeed?: number;
hooks?: TestSeedHookSpec[];
/** Override `Turn`. Default: leave unchanged. */
turn?: PieceColor;
}
function squareIndex(sq: string | number): number {
return typeof sq === "string" ? algebraicToSquare(sq) : sq;
}
function findPieceIdAt(
engine: ReturnType<GameSession["getEngine"]>,
sq: number,
): number | null {
for (const f of engine.session.allFacts()) {
if (
f.attr === "Position" &&
f.value === sq &&
(f.id as number) > 0
) {
return f.id as number;
}
}
return null;
}
function clearBoardOnEngine(
engine: ReturnType<GameSession["getEngine"]>,
preserveKings: boolean,
): void {
const facts = engine.session.allFacts();
const toClear: number[] = [];
for (const f of facts) {
if (f.attr !== "PieceType") continue;
if ((f.id as number) <= 0) continue;
if (preserveKings && f.value === "king") continue;
toClear.push(f.id as number);
}
for (const id of toClear) {
for (const attr of engine.effectivePieceAttrs as readonly ChessAttrKey[]) {
if (engine.session.contains(id as never, attr)) {
engine.session.retract(id as never, attr);
}
}
}
}
function placePieceOnEngine(
engine: ReturnType<GameSession["getEngine"]>,
type: PieceType,
color: PieceColor,
sq: number,
hasMoved: boolean,
): number {
const id = engine.session.nextId();
engine.session.insert(id, "PieceType", type);
engine.session.insert(id, "Color", color);
engine.session.insert(id, "Position", sq);
engine.session.insert(id, "HasMoved", hasMoved);
return id as number;
}
function handleTestSetupBoard(
ws: ServerWebSocket<ClientData>,
payload: unknown,
): void {
if (!TEST_DEBUG_ENABLED) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"test-debug frames disabled in production",
false,
),
);
return;
}
let parsed: TestSetupBoardPayload;
try {
if (typeof payload !== "object" || payload === null) {
throw new Error("payload must be an object");
}
parsed = payload as TestSetupBoardPayload;
if (typeof parsed.roomCode !== "string") {
throw new Error("roomCode must be a string");
}
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.setup-board: ${(err as Error).message}`,
false,
),
);
return;
}
const session = sessionRegistry.get(parsed.roomCode);
if (!session) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.setup-board: no session for room ${parsed.roomCode}`,
false,
),
);
return;
}
const engine = session.getEngine();
// 0. Ensure the integration preset is active. The server's
// GameSession only auto-activates `__modifier-profile-integration__`
// when `room.create` carries a profile (see game-session.ts §
// constructor + engine.ts § auto-activate). Without it,
// `engine.applyMove` runs the move but the onAfterMove dispatcher
// NEVER calls `fireOn{Move,Capture,...}Hooks` — meaning every
// seeded trigger hook is a silent no-op. The default e2e room
// creation path doesn't include a profile, so we explicitly
// activate the integration preset here. Idempotent: if it's
// already in the activation list (a custom-profile room would
// have it), this is a no-op.
const presets = engine.activePresets.list();
if (
!presets.some((p) => p.id === "__modifier-profile-integration__")
) {
engine.activePresets.replaceAll([
{
id: "__modifier-profile-integration__",
scope: "both",
turnsRemaining: null,
},
...presets.map((p) => ({
id: p.id,
scope: p.scope,
turnsRemaining: p.turnsRemaining,
})),
]);
}
// 1. Clear board (default: yes, preserve kings).
if (parsed.clear !== false) {
clearBoardOnEngine(engine, !(parsed.clearIncludingKings === true));
}
// 2. Place pieces. Track handle → entityId for handle-based hook seeds.
const handleToId = new Map<string, number>();
const placements = parsed.placements ?? [];
for (const p of placements) {
const sq = squareIndex(p.square);
const id = placePieceOnEngine(
engine,
p.type,
p.color,
sq,
p.hasMoved ?? false,
);
if (p.handle !== undefined) handleToId.set(p.handle, id);
}
// 3. Optional RngSeed pin.
if (typeof parsed.rngSeed === "number") {
engine.setRngSeed(parsed.rngSeed);
}
// 4. Optional Turn override.
if (parsed.turn === "white" || parsed.turn === "black") {
engine.session.insert(GAME_ENTITY, "Turn", parsed.turn);
}
// 5. Seed per-piece trigger hooks. Caller passes the descriptor +
// target piece + hook attr; we read the descriptor's root
// primitive's `params.primitives` (the inner arm) and write the
// {descriptorId, primitives} entry under the requested attr.
// This mirrors what on-move.apply() / on-capture.apply() would
// produce via the canonical seeder primitives, without driving
// `applyCustomDescriptor` (which has unrelated walker concerns
// documented in the parity *-real.test.ts files).
const hooks = parsed.hooks ?? [];
for (const spec of hooks) {
let descriptor: CustomModifierDescriptor;
try {
descriptor = parseCustomModifierDescriptor(spec.descriptor);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.setup-board: descriptor parse failed: ${(err as Error).message}`,
false,
),
);
return;
}
let pieceId: number | null = null;
if (spec.pieceHandle !== undefined) {
pieceId = handleToId.get(spec.pieceHandle) ?? null;
} else if (spec.pieceSquare !== undefined) {
pieceId = findPieceIdAt(engine, squareIndex(spec.pieceSquare));
}
if (pieceId === null) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.setup-board: hook target not found (handle=${String(spec.pieceHandle)} square=${String(spec.pieceSquare)})`,
false,
),
);
return;
}
const idForRegistry =
spec.descriptorIdOverride !== undefined
? asCustomModifierId(spec.descriptorIdOverride)
: descriptor.id;
if (!engine.customModifiers.has(idForRegistry)) {
engine.customModifiers.register({ ...descriptor, id: idForRegistry });
}
const root = descriptor.primitives[0];
if (root === undefined) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"__test__.setup-board: descriptor has no root primitive",
false,
),
);
return;
}
const innerArm = (root.params as { primitives?: EffectPrimitiveNode[] })
.primitives ?? [];
engine.session.insert(
pieceId as never,
spec.hookAttr as ChessAttrKey,
[
{ descriptorId: idForRegistry, primitives: innerArm },
] as unknown as never,
);
}
// 6. Same choice-timeout relaxation as the other test-debug handlers
// (T79 rationale): churned sockets during /game mount otherwise
// auto-forfeit the room mid-test.
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
mode: "no-timeout",
});
// 7. Broadcast fresh game.state so connected clients render the
// synthetic board before the test drives any move.
broadcastGameStateSnapshot(parsed.roomCode, session);
}
let testChoiceCounter = 0;
/**
@ -1139,6 +1999,12 @@ export function handleMessage(
const { type, payload } = earlyParsed;
if (type === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE) {
handleTestActivateDescriptor(ws, payload);
} else if (type === TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE) {
handleTestSeedOnCapturedHook(ws, payload);
} else if (type === TEST_DEBUG_SETUP_BOARD_TYPE) {
handleTestSetupBoard(ws, payload);
} else if (type === TEST_DEBUG_APPLY_DESCRIPTOR_TYPE) {
handleTestApplyDescriptor(ws, payload);
}
return;
}

View file

@ -45,6 +45,7 @@ import type { ServerWebSocket } from "bun";
import { afterEach, describe, expect, it } from "vitest";
import {
asCustomModifierId,
GAME_ENTITY,
parseCustomModifierDescriptor,
type EffectPrimitiveNode,
@ -296,6 +297,32 @@ function armCancelCaptureOnPiece(ctx: RoomCtx, defenderId: number): void {
primitives: EffectPrimitiveNode[];
}).primitives;
// T83 — also register a LIFTED descriptor whose top-level
// primitives ARE the inner arm. This is load-bearing for the
// resume path: `submitChoiceAndResume` walks the descriptor's
// `primitives` via `walkTriggerPath(triggerPath)`. The
// on-captured-fired PendingChoice frame carries `triggerPath:
// []` and `primitiveIndex: 0` (the request-choice's position
// inside the inner arm — that's what runPrimitives sees when
// the dispatcher invokes the hook with the inner arm as its
// node list). With the unlifted descriptor, walkTriggerPath
// would return `[on-captured]` and arm[0] would resolve to the
// wrapper node, NOT the request-choice — so the resume's
// `params.then` continuation walk would fail to find
// cancel-capture. Pre-T83 the broadcast-revert assertion below
// (T81's "post-resume game.state") fired BEFORE this resume
// discrepancy mattered (the test only asserted that the
// snapshot was broadcast, not that the engine state inside it
// reflected cancel-capture). The T83 follow-on assertion
// tightens that — see the new "T83: defender-wins parry submit
// fully restores defender + attacker" test in this file.
const liftedId = `${String(descriptor.id)}__lifted-on-captured`;
engine.customModifiers.register({
...descriptor,
id: asCustomModifierId(liftedId),
primitives: innerArm,
});
// The Session API takes a branded `EntityId`, while
// `findPieceIdAtSquare` returned the raw fact id (a number — that's
// the wire-shape, not the branded engine type). Cast at this
@ -306,14 +333,11 @@ function armCancelCaptureOnPiece(ctx: RoomCtx, defenderId: number): void {
engine.session.insert(defenderEntity, "OnCapturedHooks", [
{
// T80: include the real descriptorId so the request-choice
// pushed inside this arm carries a resolvable id (the
// dispatcher passes hook.descriptorId through to runPrimitives;
// submitChoiceAndResume looks up the descriptor by id at
// resume time). Without it the frame's descriptorId falls
// back to the synthetic `"__trigger__"` placeholder and the
// resume throws `runtime.descriptor-not-found`.
descriptorId: String(descriptor.id),
// T83: store the LIFTED descriptor's id so the dispatcher
// threads the lifted id into the PendingChoice frame, and
// submitChoiceAndResume's walkTriggerPath resolves the
// request-choice at primitives[0] of the lifted descriptor.
descriptorId: liftedId,
target: "self",
primitives: innerArm,
},
@ -517,4 +541,123 @@ describe("T81 — broadcast revert for cancel-capture (Gap H)", () => {
teardown(ctx);
}
});
// T83 (Wave 15) — post-resume capture restoration.
//
// Wave 14 (T81) shipped suppression of the mid-suspension delta;
// T83 adds the OTHER half of the parry contract: when the
// resumed continuation fires `cancel-capture`, the engine state
// visible in the post-resume snapshot must reflect the FULL
// restoration (defender back at the original square, attacker
// back at its origin), not the post-capture state.
//
// Two production fixes converge here:
// 1. `submitChoiceAndResume` synthesizes a capture event from
// `LastCaptureSnapshot` so `cancel-capture` (which gates on
// `ctx.event.kind === "capture"`) doesn't throw at resume.
// 2. `handleSubmitChoice` mirrors apply.ts stage 4b's cleanup
// after the resume: `rollbackAttackerFromSnapshot` puts the
// attacker back on its origin square, and the snapshot +
// CaptureCancelled flag are retracted so the next move
// starts clean.
it("T83: defender-wins parry submit fully restores defender + attacker in post-resume snapshot", () => {
const ctx = setupRoom();
try {
sendClient(
ctx.white,
"game.move",
{ from: "e2", to: "e4" },
{ protocolVersion: 2 },
);
nextMsgOfType(ctx.white, "game.delta");
nextMsgOfType(ctx.black, "game.delta");
sendClient(
ctx.black,
"game.move",
{ from: "d7", to: "d5" },
{ protocolVersion: 2 },
);
nextMsgOfType(ctx.white, "game.delta");
nextMsgOfType(ctx.black, "game.delta");
const e4Square = 28;
const d5Square = 35;
const defenderId = findPieceIdAtSquare(ctx, d5Square);
const attackerId = findPieceIdAtSquare(ctx, e4Square);
expect(defenderId).toBeDefined();
expect(attackerId).toBeDefined();
armCancelCaptureOnPiece(ctx, defenderId!);
// Capture: e4xd5. Suspends on the parry rps prompt (T81
// suppresses the post-capture delta).
sendClient(
ctx.white,
"game.move",
{ from: "e4", to: "d5" },
{ protocolVersion: 2 },
);
const rc = nextMsgOfType(ctx.white, "request-choice");
nextMsgOfType(ctx.black, "request-choice");
const choiceId = rc["choiceId"] as string;
sendV2(ctx.white, {
kind: "submit-choice",
protocolVersion: 2,
choiceId,
value: "rock",
});
// Drain post-resume game.state snapshot on both sides.
const wState = nextMsgOfType(ctx.white, "game.state");
const bState = nextMsgOfType(ctx.black, "game.state");
// T83 PRIMARY ASSERTION: the snapshot reflects FULL
// restoration. The defender (black pawn at d5) must still
// be at d5 with PieceType=pawn, Color=black; the attacker
// (white pawn) must be back at e4, NOT at d5.
const wFacts = wState["payload"]!["facts"] as Array<{
id: number;
attr: string;
value: unknown;
}>;
const findFact = (
id: number,
attr: string,
): { id: number; attr: string; value: unknown } | undefined =>
wFacts.find((f) => f.id === id && f.attr === attr);
// Defender restored at d5.
const defenderPosition = findFact(defenderId!, "Position");
expect(defenderPosition?.value).toBe(d5Square);
const defenderType = findFact(defenderId!, "PieceType");
expect(defenderType?.value).toBe("pawn");
const defenderColor = findFact(defenderId!, "Color");
expect(defenderColor?.value).toBe("black");
// Attacker rolled back to e4.
const attackerPosition = findFact(attackerId!, "Position");
expect(attackerPosition?.value).toBe(e4Square);
// Both clients see identical post-resume snapshots.
expect(bState["payload"]!["facts"]).toEqual(wFacts);
// Engine bookkeeping cleared: PendingChoices empty,
// CaptureCancelled flag retracted, snapshot retracted.
const session = sessionRegistry.get(ctx.code)!;
const engine = session.getEngine();
expect(
engine.session.get(GAME_ENTITY, "PendingChoices"),
).toEqual([]);
expect(
engine.session.get(GAME_ENTITY, "CaptureCancelled"),
).toBeUndefined();
expect(
engine.session.get(GAME_ENTITY, "LastCaptureSnapshot"),
).toBeUndefined();
} finally {
teardown(ctx);
}
});
});