feat(engine): DuplicateGame core — boards, history, undo, draw clocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user