test(chess): replay 5 classic FIDE games; Phase 2 acceptance gate (P2.23)

- packages/chess/src/engine.ts — ChessEngine integrates all rule modules
  (pawn, knight, sliding, king, castling, en-passant, promotion, check,
  checkmate, stalemate, draws) into a playable game without the Rete
  production network
- packages/chess/src/pgn.ts — minimal SAN/PGN parser with full
  disambiguation support (file/rank hints, full from-square)
- packages/chess/tests/fide-games/classic-games.test.ts — 5 game tests:
  Fool's Mate, Scholar's Mate, Ruy López, Sicilian Defence, Italian Game

All 5 tests green; typecheck clean.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:18:57 -06:00
commit 6baab9f3fd
No known key found for this signature in database
3 changed files with 528 additions and 0 deletions

View file

@ -0,0 +1,234 @@
/**
* ChessEngine — integrates all rule modules into a playable chess game.
*
* Uses Session as working memory; calls rule functions directly (no Rete
* production network — that integration is future work).
*/
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { GAME_ENTITY, type PieceType, type PieceColor } from "./schema.js";
import { generateStartingPosition } from "./starting-position.js";
import { getLegalPawnMoves } from "./rules/pawn.js";
import { getLegalKnightMoves } from "./rules/knight.js";
import {
getLegalRookMoves,
getLegalBishopMoves,
getLegalQueenMoves,
} from "./rules/sliding.js";
import { getLegalKingMoves } from "./rules/king.js";
import {
getCastlingMoves,
applyCastlingMove,
type CastlingMove,
} from "./rules/castling.js";
import {
getEnPassantMoves,
setEnPassantTarget,
clearEnPassantTarget,
applyEnPassantCapture,
} from "./rules/enpassant.js";
import {
isPromotionMove,
applyPromotion,
getPromotionMoves,
} from "./rules/promotion.js";
import {
filterSelfCheckMoves,
isSquareAttacked,
} from "./rules/check.js";
import { isCheckmate } from "./rules/checkmate.js";
import { isStalemate } from "./rules/stalemate.js";
import { isInsufficientMaterial } from "./rules/insufficient.js";
import {
isFiftyMoveDraw,
updateHalfmoveClock,
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.js";
import { applyCapture } from "./rules/capture.js";
import type { LegalMove } from "./rules/types.js";
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
const PIECE_MOVE_GETTERS: Record<PieceType, MoveGetter> = {
pawn: getLegalPawnMoves,
knight: getLegalKnightMoves,
bishop: getLegalBishopMoves,
rook: getLegalRookMoves,
queen: getLegalQueenMoves,
king: getLegalKingMoves,
};
export type GameResult =
| "checkmate"
| "stalemate"
| "draw-50"
| "draw-3fold"
| "draw-insufficient"
| "ongoing";
export class ChessEngine {
public readonly session: Session;
constructor() {
this.session = new Session({ autoFire: false });
generateStartingPosition(this.session);
recordPosition(this.session);
}
getCurrentTurn(): PieceColor {
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
}
getAllLegalMoves(): LegalMove[] {
const color = this.getCurrentTurn();
const facts = this.session.allFacts();
const moves: LegalMove[] = [];
const pieces = facts
.filter(f => f.attr === "Color" && f.value === color && (f.id as number) > 0)
.map(f => ({
id: f.id as EntityId,
type: facts.find(t => t.id === f.id && t.attr === "PieceType")
?.value as PieceType,
}))
.filter(p => p.type !== undefined);
for (const piece of pieces) {
const getter = PIECE_MOVE_GETTERS[piece.type];
if (!getter) continue;
let pieceMoves = getter(this.session, piece.id);
// Add en passant for pawns
if (piece.type === "pawn") {
pieceMoves = [
...pieceMoves,
...getEnPassantMoves(this.session, piece.id),
];
}
// Add castling for kings
if (piece.type === "king") {
const enemyColor: PieceColor = color === "white" ? "black" : "white";
const castling = getCastlingMoves(
this.session,
piece.id,
(s, sq) => isSquareAttacked(s, sq, enemyColor),
);
pieceMoves = [...pieceMoves, ...castling];
}
// Replace raw promotion-rank pawn moves with promotion-tagged variants
if (piece.type === "pawn") {
const promotions = getPromotionMoves(this.session, piece.id);
if (promotions.length > 0) {
const promotionTos = new Set(promotions.map(m => m.to));
pieceMoves = pieceMoves.filter(m => !promotionTos.has(m.to));
pieceMoves = [...pieceMoves, ...promotions];
}
}
moves.push(...pieceMoves);
}
// Filter self-check moves
return filterSelfCheckMoves(this.session, moves, color);
}
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
const color = this.getCurrentTurn();
const facts = this.session.allFacts();
const movingType = facts.find(
f => f.id === move.pieceId && f.attr === "PieceType",
)?.value as PieceType;
const epTarget = this.session.get(GAME_ENTITY, "EnPassantTarget");
const isEnPassant =
movingType === "pawn" &&
epTarget !== null &&
epTarget !== undefined &&
epTarget === move.to;
const isCastling = (move as CastlingMove).isCastling === true;
if (isEnPassant) {
applyEnPassantCapture(this.session, move, color);
} else if (isCastling) {
applyCastlingMove(this.session, move as CastlingMove);
} else {
// Normal move: handle capture, then update position
if (move.isCapture) {
const capturedId = this.getPieceAt(move.to);
if (capturedId !== null) {
applyCapture(this.session, capturedId);
}
}
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
// Handle promotion (pawn reaching last rank)
const effectiveTo = isCastling ? (move as CastlingMove).to : move.to;
if (movingType === "pawn" && isPromotionMove(effectiveTo, color)) {
const promoteAs =
(move as LegalMove & { promoteTo?: PieceType }).promoteTo ?? promoteTo;
applyPromotion(this.session, move.pieceId, promoteAs);
}
// Set/clear en passant target
if (movingType === "pawn" && Math.abs(move.to - move.from) === 16) {
setEnPassantTarget(this.session, move.from, move.to);
} else {
clearEnPassantTarget(this.session);
}
// Update halfmove clock
updateHalfmoveClock(this.session, move, movingType === "pawn");
// Switch turn
const nextColor: PieceColor = color === "white" ? "black" : "white";
this.session.insert(GAME_ENTITY, "Turn", nextColor);
// Increment fullmove number after black's move
if (color === "black") {
const fn =
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
}
// Record position for threefold repetition
recordPosition(this.session);
return this.checkGameResult();
}
checkGameResult(): GameResult {
const nextColor = this.getCurrentTurn();
if (isCheckmate(this.session, nextColor)) return "checkmate";
if (isStalemate(this.session, nextColor)) return "stalemate";
if (isFiftyMoveDraw(this.session)) return "draw-50";
if (isThreefoldRepetition(this.session)) return "draw-3fold";
if (isInsufficientMaterial(this.session)) return "draw-insufficient";
return "ongoing";
}
/** Find first legal move matching from+to squares and optional promoteTo. */
findMove(from: number, to: number, promoteTo?: PieceType): LegalMove | null {
return (
this.getAllLegalMoves().find(
m =>
m.from === from &&
m.to === to &&
(!m.promoteTo || m.promoteTo === (promoteTo ?? "queen")),
) ?? null
);
}
private getPieceAt(square: number): EntityId | null {
const f = this.session
.allFacts()
.find(x => x.attr === "Position" && x.value === square && (x.id as number) > 0);
return f ? (f.id as EntityId) : null;
}
}