From f37c0934aab2e5fda1b6de12d6e9ed59ecd612d9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 17:17:42 -0600 Subject: [PATCH] feat(server): add authoritative game session per room (P4.5) Each room owns a ChessEngine wrapped in a GameSession; only the server calls insert/retract/fireRules and all EntityIds are minted server-side. GameSessionRegistry keys sessions by room code so two rooms cannot observe or collide with each other's working-memory state. GameSession.applyMove validates algebraic inputs, finds the matching legal move via ChessEngine.findMove, applies it, and returns a fact- level diff (inserted/retracted) plus the new turn and terminal state. Terminal states are sticky: further moves after checkmate/draw return GAME_OVER rather than silently mutating a dead session. Exposes @paratype/chess's headless surface (ChessEngine, coord helpers, schema types) via a new package entry point; the React app continues to import concrete modules directly. --- packages/chess/package.json | 6 + packages/chess/src/index.ts | 33 ++- packages/server/package.json | 1 + packages/server/src/game-session.test.ts | 248 +++++++++++++++++++ packages/server/src/game-session.ts | 289 +++++++++++++++++++++++ packages/server/tsconfig.json | 2 +- 6 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/game-session.test.ts create mode 100644 packages/server/src/game-session.ts diff --git a/packages/chess/package.json b/packages/chess/package.json index 74a5e61..f52fee1 100644 --- a/packages/chess/package.json +++ b/packages/chess/package.json @@ -2,6 +2,12 @@ "name": "@paratype/chess", "version": "0.1.0", "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index ad2049b..42013ee 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -1,2 +1,31 @@ -// @paratype/chess — browser chess game -export {}; +// @paratype/chess — public package entry. +// +// Re-exports the headless engine surface consumed by the authoritative +// WebSocket server (see @paratype/chess-server). The React app imports +// concrete modules directly; this barrel intentionally excludes anything +// that pulls in DOM/React so that server consumers stay pure Node/Bun. + +export { ChessEngine, type GameResult } from "./engine.js"; +export { + algebraicToSquare, + squareToAlgebraic, + fileOf, + rankOf, + squareOf, + isOnBoard, + isValidSquare, +} from "./coord.js"; +export { + GAME_ENTITY, + PROMOTION_PIECES, + oppositeColor, + chessFact, + type PieceType, + type PieceColor, + type Square, + type GameStatus, + type ChessAttrMap, + type ChessAttrKey, + type ChessFact, +} from "./schema.js"; +export type { LegalMove } from "./rules/types.js"; diff --git a/packages/server/package.json b/packages/server/package.json index 66b0832..41ae86d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@paratype/chess": "workspace:*", "@paratype/rete": "workspace:*", "pino": "^9.0.0", "zod": "^3.23.0" diff --git a/packages/server/src/game-session.test.ts b/packages/server/src/game-session.test.ts new file mode 100644 index 0000000..7a49e16 --- /dev/null +++ b/packages/server/src/game-session.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from "vitest"; +import { + GameSession, + GameSessionRegistry, + diffFacts, + type Fact, +} from "./game-session.js"; + +// --------------------------------------------------------------------------- +// Per-session isolation & ID authority +// --------------------------------------------------------------------------- + +describe("GameSession — isolation & ID authority", () => { + it("two sessions maintain independent state", () => { + const a = new GameSession(); + const b = new GameSession(); + + const moved = a.applyMove("e2", "e4"); + expect(moved.ok).toBe(true); + + // B must still be on white's turn with the pristine starting position. + expect(b.getTurn()).toBe("white"); + // And B's position attribute for e2 should still hold a pawn. + const bFacts = b.getAllFacts(); + const e2Pawn = bFacts.find( + (f) => f.attr === "Position" && f.value === 12, // e2 = file 4 + rank 1*8 + ); + expect(e2Pawn).toBeDefined(); + }); + + it("fact IDs are minted per session and do not collide with other sessions", () => { + const a = new GameSession(); + const b = new GameSession(); + + // Each session starts with the same set of entity IDs (0 for the + // game entity plus 32 pieces) because every ChessEngine mints IDs + // from a fresh counter. That's the *authoritative* property we want: + // no client-supplied IDs ever enter working memory, so two rooms + // cannot collide by accident. + const aIds = new Set(a.getAllFacts().map((f) => f.id)); + const bIds = new Set(b.getAllFacts().map((f) => f.id)); + expect(aIds.size).toBeGreaterThan(0); + expect(aIds).toEqual(bIds); + + const bCountBefore = b.getAllFacts().length; + + // Mutating A must not affect B. After e2→e4 the total fact count in + // A changes (new Position on e4, EnPassantTarget set, Turn flipped). + a.applyMove("e2", "e4"); + expect(b.getAllFacts().length).toBe(bCountBefore); + expect(a.getAllFacts().length).not.toBe(bCountBefore); + expect(b.getTurn()).toBe("white"); + }); + + it("getAllFacts returns a defensive copy (mutation does not leak)", () => { + const s = new GameSession(); + const before = s.getAllFacts(); + before.push({ id: 99999, attr: "Spoof", value: true }); + const after = s.getAllFacts(); + expect(after.find((f) => f.attr === "Spoof")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Move application +// --------------------------------------------------------------------------- + +describe("GameSession.applyMove", () => { + it("initial turn is white, black after one move", () => { + const s = new GameSession(); + expect(s.getTurn()).toBe("white"); + + const r = s.applyMove("e2", "e4"); + expect(r.ok).toBe(true); + expect(s.getTurn()).toBe("black"); + if (r.ok) expect(r.turn).toBe("black"); + }); + + it("legal move returns ok with non-empty inserted + retracted and no gameOver", () => { + const s = new GameSession(); + const r = s.applyMove("e2", "e4"); + expect(r.ok).toBe(true); + if (!r.ok) return; + // The pawn's Position fact was retracted (old e2=12) and inserted (new + // e4=28); Turn flipped from white→black so at minimum 2 inserts + 1 + // retract. We assert *non-empty* rather than exact counts to stay + // resilient to engine-internal attribute churn. + expect(r.inserted.length).toBeGreaterThan(0); + expect(r.retracted.length).toBeGreaterThan(0); + expect(r.gameOver).toBeNull(); + }); + + it("illegal move returns ok=false with ILLEGAL_MOVE", () => { + const s = new GameSession(); + // e2→e5 is two squares forward from the pawn's double-sprint square — + // actually e4 is legal; e5 is three ranks which is never legal. + const r = s.applyMove("e2", "e5"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("ILLEGAL_MOVE"); + // Session state is unchanged. + expect(s.getTurn()).toBe("white"); + }); + + it("malformed algebraic notation is rejected as ILLEGAL_MOVE", () => { + const s = new GameSession(); + const r = s.applyMove("z9", "a1"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("ILLEGAL_MOVE"); + }); + + it("detects Fool's Mate and reports checkmate with white/black winner", () => { + const s = new GameSession(); + // Fool's Mate (shortest possible checkmate, 2 full moves): + // 1. f2-f3 e7-e5 + // 2. g2-g4 d8-h4# + const m1 = s.applyMove("f2", "f3"); + expect(m1.ok).toBe(true); + const m2 = s.applyMove("e7", "e5"); + expect(m2.ok).toBe(true); + const m3 = s.applyMove("g2", "g4"); + expect(m3.ok).toBe(true); + const m4 = s.applyMove("d8", "h4"); + expect(m4.ok).toBe(true); + if (!m4.ok) return; + + expect(m4.gameOver).not.toBeNull(); + expect(m4.gameOver?.winner).toBe("black"); + expect(m4.gameOver?.reason).toBe("checkmate"); + expect(s.getGameOver()?.winner).toBe("black"); + }); + + it("refuses further moves after game is over", () => { + const s = new GameSession(); + s.applyMove("f2", "f3"); + s.applyMove("e7", "e5"); + s.applyMove("g2", "g4"); + const mate = s.applyMove("d8", "h4"); + expect(mate.ok).toBe(true); + + const after = s.applyMove("a2", "a3"); + expect(after.ok).toBe(false); + if (!after.ok) expect(after.error).toBe("GAME_OVER"); + }); + + it("promoteTo is forwarded to the engine for underpromotion", () => { + // Construct a simple promotion scenario by playing a quick line that + // reaches a pawn on the 7th rank. We can't easily set up arbitrary + // positions without PGN/FEN loading in P4.5, so we just assert the + // *error shape* when there is no promotion-legal move — the happy + // path is covered by @paratype/chess's own promotion tests. + const s = new GameSession(); + const r = s.applyMove("e2", "e4", "knight"); + // e4 is not a promotion square; the promoteTo hint is ignored and the + // move succeeds. + expect(r.ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// diffFacts — pure helper +// --------------------------------------------------------------------------- + +describe("diffFacts", () => { + const f = (id: number, attr: string, value: unknown): Fact => ({ + id, + attr, + value, + }); + + it("returns empty sets for identical inputs", () => { + const prev = [f(1, "Color", "white"), f(1, "Position", 12)]; + const { inserted, retracted } = diffFacts(prev, prev); + expect(inserted).toEqual([]); + expect(retracted).toEqual([]); + }); + + it("reports newly added facts as inserted", () => { + const prev = [f(1, "Color", "white")]; + const next = [f(1, "Color", "white"), f(1, "Position", 28)]; + const { inserted, retracted } = diffFacts(prev, next); + expect(inserted).toEqual([f(1, "Position", 28)]); + expect(retracted).toEqual([]); + }); + + it("reports removed facts as retracted", () => { + const prev = [f(1, "Color", "white"), f(1, "Position", 12)]; + const next = [f(1, "Color", "white")]; + const { inserted, retracted } = diffFacts(prev, next); + expect(inserted).toEqual([]); + expect(retracted).toEqual([f(1, "Position", 12)]); + }); + + it("treats value changes as retract+insert for the same (id, attr)", () => { + const prev = [f(1, "Position", 12)]; + const next = [f(1, "Position", 28)]; + const { inserted, retracted } = diffFacts(prev, next); + expect(inserted).toEqual([f(1, "Position", 28)]); + expect(retracted).toEqual([f(1, "Position", 12)]); + }); +}); + +// --------------------------------------------------------------------------- +// GameSessionRegistry +// --------------------------------------------------------------------------- + +describe("GameSessionRegistry", () => { + it("creates sessions keyed by room code", () => { + const reg = new GameSessionRegistry(); + const s = reg.create("ABC123"); + expect(reg.get("ABC123")).toBe(s); + expect(reg.size()).toBe(1); + }); + + it("throws on duplicate create for the same code", () => { + const reg = new GameSessionRegistry(); + reg.create("ABC123"); + expect(() => reg.create("ABC123")).toThrow(/already exists/); + }); + + it("get returns undefined for unknown codes", () => { + const reg = new GameSessionRegistry(); + expect(reg.get("NOPE00")).toBeUndefined(); + }); + + it("delete returns true only when a session existed", () => { + const reg = new GameSessionRegistry(); + reg.create("ABC123"); + expect(reg.delete("ABC123")).toBe(true); + expect(reg.delete("ABC123")).toBe(false); + expect(reg.size()).toBe(0); + }); + + it("sessions for different codes are fully independent", () => { + const reg = new GameSessionRegistry(); + const a = reg.create("AAAAAA"); + const b = reg.create("BBBBBB"); + a.applyMove("e2", "e4"); + expect(a.getTurn()).toBe("black"); + expect(b.getTurn()).toBe("white"); + }); + + it("passes rulesetIds through without throwing (v1: no-op)", () => { + const reg = new GameSessionRegistry(); + expect(() => + reg.create("ABC123", ["pawns-move-backward"]), + ).not.toThrow(); + }); +}); diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts new file mode 100644 index 0000000..1f2083f --- /dev/null +++ b/packages/server/src/game-session.ts @@ -0,0 +1,289 @@ +// 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 { + ChessEngine, + algebraicToSquare, + type GameResult, + type PieceColor, + type PieceType, +} from "@paratype/chess"; + +// --------------------------------------------------------------------------- +// 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"; + +// --------------------------------------------------------------------------- +// GameSession +// --------------------------------------------------------------------------- + +export class GameSession { + private readonly engine: ChessEngine; + /** + * 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["gameOver"] + > | null = null; + + /** + * @param _rulesetIds — activated preset IDs from room.create. v1: accepted + * for API shape but not yet wired to ChessEngine. Preset activation is + * tracked by PRESET_REGISTRY which is process-global today; per-room + * preset isolation is a follow-up (tracked in PROTOCOL.md). + */ + constructor(_rulesetIds: readonly string[] = []) { + this.engine = new ChessEngine(); + this.prevFacts = this.snapshotFacts(); + } + + /** 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["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(); + + /** + * 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). + */ + create(code: string, rulesetIds?: readonly string[]): GameSession { + if (this.sessions.has(code)) { + throw new Error( + `GameSessionRegistry: session already exists for code "${code}"`, + ); + } + const session = new GameSession(rulesetIds ?? []); + 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["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" }; + 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)}`, + ); + } + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 5f7e1a2..a722cd7 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -8,5 +8,5 @@ "rootDir": "src" }, "include": ["src/**/*"], - "references": [{ "path": "../rete" }] + "references": [{ "path": "../rete" }, { "path": "../chess" }] }