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:
parent
aafc18ef9e
commit
f37c0934aa
6 changed files with 576 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paratype/chess": "workspace:*",
|
||||
"@paratype/rete": "workspace:*",
|
||||
"pino": "^9.0.0",
|
||||
"zod": "^3.23.0"
|
||||
|
|
|
|||
248
packages/server/src/game-session.test.ts
Normal file
248
packages/server/src/game-session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
289
packages/server/src/game-session.ts
Normal file
289
packages/server/src/game-session.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,5 +8,5 @@
|
|||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../rete" }]
|
||||
"references": [{ "path": "../rete" }, { "path": "../chess" }]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue