feat(engine): endgame detection with provisional rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user