feat(chess): add pawn move/capture rules (P2.9)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:51:49 -06:00
commit 9a87e57007
No known key found for this signature in database
4 changed files with 344 additions and 0 deletions

View file

@ -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;
}

View file

@ -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([]);
});
});

View file

@ -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;
}

View file

@ -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;
}