import { Chess } from 'chess.js'; import type { BoardId, Player, SyncMove, HistoryEntry } from './types'; import { BOARD_IDS, PLAYERS, PLAYER_BOARDS } from './boards'; /** A chess.js move result has these fields we rely on. */ interface ChessMoveResult { piece: string; captured?: string; } export class DuplicateGame { readonly boards: Record; readonly history: HistoryEntry[] = []; /** Plies since the last capture or pawn move on any board (for the 50-move rule). */ pliesSinceProgress = 0; /** Repetition keys of the whole 4-board system, one per position incl. the start. */ readonly repetitionKeys: string[] = []; constructor(history: HistoryEntry[] = []) { this.boards = { NW: new Chess(), NE: new Chess(), SW: new Chess(), SE: new Chess(), }; this.repetitionKeys.push(this.systemKey()); for (const entry of history) this.applyMove(entry, entry.player); } get ply(): number { return this.history.length; } get currentPlayer(): Player { return PLAYERS[this.history.length % 4]; } /** Whether `move` is a legal chess move on `board` in the current position. */ isLegalOnBoard(board: BoardId, move: SyncMove): boolean { return this.boards[board].moves({ verbose: true }).some( (m) => m.from === move.from && m.to === move.to && (m.promotion ?? undefined) === (move.promotion ?? undefined), ); } /** Apply one synchronized move to a player's two boards. Throws if illegal on either. */ applyMove(move: SyncMove, player: Player = this.currentPlayer): void { const [a, b] = PLAYER_BOARDS[player]; if (!this.isLegalOnBoard(a, move) || !this.isLegalOnBoard(b, move)) { throw new Error( `Illegal synchronized move ${move.from}${move.to} for ${player}`, ); } const ra = this.boards[a].move(move) as unknown as ChessMoveResult; const rb = this.boards[b].move(move) as unknown as ChessMoveResult; this.history.push({ ...move, player }); const progress = isProgress(ra) || isProgress(rb); this.pliesSinceProgress = progress ? 0 : this.pliesSinceProgress + 1; this.repetitionKeys.push(this.systemKey()); } /** Remove the last move by replaying the truncated history. */ undo(): void { if (this.history.length === 0) return; const replay = this.history.slice(0, -1); for (const id of BOARD_IDS) this.boards[id].reset(); this.history.length = 0; this.pliesSinceProgress = 0; this.repetitionKeys.length = 0; this.repetitionKeys.push(this.systemKey()); for (const e of replay) this.applyMove(e, e.player); } /** A repetition key for the whole system: each board's placement+castling+ep, plus the side to move. */ systemKey(): string { return ( BOARD_IDS.map((id) => { const parts = this.boards[id].fen().split(' '); return `${parts[0]}${parts[2]}${parts[3]}`; }).join('|') + ':' + this.currentPlayer ); } /** How many times the current system position has occurred. */ repetitionCount(): number { const current = this.repetitionKeys[this.repetitionKeys.length - 1]; return this.repetitionKeys.filter((k) => k === current).length; } } function isProgress(result: ChessMoveResult): boolean { return result.piece === 'p' || result.captured != null; }