diff --git a/packages/chess/src/rules/primitives.test.ts b/packages/chess/src/rules/primitives.test.ts new file mode 100644 index 0000000..3968996 --- /dev/null +++ b/packages/chess/src/rules/primitives.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + rookCandidates, bishopCandidates, queenCandidates, + knightCandidates, kingCandidates, + pawnSingleAdvance, pawnDoubleAdvance, pawnCaptureSqares, +} from "./primitives.js"; + +describe("rookCandidates", () => { + it("rook at a1 (0): right ray has 7 squares, others are bounded", () => { + const rays = rookCandidates(0); + expect(rays).toHaveLength(4); + expect(rays[0]).toHaveLength(7); // right: b1-h1 + expect(rays[1]).toHaveLength(0); // left: nothing (a-file) + expect(rays[2]).toHaveLength(7); // up: a2-a8 + expect(rays[3]).toHaveLength(0); // down: nothing (rank1) + }); + it("rook at e4 (28): all 4 rays non-empty", () => { + const rays = rookCandidates(28); + expect(rays.every(r => r.length > 0)).toBe(true); + }); +}); + +describe("bishopCandidates", () => { + it("bishop at a1 (0): only one diagonal (up-right)", () => { + const rays = bishopCandidates(0); + expect(rays[0]).toHaveLength(7); // up-right diagonal: b2-h8 + expect(rays[1]).toHaveLength(0); // down-right: nothing + expect(rays[2]).toHaveLength(0); // up-left: nothing + expect(rays[3]).toHaveLength(0); // down-left: nothing + }); +}); + +describe("queenCandidates", () => { + it("queen at e4 (28): 8 rays total", () => { + const rays = queenCandidates(28); + expect(rays).toHaveLength(8); + }); +}); + +describe("knightCandidates", () => { + it("knight at e4 (28) has 8 moves", () => { + expect(knightCandidates(28)).toHaveLength(8); + }); + it("knight at a1 (0) has 2 moves", () => { + expect(knightCandidates(0)).toHaveLength(2); + }); +}); + +describe("kingCandidates", () => { + it("king at e4 (28) has 8 moves", () => { + expect(kingCandidates(28)).toHaveLength(8); + }); + it("king at a1 (0) has 3 moves", () => { + expect(kingCandidates(0)).toHaveLength(3); + }); +}); + +describe("pawnSingleAdvance", () => { + it("white pawn at e2 (12) advances to e3 (20)", () => { + expect(pawnSingleAdvance(12, "white")).toBe(20); + }); + it("black pawn at e7 (52) advances to e6 (44)", () => { + expect(pawnSingleAdvance(52, "black")).toBe(44); + }); + it("white pawn at rank8 returns null", () => { + expect(pawnSingleAdvance(60, "white")).toBeNull(); + }); +}); + +describe("pawnDoubleAdvance", () => { + it("white pawn at e2 (12) can double-advance to e4 (28)", () => { + expect(pawnDoubleAdvance(12, "white")).toBe(28); + }); + it("white pawn at e3 (20) cannot double-advance", () => { + expect(pawnDoubleAdvance(20, "white")).toBeNull(); + }); + it("black pawn at e7 (52) can double-advance to e5 (36)", () => { + expect(pawnDoubleAdvance(52, "black")).toBe(36); + }); +}); + +describe("pawnCaptureSquares", () => { + it("white pawn at e4 (28) can capture on d5 (35) and f5 (37)", () => { + const sq = pawnCaptureSqares(28, "white"); + expect(sq).toContain(35); // d5 + expect(sq).toContain(37); // f5 + expect(sq).toHaveLength(2); + }); + it("white pawn at a4 (24) can only capture right (b5=33)", () => { + const sq = pawnCaptureSqares(24, "white"); + expect(sq).toHaveLength(1); + expect(sq[0]).toBe(33); + }); +}); diff --git a/packages/chess/src/rules/primitives.ts b/packages/chess/src/rules/primitives.ts new file mode 100644 index 0000000..08a8709 --- /dev/null +++ b/packages/chess/src/rules/primitives.ts @@ -0,0 +1,104 @@ +/** + * Movement primitive rules for @paratype/chess. + * Each primitive computes candidate target squares for a piece type. + * These are used by higher-level rule files (pawn.ts, knight.ts, etc.) + * + * NOTE: These are CANDIDATE moves only — they do not check: + * - Whether target squares are occupied (capture/blocking handled in P2.9+) + * - Whether the move would leave the king in check (P2.18) + * - Turn order (P2.13) + */ +import type { PieceColor, Square } from "../schema.js"; +import { + slidingSquares, + knightSquares as knightTargets, + kingSquares as kingTargets, + rankOf, + fileOf, +} from "../coord.js"; + +export interface CandidateMove { + readonly pieceId: number; + readonly from: Square; + readonly to: Square; +} + +// ─── Sliding piece helpers ─────────────────────────────────────────────────── + +/** All candidate squares for a rook (4 orthogonal rays). */ +export function rookCandidates(from: Square): Square[][] { + return [ + slidingSquares(from, 1, 0), // right + slidingSquares(from, -1, 0), // left + slidingSquares(from, 0, 1), // up + slidingSquares(from, 0, -1), // down + ]; +} + +/** All candidate squares for a bishop (4 diagonal rays). */ +export function bishopCandidates(from: Square): Square[][] { + return [ + slidingSquares(from, 1, 1), // up-right + slidingSquares(from, 1, -1), // down-right + slidingSquares(from, -1, 1), // up-left + slidingSquares(from, -1, -1), // down-left + ]; +} + +/** All candidate squares for a queen (8 rays). */ +export function queenCandidates(from: Square): Square[][] { + return [...rookCandidates(from), ...bishopCandidates(from)]; +} + +// ─── Leap piece helpers ────────────────────────────────────────────────────── + +/** Knight candidate squares (already filters off-board). */ +export function knightCandidates(from: Square): Square[] { + return knightTargets(from); +} + +/** King candidate squares (already filters off-board). */ +export function kingCandidates(from: Square): Square[] { + return kingTargets(from); +} + +// ─── Pawn helpers ──────────────────────────────────────────────────────────── + +/** + * Pawn single-advance target (no capture). + * Returns null if pawn is on the back rank (invalid). + */ +export function pawnSingleAdvance(from: Square, color: PieceColor): Square | null { + const rank = rankOf(from); + const targetRank = color === "white" ? rank + 1 : rank - 1; + if (targetRank < 0 || targetRank > 7) return null; + return (from + (color === "white" ? 8 : -8)) as Square; +} + +/** + * Pawn double-advance target (from home rank only). + * White home rank = rank 1 (squares 8-15) + * Black home rank = rank 6 (squares 48-55) + */ +export function pawnDoubleAdvance(from: Square, color: PieceColor): Square | null { + const rank = rankOf(from); + const homeRank = color === "white" ? 1 : 6; + if (rank !== homeRank) return null; + return (from + (color === "white" ? 16 : -16)) as Square; +} + +/** + * Pawn diagonal capture squares (1 or 2, within board bounds). + * Pawns capture diagonally forward — this returns the squares, not whether + * an enemy occupies them (capture validation is in P2.9). + */ +export function pawnCaptureSqares(from: Square, color: PieceColor): Square[] { + const rank = rankOf(from); + const targetRank = color === "white" ? rank + 1 : rank - 1; + if (targetRank < 0 || targetRank > 7) return []; + const file = fileOf(from); + const squares: Square[] = []; + if (file > 0) squares.push((from + (color === "white" ? 7 : -9)) as Square); // left diagonal + if (file < 7) squares.push((from + (color === "white" ? 9 : -7)) as Square); // right diagonal + return squares; +}