88f1da9f70
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
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<BoardId, Chess>;
|
|
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;
|
|
}
|