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:
parent
f94ab386a8
commit
6baab9f3fd
3 changed files with 528 additions and 0 deletions
234
packages/chess/src/engine.ts
Normal file
234
packages/chess/src/engine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue