feat(chess): add turn order + move integration (P2.13)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:54:30 -06:00
commit d9eaeb2f06
No known key found for this signature in database
2 changed files with 434 additions and 0 deletions

View file

@ -0,0 +1,257 @@
/**
* Tests for turn.ts (P2.13).
*
* We register a minimal inline pawn generator so the tests do not depend
* on pawn.ts being fully wired up at module-load time (parallel-task
* robustness). Each describe/it block that needs the registry calls
* {@link clearMoveGeneratorRegistry} first to avoid cross-test pollution.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import {
registerMoveGenerator,
clearMoveGeneratorRegistry,
getLegalMovesForColor,
getLegalMovesForCurrentTurn,
applyMove,
isLegalMove,
} from "./turn.js";
import { generateStartingPosition } from "../starting-position.js";
import { GAME_ENTITY, type PieceColor, type PieceType } from "../schema.js";
import type { LegalMove } from "./types.js";
const mkId = (n: number) => n as EntityId;
/** Minimal single-advance pawn generator — sufficient for these tests. */
function registerStubPawnGenerator(): void {
registerMoveGenerator("pawn", (session, pieceId) => {
const fromRaw = session.get(pieceId, "Position");
const colorRaw = session.get(pieceId, "Color");
if (fromRaw === undefined || colorRaw === undefined) return [];
const from = fromRaw as number;
const color = colorRaw as PieceColor;
const to = color === "white" ? from + 8 : from - 8;
if (to < 0 || to > 63) return [];
// Occupancy check.
const facts = session.allFacts();
const occupantPos = facts.find(
(f) => f.attr === "Position" && f.value === to,
);
if (occupantPos === undefined) {
return [{ pieceId, from, to, isCapture: false }];
}
const occupantColor = session.get(occupantPos.id, "Color");
if (occupantColor === color) return []; // blocked by ally
return [{ pieceId, from, to, isCapture: true }];
});
}
function insertPiece(
session: Session,
id: number,
type: PieceType,
color: PieceColor,
square: number,
): EntityId {
const eid = mkId(id);
session.insert(eid, "PieceType", type);
session.insert(eid, "Color", color);
session.insert(eid, "Position", square);
session.insert(eid, "HasMoved", false);
return eid;
}
// ─── applyMove ───────────────────────────────────────────────────────────────
describe("applyMove", () => {
it("updates piece position and flips turn white→black", () => {
const session = new Session({ autoFire: false });
generateStartingPosition(session);
// e2 pawn: square 12, white. Starting-position insertion assigns
// incremental ids — find by Position/Color rather than hard-coding.
const facts = session.allFacts();
const e2PawnFact = facts.find(
(f) =>
f.attr === "Position" &&
f.value === 12 &&
session.get(f.id, "Color") === "white",
);
expect(e2PawnFact).toBeDefined();
const e2Pawn = e2PawnFact!.id;
const move: LegalMove = { pieceId: e2Pawn, from: 12, to: 20, isCapture: false };
applyMove(session, move);
expect(session.get(e2Pawn, "Position")).toBe(20);
expect(session.get(GAME_ENTITY, "Turn")).toBe("black");
expect(session.get(e2Pawn, "HasMoved")).toBe(true);
});
it("capture retracts all attrs of the enemy piece at destination", () => {
const session = new Session({ autoFire: false });
const wp = insertPiece(session, 1, "pawn", "white", 28); // e4
const bp = insertPiece(session, 2, "pawn", "black", 35); // d5
session.insert(GAME_ENTITY, "Turn", "white");
applyMove(session, { pieceId: wp, from: 28, to: 35, isCapture: true });
expect(session.get(wp, "Position")).toBe(35);
expect(session.contains(bp, "Position")).toBe(false);
expect(session.contains(bp, "Color")).toBe(false);
expect(session.contains(bp, "PieceType")).toBe(false);
expect(session.contains(bp, "HasMoved")).toBe(false);
});
it("HasMoved flag becomes true after any move (king example)", () => {
const session = new Session({ autoFire: false });
const king = insertPiece(session, 1, "king", "white", 4); // e1
session.insert(GAME_ENTITY, "Turn", "white");
applyMove(session, { pieceId: king, from: 4, to: 5, isCapture: false });
expect(session.get(king, "HasMoved")).toBe(true);
});
it("increments HalfmoveClock on every move", () => {
const session = new Session({ autoFire: false });
insertPiece(session, 1, "knight", "white", 1);
session.insert(GAME_ENTITY, "Turn", "white");
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
applyMove(session, {
pieceId: mkId(1),
from: 1,
to: 18,
isCapture: false,
});
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(1);
});
it("increments FullmoveNumber after black's move, not after white's", () => {
const session = new Session({ autoFire: false });
insertPiece(session, 1, "knight", "white", 1);
insertPiece(session, 2, "knight", "black", 57);
session.insert(GAME_ENTITY, "Turn", "white");
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
applyMove(session, { pieceId: mkId(1), from: 1, to: 18, isCapture: false });
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1); // still 1 after white
expect(session.get(GAME_ENTITY, "Turn")).toBe("black");
applyMove(session, { pieceId: mkId(2), from: 57, to: 42, isCapture: false });
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2); // bumped after black
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
});
});
// ─── getLegalMovesForColor / CurrentTurn ─────────────────────────────────────
describe("getLegalMovesForColor + getLegalMovesForCurrentTurn", () => {
beforeEach(() => {
clearMoveGeneratorRegistry();
registerStubPawnGenerator();
});
it("returns only moves for pieces of the requested color", () => {
const session = new Session({ autoFire: false });
generateStartingPosition(session);
const white = getLegalMovesForColor(session, "white");
const black = getLegalMovesForColor(session, "black");
// With the stub single-advance pawn gen, each side has 8 pawn moves
// from the starting position (knights/bishops/etc. have no registered
// generator, so they silently contribute zero).
expect(white.length).toBe(8);
expect(black.length).toBe(8);
for (const m of white) {
expect(session.get(m.pieceId, "Color")).toBe("white");
}
for (const m of black) {
expect(session.get(m.pieceId, "Color")).toBe("black");
}
});
it("getLegalMovesForCurrentTurn respects the Turn fact", () => {
const session = new Session({ autoFire: false });
generateStartingPosition(session);
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
const firstTurn = getLegalMovesForCurrentTurn(session);
expect(firstTurn.length).toBe(8);
for (const m of firstTurn) {
expect(session.get(m.pieceId, "Color")).toBe("white");
}
session.insert(GAME_ENTITY, "Turn", "black");
const secondTurn = getLegalMovesForCurrentTurn(session);
for (const m of secondTurn) {
expect(session.get(m.pieceId, "Color")).toBe("black");
}
});
it("pieces without a registered generator contribute zero moves", () => {
const session = new Session({ autoFire: false });
insertPiece(session, 1, "knight", "white", 1); // no knight generator registered
session.insert(GAME_ENTITY, "Turn", "white");
const moves = getLegalMovesForColor(session, "white");
expect(moves).toEqual([]);
});
it("ignores game-level facts (GAME_ENTITY=0)", () => {
const session = new Session({ autoFire: false });
// Only game-level facts, no pieces.
session.insert(GAME_ENTITY, "Turn", "white");
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
const moves = getLegalMovesForColor(session, "white");
expect(moves).toEqual([]);
});
});
// ─── isLegalMove ─────────────────────────────────────────────────────────────
describe("isLegalMove", () => {
beforeEach(() => {
clearMoveGeneratorRegistry();
registerStubPawnGenerator();
});
it("accepts a single-advance pawn move from the starting position", () => {
const session = new Session({ autoFire: false });
const p = insertPiece(session, 1, "pawn", "white", 12); // e2
session.insert(GAME_ENTITY, "Turn", "white");
expect(
isLegalMove(session, { pieceId: p, from: 12, to: 20, isCapture: false }),
).toBe(true);
});
it("rejects a move to a square the piece cannot reach", () => {
const session = new Session({ autoFire: false });
const p = insertPiece(session, 1, "pawn", "white", 12);
session.insert(GAME_ENTITY, "Turn", "white");
// Stub only generates single-advance; 28 (double advance) is NOT legal.
expect(
isLegalMove(session, { pieceId: p, from: 12, to: 28, isCapture: false }),
).toBe(false);
});
it("rejects a move whose pieceId has no Color in WM", () => {
const session = new Session({ autoFire: false });
session.insert(GAME_ENTITY, "Turn", "white");
expect(
isLegalMove(session, {
pieceId: mkId(99),
from: 0,
to: 8,
isCapture: false,
}),
).toBe(false);
});
});

View file

@ -0,0 +1,177 @@
/**
* Turn order + move legality integration (P2.13).
*
* Wires per-piece move generators (pawn.ts, knight.ts, sliding.ts, king.ts)
* into a single entry point via a registry, and implements the move-
* application side-effect (Position update, Turn flip, clock bookkeeping,
* capture handling).
*
* Design notes
*
* - We use a *registry* of GetLegalMovesForPiece functions rather than
* direct imports so that:
* (a) turn.ts has zero compile-time dependency on individual piece
* modules (they may not yet exist during parallel task execution);
* (b) custom rule presets (RULES.md) can swap a generator in e.g. a
* "knight-rider" ruleset replaces the knight entry;
* (c) tests can register minimal stubs (see turn.test.ts).
*
* - `applyMove` treats `session.insert` as *upsert* (see wm.ts): inserting
* `Position` again overwrites the previous value and fires retractinsert
* listeners, so rules downstream can observe the transition.
*
* - Capture semantics: we retract ALL known piece-level attrs of the
* captured entity (PieceType, Color, Position, HasMoved, Hp). We do NOT
* touch the EntityId itself once minted, ids are never reused
* (SPEC.md §ID Authority).
*
* Explicitly NOT handled here (deferred):
* - castling P2.15
* - en passant P2.16
* - promotion choice P2.17
* - check filtering P2.18
*/
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType } from "../schema.js";
import { GAME_ENTITY } from "../schema.js";
import type { LegalMove } from "./types.js";
import { getPieceColor, getPieceAt } from "./board-queries.js";
/** Signature for a per-piece-type legal-move generator. */
export type GetLegalMovesForPiece = (
session: Session,
pieceId: EntityId,
) => LegalMove[];
/**
* Registry of move generators keyed by PieceType.
*
* Populated via {@link registerMoveGenerator}. Missing entries cause the
* corresponding pieces to contribute zero moves (silently) this is
* intentional so that turn.ts remains usable while peer piece modules are
* still being written in parallel.
*/
const moveGeneratorRegistry = new Map<PieceType, GetLegalMovesForPiece>();
/** Register (or replace) the move generator for a given PieceType. */
export function registerMoveGenerator(
type: PieceType,
fn: GetLegalMovesForPiece,
): void {
moveGeneratorRegistry.set(type, fn);
}
/** Remove all registered generators. Primarily for test isolation. */
export function clearMoveGeneratorRegistry(): void {
moveGeneratorRegistry.clear();
}
/** Piece-level attributes retracted when an entity is captured. */
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
/**
* Get every legal move available to pieces of `color` given the current
* board state. Pieces whose type has no registered generator contribute
* nothing (see module docstring).
*/
export function getLegalMovesForColor(
session: Session,
color: PieceColor,
): LegalMove[] {
const facts = session.allFacts();
const moves: LegalMove[] = [];
for (const f of facts) {
// Skip game-level facts (GAME_ENTITY = 0) and non-type facts.
if (f.attr !== "PieceType" || (f.id as number) <= 0) continue;
const pieceId = f.id;
// Color lookup: direct session.get is O(1) — cheaper than another
// facts.find() scan inside the outer loop.
const pieceColor = session.get(pieceId, "Color");
if (pieceColor !== color) continue;
const generator = moveGeneratorRegistry.get(f.value as PieceType);
if (generator === undefined) continue;
moves.push(...generator(session, pieceId));
}
return moves;
}
/** Get all legal moves for the color whose turn it currently is. */
export function getLegalMovesForCurrentTurn(session: Session): LegalMove[] {
const turn = session.get(GAME_ENTITY, "Turn");
const color: PieceColor = (turn ?? "white") as PieceColor;
return getLegalMovesForColor(session, color);
}
/**
* Is `move` a currently-legal move?
*
* A move is legal iff it appears in the legal-move list for the mover's
* color. We look up the mover's color from WM rather than trusting the
* caller this prevents cross-color "turn jumping" via forged moves.
*/
export function isLegalMove(session: Session, move: LegalMove): boolean {
const color = getPieceColor(session, move.pieceId);
if (color === null) return false;
const legal = getLegalMovesForColor(session, color);
return legal.some(
(m) =>
m.pieceId === move.pieceId && m.from === move.from && m.to === move.to,
);
}
/**
* Apply `move` to `session` in-place.
*
* 1. If `isCapture`, retract all piece-level attrs of the victim at `to`.
* 2. Update the mover's Position (upsert) and set HasMoved=true.
* 3. Flip Turn.
* 4. Increment HalfmoveClock (simplified pawn-move / capture reset is
* handled at the rule layer in a later phase).
* 5. Increment FullmoveNumber after black's move (FIDE convention).
*
* Does NOT validate legality pair with {@link isLegalMove} at the
* boundary (e.g. network handler) before calling.
*/
export function applyMove(session: Session, move: LegalMove): void {
// ─── 1. Capture ──────────────────────────────────────────────────────────
if (move.isCapture) {
const capturedId = getPieceAt(session, move.to);
if (capturedId !== null && capturedId !== move.pieceId) {
for (const attr of PIECE_ATTRS) {
if (session.contains(capturedId, attr)) {
session.retract(capturedId, attr);
}
}
}
}
// ─── 2. Move ─────────────────────────────────────────────────────────────
session.insert(move.pieceId, "Position", move.to);
session.insert(move.pieceId, "HasMoved", true);
// ─── 3/4/5. Game-level bookkeeping ───────────────────────────────────────
const currentTurn = (session.get(GAME_ENTITY, "Turn") ?? "white") as PieceColor;
const nextTurn: PieceColor = currentTurn === "white" ? "black" : "white";
session.insert(GAME_ENTITY, "Turn", nextTurn);
const halfmove =
((session.get(GAME_ENTITY, "HalfmoveClock") as number | undefined) ?? 0) + 1;
session.insert(GAME_ENTITY, "HalfmoveClock", halfmove);
if (currentTurn === "black") {
const fullmove =
((session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1) + 1;
session.insert(GAME_ENTITY, "FullmoveNumber", fullmove);
}
}