diff --git a/packages/chess/src/rules/sliding.test.ts b/packages/chess/src/rules/sliding.test.ts new file mode 100644 index 0000000..d801c3f --- /dev/null +++ b/packages/chess/src/rules/sliding.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + getLegalBishopMoves, + getLegalRookMoves, + getLegalQueenMoves, +} from "./sliding.js"; +import type { PieceColor, PieceType, Square } from "../schema.js"; + +function setupSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = id as EntityId; + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +// ─── Rook ──────────────────────────────────────────────────────────────────── + +describe("getLegalRookMoves", () => { + it("rook at a1 on empty board produces 14 moves (7 right + 7 up)", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + const moves = getLegalRookMoves(session, rook); + expect(moves).toHaveLength(14); + expect(moves.every(m => !m.isCapture)).toBe(true); + // along rank 1: b1..h1 + expect(moves.some(m => m.to === 1)).toBe(true); + expect(moves.some(m => m.to === 7)).toBe(true); + // along a-file: a2..a8 + expect(moves.some(m => m.to === 8)).toBe(true); + expect(moves.some(m => m.to === 56)).toBe(true); + }); + + it("rook at e4 (28) on empty board produces 14 moves", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 28); + const moves = getLegalRookMoves(session, rook); + expect(moves).toHaveLength(14); + }); + + it("rook stops before an allied piece (does not include it)", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + insertPiece(session, 2, "pawn", "white", 4); // e1 ally blocker + const rightMoves = getLegalRookMoves(session, rook) + .filter(m => m.to >= 1 && m.to <= 7); + expect(rightMoves.map(m => m.to)).toEqual([1, 2, 3]); // b1, c1, d1 + expect(rightMoves.every(m => !m.isCapture)).toBe(true); + }); + + it("rook captures an enemy piece and then stops", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + insertPiece(session, 2, "pawn", "black", 4); // e1 enemy + const rightMoves = getLegalRookMoves(session, rook) + .filter(m => m.to >= 1 && m.to <= 7); + expect(rightMoves.map(m => m.to)).toEqual([1, 2, 3, 4]); + expect(rightMoves.find(m => m.to === 4)?.isCapture).toBe(true); + expect(rightMoves.filter(m => m.to !== 4).every(m => !m.isCapture)).toBe(true); + // f1..h1 unreachable + expect(rightMoves.some(m => m.to === 5)).toBe(false); + expect(rightMoves.some(m => m.to === 6)).toBe(false); + expect(rightMoves.some(m => m.to === 7)).toBe(false); + }); + + it("rook boxed in by four allies produces zero moves", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 28); // e4 + insertPiece(session, 2, "pawn", "white", 29); // f4 right + insertPiece(session, 3, "pawn", "white", 27); // d4 left + insertPiece(session, 4, "pawn", "white", 36); // e5 up + insertPiece(session, 5, "pawn", "white", 20); // e3 down + expect(getLegalRookMoves(session, rook)).toHaveLength(0); + }); + + it("rook boxed in by four enemies produces four captures only", () => { + const session = setupSession(); + const rook = insertPiece(session, 1, "rook", "white", 28); // e4 + insertPiece(session, 2, "pawn", "black", 29); // f4 + insertPiece(session, 3, "pawn", "black", 27); // d4 + insertPiece(session, 4, "pawn", "black", 36); // e5 + insertPiece(session, 5, "pawn", "black", 20); // e3 + const moves = getLegalRookMoves(session, rook); + expect(moves).toHaveLength(4); + expect(moves.every(m => m.isCapture)).toBe(true); + expect(moves.map(m => m.to).sort((a, b) => a - b)).toEqual([20, 27, 29, 36]); + }); +}); + +// ─── Bishop ────────────────────────────────────────────────────────────────── + +describe("getLegalBishopMoves", () => { + it("bishop at a1 on empty board has exactly 7 moves along the a1-h8 diagonal", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); + const moves = getLegalBishopMoves(session, bishop); + expect(moves).toHaveLength(7); + expect(moves.map(m => m.to).sort((a, b) => a - b)) + .toEqual([9, 18, 27, 36, 45, 54, 63]); + expect(moves.every(m => !m.isCapture)).toBe(true); + }); + + it("bishop at e4 (28) on empty board has 13 moves across four diagonals", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 28); + const moves = getLegalBishopMoves(session, bishop); + // up-right: f5,g6,h7 = 3; down-right: f3,g2,h1 = 3 + // up-left: d5,c6,b7,a8 = 4; down-left: d3,c2,b1 = 3 + expect(moves).toHaveLength(13); + }); + + it("bishop stops before an allied piece on the diagonal", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + insertPiece(session, 2, "pawn", "white", 18); // c3 ally + const moves = getLegalBishopMoves(session, bishop); + expect(moves.map(m => m.to)).toContain(9); // b2 reachable + expect(moves.map(m => m.to)).not.toContain(18); // c3 ally blocks + expect(moves.map(m => m.to)).not.toContain(27); // d4 beyond + expect(moves.map(m => m.to)).not.toContain(36); // e5 beyond + }); + + it("bishop captures an enemy on the diagonal and then stops", () => { + const session = setupSession(); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + insertPiece(session, 2, "pawn", "black", 18); // c3 enemy + const moves = getLegalBishopMoves(session, bishop); + expect(moves.map(m => m.to)).toContain(9); // b2 + expect(moves.map(m => m.to)).toContain(18); // c3 captured + expect(moves.find(m => m.to === 18)?.isCapture).toBe(true); + expect(moves.map(m => m.to)).not.toContain(27); // d4 unreachable + }); +}); + +// ─── Queen ─────────────────────────────────────────────────────────────────── + +describe("getLegalQueenMoves", () => { + it("queen at e4 (28) on empty board has 27 moves", () => { + const session = setupSession(); + const queen = insertPiece(session, 1, "queen", "white", 28); + const moves = getLegalQueenMoves(session, queen); + expect(moves).toHaveLength(27); + expect(moves.every(m => !m.isCapture)).toBe(true); + }); + + it("queen at a1 on empty board has 21 moves (14 rook + 7 bishop)", () => { + const session = setupSession(); + const queen = insertPiece(session, 1, "queen", "white", 0); + const moves = getLegalQueenMoves(session, queen); + expect(moves).toHaveLength(21); + }); + + it("queen move set equals union of rook and bishop moves from the same square", () => { + const session = setupSession(); + const queen = insertPiece(session, 1, "queen", "white", 28); + const session2 = setupSession(); + const rook = insertPiece(session2, 1, "rook", "white", 28); + const session3 = setupSession(); + const bishop = insertPiece(session3, 1, "bishop", "white", 28); + const qTos = getLegalQueenMoves(session, queen).map(m => m.to).sort((a, b) => a - b); + const rTos = getLegalRookMoves(session2, rook).map(m => m.to); + const bTos = getLegalBishopMoves(session3, bishop).map(m => m.to); + const combined = [...rTos, ...bTos].sort((a, b) => a - b); + expect(qTos).toEqual(combined); + }); + + it("queen captures enemy and stops along a diagonal ray", () => { + const session = setupSession(); + const queen = insertPiece(session, 1, "queen", "white", 0); // a1 + insertPiece(session, 2, "pawn", "black", 18); // c3 enemy on diagonal + insertPiece(session, 3, "pawn", "black", 4); // e1 enemy on rank + const moves = getLegalQueenMoves(session, queen); + // Diagonal: b2 ok, c3 capture, no d4 + expect(moves.find(m => m.to === 9)?.isCapture).toBe(false); + expect(moves.find(m => m.to === 18)?.isCapture).toBe(true); + expect(moves.some(m => m.to === 27)).toBe(false); + // Rank: b1, c1, d1 ok, e1 capture, no f1 + expect(moves.find(m => m.to === 4)?.isCapture).toBe(true); + expect(moves.some(m => m.to === 5)).toBe(false); + }); +}); + +// ─── Degenerate inputs ─────────────────────────────────────────────────────── + +describe("sliding rules — degenerate inputs", () => { + it("returns [] for an unknown piece id (rook)", () => { + const session = setupSession(); + expect(getLegalRookMoves(session, 999 as EntityId)).toEqual([]); + }); + + it("returns [] for an unknown piece id (bishop)", () => { + const session = setupSession(); + expect(getLegalBishopMoves(session, 999 as EntityId)).toEqual([]); + }); + + it("returns [] for an unknown piece id (queen)", () => { + const session = setupSession(); + expect(getLegalQueenMoves(session, 999 as EntityId)).toEqual([]); + }); + + it("returns [] when piece has Position but no Color", () => { + const session = setupSession(); + const eid = 1 as EntityId; + session.insert(eid, "Position", 28); + expect(getLegalRookMoves(session, eid)).toEqual([]); + expect(getLegalBishopMoves(session, eid)).toEqual([]); + expect(getLegalQueenMoves(session, eid)).toEqual([]); + }); +}); diff --git a/packages/chess/src/rules/sliding.ts b/packages/chess/src/rules/sliding.ts new file mode 100644 index 0000000..b94b4e5 --- /dev/null +++ b/packages/chess/src/rules/sliding.ts @@ -0,0 +1,92 @@ +/** + * Sliding piece rules: bishop, rook, queen (P2.11). + * + * Each sliding piece walks along a set of rays emitted by the + * corresponding candidate helper in primitives.ts. For every ray we + * iterate squares from the piece outward and apply blocker logic: + * + * 1. Empty square → legal non-capture move, keep walking + * 2. Ally-occupied → stop immediately (square NOT included) + * 3. Enemy-occupied → legal capture, then stop + * + * Intentionally NOT handled here (see later phases): + * - check filtering → P2.18 + * - turn order → P2.13 + */ +import type { Session, EntityId } from "@paratype/rete"; +import { + rookCandidates, + bishopCandidates, + queenCandidates, +} from "./primitives.js"; +import type { LegalMove } from "./types.js"; +import { + getPiecePosition, + getPieceColor, + isAllyAt, + isEnemyAt, +} from "./board-queries.js"; +import type { PieceColor, Square } from "../schema.js"; + +/** + * Walk each ray in `rays` from the piece outward, emitting legal moves + * until a blocker is hit. + */ +function getSlidingMoves( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + rays: Square[][], +): LegalMove[] { + const moves: LegalMove[] = []; + for (const ray of rays) { + for (const to of ray) { + if (isAllyAt(session, to, color)) { + // Own piece blocks — do not include this square, stop the ray. + break; + } + if (isEnemyAt(session, to, color)) { + // Enemy piece — legal capture, then stop. + moves.push({ pieceId, from, to, isCapture: true }); + break; + } + // Empty square — legal non-capture move, keep walking. + moves.push({ pieceId, from, to, isCapture: false }); + } + } + return moves; +} + +/** Get all legal bishop moves for the given bishop entity. */ +export function getLegalBishopMoves( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + return getSlidingMoves(session, pieceId, from, color, bishopCandidates(from)); +} + +/** Get all legal rook moves for the given rook entity. */ +export function getLegalRookMoves( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + return getSlidingMoves(session, pieceId, from, color, rookCandidates(from)); +} + +/** Get all legal queen moves for the given queen entity. */ +export function getLegalQueenMoves( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + return getSlidingMoves(session, pieceId, from, color, queenCandidates(from)); +}