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

Closes critical gaps surfaced by oracle gap audit:

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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