feat(chess): add pawn move/capture rules (P2.9)
This commit is contained in:
parent
eb58441752
commit
9a87e57007
4 changed files with 344 additions and 0 deletions
80
packages/chess/src/rules/board-queries.ts
Normal file
80
packages/chess/src/rules/board-queries.ts
Normal 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;
|
||||
}
|
||||
173
packages/chess/src/rules/pawn.test.ts
Normal file
173
packages/chess/src/rules/pawn.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
67
packages/chess/src/rules/pawn.ts
Normal file
67
packages/chess/src/rules/pawn.ts
Normal 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;
|
||||
}
|
||||
24
packages/chess/src/rules/types.ts
Normal file
24
packages/chess/src/rules/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue