feat(chess): add pawn promotion rule (P2.17)
This commit is contained in:
parent
6938a2aedb
commit
fbdf10ec51
2 changed files with 322 additions and 0 deletions
220
packages/chess/src/rules/promotion.test.ts
Normal file
220
packages/chess/src/rules/promotion.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
102
packages/chess/src/rules/promotion.ts
Normal file
102
packages/chess/src/rules/promotion.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue