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.
248 lines
8.9 KiB
TypeScript
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();
|
|
});
|
|
});
|