feat(chess): add pawn promotion rule (P2.17)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:58:31 -06:00
commit fbdf10ec51
No known key found for this signature in database
2 changed files with 322 additions and 0 deletions

View file

@ -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/,
);
});
});

View file

@ -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<PieceColor, number> = { 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);
}