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