diff --git a/ui/lib/src/game/endgame.ts b/ui/lib/src/game/endgame.ts index 1ac4042..00cb05a 100644 --- a/ui/lib/src/game/endgame.ts +++ b/ui/lib/src/game/endgame.ts @@ -1,9 +1,10 @@ import type { DrawShape } from '@lichess-org/chessground/draw'; -import type { Outcome } from 'chessops'; +import type { Outcome, Position } from 'chessops'; import { parseFen } from 'chessops/fen'; import { makeSquare, opposite } from 'chessops/util'; -import { fenColor } from './chess'; +import type { PositionResult } from '@/tree/types'; + import { endgameGlyphs } from './glyphs'; import type { StatusName } from './status'; @@ -14,31 +15,24 @@ export interface EndgameResult { type EndgameNode = { fen: FEN; + pos: () => PositionResult; outcome: () => Outcome | undefined; - check: () => boolean; dests: () => Dests; }; +const terminalStatus = (pos: Position, outcome: Outcome): StatusName => + outcome.winner ? (pos.isCheckmate() ? 'mate' : 'variantEnd') : pos.isStalemate() ? 'stalemate' : 'draw'; + const endgameResult = ( node: EndgameNode, isLast: boolean, gameWinner: Color | undefined, gameStatus: StatusName, ): EndgameResult => { - const isTerminal = node.dests().size === 0, - isMate = node.check() && isTerminal, - isStalemate = !node.check() && isTerminal, - isGameEnd = isLast || isTerminal; - - if (!isGameEnd) return {}; - return { - winner: isMate - ? opposite(fenColor(node.fen)) - : isStalemate - ? undefined - : (node.outcome()?.winner ?? gameWinner), - status: isMate ? 'mate' : isStalemate ? 'stalemate' : gameStatus, - }; + const outcome = node.dests().size === 0 ? node.outcome() : undefined; + if (outcome) return { winner: outcome.winner, status: terminalStatus(node.pos().unwrap(), outcome) }; + if (!isLast) return {}; + return { winner: node.outcome()?.winner ?? gameWinner, status: gameStatus }; }; export function endgameShapesForNode( diff --git a/ui/lib/tests/endgame.test.ts b/ui/lib/tests/endgame.test.ts new file mode 100644 index 0000000..8459782 --- /dev/null +++ b/ui/lib/tests/endgame.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { endgameShapesForNode } from '../src/game/endgame'; +import { endgameGlyphs } from '../src/game/glyphs'; +import type { StatusName } from '../src/game/status'; +import { completeNode } from '../src/tree/node'; + +const glyphName = (html: string | undefined) => + Object.entries(endgameGlyphs).find(([, glyph]) => glyph(0) === html)?.[0]; + +type GameEnd = { isLast?: boolean; winner?: Color; status?: StatusName }; + +const kingGlyphs = ( + variant: VariantKey, + fen: FEN, + { isLast = false, winner, status = 'started' }: GameEnd = {}, +) => { + const node = completeNode(variant)({ ply: 1, fen }); + return Object.fromEntries( + endgameShapesForNode(node, isLast, winner, status).map(shape => [ + shape.orig, + glyphName(shape.customSvg?.html), + ]), + ); +}; + +describe('endgame glyphs on terminal positions', () => { + test('standard checkmate', () => + assert.deepEqual(kingGlyphs('standard', 'R5k1/5ppp/8/8/8/8/8/6K1 b - - 1 1'), { + g1: 'win', + g8: 'mate', + })); + + test('standard stalemate', () => + assert.deepEqual(kingGlyphs('standard', '7k/5Q2/6K1/8/8/8/8/8 b - - 0 1'), { + g6: 'stalemate', + h8: 'stalemate', + })); + + test('atomic king explosion', () => + assert.deepEqual(kingGlyphs('atomic', 'rn2k2r/ppp3pp/8/3ppp2/3P2P1/5P1B/PP5P/RNB4R w kq - 0 12'), { + e8: 'win', + })); + + test('atomic checkmate', () => + assert.deepEqual(kingGlyphs('atomic', 'R5k1/5ppp/8/8/8/8/8/6K1 b - - 1 1'), { + g1: 'win', + g8: 'mate', + })); + + test('antichess win by losing all pieces', () => + assert.deepEqual(kingGlyphs('antichess', '8/8/8/8/8/8/8/k7 w - - 0 1'), { + a1: 'unknown', + })); + + test('antichess win by being stalemated', () => + assert.deepEqual(kingGlyphs('antichess', '8/8/8/p7/P7/8/8/7k w - - 0 1'), { + h1: 'unknown', + })); + + test('king of the hill', () => + assert.deepEqual(kingGlyphs('kingOfTheHill', '8/8/8/8/4K3/8/8/k7 b - - 0 1'), { + e4: 'win', + a1: 'unknown', + })); + + test('racing kings win', () => + assert.deepEqual(kingGlyphs('racingKings', 'K7/8/8/8/8/8/k7/8 b - - 0 1'), { + a8: 'win', + a2: 'unknown', + })); + + test('racing kings draw', () => + assert.deepEqual(kingGlyphs('racingKings', 'K6k/8/8/8/8/8/8/8 w - - 0 1'), { + a8: 'draw', + h8: 'draw', + })); + + test('three-check', () => + assert.deepEqual( + kingGlyphs('threeCheck', 'rnb1kbnr/pppp1ppp/8/4p3/4P2q/5P2/PPPP2PP/RNBQKBNR w KQkq - 3+0 0 1'), + { e8: 'win', e1: 'unknown' }, + )); + + test('horde', () => + assert.deepEqual(kingGlyphs('horde', 'k7/8/8/8/8/8/8/8 w - - 0 1'), { + a8: 'win', + })); +}); + +describe('endgame glyphs on non-terminal positions', () => { + test('crazyhouse check that can be blocked by a drop', () => + assert.deepEqual(kingGlyphs('crazyhouse', 'R5k1/5ppp/8/8/8/8/8/6K1[n] b - - 1 1'), {})); + + test('last move of a resigned game', () => + assert.deepEqual( + kingGlyphs('standard', 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', { + isLast: true, + winner: 'black', + status: 'resign', + }), + { e8: 'win', e1: 'resign' }, + )); + + test('mid-game position', () => + assert.deepEqual( + kingGlyphs('standard', 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1'), + {}, + )); +});