feat(server): modifier-profile.update WS handler
This commit is contained in:
parent
cc0b7b0446
commit
fa1ef765f8
5 changed files with 715 additions and 14 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
374
packages/server/src/ws.modifier-profile-update.test.ts
Normal file
374
packages/server/src/ws.modifier-profile-update.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue