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
|
|
@ -33,6 +33,7 @@ import {
|
|||
shouldSkipV2Broadcast,
|
||||
validateAnyMessageString,
|
||||
type CustomModifierRegisterPayload,
|
||||
type CustomModifierRemovePayload,
|
||||
type ErrorCode,
|
||||
type Fact as WireFact,
|
||||
type GameActionPayload,
|
||||
|
|
@ -63,7 +64,7 @@ import {
|
|||
} from "./choice-timeout.js";
|
||||
import {
|
||||
peekPendingChoice,
|
||||
popPendingChoice,
|
||||
submitChoiceAndResume,
|
||||
validateProfile,
|
||||
type ActionResult,
|
||||
type ModifierProfile,
|
||||
|
|
@ -454,15 +455,37 @@ function armChoiceTimeoutFor(
|
|||
|
||||
// Auto-resolve with the first option for this kind. The value
|
||||
// bypasses the wire-side `isValidChoiceValue` check (it never
|
||||
// touches the wire); the engine resume path (T46) will do its
|
||||
// touches the wire); the engine resume path (T46) does its
|
||||
// own kind-specific legality gate.
|
||||
const defaultValue = firstDefaultForKind(kind);
|
||||
void defaultValue; // T46: thread into engine resume context.
|
||||
|
||||
const popped = popPendingChoice(liveSession.getEngine());
|
||||
if (popped !== undefined) {
|
||||
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
|
||||
// T70 — thread the default value through the real resume
|
||||
// mechanism so any `params.then` continuation runs. The helper
|
||||
// pops the frame unconditionally (even on descriptor-not-found,
|
||||
// see pending-choices.ts header doc), so bookkeeping must be
|
||||
// cleared in both the success and the catch branch. A throw
|
||||
// from a continuation primitive must NOT crash the timer
|
||||
// callback — log + clean up so the room remains live.
|
||||
try {
|
||||
submitChoiceAndResume(liveSession.getEngine(), choiceId, defaultValue);
|
||||
} catch (err) {
|
||||
logger
|
||||
.child({ roomCode })
|
||||
.warn(
|
||||
{ choiceId, kind, err: (err as Error).message },
|
||||
"T49: timeout-default resume threw; frame popped, room continues",
|
||||
);
|
||||
}
|
||||
forgetBroadcastedChoiceId(roomCode, choiceId);
|
||||
|
||||
// Surface the resulting state to clients so any continuation
|
||||
// side-effect (HpBonus writes, fact mutations, terminal-state
|
||||
// detection) becomes visible without waiting for the next
|
||||
// move/action. A subsequent inner choice (if the continuation
|
||||
// pushed one) is broadcast via the dedicated helper.
|
||||
broadcastGameStateSnapshot(roomCode, liveSession);
|
||||
broadcastTopChoiceIfNew(roomCode, liveSession);
|
||||
|
||||
logger
|
||||
.child({ roomCode })
|
||||
.info(
|
||||
|
|
@ -680,11 +703,12 @@ function handleV2Frame(
|
|||
* (see `isValidChoiceValue`). Failures emit a `protocol.invalid-
|
||||
* choice-value` message.
|
||||
*
|
||||
* On full success: the frame is popped (`popPendingChoice`),
|
||||
* bookkeeping for the broadcasted-set is cleared, and the popped
|
||||
* value is currently DROPPED — T46 will replace this with the real
|
||||
* resume mechanism (`submitChoiceAndResume`). The pop itself is
|
||||
* preserved so the LIFO contract holds even before T46 lands.
|
||||
* On full success: the frame is resumed via `submitChoiceAndResume`
|
||||
* (which pops the frame and runs the suspended `params.then`
|
||||
* continuation with the player's value bound under the request-
|
||||
* choice's `bind` name), broadcasted-set bookkeeping is cleared,
|
||||
* and the resulting game state + any new top-of-stack choice are
|
||||
* broadcast to the room.
|
||||
*/
|
||||
function handleSubmitChoice(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
|
|
@ -783,20 +807,42 @@ function handleSubmitChoice(
|
|||
// fired after submit" log line in the latency window.
|
||||
choiceTimeoutManager.cancel(roomCode, frame.choiceId);
|
||||
|
||||
// Pop the top frame and drop the bookkeeping entry. T46 will replace
|
||||
// the bare pop with the real resume mechanism (which threads the
|
||||
// value back into the engine's resume context); for now we satisfy
|
||||
// the LIFO contract and clear our broadcast tracking so the next
|
||||
// pending choice on this socket can be re-broadcast cleanly.
|
||||
const popped = popPendingChoice(session.getEngine());
|
||||
if (popped !== undefined) {
|
||||
forgetBroadcastedChoiceId(roomCode, popped.choiceId);
|
||||
// T70 — thread the player's value through the real resume
|
||||
// mechanism. `submitChoiceAndResume` pops the frame and runs the
|
||||
// suspended `params.then` continuation. The pop is unconditional
|
||||
// even on descriptor-not-found (see pending-choices.ts header),
|
||||
// so we always clear the broadcasted-id bookkeeping. A throw
|
||||
// from a continuation primitive must not propagate to the WS
|
||||
// dispatcher — log and continue so the room stays live.
|
||||
try {
|
||||
submitChoiceAndResume(session.getEngine(), frame.choiceId, frame.value);
|
||||
} catch (err) {
|
||||
logger
|
||||
.child({ clientId: ws.data.clientId, roomCode })
|
||||
.warn(
|
||||
{
|
||||
choiceId: frame.choiceId,
|
||||
kind: top.kind,
|
||||
err: (err as Error).message,
|
||||
},
|
||||
"submit-choice resume threw; frame popped, room continues",
|
||||
);
|
||||
}
|
||||
forgetBroadcastedChoiceId(roomCode, frame.choiceId);
|
||||
|
||||
// Surface the resulting state to clients so any continuation
|
||||
// side-effect (HpBonus writes, fact mutations, terminal-state
|
||||
// detection) becomes visible without waiting for the next
|
||||
// move/action. A subsequent inner choice pushed by the
|
||||
// continuation is broadcast via the dedicated helper.
|
||||
broadcastGameStateSnapshot(roomCode, session);
|
||||
broadcastTopChoiceIfNew(roomCode, session);
|
||||
|
||||
logger
|
||||
.child({ clientId: ws.data.clientId, roomCode })
|
||||
.info(
|
||||
{ choiceId: frame.choiceId, kind: top.kind },
|
||||
"submit-choice (pre-T46: value accepted, resume not yet wired)",
|
||||
"submit-choice: value accepted, continuation resumed",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -883,6 +929,9 @@ export function handleMessage(
|
|||
case "custom-modifier.register":
|
||||
handleCustomModifierRegister(ws, msg.payload);
|
||||
break;
|
||||
case "custom-modifier.remove":
|
||||
handleCustomModifierRemove(ws, msg.payload);
|
||||
break;
|
||||
case "room.created":
|
||||
case "room.joined":
|
||||
case "game.state":
|
||||
|
|
@ -895,6 +944,7 @@ export function handleMessage(
|
|||
case "modifier-profile.rejected":
|
||||
case "modifier-profile.consent-received":
|
||||
case "custom-modifier.registered":
|
||||
case "custom-modifier.removed":
|
||||
case "error":
|
||||
sendTo(
|
||||
ws,
|
||||
|
|
@ -2444,6 +2494,96 @@ function handleCustomModifierRegister(
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T71 — custom-modifier.remove handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detach a previously-registered custom modifier descriptor from the
|
||||
* authoritative engine, drop it from the room's per-room registry,
|
||||
* and broadcast `custom-modifier.removed` so opponents mirror the
|
||||
* detach. Host-only — mirrors the register-side gate.
|
||||
*
|
||||
* The detach pipeline on the engine is idempotent (already-detached
|
||||
* id is a safe no-op), so re-sending a remove for an unknown id is
|
||||
* not an error: the broadcast still goes out so client engines
|
||||
* mirror their own removal.
|
||||
*/
|
||||
function handleCustomModifierRemove(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
payload: CustomModifierRemovePayload,
|
||||
): void {
|
||||
const { roomCode, token } = ws.data;
|
||||
if (roomCode === undefined || token === undefined) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.roomCode !== roomCode) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"BAD_TOKEN",
|
||||
"custom-modifier.remove roomCode does not match authenticated room",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = roomRegistry.getRoom(roomCode);
|
||||
if (!room) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage("ROOM_NOT_FOUND", `room ${roomCode} not found`, false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Host-only — same gate as register. An opponent who could detach
|
||||
// arbitrary descriptors could grief the host's rule set.
|
||||
if (room.hostToken !== token) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"BAD_TOKEN",
|
||||
"only the room host can remove custom modifiers",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptorId = payload.descriptorId;
|
||||
|
||||
// Drop from the room's per-room registry (if present). This is the
|
||||
// mirror of register's `room.customModifiers.set` write — the slot
|
||||
// is freed for re-use under the per-room cap.
|
||||
room.customModifiers?.delete(descriptorId);
|
||||
|
||||
// Engine-side detach: fires `on-rule-expire` hooks and retracts
|
||||
// hook-list entries for `descriptorId`. The session is the
|
||||
// authoritative engine — clients run their own detach on receipt
|
||||
// of the broadcast below.
|
||||
const session = sessionRegistry.get(roomCode);
|
||||
session?.getEngine().detachCustomDescriptor(descriptorId);
|
||||
|
||||
logger
|
||||
.child({ roomCode, clientId: ws.data.clientId })
|
||||
.info({ descriptorId }, "custom-modifier.remove");
|
||||
|
||||
broadcastToRoom(
|
||||
roomCode,
|
||||
envelope("custom-modifier.removed", {
|
||||
roomCode,
|
||||
descriptorId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a game.end to everyone in `code` EXCEPT the player whose
|
||||
* token is `leaverToken`. Used when a player disconnects or leaves
|
||||
|
|
|
|||
|
|
@ -692,6 +692,38 @@ export type CustomModifierRegisteredPayload = z.infer<
|
|||
typeof CustomModifierRegisteredPayloadSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* T71 — client → server: detach a previously-registered custom
|
||||
* modifier descriptor. Server fires `on-rule-expire` hooks on the
|
||||
* authoritative engine, retracts the descriptor's hook-list entries,
|
||||
* removes it from the room's custom registry, and broadcasts
|
||||
* `custom-modifier.removed` so peer clients mirror the removal.
|
||||
*
|
||||
* Host-only (mirrors register's host-only gate). Unknown descriptor
|
||||
* ids are tolerated (idempotent on already-detached) — the server
|
||||
* still broadcasts so clients can mirror their own removal.
|
||||
*/
|
||||
export const CustomModifierRemovePayloadSchema = z.object({
|
||||
roomCode: RoomCodeSchema,
|
||||
descriptorId: z.string().min(1).max(128),
|
||||
});
|
||||
export type CustomModifierRemovePayload = z.infer<
|
||||
typeof CustomModifierRemovePayloadSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* T71 — server → all clients in room: a custom modifier descriptor
|
||||
* has been detached. Clients should call `engine.detachCustomDescriptor`
|
||||
* locally so their session state mirrors the server's.
|
||||
*/
|
||||
export const CustomModifierRemovedPayloadSchema = z.object({
|
||||
roomCode: RoomCodeSchema,
|
||||
descriptorId: z.string().min(1).max(128),
|
||||
});
|
||||
export type CustomModifierRemovedPayload = z.infer<
|
||||
typeof CustomModifierRemovedPayloadSchema
|
||||
>;
|
||||
|
||||
export const RoomJoinPayloadSchema = z.object({
|
||||
code: RoomCodeSchema,
|
||||
});
|
||||
|
|
@ -928,6 +960,10 @@ export const CustomModifierRegisterMessageSchema = msg(
|
|||
"custom-modifier.register",
|
||||
CustomModifierRegisterPayloadSchema,
|
||||
);
|
||||
export const CustomModifierRemoveMessageSchema = msg(
|
||||
"custom-modifier.remove",
|
||||
CustomModifierRemovePayloadSchema,
|
||||
);
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
RoomCreateMessageSchema,
|
||||
|
|
@ -940,6 +976,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileProposeMessageSchema,
|
||||
ModifierProfileConsentMessageSchema,
|
||||
CustomModifierRegisterMessageSchema,
|
||||
CustomModifierRemoveMessageSchema,
|
||||
]);
|
||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||
|
||||
|
|
@ -982,6 +1019,10 @@ export const CustomModifierRegisteredMessageSchema = msg(
|
|||
"custom-modifier.registered",
|
||||
CustomModifierRegisteredPayloadSchema,
|
||||
);
|
||||
export const CustomModifierRemovedMessageSchema = msg(
|
||||
"custom-modifier.removed",
|
||||
CustomModifierRemovedPayloadSchema,
|
||||
);
|
||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
|
|
@ -997,6 +1038,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileRejectedMessageSchema,
|
||||
ModifierProfileConsentReceivedMessageSchema,
|
||||
CustomModifierRegisteredMessageSchema,
|
||||
CustomModifierRemovedMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||
|
|
@ -1012,6 +1054,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileProposeMessageSchema,
|
||||
ModifierProfileConsentMessageSchema,
|
||||
CustomModifierRegisterMessageSchema,
|
||||
CustomModifierRemoveMessageSchema,
|
||||
RoomCreatedMessageSchema,
|
||||
RoomJoinedMessageSchema,
|
||||
GameStateMessageSchema,
|
||||
|
|
@ -1024,6 +1067,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileRejectedMessageSchema,
|
||||
ModifierProfileConsentReceivedMessageSchema,
|
||||
CustomModifierRegisteredMessageSchema,
|
||||
CustomModifierRemovedMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||
|
|
@ -1039,6 +1083,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
|||
"modifier-profile.propose",
|
||||
"modifier-profile.consent",
|
||||
"custom-modifier.register",
|
||||
"custom-modifier.remove",
|
||||
"room.created",
|
||||
"room.joined",
|
||||
"game.state",
|
||||
|
|
@ -1051,6 +1096,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
|||
"modifier-profile.rejected",
|
||||
"modifier-profile.consent-received",
|
||||
"custom-modifier.registered",
|
||||
"custom-modifier.removed",
|
||||
"error",
|
||||
] as const;
|
||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
||||
|
|
|
|||
253
packages/server/src/ws.custom-modifier-remove.test.ts
Normal file
253
packages/server/src/ws.custom-modifier-remove.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* T71 integration tests: `custom-modifier.remove` WebSocket handler.
|
||||
*
|
||||
* Mirrors the register-side test harness. Covers:
|
||||
* 1. Valid remove → on-rule-expire fires + broadcast lands.
|
||||
* 2. Host-only gate (BAD_TOKEN for non-host).
|
||||
* 3. Idempotent on unknown id (still broadcasts).
|
||||
* 4. Hook entries retracted from session.
|
||||
*/
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GAME_ENTITY, PRESET_STATE_ENTITY } from "@paratype/chess";
|
||||
|
||||
import {
|
||||
handleMessage,
|
||||
registerConnection,
|
||||
roomRegistry,
|
||||
sessionRegistry,
|
||||
type ClientData,
|
||||
} from "./broadcast.js";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type ClientMessage,
|
||||
type CustomModifierDescriptorWire,
|
||||
} from "./protocol.js";
|
||||
|
||||
interface MockWs extends ServerWebSocket<ClientData> {
|
||||
readonly sent: unknown[];
|
||||
readonly closed: boolean;
|
||||
}
|
||||
|
||||
function makeMockWs(clientId: string): MockWs {
|
||||
const sent: unknown[] = [];
|
||||
const closedFlag = { value: false };
|
||||
const ws = {
|
||||
data: { clientId } as ClientData,
|
||||
sent,
|
||||
get closed(): boolean {
|
||||
return closedFlag.value;
|
||||
},
|
||||
send(msg: string | Buffer): number {
|
||||
const str = typeof msg === "string" ? msg : msg.toString("utf8");
|
||||
sent.push(JSON.parse(str));
|
||||
return str.length;
|
||||
},
|
||||
close(): void {
|
||||
closedFlag.value = true;
|
||||
},
|
||||
} as unknown as MockWs;
|
||||
return ws;
|
||||
}
|
||||
|
||||
function nextMsgOfType(
|
||||
ws: MockWs,
|
||||
type: string,
|
||||
): { payload: Record<string, unknown> } {
|
||||
const idx = ws.sent.findIndex(
|
||||
(m) =>
|
||||
typeof m === "object" &&
|
||||
m !== null &&
|
||||
(m as { type: unknown }).type === type,
|
||||
);
|
||||
if (idx < 0) {
|
||||
const types = ws.sent.map((m) => (m as { type?: string }).type);
|
||||
throw new Error(
|
||||
`no message of type "${type}" in inbox (got ${JSON.stringify(types)})`,
|
||||
);
|
||||
}
|
||||
const msg = ws.sent[idx] as { payload: Record<string, unknown> };
|
||||
ws.sent.splice(idx, 1);
|
||||
return msg;
|
||||
}
|
||||
|
||||
function sendClient(
|
||||
ws: MockWs,
|
||||
type: ClientMessage["type"],
|
||||
payload: unknown,
|
||||
seq = 1,
|
||||
): void {
|
||||
handleMessage(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
v: PROTOCOL_VERSION,
|
||||
seq,
|
||||
ts: Date.now(),
|
||||
type,
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function makeDescriptor(
|
||||
id: string,
|
||||
overrides: Partial<CustomModifierDescriptorWire> = {},
|
||||
): CustomModifierDescriptorWire {
|
||||
return {
|
||||
type: "data",
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
|
||||
],
|
||||
targetAttrs: ["HpBonus"],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupRoom(): { white: MockWs; black: MockWs; code: string } {
|
||||
const white = makeMockWs(`T71-W-${crypto.randomUUID()}`);
|
||||
const black = makeMockWs(`T71-B-${crypto.randomUUID()}`);
|
||||
registerConnection(white);
|
||||
registerConnection(black);
|
||||
|
||||
sendClient(white, "room.create", {});
|
||||
const created = nextMsgOfType(white, "room.created");
|
||||
const code = created.payload["code"] as string;
|
||||
|
||||
sendClient(black, "room.join", { code });
|
||||
nextMsgOfType(black, "room.joined");
|
||||
|
||||
// Drain any game.state broadcasts so subsequent assertions only see
|
||||
// custom-modifier messages.
|
||||
for (const ws of [white, black] as MockWs[]) {
|
||||
while (ws.sent.some((m) => (m as { type?: string }).type === "game.state")) {
|
||||
nextMsgOfType(ws, "game.state");
|
||||
}
|
||||
}
|
||||
|
||||
return { white, black, code };
|
||||
}
|
||||
|
||||
describe("custom-modifier.remove WS handler (T71)", () => {
|
||||
it("removes the descriptor and broadcasts custom-modifier.removed", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const desc = makeDescriptor("custom:t71-remove");
|
||||
|
||||
// First register, then remove.
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: desc,
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.registered");
|
||||
nextMsgOfType(black, "custom-modifier.registered");
|
||||
|
||||
sendClient(white, "custom-modifier.remove", {
|
||||
roomCode: code,
|
||||
descriptorId: "custom:t71-remove",
|
||||
});
|
||||
|
||||
const wMsg = nextMsgOfType(white, "custom-modifier.removed");
|
||||
expect(wMsg.payload["roomCode"]).toBe(code);
|
||||
expect(wMsg.payload["descriptorId"]).toBe("custom:t71-remove");
|
||||
|
||||
const bMsg = nextMsgOfType(black, "custom-modifier.removed");
|
||||
expect(bMsg.payload["descriptorId"]).toBe("custom:t71-remove");
|
||||
|
||||
// Room state reflects removal — slot freed.
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.has("custom:t71-remove")).toBe(false);
|
||||
});
|
||||
|
||||
it("fires on-rule-expire hooks on the authoritative engine", () => {
|
||||
const { white, code } = setupRoom();
|
||||
|
||||
// Seed an on-rule-expire hook directly on the authoritative
|
||||
// session (simulating a descriptor's apply-time seeding).
|
||||
const session = sessionRegistry.get(code);
|
||||
if (!session) throw new Error("session not found");
|
||||
const engine = session.getEngine();
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
{
|
||||
descriptorId: "custom:t71-fire",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
|
||||
|
||||
sendClient(white, "custom-modifier.remove", {
|
||||
roomCode: code,
|
||||
descriptorId: "custom:t71-fire",
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.removed");
|
||||
|
||||
// The expire hook fired during the server-side detach.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(9);
|
||||
// Hook list now empty (entry retracted).
|
||||
expect(engine.session.get(GAME_ENTITY, "OnRuleExpireHooks")).toEqual([]);
|
||||
// RuleExpireFiredFor records the fire (idempotency guard).
|
||||
const fired = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleExpireFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired ?? []).toContain("custom:t71-fire");
|
||||
});
|
||||
|
||||
it("rejects non-host (opponent) removals with BAD_TOKEN", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
|
||||
// White (host) registers.
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: makeDescriptor("custom:t71-host-only"),
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.registered");
|
||||
nextMsgOfType(black, "custom-modifier.registered");
|
||||
|
||||
// Black (non-host) tries to remove → rejected.
|
||||
sendClient(black, "custom-modifier.remove", {
|
||||
roomCode: code,
|
||||
descriptorId: "custom:t71-host-only",
|
||||
});
|
||||
const err = nextMsgOfType(black, "error");
|
||||
expect(err.payload["code"]).toBe("BAD_TOKEN");
|
||||
expect(err.payload["message"]).toMatch(/host/i);
|
||||
|
||||
// Descriptor still present.
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.has("custom:t71-host-only")).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent on an unknown / already-removed descriptor id", () => {
|
||||
const { white, code } = setupRoom();
|
||||
|
||||
// Remove an id that was never registered. Still broadcasts.
|
||||
sendClient(white, "custom-modifier.remove", {
|
||||
roomCode: code,
|
||||
descriptorId: "custom:t71-never-existed",
|
||||
});
|
||||
|
||||
const msg = nextMsgOfType(white, "custom-modifier.removed");
|
||||
expect(msg.payload["descriptorId"]).toBe("custom:t71-never-existed");
|
||||
});
|
||||
|
||||
it("rejects roomCode mismatch with BAD_TOKEN", () => {
|
||||
const { white, code } = setupRoom();
|
||||
sendClient(white, "custom-modifier.remove", {
|
||||
roomCode: "ZZZZZZ",
|
||||
descriptorId: "custom:t71-anything",
|
||||
});
|
||||
const err = nextMsgOfType(white, "error");
|
||||
expect(err.payload["code"]).toBe("BAD_TOKEN");
|
||||
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,8 +11,13 @@
|
|||
// wire-level contract independently of T47's trigger plumbing.
|
||||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pushPendingChoice, type PendingChoice } from "@paratype/chess";
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
parseCustomModifierDescriptor,
|
||||
pushPendingChoice,
|
||||
type PendingChoice,
|
||||
} from "@paratype/chess";
|
||||
|
||||
import {
|
||||
broadcastTopChoiceIfNew,
|
||||
|
|
@ -22,6 +27,7 @@ import {
|
|||
unregisterConnection,
|
||||
type ClientData,
|
||||
} from "./broadcast.js";
|
||||
import { choiceTimeoutManager } from "./choice-timeout.js";
|
||||
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -529,3 +535,191 @@ describe("T44 — submit-choice validation", () => {
|
|||
unregisterConnection(black);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T70 — submit-choice / timeout-default trigger continuation resume
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build + register a real custom-modifier descriptor on a session's
|
||||
* engine. The descriptor's `primitives` is a single `request-choice`
|
||||
* whose `params.then` writes a sentinel value into `HpBonus` on
|
||||
* `GAME_ENTITY`. The continuation primitive uses `{ $var: <bind> }`
|
||||
* so the resumed scope's binding of the player's value is observable
|
||||
* via a single `engine.session.get(GAME_ENTITY, "HpBonus")` call.
|
||||
*
|
||||
* Mirrors the fixture pattern from
|
||||
* `pending-choices-resume.test.ts` (the unit-level companion to T46)
|
||||
* but goes through the wire schema's `parseCustomModifierDescriptor`
|
||||
* boundary so the descriptor's id is properly branded for
|
||||
* `engine.customModifiers.register`.
|
||||
*/
|
||||
function registerResumeDescriptor(
|
||||
code: string,
|
||||
descriptorId: string,
|
||||
bindName: string,
|
||||
): void {
|
||||
const session = sessionRegistry.get(code);
|
||||
if (!session) throw new Error(`no session for ${code}`);
|
||||
const raw = {
|
||||
type: "data",
|
||||
id: descriptorId,
|
||||
name: descriptorId,
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "rps",
|
||||
prompt: "?",
|
||||
forPlayer: "both",
|
||||
bind: bindName,
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
// The submitted value is bound under `bindName` and
|
||||
// forwarded into HpBonus, so the test reads it back
|
||||
// and asserts on the engine state.
|
||||
value: { $var: bindName },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
const desc = parseCustomModifierDescriptor(raw);
|
||||
session.getEngine().customModifiers.register(desc);
|
||||
}
|
||||
|
||||
describe("T70 — submit-choice triggers continuation resume", () => {
|
||||
it("a real submit-choice runs the request-choice's params.then continuation", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const session = sessionRegistry.get(code)!;
|
||||
|
||||
registerResumeDescriptor(code, "t70-submit-resume", "winner");
|
||||
|
||||
pushPendingChoice(
|
||||
session.getEngine(),
|
||||
buildPendingChoice({
|
||||
choiceId: "t70-submit",
|
||||
descriptorId: "t70-submit-resume",
|
||||
kind: "rps",
|
||||
forPlayer: "both",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
}),
|
||||
);
|
||||
broadcastTopChoiceIfNew(code, session);
|
||||
nextMsgOfType(white, "request-choice");
|
||||
nextMsgOfType(black, "request-choice");
|
||||
|
||||
// Pre-condition: continuation has not run.
|
||||
expect(
|
||||
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
|
||||
).toBeUndefined();
|
||||
|
||||
// Player submits "rock" — continuation should bind it under
|
||||
// `winner` and the seed-attribute primitive writes it to HpBonus.
|
||||
sendV2(white, {
|
||||
kind: "submit-choice",
|
||||
protocolVersion: 2,
|
||||
choiceId: "t70-submit",
|
||||
value: "rock",
|
||||
});
|
||||
|
||||
// No error frame.
|
||||
expect(findMsgOfType(white, "error")).toBeUndefined();
|
||||
|
||||
// Engine state advanced beyond the trivial pop: HpBonus carries
|
||||
// the value bound from the player's submission. This is the
|
||||
// assertion that proves T70's wiring fix works — the value is
|
||||
// no longer dropped on the floor at the WS boundary.
|
||||
expect(
|
||||
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
|
||||
).toBe("rock");
|
||||
|
||||
// Stack drained — the resume popped the frame.
|
||||
expect(
|
||||
session.getEngine().session.get(GAME_ENTITY, "PendingChoices"),
|
||||
).toEqual([]);
|
||||
|
||||
// Post-resume game.state broadcast — clients receive the new
|
||||
// authoritative snapshot so they can mirror the continuation's
|
||||
// side-effects without waiting for the next move.
|
||||
nextMsgOfType(white, "game.state");
|
||||
nextMsgOfType(black, "game.state");
|
||||
|
||||
unregisterConnection(white);
|
||||
unregisterConnection(black);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T70 — timeout default ALSO triggers continuation resume", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-default after timeout runs the continuation with firstDefaultForKind", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const session = sessionRegistry.get(code)!;
|
||||
|
||||
// Override the policy fact for a tighter deadline so the test
|
||||
// doesn't have to advance fake timers by 60s.
|
||||
session
|
||||
.getEngine()
|
||||
.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
|
||||
mode: "timeout-with-default",
|
||||
seconds: 1,
|
||||
});
|
||||
|
||||
registerResumeDescriptor(code, "t70-timeout-resume", "autoPick");
|
||||
|
||||
pushPendingChoice(
|
||||
session.getEngine(),
|
||||
buildPendingChoice({
|
||||
choiceId: "t70-timeout",
|
||||
descriptorId: "t70-timeout-resume",
|
||||
kind: "rps",
|
||||
forPlayer: "both",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
}),
|
||||
);
|
||||
broadcastTopChoiceIfNew(code, session);
|
||||
nextMsgOfType(white, "request-choice");
|
||||
nextMsgOfType(black, "request-choice");
|
||||
expect(choiceTimeoutManager.isArmed(code, "t70-timeout")).toBe(true);
|
||||
|
||||
// Trip the timer. firstDefaultForKind("rps") returns "rock"
|
||||
// (locked by T49). The timeout path threads that into
|
||||
// submitChoiceAndResume, which binds it under `autoPick` and
|
||||
// the continuation writes it to HpBonus.
|
||||
vi.advanceTimersByTime(2_000);
|
||||
|
||||
expect(
|
||||
session.getEngine().session.get(GAME_ENTITY, "HpBonus"),
|
||||
).toBe("rock");
|
||||
expect(
|
||||
session.getEngine().session.get(GAME_ENTITY, "PendingChoices"),
|
||||
).toEqual([]);
|
||||
expect(choiceTimeoutManager.isArmed(code, "t70-timeout")).toBe(false);
|
||||
|
||||
// Timeout-driven game.state broadcast reaches both clients.
|
||||
nextMsgOfType(white, "game.state");
|
||||
nextMsgOfType(black, "game.state");
|
||||
|
||||
unregisterConnection(white);
|
||||
unregisterConnection(black);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue