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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 17:17:42 -06:00
commit f37c0934aa
No known key found for this signature in database
6 changed files with 576 additions and 3 deletions

View file

@ -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",

View file

@ -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";

View file

@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paratype/chess": "workspace:*",
"@paratype/rete": "workspace:*",
"pino": "^9.0.0",
"zod": "^3.23.0"

View file

@ -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();
});
});

View file

@ -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<MoveResult, { ok: true }>["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<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).
*/
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<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" };
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)}`,
);
}
}
}

View file

@ -8,5 +8,5 @@
"rootDir": "src"
},
"include": ["src/**/*"],
"references": [{ "path": "../rete" }]
"references": [{ "path": "../rete" }, { "path": "../chess" }]
}