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).
602 lines
22 KiB
TypeScript
602 lines
22 KiB
TypeScript
// Authoritative chess session per room (P4.5).
|
|
//
|
|
// Every room owns exactly one GameSession which wraps a ChessEngine. The
|
|
// server is the only actor permitted to call insert/retract/fireRules on
|
|
// that engine — clients submit high-level game.move payloads and receive
|
|
// the authoritative fact delta back.
|
|
//
|
|
// Fact IDs are minted server-side only: the engine's Session is the sole
|
|
// source of EntityId authority (per @paratype/rete SPEC.md §ID Authority).
|
|
// 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,
|
|
validateProfile,
|
|
type ActivationRequest,
|
|
type ModifierProfile,
|
|
type ModifierValidationErrorCode,
|
|
type PresetActivation,
|
|
type GameResult,
|
|
type PieceColor,
|
|
type PieceType,
|
|
type StartingLayout,
|
|
} from "@paratype/chess";
|
|
|
|
import type { Room } from "./rooms.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wire-shape fact. Mirrors the protocol's FactSchema (protocol.ts) but
|
|
* duplicated here so game-session.ts does not import the zod schemas
|
|
* at runtime — the server module depends on the *types*, not the parser.
|
|
*/
|
|
export interface Fact {
|
|
id: number;
|
|
attr: string;
|
|
value: unknown;
|
|
}
|
|
|
|
/** Result of a promotion selection in a `game.move` payload. */
|
|
export type PromotionPiece = "queen" | "rook" | "bishop" | "knight";
|
|
|
|
/**
|
|
* Result of GameSession.applyMove.
|
|
*
|
|
* On success, `inserted` and `retracted` are the fact-level diff vs. the
|
|
* session's state *before* the move. On failure a single error code is
|
|
* returned — callers map that onto the protocol error envelope.
|
|
*/
|
|
export type MoveResult =
|
|
| {
|
|
ok: true;
|
|
inserted: Fact[];
|
|
retracted: Fact[];
|
|
turn: PieceColor;
|
|
gameOver: null | { winner: PieceColor | "draw"; reason: GameEndReason };
|
|
}
|
|
| { ok: false; error: MoveError };
|
|
|
|
export type MoveError = "ILLEGAL_MOVE" | "GAME_OVER";
|
|
|
|
/**
|
|
* Terminal-state reason strings. Chosen to match protocol.ts
|
|
* GameEndReasonSchema so callers can forward without translation.
|
|
*/
|
|
export type GameEndReason =
|
|
| "checkmate"
|
|
| "stalemate"
|
|
| "50-move"
|
|
| "threefold"
|
|
| "insufficient"
|
|
// Preset-defined decisive result; see GameResult variants.
|
|
| "variant-win";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GameSession
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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
|
|
* without re-walking full history.
|
|
*/
|
|
private prevFacts: Fact[];
|
|
/**
|
|
* Sticky terminal state. Once set, further applyMove calls short-circuit
|
|
* with { ok: false, error: "GAME_OVER" } — the engine would otherwise
|
|
* silently keep accepting moves past checkmate.
|
|
*/
|
|
private finalGameOver: NonNullable<
|
|
Extract<MoveResult, { ok: true }>["gameOver"]
|
|
> | null = null;
|
|
|
|
/**
|
|
* @param rulesetIds — preset IDs from room.create. Each is activated
|
|
* as `scope=both, turnsRemaining=null` (permanent) on the engine's
|
|
* private ActivePresetSet. Invalid ids are silently skipped here —
|
|
* the server validates them earlier at the protocol layer.
|
|
* @param layout — optional starting layout. When undefined, engine
|
|
* defaults to CLASSIC_LAYOUT (FIDE). The server has already
|
|
* resolved and validated the layout before constructing the
|
|
* session, so this value is trusted.
|
|
* @param profile — optional ModifierProfile to apply at game start.
|
|
* Server has already run `validateProfile` against it so we can
|
|
* pass straight through to `EngineOptions.profile`, which seeds
|
|
* the modifier facts and auto-activates the
|
|
* `__modifier-profile-integration__` preset.
|
|
*/
|
|
constructor(
|
|
rulesetIds: readonly string[] = [],
|
|
layout?: StartingLayout,
|
|
profile?: ModifierProfile,
|
|
) {
|
|
// Build the options bag once, including only the keys that were
|
|
// supplied — ChessEngine treats missing keys as "use default".
|
|
const opts: { layout?: StartingLayout; profile?: ModifierProfile } = {};
|
|
if (layout !== undefined) opts.layout = layout;
|
|
if (profile !== undefined) opts.profile = profile;
|
|
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(
|
|
rulesetIds.map((id) => ({
|
|
id,
|
|
scope: "both" as const,
|
|
turnsRemaining: null,
|
|
})),
|
|
);
|
|
} catch {
|
|
// A bad initial rulesetId shouldn't prevent session creation —
|
|
// start empty and let clients reconfigure via room.setPresets.
|
|
this.engine.activePresets.clear();
|
|
}
|
|
}
|
|
this.prevFacts = this.snapshotFacts();
|
|
}
|
|
|
|
/**
|
|
* Replace the full active preset set. Returns `{ ok: true }` on
|
|
* success so the server can then broadcast `game.presets`; returns
|
|
* `{ ok: false, error }` on validation failure (unknown id,
|
|
* incompatibility, missing requirement) so the server can surface
|
|
* an `INVALID_MESSAGE` error to the requesting client.
|
|
*
|
|
* Rejections leave the pre-existing set intact (see
|
|
* ActivePresetSet.replaceAll for atomicity).
|
|
*/
|
|
setPresets(
|
|
activations: readonly ActivationRequest[],
|
|
): { ok: true } | { ok: false; error: string } {
|
|
try {
|
|
this.engine.setActivePresets(activations);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
const msg =
|
|
e instanceof PresetActivationError
|
|
? `${e.code}: ${e.message}`
|
|
: e instanceof Error
|
|
? e.message
|
|
: String(e);
|
|
return { ok: false, error: msg };
|
|
}
|
|
}
|
|
|
|
/** Snapshot of the current active preset set, wire-shape. Used by
|
|
* broadcast to populate `game.state.activations` and `game.presets`. */
|
|
getPresetActivations(): PresetActivation[] {
|
|
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();
|
|
}
|
|
|
|
/** Whose turn is it? "white" on a pristine session. */
|
|
getTurn(): PieceColor {
|
|
return this.engine.getCurrentTurn();
|
|
}
|
|
|
|
/**
|
|
* Is the game terminally over? Returns the terminal descriptor or null.
|
|
* Cheap — just reads the sticky flag set in applyMove.
|
|
*/
|
|
getGameOver(): Extract<MoveResult, { ok: true }>["gameOver"] {
|
|
return this.finalGameOver;
|
|
}
|
|
|
|
/**
|
|
* Apply a move specified in algebraic notation ("e2" -> "e4").
|
|
*
|
|
* Failure modes:
|
|
* - Game already over → { ok: false, error: "GAME_OVER" }
|
|
* - Square parse fails, no piece, or no legal move matches →
|
|
* { ok: false, error: "ILLEGAL_MOVE" }
|
|
*
|
|
* Note: we intentionally collapse "bad square string", "no piece on
|
|
* from", and "illegal destination" into one error code. The server
|
|
* already validated algebraic shape via zod; the engine's legality
|
|
* check subsumes the rest, and leaking internal reasons would let
|
|
* clients probe our rule implementation.
|
|
*/
|
|
applyMove(
|
|
from: string,
|
|
to: string,
|
|
promoteTo?: PromotionPiece,
|
|
): MoveResult {
|
|
if (this.finalGameOver !== null) {
|
|
return { ok: false, error: "GAME_OVER" };
|
|
}
|
|
|
|
const fromSq = algebraicToSquare(from);
|
|
const toSq = algebraicToSquare(to);
|
|
// algebraicToSquare returns -1 for malformed input; a square of -1 can
|
|
// never be the start or end of a legal move, so we reject early rather
|
|
// than feeding sentinel values to the engine.
|
|
if (fromSq < 0 || toSq < 0) {
|
|
return { ok: false, error: "ILLEGAL_MOVE" };
|
|
}
|
|
|
|
// Translate the protocol's promotion enum to the engine's PieceType.
|
|
// The two vocabularies overlap exactly, so a direct cast is safe.
|
|
const promotionType = promoteTo as PieceType | undefined;
|
|
|
|
const move = this.engine.findMove(fromSq, toSq, promotionType);
|
|
if (move === null) {
|
|
return { ok: false, error: "ILLEGAL_MOVE" };
|
|
}
|
|
|
|
// Capture the mover's color *before* applyMove swaps the turn — we
|
|
// need it to compute the checkmate winner (the side that just moved).
|
|
const moverColor = this.engine.getCurrentTurn();
|
|
const result = this.engine.applyMove(move, promotionType ?? "queen");
|
|
|
|
const newFacts = this.snapshotFacts();
|
|
const { inserted, retracted } = diffFacts(this.prevFacts, newFacts);
|
|
this.prevFacts = newFacts;
|
|
|
|
const gameOver = mapGameResult(result, moverColor);
|
|
if (gameOver !== null) {
|
|
this.finalGameOver = gameOver;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
inserted,
|
|
retracted,
|
|
turn: this.engine.getCurrentTurn(),
|
|
gameOver,
|
|
};
|
|
}
|
|
|
|
/** Snapshot helper — normalises Session fact records to wire shape. */
|
|
private snapshotFacts(): Fact[] {
|
|
return this.engine.session.allFacts().map((f) => ({
|
|
id: f.id as number,
|
|
// attr is a branded string (AttrKey); the wire protocol uses plain
|
|
// strings. Cast is safe: AttrKey IS a string at runtime.
|
|
attr: f.attr as string,
|
|
value: f.value as unknown,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GameSessionRegistry — one session per room code.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class GameSessionRegistry {
|
|
private readonly sessions = new Map<string, GameSession>();
|
|
|
|
/**
|
|
* Create and store a new session for `code`. Throws if a session for
|
|
* that code already exists — callers should call delete() first if they
|
|
* intend to recycle a code (in practice room codes never recycle).
|
|
*
|
|
* `layout` is optional; when provided, the engine opens from it
|
|
* instead of the FIDE default. `profile` is optional; when provided
|
|
* the engine seeds modifier facts on piece entities and auto-
|
|
* activates the integration preset.
|
|
*/
|
|
create(
|
|
code: string,
|
|
rulesetIds?: readonly string[],
|
|
layout?: StartingLayout,
|
|
profile?: ModifierProfile,
|
|
): GameSession {
|
|
if (this.sessions.has(code)) {
|
|
throw new Error(
|
|
`GameSessionRegistry: session already exists for code "${code}"`,
|
|
);
|
|
}
|
|
const session = new GameSession(rulesetIds ?? [], layout, profile);
|
|
this.sessions.set(code, session);
|
|
return session;
|
|
}
|
|
|
|
get(code: string): GameSession | undefined {
|
|
return this.sessions.get(code);
|
|
}
|
|
|
|
/** Returns true iff a session existed and was removed. */
|
|
delete(code: string): boolean {
|
|
return this.sessions.delete(code);
|
|
}
|
|
|
|
/** Exposed for tests/diagnostics only. */
|
|
size(): number {
|
|
return this.sessions.size;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Compute the set difference between two fact arrays.
|
|
*
|
|
* Identity is defined by the (id, attr, value) triple serialised as a
|
|
* string key — two facts are "the same" iff all three components match.
|
|
* JSON.stringify is adequate because values are primitives (number,
|
|
* string, boolean, null) or plain squares; no functions or cycles ever
|
|
* enter working memory.
|
|
*/
|
|
export function diffFacts(
|
|
prev: readonly Fact[],
|
|
next: readonly Fact[],
|
|
): { inserted: Fact[]; retracted: Fact[] } {
|
|
const key = (f: Fact): string =>
|
|
`${String(f.id)}:${f.attr}:${JSON.stringify(f.value)}`;
|
|
const prevKeys = new Set(prev.map(key));
|
|
const nextKeys = new Set(next.map(key));
|
|
const inserted = next.filter((f) => !prevKeys.has(key(f)));
|
|
const retracted = prev.filter((f) => !nextKeys.has(key(f)));
|
|
return { inserted, retracted };
|
|
}
|
|
|
|
/**
|
|
* Translate the engine's GameResult into the wire-facing gameOver
|
|
* descriptor. `moverColor` is the side that just played the move —
|
|
* i.e. the winner in a checkmate. Returns null when the game continues.
|
|
*/
|
|
function mapGameResult(
|
|
result: GameResult,
|
|
moverColor: PieceColor,
|
|
): Extract<MoveResult, { ok: true }>["gameOver"] {
|
|
switch (result) {
|
|
case "ongoing":
|
|
return null;
|
|
case "checkmate":
|
|
return { winner: moverColor, reason: "checkmate" };
|
|
case "stalemate":
|
|
return { winner: "draw", reason: "stalemate" };
|
|
case "draw-50":
|
|
return { winner: "draw", reason: "50-move" };
|
|
case "draw-3fold":
|
|
return { winner: "draw", reason: "threefold" };
|
|
case "draw-insufficient":
|
|
return { winner: "draw", reason: "insufficient" };
|
|
// Preset-defined decisive results. The engine names the winner
|
|
// explicitly because variant rules don't always pair "winner"
|
|
// with "side to move" the way standard checkmate does.
|
|
case "white-wins":
|
|
return { winner: "white", reason: "variant-win" };
|
|
case "black-wins":
|
|
return { winner: "black", reason: "variant-win" };
|
|
default: {
|
|
// Exhaustiveness guard — if GameResult ever grows a variant, TS
|
|
// will flag this by failing the never-cast.
|
|
const _exhaustive: never = result;
|
|
throw new Error(
|
|
`mapGameResult: unreachable GameResult variant ${String(_exhaustive)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 };
|
|
}
|