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:
parent
88581ff6a9
commit
48a15a6d57
21 changed files with 2766 additions and 47 deletions
|
|
@ -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"
|
||||
|
|
|
|||
461
packages/chess/src/__fixtures__/parity/parry-real.test.ts
Normal file
461
packages/chess/src/__fixtures__/parity/parry-real.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
176
packages/chess/src/engine.detach.test.ts
Normal file
176
packages/chess/src/engine.detach.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
314
packages/chess/src/hooks/useMultiplayerGame.test.ts
Normal file
314
packages/chess/src/hooks/useMultiplayerGame.test.ts
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export {
|
|||
} from "./coord.js";
|
||||
export {
|
||||
GAME_ENTITY,
|
||||
PRESET_STATE_ENTITY,
|
||||
PROMOTION_PIECES,
|
||||
CaptureFlag,
|
||||
DEFAULT_CHOICE_TIMEOUT_POLICY,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
*
|
||||
|
|
|
|||
316
packages/chess/src/ui/GameView.test.tsx
Normal file
316
packages/chess/src/ui/GameView.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
253
packages/server/src/ws.custom-modifier-remove.test.ts
Normal file
253
packages/server/src/ws.custom-modifier-remove.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue