houserules/packages/server/src/game-session.ts
Joey Yakimowich-Payne 0bd65e0a73
feat(server): turn-boundary queue for modifier profile updates
Replace T1 immediate-apply semantics with a single-slot pending
queue (T2-ADR-1). On `modifier-profile.update` receipt the server
validates shape + layout legality, stashes the profile on
`Room.pendingProfile` with the proposer's token, and acks the
sender with a new `modifier-profile.queued` message. The actual
`reconcileProfileSwap` + version bump + `modifier-profile.updated`
broadcast now runs in `applyPendingProfileIfAny` after the next
successful `applyMove` — either player's move triggers it.

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

Tests updated: 7 scenarios covering queued ACK, deferred apply,
last-write-wins, opponent-move-drains-queue, and all original
rejection paths. 1220 unit tests + 18 modifier Playwright tests
green (e2e specs never used `modifier-profile.update` at
runtime so were unaffected).
2026-04-19 09:06:15 -06:00

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 };
}