From fa1ef765f8a2e074abf2dc0dbcf3036989a8dec8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 23:11:21 -0600 Subject: [PATCH] feat(server): modifier-profile.update WS handler --- packages/chess/src/index.ts | 5 + packages/server/src/broadcast.ts | 201 ++++++++++ packages/server/src/game-session.ts | 101 +++++ packages/server/src/protocol.ts | 48 ++- .../src/ws.modifier-profile-update.test.ts | 374 ++++++++++++++++++ 5 files changed, 715 insertions(+), 14 deletions(-) create mode 100644 packages/server/src/ws.modifier-profile-update.test.ts diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index ac31124..6a708c9 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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"; diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 5fd4807..d87c8fc 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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, + 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, payload: RoomSetPresetsPayload, diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 353cb48..5725edd 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -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(); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 93f586c..ba625b9 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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; @@ -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; @@ -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; @@ -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]; diff --git a/packages/server/src/ws.modifier-profile-update.test.ts b/packages/server/src/ws.modifier-profile-update.test.ts new file mode 100644 index 0000000..ae93229 --- /dev/null +++ b/packages/server/src/ws.modifier-profile-update.test.ts @@ -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 { + 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 } { + 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 }; + 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, + version: number, +): Record { + 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); + }); +});