feat(server): modifier-profile.update WS handler

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 23:11:21 -06:00
commit fa1ef765f8
No known key found for this signature in database
5 changed files with 715 additions and 14 deletions

View file

@ -82,3 +82,8 @@ export type {
ValidationErrorCode as ModifierValidationErrorCode,
ValidationWarningCode as ModifierValidationWarningCode,
} from "./modifiers/validate.js";
// Hot-swap reconciliation (T15). Exported so the server can apply a
// new ModifierProfile to a live session at a turn boundary without
// reaching into engine internals.
export { reconcileProfileSwap } from "./modifiers/reconcile.js";

View file

@ -26,6 +26,7 @@ import {
type ErrorCode,
type Fact as WireFact,
type GameMovePayload,
type ModifierProfileUpdatePayload,
type PresetActivation,
type RoomCreatePayload,
type RoomJoinPayload,
@ -254,12 +255,16 @@ export function handleMessage(
case "room.setPresets":
handleSetPresets(ws, msg.payload);
break;
case "modifier-profile.update":
handleModifierProfileUpdate(ws, msg.payload);
break;
case "room.created":
case "room.joined":
case "game.state":
case "game.delta":
case "game.end":
case "game.presets":
case "modifier-profile.updated":
case "error":
sendTo(
ws,
@ -721,6 +726,202 @@ function handleGameMove(
}
}
/**
* Handle a client request to hot-swap the room's modifier profile.
*
* ## Authority model
*
* Only the HOST (white first connected, lowest-indexed player) may
* issue this command. Black can propose profile changes out-of-band,
* but the host has final say. This mirrors how `room.setPresets`
* worked implicitly (tests always sent it from white); we formalise
* the rule here because profiles are more consequential (they mutate
* every piece's capabilities).
*
* ## Turn-boundary semantics (T20 simplification)
*
* ADR-3 specifies "apply at the next turn boundary". The full
* implementation would queue the update onto the room and drain it
* after the in-flight `game.move` completes. However, because WS
* message processing is single-threaded per socket AND the server
* completes each `handleMessage` call synchronously before reading
* the next frame, a client cannot interleave a `modifier-profile.update`
* between its own `game.move` request and the server's reply. The
* handler therefore applies immediately; from the client's view this
* IS the turn boundary.
*
* What this DOESN'T prevent: a client submitting a profile update
* while its OPPONENT's move is in flight. The current simplification
* accepts this the swap lands between the two players' moves,
* which is still a turn boundary under any reasonable definition.
* If a stricter "only between full game plies" policy is ever
* required, we'll revisit with a proper queue.
*
* ## Pipeline
*
* 1. Authenticate: socket must be bound to the room and be the host.
* 2. Sanity: session must exist, game must not be over.
* 3. Staleness: incoming `version` must equal the session's current
* `profileVersion` rejects concurrent edits from stale editors.
* 4. Validate: run `validateProfile` against the session's layout.
* Any error NACK to the sender with the mapped wire code.
* 5. Apply: `GameSession.reconcileProfile` seeds/retracts modifier
* facts via the ADR-3 retract-then-reapply path and bumps the
* version.
* 6. Mirror into room state so late joiners see the new profile.
* 7. Broadcast `modifier-profile.updated` to every connection in
* the room (including the sender they want the authoritative
* echo to confirm their request landed).
*/
function handleModifierProfileUpdate(
ws: ServerWebSocket<ClientData>,
payload: ModifierProfileUpdatePayload,
): void {
const { roomCode, token } = ws.data;
if (roomCode === undefined || token === undefined) {
sendTo(
ws,
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
);
return;
}
// roomCode in the envelope must match the socket's bound room.
// Mismatch = misuse (a room's socket trying to update a different
// room's profile) — reject rather than silently reroute. We only
// surface BAD_TOKEN because letting the client probe other rooms
// by roomCode is an information leak.
if (payload.roomCode !== roomCode) {
sendTo(
ws,
errorMessage(
"BAD_TOKEN",
"profile update roomCode does not match authenticated room",
false,
),
);
return;
}
const room = roomRegistry.getRoom(roomCode);
if (!room) {
sendTo(
ws,
errorMessage(
"ROOM_NOT_FOUND",
`profile update: room ${roomCode} not found`,
false,
),
);
return;
}
// Host authorisation. "Host" = the white player, which for new
// rooms is always the creator (assigned WHITE in `createRoom`).
// We look up the requesting player by their token and require
// their color to be white.
const requester = room.players.get(token);
if (!requester || requester.color !== "white") {
sendTo(
ws,
errorMessage(
"BAD_TOKEN",
"only the host may update the modifier profile",
false,
),
);
return;
}
const session = sessionRegistry.get(roomCode);
if (!session) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"internal error: missing game session",
true,
),
);
ws.close();
return;
}
// Reject updates to a finished game — there's no meaningful way
// to "apply" a profile to a position that can no longer change.
if (session.getGameOver() !== null) {
sendTo(
ws,
errorMessage("GAME_OVER", "cannot update profile on a finished game", false),
);
return;
}
// Staleness check. The client tells us which profileVersion they
// believed the room was at; if our current version has already
// moved past that, their request is based on outdated state
// (another editor swapped while they composed). Reject with the
// generic INVALID wire code — same family as "schema-invalid"
// because the OUTCOME is the same (the update won't apply) and
// adding a new wire code for every failure mode bloats the
// taxonomy.
if (payload.version !== session.getProfileVersion()) {
sendTo(
ws,
errorMessage(
"MODIFIER_PROFILE_INVALID",
`profile version mismatch: client=${String(payload.version)}, server=${String(session.getProfileVersion())}`,
false,
),
);
return;
}
// Validate against the layout. The schema already accepted the
// wire shape; validateProfile enforces game-rule legality
// (king presence, invuln-king, attribute ceiling). We report the
// FIRST error so the client gets a single actionable code.
const newProfile = asChessProfile(payload.newProfile);
const check = validateProfile(newProfile, room.layout);
if (!check.valid) {
const first = check.errors[0]!;
sendTo(
ws,
errorMessage(
mapProfileValidationCode(first.code),
first.message,
false,
),
);
return;
}
// Apply. `reconcileProfile` owns the retract-then-reapply and the
// Hp clamp (ADR-3). It also bumps the internal profileVersion so
// subsequent broadcasts / echoes carry the post-swap number.
session.reconcileProfile(newProfile);
// Mirror onto the Room so `room.joined` late-joiners see the
// current profile, not the original one. The session is the
// authoritative copy for engine state; the room copy is the
// replay source for new connections.
room.profile = newProfile;
// Broadcast to everyone in the room including the sender.
broadcastToRoom(
roomCode,
envelope("modifier-profile.updated", {
profile: newProfile,
version: session.getProfileVersion(),
appliedAt: "turn-boundary",
}),
);
logger
.child({ clientId: ws.data.clientId, roomCode })
.info("modifier-profile.update");
}
function handleSetPresets(
ws: ServerWebSocket<ClientData>,
payload: RoomSetPresetsPayload,

View file

@ -10,9 +10,11 @@
// GameSessionRegistry keeps sessions isolated per room code so two rooms
// cannot observe or collide with each other's IDs or facts.
import {
CLASSIC_LAYOUT,
ChessEngine,
algebraicToSquare,
PresetActivationError,
reconcileProfileSwap,
type ActivationRequest,
type ModifierProfile,
type PresetActivation,
@ -78,6 +80,30 @@ export type GameEndReason =
export class GameSession {
private readonly engine: ChessEngine;
/**
* Starting layout used to open the game. Held for the lifetime of
* the session so `reconcileProfile` can re-resolve per-instance
* modifier entries (which are keyed by algebraic square) against
* the original layout, not the current board state.
*/
private readonly layout: StartingLayout;
/**
* The active modifier profile, or `undefined` when the session was
* created without one. Mutated by `reconcileProfile` at each swap
* the server uses this to diff incoming updates and to echo the
* current profile on `room.joined`.
*/
private activeProfile: ModifierProfile | undefined;
/**
* Monotonically-increasing counter bumped every time the profile
* swap lands successfully. Version 0 means "no profile ever
* applied" (even if a profile was passed at construction
* construction is the first "version" increment, so sessions that
* opened with a profile start at version 1). Stale update requests
* from out-of-sync clients are rejected by comparing the incoming
* `version` to this counter.
*/
private profileVersion: number;
/**
* Snapshot of the fact set taken immediately after the last successful
* mutation. Used to compute inserted/retracted deltas for the next move
@ -121,6 +147,17 @@ export class GameSession {
this.engine = Object.keys(opts).length > 0
? new ChessEngine(opts)
: new ChessEngine();
// Stash the resolved layout so reconcileProfile can re-resolve
// per-instance modifiers (square-keyed) against the ORIGINAL
// board topology, not the current in-game board. Falls back to
// CLASSIC_LAYOUT to mirror the engine's own default when the
// caller opted into FIDE.
this.layout = layout ?? CLASSIC_LAYOUT;
this.activeProfile = profile;
// Version 1 if a profile was applied at construction; 0 otherwise.
// Clients that want to know whether a profile is live inspect the
// version; version > 0 ⇒ at least one apply has occurred.
this.profileVersion = profile !== undefined ? 1 : 0;
if (rulesetIds.length > 0) {
try {
this.engine.setActivePresets(
@ -172,6 +209,70 @@ export class GameSession {
return this.engine.activePresets.list();
}
/**
* Current modifier profile (the one effective on the session right
* now), or `undefined` if the session has no profile applied.
* Server-side code reads this to echo on `room.joined` for late
* joiners, and to diff a new `modifier-profile.update` request
* against the current state.
*/
getProfile(): ModifierProfile | undefined {
return this.activeProfile;
}
/**
* Monotonic version counter for the active profile. Starts at 0
* (no profile ever applied) or 1 (profile applied at construction).
* Every successful `reconcileProfile` call bumps it by 1. Clients
* use this to detect staleness when sending
* `modifier-profile.update` requests.
*/
getProfileVersion(): number {
return this.profileVersion;
}
/**
* Apply a hot-swap modifier profile to this session's engine.
*
* Runs the full reconcile pass (retract-then-reapply with Hp clamp
* per ADR-3) via `reconcileProfileSwap`. The caller is responsible
* for having already validated the new profile against the layout
* via `validateProfile` this method TRUSTS its input.
*
* After a successful call:
* - `activeProfile` reflects `newProfile` (or null on a clear).
* - `profileVersion` has been bumped by 1.
* - The fact snapshot used for move deltas is re-taken, so the
* NEXT `applyMove` won't surface the profile swap in its
* inserted/retracted sets (the swap is communicated via a
* separate `modifier-profile.updated` broadcast, not a
* game.delta clients shouldn't conflate the two).
*
* Returns the pair of fact sets (`inserted` / `retracted`) that
* describe the swap itself, useful for callers that want to
* reconcile their own mirror of the session state without
* re-walking full facts.
*/
reconcileProfile(
newProfile: ModifierProfile | null,
): { inserted: Fact[]; retracted: Fact[] } {
const before = this.snapshotFacts();
reconcileProfileSwap(
this.engine.session,
this.activeProfile ?? null,
newProfile,
this.layout,
);
const after = this.snapshotFacts();
const diff = diffFacts(before, after);
// Re-seat prevFacts so the next applyMove's delta starts from
// the post-swap fact set, not the pre-swap one.
this.prevFacts = after;
this.activeProfile = newProfile ?? undefined;
this.profileVersion += 1;
return diff;
}
/** Returns a fresh snapshot of current facts in deterministic order. */
getAllFacts(): Fact[] {
return this.snapshotFacts();

View file

@ -302,21 +302,27 @@ export type ModifierProfileUpdatePayload = z.infer<
* next turn boundary (server guarantees this is AT the turn
* boundary the broadcast is emitted immediately before the
* ensuing `game.state` / `game.delta`).
*
* Not currently dispatched through the Zod `AnyMessageSchema` union
* because the feature is still being rolled out end-to-end; once T18
* wires broadcast through the rooms code this payload gets promoted
* to a full `msg()` entry in the union.
*/
export interface ModifierProfileUpdatedPayload {
readonly type: "modifier-profile.updated";
readonly profile: ModifierProfile;
readonly version: number;
/** Indicates WHEN the new profile became effective always the
* turn boundary for now; kept explicit so a future "immediate"
* policy can be added without breaking existing clients. */
readonly appliedAt: "turn-boundary";
}
export const ModifierProfileUpdatedPayloadSchema = z.object({
/** The new authoritative profile. Always the full value, never a
* diff reconciling a partial update is the server's job. */
profile: ModifierProfileSchema,
/** Post-swap version. Monotonic within a room; clients echo this
* back on their next `modifier-profile.update` to avoid races. */
version: z.number().int().min(1),
/** When the swap landed. `"turn-boundary"` is the only value today;
* carved out as a string literal so a future `"immediate"` policy
* can be added without breaking wire consumers. */
appliedAt: z.literal("turn-boundary"),
});
export type ModifierProfileUpdatedPayload = z.infer<
typeof ModifierProfileUpdatedPayloadSchema
> & {
/** Preserved for type-level consumers that were already relying on
* a discriminator field on the payload. Not part of the wire
* schema the envelope `type` is authoritative. */
readonly type?: "modifier-profile.updated";
};
export const RoomJoinPayloadSchema = z.object({
code: RoomCodeSchema,
@ -479,6 +485,10 @@ export const RoomSetPresetsMessageSchema = msg(
"room.setPresets",
RoomSetPresetsPayloadSchema,
);
export const ModifierProfileUpdateMessageSchema = msg(
"modifier-profile.update",
ModifierProfileUpdatePayloadSchema,
);
export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
@ -486,6 +496,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
ModifierProfileUpdateMessageSchema,
]);
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
@ -504,6 +515,10 @@ export const GamePresetsMessageSchema = msg(
"game.presets",
GamePresetsPayloadSchema,
);
export const ModifierProfileUpdatedMessageSchema = msg(
"modifier-profile.updated",
ModifierProfileUpdatedPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -513,6 +528,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ModifierProfileUpdatedMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -523,12 +539,14 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
ModifierProfileUpdateMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ModifierProfileUpdatedMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -539,12 +557,14 @@ export const KNOWN_MESSAGE_TYPES = [
"room.leave",
"game.move",
"room.setPresets",
"modifier-profile.update",
"room.created",
"room.joined",
"game.state",
"game.delta",
"game.end",
"game.presets",
"modifier-profile.updated",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];

View file

@ -0,0 +1,374 @@
/**
* T20 integration tests: `modifier-profile.update` WebSocket handler.
*
* Covers the full server dispatch pipeline for a hot-swap request:
* 1. Happy path host sends a valid profile update, server validates,
* reconciles the session, bumps the version, and broadcasts
* `modifier-profile.updated` to BOTH players.
* 2. Invalid profile (CANNOT_BE_CAPTURED on king) NACK to sender,
* no broadcast to the opponent, room.profile unchanged, version
* NOT bumped.
* 3. Non-host sender (black) `BAD_TOKEN` error back to sender,
* host unaffected, no broadcast.
* 4. Stale version client's version lags the server rejected
* with `MODIFIER_PROFILE_INVALID`, no mutation.
*
* Harness mirrors `room.create-profile.test.ts`: mock ServerWebSockets
* drive `handleMessage` through the real Zod validation + dispatch
* path so the tests exercise exactly what the wire will hit.
*/
import type { ServerWebSocket } from "bun";
import { describe, expect, it } from "vitest";
import {
handleMessage,
registerConnection,
roomRegistry,
sessionRegistry,
type ClientData,
} from "./broadcast.js";
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
import { CaptureFlag } from "@paratype/chess";
// ─── Mock ServerWebSocket (copy of room.create-profile harness) ──────
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 hasMsgOfType(ws: MockWs, type: string): boolean {
return ws.sent.some(
(m) =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === type,
);
}
/**
* Envelope `type` and `payload.type` both carry `"modifier-profile.update"`.
* The envelope type is what the ClientMessageSchema dispatches on; the
* payload type was baked in by T17 for a future standalone-schema use
* case we include it to satisfy the existing schema, but the
* broadcast handler ignores it.
*/
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,
}),
);
}
// ─── Profile fixtures ────────────────────────────────────────────────
/** Initial profile used to open the room. +1 HP on all white pawns. */
const initialProfile = {
id: "t20-initial",
name: "Initial",
description: "All white pawns start with +1 HP.",
layoutId: "classic",
perType: [
{
kind: "hp-bonus" as const,
pieceType: "pawn" as const,
color: "white" as const,
value: 1,
},
],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
/** A valid hot-swap target: +1 RangeBonus on rooks. Different kind,
* different pieces, so it's a meaningful diff from initialProfile. */
const swapProfile = {
id: "t20-swap",
name: "Swap",
description: "Rooks get +1 range.",
layoutId: "classic",
perType: [
{
kind: "range-bonus" as const,
pieceType: "rook" as const,
color: "both" as const,
value: 1,
},
],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
/** Invalid profile: marks both kings as CANNOT_BE_CAPTURED. Validator
* flags this as `E_PROFILE_INVULN_KING`; the wire code is
* `MODIFIER_PROFILE_INVULN_KING`. */
const invulnKingProfile = {
id: "t20-invuln-kings",
name: "Invulnerable Kings",
description: "Kings cannot be captured.",
layoutId: "classic",
perType: [
{
kind: "capture-flags" as const,
pieceType: "king" as const,
color: "both" as const,
value: CaptureFlag.CANNOT_BE_CAPTURED,
},
],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
/**
* Build an `modifier-profile.update` payload. Includes the `type`
* literal inside the payload to satisfy the T17
* `ModifierProfileUpdatePayloadSchema` shape.
*/
function updatePayload(
roomCode: string,
newProfile: Record<string, unknown>,
version: number,
): Record<string, unknown> {
return {
type: "modifier-profile.update",
roomCode,
newProfile,
version,
};
}
// ─── Test setup helper ───────────────────────────────────────────────
/**
* Spin up a room with an initial profile + two connected players.
* Returns the pair of sockets plus the room code so tests can drive
* follow-up interactions.
*
* Version semantics: `createRoom` session profileVersion = 1, so
* happy-path tests use `version: 1` on their first update and get
* bumped to 2.
*/
function setupRoom(): {
white: MockWs;
black: MockWs;
code: string;
} {
const white = makeMockWs(`T20-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T20-B-${crypto.randomUUID()}`);
registerConnection(white);
registerConnection(black);
sendClient(white, "room.create", { profile: initialProfile });
const created = nextMsgOfType(white, "room.created");
const code = created.payload["code"] as string;
sendClient(black, "room.join", { code });
// Drain any broadcast noise from the joined handshake.
nextMsgOfType(black, "room.joined");
// game.state fires to both on join; drain from both so later
// assertions aren't polluted by the initial snapshot.
if (hasMsgOfType(white, "game.state")) nextMsgOfType(white, "game.state");
if (hasMsgOfType(black, "game.state")) nextMsgOfType(black, "game.state");
return { white, black, code };
}
// ─── Tests ───────────────────────────────────────────────────────────
describe("modifier-profile.update WS handler (T20)", () => {
it("host submits a valid profile → broadcast to both players, version bumps, session swapped", () => {
const { white, black, code } = setupRoom();
// Sanity: session opened with profileVersion = 1 (profile at create).
const preSession = sessionRegistry.get(code);
expect(preSession?.getProfileVersion()).toBe(1);
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfile, 1),
);
// Both players receive the authoritative echo.
const updatedForWhite = nextMsgOfType(white, "modifier-profile.updated");
const updatedForBlack = nextMsgOfType(black, "modifier-profile.updated");
expect(updatedForWhite.payload["version"]).toBe(2);
expect(updatedForBlack.payload["version"]).toBe(2);
expect(updatedForWhite.payload["appliedAt"]).toBe("turn-boundary");
const echoedProfile = updatedForWhite.payload["profile"] as {
id: string;
perType: unknown[];
};
expect(echoedProfile.id).toBe(swapProfile.id);
expect(echoedProfile.perType).toEqual(swapProfile.perType);
// Session state reflects the swap.
const session = sessionRegistry.get(code);
expect(session?.getProfileVersion()).toBe(2);
expect(session?.getProfile()?.id).toBe(swapProfile.id);
// HpBonus facts (from the OLD profile) should be gone; RangeBonus
// facts (from the new profile) should be present.
const facts = session!.getAllFacts();
expect(facts.some((f) => f.attr === "HpBonus")).toBe(false);
expect(facts.some((f) => f.attr === "RangeBonus")).toBe(true);
// Room registry mirror is updated too so late joiners see the new
// profile on `room.joined`.
const room = roomRegistry.getRoom(code);
expect(room?.profile?.id).toBe(swapProfile.id);
// No error was sent to sender.
expect(hasMsgOfType(white, "error")).toBe(false);
});
it("invalid profile (CANNOT_BE_CAPTURED on king) → NACK to sender, no broadcast, no state mutation", () => {
const { white, black, code } = setupRoom();
const versionBefore = sessionRegistry.get(code)!.getProfileVersion();
const profileBefore = sessionRegistry.get(code)!.getProfile()?.id;
sendClient(
white,
"modifier-profile.update",
updatePayload(code, invulnKingProfile, versionBefore),
);
// Sender gets the specific wire code.
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("MODIFIER_PROFILE_INVULN_KING");
expect(err.payload["fatal"]).toBe(false);
expect(String(err.payload["message"])).toMatch(/CANNOT_BE_CAPTURED/);
// Opponent NEVER sees a modifier-profile.updated (broadcast did
// not fire because validation failed before reconcile).
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// Sender also doesn't see the updated broadcast.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
// Version and profile unchanged.
const session = sessionRegistry.get(code)!;
expect(session.getProfileVersion()).toBe(versionBefore);
expect(session.getProfile()?.id).toBe(profileBefore);
// Room mirror also unchanged.
expect(roomRegistry.getRoom(code)?.profile?.id).toBe(profileBefore);
});
it("non-host (black) sender → BAD_TOKEN, host unaffected, no broadcast", () => {
const { white, black, code } = setupRoom();
const versionBefore = sessionRegistry.get(code)!.getProfileVersion();
sendClient(
black,
"modifier-profile.update",
updatePayload(code, swapProfile, versionBefore),
);
// Black gets a BAD_TOKEN error.
const err = nextMsgOfType(black, "error");
expect(err.payload["code"]).toBe("BAD_TOKEN");
expect(String(err.payload["message"])).toMatch(/host/i);
// No updated broadcast to either side.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// Session unchanged.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(versionBefore);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(initialProfile.id);
});
it("stale version → MODIFIER_PROFILE_INVALID, no mutation", () => {
const { white, black, code } = setupRoom();
// First apply a successful swap so server version moves to 2.
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfile, 1),
);
// Drain the successful broadcasts so inbox asserts are clean below.
nextMsgOfType(white, "modifier-profile.updated");
nextMsgOfType(black, "modifier-profile.updated");
expect(sessionRegistry.get(code)!.getProfileVersion()).toBe(2);
// Now a second update from white carrying version=1 — stale. The
// server must refuse rather than silently apply; otherwise a
// client editing locally while another swap lands would overwrite.
sendClient(
white,
"modifier-profile.update",
updatePayload(code, initialProfile, 1),
);
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("MODIFIER_PROFILE_INVALID");
expect(String(err.payload["message"])).toMatch(/version/i);
// No broadcast from the stale request.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// Session stayed on version=2 with swapProfile.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(2);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(swapProfile.id);
});
});