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;
|
||||
}
|
||||
}
|
||||
139
packages/chess/src/pgn.ts
Normal file
139
packages/chess/src/pgn.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Minimal PGN/SAN parser for @paratype/chess.
|
||||
* Parses standard algebraic notation moves — no external dependencies.
|
||||
*/
|
||||
import { algebraicToSquare } from "./coord.js";
|
||||
import type { PieceType } from "./schema.js";
|
||||
|
||||
export interface ParsedMove {
|
||||
/** Full from-square when fully disambiguated (e.g. "Nb3d5"). */
|
||||
from?: number;
|
||||
/** File hint (0-7) when only file is given (e.g. "exd5" → fromFile=4). */
|
||||
fromFile?: number;
|
||||
/** Rank hint (0-7) when only rank is given (e.g. "N3d5" → fromRank=2). */
|
||||
fromRank?: number;
|
||||
/** Target square. -1 for castling (use isCastlingKingside/Queenside). */
|
||||
to: number;
|
||||
pieceType: PieceType;
|
||||
isCapture: boolean;
|
||||
promoteTo?: PieceType;
|
||||
isCastlingKingside?: boolean;
|
||||
isCastlingQueenside?: boolean;
|
||||
}
|
||||
|
||||
const PIECE_MAP: Record<string, PieceType> = {
|
||||
N: "knight",
|
||||
B: "bishop",
|
||||
R: "rook",
|
||||
Q: "queen",
|
||||
K: "king",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a SAN move string into a ParsedMove.
|
||||
* Handles: e4, Nf3, exd5, Nbd7, N3d5, Nb3d5, O-O, O-O-O, e8=Q, exd8=R, etc.
|
||||
* Strips trailing +, #, !, ? annotation characters.
|
||||
*/
|
||||
export function parseSanMove(san: string): ParsedMove {
|
||||
// Strip check/checkmate/annotation indicators
|
||||
const clean = san.replace(/[+#!?]+$/g, "");
|
||||
|
||||
// ── Castling ──────────────────────────────────────────────────────────────
|
||||
if (clean === "O-O" || clean === "0-0") {
|
||||
return {
|
||||
to: -1,
|
||||
pieceType: "king",
|
||||
isCapture: false,
|
||||
isCastlingKingside: true,
|
||||
};
|
||||
}
|
||||
if (clean === "O-O-O" || clean === "0-0-0") {
|
||||
return {
|
||||
to: -1,
|
||||
pieceType: "king",
|
||||
isCapture: false,
|
||||
isCastlingQueenside: true,
|
||||
};
|
||||
}
|
||||
|
||||
let remaining = clean;
|
||||
|
||||
// ── Promotion: trailing =Q or Q ──────────────────────────────────────────
|
||||
let promoteTo: PieceType | undefined;
|
||||
const promoMatch = remaining.match(/=?([NBRQ])$/);
|
||||
if (promoMatch && !PIECE_MAP[remaining[0]!]) {
|
||||
// Only treat it as promotion when the first character is NOT a piece letter
|
||||
// (i.e., this is a pawn move, not e.g. "Qe8" where 'Q' is the piece type)
|
||||
const promoPiece = PIECE_MAP[promoMatch[1]!];
|
||||
if (promoPiece) {
|
||||
promoteTo = promoPiece;
|
||||
remaining = remaining.slice(0, promoMatch.index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Piece type ────────────────────────────────────────────────────────────
|
||||
let pieceType: PieceType = "pawn";
|
||||
if (PIECE_MAP[remaining[0]!]) {
|
||||
pieceType = PIECE_MAP[remaining[0]!]!;
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
|
||||
// ── Capture indicator ─────────────────────────────────────────────────────
|
||||
const isCapture = remaining.includes("x");
|
||||
remaining = remaining.replace("x", "");
|
||||
|
||||
// ── Target square (always last 2 chars) ───────────────────────────────────
|
||||
if (remaining.length < 2) {
|
||||
throw new Error(`Cannot parse SAN: "${san}"`);
|
||||
}
|
||||
const toNotation = remaining.slice(-2);
|
||||
const to = algebraicToSquare(toNotation);
|
||||
if (to < 0) {
|
||||
throw new Error(`Invalid target square "${toNotation}" in SAN: "${san}"`);
|
||||
}
|
||||
|
||||
// ── Disambiguation prefix ─────────────────────────────────────────────────
|
||||
const disambiguation = remaining.slice(0, -2);
|
||||
let from: number | undefined;
|
||||
let fromFile: number | undefined;
|
||||
let fromRank: number | undefined;
|
||||
|
||||
if (disambiguation.length === 2) {
|
||||
// Full from-square disambiguation: e.g. "b3" in "Nb3d5"
|
||||
const sq = algebraicToSquare(disambiguation);
|
||||
if (sq >= 0) from = sq;
|
||||
} else if (disambiguation.length === 1) {
|
||||
const ch = disambiguation[0]!;
|
||||
if (ch >= "a" && ch <= "h") {
|
||||
fromFile = ch.charCodeAt(0) - 97; // 'a'=0
|
||||
} else if (ch >= "1" && ch <= "8") {
|
||||
fromRank = parseInt(ch, 10) - 1; // rank 1 → 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
pieceType,
|
||||
isCapture,
|
||||
...(from !== undefined && { from }),
|
||||
...(fromFile !== undefined && { fromFile }),
|
||||
...(fromRank !== undefined && { fromRank }),
|
||||
...(promoteTo !== undefined && { promoteTo }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract SAN move tokens from a PGN string.
|
||||
* Strips move numbers, comments, variations, and result strings.
|
||||
*/
|
||||
export function extractMoves(pgn: string): string[] {
|
||||
return pgn
|
||||
.replace(/\{[^}]*\}/g, "") // remove { comments }
|
||||
.replace(/\([^)]*\)/g, "") // remove (variations)
|
||||
.replace(/\d+\.\.\./g, "") // remove "1..." black move numbers
|
||||
.replace(/\d+\./g, "") // remove "1." move numbers
|
||||
.replace(/1-0|0-1|1\/2-1\/2|\*/g, "") // remove result tokens
|
||||
.split(/\s+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !/^\d+$/.test(s));
|
||||
}
|
||||
155
packages/chess/tests/fide-games/classic-games.test.ts
Normal file
155
packages/chess/tests/fide-games/classic-games.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* FIDE game replay integration tests (P2.23).
|
||||
*
|
||||
* Each test replays a real chess game (or opening sequence) through the
|
||||
* rules engine, asserting that every move is accepted without error and
|
||||
* that the correct terminal state is detected.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ChessEngine, type GameResult } from "../../src/engine.js";
|
||||
import { parseSanMove } from "../../src/pgn.js";
|
||||
import type { LegalMove } from "../../src/rules/types.js";
|
||||
import type { CastlingMove } from "../../src/rules/castling.js";
|
||||
|
||||
// ── Move resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a SAN string against the engine's current legal moves.
|
||||
* Uses piece-type, target square, and available disambiguation fields
|
||||
* (full from-square, file hint, rank hint) to identify the unique legal move.
|
||||
*/
|
||||
function resolveMove(engine: ChessEngine, san: string): LegalMove {
|
||||
const parsed = parseSanMove(san);
|
||||
const legalMoves = engine.getAllLegalMoves();
|
||||
|
||||
// ── Castling ──
|
||||
if (parsed.isCastlingKingside || parsed.isCastlingQueenside) {
|
||||
const m = legalMoves.find(
|
||||
mv =>
|
||||
(mv as CastlingMove).isCastling === true &&
|
||||
((parsed.isCastlingKingside && (mv as CastlingMove).side === "kingside") ||
|
||||
(parsed.isCastlingQueenside && (mv as CastlingMove).side === "queenside")),
|
||||
);
|
||||
if (!m) {
|
||||
throw new Error(
|
||||
`Castling not available for "${san}" (turn=${engine.getCurrentTurn()})`,
|
||||
);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Piece/pawn moves ──
|
||||
const facts = engine.session.allFacts();
|
||||
|
||||
const candidates = legalMoves.filter(mv => {
|
||||
// Filter by target square
|
||||
if (mv.to !== parsed.to) return false;
|
||||
|
||||
// Filter by piece type
|
||||
const typeFact = facts.find(
|
||||
f => f.id === mv.pieceId && f.attr === "PieceType",
|
||||
);
|
||||
if (typeFact?.value !== parsed.pieceType) return false;
|
||||
|
||||
// Disambiguation: full from-square
|
||||
if (parsed.from !== undefined && mv.from !== parsed.from) return false;
|
||||
|
||||
// Disambiguation: file hint
|
||||
if (parsed.fromFile !== undefined && (mv.from % 8) !== parsed.fromFile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disambiguation: rank hint
|
||||
if (
|
||||
parsed.fromRank !== undefined &&
|
||||
Math.floor(mv.from / 8) !== parsed.fromRank
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const turn = engine.getCurrentTurn();
|
||||
const allMovesSummary = legalMoves
|
||||
.slice(0, 8)
|
||||
.map(mv => `${mv.from}→${mv.to}`)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`No legal move found for "${san}" ` +
|
||||
`(to=${parsed.to}, type=${parsed.pieceType}, turn=${turn}). ` +
|
||||
`First 8 legal moves: ${allMovesSummary}`,
|
||||
);
|
||||
}
|
||||
|
||||
// If promotion, prefer the matching promoteTo variant
|
||||
if (parsed.promoteTo) {
|
||||
const promo = candidates.find(mv => mv.promoteTo === parsed.promoteTo);
|
||||
if (promo) return promo;
|
||||
}
|
||||
|
||||
return candidates[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sequence of SAN moves on a fresh engine.
|
||||
* Returns the engine and the result string after the last move.
|
||||
*/
|
||||
function playGame(moves: string[]): { engine: ChessEngine; result: GameResult } {
|
||||
const engine = new ChessEngine();
|
||||
let result: GameResult = "ongoing";
|
||||
for (const san of moves) {
|
||||
const move = resolveMove(engine, san);
|
||||
result = engine.applyMove(move, move.promoteTo ?? "queen");
|
||||
}
|
||||
return { engine, result };
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Classic chess games — FIDE replay (P2.23)", () => {
|
||||
// ── 1. Fool's Mate ────────────────────────────────────────────────────────
|
||||
// Shortest possible checkmate: white self-destructs in 2 moves.
|
||||
it("Fool's Mate (2 white moves, white checkmated)", () => {
|
||||
// 1. f3 e5 2. g4 Qh4#
|
||||
const { result } = playGame(["f3", "e5", "g4", "Qh4"]);
|
||||
expect(result).toBe("checkmate");
|
||||
});
|
||||
|
||||
// ── 2. Scholar's Mate ─────────────────────────────────────────────────────
|
||||
// Classic 4-move checkmate against a careless black.
|
||||
it("Scholar's Mate (4 moves, black checkmated)", () => {
|
||||
// 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#
|
||||
const { result } = playGame(["e4", "e5", "Bc4", "Nc6", "Qh5", "Nf6", "Qxf7"]);
|
||||
expect(result).toBe("checkmate");
|
||||
});
|
||||
|
||||
// ── 3. Ruy López opening (6 moves) ───────────────────────────────────────
|
||||
it("Ruy López opening plays 6 moves without error", () => {
|
||||
// 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
||||
const { engine, result } = playGame(["e4", "e5", "Nf3", "Nc6", "Bb5", "a6"]);
|
||||
expect(result).toBe("ongoing");
|
||||
// After 6 half-moves (3 full), white to move
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
});
|
||||
|
||||
// ── 4. Sicilian Defense — capture on d4 ───────────────────────────────────
|
||||
it("Sicilian Defense — pawn capture cxd4 on move 3", () => {
|
||||
// 1. e4 c5 2. Nf3 d6 3. d4 cxd4
|
||||
const { engine, result } = playGame(["e4", "c5", "Nf3", "d6", "d4", "cxd4"]);
|
||||
expect(result).toBe("ongoing");
|
||||
// 6 half-moves played; white to move
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
});
|
||||
|
||||
// ── 5. Italian Game — 10 moves with bishop capture ───────────────────────
|
||||
it("Italian Game / Evans Gambit opening — 10 moves, bishop capture", () => {
|
||||
// 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. b4 Bxb4 5. c3 Ba5
|
||||
const moves = ["e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "b4", "Bxb4", "c3", "Ba5"];
|
||||
const { engine, result } = playGame(moves);
|
||||
expect(result).toBe("ongoing");
|
||||
// 10 half-moves (5 full), white to move
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue