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.
This commit is contained in:
dualfroz committed 2026-09-26 10:47:45 +02:00
1 parent 3f6c487976
commit 8e8500f7ac
2 files changed
+122 -17

No files matched your search

+11 -17
View File
@@ -1,9 +1,10 @@
import type { DrawShape } from '@lichess-org/chessground/draw'; 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 { parseFen } from 'chessops/fen';
import { makeSquare, opposite } from 'chessops/util'; import { makeSquare, opposite } from 'chessops/util';
import { fenColor } from './chess'; import type { PositionResult } from '@/tree/types';
import { endgameGlyphs } from './glyphs'; import { endgameGlyphs } from './glyphs';
import type { StatusName } from './status'; import type { StatusName } from './status';
@@ -14,31 +15,24 @@ export interface EndgameResult {
type EndgameNode = { type EndgameNode = {
fen: FEN; fen: FEN;
pos: () => PositionResult;
outcome: () => Outcome | undefined; outcome: () => Outcome | undefined;
check: () => boolean;
dests: () => Dests; dests: () => Dests;
}; };
const terminalStatus = (pos: Position, outcome: Outcome): StatusName =>
outcome.winner ? (pos.isCheckmate() ? 'mate' : 'variantEnd') : pos.isStalemate() ? 'stalemate' : 'draw';
const endgameResult = ( const endgameResult = (
node: EndgameNode, node: EndgameNode,
isLast: boolean, isLast: boolean,
gameWinner: Color | undefined, gameWinner: Color | undefined,
gameStatus: StatusName, gameStatus: StatusName,
): EndgameResult => { ): EndgameResult => {
const isTerminal = node.dests().size === 0, const outcome = node.dests().size === 0 ? node.outcome() : undefined;
isMate = node.check() && isTerminal, if (outcome) return { winner: outcome.winner, status: terminalStatus(node.pos().unwrap(), outcome) };
isStalemate = !node.check() && isTerminal, if (!isLast) return {};
isGameEnd = isLast || isTerminal; return { winner: node.outcome()?.winner ?? gameWinner, status: gameStatus };
if (!isGameEnd) return {};
return {
winner: isMate
? opposite(fenColor(node.fen))
: isStalemate
? undefined
: (node.outcome()?.winner ?? gameWinner),
status: isMate ? 'mate' : isStalemate ? 'stalemate' : gameStatus,
};
}; };
export function endgameShapesForNode( export function endgameShapesForNode(
+111
View File
@@ -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'),
{},
));
});