feat(server): turn-boundary queue for modifier profile updates

Replace T1 immediate-apply semantics with a single-slot pending
queue (T2-ADR-1). On `modifier-profile.update` receipt the server
validates shape + layout legality, stashes the profile on
`Room.pendingProfile` with the proposer's token, and acks the
sender with a new `modifier-profile.queued` message. The actual
`reconcileProfileSwap` + version bump + `modifier-profile.updated`
broadcast now runs in `applyPendingProfileIfAny` after the next
successful `applyMove` — either player's move triggers it.

- `Room` gains `pendingProfile` and `pendingProposerToken`
  (token-keyed for reconnect-safe NACK routing).
- `game-session.ts` exposes `setPendingProfile`,
  `applyPendingProfile`, `clearPendingProfile`. Apply re-runs
  `validateProfile` as defence in depth; rejections clear the
  slot and surface the validator error code.
- New `modifier-profile.queued` wire schema (server\u2192client ack
  carrying the expected post-apply version).
- Last-write-wins: a second update overwrites the pending slot
  because the server's `profileVersion` only bumps on apply, so
  the second request legitimately carries the same version.
- Existing early-rejection paths (non-host, stale version,
  invalid profile) remain unchanged.

Tests updated: 7 scenarios covering queued ACK, deferred apply,
last-write-wins, opponent-move-drains-queue, and all original
rejection paths. 1220 unit tests + 18 modifier Playwright tests
green (e2e specs never used `modifier-profile.update` at
runtime so were unaffected).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:06:15 -06:00
commit 0bd65e0a73
No known key found for this signature in database
5 changed files with 595 additions and 117 deletions

View file

@ -10,7 +10,13 @@
// or the event loop via timers — handlers are synchronous and O(facts).
import type { ServerWebSocket } from "bun";
import { GameSessionRegistry } from "./game-session.js";
import {
GameSessionRegistry,
applyPendingProfile,
clearPendingProfile,
setPendingProfile,
type GameSession,
} from "./game-session.js";
import { logger } from "./logger.js";
import {
incMessages,
@ -265,6 +271,7 @@ export function handleMessage(
case "game.end":
case "game.presets":
case "modifier-profile.updated":
case "modifier-profile.queued":
case "error":
sendTo(
ws,
@ -702,6 +709,13 @@ function handleGameMove(
broadcastToRoom(roomCode, deltaMsg);
bufferDeltaForDisconnected(roomCode, token, deltaMsg.seq, deltaPayload);
// Turn-boundary pending-profile drain (T2-ADR-1). Runs AFTER the
// move delta is broadcast so clients observe the authoritative
// ordering: "move first, then rule changes". A no-op when no
// profile is queued. Either player's successful move triggers
// this — the pending slot is shared across the room.
applyPendingProfileIfAny(roomCode, session);
// If any preset durations expired during this move's tick, push the
// new set so clients stop rendering those rules. We skip the broadcast
// when the set is byte-identical to pre-move — the common case —
@ -732,46 +746,35 @@ function handleGameMove(
* ## 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).
* issue this command. Two-player consent (T2-ADR-2) is a separate,
* future flow; this handler remains the host-unilateral path used by
* solo mode and tests.
*
* ## Turn-boundary semantics (T20 simplification)
* ## Turn-boundary queue (T2-ADR-1)
*
* 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.
* Unlike T1, receipt does NOT apply the swap. Instead the handler:
*
* 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
* 1. Authenticates the sender as the host; resolves the room and
* session; checks liveness (no GAME_OVER) and version freshness.
* 2. Runs `validateProfile` against the layout as an *early
* rejection* gate an obviously-illegal profile (no king,
* invuln king, attribute overflow) is NACK'd here so the client
* gets immediate feedback instead of discovering the error a
* full turn later.
* 3. On acceptance, stashes the profile on `room.pendingProfile`
* via `setPendingProfile` (last-write-wins a prior pending is
* overwritten) and ACKs the sender with
* `modifier-profile.queued` carrying the expected post-apply
* 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).
*
* The swap itself `reconcileProfileSwap`, version bump, room
* mirror, `modifier-profile.updated` broadcast happens ONLY in
* `applyPendingProfileIfAny` after the next successful `applyMove`
* (either player's move drains the queue).
*
* This eliminates the T1 race where an opponent's in-flight move
* validated against a profile they didn't agree to. See T2-ADR-1
* for full rationale.
*/
function handleModifierProfileUpdate(
ws: ServerWebSocket<ClientData>,
@ -848,7 +851,9 @@ function handleModifierProfileUpdate(
}
// Reject updates to a finished game — there's no meaningful way
// to "apply" a profile to a position that can no longer change.
// to "apply" a profile to a position that can no longer change,
// and the turn boundary that would drain the queue is never
// going to fire.
if (session.getGameOver() !== null) {
sendTo(
ws,
@ -862,9 +867,15 @@ function handleModifierProfileUpdate(
// 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.
// because the OUTCOME is the same (the update won't apply).
//
// NOTE (T2): `profileVersion` is NOT bumped when a profile is
// merely queued — it advances only when a pending profile
// actually applies at a turn boundary. So a second
// `modifier-profile.update` arriving between receipt and the
// boundary carries the SAME version as the first and passes this
// gate cleanly. That is intentional: it's how last-write-wins is
// expressed on the wire.
if (payload.version !== session.getProfileVersion()) {
sendTo(
ws,
@ -877,10 +888,17 @@ function handleModifierProfileUpdate(
return;
}
// Validate against the layout. The schema already accepted the
// Early-rejection validation. 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.
//
// Profile validation is stateless w.r.t. session facts — it only
// inspects the profile + layout — so running it here short-
// circuits obviously-bad inputs that would otherwise sit in the
// pending slot until the next move before being rejected. The
// same check runs again in `applyPendingProfile` as defence in
// depth against a future validator that grows stateful.
const newProfile = asChessProfile(payload.newProfile);
const check = validateProfile(newProfile, room.layout);
if (!check.valid) {
@ -896,32 +914,120 @@ function handleModifierProfileUpdate(
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);
// Stash as pending (T2-ADR-1). Overwrites any previously-queued
// profile — this is the wire-level expression of last-write-wins
// on the single-slot queue. The actual reconcile + version bump
// + broadcast happens in `applyPendingProfileIfAny` AFTER the
// next successful move.
setPendingProfile(room, newProfile, token);
// 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",
// Ack the SUBMITTER only. The opponent doesn't hear about a
// queued pending until it actually lands via
// `modifier-profile.updated` at the next turn boundary.
// pendingVersion = current + 1 (the version the server will
// emit if the pending applies successfully).
sendTo(
ws,
envelope("modifier-profile.queued", {
roomCode,
pendingVersion: session.getProfileVersion() + 1,
}),
);
logger
.child({ clientId: ws.data.clientId, roomCode })
.info("modifier-profile.update");
.info("modifier-profile.update (queued)");
}
/**
* Drain the room's pending profile if any and emit the
* appropriate wire traffic. Called immediately after a successful
* `applyMove` so observable ordering on the wire is:
*
* 1. game.delta (the move that just happened)
* 2. modifier-profile.updated (if pending applied cleanly)
* OR
* error original proposer (if pending was rejected at
* apply-time; no broadcast fires)
* 3. game.presets / game.end (if applicable)
*
* Callers: `handleGameMove`. No-op when no profile is pending.
*/
function applyPendingProfileIfAny(
roomCode: string,
session: GameSession,
): void {
const room = roomRegistry.getRoom(roomCode);
if (!room) return;
const result = applyPendingProfile(room, session);
switch (result.kind) {
case "none":
return;
case "applied": {
// Broadcast the authoritative echo to everyone in the room,
// including the sender — same semantics as the T1 immediate
// path, just triggered at the turn boundary instead of on
// receipt.
broadcastToRoom(
roomCode,
envelope("modifier-profile.updated", {
profile: result.profile,
version: result.version,
appliedAt: "turn-boundary",
}),
);
logger
.child({ roomCode })
.info("modifier-profile applied at turn boundary");
return;
}
case "rejected": {
// Apply-time re-validation failed (unlikely with the current
// stateless validator — see the comment in
// `applyPendingProfile` — but handled so future validator
// changes degrade gracefully). Route the NACK to the original
// proposer. If the proposer has disconnected AND the grace
// window expired, the token lookup returns no socket and the
// NACK is silently dropped — at that point the proposer has
// bigger problems than a stale queued profile, and the
// pending slot has already been cleared.
const proposerSocket = findSocketByToken(result.proposerToken);
if (proposerSocket !== undefined) {
sendTo(
proposerSocket,
errorMessage(
mapProfileValidationCode(result.errorCode),
result.errorMessage,
false,
),
);
}
logger
.child({ roomCode })
.warn("pending profile rejected at turn boundary");
return;
}
}
}
/** Resolve a player token to its currently-connected socket, or
* undefined if the slot has no live connection. Used when routing
* deferred NACKs (pending-profile apply-time rejection). */
function findSocketByToken(
token: string,
): ServerWebSocket<ClientData> | undefined {
for (const ws of connections.values()) {
if (ws.data.token === token) return ws;
}
return undefined;
}
// `clearPendingProfile` is re-exported for tests and future wire
// flows (profile-abort, game-end cleanup) that need to drop the
// slot without going through `applyPendingProfile`.
export { clearPendingProfile };
function handleSetPresets(
ws: ServerWebSocket<ClientData>,
payload: RoomSetPresetsPayload,

View file

@ -15,8 +15,10 @@ import {
algebraicToSquare,
PresetActivationError,
reconcileProfileSwap,
validateProfile,
type ActivationRequest,
type ModifierProfile,
type ModifierValidationErrorCode,
type PresetActivation,
type GameResult,
type PieceColor,
@ -24,6 +26,8 @@ import {
type StartingLayout,
} from "@paratype/chess";
import type { Room } from "./rooms.js";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
@ -480,3 +484,119 @@ function mapGameResult(
}
}
}
// ---------------------------------------------------------------------------
// Turn-boundary profile queue (T2-ADR-1)
// ---------------------------------------------------------------------------
//
// Per ADR, a `modifier-profile.update` accepted at receipt is NOT applied
// immediately — it is stashed on the Room as `pendingProfile` and applied
// AFTER the next successful `applyMove()`. This keeps the game state at
// turn N a pure function of {profile at turn N, moves 1..N} and eliminates
// the race where an opponent's in-flight move validates against a profile
// they didn't agree to.
//
// The queue is deliberately a single slot (not a FIFO): rapid successive
// updates overwrite, so "last write wins" on the same turn. See ADR
// §Rejected Alternatives for why a per-sender queue was not chosen.
/**
* Stash a freshly-received profile on the Room as pending. Overwrites
* any previously-queued profile (last-write-wins). Caller has already:
* 1. Zod-validated the wire shape.
* 2. Run `validateProfile` against the layout receipt-time
* legality gate; apply-time re-validation happens later.
* 3. Authenticated the proposer as the host.
*
* `proposerToken` is the player token of the submitter stored so
* an apply-time NACK can be routed back even across reconnects.
*/
export function setPendingProfile(
room: Room,
profile: ModifierProfile,
proposerToken: string,
): void {
room.pendingProfile = profile;
room.pendingProposerToken = proposerToken;
}
/** Forget any pending profile. Idempotent safe to call when the
* slot is already empty. */
export function clearPendingProfile(room: Room): void {
delete room.pendingProfile;
delete room.pendingProposerToken;
}
/**
* Outcome of a turn-boundary pending-profile apply attempt. The
* caller (broadcast.ts) uses this to decide what wire traffic to emit:
* - `none` : no pending profile was queued; nothing happens.
* - `applied` : swap succeeded; broadcast `modifier-profile.updated`.
* - `rejected`: apply-time re-validation failed; NACK the proposer
* and clear the slot (no broadcast).
*/
export type PendingProfileApplyResult =
| { kind: "none" }
| {
kind: "applied";
profile: ModifierProfile;
version: number;
}
| {
kind: "rejected";
proposerToken: string;
errorCode: ModifierValidationErrorCode;
errorMessage: string;
};
/**
* Drain the room's pending profile at a turn boundary. Re-runs
* `validateProfile` against the current layout (defence in depth for
* a future where layout could change mid-queue), then commits the
* swap via `GameSession.reconcileProfile` on success.
*
* On success: pending slot cleared, session profile + version
* updated, room mirror updated, returns `{ kind: "applied", ... }`.
* On failure: pending slot cleared, session and room UNCHANGED,
* returns `{ kind: "rejected", ... }` carrying the original
* proposer's token so the caller can route the NACK.
*
* No pending `{ kind: "none" }`. Callers typically short-circuit
* on this to avoid allocating an empty broadcast.
*/
export function applyPendingProfile(
room: Room,
session: GameSession,
): PendingProfileApplyResult {
const pending = room.pendingProfile;
const proposerToken = room.pendingProposerToken;
if (pending === undefined || proposerToken === undefined) {
return { kind: "none" };
}
// Re-validate. Profile validation is currently stateless w.r.t.
// session facts (it only inspects the profile + layout), so a
// receipt-time pass implies an apply-time pass today. We re-check
// anyway so a future validator that consults session state can't
// silently regress this contract.
const check = validateProfile(pending, room.layout);
if (!check.valid) {
const first = check.errors[0]!;
clearPendingProfile(room);
return {
kind: "rejected",
proposerToken,
errorCode: first.code,
errorMessage: first.message,
};
}
// Commit. `reconcileProfile` owns the retract-then-reapply + Hp
// clamp and bumps profileVersion; the room mirror tracks the
// authoritative copy for late joiners.
session.reconcileProfile(pending);
room.profile = pending;
const version = session.getProfileVersion();
clearPendingProfile(room);
return { kind: "applied", profile: pending, version };
}

View file

@ -324,6 +324,30 @@ export type ModifierProfileUpdatedPayload = z.infer<
readonly type?: "modifier-profile.updated";
};
/**
* Server client: ack that a `modifier-profile.update` request has
* been queued for the next turn boundary (T2-ADR-1). Sent to the
* submitting client ONLY the opponent doesn't hear about a queued
* profile until it actually lands via `modifier-profile.updated`.
*
* `pendingVersion` is the version the server EXPECTS to emit when
* the pending profile successfully applies i.e. the current
* `profileVersion` + 1. The client uses it to detect a stale
* round-trip: if `modifier-profile.updated.version > pendingVersion`,
* another swap landed ahead of theirs and their pending was
* overwritten (last-write-wins).
*/
export const ModifierProfileQueuedPayloadSchema = z.object({
roomCode: RoomCodeSchema,
/** Expected post-apply version. Advisory the authoritative
* version appears on the ensuing `modifier-profile.updated`
* broadcast once the swap lands. */
pendingVersion: z.number().int().min(1),
});
export type ModifierProfileQueuedPayload = z.infer<
typeof ModifierProfileQueuedPayloadSchema
>;
export const RoomJoinPayloadSchema = z.object({
code: RoomCodeSchema,
});
@ -519,6 +543,10 @@ export const ModifierProfileUpdatedMessageSchema = msg(
"modifier-profile.updated",
ModifierProfileUpdatedPayloadSchema,
);
export const ModifierProfileQueuedMessageSchema = msg(
"modifier-profile.queued",
ModifierProfileQueuedPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -529,6 +557,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
GameEndMessageSchema,
GamePresetsMessageSchema,
ModifierProfileUpdatedMessageSchema,
ModifierProfileQueuedMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -547,6 +576,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
GameEndMessageSchema,
GamePresetsMessageSchema,
ModifierProfileUpdatedMessageSchema,
ModifierProfileQueuedMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -565,6 +595,7 @@ export const KNOWN_MESSAGE_TYPES = [
"game.end",
"game.presets",
"modifier-profile.updated",
"modifier-profile.queued",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];

View file

@ -44,6 +44,26 @@ export interface Room {
* and the server replays it onto reconstructed engines.
*/
profile?: ModifierProfile;
/**
* Queued profile swap awaiting the next turn boundary (T2-ADR-1).
*
* A `modifier-profile.update` request that passes receipt-time
* validation is stored here and applied by the next successful
* `applyMove()`. Last-write-wins: a second update before the
* boundary overwrites the pending slot. Cleared on apply or on
* NACK at apply time.
*/
pendingProfile?: ModifierProfile;
/**
* Stable identity (player token) of the client that submitted the
* currently-pending profile. Used to route an apply-time NACK back
* to the original proposer if the pending profile is re-validated
* and fails at the turn boundary. Token is preferred over socket
* id because tokens survive reconnect, so a brief disconnect
* between queue-and-apply still delivers the NACK when the client
* returns.
*/
pendingProposerToken?: string;
}
export type JoinResult =

View file

@ -1,17 +1,22 @@
/**
* T20 integration tests: `modifier-profile.update` WebSocket handler.
* T2 integration tests: `modifier-profile.update` WebSocket handler
* with turn-boundary queue semantics (T2-ADR-1).
*
* 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.
* Covers the full server dispatch pipeline for a hot-swap request
* under the new queue model:
* 1. Receipt `modifier-profile.queued` ACK to submitter only.
* No broadcast yet, session + room profile UNCHANGED.
* 2. Next successful `applyMove` drains the pending slot
* `modifier-profile.updated` broadcast to both players, version
* bumps, session + room reflect the swap.
* 3. Last-write-wins: two updates between turn boundaries only
* the second is applied when the move lands.
* 4. Either player's move drains the queue (white's move works;
* black's move works).
* 5. Invalid profile (CANNOT_BE_CAPTURED on king) early rejection
* on receipt (before queuing). No pending stored.
* 6. Non-host sender, stale version all still early-reject as in
* T1.
*
* Harness mirrors `room.create-profile.test.ts`: mock ServerWebSockets
* drive `handleMessage` through the real Zod validation + dispatch
@ -88,6 +93,26 @@ function hasMsgOfType(ws: MockWs, type: string): boolean {
);
}
/**
* Drain *all* messages of a given type from an inbox and return
* them in order. Useful when a single scenario fires multiple
* broadcasts of the same type (e.g. a `game.delta` per move).
*/
function drainMsgsOfType(
ws: MockWs,
type: string,
): { payload: Record<string, unknown> }[] {
const out: { payload: Record<string, unknown> }[] = [];
for (let i = ws.sent.length - 1; i >= 0; i--) {
const m = ws.sent[i] as { type?: string; payload?: Record<string, unknown> };
if (m.type === type) {
out.unshift(m as { payload: Record<string, unknown> });
ws.sent.splice(i, 1);
}
}
return out;
}
/**
* Envelope `type` and `payload.type` both carry `"modifier-profile.update"`.
* The envelope type is what the ClientMessageSchema dispatches on; the
@ -117,7 +142,7 @@ function sendClient(
/** Initial profile used to open the room. +1 HP on all white pawns. */
const initialProfile = {
id: "t20-initial",
id: "t2-initial",
name: "Initial",
description: "All white pawns start with +1 HP.",
layoutId: "classic",
@ -134,10 +159,9 @@ const initialProfile = {
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. */
/** A valid hot-swap target: +1 RangeBonus on rooks. */
const swapProfile = {
id: "t20-swap",
id: "t2-swap",
name: "Swap",
description: "Rooks get +1 range.",
layoutId: "classic",
@ -154,11 +178,32 @@ const swapProfile = {
source: "custom" as const,
};
/** A second valid swap — different id / shape used to prove
* last-write-wins: if the final applied profile is this one, the
* queue overwrote, as designed. */
const swapProfileB = {
id: "t2-swap-b",
name: "SwapB",
description: "Bishops get +1 range (overwrites any prior queued).",
layoutId: "classic",
perType: [
{
kind: "range-bonus" as const,
pieceType: "bishop" 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",
id: "t2-invuln-kings",
name: "Invulnerable Kings",
description: "Kings cannot be captured.",
layoutId: "classic",
@ -202,15 +247,15 @@ function updatePayload(
*
* Version semantics: `createRoom` session profileVersion = 1, so
* happy-path tests use `version: 1` on their first update and get
* bumped to 2.
* bumped to 2 AFTER the next move applies the pending profile.
*/
function setupRoom(): {
white: MockWs;
black: MockWs;
code: string;
} {
const white = makeMockWs(`T20-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T20-B-${crypto.randomUUID()}`);
const white = makeMockWs(`T2-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T2-B-${crypto.randomUUID()}`);
registerConnection(white);
registerConnection(black);
@ -231,13 +276,13 @@ function setupRoom(): {
// ─── Tests ───────────────────────────────────────────────────────────
describe("modifier-profile.update WS handler (T20)", () => {
it("host submits a valid profile → broadcast to both players, version bumps, session swapped", () => {
describe("modifier-profile.update WS handler (T2 turn-boundary queue)", () => {
it("valid update on receipt → queued ACK to sender only, no session/room mutation, no broadcast", () => {
const { white, black, code } = setupRoom();
// Sanity: session opened with profileVersion = 1 (profile at create).
const preSession = sessionRegistry.get(code);
expect(preSession?.getProfileVersion()).toBe(1);
const preProfileId = preSession?.getProfile()?.id;
sendClient(
white,
@ -245,7 +290,47 @@ describe("modifier-profile.update WS handler (T20)", () => {
updatePayload(code, swapProfile, 1),
);
// Both players receive the authoritative echo.
// Sender receives `modifier-profile.queued` — NOT `modifier-profile.updated`.
const queued = nextMsgOfType(white, "modifier-profile.queued");
expect(queued.payload["roomCode"]).toBe(code);
// Expected post-apply version is current + 1 (advisory ack).
expect(queued.payload["pendingVersion"]).toBe(2);
// Opponent sees nothing — queued is submitter-only.
expect(hasMsgOfType(black, "modifier-profile.queued")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
// Session + room profile UNCHANGED at receipt time.
const session = sessionRegistry.get(code)!;
expect(session.getProfileVersion()).toBe(1);
expect(session.getProfile()?.id).toBe(preProfileId);
expect(roomRegistry.getRoom(code)?.profile?.id).toBe(preProfileId);
// Pending slot populated.
expect(roomRegistry.getRoom(code)?.pendingProfile?.id).toBe(swapProfile.id);
});
it("queued profile applies AFTER the next successful move → updated broadcast to both, version bumps, facts swap", () => {
const { white, black, code } = setupRoom();
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfile, 1),
);
// Drain the queued ACK so later `updated` assertion is clean.
nextMsgOfType(white, "modifier-profile.queued");
// Still no updated broadcast pre-move.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// White plays a legal opening move — this is the turn boundary
// that drains the pending profile queue.
sendClient(white, "game.move", { from: "e2", to: "e4" });
// Both players receive the authoritative echo AFTER the move.
const updatedForWhite = nextMsgOfType(white, "modifier-profile.updated");
const updatedForBlack = nextMsgOfType(black, "modifier-profile.updated");
@ -260,27 +345,115 @@ describe("modifier-profile.update WS handler (T20)", () => {
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);
// Session 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();
// HpBonus facts gone, RangeBonus facts 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);
// Room registry mirror updated.
expect(roomRegistry.getRoom(code)?.profile?.id).toBe(swapProfile.id);
// Pending slot drained.
expect(roomRegistry.getRoom(code)?.pendingProfile).toBeUndefined();
});
it("invalid profile (CANNOT_BE_CAPTURED on king) → NACK to sender, no broadcast, no state mutation", () => {
it("last-write-wins: two rapid updates before a move → only the SECOND applies on next move", () => {
const { white, black, code } = setupRoom();
// First update carries version=1. Version is NOT bumped on queue
// (only on apply), so the second update ALSO legitimately carries
// version=1 and should pass the staleness gate.
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfile, 1),
);
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfileB, 1),
);
// Both receive queued ACKs (drain to keep inbox clean).
const q1 = nextMsgOfType(white, "modifier-profile.queued");
const q2 = nextMsgOfType(white, "modifier-profile.queued");
expect(q1.payload["pendingVersion"]).toBe(2);
expect(q2.payload["pendingVersion"]).toBe(2);
// Pending slot holds swapProfileB (the second one) — last-write-wins.
expect(roomRegistry.getRoom(code)?.pendingProfile?.id).toBe(
swapProfileB.id,
);
// Turn boundary.
sendClient(white, "game.move", { from: "e2", to: "e4" });
// Exactly one `modifier-profile.updated` broadcast (not two).
const whiteUpdated = drainMsgsOfType(white, "modifier-profile.updated");
const blackUpdated = drainMsgsOfType(black, "modifier-profile.updated");
expect(whiteUpdated.length).toBe(1);
expect(blackUpdated.length).toBe(1);
// Only swapProfileB applied — swapProfile was overwritten.
const profile = whiteUpdated[0]!.payload["profile"] as { id: string };
expect(profile.id).toBe(swapProfileB.id);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(swapProfileB.id);
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(2);
});
it("opponent's move drains the queue too (not just the proposer's)", () => {
const { white, black, code } = setupRoom();
// Scenario: first cycle is white-queue → white-move (drains). Then
// white queues a SECOND swap and BLACK moves to drain it. This
// proves either player's move triggers the boundary.
// Round 1: white queues profile A, white moves → drains A.
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfile, 1),
);
nextMsgOfType(white, "modifier-profile.queued");
sendClient(white, "game.move", { from: "e2", to: "e4" });
drainMsgsOfType(white, "modifier-profile.updated");
drainMsgsOfType(black, "modifier-profile.updated");
drainMsgsOfType(white, "game.delta");
drainMsgsOfType(black, "game.delta");
// Sanity: session on version 2, swapProfile active, now black to move.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(2);
expect(sessionRegistry.get(code)?.getTurn()).toBe("black");
// Round 2: white queues profile B (turn still belongs to BLACK).
sendClient(
white,
"modifier-profile.update",
updatePayload(code, swapProfileB, 2),
);
nextMsgOfType(white, "modifier-profile.queued");
// Queue is loaded but not yet applied.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(2);
expect(roomRegistry.getRoom(code)?.pendingProfile?.id).toBe(
swapProfileB.id,
);
// Black plays — drains the queue.
sendClient(black, "game.move", { from: "e7", to: "e5" });
const updatedForWhite = nextMsgOfType(white, "modifier-profile.updated");
const updatedForBlack = nextMsgOfType(black, "modifier-profile.updated");
expect(updatedForWhite.payload["version"]).toBe(3);
expect(updatedForBlack.payload["version"]).toBe(3);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(swapProfileB.id);
});
it("invalid profile (CANNOT_BE_CAPTURED on king) → early NACK on receipt, no pending stored, no broadcast", () => {
const { white, black, code } = setupRoom();
const versionBefore = sessionRegistry.get(code)!.getProfileVersion();
const profileBefore = sessionRegistry.get(code)!.getProfile()?.id;
@ -291,28 +464,36 @@ describe("modifier-profile.update WS handler (T20)", () => {
updatePayload(code, invulnKingProfile, versionBefore),
);
// Sender gets the specific wire code.
// Sender gets the specific wire code on RECEIPT (not deferred).
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).
// No queued ack fired either — early rejection path.
expect(hasMsgOfType(white, "modifier-profile.queued")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.queued")).toBe(false);
// Opponent NEVER sees a modifier-profile.updated.
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// Sender also doesn't see the updated broadcast.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
// Pending slot untouched.
expect(roomRegistry.getRoom(code)?.pendingProfile).toBeUndefined();
// 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);
// A subsequent move must NOT trigger any deferred profile apply
// (nothing was queued).
sendClient(white, "game.move", { from: "e2", to: "e4" });
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
});
it("non-host (black) sender → BAD_TOKEN, host unaffected, no broadcast", () => {
it("non-host (black) sender → BAD_TOKEN on receipt, host unaffected, nothing queued", () => {
const { white, black, code } = setupRoom();
const versionBefore = sessionRegistry.get(code)!.getProfileVersion();
@ -322,37 +503,38 @@ describe("modifier-profile.update WS handler (T20)", () => {
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);
expect(hasMsgOfType(white, "modifier-profile.queued")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.queued")).toBe(false);
// Session unchanged.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(versionBefore);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(initialProfile.id);
expect(roomRegistry.getRoom(code)?.pendingProfile).toBeUndefined();
});
it("stale version → MODIFIER_PROFILE_INVALID, no mutation", () => {
it("stale version → MODIFIER_PROFILE_INVALID on receipt, nothing queued", () => {
const { white, black, code } = setupRoom();
// First apply a successful swap so server version moves to 2.
// First apply a full queue → move → applied cycle so the server
// version truly 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.queued");
sendClient(white, "game.move", { from: "e2", to: "e4" });
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.
// Second update from white carrying version=1 — stale. Rejected
// on receipt before ever reaching the pending slot.
sendClient(
white,
"modifier-profile.update",
@ -363,12 +545,31 @@ describe("modifier-profile.update WS handler (T20)", () => {
expect(err.payload["code"]).toBe("MODIFIER_PROFILE_INVALID");
expect(String(err.payload["message"])).toMatch(/version/i);
// No broadcast from the stale request.
// No queued ACK.
expect(hasMsgOfType(white, "modifier-profile.queued")).toBe(false);
// No broadcast.
expect(hasMsgOfType(white, "modifier-profile.updated")).toBe(false);
expect(hasMsgOfType(black, "modifier-profile.updated")).toBe(false);
// Session stayed on version=2 with swapProfile.
// Pending slot untouched.
expect(roomRegistry.getRoom(code)?.pendingProfile).toBeUndefined();
// Session state: still on the swapProfile at version 2.
expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(2);
expect(sessionRegistry.get(code)?.getProfile()?.id).toBe(swapProfile.id);
});
// Documented omission: the "valid at receipt, invalid at apply"
// scenario. `validateProfile` is currently stateless w.r.t. session
// state — it only inspects `(profile, layout)` — and the layout is
// immutable for a room's lifetime. So a profile that validates at
// receipt today will always validate at the turn boundary too.
//
// `applyPendingProfile` still re-runs validation as defence in depth
// so that if the validator grows stateful (e.g. rejects a profile
// that would leave a side with zero legal moves from the CURRENT
// position), the pending queue handles the rejection correctly
// without silent data loss. Writing a test for that today would
// require injecting a bespoke layout mutation that doesn't exist in
// the wire surface, so we intentionally defer until a real stateful
// validator lands.
});