diff --git a/packages/chess/src/rules/turn.test.ts b/packages/chess/src/rules/turn.test.ts new file mode 100644 index 0000000..3f445a6 --- /dev/null +++ b/packages/chess/src/rules/turn.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/rules/turn.ts b/packages/chess/src/rules/turn.ts new file mode 100644 index 0000000..dc0263c --- /dev/null +++ b/packages/chess/src/rules/turn.ts @@ -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 retract→insert + * 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(); + +/** 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); + } +}