From 8e8500f7ac77757d54d5118c278369279f4e1ade Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 26 Sep 2026 10:47:45 +0200 Subject: [PATCH] fix endgame glyphs for variant wins - closes #21810 A terminal position with no legal moves and no check was always shown as stalemate, so atomic explosions, antichess, king of the hill, racing kings and horde wins got the stalemate glyph. Use the chessops outcome of the position instead, and don't treat a crazyhouse check that can be blocked by a drop as mate. --- ui/lib/src/game/endgame.ts | 28 ++++----- ui/lib/tests/endgame.test.ts | 111 +++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 ui/lib/tests/endgame.test.ts 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'), + {}, + )); +});