houserules/packages/server/src/game-session.test.ts
Joey Yakimowich-Payne f37c0934aa
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.
2026-04-16 17:17:42 -06:00

248 lines
8.9 KiB
TypeScript

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