diff --git a/packages/chess/src/rules/promotion.test.ts b/packages/chess/src/rules/promotion.test.ts new file mode 100644 index 0000000..119dca3 --- /dev/null +++ b/packages/chess/src/rules/promotion.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import { + getPromotionMoves, + applyPromotion, + isPromotionMove, +} from "./promotion.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; +} + +// ─── isPromotionMove ───────────────────────────────────────────────────────── + +describe("isPromotionMove", () => { + it("white pawn landing on rank 7 (e8=60) is a promotion", () => { + expect(isPromotionMove(60, "white")).toBe(true); + }); + + it("white pawn landing on rank 6 (e7=52) is not a promotion", () => { + expect(isPromotionMove(52, "white")).toBe(false); + }); + + it("white pawn landing on rank 0 is not a promotion (wrong direction)", () => { + expect(isPromotionMove(4, "white")).toBe(false); + }); + + it("black pawn landing on rank 0 (e1=4) is a promotion", () => { + expect(isPromotionMove(4, "black")).toBe(true); + }); + + it("black pawn landing on rank 1 is not a promotion", () => { + expect(isPromotionMove(12, "black")).toBe(false); + }); + + it("black pawn landing on rank 7 is not a promotion (wrong direction)", () => { + expect(isPromotionMove(60, "black")).toBe(false); + }); + + it("covers the full rank-7 strip for white (squares 56..63)", () => { + for (let sq = 56; sq <= 63; sq++) { + expect(isPromotionMove(sq, "white")).toBe(true); + } + }); + + it("covers the full rank-0 strip for black (squares 0..7)", () => { + for (let sq = 0; sq <= 7; sq++) { + expect(isPromotionMove(sq, "black")).toBe(true); + } + }); +}); + +// ─── getPromotionMoves ─────────────────────────────────────────────────────── + +describe("getPromotionMoves — white", () => { + it("pawn at e7 (52) with e8 (60) empty: 4 push-promotion moves", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + const moves = getPromotionMoves(session, pawn); + expect(moves).toHaveLength(4); + + const promotions = moves.map((m) => m.promoteTo); + expect(promotions).toContain("queen"); + expect(promotions).toContain("rook"); + expect(promotions).toContain("bishop"); + expect(promotions).toContain("knight"); + + expect(moves.every((m) => m.to === 60)).toBe(true); + expect(moves.every((m) => m.from === 52)).toBe(true); + expect(moves.every((m) => m.pieceId === pawn)).toBe(true); + expect(moves.every((m) => !m.isCapture)).toBe(true); + }); + + it("no push-promotion when the promotion square is blocked", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + insertPiece(session, 2, "rook", "black", 60); // e8 blocks push + const moves = getPromotionMoves(session, pawn); + expect(moves.filter((m) => m.to === 60 && !m.isCapture)).toHaveLength(0); + }); + + it("capture-promotion on d7→e8: 4 capture moves with 4 piece options", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "black", 60); // e8 — enemy to capture + const moves = getPromotionMoves(session, pawn); + const captures = moves.filter((m) => m.to === 60 && m.isCapture); + expect(captures).toHaveLength(4); + expect(captures.map((m) => m.promoteTo).sort()).toEqual( + ["bishop", "knight", "queen", "rook"], + ); + }); + + it("cannot capture-promote onto an allied piece", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "white", 60); // e8 ally + const moves = getPromotionMoves(session, pawn); + expect(moves.filter((m) => m.to === 60)).toHaveLength(0); + }); + + it("edge file: a7 pawn can only capture to b8 (no off-board left capture)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 48); // a7 + insertPiece(session, 2, "rook", "black", 57); // b8 enemy + insertPiece(session, 3, "knight", "white", 56); // a8 blocks push (ally) + const moves = getPromotionMoves(session, pawn); + // Only 4 capture-promotions to b8 (57); a8 push blocked by ally. + expect(moves).toHaveLength(4); + expect(moves.every((m) => m.to === 57 && m.isCapture)).toBe(true); + }); + + it("pawn not yet on promotion-approaching rank returns []", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 12); // e2 + expect(getPromotionMoves(session, pawn)).toEqual([]); + }); +}); + +describe("getPromotionMoves — black", () => { + it("black pawn at e2 (12) with e1 (4) empty: 4 push-promotion moves", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 12); // e2 + const moves = getPromotionMoves(session, pawn); + expect(moves).toHaveLength(4); + expect(moves.every((m) => m.to === 4 && !m.isCapture)).toBe(true); + expect(moves.map((m) => m.promoteTo).sort()).toEqual( + ["bishop", "knight", "queen", "rook"], + ); + }); + + it("black capture-promotion d2→e1: 4 moves", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "black", 11); // d2 + insertPiece(session, 2, "rook", "white", 4); // e1 enemy + const moves = getPromotionMoves(session, pawn); + const captures = moves.filter((m) => m.to === 4 && m.isCapture); + expect(captures).toHaveLength(4); + }); +}); + +describe("getPromotionMoves — degenerate inputs", () => { + it("unknown piece id returns []", () => { + const session = setupSession(); + expect(getPromotionMoves(session, 999 as EntityId)).toEqual([]); + }); + + it("piece with Position but no Color returns []", () => { + const session = setupSession(); + const eid = 1 as EntityId; + session.insert(eid, "Position", 52); + expect(getPromotionMoves(session, eid)).toEqual([]); + }); +}); + +// ─── applyPromotion ────────────────────────────────────────────────────────── + +describe("applyPromotion", () => { + it("defaults to queen when no target specified", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + applyPromotion(session, pawn); + expect(session.get(pawn, "PieceType")).toBe("queen"); + }); + + it("promotes to a specified piece type (knight)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + applyPromotion(session, pawn, "knight"); + expect(session.get(pawn, "PieceType")).toBe("knight"); + }); + + it("promotes to each of queen / rook / bishop / knight", () => { + for (const target of ["queen", "rook", "bishop", "knight"] as const) { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + applyPromotion(session, pawn, target); + expect(session.get(pawn, "PieceType")).toBe(target); + } + }); + + it("preserves color and position when promoting", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + applyPromotion(session, pawn, "rook"); + expect(session.get(pawn, "Color")).toBe("white"); + expect(session.get(pawn, "Position")).toBe(60); + }); + + it("throws when promoting to king", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + expect(() => applyPromotion(session, pawn, "king")).toThrow( + /Invalid promotion target/, + ); + }); + + it("throws when promoting to pawn", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + expect(() => applyPromotion(session, pawn, "pawn")).toThrow( + /Invalid promotion target/, + ); + }); +}); diff --git a/packages/chess/src/rules/promotion.ts b/packages/chess/src/rules/promotion.ts new file mode 100644 index 0000000..c930652 --- /dev/null +++ b/packages/chess/src/rules/promotion.ts @@ -0,0 +1,102 @@ +/** + * Pawn promotion rules (P2.17). + * + * A pawn promotes when it reaches the opposite back rank: + * - White pawn reaching rank 7 (squares 56-63) + * - Black pawn reaching rank 0 (squares 0-7) + * + * `getPromotionMoves` enumerates all pawn moves that would trigger promotion, + * producing one LegalMove per candidate target square × promotion piece type + * (queen / rook / bishop / knight = 4 variants per reachable target). + * + * `applyPromotion` mutates the session by replacing the pawn's PieceType + * fact with the chosen promotion target (defaults to queen). + * + * Intentionally NOT handled here: + * - check filtering (P2.18) + * - turn order (handled by caller) + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceType, PieceColor, Square } from "../schema.js"; +import { PROMOTION_PIECES } from "../schema.js"; +import { rankOf } from "../coord.js"; +import type { LegalMove } from "./types.js"; +import { + getPiecePosition, + getPieceColor, + isPieceAt, + isEnemyAt, +} from "./board-queries.js"; +import { pawnSingleAdvance, pawnCaptureSqares } from "./primitives.js"; + +/** Rank a pawn of each color must reach to promote. */ +const PROMOTION_RANK: Record = { white: 7, black: 0 }; + +/** + * True iff moving (a pawn of `color`) to square `to` lands on that color's + * promotion rank. Caller is responsible for confirming the mover is a pawn. + */ +export function isPromotionMove(to: Square, color: PieceColor): boolean { + return rankOf(to) === PROMOTION_RANK[color]; +} + +/** + * Enumerate all promotion moves available to `pieceId` from its current + * square. Returns 4 LegalMove entries per reachable promotion-rank target + * (one per element of {@link PROMOTION_PIECES}). + * + * Does not verify that `pieceId` is a pawn — the caller is expected to + * dispatch on PieceType. Returns [] if the piece has no Position/Color. + */ +export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + + const candidates: { to: Square; isCapture: boolean }[] = []; + + // Single advance to the back rank (must be empty). + const single = pawnSingleAdvance(from, color); + if (single !== null && isPromotionMove(single, color) && !isPieceAt(session, single)) { + candidates.push({ to: single, isCapture: false }); + } + + // Diagonal captures onto the back rank (must have an enemy). + for (const sq of pawnCaptureSqares(from, color)) { + if (isPromotionMove(sq, color) && isEnemyAt(session, sq, color)) { + candidates.push({ to: sq, isCapture: true }); + } + } + + if (candidates.length === 0) return []; + + // Fan each candidate target into one LegalMove per promotion piece type. + const moves: LegalMove[] = []; + for (const { to, isCapture } of candidates) { + for (const promoteTo of PROMOTION_PIECES) { + moves.push({ pieceId, from, to, isCapture, promoteTo }); + } + } + return moves; +} + +/** + * Apply a promotion by replacing the pawn's PieceType fact with + * `promoteTo` (defaults to queen). Session.insert has update semantics, + * so this retracts the old PieceType and inserts the new one atomically. + * + * Throws if `promoteTo` is not in {@link PROMOTION_PIECES} (i.e. attempts + * to promote to pawn or king). + */ +export function applyPromotion( + session: Session, + pieceId: EntityId, + promoteTo: PieceType = "queen", +): void { + if (!PROMOTION_PIECES.includes(promoteTo)) { + throw new Error( + `Invalid promotion target: ${promoteTo}. Cannot promote to king or pawn.`, + ); + } + session.insert(pieceId, "PieceType", promoteTo); +}