diff --git a/packages/chess/src/rules/board-queries.ts b/packages/chess/src/rules/board-queries.ts new file mode 100644 index 0000000..fd55a39 --- /dev/null +++ b/packages/chess/src/rules/board-queries.ts @@ -0,0 +1,80 @@ +/** + * Helper functions to query board state from a Session. + * + * All queries walk `session.allFacts()` (deterministic, sorted order). + * These helpers are read-only and have no side effects. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceColor, Square } from "../schema.js"; + +/** A {pieceId, position, color} triple projected from the working memory. */ +export interface BoardPiece { + readonly id: EntityId; + readonly position: Square; + readonly color: PieceColor; +} + +/** Get all pieces on the board with known Position + Color. */ +export function getBoardPieces(session: Session): BoardPiece[] { + const facts = session.allFacts(); + const result: BoardPiece[] = []; + for (const f of facts) { + if (f.attr !== "Position") continue; + const colorFact = facts.find(c => c.id === f.id && c.attr === "Color"); + if (colorFact === undefined) continue; + result.push({ + id: f.id, + position: f.value as Square, + color: colorFact.value as PieceColor, + }); + } + return result; +} + +/** Is any piece occupying the given square? */ +export function isPieceAt(session: Session, square: Square): boolean { + const facts = session.allFacts(); + for (const f of facts) { + if (f.attr === "Position" && f.value === square) return true; + } + return false; +} + +/** Is an enemy piece (color !== myColor) occupying the given square? */ +export function isEnemyAt(session: Session, square: Square, myColor: PieceColor): boolean { + const facts = session.allFacts(); + const positionFact = facts.find(f => f.attr === "Position" && f.value === square); + if (positionFact === undefined) return false; + const colorFact = facts.find(f => f.id === positionFact.id && f.attr === "Color"); + if (colorFact === undefined) return false; + return colorFact.value !== myColor; +} + +/** Is an ally piece (color === myColor) occupying the given square? */ +export function isAllyAt(session: Session, square: Square, myColor: PieceColor): boolean { + const facts = session.allFacts(); + const positionFact = facts.find(f => f.attr === "Position" && f.value === square); + if (positionFact === undefined) return false; + const colorFact = facts.find(f => f.id === positionFact.id && f.attr === "Color"); + if (colorFact === undefined) return false; + return colorFact.value === myColor; +} + +/** Get a piece's current position. Returns null if not found. */ +export function getPiecePosition(session: Session, pieceId: EntityId): Square | null { + const value = session.get(pieceId, "Position"); + return value === undefined ? null : (value as Square); +} + +/** Get a piece's color. Returns null if not found. */ +export function getPieceColor(session: Session, pieceId: EntityId): PieceColor | null { + const value = session.get(pieceId, "Color"); + return value === undefined ? null : (value as PieceColor); +} + +/** Get the piece entity ID at a given square (or null). */ +export function getPieceAt(session: Session, square: Square): EntityId | null { + const facts = session.allFacts(); + const positionFact = facts.find(f => f.attr === "Position" && f.value === square); + return positionFact === undefined ? null : positionFact.id; +} diff --git a/packages/chess/src/rules/pawn.test.ts b/packages/chess/src/rules/pawn.test.ts new file mode 100644 index 0000000..4a97952 --- /dev/null +++ b/packages/chess/src/rules/pawn.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { getLegalPawnMoves } from "./pawn.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; +} + +// ─── White pawn ────────────────────────────────────────────────────────────── + +describe("getLegalPawnMoves — white pawn", () => { + it("pawn at e2 (12) can move to e3 (20) and e4 (28)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 12); // e2 + const moves = getLegalPawnMoves(session, pawn); + const tos = moves.map(m => m.to).sort((a, b) => a - b); + expect(tos).toEqual([20, 28]); + expect(moves.every(m => !m.isCapture)).toBe(true); + expect(moves).toHaveLength(2); + }); + + it("pawn at e3 (20) can only move to e4 (28) — no double advance off home rank", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 20); // e3 + const moves = getLegalPawnMoves(session, pawn); + expect(moves.map(m => m.to)).toEqual([28]); + }); + + it("pawn at e2 blocked on e3 has zero moves (no jumping)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 12); // e2 + insertPiece(session, 2, "rook", "black", 20); // e3 blocker + const moves = getLegalPawnMoves(session, pawn); + expect(moves).toHaveLength(0); + }); + + it("pawn at e2 with e3 clear but e4 blocked: only single advance allowed", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 12); // e2 + insertPiece(session, 2, "rook", "black", 28); // e4 blocker + const moves = getLegalPawnMoves(session, pawn); + expect(moves.map(m => m.to)).toEqual([20]); + }); + + it("pawn can capture diagonally when enemy present on both diagonals", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + insertPiece(session, 2, "pawn", "black", 35); // d5 + insertPiece(session, 3, "pawn", "black", 37); // f5 + const moves = getLegalPawnMoves(session, pawn); + const captures = moves.filter(m => m.isCapture); + expect(captures).toHaveLength(2); + expect(captures.map(m => m.to).sort((a, b) => a - b)).toEqual([35, 37]); + // forward push to e5 (36) still allowed — no blocker + expect(moves.some(m => m.to === 36 && !m.isCapture)).toBe(true); + }); + + it("pawn cannot capture diagonally without an enemy", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + const moves = getLegalPawnMoves(session, pawn); + expect(moves.filter(m => m.isCapture)).toHaveLength(0); + }); + + it("pawn cannot capture own piece", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 28); // e4 + insertPiece(session, 2, "knight", "white", 35); // ally at d5 + const moves = getLegalPawnMoves(session, pawn); + expect(moves.filter(m => m.to === 35)).toHaveLength(0); + }); + + it("edge-file pawn has only one capture diagonal (a-file)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 24); // a4 + insertPiece(session, 2, "pawn", "black", 33); // b5 + const moves = getLegalPawnMoves(session, pawn); + const captures = moves.filter(m => m.isCapture); + expect(captures).toHaveLength(1); + expect(captures[0]!.to).toBe(33); + }); + + it("edge-file pawn has only one capture diagonal (h-file)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 31); // h4 + insertPiece(session, 2, "pawn", "black", 38); // g5 + const moves = getLegalPawnMoves(session, pawn); + const captures = moves.filter(m => m.isCapture); + expect(captures).toHaveLength(1); + expect(captures[0]!.to).toBe(38); + }); + + it("returns LegalMove objects carrying pieceId and from square", () => { + const session = setupSession(); + const pawn = insertPiece(session, 7, "pawn", "white", 12); // e2 + const moves = getLegalPawnMoves(session, pawn); + for (const m of moves) { + expect(m.pieceId).toBe(pawn); + expect(m.from).toBe(12); + } + }); +}); + +// ─── Black pawn ────────────────────────────────────────────────────────────── + +describe("getLegalPawnMoves — black pawn", () => { + it("black pawn at e7 (52) can move to e6 (44) and e5 (36)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 52); // e7 + const moves = getLegalPawnMoves(session, pawn); + const tos = moves.map(m => m.to).sort((a, b) => a - b); + expect(tos).toEqual([36, 44]); + expect(moves.every(m => !m.isCapture)).toBe(true); + }); + + it("black pawn at e6 (44) has only single advance to e5 (36)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 44); + const moves = getLegalPawnMoves(session, pawn); + expect(moves.map(m => m.to)).toEqual([36]); + }); + + it("black pawn can capture diagonally (moves down the board)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 36); // e5 + insertPiece(session, 2, "pawn", "white", 27); // d4 + insertPiece(session, 3, "pawn", "white", 29); // f4 + const moves = getLegalPawnMoves(session, pawn); + const captures = moves.filter(m => m.isCapture); + expect(captures.map(m => m.to).sort((a, b) => a - b)).toEqual([27, 29]); + }); + + it("black pawn blocked on e6 from e7 produces no moves", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 52); // e7 + insertPiece(session, 2, "rook", "white", 44); // e6 blocker + const moves = getLegalPawnMoves(session, pawn); + expect(moves).toHaveLength(0); + }); +}); + +// ─── Degenerate inputs ─────────────────────────────────────────────────────── + +describe("getLegalPawnMoves — degenerate inputs", () => { + it("returns [] for an unknown piece id", () => { + const session = setupSession(); + const moves = getLegalPawnMoves(session, 999 as EntityId); + expect(moves).toEqual([]); + }); + + it("returns [] when piece has Position but no Color", () => { + const session = setupSession(); + const eid = 1 as EntityId; + session.insert(eid, "Position", 12); + const moves = getLegalPawnMoves(session, eid); + expect(moves).toEqual([]); + }); +}); diff --git a/packages/chess/src/rules/pawn.ts b/packages/chess/src/rules/pawn.ts new file mode 100644 index 0000000..f572b63 --- /dev/null +++ b/packages/chess/src/rules/pawn.ts @@ -0,0 +1,67 @@ +/** + * Pawn move and capture rules (P2.9). + * + * Computes the set of legal target squares for a single pawn, given the + * current board state in a Session. Handles: + * - single advance (blocked by any piece) + * - double advance from home rank (both squares must be empty) + * - diagonal captures (only when an enemy occupies the diagonal) + * + * Intentionally NOT handled here (see later phases): + * - en passant → P2.16 + * - promotion choice → P2.17 + * - check filtering → P2.18 + */ +import type { Session, EntityId } from "@paratype/rete"; +import { + pawnSingleAdvance, + pawnDoubleAdvance, + pawnCaptureSqares, +} from "./primitives.js"; +import type { LegalMove } from "./types.js"; +import { + isPieceAt, + isEnemyAt, + getPiecePosition, + getPieceColor, +} from "./board-queries.js"; + +/** + * Get all legal pawn moves for a given pawn entity. + * + * Does not verify that `pieceId` is actually a pawn — the caller is + * expected to dispatch on PieceType. If the piece has no Position or + * Color fact, returns []. + */ +export function getLegalPawnMoves(session: Session, pieceId: EntityId): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + + const moves: LegalMove[] = []; + + // ─── Single advance ────────────────────────────────────────────────────── + const single = pawnSingleAdvance(from, color); + const singleClear = single !== null && !isPieceAt(session, single); + if (singleClear) { + moves.push({ pieceId, from, to: single, isCapture: false }); + } + + // ─── Double advance ────────────────────────────────────────────────────── + // Requires the single-advance square also to be empty (no jumping). + if (singleClear) { + const dbl = pawnDoubleAdvance(from, color); + if (dbl !== null && !isPieceAt(session, dbl)) { + moves.push({ pieceId, from, to: dbl, isCapture: false }); + } + } + + // ─── Diagonal captures ─────────────────────────────────────────────────── + for (const capSq of pawnCaptureSqares(from, color)) { + if (isEnemyAt(session, capSq, color)) { + moves.push({ pieceId, from, to: capSq, isCapture: true }); + } + } + + return moves; +} diff --git a/packages/chess/src/rules/types.ts b/packages/chess/src/rules/types.ts new file mode 100644 index 0000000..c4c1501 --- /dev/null +++ b/packages/chess/src/rules/types.ts @@ -0,0 +1,24 @@ +/** + * Shared types for piece-rule modules (pawn.ts, knight.ts, bishop.ts, ...). + */ +import type { EntityId } from "@paratype/rete"; +import type { PieceType, Square } from "../schema.js"; + +/** + * A legal move produced by a piece-specific rule module. + * + * NOTE: "Legal" here means legal w.r.t. the piece's movement + board + * occupancy only. It does NOT yet account for: + * - en passant (P2.16) + * - castling (P2.15) + * - promotion choice (P2.17) + * - leaving the king in check (P2.18) + */ +export interface LegalMove { + readonly pieceId: EntityId; + readonly from: Square; + readonly to: Square; + readonly isCapture: boolean; + /** Set for pawn moves that reach the last rank (populated in P2.17). */ + readonly promoteTo?: PieceType; +}