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,
|
ValidationErrorCode as ModifierValidationErrorCode,
|
||||||
ValidationWarningCode as ModifierValidationWarningCode,
|
ValidationWarningCode as ModifierValidationWarningCode,
|
||||||
} from "./modifiers/validate.js";
|
} 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 ErrorCode,
|
||||||
type Fact as WireFact,
|
type Fact as WireFact,
|
||||||
type GameMovePayload,
|
type GameMovePayload,
|
||||||
|
type ModifierProfileUpdatePayload,
|
||||||
type PresetActivation,
|
type PresetActivation,
|
||||||
type RoomCreatePayload,
|
type RoomCreatePayload,
|
||||||
type RoomJoinPayload,
|
type RoomJoinPayload,
|
||||||
|
|
@ -254,12 +255,16 @@ export function handleMessage(
|
||||||
case "room.setPresets":
|
case "room.setPresets":
|
||||||
handleSetPresets(ws, msg.payload);
|
handleSetPresets(ws, msg.payload);
|
||||||
break;
|
break;
|
||||||
|
case "modifier-profile.update":
|
||||||
|
handleModifierProfileUpdate(ws, msg.payload);
|
||||||
|
break;
|
||||||
case "room.created":
|
case "room.created":
|
||||||
case "room.joined":
|
case "room.joined":
|
||||||
case "game.state":
|
case "game.state":
|
||||||
case "game.delta":
|
case "game.delta":
|
||||||
case "game.end":
|
case "game.end":
|
||||||
case "game.presets":
|
case "game.presets":
|
||||||
|
case "modifier-profile.updated":
|
||||||
case "error":
|
case "error":
|
||||||
sendTo(
|
sendTo(
|
||||||
ws,
|
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(
|
function handleSetPresets(
|
||||||
ws: ServerWebSocket<ClientData>,
|
ws: ServerWebSocket<ClientData>,
|
||||||
payload: RoomSetPresetsPayload,
|
payload: RoomSetPresetsPayload,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@
|
||||||
// GameSessionRegistry keeps sessions isolated per room code so two rooms
|
// GameSessionRegistry keeps sessions isolated per room code so two rooms
|
||||||
// cannot observe or collide with each other's IDs or facts.
|
// cannot observe or collide with each other's IDs or facts.
|
||||||
import {
|
import {
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
ChessEngine,
|
ChessEngine,
|
||||||
algebraicToSquare,
|
algebraicToSquare,
|
||||||
PresetActivationError,
|
PresetActivationError,
|
||||||
|
reconcileProfileSwap,
|
||||||
type ActivationRequest,
|
type ActivationRequest,
|
||||||
type ModifierProfile,
|
type ModifierProfile,
|
||||||
type PresetActivation,
|
type PresetActivation,
|
||||||
|
|
@ -78,6 +80,30 @@ export type GameEndReason =
|
||||||
|
|
||||||
export class GameSession {
|
export class GameSession {
|
||||||
private readonly engine: ChessEngine;
|
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
|
* Snapshot of the fact set taken immediately after the last successful
|
||||||
* mutation. Used to compute inserted/retracted deltas for the next move
|
* 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
|
this.engine = Object.keys(opts).length > 0
|
||||||
? new ChessEngine(opts)
|
? new ChessEngine(opts)
|
||||||
: new ChessEngine();
|
: 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) {
|
if (rulesetIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
this.engine.setActivePresets(
|
this.engine.setActivePresets(
|
||||||
|
|
@ -172,6 +209,70 @@ export class GameSession {
|
||||||
return this.engine.activePresets.list();
|
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. */
|
/** Returns a fresh snapshot of current facts in deterministic order. */
|
||||||
getAllFacts(): Fact[] {
|
getAllFacts(): Fact[] {
|
||||||
return this.snapshotFacts();
|
return this.snapshotFacts();
|
||||||
|
|
|
||||||
|
|
@ -302,21 +302,27 @@ export type ModifierProfileUpdatePayload = z.infer<
|
||||||
* next turn boundary (server guarantees this is AT the turn
|
* next turn boundary (server guarantees this is AT the turn
|
||||||
* boundary — the broadcast is emitted immediately before the
|
* boundary — the broadcast is emitted immediately before the
|
||||||
* ensuing `game.state` / `game.delta`).
|
* 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 {
|
export const ModifierProfileUpdatedPayloadSchema = z.object({
|
||||||
readonly type: "modifier-profile.updated";
|
/** The new authoritative profile. Always the full value, never a
|
||||||
readonly profile: ModifierProfile;
|
* diff — reconciling a partial update is the server's job. */
|
||||||
readonly version: number;
|
profile: ModifierProfileSchema,
|
||||||
/** Indicates WHEN the new profile became effective — always the
|
/** Post-swap version. Monotonic within a room; clients echo this
|
||||||
* turn boundary for now; kept explicit so a future "immediate"
|
* back on their next `modifier-profile.update` to avoid races. */
|
||||||
* policy can be added without breaking existing clients. */
|
version: z.number().int().min(1),
|
||||||
readonly appliedAt: "turn-boundary";
|
/** 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({
|
export const RoomJoinPayloadSchema = z.object({
|
||||||
code: RoomCodeSchema,
|
code: RoomCodeSchema,
|
||||||
|
|
@ -479,6 +485,10 @@ export const RoomSetPresetsMessageSchema = msg(
|
||||||
"room.setPresets",
|
"room.setPresets",
|
||||||
RoomSetPresetsPayloadSchema,
|
RoomSetPresetsPayloadSchema,
|
||||||
);
|
);
|
||||||
|
export const ModifierProfileUpdateMessageSchema = msg(
|
||||||
|
"modifier-profile.update",
|
||||||
|
ModifierProfileUpdatePayloadSchema,
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomCreateMessageSchema,
|
RoomCreateMessageSchema,
|
||||||
|
|
@ -486,6 +496,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
|
ModifierProfileUpdateMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||||
|
|
||||||
|
|
@ -504,6 +515,10 @@ export const GamePresetsMessageSchema = msg(
|
||||||
"game.presets",
|
"game.presets",
|
||||||
GamePresetsPayloadSchema,
|
GamePresetsPayloadSchema,
|
||||||
);
|
);
|
||||||
|
export const ModifierProfileUpdatedMessageSchema = msg(
|
||||||
|
"modifier-profile.updated",
|
||||||
|
ModifierProfileUpdatedPayloadSchema,
|
||||||
|
);
|
||||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||||
|
|
||||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
|
@ -513,6 +528,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
GameDeltaMessageSchema,
|
GameDeltaMessageSchema,
|
||||||
GameEndMessageSchema,
|
GameEndMessageSchema,
|
||||||
GamePresetsMessageSchema,
|
GamePresetsMessageSchema,
|
||||||
|
ModifierProfileUpdatedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||||
|
|
@ -523,12 +539,14 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
|
ModifierProfileUpdateMessageSchema,
|
||||||
RoomCreatedMessageSchema,
|
RoomCreatedMessageSchema,
|
||||||
RoomJoinedMessageSchema,
|
RoomJoinedMessageSchema,
|
||||||
GameStateMessageSchema,
|
GameStateMessageSchema,
|
||||||
GameDeltaMessageSchema,
|
GameDeltaMessageSchema,
|
||||||
GameEndMessageSchema,
|
GameEndMessageSchema,
|
||||||
GamePresetsMessageSchema,
|
GamePresetsMessageSchema,
|
||||||
|
ModifierProfileUpdatedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||||
|
|
@ -539,12 +557,14 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"room.leave",
|
"room.leave",
|
||||||
"game.move",
|
"game.move",
|
||||||
"room.setPresets",
|
"room.setPresets",
|
||||||
|
"modifier-profile.update",
|
||||||
"room.created",
|
"room.created",
|
||||||
"room.joined",
|
"room.joined",
|
||||||
"game.state",
|
"game.state",
|
||||||
"game.delta",
|
"game.delta",
|
||||||
"game.end",
|
"game.end",
|
||||||
"game.presets",
|
"game.presets",
|
||||||
|
"modifier-profile.updated",
|
||||||
"error",
|
"error",
|
||||||
] as const;
|
] as const;
|
||||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
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