diff --git a/src/engine/game.test.ts b/src/engine/game.test.ts new file mode 100644 index 0000000..d4fe312 --- /dev/null +++ b/src/engine/game.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { DuplicateGame } from './game'; + +const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR'; + +function placement(fen: string) { + return fen.split(' ')[0]; +} + +describe('DuplicateGame', () => { + it('starts with four boards in the standard position, North to move', () => { + const g = new DuplicateGame(); + expect(g.ply).toBe(0); + expect(g.currentPlayer).toBe('N'); + for (const id of ['NW', 'NE', 'SW', 'SE'] as const) { + expect(placement(g.boards[id].fen())).toBe(START); + } + }); + + it("applies a synchronized move to the current player's two boards only", () => { + const g = new DuplicateGame(); + g.applyMove({ from: 'e2', to: 'e4' }); // North + expect(g.ply).toBe(1); + expect(g.currentPlayer).toBe('S'); + expect(placement(g.boards.NW.fen())).toContain('4P3'); // pawn advanced + expect(placement(g.boards.NE.fen())).toContain('4P3'); + expect(placement(g.boards.SW.fen())).toBe(START); // untouched + expect(placement(g.boards.SE.fen())).toBe(START); + }); + + it('cycles the current player N -> S -> E -> W -> N', () => { + const g = new DuplicateGame(); + g.applyMove({ from: 'e2', to: 'e4' }); // N + g.applyMove({ from: 'e2', to: 'e4' }); // S + g.applyMove({ from: 'e7', to: 'e5' }); // E + g.applyMove({ from: 'e7', to: 'e5' }); // W + expect(g.currentPlayer).toBe('N'); + expect(g.ply).toBe(4); + }); + + it("throws on a move not legal on both of the player's boards", () => { + const g = new DuplicateGame(); + expect(() => g.applyMove({ from: 'e2', to: 'e5' })).toThrow(); + }); + + it('undo removes the last move and restores the position', () => { + const g = new DuplicateGame(); + g.applyMove({ from: 'e2', to: 'e4' }); + g.undo(); + expect(g.ply).toBe(0); + expect(g.currentPlayer).toBe('N'); + expect(placement(g.boards.NW.fen())).toBe(START); + }); + + it('rebuilds from a history array passed to the constructor', () => { + const g = new DuplicateGame([ + { player: 'N', from: 'e2', to: 'e4' }, + { player: 'S', from: 'e2', to: 'e4' }, + ]); + expect(g.ply).toBe(2); + expect(g.currentPlayer).toBe('E'); + }); + + it('resets the progress clock on a pawn move or capture', () => { + const g = new DuplicateGame(); + g.applyMove({ from: 'e2', to: 'e4' }); // pawn move -> clock stays 0 + expect(g.pliesSinceProgress).toBe(0); + g.applyMove({ from: 'e2', to: 'e4' }); // pawn move + expect(g.pliesSinceProgress).toBe(0); + g.applyMove({ from: 'g8', to: 'f6' }); // knight move -> clock increments + expect(g.pliesSinceProgress).toBe(1); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts new file mode 100644 index 0000000..611e5b9 --- /dev/null +++ b/src/engine/game.ts @@ -0,0 +1,93 @@ +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; +} diff --git a/src/engine/test-helpers.ts b/src/engine/test-helpers.ts new file mode 100644 index 0000000..7434792 --- /dev/null +++ b/src/engine/test-helpers.ts @@ -0,0 +1,23 @@ +import type { DuplicateGame } from './game'; +import type { SyncMove } from './types'; + +/** + * Apply a list of [whiteMove, blackMove?] underlying ply-pairs symmetrically: + * North then South play the white move, East then West play the black move. + * While every move is symmetric all four boards stay identical, so each board + * behaves as an ordinary chess game — useful for reaching ordinary checkmate / + * stalemate / repetition positions. Omit blackMove for a final unanswered white move. + */ +export function playSymmetric( + game: DuplicateGame, + pairs: Array<[SyncMove, SyncMove?]>, +): void { + for (const [white, black] of pairs) { + game.applyMove(white); // N + game.applyMove(white); // S + if (black) { + game.applyMove(black); // E + game.applyMove(black); // W + } + } +}