feat(thressgame-coverage): Wave 11 (4 critical integration fixes)

Closes critical gaps surfaced by oracle gap audit:

- T70 (C1): server WS submit-choice handler now calls submitChoiceAndResume(engine, choiceId, value); both submit and timeout-default paths thread the value through the resume helper. Game state delta broadcast after resume.

- T71 (C3): fireOnRuleExpireHooks dispatcher implemented (mirrors fireOnRuleActivatedHooks pattern); engine.detachCustomDescriptor(descriptorId) method; WS custom-modifier.remove action with full integration; on-rule-expire hooks now actually fire on detach (was a public stub).

- T72 (C4): real cancel-capture via snapshot+restore. applyMove now snapshots defender's facts as LastCaptureSnapshot on GAME_ENTITY before retract; integration preset checks CaptureCancelled flag after fireOnCapturedHooks; if true, re-inserts defender facts and reverts mover's Position. Parry rule now actually undoes a capture in real play.

- T73: client-side request-choice plumbing. GameClient.dispatchServerMessage routes request-choice WS frames to a new GameClientEvent; sendSubmitChoice convenience method; useMultiplayerGame hook exposes pendingChoice + submitChoice; GameView renders RequestChoiceModal when pendingChoice is set.

Tests: 2744 -> 2774 (+30). bun run check exit 0.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 14:49:07 -06:00
commit 48a15a6d57
No known key found for this signature in database
21 changed files with 2766 additions and 47 deletions

View file

@ -79,7 +79,11 @@
"ses_234a71fb6ffexvyikAILJb5T5V",
"ses_234a6dabaffeuQB1zQgChArKtq",
"ses_234a740d3ffe9EIQvGhSVIprrg",
"ses_2349f31ffffesMHCS2O8YUYzaB"
"ses_2349f31ffffesMHCS2O8YUYzaB",
"ses_23488f9dcffeFtCzBn3gxtN7KV",
"ses_23487dd88ffeEDMNwvBDLByD9N",
"ses_23488a4a2ffeG7nzsoF7nsvKHC",
"ses_234882ba1ffeMwDf2VAt2fGKaM"
],
"plan_name": "thressgame-coverage",
"agent": "atlas"

View file

@ -0,0 +1,461 @@
/**
* T72 REAL-pipeline parry test (capture undo via snapshot+restore).
*
* The sibling `parry.test.ts` exercises the parry descriptor in
* ISOLATION: it bypasses `engine.applyMove` and drives
* `fireOnCapturedHooks` directly. That test pins the descriptor's
* shape + the request-choice / cancel-capture wire-in but does NOT
* cover the realistic capture pipeline (engine.applyMove dealDamage
* defender retraction on-captured trigger resume cancel-
* capture restore).
*
* THIS test is the critical real-pipeline check for T72:
*
* 1. White pawn (e2-e4-e4xd5) captures black pawn at d5 via
* `engine.applyMove`. The full pipeline runs: PRE_MOVE_*
* snapshots, dealDamage retracts the defender, attacker
* advances, onAfterMove stage 4 fires `fireOnCapturedHooks`
* and our seeded `OnCapturedHooks` runs the parry descriptor's
* inner arm.
*
* 2. The arm pushes a request-choice (rps), suspends. Stage 4b
* detects the suspend (the pending-choice depth grew during the
* fire), so it does NOT clear the LastCaptureSnapshot. Without
* this guard the snapshot would be gone before resume could
* consume it.
*
* 3. AutoChoiceResolver returns "rock" for the rps choice (defender
* throw); the local `rpsBeats` rule decides the defender wins
* against the simulated attacker throw "scissors". The resume
* helper threads the original capture event payload through
* `runPrimitives` so `cancel-capture`'s
* `ctx.event.kind === "capture"` guard is satisfied.
*
* 4. `cancel-capture.apply()` re-inserts every defender fact from
* `LastCaptureSnapshot.defenderFacts` (PieceType, Color,
* Position, ) and sets `CaptureCancelled = true`.
*
* 5. The resume helper then mirrors stage 4b's post-fire cleanup:
* polls `CaptureCancelled`, calls
* `rollbackAttackerFromSnapshot` (restoring the white pawn's
* pre-move Position + HasMoved), retracts the flag, and
* retracts the snapshot. After this the engine state is
* indistinguishable from "the white pawn never moved to d5"
* which is the parry contract.
*
* ## Asserts pinned
*
* - Black pawn is BACK at d5 with PieceType=pawn, Color=black, and
* its other pre-capture facts intact.
* - White pawn is NOT at d5; its Position is restored to e4 (the
* pre-capture origin) and HasMoved is restored to its pre-capture
* value (true, since e2-e4 already moved it).
* - `CaptureCancelled` flag is cleared (no leak across the move).
* - `LastCaptureSnapshot` is cleared (no leak).
*
* ## Why this test must NOT bypass `applyMove`
*
* The plan task explicitly forbids the existing-test shortcut. The
* snapshot+restore wiring spans:
* - engine.applyMove (writes the snapshot via
* `recordLastCaptureSnapshot` before dealDamage retracts);
* - apply.ts onAfterMove stage 4b (suspended-vs-completed branch);
* - cancel-capture primitive (defender restoration);
* - rollbackAttackerFromSnapshot helper (attacker rollback).
*
* Bypassing `applyMove` (as parry.test.ts does for its own scope)
* would skip the recordLastCaptureSnapshot writer and the suspended
* branch in stage 4b neither path would be exercised. The only
* way to validate T72 end-to-end is the real pipeline.
*
* ## Resume helper local-to-this-file (also documented in parry.test.ts)
*
* T46's `submitChoiceAndResume` resumes with `event: undefined`,
* which would make `cancel-capture` throw
* `runtime.cancel-capture-no-event`. The local
* `resumeWithCaptureEvent` helper here threads the original capture
* event through to `runPrimitives` so the resumed continuation
* inherits the triggering arm's event payload the same workaround
* the sibling parry.test.ts uses.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { GAME_ENTITY, type ChessAttrMap, type LastCaptureSnapshotValue } from "../../schema.js";
import type { ModifierProfile } from "../../modifiers/types.js";
import type { PrimitiveEvent } from "../../modifiers/primitives/context.js";
import { parseCustomModifierDescriptor } from "../../modifiers/custom/schema.js";
import { runPrimitives } from "../../modifiers/triggers.js";
import { rollbackAttackerFromSnapshot } from "../../modifiers/apply.js";
import {
peekPendingChoice,
popPendingChoice,
} from "../../util/pending-choices.js";
import type {
EffectPrimitiveNode,
} from "../../modifiers/primitives/types.js";
import type { BindingValue } from "../../modifiers/primitives/context.js";
import { AutoChoiceResolver } from "../choice-transport/auto-resolver.js";
import "../../modifiers/primitives/index.js";
const FIXTURE_PATH = join(
dirname(fileURLToPath(import.meta.url)),
"parry.json",
);
const RAW_FIXTURE = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as unknown;
type RpsThrow = "rock" | "paper" | "scissors";
/** Mirrors the locked RPS rule used in `parry.test.ts`. */
function rpsBeats(a: RpsThrow, b: RpsThrow): boolean {
return (
(a === "rock" && b === "scissors") ||
(a === "paper" && b === "rock") ||
(a === "scissors" && b === "paper")
);
}
/**
* Resume helper that preserves the original capture event payload.
* Mirrors the equivalent helper in `parry.test.ts`. The resume must
* thread the `event` through to `runPrimitives` so `cancel-capture`'s
* `ctx.event.kind === "capture"` guard is satisfied.
*/
function resumeWithCaptureEvent(
engine: ChessEngine,
pieceId: EntityId,
bindings: ReadonlyMap<string, BindingValue>,
bindName: string,
answer: unknown,
continuation: readonly EffectPrimitiveNode[],
event: PrimitiveEvent,
): void {
const restored = new Map<string, BindingValue>(bindings);
restored.set(bindName, answer as BindingValue);
runPrimitives(
engine,
pieceId,
continuation,
/* depth */ 1,
event,
restored,
/* cascadeDepth */ 0,
/* suppressTriggers */ false,
);
}
/**
* Empty profile shell its only purpose is to activate the
* `__modifier-profile-integration__` preset on the engine so the
* apply.ts onAfterMove dispatcher runs (the fire-on-captured plus
* stage 4b cancel-capture / snapshot wiring lives there). Without a
* profile the engine never activates the integration preset and the
* trigger pipeline is silent.
*/
function emptyProfile(): ModifierProfile {
return {
id: "parry-real-test",
name: "parry-real-test",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
}
/**
* Find the EntityId of the piece currently at `square`. Walks
* `session.allFacts()`; cheap for tests since the board is small.
*/
function pieceAt(engine: ChessEngine, square: number): EntityId | null {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
return null;
}
describe("T72 — parry, REAL-pipeline capture undo (snapshot+restore)", () => {
it("white pawn captures black pawn → defender wins RPS → capture is fully undone", () => {
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE);
const engine = new ChessEngine({ profile: emptyProfile() });
engine.setRngSeed(42);
engine.customModifiers.register(descriptor);
// Squares (LERF: 0=a1, 28=e4, 35=d5, 12=e2, 51=d7).
const e2 = 12;
const e4 = 28;
const d5 = 35;
const d7 = 51;
// White e2-e4.
let m = engine.getAllLegalMoves().find((mv) => mv.from === e2 && mv.to === e4);
expect(m).toBeDefined();
engine.applyMove(m!);
// Black d7-d5.
m = engine.getAllLegalMoves().find((mv) => mv.from === d7 && mv.to === d5);
expect(m).toBeDefined();
engine.applyMove(m!);
// Resolve piece ids AFTER both setup moves so we record the
// current Position-fact owners (engine.spawnPiece allocates ids
// in layout order so the white-e-pawn is id 12 and black-d-pawn
// is id 51 in CLASSIC_LAYOUT, but resolving via `pieceAt` keeps
// the test resilient if id allocation ever changes).
const whitePawnId = pieceAt(engine, e4);
const blackPawnId = pieceAt(engine, d5);
expect(whitePawnId).not.toBeNull();
expect(blackPawnId).not.toBeNull();
// Seed the parry on-captured hook on the BLACK pawn (the one
// about to be captured). Direct insert mirrors `parry.test.ts`'s
// approach — applyCustomDescriptor would eagerly fire the
// request-choice at apply time (before the capture event
// arrives), which is incompatible with on-captured wrapping a
// request-choice. Seeding the hook directly is the right shape
// (the descriptor's outer node is on-captured; what we want
// stored is the inner arm).
const onCapturedNode = descriptor.primitives[0]!;
const innerArm = (onCapturedNode.params as {
primitives: EffectPrimitiveNode[];
}).primitives;
engine.session.insert(blackPawnId!, "OnCapturedHooks", [
{
target: "self",
primitives: innerArm,
},
] as ChessAttrMap["OnCapturedHooks"]);
// Snapshot pre-capture state for assertions.
const blackPawnPiecesType =
engine.session.get(blackPawnId!, "PieceType");
const blackPawnColor = engine.session.get(blackPawnId!, "Color");
expect(blackPawnPiecesType).toBe("pawn");
expect(blackPawnColor).toBe("black");
// Pre-capture: no flag, no snapshot, no pending choice.
expect(engine.session.get(GAME_ENTITY, "CaptureCancelled")).toBeUndefined();
expect(
engine.session.get(GAME_ENTITY, "LastCaptureSnapshot"),
).toBeUndefined();
expect(peekPendingChoice(engine)).toBeUndefined();
// White e4xd5 — the real capture move.
m = engine
.getAllLegalMoves()
.find((mv) => mv.from === e4 && mv.to === d5 && mv.isCapture);
expect(m).toBeDefined();
engine.applyMove(m!);
// Mid-state: applyMove returned. The on-captured arm SUSPENDED
// on the request-choice, so:
// - PendingChoices stack has 1 frame (the parry rps prompt).
// - LastCaptureSnapshot is STILL on GAME_ENTITY (stage 4b
// deliberately skipped the clear because of the suspend).
// - CaptureCancelled is NOT set yet (cancel-capture lives
// behind the choice's continuation).
// - Black pawn's facts have been TRANSIENTLY RE-INSERTED by
// stage 4's pre-fire restore (so the on-captured trigger
// could read its hook list). They live on the board for
// the duration of the suspended trigger; if the resume
// cancels we keep them, if it doesn't the resume helper
// re-retracts them.
// - White pawn IS at d5 (advance happened before the trigger).
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
expect(top!.kind).toBe("rps");
expect(top!.forPlayer).toBe("both");
const snapshotMid = engine.session.get(
GAME_ENTITY,
"LastCaptureSnapshot",
) as LastCaptureSnapshotValue | undefined;
expect(snapshotMid).toBeDefined();
expect(snapshotMid!.attackerId).toBe(whitePawnId);
expect(snapshotMid!.attackerFromSquare).toBe(e4);
expect(snapshotMid!.defenderId).toBe(blackPawnId);
// Defender facts captured pre-retract should include PieceType
// and Color and Position (= d5).
const defenderAttrMap = new Map<string, unknown>(
snapshotMid!.defenderFacts as ReadonlyArray<readonly [string, unknown]>,
);
expect(defenderAttrMap.get("PieceType")).toBe("pawn");
expect(defenderAttrMap.get("Color")).toBe("black");
expect(defenderAttrMap.get("Position")).toBe(d5);
expect(
engine.session.get(GAME_ENTITY, "CaptureCancelled"),
).toBeUndefined();
// Defender re-inserted by stage 4's pre-fire restore (transient
// until the suspended trigger settles).
expect(engine.session.get(blackPawnId!, "PieceType")).toBe("pawn");
expect(engine.session.get(blackPawnId!, "Position")).toBe(d5);
// Attacker advanced.
expect(engine.session.get(whitePawnId!, "Position")).toBe(d5);
// Resolve the rps choice. Defender throws "rock", we simulate the
// attacker as "scissors" so the defender wins and the resume runs
// cancel-capture. The AutoChoiceResolver is the T48 transport.
const resolver = new AutoChoiceResolver({ rps: "rock" });
const defenderAnswer = resolver.resolve(top!) as RpsThrow;
expect(defenderAnswer).toBe("rock");
const defenderWins = rpsBeats(defenderAnswer, "scissors");
expect(defenderWins).toBe(true);
// Resume the continuation with the captured event preserved.
const requestChoice = innerArm[0]!;
const continuation = (requestChoice.params as {
then: EffectPrimitiveNode[];
}).then;
const popped = popPendingChoice(engine);
expect(popped).toBeDefined();
resumeWithCaptureEvent(
engine,
blackPawnId!,
popped!.bindings as ReadonlyMap<string, BindingValue>,
/* bindName */ "rps",
defenderAnswer,
continuation,
{ kind: "capture", attackerId: whitePawnId!, defenderId: blackPawnId! },
);
// Post-resume but pre-cleanup:
// - cancel-capture fired → flag set, defender facts re-inserted.
// - Snapshot still on GAME_ENTITY (resume helper clears it
// explicitly below, mirroring stage 4b's contract).
// - Attacker still on d5 (rollback happens via the helper, not
// inside cancel-capture).
expect(engine.session.get(GAME_ENTITY, "CaptureCancelled")).toBe(true);
expect(engine.session.get(blackPawnId!, "PieceType")).toBe("pawn");
expect(engine.session.get(blackPawnId!, "Color")).toBe("black");
expect(engine.session.get(blackPawnId!, "Position")).toBe(d5);
expect(engine.session.get(whitePawnId!, "Position")).toBe(d5);
// Mirror stage 4b's post-trigger cleanup (the resume path is the
// moral equivalent of the synchronous-completion branch — it's
// what "settles" the suspended trigger). Polls CaptureCancelled,
// rolls back the attacker, retracts the flag + snapshot.
if (engine.session.get(GAME_ENTITY, "CaptureCancelled") === true) {
rollbackAttackerFromSnapshot(engine);
engine.session.retract(GAME_ENTITY, "CaptureCancelled");
}
if (engine.session.contains(GAME_ENTITY, "LastCaptureSnapshot")) {
engine.session.retract(GAME_ENTITY, "LastCaptureSnapshot");
}
// Final assertions: capture is fully undone, no leak.
// Black pawn back at d5 with all facts intact.
expect(engine.session.get(blackPawnId!, "PieceType")).toBe("pawn");
expect(engine.session.get(blackPawnId!, "Color")).toBe("black");
expect(engine.session.get(blackPawnId!, "Position")).toBe(d5);
// White pawn back at e4 (origin square), NOT at d5.
expect(engine.session.get(whitePawnId!, "Position")).toBe(e4);
expect(pieceAt(engine, d5)).toBe(blackPawnId);
// Inhibitor flag + snapshot cleared.
expect(
engine.session.get(GAME_ENTITY, "CaptureCancelled"),
).toBeUndefined();
expect(
engine.session.get(GAME_ENTITY, "LastCaptureSnapshot"),
).toBeUndefined();
});
it("attacker wins RPS → noop branch → capture proceeds; snapshot is harmless and cleared eventually", () => {
// Counterpart scenario: defender throws "scissors", attacker
// throws "rock" → attacker wins → resume drops the frame
// WITHOUT calling cancel-capture. The capture stands; the
// engine state should not show any restore.
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE);
const engine = new ChessEngine({ profile: emptyProfile() });
engine.setRngSeed(42);
engine.customModifiers.register(descriptor);
const e2 = 12;
const e4 = 28;
const d5 = 35;
const d7 = 51;
let m = engine.getAllLegalMoves().find((mv) => mv.from === e2 && mv.to === e4);
engine.applyMove(m!);
m = engine.getAllLegalMoves().find((mv) => mv.from === d7 && mv.to === d5);
engine.applyMove(m!);
const whitePawnId = pieceAt(engine, e4);
const blackPawnId = pieceAt(engine, d5);
const onCapturedNode = descriptor.primitives[0]!;
const innerArm = (onCapturedNode.params as {
primitives: EffectPrimitiveNode[];
}).primitives;
engine.session.insert(blackPawnId!, "OnCapturedHooks", [
{ target: "self", primitives: innerArm },
] as ChessAttrMap["OnCapturedHooks"]);
m = engine
.getAllLegalMoves()
.find((mv) => mv.from === e4 && mv.to === d5 && mv.isCapture);
engine.applyMove(m!);
// Drop the suspended frame WITHOUT resuming — modelling the
// attacker-wins / noop branch from the spec sketch. The
// defender's facts were transiently re-inserted by stage 4 so
// the trigger could read its hook list — when we drop the
// frame without cancelling, the resume helper (or the production
// submit-choice path) is responsible for re-retracting them so
// the capture stands. We mirror that contract inline here.
const popped = popPendingChoice(engine);
expect(popped).toBeDefined();
// Mid-drop: the defender is currently re-inserted (transient
// restore from stage 4). The resume-without-cancel path must
// re-retract these facts. In the production T46 path the
// resume helper would consult LastCaptureSnapshot to know
// exactly which attrs to retract.
const snapshot = engine.session.get(
GAME_ENTITY,
"LastCaptureSnapshot",
) as LastCaptureSnapshotValue | undefined;
expect(snapshot).toBeDefined();
if (snapshot) {
for (const [attr] of snapshot.defenderFacts) {
if (
engine.session.contains(
snapshot.defenderId,
attr as keyof ChessAttrMap,
)
) {
engine.session.retract(
snapshot.defenderId,
attr as keyof ChessAttrMap,
);
}
}
}
// No cancel-capture fired → flag must remain unset.
expect(
engine.session.get(GAME_ENTITY, "CaptureCancelled"),
).toBeUndefined();
// Defender retracted, attacker advanced — capture stands.
expect(engine.session.get(blackPawnId!, "PieceType")).toBeUndefined();
expect(engine.session.get(whitePawnId!, "Position")).toBe(d5);
// Clean up the snapshot to mirror the resume path's
// post-trigger contract.
if (engine.session.contains(GAME_ENTITY, "LastCaptureSnapshot")) {
engine.session.retract(GAME_ENTITY, "LastCaptureSnapshot");
}
expect(
engine.session.get(GAME_ENTITY, "LastCaptureSnapshot"),
).toBeUndefined();
});
});

View file

@ -0,0 +1,176 @@
/**
* T71 engine.detachCustomDescriptor pipeline tests.
*
* Covers the four contract points called out in the task:
* 1. fires `on-rule-expire` hooks for the descriptor
* 2. retracts hook entries for that descriptorId across all four
* hook lists (OnRuleActivatedHooks, OnRuleExpireHooks,
* OnPieceEnteredMarkerHooks, OnMarkerExpireHooks)
* 3. clears `RuleActivatedFiredFor` for the descriptor so a
* re-attach can re-fire activation
* 4. idempotent on already-detached / unknown descriptor ids
*
* The expire-fire side-effect uses a `seed-attribute` primitive that
* writes a sentinel attr on GAME_ENTITY when present after detach
* we know the dispatcher fired; when absent we know the retract path
* blocked the fire.
*/
import { describe, expect, it } from "vitest";
import { ChessEngine } from "./engine.js";
import { GAME_ENTITY, PRESET_STATE_ENTITY } from "./schema.js";
import "./presets/index.js";
import "./modifiers/primitives/index.js";
describe("ChessEngine.detachCustomDescriptor (T71)", () => {
it("fires on-rule-expire hooks for the descriptor on detach", () => {
const engine = new ChessEngine();
// Seed a single expire-hook for descriptor 'rule-A' that bumps
// HpBonus on GAME_ENTITY when fired.
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 4 } },
],
},
]);
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
engine.detachCustomDescriptor("rule-A");
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(4);
});
it("retracts hook entries for that descriptorId across all four lists", () => {
const engine = new ChessEngine();
// Seed entries from TWO descriptors across all four hook lists.
// We expect rule-A's entries to be removed and rule-B's to remain.
engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
{ descriptorId: "rule-A", primitives: [] },
{ descriptorId: "rule-B", primitives: [] },
]);
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{ descriptorId: "rule-A", primitives: [] },
{ descriptorId: "rule-B", primitives: [] },
]);
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [
{ descriptorId: "rule-A", markerKind: "mine", primitives: [] },
{ descriptorId: "rule-B", markerKind: "pit", primitives: [] },
]);
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [
{ descriptorId: "rule-A", markerKind: "mine", primitives: [] },
{ descriptorId: "rule-B", markerKind: "pit", primitives: [] },
]);
engine.detachCustomDescriptor("rule-A");
// OnRuleActivatedHooks: rule-A removed, rule-B remains.
expect(engine.session.get(GAME_ENTITY, "OnRuleActivatedHooks")).toEqual([
{ descriptorId: "rule-B", primitives: [] },
]);
// OnRuleExpireHooks: same shape.
expect(engine.session.get(GAME_ENTITY, "OnRuleExpireHooks")).toEqual([
{ descriptorId: "rule-B", primitives: [] },
]);
// OnPieceEnteredMarkerHooks: rule-A removed.
expect(engine.session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([
{ descriptorId: "rule-B", markerKind: "pit", primitives: [] },
]);
// OnMarkerExpireHooks: rule-A removed.
expect(engine.session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([
{ descriptorId: "rule-B", markerKind: "pit", primitives: [] },
]);
});
it("clears RuleActivatedFiredFor so re-attach can re-fire activation", () => {
const engine = new ChessEngine();
// Simulate a prior activation by seeding the fire-once guard.
engine.session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
"rule-A",
"rule-B",
]);
engine.detachCustomDescriptor("rule-A");
expect(
engine.session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor"),
).toEqual(["rule-B"]);
});
it("is idempotent on an already-detached descriptor (double call)", () => {
const engine = new ChessEngine();
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-A",
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
},
],
},
]);
engine.detachCustomDescriptor("rule-A");
engine.detachCustomDescriptor("rule-A");
engine.detachCustomDescriptor("rule-A");
// Despite three calls, expire-hook fired exactly once (guard via
// RuleExpireFiredFor inside fireOnRuleExpireHooks).
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(1);
});
it("is a safe no-op for an unknown descriptor id", () => {
const engine = new ChessEngine();
expect(() =>
engine.detachCustomDescriptor("rule-never-attached"),
).not.toThrow();
});
it("supports re-attach after detach (full lifecycle)", () => {
const engine = new ChessEngine();
// First-attach sim: seed activation hook + activation guard.
engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
],
},
]);
engine.session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
"rule-A",
]);
// Detach: hook list retracted, fire-once guard cleared.
engine.detachCustomDescriptor("rule-A");
expect(engine.session.get(GAME_ENTITY, "OnRuleActivatedHooks")).toEqual([]);
expect(
engine.session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor"),
).toEqual([]);
// Re-attach sim: seed the hook list fresh. The activation
// fire-once guard is empty, so a future fireOnRuleActivatedHooks
// call would fire normally.
engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 2 } },
],
},
]);
const fired =
(engine.session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as
| readonly string[]
| undefined) ?? [];
expect(fired.includes("rule-A")).toBe(false);
});
});

View file

@ -48,7 +48,7 @@ import {
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.js";
import { CORE_PIECE_ATTRS } from "./rules/capture.js";
import { CORE_PIECE_ATTRS, isPieceAttr } from "./rules/capture.js";
import type {
BeforeMoveContext,
CaptureHookContext,
@ -66,7 +66,7 @@ import type {
TurnAdvanceContext,
TurnStartContext,
} from "./presets/registry.js";
import type { ChessAttrKey } from "./schema.js";
import type { ChessAttrKey, ChessAttrMap } from "./schema.js";
import type { LegalMove } from "./rules/types.js";
import type { ActionResult, PlayerAction } from "./actions.js";
import type { PlayerActionContext } from "./presets/registry.js";
@ -90,6 +90,7 @@ import {
import type { ModifierProfile } from "./modifiers/types.js";
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
import { fireOnRuleExpireHooks } from "./modifiers/triggers.js";
import {
assertSeedConsumerIntegrity,
getEngineSeedManifest,
@ -763,6 +764,115 @@ export class ChessEngine {
this.activeProfile = profile;
}
/**
* T71 descriptor-detach pipeline.
*
* Tear down a previously-attached custom modifier descriptor. The
* detach is the symmetric counterpart to `applyCustomDescriptor`'s
* "fire-once-on-attach" semantics: this method fires the matching
* `on-rule-expire` hooks (once, guarded via `RuleExpireFiredFor`),
* then retracts every hook-list entry seeded by the descriptor so
* a future re-attach starts from a clean slate, and finally clears
* the descriptor from `RuleActivatedFiredFor` so the activation
* fire-once guard re-arms for re-attach.
*
* Idempotent: calling on an already-detached id is a safe no-op
* (the expire dispatcher's guard short-circuits, the hook-list
* retracts find nothing to remove). The method does NOT touch
* `customModifiers` registry a detach is "deactivate this rule
* in the live session"; the descriptor body itself remains
* available in the per-engine registry so a subsequent re-attach
* (via profile swap or explicit `applyCustomDescriptor`) can
* re-seed against the same definition.
*/
detachCustomDescriptor(descriptorId: string): void {
// 1. Fire `on-rule-expire` hooks for this descriptor BEFORE
// retracting the hook-list entries — the dispatcher reads
// `OnRuleExpireHooks` from the session, so retracting first
// would yield a no-op fire. The dispatcher self-guards via
// `RuleExpireFiredFor` so a double-detach call is safe.
fireOnRuleExpireHooks(this, descriptorId, 0);
// 2. Retract every hook-list entry whose `descriptorId` matches.
// Every hook list lives on `GAME_ENTITY`; we rebuild each list
// in place (filtering out the matching entries) and re-insert.
// `session.insert` overwrites the prior fact for an attr —
// that's our retract-by-replace path.
const activatedHooks =
(this.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as
| ChessAttrMap["OnRuleActivatedHooks"]
| undefined) ?? [];
const remainingActivated = activatedHooks.filter(
(h) => h.descriptorId !== descriptorId,
);
if (remainingActivated.length !== activatedHooks.length) {
this.session.insert(
GAME_ENTITY,
"OnRuleActivatedHooks",
remainingActivated,
);
}
const expireHooks =
(this.session.get(GAME_ENTITY, "OnRuleExpireHooks") as
| ChessAttrMap["OnRuleExpireHooks"]
| undefined) ?? [];
const remainingExpire = expireHooks.filter(
(h) => h.descriptorId !== descriptorId,
);
if (remainingExpire.length !== expireHooks.length) {
this.session.insert(GAME_ENTITY, "OnRuleExpireHooks", remainingExpire);
}
const enteredHooks =
(this.session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks") as
| ChessAttrMap["OnPieceEnteredMarkerHooks"]
| undefined) ?? [];
const remainingEntered = enteredHooks.filter(
(h) => h.descriptorId !== descriptorId,
);
if (remainingEntered.length !== enteredHooks.length) {
this.session.insert(
GAME_ENTITY,
"OnPieceEnteredMarkerHooks",
remainingEntered,
);
}
const markerExpireHooks =
(this.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as
| ChessAttrMap["OnMarkerExpireHooks"]
| undefined) ?? [];
const remainingMarkerExpire = markerExpireHooks.filter(
(h) => h.descriptorId !== descriptorId,
);
if (remainingMarkerExpire.length !== markerExpireHooks.length) {
this.session.insert(
GAME_ENTITY,
"OnMarkerExpireHooks",
remainingMarkerExpire,
);
}
// 3. Clear the activation fire-once guard so a re-attach (in the
// SAME session) can re-fire `on-rule-activated`. The expire
// guard is INTENTIONALLY left set — `RuleExpireFiredFor`
// persists for the life of the session per the same once-per-
// session semantics that govern activation. A re-attach + re-
// detach cycle within one game does not re-fire either side.
const firedActivated =
(this.session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as
| ChessAttrMap["RuleActivatedFiredFor"]
| undefined) ?? [];
if (firedActivated.includes(descriptorId)) {
this.session.insert(
PRESET_STATE_ENTITY,
"RuleActivatedFiredFor",
firedActivated.filter((id) => id !== descriptorId),
);
}
}
setActivePresets(requests: readonly ActivationRequest[]): void {
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
this.activePresets.replaceAll(requests);
@ -1005,6 +1115,55 @@ export class ChessEngine {
}
}
/**
* T72 capture-undo snapshot writer.
*
* Captures every (attr, value) pair currently on the defender, plus
* the attacker's pre-move Position and HasMoved status, and writes
* the result to `GAME_ENTITY` as `LastCaptureSnapshot`. The snapshot
* is read by the `cancel-capture` primitive (which re-inserts the
* defender's facts when an on-captured arm signals "undo this
* capture") and by `apply.ts` stage 4b (which rolls back the
* attacker's Position / HasMoved on the same signal).
*
* Walks `session.allFacts()` once and filters to facts whose
* `id === defenderId` AND whose `attr` passes `isPieceAttr` this
* mirrors the filter `rules/turn.applyMove` uses to enumerate the
* captured piece's attributes for retract, so the snapshot covers
* exactly the same set of facts. Storing as a frozen array of
* `[attr, value]` tuples (not a Map) keeps the value structurally
* cloneable / JSON-friendly even though it's transient the
* snapshot is unconditionally cleared at the end of stage 4b.
*
* Called only from the NORMAL capture branch of `applyMove` (not
* en-passant) EP capture-undo is a documented limitation that
* tracks alongside `PRE_MOVE_CAPTURED_DEFENDERS` (apply.ts) which
* has the same EP gap. Adding EP support requires re-routing the
* EP victim's defender id through the snapshot path; deferred.
*/
private recordLastCaptureSnapshot(
attackerId: EntityId,
attackerFromSquare: Square,
defenderId: EntityId,
): void {
const defenderFacts: Array<readonly [string, unknown]> = [];
for (const f of this.session.allFacts()) {
if (f.id !== defenderId) continue;
if (!isPieceAttr(f.attr as string)) continue;
defenderFacts.push([f.attr as string, f.value]);
}
const attackerHadMoved =
this.session.get(attackerId, "HasMoved") === true;
const snapshot: import("./schema.js").LastCaptureSnapshotValue = {
attackerId,
attackerFromSquare,
attackerHadMoved,
defenderId,
defenderFacts,
};
this.session.insert(GAME_ENTITY, "LastCaptureSnapshot", snapshot);
}
/**
* Return marker entity ids currently positioned at `square`,
* sorted by {@link MARKER_KIND_PRIORITY} ascending (T10).
@ -1504,6 +1663,17 @@ export class ChessEngine {
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
// T72 — snapshot defender + attacker pre-state BEFORE
// dealDamage retracts the defender. The snapshot is
// consumed by the `cancel-capture` primitive (defender
// restoration) and apply.ts stage 4b (attacker rollback)
// when an on-captured arm sets `CaptureCancelled`. We
// skip the snapshot path when a preset's onBeforeCapture
// already CONSUMED the capture (it owns all post-capture
// behaviour) or when the move isn't a capture at all —
// both are guarded by the surrounding `move.isCapture`
// and `!consumed` predicates.
this.recordLastCaptureSnapshot(move.pieceId, move.from, capturedId);
const { died } = this.dealDamage(capturedId, 1, {
kind: "capture",
attacker: move.pieceId,

View file

@ -0,0 +1,314 @@
/**
* T73 useMultiplayerGame request-choice plumbing.
*
* Verifies that:
* 1. A server-broadcast `request-choice` (flat v2 frame) sets the
* hook's `pendingChoice` to the new payload.
* 2. Calling the hook's `submitChoice` clears the local stack AND
* sends a flat `submit-choice` frame on the wire.
*
* The hook hard-codes `new GameClient(WS_URL)` so we drive everything
* by patching `globalThis.WebSocket` with a deterministic mock. React's
* `act` keeps state updates flushed inside the assertion window so we
* can read `pendingChoice` synchronously after dispatching.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { createElement, useEffect } from 'react';
import { useMultiplayerGame } from './useMultiplayerGame.js';
import type { RequestChoicePayload } from '../net/types.js';
// Tell React we're in a testing environment so `act()` actually batches
// state updates instead of warning. Required when using react-dom/client
// outside of @testing-library/react.
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// ---------------------------------------------------------------------------
// MockWebSocket — same shape as packages/chess/src/net/client.test.ts.
// ---------------------------------------------------------------------------
interface SentFrame {
raw: string;
parsed: Record<string, unknown>;
}
class MockWebSocket {
static CONNECTING = 0 as const;
static OPEN = 1 as const;
static CLOSING = 2 as const;
static CLOSED = 3 as const;
readyState: number = MockWebSocket.CONNECTING;
url: string;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
readonly sent: SentFrame[] = [];
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
static instances: MockWebSocket[] = [];
static reset(): void {
MockWebSocket.instances = [];
}
static latest(): MockWebSocket {
const inst = MockWebSocket.instances.at(-1);
if (!inst) throw new Error('MockWebSocket: no instances yet');
return inst;
}
send(data: string): void {
let parsed: Record<string, unknown> = {};
try {
const raw = JSON.parse(data);
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
parsed = raw as Record<string, unknown>;
}
} catch {
/* leave parsed empty */
}
this.sent.push({ raw: data, parsed });
}
close(): void {
if (this.readyState !== MockWebSocket.CLOSED) {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
}
// Test driver helpers.
simulateOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
}
simulateMessage(msg: unknown): void {
this.onmessage?.(
new MessageEvent('message', { data: JSON.stringify(msg) }),
);
}
}
// ---------------------------------------------------------------------------
// Hook driver
// ---------------------------------------------------------------------------
type HookReturn = ReturnType<typeof useMultiplayerGame>;
/**
* Render the hook into a detached DOM container and capture its
* latest return value via a ref. The render function calls the hook
* and stuffs the result into `outRef.current` on every render
* tests assert against `outRef.current` after dispatching events.
*/
function renderHook(): {
outRef: { current: HookReturn | null };
unmount: () => void;
} {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const outRef: { current: HookReturn | null } = { current: null };
function Probe() {
const result = useMultiplayerGame('ABC123', 'tok-1');
// Stash on every render so the latest values are visible to
// assertions outside the React tree.
outRef.current = result;
// Return null — we only care about the hook's outputs, not DOM.
useEffect(() => {
// no-op — keeps Probe a valid component
}, []);
return null;
}
act(() => {
root.render(createElement(Probe));
});
return {
outRef,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
// ---------------------------------------------------------------------------
// Setup — patch globalThis.WebSocket to MockWebSocket so the hook's
// `new GameClient(WS_URL)` picks it up via the global fallback.
// ---------------------------------------------------------------------------
const realWebSocket = globalThis.WebSocket;
beforeEach(() => {
MockWebSocket.reset();
// The hook expects `new WebSocket(url)` to construct something with
// OPEN/CLOSED/CONNECTING constants. MockWebSocket is shaped that way.
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket =
MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket =
realWebSocket;
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useMultiplayerGame — request-choice (T73)', () => {
it('exposes the new prompt as pendingChoice when the server pushes one', async () => {
const { outRef, unmount } = renderHook();
try {
// Initial state — no pending choice yet.
expect(outRef.current?.pendingChoice).toBeUndefined();
// Drive the connect handshake so subsequent simulateMessage()
// calls flow through the open socket.
const socket = MockWebSocket.latest();
await act(async () => {
socket.simulateOpen();
// Wait for any microtask-triggered state updates.
await Promise.resolve();
});
const choice: RequestChoicePayload = {
choiceId: 'c-1',
choiceKind: 'rps',
prompt: 'Pick rock paper scissors',
forPlayer: 'white',
descriptorId: 'rps-test',
};
await act(async () => {
socket.simulateMessage({
kind: 'request-choice',
protocolVersion: 2,
...choice,
});
await Promise.resolve();
});
expect(outRef.current?.pendingChoice).toMatchObject({
choiceId: 'c-1',
choiceKind: 'rps',
prompt: 'Pick rock paper scissors',
forPlayer: 'white',
descriptorId: 'rps-test',
});
} finally {
unmount();
}
});
it('submitChoice clears pendingChoice and sends a flat submit-choice frame', async () => {
const { outRef, unmount } = renderHook();
try {
const socket = MockWebSocket.latest();
await act(async () => {
socket.simulateOpen();
await Promise.resolve();
});
// Push a prompt so submitChoice has something to resolve.
await act(async () => {
socket.simulateMessage({
kind: 'request-choice',
protocolVersion: 2,
choiceId: 'c-2',
choiceKind: 'coin-flip',
prompt: 'Flip the coin',
forPlayer: 'both',
descriptorId: 'coin-flip-test',
});
await Promise.resolve();
});
expect(outRef.current?.pendingChoice?.choiceId).toBe('c-2');
// Snapshot wire traffic before submit so the assertion below
// only sees the submit-choice frame.
const beforeSent = socket.sent.length;
await act(async () => {
outRef.current?.submitChoice('c-2', 'heads');
await Promise.resolve();
});
// 1) Local stack cleared — modal closes.
expect(outRef.current?.pendingChoice).toBeUndefined();
// 2) Flat v2 submit-choice frame sent on the wire.
const newFrames = socket.sent.slice(beforeSent);
expect(newFrames).toHaveLength(1);
const frame = newFrames[0]!.parsed;
expect(frame['kind']).toBe('submit-choice');
expect(frame['protocolVersion']).toBe(2);
expect(frame['choiceId']).toBe('c-2');
expect(frame['value']).toBe('heads');
// V2 frames are FLAT — no v1 envelope fields.
expect(frame['v']).toBeUndefined();
expect(frame['type']).toBeUndefined();
} finally {
unmount();
}
});
it('de-dupes a re-broadcast of the same choiceId (idempotent resume)', async () => {
const { outRef, unmount } = renderHook();
try {
const socket = MockWebSocket.latest();
await act(async () => {
socket.simulateOpen();
await Promise.resolve();
});
const push = async (expiresAtTimestamp: number) => {
await act(async () => {
socket.simulateMessage({
kind: 'request-choice',
protocolVersion: 2,
choiceId: 'c-dup',
choiceKind: 'rps',
prompt: 'rps?',
forPlayer: 'white',
descriptorId: 'd',
expiresAtTimestamp,
});
await Promise.resolve();
});
};
await push(1_000);
await push(2_000); // Same choiceId, fresher payload.
// Only one entry on the stack — the latest one wins.
const top = outRef.current?.pendingChoice;
expect(top?.choiceId).toBe('c-dup');
expect(top?.expiresAtTimestamp).toBe(2_000);
// Resolving once empties the stack (no stale duplicates).
await act(async () => {
outRef.current?.submitChoice('c-dup', 'rock');
await Promise.resolve();
});
expect(outRef.current?.pendingChoice).toBeUndefined();
} finally {
unmount();
}
});
});
// Suppress unused import error in environments that don't surface vi.
void vi;

View file

@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
import type { GameResult } from '../engine';
import { GameClient } from '../net/client';
import { PredictionManager } from '../net/prediction';
import type { Color, PlayerActionWire, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
import type { Color, PlayerActionWire, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload, RequestChoicePayload } from '../net/types';
import * as audio from '../audio';
import { toast } from 'sonner';
@ -58,6 +58,13 @@ export interface MultiplayerGameState {
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage: string | null;
isProposer: boolean;
/**
* T73: stack of pending request-choice prompts. Server's
* PendingChoices is LIFO; the UI mirrors that top of the stack
* is the active modal. Stored as an array so a new push from a
* trigger cascade doesn't lose the outer frame underneath.
*/
pendingChoiceStack: RequestChoicePayload[];
}
/**
@ -79,6 +86,7 @@ export function useMultiplayerGame(code: string, token: string) {
modifierProposal: null,
modifierRejectionMessage: null,
isProposer: false,
pendingChoiceStack: [],
});
// Mount the connection exactly once per code/token pair.
@ -157,6 +165,20 @@ export function useMultiplayerGame(code: string, token: string) {
audio.play('move');
}
};
const onRequestChoice = (e: { payload: RequestChoicePayload }) => {
// T73: push a new request-choice onto the local stack so the
// modal surfaces. De-dupe by choiceId — the server idempotently
// re-broadcasts the same `request-choice` on snapshot replay
// (reconnect), and we shouldn't grow the stack on a passive
// resume. Drop any earlier entry with the same choiceId so the
// freshest payload (e.g. updated expiresAtTimestamp) wins.
setMeta((m) => {
const filtered = m.pendingChoiceStack.filter(
(c) => c.choiceId !== e.payload.choiceId,
);
return { ...m, pendingChoiceStack: [...filtered, e.payload] };
});
};
const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => {
// Fatal errors tear down the session entirely and the server
// closes the socket. Non-fatal server errors (ILLEGAL_MOVE,
@ -196,6 +218,7 @@ export function useMultiplayerGame(code: string, token: string) {
client.on('modifier-profile.rejected', onProposalRejected);
client.on('modifier-profile.consent-received', onProposalConsentReceived);
client.on('modifier-profile.queued', onProposalQueued);
client.on('request-choice', onRequestChoice);
client.on('error', onError);
client.connect(code, token).catch((err: unknown) => {
@ -218,6 +241,7 @@ export function useMultiplayerGame(code: string, token: string) {
client.off('modifier-profile.rejected', onProposalRejected);
client.off('modifier-profile.consent-received', onProposalConsentReceived);
client.off('modifier-profile.queued', onProposalQueued);
client.off('request-choice', onRequestChoice);
client.off('error', onError);
client.close();
clientRef.current = null;
@ -297,6 +321,25 @@ export function useMultiplayerGame(code: string, token: string) {
clientRef.current?.sendAction(action);
}, []);
/**
* T73: resolve the active request-choice. Sends `submit-choice` to
* the server (flat v2 frame) and optimistically pops the local
* stack entry so the modal closes immediately. If the server
* rejects (mismatched choiceId, unauthorised player, malformed
* value), it emits a non-fatal `error` event which surfaces via
* the existing error banner; the next snapshot replay will then
* re-push the same prompt so the UI recovers.
*/
const submitChoice = useCallback((choiceId: string, value: unknown) => {
clientRef.current?.sendSubmitChoice(choiceId, value);
setMeta((m) => ({
...m,
pendingChoiceStack: m.pendingChoiceStack.filter(
(c) => c.choiceId !== choiceId,
),
}));
}, []);
const loadEngine = useCallback(() => {
// Also a no-op: authoritative state is server-driven. The UI should
// not have any need to swap the engine under multiplayer — all state
@ -346,6 +389,11 @@ export function useMultiplayerGame(code: string, token: string) {
modifierProposal: meta.modifierProposal,
modifierRejectionMessage: meta.modifierRejectionMessage,
isProposer: meta.isProposer,
/** T73: top of the request-choice stack, or undefined when no
* prompt is pending. GameView feeds this directly into the
* RequestChoiceModal `choice` prop. */
pendingChoice: meta.pendingChoiceStack.at(-1),
submitChoice,
sendConsent: (decision: 'approve' | 'reject') => {
clientRef.current?.send({
type: 'modifier-profile.consent',

View file

@ -17,6 +17,7 @@ export {
} from "./coord.js";
export {
GAME_ENTITY,
PRESET_STATE_ENTITY,
PROMOTION_PIECES,
CaptureFlag,
DEFAULT_CHOICE_TIMEOUT_POLICY,

View file

@ -48,7 +48,7 @@
* to activate it manually.
*/
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType, Square } from "../schema.js";
import type { ChessAttrKey, PieceColor, PieceType, Square } from "../schema.js";
import { CaptureFlag, GAME_ENTITY } from "../schema.js";
import { algebraicToSquare } from "../coord.js";
import type { StartingLayout } from "../layouts/types.js";
@ -197,6 +197,13 @@ registerAttrConsumer("OnMarkerExpireHooks");
// existing capture pipeline retracts the defender BEFORE the
// on-captured trigger fires (see stage-4 NOTE in onAfterMove).
registerAttrConsumer("CaptureCancelled");
// T72 — capture-undo snapshot. Stored on GAME_ENTITY by the capture
// pipeline (engine.applyMove + rules/turn.applyMove) BEFORE defender
// retract / attacker advance; consumed by the `cancel-capture`
// primitive (re-insert defender facts) AND by the dispatcher
// (apply.ts stage 4b — attacker rollback on flag set, plus
// unconditional snapshot clear).
registerAttrConsumer("LastCaptureSnapshot");
// T35 — turn-bounded attr lifetime registry, stored on GAME_ENTITY.
// Seeded by `set-piece-attr` (T26) when the caller passes
// `lifetime: { kind: "turns", count: N }`; consumed by
@ -281,6 +288,59 @@ function classifyNonCaptureMove(from: number, to: number): "step" | "slide" {
*/
const PRE_MOVE_CAPTURE_ATTACKERS = new WeakMap<ChessEngine, EntityId>();
/**
* T72 attacker-side rollback half of the capture-undo pipeline.
*
* Reads `LastCaptureSnapshot` from `GAME_ENTITY` and re-inserts the
* attacker's pre-move `Position`, restoring `HasMoved` to its
* pre-move state (retract or re-insert depending on whether the
* attacker had already moved before this turn). Called from:
*
* - `onAfterMove` stage 4b synchronously when `CaptureCancelled`
* is set AND the on-captured arm did NOT suspend. The flag is
* consumed by the dispatcher in the same block.
* - The resume continuation path (parry-real test shim today;
* generalised T46 `submit-choice` resume in the future) when
* the player's choice resolves into `cancel-capture`. The
* resume helper polls `CaptureCancelled` after running the
* continuation and invokes this when it's set, then clears the
* flag and snapshot.
*
* Defender restoration is owned by `cancel-capture.apply()` (it sees
* the capture event and always runs in the same control-flow scope
* as the cancel decision). Attacker rollback lives here because the
* attacker advance happens BEFORE the on-captured trigger fires
* the primitive can't undo it without crossing into dispatcher
* territory.
*
* Idempotent against a missing snapshot: silently no-ops if no
* snapshot is present (e.g. legacy V1 cancel-capture from a
* synthetic test path that doesn't go through the engine pipeline).
*/
export function rollbackAttackerFromSnapshot(engine: ChessEngine): void {
const snapshot = engine.session.get(
GAME_ENTITY,
"LastCaptureSnapshot",
) as import("../schema.js").LastCaptureSnapshotValue | undefined;
if (snapshot === undefined) return;
engine.session.insert(
snapshot.attackerId,
"Position",
snapshot.attackerFromSquare,
);
if (snapshot.attackerHadMoved) {
// Attacker had already moved earlier in the game — keep
// HasMoved=true (a previously-castled king must not become
// eligible again because the cancelled move was rolled back).
engine.session.insert(snapshot.attackerId, "HasMoved", true);
} else if (engine.session.contains(snapshot.attackerId, "HasMoved")) {
// Attacker was making its first move; the move set HasMoved=true.
// Roll that back so the attacker is still treated as "fresh"
// (castling rights, double pawn push eligibility, etc.).
engine.session.retract(snapshot.attackerId, "HasMoved");
}
}
/**
* Pre-move check-state snapshot. For each color, maps every royal's
* EntityId to the list of enemy attacker EntityIds threatening it
@ -1158,20 +1218,102 @@ PRESET_REGISTRY.register({
// the move was not a capture, both are null and we skip.
const defender = PRE_MOVE_CAPTURED_DEFENDERS.get(ctx.engine) ?? null;
if (defender !== null && attacker !== null) {
// T72: defender facts have already been retracted by
// `dealDamage` (engine.applyMove stage 1) — including
// `OnCapturedHooks` itself. `fireOnCapturedHooks` reads
// hooks directly from the session, so without intervention
// it would see `undefined` and silently skip. We solve
// this by transiently RE-INSERTING the defender's
// pre-capture facts from `LastCaptureSnapshot.defenderFacts`
// BEFORE firing the trigger. Three downstream branches
// diverge:
// 1. cancel-capture fired → keep the re-inserted facts
// (defender restored), roll back the attacker.
// 2. trigger completed without cancel → retract the
// defender facts again (capture proceeds, identical
// to the no-snapshot path).
// 3. trigger SUSPENDED on a request-choice → keep the
// defender facts in place; the resume continuation
// decides whether to cancel. The post-resume helper
// (parry-test shim today, future T46 generic) is
// responsible for the cancel-vs-proceed cleanup
// mirroring branches 1 / 2 above.
const snapshotForFire = ctx.engine.session.get(
GAME_ENTITY,
"LastCaptureSnapshot",
) as import("../schema.js").LastCaptureSnapshotValue | undefined;
if (
snapshotForFire !== undefined &&
snapshotForFire.defenderId === defender
) {
for (const [attr, value] of snapshotForFire.defenderFacts) {
ctx.engine.session.insert(
defender,
attr as ChessAttrKey,
value as never,
);
}
}
const pendingDepthBefore = (
(ctx.engine.session.get(GAME_ENTITY, "PendingChoices") as
| readonly unknown[]
| undefined) ?? []
).length;
fireOnCapturedHooks(ctx.engine, defender, attacker);
// 4b. T27 — `cancel-capture` inhibitor poll. The
// `cancel-capture` primitive (legal only inside an
const pendingDepthAfter = (
(ctx.engine.session.get(GAME_ENTITY, "PendingChoices") as
| readonly unknown[]
| undefined) ?? []
).length;
const suspended = pendingDepthAfter > pendingDepthBefore;
// 4b. T27 / T72 — `cancel-capture` inhibitor poll + capture
// undo. The `cancel-capture` primitive (legal only inside an
// on-captured arm) writes CaptureCancelled = true on
// GAME_ENTITY. We poll + retract the flag here so it never
// leaks across moves or cascade boundaries. V1 wire-in
// scope: the flag is observed and reset; full restoration
// of defender facts is DEFERRED to T28+ because the engine
// capture path already retracted the defender BEFORE this
// dispatcher fired (see stage-4 NOTE above). Sibling
// primitives + the future capture-pipeline refactor
// consume the flag as the inhibitor signal.
if (ctx.engine.session.get(GAME_ENTITY, "CaptureCancelled") === true) {
// GAME_ENTITY and ALSO re-inserts defender facts from the
// T72 LastCaptureSnapshot. This stage-4b dispatcher
// additionally rolls back the ATTACKER side (Position +
// HasMoved) — the attacker advance happened before the on-
// captured trigger fired, so the cancel-capture primitive
// can't undo it without taking over the dispatcher's
// ownership of move-level state. Splitting the work this
// way keeps the primitive defender-scoped and the
// dispatcher attacker-scoped.
const cancelled =
ctx.engine.session.get(GAME_ENTITY, "CaptureCancelled") === true;
if (cancelled) {
rollbackAttackerFromSnapshot(ctx.engine);
ctx.engine.session.retract(GAME_ENTITY, "CaptureCancelled");
} else if (
!suspended &&
snapshotForFire !== undefined &&
snapshotForFire.defenderId === defender
) {
// Trigger completed without cancel — re-retract the
// defender's facts so the engine state matches the
// no-snapshot capture path. The pre-fire re-insert was
// purely a "let the hook see live attrs" affordance.
for (const [attr] of snapshotForFire.defenderFacts) {
if (
ctx.engine.session.contains(defender, attr as ChessAttrKey)
) {
ctx.engine.session.retract(defender, attr as ChessAttrKey);
}
}
}
// Clear the snapshot only when the trigger arm completed
// synchronously — when it suspended, the resume continuation
// is what eventually fires `cancel-capture`, and it needs
// the snapshot to still be there. The resume helper
// (currently a parry-test shim; future T46 generic) is
// responsible for invoking the same rollback + clear after
// resume completes.
if (
!suspended &&
ctx.engine.session.contains(GAME_ENTITY, "LastCaptureSnapshot")
) {
ctx.engine.session.retract(GAME_ENTITY, "LastCaptureSnapshot");
}
}

View file

@ -70,6 +70,7 @@
*/
import { z } from "zod";
import { GAME_ENTITY } from "../../schema.js";
import type { ChessAttrKey, LastCaptureSnapshotValue } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
@ -80,9 +81,9 @@ const descriptor: EffectPrimitive<Params> = {
kind: "cancel-capture",
label: "Cancel Capture",
description:
"Inside an on-captured arm, sets the CaptureCancelled flag on GAME_ENTITY signalling the dispatcher to suppress downstream cascade. Throws if no capture event is present in ctx.",
"Inside an on-captured arm, sets the CaptureCancelled flag on GAME_ENTITY AND restores the defender's pre-capture facts from LastCaptureSnapshot. Throws if no capture event is present in ctx.",
longDescription:
"Imperative inhibitor primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Requires ctx.event.kind === 'capture' (typically inside an on-captured arm) — calling outside that context throws runtime.cancel-capture-no-event. Sets CaptureCancelled = true on GAME_ENTITY. The capture dispatcher polls + retracts this flag AFTER fireOnCapturedHooks returns, so it never leaks across moves. V1 wire-in scope: the flag is set + retracted; full restoration of defender facts is DEFERRED to T28+ because the existing capture pipeline retracts the defender BEFORE the on-captured trigger fires (see apply.ts stage-4 NOTE). The flag is the inhibitor signal sibling primitives + future capture-pipeline refactor will consume.",
"Imperative inhibitor primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Requires ctx.event.kind === 'capture' (typically inside an on-captured arm) — calling outside that context throws runtime.cancel-capture-no-event. T72 wire-in: in addition to setting CaptureCancelled = true on GAME_ENTITY (the dispatcher signal), this primitive RESTORES the defender's pre-capture facts by re-inserting every (attr, value) pair from `LastCaptureSnapshot.defenderFacts`. The dispatcher (apply.ts stage 4b) handles attacker rollback — it owns Position/HasMoved on the moving piece because the move-advance happens before the on-captured trigger fires. Splitting the work this way keeps the primitive defender-scoped and the dispatcher attacker-scoped. The flag and snapshot are both cleared by the dispatcher after the hook fires, so neither leaks across moves.",
examples: [
{
title: "Parry — cancel a capture when defender wins RPS",
@ -114,6 +115,37 @@ const descriptor: EffectPrimitive<Params> = {
);
}
ctx.session.insert(GAME_ENTITY, "CaptureCancelled", true);
// T72 — restore the defender's pre-capture facts from the
// snapshot the capture pipeline wrote BEFORE retraction. The
// snapshot's defenderId MUST agree with the event's defenderId
// — the pipeline writes one snapshot per capture and the
// dispatcher fires on-captured for that defender exclusively.
// We still defensively check the match in case a future code
// path stages multiple captures within a single move (e.g. AoE
// / explosive-rook): mismatched snapshot is treated as "no
// snapshot available" and the cancel falls back to flag-only
// semantics (inhibitor without restore — useful when the
// primitive runs from synthetic test contexts that don't go
// through the engine.applyMove pipeline, e.g. the legacy V1
// unit tests).
const snapshot = ctx.session.get(GAME_ENTITY, "LastCaptureSnapshot") as
| LastCaptureSnapshotValue
| undefined;
if (snapshot === undefined) return;
if (snapshot.defenderId !== ctx.event.defenderId) return;
for (const [attr, value] of snapshot.defenderFacts) {
// The snapshot stores attrs as `string` for round-trip
// friendliness; cast to the engine's ChessAttrKey at insert
// time. An unrecognised attr name simply yields a no-op
// insert at the Session level — Session doesn't validate
// attr names against the schema.
ctx.session.insert(
snapshot.defenderId,
attr as ChessAttrKey,
value as never,
);
}
},
};

View file

@ -23,6 +23,7 @@ import {
fireOnMovedOntoSquareHooks,
fireOnPromotionHooks,
fireOnRuleActivatedHooks,
fireOnRuleExpireHooks,
fireOnTurnEndHooks,
runPrimitives,
type PreMoveCheckStateLike,
@ -1129,3 +1130,76 @@ describe("fireOnRuleActivatedHooks (T16)", () => {
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
});
});
// ─── T17 / T71: fireOnRuleExpireHooks ───────────────────────────────────
//
// Mirror image of fireOnRuleActivatedHooks. Reads
// `OnRuleExpireHooks` from GAME_ENTITY and fires only entries whose
// `descriptorId` matches. Self-guards against double-fire via
// `RuleExpireFiredFor` on PRESET_STATE_ENTITY.
describe("fireOnRuleExpireHooks (T17 / T71)", () => {
it("fires the matching descriptor's primitives, skips other descriptors", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-rule-expire-dispatch"),
});
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-X",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } },
],
},
{
descriptorId: "rule-Y",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
],
},
]);
fireOnRuleExpireHooks(engine, "rule-X");
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(5);
// rule-Y did NOT fire.
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
});
it("self-guards against double-fire via RuleExpireFiredFor", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-rule-expire-dedup"),
});
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-Z",
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
},
],
},
]);
fireOnRuleExpireHooks(engine, "rule-Z");
fireOnRuleExpireHooks(engine, "rule-Z");
fireOnRuleExpireHooks(engine, "rule-Z");
// Despite three calls, the increment fired exactly once.
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(1);
});
it("no-op when the descriptor has no expire-hook entries", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-rule-expire-empty"),
});
// No entries at all — should not throw, and should still mark
// the descriptor as fired (idempotent semantics).
expect(() =>
fireOnRuleExpireHooks(engine, "rule-no-hooks"),
).not.toThrow();
});
});

View file

@ -7,6 +7,7 @@ import { GameClient } from "./client.js";
import type {
GameDeltaPayload,
GameStatePayload,
RequestChoicePayload,
RoomJoinedPayload,
} from "./types.js";
@ -515,6 +516,169 @@ describe("GameClient — sequence tracking", () => {
});
});
// ---------------------------------------------------------------------------
// T73 — request-choice / submit-choice (protocol v2 flat frames)
// ---------------------------------------------------------------------------
//
// The server broadcasts `request-choice` as a FLAT top-level v2 frame
// (no v1 envelope, no `seq`). The client must detect this shape on
// `kind` and dispatch a `request-choice` event with the payload. The
// reverse direction — `sendSubmitChoice` — must emit a similarly flat
// frame with `protocolVersion: 2`, NOT route through the v1 envelope.
describe("GameClient — request-choice (T73)", () => {
it("emits request-choice when the server sends a flat v2 frame", async () => {
const { client } = makeClient();
const received: RequestChoicePayload[] = [];
client.on("request-choice", (ev) => received.push(ev.payload));
const pending = client.connect("ABC123", "tok-1");
const socket = MockWebSocket.latest();
socket.simulateOpen();
await pending;
// Mirrors `buildRequestChoice` in server/src/broadcast.ts: flat,
// top-level `kind`, no envelope. Optional fields omitted.
socket.simulateMessage({
kind: "request-choice",
protocolVersion: 2,
choiceId: "c-1",
descriptorId: "rps-test",
prompt: "Pick rock paper scissors",
choiceKind: "rps",
forPlayer: "white",
});
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
choiceId: "c-1",
choiceKind: "rps",
prompt: "Pick rock paper scissors",
forPlayer: "white",
descriptorId: "rps-test",
});
});
it("preserves optional timeout / expiresAtTimestamp fields", async () => {
const { client } = makeClient();
const received: RequestChoicePayload[] = [];
client.on("request-choice", (ev) => received.push(ev.payload));
const pending = client.connect("ABC123", "tok-1");
MockWebSocket.latest().simulateOpen();
await pending;
MockWebSocket.latest().simulateMessage({
kind: "request-choice",
protocolVersion: 2,
choiceId: "c-2",
descriptorId: "d-2",
prompt: "Flip the coin",
choiceKind: "coin-flip",
forPlayer: "both",
timeout: 30000,
expiresAtTimestamp: 1_700_000_000_000,
});
expect(received[0]).toMatchObject({
timeout: 30000,
expiresAtTimestamp: 1_700_000_000_000,
});
});
it("does NOT bump currentSeq for v2 flat frames", async () => {
const { client } = makeClient();
const pending = client.connect("ABC123", "tok-1");
MockWebSocket.latest().simulateOpen();
await pending;
expect(client.currentSeq).toBe(0);
MockWebSocket.latest().simulateMessage({
kind: "request-choice",
protocolVersion: 2,
choiceId: "c-3",
descriptorId: "d-3",
prompt: "?",
choiceKind: "square",
forPlayer: "white",
});
// V2 flat frames carry no `seq` — they must NOT advance the seq
// counter (which is the v1 reconcile cursor).
expect(client.currentSeq).toBe(0);
});
it("ignores unknown v2 kinds (forward compat)", async () => {
const { client } = makeClient();
const seen: unknown[] = [];
client.on("request-choice", (ev) => seen.push(ev.payload));
const pending = client.connect("ABC123", "tok-1");
MockWebSocket.latest().simulateOpen();
await pending;
expect(() =>
MockWebSocket.latest().simulateMessage({
kind: "future.v2.kind",
protocolVersion: 2,
}),
).not.toThrow();
expect(seen).toEqual([]);
});
});
describe("GameClient — sendSubmitChoice (T73)", () => {
it("sends a flat v2 submit-choice frame", async () => {
const { client } = makeClient();
const pending = client.connect("ABC123", "tok-1");
const socket = MockWebSocket.latest();
socket.simulateOpen();
await pending;
// Drop the room.join frame from the recorded list so the assertion
// focuses on the submit-choice frame we're actually testing.
const before = socket.sent.length;
client.sendSubmitChoice("c-1", "paper");
const newFrames = socket.sent.slice(before);
expect(newFrames).toHaveLength(1);
const frame = newFrames[0]!.parsed;
// V2 flat shape — no `v`, no `seq`, no `ts`, no envelope-level
// `type`. The discriminator is `kind` and `protocolVersion: 2`.
expect(frame["v"]).toBeUndefined();
expect(frame["seq"]).toBeUndefined();
expect(frame["ts"]).toBeUndefined();
expect(frame["type"]).toBeUndefined();
expect(frame["kind"]).toBe("submit-choice");
expect(frame["protocolVersion"]).toBe(2);
expect(frame["choiceId"]).toBe("c-1");
expect(frame["value"]).toBe("paper");
});
it("supports arbitrary value shapes (kind-dependent payloads)", async () => {
const { client } = makeClient();
const pending = client.connect("ABC123", "tok-1");
const socket = MockWebSocket.latest();
socket.simulateOpen();
await pending;
// square = number, piece = number, rps = string, etc. The wire
// accepts arbitrary `value`; engine-side validators gate on
// `choiceKind` after the frame is parsed.
client.sendSubmitChoice("c-square", 28);
const numFrame = socket.sent.at(-1)!.parsed;
expect(numFrame["value"]).toBe(28);
client.sendSubmitChoice("c-obj", { x: 1, y: 2 });
const objFrame = socket.sent.at(-1)!.parsed;
expect(objFrame["value"]).toEqual({ x: 1, y: 2 });
});
it("drops sends when the socket is not open", () => {
const { client } = makeClient();
void client.connect("ABC123", "tok-1");
// Never simulateOpen — readyState stays CONNECTING.
client.sendSubmitChoice("c-x", "rock");
expect(MockWebSocket.latest().sent).toHaveLength(0);
});
});
describe("GameClient — listener management", () => {
it("off() removes a listener", async () => {
const { client } = makeClient();

View file

@ -28,6 +28,7 @@ import type {
PlayerActionWire,
PresetActivation,
PromotionPiece,
RequestChoicePayload,
RoomCreatedPayload,
RoomJoinedPayload,
RoomSetPresetsPayload,
@ -50,6 +51,7 @@ export type GameClientEvent =
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
| { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload }
| { type: "request-choice"; payload: RequestChoicePayload }
| { type: "error"; payload: ErrorPayload }
| { type: "connected" }
| { type: "disconnected"; willReconnect: boolean };
@ -323,6 +325,29 @@ export class GameClient {
this.send({ type: "custom-modifier.register", payload });
}
/**
* T44: resolve a server-broadcast `request-choice` by sending a v2
* flat `submit-choice` frame. NOT routed through the v1 envelope
* v2 frames are top-level and carry their own `protocolVersion` /
* `kind` discriminators (see packages/server/src/protocol.ts T43).
*
* Silently no-op when the socket is not OPEN; callers that need
* durability should retry at a higher layer (in practice, the
* server re-broadcasts the same `request-choice` on snapshot
* replay so reconnecting clients can recover).
*/
sendSubmitChoice(choiceId: string, value: unknown): void {
const socket = this.ws;
if (!socket || socket.readyState !== this.WSCtor.OPEN) return;
const frame = {
kind: "submit-choice" as const,
protocolVersion: 2 as const,
choiceId,
value,
};
socket.send(JSON.stringify(frame));
}
// -------------------------------------------------------------------------
// Accessors (primarily for tests & reconnect logic)
// -------------------------------------------------------------------------
@ -439,6 +464,16 @@ export class GameClient {
return; // Malformed frames are silently ignored at this layer.
}
if (typeof parsed !== "object" || parsed === null) return;
// T43/T44: protocol v2 frames are FLAT (no v1 envelope) and carry a
// `kind` discriminator at the top level. Detect and route them
// before falling through to the v1 envelope path.
const v2Kind = (parsed as { kind?: unknown }).kind;
if (typeof v2Kind === "string") {
this.dispatchV2Frame(v2Kind, parsed);
return;
}
const msg = parsed as IncomingEnvelope;
if (typeof msg.seq === "number" && msg.seq > this.lastSeq) {
@ -454,6 +489,27 @@ export class GameClient {
this.dispatchServerMessage(msg.type, msg.payload);
}
/**
* Route a v2 flat frame (T43). Currently only `request-choice` is
* surfaced as an event; `protocol-version-mismatch` is connection-
* fatal and the server closes the socket immediately after sending
* it (the resulting `disconnected` event is what callers observe).
* Unknown v2 kinds are ignored for forward-compat.
*/
private dispatchV2Frame(kind: string, frame: object): void {
if (kind === "request-choice") {
// Trust the server's schema (mirrors v1 dispatch convention) —
// the wire shape was validated server-side before send.
this.emit({
type: "request-choice",
payload: frame as RequestChoicePayload,
});
return;
}
// Other v2 kinds (`protocol-version-mismatch`, future additions) are
// ignored at this layer.
}
private captureAuth(msg: IncomingEnvelope): void {
if (msg.type !== "room.created" && msg.type !== "room.joined") return;
const p = msg.payload;

View file

@ -308,6 +308,43 @@ export interface ErrorPayload {
fatal: boolean;
}
// ---------------------------------------------------------------------------
// T43/T44 — Protocol v2: request-choice / submit-choice
// ---------------------------------------------------------------------------
//
// FLAT top-level v2 frames (NOT v1 envelopes). Server emits `request-choice`
// to clients that negotiated protocolVersion >= 2; clients reply with
// `submit-choice` to resolve the top of the LIFO PendingChoices stack.
export type ChoiceKind =
| "rps"
| "piece"
| "square"
| "column"
| "row"
| "coin-flip";
export type ChoiceForPlayer = "white" | "black" | "both";
/**
* Server client (v2 flat frame). Wire shape mirrors
* `RequestChoiceSchema` in packages/server/src/protocol.ts (T43).
* `choiceId` echoes back unchanged on `submit-choice`.
*/
export interface RequestChoicePayload {
choiceId: string;
choiceKind: ChoiceKind;
prompt: string;
forPlayer: ChoiceForPlayer;
descriptorId: string;
/** Optional milliseconds-from-now deadline. */
timeout?: number;
/** Optional unix-ms wall-clock deadline. */
expiresAtTimestamp?: number;
/** Optional concrete option list — value space depends on `choiceKind`. */
options?: unknown[];
}
// ---------------------------------------------------------------------------
// Client → Server payloads (shape for send())
// ---------------------------------------------------------------------------

View file

@ -32,7 +32,7 @@
* - check filtering P2.18
*/
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType } from "../schema.js";
import type { PieceColor, PieceType, LastCaptureSnapshotValue } from "../schema.js";
import { GAME_ENTITY } from "../schema.js";
import type { LegalMove } from "./types.js";
import { getPieceColor, getPieceAt } from "./board-queries.js";
@ -143,6 +143,31 @@ export function applyMove(session: Session, move: LegalMove): void {
if (move.isCapture) {
const capturedId = getPieceAt(session, move.to);
if (capturedId !== null && capturedId !== move.pieceId) {
// T72 — snapshot defender facts + attacker pre-state BEFORE the
// retract so an `on-captured → cancel-capture` trigger arm can
// undo the capture wholesale. The snapshot is stored on
// GAME_ENTITY as `LastCaptureSnapshot`. The live engine path
// (engine.applyMove) writes its own snapshot via
// `recordLastCaptureSnapshot` — this function is the
// pure-helper variant used by tests and ports. Behaviour
// matches: same defender-fact filter (`isPieceAttr`), same
// attacker pre-state (Position + HasMoved-was-already-set).
const defenderFacts: Array<readonly [string, unknown]> = [];
for (const f of session.allFacts()) {
if (f.id !== capturedId) continue;
if (!isPieceAttr(f.attr)) continue;
defenderFacts.push([f.attr as string, f.value]);
}
const attackerHadMoved = session.get(move.pieceId, "HasMoved") === true;
const snapshot: LastCaptureSnapshotValue = {
attackerId: move.pieceId,
attackerFromSquare: move.from,
attackerHadMoved,
defenderId: capturedId,
defenderFacts,
};
session.insert(GAME_ENTITY, "LastCaptureSnapshot", snapshot);
// Enumerate the captured piece's attrs from the session rather
// than from a hardcoded list — preset-declared attrs (Hp,
// Shield, …) get cleaned up too without this function needing

View file

@ -396,14 +396,26 @@ export interface ChessAttrMap {
* (apply.ts stage 4b) so the flag never leaks across moves or
* cascade boundaries.
*
* V1 wire-in scope: the flag is set + retracted; full restoration
* of defender facts is DEFERRED to T28+ because the existing
* capture pipeline retracts the defender BEFORE the on-captured
* trigger fires (see apply.ts stage-4 NOTE). Sibling primitives
* and the future capture-pipeline refactor consume this flag as
* the inhibitor signal.
* T72: full restoration of defender facts AND attacker rollback is
* now wired. The capture pipeline writes a {@link LastCaptureSnapshot}
* BEFORE retracting defender facts / advancing the attacker; the
* `cancel-capture` primitive re-inserts defender facts from that
* snapshot, and the dispatcher (apply.ts stage 4b) restores the
* attacker's pre-move Position / HasMoved when the flag is set.
*/
CaptureCancelled: boolean;
/**
* T72 capture-undo snapshot, stored on `GAME_ENTITY`. Written by
* the capture pipeline (engine.applyMove + rules/turn.applyMove)
* BEFORE the defender's facts are retracted and BEFORE the attacker
* is advanced. Read by `cancel-capture` (which re-inserts every
* defender fact from `defenderFacts`) and by the post-hook
* dispatcher in apply.ts stage 4b (which rolls back the attacker's
* Position / HasMoved when `CaptureCancelled === true`). Cleared
* unconditionally at the end of stage 4b so the snapshot never
* leaks across moves.
*/
LastCaptureSnapshot: LastCaptureSnapshotValue;
/**
* T35 turn-bounded attribute lifetime registry. Stored on
* `GAME_ENTITY` (one list per game). Each entry records that some
@ -591,6 +603,47 @@ export interface PendingChoice {
readonly expiresAtTimestamp?: number;
}
/**
* T72 value shape of {@link ChessAttrMap.LastCaptureSnapshot}.
*
* Snapshot taken BEFORE the capture mutates the session, used to undo
* the capture wholesale when an `on-captured` arm fires `cancel-capture`.
*
* - `attackerId` the moving piece. Restored by stage 4b on cancel.
* - `attackerFromSquare` the attacker's pre-move Position. The
* cancel path re-inserts this so the attacker stays on its origin.
* - `attackerHadMoved` whether `HasMoved` was already true on the
* attacker BEFORE the move. `true` => leave HasMoved alone on
* cancel (a king/rook that had already moved before this turn
* must remain ineligible for castling). `false` => retract
* HasMoved on cancel (the move never happened, restore "fresh"
* status).
* - `defenderId` the captured piece. Used by `cancel-capture` to
* target the re-insert.
* - `defenderFacts` every (attr, value) pair that was on the
* defender BEFORE retraction. The cancel path re-inserts each
* pair, fully restoring the defender to its pre-capture state
* (PieceType, Color, Position, HasMoved, Hp, plus any
* preset-declared / primitive-seeded attrs the engine had
* snapshotted).
*
* Stored as a plain object (not a Map) so the value round-trips through
* `session.allFacts()` exports / save-load Maps don't survive JSON
* serialization without a custom codec, and the snapshot is transient
* (cleared at end of stage 4b) so persistence isn't required, but
* structural-clone friendliness is still nice for in-memory tests.
* `defenderFacts` is a `ReadonlyArray<[attr, value]>` for the same
* reason Maps would force every test reading the snapshot to know
* about Map's iteration shape.
*/
export interface LastCaptureSnapshotValue {
readonly attackerId: EntityId;
readonly attackerFromSquare: Square;
readonly attackerHadMoved: boolean;
readonly defenderId: EntityId;
readonly defenderFacts: ReadonlyArray<readonly [string, unknown]>;
}
/**
* T38 value shape of {@link ChessAttrMap.MoveClassRestriction}.
*

View file

@ -0,0 +1,316 @@
/**
* T73 MultiplayerGameView surfaces request-choice prompts via
* RequestChoiceModal.
*
* End-to-end-ish: drives a real `useMultiplayerGame` instance through a
* MockWebSocket so we exercise the GameClient hook modal pipeline.
* Uses happy-dom + react-dom/client (no @testing-library/react in the
* repo). The board itself is heavy we only assert the modal text
* surfaces, not its full structure (covered by RequestChoiceModal.test).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createElement } from 'react';
import { MultiplayerGameView } from './GameView.js';
// React must be in act-environment so state updates flush synchronously
// inside `act()` and the DOM reflects them when we read it.
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// happy-dom provides `localStorage` as a stub that exists but doesn't
// implement getItem in this version. The audio module + GameLayout's
// LayoutBadge / ModifierProfileBadge call `getItem` at render time —
// shim it so the rendering pipeline doesn't crash.
if (
typeof globalThis.localStorage !== 'object' ||
typeof (globalThis.localStorage as { getItem?: unknown }).getItem !==
'function'
) {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
clear: () => {
store.clear();
},
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
} as Storage;
}
// sessionStorage is read by Layout / ModifierProfile badges.
if (
typeof globalThis.sessionStorage !== 'object' ||
typeof (globalThis.sessionStorage as { getItem?: unknown }).getItem !==
'function'
) {
const store = new Map<string, string>();
(globalThis as unknown as { sessionStorage: Storage }).sessionStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
clear: () => {
store.clear();
},
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
} as Storage;
}
// ---------------------------------------------------------------------------
// MockWebSocket — same shape as the client/hook tests.
// ---------------------------------------------------------------------------
interface SentFrame {
raw: string;
parsed: Record<string, unknown>;
}
class MockWebSocket {
static CONNECTING = 0 as const;
static OPEN = 1 as const;
static CLOSING = 2 as const;
static CLOSED = 3 as const;
readyState: number = MockWebSocket.CONNECTING;
url: string;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
readonly sent: SentFrame[] = [];
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
static instances: MockWebSocket[] = [];
static reset(): void {
MockWebSocket.instances = [];
}
static latest(): MockWebSocket {
const inst = MockWebSocket.instances.at(-1);
if (!inst) throw new Error('no MockWebSocket instances yet');
return inst;
}
send(data: string): void {
let parsed: Record<string, unknown> = {};
try {
const raw = JSON.parse(data);
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
parsed = raw as Record<string, unknown>;
}
} catch {
/* leave parsed empty */
}
this.sent.push({ raw: data, parsed });
}
close(): void {
if (this.readyState !== MockWebSocket.CLOSED) {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
}
simulateOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
}
simulateMessage(msg: unknown): void {
this.onmessage?.(
new MessageEvent('message', { data: JSON.stringify(msg) }),
);
}
}
// ---------------------------------------------------------------------------
// Render harness
// ---------------------------------------------------------------------------
const realWebSocket = globalThis.WebSocket;
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
MockWebSocket.reset();
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket =
MockWebSocket as unknown as typeof WebSocket;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container) {
container.remove();
container = null;
}
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket =
realWebSocket;
});
/**
* Drive the WS handshake to the point where `useMultiplayerGame.loading`
* is false (so the GameLayout and therefore the modal actually
* renders). Returns the latest mock socket so tests can push more
* messages through it.
*/
async function bootHandshake(): Promise<MockWebSocket> {
const socket = MockWebSocket.latest();
await act(async () => {
socket.simulateOpen();
await Promise.resolve();
});
// room.joined sets `myColor` AND clears `loading` in one step.
await act(async () => {
socket.simulateMessage({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.joined',
payload: {
code: 'ABC123',
token: 'tok-1',
color: 'white',
activeRules: [],
},
});
await Promise.resolve();
});
// game.state with empty facts gives PredictionManager a real engine
// so the GameLayout's downstream queries (legalMoves, allFacts, …)
// don't blow up.
await act(async () => {
socket.simulateMessage({
v: 1,
seq: 2,
ts: Date.now(),
type: 'game.state',
payload: {
facts: [],
turn: 'white',
lastSeq: 2,
moveHistory: [],
activeRules: [],
activations: [],
fen: '',
},
});
await Promise.resolve();
});
return socket;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MultiplayerGameView — request-choice modal (T73)', () => {
it('renders RequestChoiceModal when the server pushes a request-choice', async () => {
act(() => {
root!.render(
createElement(MultiplayerGameView, { code: 'ABC123', token: 'tok-1' }),
);
});
const socket = await bootHandshake();
// Pre-state: no modal, no prompt text.
expect(container!.textContent).not.toContain('Pick rock paper scissors');
expect(container!.querySelector('[role="dialog"]')).toBeNull();
await act(async () => {
socket.simulateMessage({
kind: 'request-choice',
protocolVersion: 2,
choiceId: 'c-1',
descriptorId: 'rps-test',
prompt: 'Pick rock paper scissors',
choiceKind: 'rps',
forPlayer: 'white',
});
await Promise.resolve();
});
// Modal opened with the server's prompt text + the rps options.
expect(container!.querySelector('[role="dialog"]')).not.toBeNull();
expect(container!.textContent).toContain('Pick rock paper scissors');
expect(container!.textContent).toContain('rock');
expect(container!.textContent).toContain('paper');
expect(container!.textContent).toContain('scissors');
});
it('clicking a modal option closes it and sends a flat submit-choice frame', async () => {
act(() => {
root!.render(
createElement(MultiplayerGameView, { code: 'ABC123', token: 'tok-1' }),
);
});
const socket = await bootHandshake();
await act(async () => {
socket.simulateMessage({
kind: 'request-choice',
protocolVersion: 2,
choiceId: 'c-2',
descriptorId: 'coin-flip-test',
prompt: 'Flip the coin',
choiceKind: 'coin-flip',
forPlayer: 'both',
});
await Promise.resolve();
});
expect(container!.querySelector('[role="dialog"]')).not.toBeNull();
const beforeSent = socket.sent.length;
// Click the "heads" button. RequestChoiceModal renders option
// buttons with their option name as the label — find by textContent.
const buttons = Array.from(
container!.querySelectorAll('button'),
) as HTMLButtonElement[];
const heads = buttons.find((b) => b.textContent?.trim() === 'heads');
expect(heads).toBeDefined();
await act(async () => {
heads!.click();
await Promise.resolve();
});
// Modal closed.
expect(container!.querySelector('[role="dialog"]')).toBeNull();
// Flat v2 submit-choice frame on the wire.
const newFrames = socket.sent.slice(beforeSent);
const submit = newFrames.find(
(f) => f.parsed['kind'] === 'submit-choice',
);
expect(submit).toBeDefined();
expect(submit!.parsed['protocolVersion']).toBe(2);
expect(submit!.parsed['choiceId']).toBe('c-2');
expect(submit!.parsed['value']).toBe('heads');
});
});

View file

@ -3,6 +3,7 @@ import { RulesDrawer } from './RulesDrawer';
import { ModifierTooltip } from './ModifierTooltip.js';
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
import { RequestChoiceModal } from './RequestChoiceModal.js';
import { ActionMenu } from './ActionMenu.js';
import { useChessEngine } from '../hooks/useChessEngine';
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
@ -121,6 +122,18 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
</div>
)}
<GameLayout state={state} myColor={state.myColor} roomCode={code} />
{/*
* T73: surface server-broadcast `request-choice` prompts. The hook
* mirrors the server's LIFO PendingChoices stack top is active.
* The forPlayer gate is enforced server-side (we only receive
* prompts targeted at our color or "both"), so no extra filter
* is needed here.
*/}
<RequestChoiceModal
open={state.pendingChoice !== undefined}
choice={state.pendingChoice}
onSubmit={state.submitChoice}
/>
</>
);
}

View file

@ -33,6 +33,7 @@ import {
shouldSkipV2Broadcast,
validateAnyMessageString,
type CustomModifierRegisterPayload,
type CustomModifierRemovePayload,
type ErrorCode,
type Fact as WireFact,
type GameActionPayload,
@ -63,7 +64,7 @@ import {
} from "./choice-timeout.js";
import {
peekPendingChoice,
popPendingChoice,
submitChoiceAndResume,
validateProfile,
type ActionResult,
type ModifierProfile,
@ -454,15 +455,37 @@ function armChoiceTimeoutFor(
// Auto-resolve with the first option for this kind. The value
// bypasses the wire-side `isValidChoiceValue` check (it never
// touches the wire); the engine resume path (T46) will do its
// touches the wire); the engine resume path (T46) does its
// own kind-specific legality gate.
const defaultValue = firstDefaultForKind(kind);
void defaultValue; // T46: thread into engine resume context.
const popped = popPendingChoice(liveSession.getEngine());
if (popped !== undefined) {
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
// T70 — thread the default value through the real resume
// mechanism so any `params.then` continuation runs. The helper
// pops the frame unconditionally (even on descriptor-not-found,
// see pending-choices.ts header doc), so bookkeeping must be
// cleared in both the success and the catch branch. A throw
// from a continuation primitive must NOT crash the timer
// callback — log + clean up so the room remains live.
try {
submitChoiceAndResume(liveSession.getEngine(), choiceId, defaultValue);
} catch (err) {
logger
.child({ roomCode })
.warn(
{ choiceId, kind, err: (err as Error).message },
"T49: timeout-default resume threw; frame popped, room continues",
);
}
forgetBroadcastedChoiceId(roomCode, choiceId);
// 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
// move/action. A subsequent inner choice (if the continuation
// pushed one) is broadcast via the dedicated helper.
broadcastGameStateSnapshot(roomCode, liveSession);
broadcastTopChoiceIfNew(roomCode, liveSession);
logger
.child({ roomCode })
.info(
@ -680,11 +703,12 @@ function handleV2Frame(
* (see `isValidChoiceValue`). Failures emit a `protocol.invalid-
* choice-value` message.
*
* On full success: the frame is popped (`popPendingChoice`),
* bookkeeping for the broadcasted-set is cleared, and the popped
* value is currently DROPPED T46 will replace this with the real
* resume mechanism (`submitChoiceAndResume`). The pop itself is
* preserved so the LIFO contract holds even before T46 lands.
* On full success: the frame is resumed via `submitChoiceAndResume`
* (which pops the frame and runs the suspended `params.then`
* continuation with the player's value bound under the request-
* choice's `bind` name), broadcasted-set bookkeeping is cleared,
* and the resulting game state + any new top-of-stack choice are
* broadcast to the room.
*/
function handleSubmitChoice(
ws: ServerWebSocket<ClientData>,
@ -783,20 +807,42 @@ function handleSubmitChoice(
// fired after submit" log line in the latency window.
choiceTimeoutManager.cancel(roomCode, frame.choiceId);
// Pop the top frame and drop the bookkeeping entry. T46 will replace
// the bare pop with the real resume mechanism (which threads the
// value back into the engine's resume context); for now we satisfy
// the LIFO contract and clear our broadcast tracking so the next
// pending choice on this socket can be re-broadcast cleanly.
const popped = popPendingChoice(session.getEngine());
if (popped !== undefined) {
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
// T70 — thread the player's value through the real resume
// mechanism. `submitChoiceAndResume` pops the frame and runs the
// suspended `params.then` continuation. The pop is unconditional
// even on descriptor-not-found (see pending-choices.ts header),
// so we always clear the broadcasted-id bookkeeping. A throw
// from a continuation primitive must not propagate to the WS
// dispatcher — log and continue so the room stays live.
try {
submitChoiceAndResume(session.getEngine(), frame.choiceId, frame.value);
} catch (err) {
logger
.child({ clientId: ws.data.clientId, roomCode })
.warn(
{
choiceId: frame.choiceId,
kind: top.kind,
err: (err as Error).message,
},
"submit-choice resume threw; frame popped, room continues",
);
}
forgetBroadcastedChoiceId(roomCode, frame.choiceId);
// 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
// move/action. A subsequent inner choice pushed by the
// continuation is broadcast via the dedicated helper.
broadcastGameStateSnapshot(roomCode, session);
broadcastTopChoiceIfNew(roomCode, session);
logger
.child({ clientId: ws.data.clientId, roomCode })
.info(
{ choiceId: frame.choiceId, kind: top.kind },
"submit-choice (pre-T46: value accepted, resume not yet wired)",
"submit-choice: value accepted, continuation resumed",
);
}
@ -883,6 +929,9 @@ export function handleMessage(
case "custom-modifier.register":
handleCustomModifierRegister(ws, msg.payload);
break;
case "custom-modifier.remove":
handleCustomModifierRemove(ws, msg.payload);
break;
case "room.created":
case "room.joined":
case "game.state":
@ -895,6 +944,7 @@ export function handleMessage(
case "modifier-profile.rejected":
case "modifier-profile.consent-received":
case "custom-modifier.registered":
case "custom-modifier.removed":
case "error":
sendTo(
ws,
@ -2444,6 +2494,96 @@ function handleCustomModifierRegister(
);
}
// ---------------------------------------------------------------------------
// T71 — custom-modifier.remove handler
// ---------------------------------------------------------------------------
/**
* Detach a previously-registered custom modifier descriptor from the
* authoritative engine, drop it from the room's per-room registry,
* and broadcast `custom-modifier.removed` so opponents mirror the
* detach. Host-only mirrors the register-side gate.
*
* The detach pipeline on the engine is idempotent (already-detached
* id is a safe no-op), so re-sending a remove for an unknown id is
* not an error: the broadcast still goes out so client engines
* mirror their own removal.
*/
function handleCustomModifierRemove(
ws: ServerWebSocket<ClientData>,
payload: CustomModifierRemovePayload,
): void {
const { roomCode, token } = ws.data;
if (roomCode === undefined || token === undefined) {
sendTo(
ws,
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
);
return;
}
if (payload.roomCode !== roomCode) {
sendTo(
ws,
errorMessage(
"BAD_TOKEN",
"custom-modifier.remove roomCode does not match authenticated room",
false,
),
);
return;
}
const room = roomRegistry.getRoom(roomCode);
if (!room) {
sendTo(
ws,
errorMessage("ROOM_NOT_FOUND", `room ${roomCode} not found`, false),
);
return;
}
// Host-only — same gate as register. An opponent who could detach
// arbitrary descriptors could grief the host's rule set.
if (room.hostToken !== token) {
sendTo(
ws,
errorMessage(
"BAD_TOKEN",
"only the room host can remove custom modifiers",
false,
),
);
return;
}
const descriptorId = payload.descriptorId;
// Drop from the room's per-room registry (if present). This is the
// mirror of register's `room.customModifiers.set` write — the slot
// is freed for re-use under the per-room cap.
room.customModifiers?.delete(descriptorId);
// Engine-side detach: fires `on-rule-expire` hooks and retracts
// hook-list entries for `descriptorId`. The session is the
// authoritative engine — clients run their own detach on receipt
// of the broadcast below.
const session = sessionRegistry.get(roomCode);
session?.getEngine().detachCustomDescriptor(descriptorId);
logger
.child({ roomCode, clientId: ws.data.clientId })
.info({ descriptorId }, "custom-modifier.remove");
broadcastToRoom(
roomCode,
envelope("custom-modifier.removed", {
roomCode,
descriptorId,
}),
);
}
/**
* Broadcast a game.end to everyone in `code` EXCEPT the player whose
* token is `leaverToken`. Used when a player disconnects or leaves

View file

@ -692,6 +692,38 @@ export type CustomModifierRegisteredPayload = z.infer<
typeof CustomModifierRegisteredPayloadSchema
>;
/**
* T71 client server: detach a previously-registered custom
* modifier descriptor. Server fires `on-rule-expire` hooks on the
* authoritative engine, retracts the descriptor's hook-list entries,
* removes it from the room's custom registry, and broadcasts
* `custom-modifier.removed` so peer clients mirror the removal.
*
* Host-only (mirrors register's host-only gate). Unknown descriptor
* ids are tolerated (idempotent on already-detached) the server
* still broadcasts so clients can mirror their own removal.
*/
export const CustomModifierRemovePayloadSchema = z.object({
roomCode: RoomCodeSchema,
descriptorId: z.string().min(1).max(128),
});
export type CustomModifierRemovePayload = z.infer<
typeof CustomModifierRemovePayloadSchema
>;
/**
* T71 server all clients in room: a custom modifier descriptor
* has been detached. Clients should call `engine.detachCustomDescriptor`
* locally so their session state mirrors the server's.
*/
export const CustomModifierRemovedPayloadSchema = z.object({
roomCode: RoomCodeSchema,
descriptorId: z.string().min(1).max(128),
});
export type CustomModifierRemovedPayload = z.infer<
typeof CustomModifierRemovedPayloadSchema
>;
export const RoomJoinPayloadSchema = z.object({
code: RoomCodeSchema,
});
@ -928,6 +960,10 @@ export const CustomModifierRegisterMessageSchema = msg(
"custom-modifier.register",
CustomModifierRegisterPayloadSchema,
);
export const CustomModifierRemoveMessageSchema = msg(
"custom-modifier.remove",
CustomModifierRemovePayloadSchema,
);
export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
@ -940,6 +976,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
ModifierProfileProposeMessageSchema,
ModifierProfileConsentMessageSchema,
CustomModifierRegisterMessageSchema,
CustomModifierRemoveMessageSchema,
]);
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
@ -982,6 +1019,10 @@ export const CustomModifierRegisteredMessageSchema = msg(
"custom-modifier.registered",
CustomModifierRegisteredPayloadSchema,
);
export const CustomModifierRemovedMessageSchema = msg(
"custom-modifier.removed",
CustomModifierRemovedPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -997,6 +1038,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
ModifierProfileRejectedMessageSchema,
ModifierProfileConsentReceivedMessageSchema,
CustomModifierRegisteredMessageSchema,
CustomModifierRemovedMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -1012,6 +1054,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
ModifierProfileProposeMessageSchema,
ModifierProfileConsentMessageSchema,
CustomModifierRegisterMessageSchema,
CustomModifierRemoveMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
@ -1024,6 +1067,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
ModifierProfileRejectedMessageSchema,
ModifierProfileConsentReceivedMessageSchema,
CustomModifierRegisteredMessageSchema,
CustomModifierRemovedMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -1039,6 +1083,7 @@ export const KNOWN_MESSAGE_TYPES = [
"modifier-profile.propose",
"modifier-profile.consent",
"custom-modifier.register",
"custom-modifier.remove",
"room.created",
"room.joined",
"game.state",
@ -1051,6 +1096,7 @@ export const KNOWN_MESSAGE_TYPES = [
"modifier-profile.rejected",
"modifier-profile.consent-received",
"custom-modifier.registered",
"custom-modifier.removed",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];

View file

@ -0,0 +1,253 @@
/**
* T71 integration tests: `custom-modifier.remove` WebSocket handler.
*
* Mirrors the register-side test harness. Covers:
* 1. Valid remove on-rule-expire fires + broadcast lands.
* 2. Host-only gate (BAD_TOKEN for non-host).
* 3. Idempotent on unknown id (still broadcasts).
* 4. Hook entries retracted from session.
*/
import type { ServerWebSocket } from "bun";
import { describe, expect, it } from "vitest";
import { GAME_ENTITY, PRESET_STATE_ENTITY } from "@paratype/chess";
import {
handleMessage,
registerConnection,
roomRegistry,
sessionRegistry,
type ClientData,
} from "./broadcast.js";
import {
PROTOCOL_VERSION,
type ClientMessage,
type CustomModifierDescriptorWire,
} from "./protocol.js";
interface MockWs extends ServerWebSocket<ClientData> {
readonly sent: unknown[];
readonly closed: boolean;
}
function makeMockWs(clientId: string): MockWs {
const sent: unknown[] = [];
const closedFlag = { value: false };
const ws = {
data: { clientId } as ClientData,
sent,
get closed(): boolean {
return closedFlag.value;
},
send(msg: string | Buffer): number {
const str = typeof msg === "string" ? msg : msg.toString("utf8");
sent.push(JSON.parse(str));
return str.length;
},
close(): void {
closedFlag.value = true;
},
} as unknown as MockWs;
return ws;
}
function nextMsgOfType(
ws: MockWs,
type: string,
): { payload: Record<string, unknown> } {
const idx = ws.sent.findIndex(
(m) =>
typeof m === "object" &&
m !== null &&
(m as { type: unknown }).type === type,
);
if (idx < 0) {
const types = ws.sent.map((m) => (m as { type?: string }).type);
throw new Error(
`no message of type "${type}" in inbox (got ${JSON.stringify(types)})`,
);
}
const msg = ws.sent[idx] as { payload: Record<string, unknown> };
ws.sent.splice(idx, 1);
return msg;
}
function sendClient(
ws: MockWs,
type: ClientMessage["type"],
payload: unknown,
seq = 1,
): void {
handleMessage(
ws,
JSON.stringify({
v: PROTOCOL_VERSION,
seq,
ts: Date.now(),
type,
payload,
}),
);
}
function makeDescriptor(
id: string,
overrides: Partial<CustomModifierDescriptorWire> = {},
): CustomModifierDescriptorWire {
return {
type: "data",
id,
name: id,
description: "",
version: 1,
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
],
targetAttrs: ["HpBonus"],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
function setupRoom(): { white: MockWs; black: MockWs; code: string } {
const white = makeMockWs(`T71-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T71-B-${crypto.randomUUID()}`);
registerConnection(white);
registerConnection(black);
sendClient(white, "room.create", {});
const created = nextMsgOfType(white, "room.created");
const code = created.payload["code"] as string;
sendClient(black, "room.join", { code });
nextMsgOfType(black, "room.joined");
// Drain any game.state broadcasts so subsequent assertions only see
// custom-modifier messages.
for (const ws of [white, black] as MockWs[]) {
while (ws.sent.some((m) => (m as { type?: string }).type === "game.state")) {
nextMsgOfType(ws, "game.state");
}
}
return { white, black, code };
}
describe("custom-modifier.remove WS handler (T71)", () => {
it("removes the descriptor and broadcasts custom-modifier.removed", () => {
const { white, black, code } = setupRoom();
const desc = makeDescriptor("custom:t71-remove");
// First register, then remove.
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: desc,
});
nextMsgOfType(white, "custom-modifier.registered");
nextMsgOfType(black, "custom-modifier.registered");
sendClient(white, "custom-modifier.remove", {
roomCode: code,
descriptorId: "custom:t71-remove",
});
const wMsg = nextMsgOfType(white, "custom-modifier.removed");
expect(wMsg.payload["roomCode"]).toBe(code);
expect(wMsg.payload["descriptorId"]).toBe("custom:t71-remove");
const bMsg = nextMsgOfType(black, "custom-modifier.removed");
expect(bMsg.payload["descriptorId"]).toBe("custom:t71-remove");
// Room state reflects removal — slot freed.
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.has("custom:t71-remove")).toBe(false);
});
it("fires on-rule-expire hooks on the authoritative engine", () => {
const { white, code } = setupRoom();
// Seed an on-rule-expire hook directly on the authoritative
// session (simulating a descriptor's apply-time seeding).
const session = sessionRegistry.get(code);
if (!session) throw new Error("session not found");
const engine = session.getEngine();
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "custom:t71-fire",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
],
},
]);
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
sendClient(white, "custom-modifier.remove", {
roomCode: code,
descriptorId: "custom:t71-fire",
});
nextMsgOfType(white, "custom-modifier.removed");
// The expire hook fired during the server-side detach.
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(9);
// Hook list now empty (entry retracted).
expect(engine.session.get(GAME_ENTITY, "OnRuleExpireHooks")).toEqual([]);
// RuleExpireFiredFor records the fire (idempotency guard).
const fired = engine.session.get(
PRESET_STATE_ENTITY,
"RuleExpireFiredFor",
) as readonly string[] | undefined;
expect(fired ?? []).toContain("custom:t71-fire");
});
it("rejects non-host (opponent) removals with BAD_TOKEN", () => {
const { white, black, code } = setupRoom();
// White (host) registers.
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: makeDescriptor("custom:t71-host-only"),
});
nextMsgOfType(white, "custom-modifier.registered");
nextMsgOfType(black, "custom-modifier.registered");
// Black (non-host) tries to remove → rejected.
sendClient(black, "custom-modifier.remove", {
roomCode: code,
descriptorId: "custom:t71-host-only",
});
const err = nextMsgOfType(black, "error");
expect(err.payload["code"]).toBe("BAD_TOKEN");
expect(err.payload["message"]).toMatch(/host/i);
// Descriptor still present.
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.has("custom:t71-host-only")).toBe(true);
});
it("is idempotent on an unknown / already-removed descriptor id", () => {
const { white, code } = setupRoom();
// Remove an id that was never registered. Still broadcasts.
sendClient(white, "custom-modifier.remove", {
roomCode: code,
descriptorId: "custom:t71-never-existed",
});
const msg = nextMsgOfType(white, "custom-modifier.removed");
expect(msg.payload["descriptorId"]).toBe("custom:t71-never-existed");
});
it("rejects roomCode mismatch with BAD_TOKEN", () => {
const { white, code } = setupRoom();
sendClient(white, "custom-modifier.remove", {
roomCode: "ZZZZZZ",
descriptorId: "custom:t71-anything",
});
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("BAD_TOKEN");
const room = roomRegistry.getRoom(code);
expect(room).toBeDefined();
});
});

View file

@ -11,8 +11,13 @@
// wire-level contract independently of T47's trigger plumbing.
import type { ServerWebSocket } from "bun";
import { describe, it, expect } from "vitest";
import { pushPendingChoice, type PendingChoice } from "@paratype/chess";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import {
GAME_ENTITY,
parseCustomModifierDescriptor,
pushPendingChoice,
type PendingChoice,
} from "@paratype/chess";
import {
broadcastTopChoiceIfNew,
@ -22,6 +27,7 @@ import {
unregisterConnection,
type ClientData,
} from "./broadcast.js";
import { choiceTimeoutManager } from "./choice-timeout.js";
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
// ---------------------------------------------------------------------------
@ -529,3 +535,191 @@ describe("T44 — submit-choice validation", () => {
unregisterConnection(black);
});
});
// ---------------------------------------------------------------------------
// T70 — submit-choice / timeout-default trigger continuation resume
// ---------------------------------------------------------------------------
/**
* Build + register a real custom-modifier descriptor on a session's
* engine. The descriptor's `primitives` is a single `request-choice`
* whose `params.then` writes a sentinel value into `HpBonus` on
* `GAME_ENTITY`. The continuation primitive uses `{ $var: <bind> }`
* so the resumed scope's binding of the player's value is observable
* via a single `engine.session.get(GAME_ENTITY, "HpBonus")` call.
*
* Mirrors the fixture pattern from
* `pending-choices-resume.test.ts` (the unit-level companion to T46)
* but goes through the wire schema's `parseCustomModifierDescriptor`
* boundary so the descriptor's id is properly branded for
* `engine.customModifiers.register`.
*/
function registerResumeDescriptor(
code: string,
descriptorId: string,
bindName: string,
): void {
const session = sessionRegistry.get(code);
if (!session) throw new Error(`no session for ${code}`);
const raw = {
type: "data",
id: descriptorId,
name: descriptorId,
description: "",
version: 1,
primitives: [
{
kind: "request-choice",
params: {
kind: "rps",
prompt: "?",
forPlayer: "both",
bind: bindName,
then: [
{
kind: "seed-attribute",
params: {
attr: "HpBonus",
// The submitted value is bound under `bindName` and
// forwarded into HpBonus, so the test reads it back
// and asserts on the engine state.
value: { $var: bindName },
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const desc = parseCustomModifierDescriptor(raw);
session.getEngine().customModifiers.register(desc);
}
describe("T70 — submit-choice triggers continuation resume", () => {
it("a real submit-choice runs the request-choice's params.then continuation", () => {
const { white, black, code } = setupRoom();
const session = sessionRegistry.get(code)!;
registerResumeDescriptor(code, "t70-submit-resume", "winner");
pushPendingChoice(
session.getEngine(),
buildPendingChoice({
choiceId: "t70-submit",
descriptorId: "t70-submit-resume",
kind: "rps",
forPlayer: "both",
triggerPath: [],
primitiveIndex: 0,
}),
);
broadcastTopChoiceIfNew(code, session);
nextMsgOfType(white, "request-choice");
nextMsgOfType(black, "request-choice");
// Pre-condition: continuation has not run.
expect(
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
).toBeUndefined();
// Player submits "rock" — continuation should bind it under
// `winner` and the seed-attribute primitive writes it to HpBonus.
sendV2(white, {
kind: "submit-choice",
protocolVersion: 2,
choiceId: "t70-submit",
value: "rock",
});
// No error frame.
expect(findMsgOfType(white, "error")).toBeUndefined();
// Engine state advanced beyond the trivial pop: HpBonus carries
// the value bound from the player's submission. This is the
// assertion that proves T70's wiring fix works — the value is
// no longer dropped on the floor at the WS boundary.
expect(
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
).toBe("rock");
// Stack drained — the resume popped the frame.
expect(
session.getEngine().session.get(GAME_ENTITY, "PendingChoices"),
).toEqual([]);
// Post-resume game.state broadcast — clients receive the new
// authoritative snapshot so they can mirror the continuation's
// side-effects without waiting for the next move.
nextMsgOfType(white, "game.state");
nextMsgOfType(black, "game.state");
unregisterConnection(white);
unregisterConnection(black);
});
});
describe("T70 — timeout default ALSO triggers continuation resume", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-default after timeout runs the continuation with firstDefaultForKind", () => {
const { white, black, code } = setupRoom();
const session = sessionRegistry.get(code)!;
// Override the policy fact for a tighter deadline so the test
// doesn't have to advance fake timers by 60s.
session
.getEngine()
.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
mode: "timeout-with-default",
seconds: 1,
});
registerResumeDescriptor(code, "t70-timeout-resume", "autoPick");
pushPendingChoice(
session.getEngine(),
buildPendingChoice({
choiceId: "t70-timeout",
descriptorId: "t70-timeout-resume",
kind: "rps",
forPlayer: "both",
triggerPath: [],
primitiveIndex: 0,
}),
);
broadcastTopChoiceIfNew(code, session);
nextMsgOfType(white, "request-choice");
nextMsgOfType(black, "request-choice");
expect(choiceTimeoutManager.isArmed(code, "t70-timeout")).toBe(true);
// Trip the timer. firstDefaultForKind("rps") returns "rock"
// (locked by T49). The timeout path threads that into
// submitChoiceAndResume, which binds it under `autoPick` and
// the continuation writes it to HpBonus.
vi.advanceTimersByTime(2_000);
expect(
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
).toBe("rock");
expect(
session.getEngine().session.get(GAME_ENTITY, "PendingChoices"),
).toEqual([]);
expect(choiceTimeoutManager.isArmed(code, "t70-timeout")).toBe(false);
// Timeout-driven game.state broadcast reaches both clients.
nextMsgOfType(white, "game.state");
nextMsgOfType(black, "game.state");
unregisterConnection(white);
unregisterConnection(black);
});
});