From 4278f2d19e054ddd67d14db1decd53303a3b6a78 Mon Sep 17 00:00:00 2001 From: "claude (duplicate_chess)" Date: Tue, 19 May 2026 00:56:38 -0400 Subject: [PATCH] feat(engine): endgame detection with provisional rules Co-Authored-By: Claude Sonnet 4.6 --- src/engine/endgame.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ src/engine/endgame.ts | 51 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/engine/endgame.test.ts create mode 100644 src/engine/endgame.ts diff --git a/src/engine/endgame.test.ts b/src/engine/endgame.test.ts new file mode 100644 index 0000000..af51e68 --- /dev/null +++ b/src/engine/endgame.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { DuplicateGame } from './game'; +import { playSymmetric } from './test-helpers'; +import { evaluateStatus } from './endgame'; + +describe('evaluateStatus', () => { + it('reports an ongoing game at the start', () => { + const s = evaluateStatus(new DuplicateGame()); + expect(s.state).toBe('playing'); + expect(s.checks).toEqual([]); + }); + + it('detects a double-board checkmate (Fool\'s mate, played symmetrically)', () => { + const g = new DuplicateGame(); + playSymmetric(g, [ + [{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }], + [{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }], + ]); + expect(g.currentPlayer).toBe('N'); // North (White) is mated + const s = evaluateStatus(g); + expect(s.state).toBe('checkmate'); + expect(s.checks.sort()).toEqual(['NE', 'NW']); + expect(s.result).toEqual({ N: 'loss', S: 'draw', E: 'win', W: 'win' }); + }); + + it('detects threefold repetition of the whole system', () => { + const g = new DuplicateGame(); + const cycle: Array<[{ from: string; to: string }, { from: string; to: string }]> = [ + [{ from: 'g1', to: 'f3' }, { from: 'g8', to: 'f6' }], + [{ from: 'f3', to: 'g1' }, { from: 'f6', to: 'g8' }], + ]; + playSymmetric(g, cycle); // back to start (occurrence 2) + playSymmetric(g, cycle); // back to start (occurrence 3) + const s = evaluateStatus(g); + expect(s.state).toBe('draw'); + expect(s.reason).toBe('threefold'); + expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' }); + }); + + it('detects a stalemate as an all-draw game end (provisional rule)', () => { + const g = new DuplicateGame(); + // The known fastest stalemate, played symmetrically on all four boards. + playSymmetric(g, [ + [{ from: 'e2', to: 'e3' }, { from: 'a7', to: 'a5' }], + [{ from: 'd1', to: 'h5' }, { from: 'a8', to: 'a6' }], + [{ from: 'h5', to: 'a5' }, { from: 'h7', to: 'h5' }], + [{ from: 'a5', to: 'c7' }, { from: 'a6', to: 'h6' }], + [{ from: 'h2', to: 'h4' }, { from: 'f7', to: 'f6' }], + [{ from: 'c7', to: 'd7' }, { from: 'e8', to: 'f7' }], + [{ from: 'd7', to: 'b7' }, { from: 'd8', to: 'd3' }], + [{ from: 'b7', to: 'b8' }, { from: 'd3', to: 'h7' }], + [{ from: 'b8', to: 'c8' }, { from: 'f7', to: 'g6' }], + [{ from: 'c8', to: 'e6' }], // no black reply — Black is stalemated + ]); + expect(g.currentPlayer).toBe('E'); // a Black player, with no move + const s = evaluateStatus(g); + expect(s.state).toBe('stalemate'); + expect(s.reason).toBe('stalemate'); + expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' }); + }); +}); diff --git a/src/engine/endgame.ts b/src/engine/endgame.ts new file mode 100644 index 0000000..d2bc710 --- /dev/null +++ b/src/engine/endgame.ts @@ -0,0 +1,51 @@ +import type { DuplicateGame } from './game'; +import type { GameStatus, GameResult, BoardId } from './types'; +import { PLAYERS, PLAYER_BOARDS, BOARD_PLAYERS } from './boards'; +import { legalSyncedMoves } from './legality'; + +/** PROVISIONAL (spec §6): the 50-move rule fires after this many rounds. */ +const FIFTY_MOVE_ROUNDS = 50; +const FIFTY_MOVE_PLIES = FIFTY_MOVE_ROUNDS * 4; + +function allDraw(): GameResult { + return { N: 'draw', S: 'draw', E: 'draw', W: 'draw' }; +} + +/** Evaluate the game from the perspective of the player to move. */ +export function evaluateStatus(game: DuplicateGame): GameStatus { + const player = game.currentPlayer; + const [a, b] = PLAYER_BOARDS[player]; + + const checks: BoardId[] = []; + if (game.boards[a].inCheck()) checks.push(a); + if (game.boards[b].inCheck()) checks.push(b); + + const synced = legalSyncedMoves(game); + + if (synced.length === 0) { + if (checks.length > 0) { + // Checkmate. PROVISIONAL (spec §6): every opponent delivering a check wins. + const winners = checks.map((board) => + BOARD_PLAYERS[board].w === player + ? BOARD_PLAYERS[board].b + : BOARD_PLAYERS[board].w, + ); + const result = {} as GameResult; + for (const p of PLAYERS) { + result[p] = p === player ? 'loss' : winners.includes(p) ? 'win' : 'draw'; + } + return { state: 'checkmate', result, checks }; + } + // PROVISIONAL (spec §6): a no-synchronized-move stalemate ends the game, all draw. + return { state: 'stalemate', result: allDraw(), reason: 'stalemate', checks }; + } + + if (game.repetitionCount() >= 3) { + return { state: 'draw', result: allDraw(), reason: 'threefold', checks }; + } + if (game.pliesSinceProgress >= FIFTY_MOVE_PLIES) { + return { state: 'draw', result: allDraw(), reason: 'fifty-move', checks }; + } + + return { state: 'playing', checks }; +}