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_234a71fb6ffexvyikAILJb5T5V",
|
||||||
"ses_234a6dabaffeuQB1zQgChArKtq",
|
"ses_234a6dabaffeuQB1zQgChArKtq",
|
||||||
"ses_234a740d3ffe9EIQvGhSVIprrg",
|
"ses_234a740d3ffe9EIQvGhSVIprrg",
|
||||||
"ses_2349f31ffffesMHCS2O8YUYzaB"
|
"ses_2349f31ffffesMHCS2O8YUYzaB",
|
||||||
|
"ses_23488f9dcffeFtCzBn3gxtN7KV",
|
||||||
|
"ses_23487dd88ffeEDMNwvBDLByD9N",
|
||||||
|
"ses_23488a4a2ffeG7nzsoF7nsvKHC",
|
||||||
|
"ses_234882ba1ffeMwDf2VAt2fGKaM"
|
||||||
],
|
],
|
||||||
"plan_name": "thressgame-coverage",
|
"plan_name": "thressgame-coverage",
|
||||||
"agent": "atlas"
|
"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,
|
recordPosition,
|
||||||
isThreefoldRepetition,
|
isThreefoldRepetition,
|
||||||
} from "./rules/draws.js";
|
} from "./rules/draws.js";
|
||||||
import { CORE_PIECE_ATTRS } from "./rules/capture.js";
|
import { CORE_PIECE_ATTRS, isPieceAttr } from "./rules/capture.js";
|
||||||
import type {
|
import type {
|
||||||
BeforeMoveContext,
|
BeforeMoveContext,
|
||||||
CaptureHookContext,
|
CaptureHookContext,
|
||||||
|
|
@ -66,7 +66,7 @@ import type {
|
||||||
TurnAdvanceContext,
|
TurnAdvanceContext,
|
||||||
TurnStartContext,
|
TurnStartContext,
|
||||||
} from "./presets/registry.js";
|
} 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 { LegalMove } from "./rules/types.js";
|
||||||
import type { ActionResult, PlayerAction } from "./actions.js";
|
import type { ActionResult, PlayerAction } from "./actions.js";
|
||||||
import type { PlayerActionContext } from "./presets/registry.js";
|
import type { PlayerActionContext } from "./presets/registry.js";
|
||||||
|
|
@ -90,6 +90,7 @@ import {
|
||||||
import type { ModifierProfile } from "./modifiers/types.js";
|
import type { ModifierProfile } from "./modifiers/types.js";
|
||||||
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
||||||
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
|
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
|
||||||
|
import { fireOnRuleExpireHooks } from "./modifiers/triggers.js";
|
||||||
import {
|
import {
|
||||||
assertSeedConsumerIntegrity,
|
assertSeedConsumerIntegrity,
|
||||||
getEngineSeedManifest,
|
getEngineSeedManifest,
|
||||||
|
|
@ -763,6 +764,115 @@ export class ChessEngine {
|
||||||
this.activeProfile = profile;
|
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 {
|
setActivePresets(requests: readonly ActivationRequest[]): void {
|
||||||
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
||||||
this.activePresets.replaceAll(requests);
|
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`,
|
* Return marker entity ids currently positioned at `square`,
|
||||||
* sorted by {@link MARKER_KIND_PRIORITY} ascending (T10).
|
* sorted by {@link MARKER_KIND_PRIORITY} ascending (T10).
|
||||||
|
|
@ -1504,6 +1663,17 @@ export class ChessEngine {
|
||||||
if (capturedId !== null) {
|
if (capturedId !== null) {
|
||||||
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||||
if (!consumed) {
|
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, {
|
const { died } = this.dealDamage(capturedId, 1, {
|
||||||
kind: "capture",
|
kind: "capture",
|
||||||
attacker: move.pieceId,
|
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 type { GameResult } from '../engine';
|
||||||
import { GameClient } from '../net/client';
|
import { GameClient } from '../net/client';
|
||||||
import { PredictionManager } from '../net/prediction';
|
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 * as audio from '../audio';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
|
@ -58,6 +58,13 @@ export interface MultiplayerGameState {
|
||||||
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||||
modifierRejectionMessage: string | null;
|
modifierRejectionMessage: string | null;
|
||||||
isProposer: boolean;
|
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,
|
modifierProposal: null,
|
||||||
modifierRejectionMessage: null,
|
modifierRejectionMessage: null,
|
||||||
isProposer: false,
|
isProposer: false,
|
||||||
|
pendingChoiceStack: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount the connection exactly once per code/token pair.
|
// Mount the connection exactly once per code/token pair.
|
||||||
|
|
@ -157,6 +165,20 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
audio.play('move');
|
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 } }) => {
|
const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => {
|
||||||
// Fatal errors tear down the session entirely and the server
|
// Fatal errors tear down the session entirely and the server
|
||||||
// closes the socket. Non-fatal server errors (ILLEGAL_MOVE,
|
// 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.rejected', onProposalRejected);
|
||||||
client.on('modifier-profile.consent-received', onProposalConsentReceived);
|
client.on('modifier-profile.consent-received', onProposalConsentReceived);
|
||||||
client.on('modifier-profile.queued', onProposalQueued);
|
client.on('modifier-profile.queued', onProposalQueued);
|
||||||
|
client.on('request-choice', onRequestChoice);
|
||||||
client.on('error', onError);
|
client.on('error', onError);
|
||||||
|
|
||||||
client.connect(code, token).catch((err: unknown) => {
|
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.rejected', onProposalRejected);
|
||||||
client.off('modifier-profile.consent-received', onProposalConsentReceived);
|
client.off('modifier-profile.consent-received', onProposalConsentReceived);
|
||||||
client.off('modifier-profile.queued', onProposalQueued);
|
client.off('modifier-profile.queued', onProposalQueued);
|
||||||
|
client.off('request-choice', onRequestChoice);
|
||||||
client.off('error', onError);
|
client.off('error', onError);
|
||||||
client.close();
|
client.close();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
|
@ -297,6 +321,25 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
clientRef.current?.sendAction(action);
|
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(() => {
|
const loadEngine = useCallback(() => {
|
||||||
// Also a no-op: authoritative state is server-driven. The UI should
|
// Also a no-op: authoritative state is server-driven. The UI should
|
||||||
// not have any need to swap the engine under multiplayer — all state
|
// 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,
|
modifierProposal: meta.modifierProposal,
|
||||||
modifierRejectionMessage: meta.modifierRejectionMessage,
|
modifierRejectionMessage: meta.modifierRejectionMessage,
|
||||||
isProposer: meta.isProposer,
|
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') => {
|
sendConsent: (decision: 'approve' | 'reject') => {
|
||||||
clientRef.current?.send({
|
clientRef.current?.send({
|
||||||
type: 'modifier-profile.consent',
|
type: 'modifier-profile.consent',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export {
|
||||||
} from "./coord.js";
|
} from "./coord.js";
|
||||||
export {
|
export {
|
||||||
GAME_ENTITY,
|
GAME_ENTITY,
|
||||||
|
PRESET_STATE_ENTITY,
|
||||||
PROMOTION_PIECES,
|
PROMOTION_PIECES,
|
||||||
CaptureFlag,
|
CaptureFlag,
|
||||||
DEFAULT_CHOICE_TIMEOUT_POLICY,
|
DEFAULT_CHOICE_TIMEOUT_POLICY,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
* to activate it manually.
|
* to activate it manually.
|
||||||
*/
|
*/
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
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 { CaptureFlag, GAME_ENTITY } from "../schema.js";
|
||||||
import { algebraicToSquare } from "../coord.js";
|
import { algebraicToSquare } from "../coord.js";
|
||||||
import type { StartingLayout } from "../layouts/types.js";
|
import type { StartingLayout } from "../layouts/types.js";
|
||||||
|
|
@ -197,6 +197,13 @@ registerAttrConsumer("OnMarkerExpireHooks");
|
||||||
// existing capture pipeline retracts the defender BEFORE the
|
// existing capture pipeline retracts the defender BEFORE the
|
||||||
// on-captured trigger fires (see stage-4 NOTE in onAfterMove).
|
// on-captured trigger fires (see stage-4 NOTE in onAfterMove).
|
||||||
registerAttrConsumer("CaptureCancelled");
|
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.
|
// T35 — turn-bounded attr lifetime registry, stored on GAME_ENTITY.
|
||||||
// Seeded by `set-piece-attr` (T26) when the caller passes
|
// Seeded by `set-piece-attr` (T26) when the caller passes
|
||||||
// `lifetime: { kind: "turns", count: N }`; consumed by
|
// `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>();
|
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
|
* Pre-move check-state snapshot. For each color, maps every royal's
|
||||||
* EntityId to the list of enemy attacker EntityIds threatening it
|
* 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.
|
// the move was not a capture, both are null and we skip.
|
||||||
const defender = PRE_MOVE_CAPTURED_DEFENDERS.get(ctx.engine) ?? null;
|
const defender = PRE_MOVE_CAPTURED_DEFENDERS.get(ctx.engine) ?? null;
|
||||||
if (defender !== null && attacker !== 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);
|
fireOnCapturedHooks(ctx.engine, defender, attacker);
|
||||||
// 4b. T27 — `cancel-capture` inhibitor poll. The
|
const pendingDepthAfter = (
|
||||||
// `cancel-capture` primitive (legal only inside an
|
(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
|
// on-captured arm) writes CaptureCancelled = true on
|
||||||
// GAME_ENTITY. We poll + retract the flag here so it never
|
// GAME_ENTITY and ALSO re-inserts defender facts from the
|
||||||
// leaks across moves or cascade boundaries. V1 wire-in
|
// T72 LastCaptureSnapshot. This stage-4b dispatcher
|
||||||
// scope: the flag is observed and reset; full restoration
|
// additionally rolls back the ATTACKER side (Position +
|
||||||
// of defender facts is DEFERRED to T28+ because the engine
|
// HasMoved) — the attacker advance happened before the on-
|
||||||
// capture path already retracted the defender BEFORE this
|
// captured trigger fired, so the cancel-capture primitive
|
||||||
// dispatcher fired (see stage-4 NOTE above). Sibling
|
// can't undo it without taking over the dispatcher's
|
||||||
// primitives + the future capture-pipeline refactor
|
// ownership of move-level state. Splitting the work this
|
||||||
// consume the flag as the inhibitor signal.
|
// way keeps the primitive defender-scoped and the
|
||||||
if (ctx.engine.session.get(GAME_ENTITY, "CaptureCancelled") === true) {
|
// 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");
|
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 { z } from "zod";
|
||||||
import { GAME_ENTITY } from "../../schema.js";
|
import { GAME_ENTITY } from "../../schema.js";
|
||||||
|
import type { ChessAttrKey, LastCaptureSnapshotValue } from "../../schema.js";
|
||||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -80,9 +81,9 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
kind: "cancel-capture",
|
kind: "cancel-capture",
|
||||||
label: "Cancel Capture",
|
label: "Cancel Capture",
|
||||||
description:
|
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:
|
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: [
|
examples: [
|
||||||
{
|
{
|
||||||
title: "Parry — cancel a capture when defender wins RPS",
|
title: "Parry — cancel a capture when defender wins RPS",
|
||||||
|
|
@ -114,6 +115,37 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ctx.session.insert(GAME_ENTITY, "CaptureCancelled", true);
|
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,
|
fireOnMovedOntoSquareHooks,
|
||||||
fireOnPromotionHooks,
|
fireOnPromotionHooks,
|
||||||
fireOnRuleActivatedHooks,
|
fireOnRuleActivatedHooks,
|
||||||
|
fireOnRuleExpireHooks,
|
||||||
fireOnTurnEndHooks,
|
fireOnTurnEndHooks,
|
||||||
runPrimitives,
|
runPrimitives,
|
||||||
type PreMoveCheckStateLike,
|
type PreMoveCheckStateLike,
|
||||||
|
|
@ -1129,3 +1130,76 @@ describe("fireOnRuleActivatedHooks (T16)", () => {
|
||||||
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
|
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 {
|
import type {
|
||||||
GameDeltaPayload,
|
GameDeltaPayload,
|
||||||
GameStatePayload,
|
GameStatePayload,
|
||||||
|
RequestChoicePayload,
|
||||||
RoomJoinedPayload,
|
RoomJoinedPayload,
|
||||||
} from "./types.js";
|
} 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", () => {
|
describe("GameClient — listener management", () => {
|
||||||
it("off() removes a listener", async () => {
|
it("off() removes a listener", async () => {
|
||||||
const { client } = makeClient();
|
const { client } = makeClient();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
PlayerActionWire,
|
PlayerActionWire,
|
||||||
PresetActivation,
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
|
RequestChoicePayload,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
RoomJoinedPayload,
|
RoomJoinedPayload,
|
||||||
RoomSetPresetsPayload,
|
RoomSetPresetsPayload,
|
||||||
|
|
@ -50,6 +51,7 @@ export type GameClientEvent =
|
||||||
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
||||||
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
|
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
|
||||||
| { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload }
|
| { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload }
|
||||||
|
| { type: "request-choice"; payload: RequestChoicePayload }
|
||||||
| { type: "error"; payload: ErrorPayload }
|
| { type: "error"; payload: ErrorPayload }
|
||||||
| { type: "connected" }
|
| { type: "connected" }
|
||||||
| { type: "disconnected"; willReconnect: boolean };
|
| { type: "disconnected"; willReconnect: boolean };
|
||||||
|
|
@ -323,6 +325,29 @@ export class GameClient {
|
||||||
this.send({ type: "custom-modifier.register", payload });
|
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)
|
// Accessors (primarily for tests & reconnect logic)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -439,6 +464,16 @@ export class GameClient {
|
||||||
return; // Malformed frames are silently ignored at this layer.
|
return; // Malformed frames are silently ignored at this layer.
|
||||||
}
|
}
|
||||||
if (typeof parsed !== "object" || parsed === null) return;
|
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;
|
const msg = parsed as IncomingEnvelope;
|
||||||
|
|
||||||
if (typeof msg.seq === "number" && msg.seq > this.lastSeq) {
|
if (typeof msg.seq === "number" && msg.seq > this.lastSeq) {
|
||||||
|
|
@ -454,6 +489,27 @@ export class GameClient {
|
||||||
this.dispatchServerMessage(msg.type, msg.payload);
|
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 {
|
private captureAuth(msg: IncomingEnvelope): void {
|
||||||
if (msg.type !== "room.created" && msg.type !== "room.joined") return;
|
if (msg.type !== "room.created" && msg.type !== "room.joined") return;
|
||||||
const p = msg.payload;
|
const p = msg.payload;
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,43 @@ export interface ErrorPayload {
|
||||||
fatal: boolean;
|
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())
|
// Client → Server payloads (shape for send())
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
* - check filtering → P2.18
|
* - check filtering → P2.18
|
||||||
*/
|
*/
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
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 { GAME_ENTITY } from "../schema.js";
|
||||||
import type { LegalMove } from "./types.js";
|
import type { LegalMove } from "./types.js";
|
||||||
import { getPieceColor, getPieceAt } from "./board-queries.js";
|
import { getPieceColor, getPieceAt } from "./board-queries.js";
|
||||||
|
|
@ -143,6 +143,31 @@ export function applyMove(session: Session, move: LegalMove): void {
|
||||||
if (move.isCapture) {
|
if (move.isCapture) {
|
||||||
const capturedId = getPieceAt(session, move.to);
|
const capturedId = getPieceAt(session, move.to);
|
||||||
if (capturedId !== null && capturedId !== move.pieceId) {
|
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
|
// Enumerate the captured piece's attrs from the session rather
|
||||||
// than from a hardcoded list — preset-declared attrs (Hp,
|
// than from a hardcoded list — preset-declared attrs (Hp,
|
||||||
// Shield, …) get cleaned up too without this function needing
|
// 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
|
* (apply.ts stage 4b) so the flag never leaks across moves or
|
||||||
* cascade boundaries.
|
* cascade boundaries.
|
||||||
*
|
*
|
||||||
* V1 wire-in scope: the flag is set + retracted; full restoration
|
* T72: full restoration of defender facts AND attacker rollback is
|
||||||
* of defender facts is DEFERRED to T28+ because the existing
|
* now wired. The capture pipeline writes a {@link LastCaptureSnapshot}
|
||||||
* capture pipeline retracts the defender BEFORE the on-captured
|
* BEFORE retracting defender facts / advancing the attacker; the
|
||||||
* trigger fires (see apply.ts stage-4 NOTE). Sibling primitives
|
* `cancel-capture` primitive re-inserts defender facts from that
|
||||||
* and the future capture-pipeline refactor consume this flag as
|
* snapshot, and the dispatcher (apply.ts stage 4b) restores the
|
||||||
* the inhibitor signal.
|
* attacker's pre-move Position / HasMoved when the flag is set.
|
||||||
*/
|
*/
|
||||||
CaptureCancelled: boolean;
|
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
|
* T35 — turn-bounded attribute lifetime registry. Stored on
|
||||||
* `GAME_ENTITY` (one list per game). Each entry records that some
|
* `GAME_ENTITY` (one list per game). Each entry records that some
|
||||||
|
|
@ -591,6 +603,47 @@ export interface PendingChoice {
|
||||||
readonly expiresAtTimestamp?: number;
|
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}.
|
* 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 { ModifierTooltip } from './ModifierTooltip.js';
|
||||||
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
||||||
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
||||||
|
import { RequestChoiceModal } from './RequestChoiceModal.js';
|
||||||
import { ActionMenu } from './ActionMenu.js';
|
import { ActionMenu } from './ActionMenu.js';
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||||
|
|
@ -121,6 +122,18 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GameLayout state={state} myColor={state.myColor} roomCode={code} />
|
<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,
|
shouldSkipV2Broadcast,
|
||||||
validateAnyMessageString,
|
validateAnyMessageString,
|
||||||
type CustomModifierRegisterPayload,
|
type CustomModifierRegisterPayload,
|
||||||
|
type CustomModifierRemovePayload,
|
||||||
type ErrorCode,
|
type ErrorCode,
|
||||||
type Fact as WireFact,
|
type Fact as WireFact,
|
||||||
type GameActionPayload,
|
type GameActionPayload,
|
||||||
|
|
@ -63,7 +64,7 @@ import {
|
||||||
} from "./choice-timeout.js";
|
} from "./choice-timeout.js";
|
||||||
import {
|
import {
|
||||||
peekPendingChoice,
|
peekPendingChoice,
|
||||||
popPendingChoice,
|
submitChoiceAndResume,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
type ModifierProfile,
|
type ModifierProfile,
|
||||||
|
|
@ -454,15 +455,37 @@ function armChoiceTimeoutFor(
|
||||||
|
|
||||||
// Auto-resolve with the first option for this kind. The value
|
// Auto-resolve with the first option for this kind. The value
|
||||||
// bypasses the wire-side `isValidChoiceValue` check (it never
|
// 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.
|
// own kind-specific legality gate.
|
||||||
const defaultValue = firstDefaultForKind(kind);
|
const defaultValue = firstDefaultForKind(kind);
|
||||||
void defaultValue; // T46: thread into engine resume context.
|
|
||||||
|
|
||||||
const popped = popPendingChoice(liveSession.getEngine());
|
// T70 — thread the default value through the real resume
|
||||||
if (popped !== undefined) {
|
// mechanism so any `params.then` continuation runs. The helper
|
||||||
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
|
// 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
|
logger
|
||||||
.child({ roomCode })
|
.child({ roomCode })
|
||||||
.info(
|
.info(
|
||||||
|
|
@ -680,11 +703,12 @@ function handleV2Frame(
|
||||||
* (see `isValidChoiceValue`). Failures emit a `protocol.invalid-
|
* (see `isValidChoiceValue`). Failures emit a `protocol.invalid-
|
||||||
* choice-value` message.
|
* choice-value` message.
|
||||||
*
|
*
|
||||||
* On full success: the frame is popped (`popPendingChoice`),
|
* On full success: the frame is resumed via `submitChoiceAndResume`
|
||||||
* bookkeeping for the broadcasted-set is cleared, and the popped
|
* (which pops the frame and runs the suspended `params.then`
|
||||||
* value is currently DROPPED — T46 will replace this with the real
|
* continuation with the player's value bound under the request-
|
||||||
* resume mechanism (`submitChoiceAndResume`). The pop itself is
|
* choice's `bind` name), broadcasted-set bookkeeping is cleared,
|
||||||
* preserved so the LIFO contract holds even before T46 lands.
|
* and the resulting game state + any new top-of-stack choice are
|
||||||
|
* broadcast to the room.
|
||||||
*/
|
*/
|
||||||
function handleSubmitChoice(
|
function handleSubmitChoice(
|
||||||
ws: ServerWebSocket<ClientData>,
|
ws: ServerWebSocket<ClientData>,
|
||||||
|
|
@ -783,20 +807,42 @@ function handleSubmitChoice(
|
||||||
// fired after submit" log line in the latency window.
|
// fired after submit" log line in the latency window.
|
||||||
choiceTimeoutManager.cancel(roomCode, frame.choiceId);
|
choiceTimeoutManager.cancel(roomCode, frame.choiceId);
|
||||||
|
|
||||||
// Pop the top frame and drop the bookkeeping entry. T46 will replace
|
// T70 — thread the player's value through the real resume
|
||||||
// the bare pop with the real resume mechanism (which threads the
|
// mechanism. `submitChoiceAndResume` pops the frame and runs the
|
||||||
// value back into the engine's resume context); for now we satisfy
|
// suspended `params.then` continuation. The pop is unconditional
|
||||||
// the LIFO contract and clear our broadcast tracking so the next
|
// even on descriptor-not-found (see pending-choices.ts header),
|
||||||
// pending choice on this socket can be re-broadcast cleanly.
|
// so we always clear the broadcasted-id bookkeeping. A throw
|
||||||
const popped = popPendingChoice(session.getEngine());
|
// from a continuation primitive must not propagate to the WS
|
||||||
if (popped !== undefined) {
|
// dispatcher — log and continue so the room stays live.
|
||||||
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
|
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
|
logger
|
||||||
.child({ clientId: ws.data.clientId, roomCode })
|
.child({ clientId: ws.data.clientId, roomCode })
|
||||||
.info(
|
.info(
|
||||||
{ choiceId: frame.choiceId, kind: top.kind },
|
{ 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":
|
case "custom-modifier.register":
|
||||||
handleCustomModifierRegister(ws, msg.payload);
|
handleCustomModifierRegister(ws, msg.payload);
|
||||||
break;
|
break;
|
||||||
|
case "custom-modifier.remove":
|
||||||
|
handleCustomModifierRemove(ws, msg.payload);
|
||||||
|
break;
|
||||||
case "room.created":
|
case "room.created":
|
||||||
case "room.joined":
|
case "room.joined":
|
||||||
case "game.state":
|
case "game.state":
|
||||||
|
|
@ -895,6 +944,7 @@ export function handleMessage(
|
||||||
case "modifier-profile.rejected":
|
case "modifier-profile.rejected":
|
||||||
case "modifier-profile.consent-received":
|
case "modifier-profile.consent-received":
|
||||||
case "custom-modifier.registered":
|
case "custom-modifier.registered":
|
||||||
|
case "custom-modifier.removed":
|
||||||
case "error":
|
case "error":
|
||||||
sendTo(
|
sendTo(
|
||||||
ws,
|
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
|
* Broadcast a game.end to everyone in `code` EXCEPT the player whose
|
||||||
* token is `leaverToken`. Used when a player disconnects or leaves
|
* token is `leaverToken`. Used when a player disconnects or leaves
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,38 @@ export type CustomModifierRegisteredPayload = z.infer<
|
||||||
typeof CustomModifierRegisteredPayloadSchema
|
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({
|
export const RoomJoinPayloadSchema = z.object({
|
||||||
code: RoomCodeSchema,
|
code: RoomCodeSchema,
|
||||||
});
|
});
|
||||||
|
|
@ -928,6 +960,10 @@ export const CustomModifierRegisterMessageSchema = msg(
|
||||||
"custom-modifier.register",
|
"custom-modifier.register",
|
||||||
CustomModifierRegisterPayloadSchema,
|
CustomModifierRegisterPayloadSchema,
|
||||||
);
|
);
|
||||||
|
export const CustomModifierRemoveMessageSchema = msg(
|
||||||
|
"custom-modifier.remove",
|
||||||
|
CustomModifierRemovePayloadSchema,
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomCreateMessageSchema,
|
RoomCreateMessageSchema,
|
||||||
|
|
@ -940,6 +976,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
ModifierProfileProposeMessageSchema,
|
ModifierProfileProposeMessageSchema,
|
||||||
ModifierProfileConsentMessageSchema,
|
ModifierProfileConsentMessageSchema,
|
||||||
CustomModifierRegisterMessageSchema,
|
CustomModifierRegisterMessageSchema,
|
||||||
|
CustomModifierRemoveMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||||
|
|
||||||
|
|
@ -982,6 +1019,10 @@ export const CustomModifierRegisteredMessageSchema = msg(
|
||||||
"custom-modifier.registered",
|
"custom-modifier.registered",
|
||||||
CustomModifierRegisteredPayloadSchema,
|
CustomModifierRegisteredPayloadSchema,
|
||||||
);
|
);
|
||||||
|
export const CustomModifierRemovedMessageSchema = msg(
|
||||||
|
"custom-modifier.removed",
|
||||||
|
CustomModifierRemovedPayloadSchema,
|
||||||
|
);
|
||||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||||
|
|
||||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
|
@ -997,6 +1038,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
ModifierProfileRejectedMessageSchema,
|
ModifierProfileRejectedMessageSchema,
|
||||||
ModifierProfileConsentReceivedMessageSchema,
|
ModifierProfileConsentReceivedMessageSchema,
|
||||||
CustomModifierRegisteredMessageSchema,
|
CustomModifierRegisteredMessageSchema,
|
||||||
|
CustomModifierRemovedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||||
|
|
@ -1012,6 +1054,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
ModifierProfileProposeMessageSchema,
|
ModifierProfileProposeMessageSchema,
|
||||||
ModifierProfileConsentMessageSchema,
|
ModifierProfileConsentMessageSchema,
|
||||||
CustomModifierRegisterMessageSchema,
|
CustomModifierRegisterMessageSchema,
|
||||||
|
CustomModifierRemoveMessageSchema,
|
||||||
RoomCreatedMessageSchema,
|
RoomCreatedMessageSchema,
|
||||||
RoomJoinedMessageSchema,
|
RoomJoinedMessageSchema,
|
||||||
GameStateMessageSchema,
|
GameStateMessageSchema,
|
||||||
|
|
@ -1024,6 +1067,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
ModifierProfileRejectedMessageSchema,
|
ModifierProfileRejectedMessageSchema,
|
||||||
ModifierProfileConsentReceivedMessageSchema,
|
ModifierProfileConsentReceivedMessageSchema,
|
||||||
CustomModifierRegisteredMessageSchema,
|
CustomModifierRegisteredMessageSchema,
|
||||||
|
CustomModifierRemovedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||||
|
|
@ -1039,6 +1083,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"modifier-profile.propose",
|
"modifier-profile.propose",
|
||||||
"modifier-profile.consent",
|
"modifier-profile.consent",
|
||||||
"custom-modifier.register",
|
"custom-modifier.register",
|
||||||
|
"custom-modifier.remove",
|
||||||
"room.created",
|
"room.created",
|
||||||
"room.joined",
|
"room.joined",
|
||||||
"game.state",
|
"game.state",
|
||||||
|
|
@ -1051,6 +1096,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"modifier-profile.rejected",
|
"modifier-profile.rejected",
|
||||||
"modifier-profile.consent-received",
|
"modifier-profile.consent-received",
|
||||||
"custom-modifier.registered",
|
"custom-modifier.registered",
|
||||||
|
"custom-modifier.removed",
|
||||||
"error",
|
"error",
|
||||||
] as const;
|
] as const;
|
||||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
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.
|
// wire-level contract independently of T47's trigger plumbing.
|
||||||
import type { ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||||
import { pushPendingChoice, type PendingChoice } from "@paratype/chess";
|
import {
|
||||||
|
GAME_ENTITY,
|
||||||
|
parseCustomModifierDescriptor,
|
||||||
|
pushPendingChoice,
|
||||||
|
type PendingChoice,
|
||||||
|
} from "@paratype/chess";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
broadcastTopChoiceIfNew,
|
broadcastTopChoiceIfNew,
|
||||||
|
|
@ -22,6 +27,7 @@ import {
|
||||||
unregisterConnection,
|
unregisterConnection,
|
||||||
type ClientData,
|
type ClientData,
|
||||||
} from "./broadcast.js";
|
} from "./broadcast.js";
|
||||||
|
import { choiceTimeoutManager } from "./choice-timeout.js";
|
||||||
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
|
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -529,3 +535,191 @@ describe("T44 — submit-choice validation", () => {
|
||||||
unregisterConnection(black);
|
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