diff --git a/ui/game/src/view/status.ts b/ui/game/src/view/status.ts index 24e3f7e9909f2..e4a6403a5214e 100644 --- a/ui/game/src/view/status.ts +++ b/ui/game/src/view/status.ts @@ -1,7 +1,9 @@ import { FEN } from 'chessground/types'; import { Ctrl } from '../interfaces'; -function bishopOnColor(expandedFen: string, offset: 0 | 1): boolean { +export function bishopOnColor(expandedFen: string, offset: 0 | 1): boolean { + if (expandedFen.length !== 64) throw new Error('Expanded FEN expected to be 64 characters'); + for (let row = 0; row < 8; row++) { for (let col = row % 2 === offset ? 0 : 1; col < 8; col += 2) { if (/[bB]/.test(expandedFen[row * 8 + col])) return true; @@ -10,7 +12,14 @@ function bishopOnColor(expandedFen: string, offset: 0 | 1): boolean { return false; } -function insufficientMaterial(variant: VariantKey, fullFen: FEN): boolean { +export function expandFen(fullFen: FEN): string { + return fullFen + .split(' ')[0] + .replace(/\d/g, n => '1'.repeat(+n)) + .replace(/\//g, ''); +} + +export function insufficientMaterial(variant: VariantKey, fullFen: FEN): boolean { // TODO: atomic and antichess if ( variant === 'horde' || @@ -18,18 +27,17 @@ function insufficientMaterial(variant: VariantKey, fullFen: FEN): boolean { variant === 'racingKings' || variant === 'crazyhouse' || variant === 'atomic' || - variant === 'antichess' + variant === 'antichess' || + variant === 'threeCheck' ) return false; - let fen = fullFen.split(' ')[0].replace(/[^a-z]/gi, ''); - if (/^[Kk]{2}$/.test(fen)) return true; - if (variant === 'threeCheck') return false; - if (/[prq]/i.test(fen)) return false; - if (/n/.test(fen)) return fen.length - fen.replace(/[a-z]/g, '').length <= 2 && !/[PBNR]/.test(fen); - if (/N/.test(fen)) return fen.length - fen.replace(/[A-Z]/g, '').length <= 2 && !/[pbnr]/.test(fen); - if (/b/i.test(fen)) { - for (let i = 8; i > 1; i--) fen = fen.replace('' + i, '1' + (i - 1)); - return (!bishopOnColor(fen, 0) || !bishopOnColor(fen, 1)) && !/[pPnN]/.test(fen); + const pieces = fullFen.split(' ')[0].replace(/[^a-z]/gi, ''); + if (/^[Kk]{2}$/.test(pieces)) return true; + if (/[prq]/i.test(pieces)) return false; + if (/^[KkNn]{3}$/.test(pieces)) return true; + if (/b/i.test(pieces)) { + const expandedFen = expandFen(fullFen); + return (!bishopOnColor(expandedFen, 0) || !bishopOnColor(expandedFen, 1)) && !/[pPnN]/.test(pieces); } return false; } diff --git a/ui/game/tests/status.test.ts b/ui/game/tests/status.test.ts new file mode 100644 index 0000000000000..028cf4604a7a5 --- /dev/null +++ b/ui/game/tests/status.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import { bishopOnColor, expandFen, insufficientMaterial } from '../src/view/status'; + +describe('expand fen', () => { + test('starting position', () => + expect(expandFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')).toBe( + 'rnbqkbnrpppppppp11111111111111111111111111111111PPPPPPPPRNBQKBNR', + )); + test('middlegame position', () => + expect(expandFen('r2q1rk1/p3ppbp/2pp1np1/2n5/2P3b1/1P1BPN2/PB1N1PPP/2RQ1RK1 w HAhq - 0 1')).toBe( + 'r11q1rk1p111ppbp11pp1np111n1111111P111b11P1BPN11PB1N1PPP11RQ1RK1', + )); +}); + +describe('bishop on color', () => { + test('bishop on square', () => { + expect(bishopOnColor(expandFen('B7/8/8/8/8/8/8/8 w - - 0 1'), 0)).toBe(true); + expect(bishopOnColor(expandFen('2B5/8/8/8/8/8/8/8 w - - 0 1'), 0)).toBe(true); + expect(bishopOnColor(expandFen('3B4/8/8/8/8/8/8/8 w - - 0 1'), 1)).toBe(true); + expect(bishopOnColor(expandFen('2BB4/8/8/8/8/8/8/8 w - - 0 1'), 1)).toBe(true); + }); + test('no bishops on black squares', () => { + expect(bishopOnColor(expandFen('B7/8/8/8/8/8/8/8 w - - 0 1'), 1)).toBe(false); + expect(bishopOnColor(expandFen('2B5/8/8/8/8/8/8/8 w - - 0 1'), 1)).toBe(false); + expect(bishopOnColor(expandFen('5K2/8/8/1B6/8/k7/6b1/8 w - - 0 39'), 1)).toBe(false); + }); +}); + +describe('test insufficient material', () => { + test('K vs K', () => expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/8/4K3 w - - 0 1')).toBe(true)); + + test('KB vs K', () => + expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/8/4KB2 w - - 0 1')).toBe(true)); + + test('KBB vs K (same color bishops)', () => + expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/6B1/4K2B w - - 0 1')).toBe(true)); + + test('KB vs KB (same color bishops)', () => + expect(insufficientMaterial('standard', 'k7/8/1b6/8/8/8/1B6/K7 w - - 0 1')).toBe(true)); +}); + +describe('should not be insufficient material', () => { + test.each([ + ['horde'], + ['kingOfTheHill'], + ['racingKings'], + ['crazyhouse'], + ['atomic'], + ['antichess'], + ['threeCheck'], + ])('variant %s', variant => + expect(insufficientMaterial(variant, 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')).toBe( + false, + ), + ); + + test('pawn is never insufficient material', () => + expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/7P/4K3 w - - 0 1')).toBe(false)); + + test('rook is never insufficient material', () => + expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/7R/4K3 w - - 0 1')).toBe(false)); + + test('queen is never insufficient material', () => + expect(insufficientMaterial('standard', '4k3/8/8/8/8/8/7Q/4K3 w - - 0 1')).toBe(false)); + + test('KBB vs K (diff color bishops)', () => { + expect(insufficientMaterial('standard', '8/8/1B6/8/1KB5/8/2k5/8 b - - 100 103')).toBe(false); + expect(insufficientMaterial('standard', '8/8/1B6/8/1KB5/8/2k5/8')).toBe(false); + }); + + test('KB vs KN', () => + expect(insufficientMaterial('standard', 'kn6/8/8/8/8/8/8/KB6 w - - 0 1')).toBe(false)); + + test('KB vs KB (diff color bishops)', () => + expect(insufficientMaterial('standard', 'k7/1b6/8/8/8/8/1B6/K7 w - - 0 1')).toBe(false)); +}); + +describe('knight rules', () => { + test('KN vs K', () => expect(insufficientMaterial('standard', 'k7/8/1n6/8/8/8/8/K7 w - - 0 1')).toBe(true)); + test('KNN vs K', () => + expect(insufficientMaterial('standard', 'k7/8/1nn5/8/8/8/8/K7 w - - 0 1')).toBe(false)); +}); + +describe('scalachess fens from AutodrawTest.scala', () => { + test.each([['5K2/8/8/1B6/8/k7/6b1/8 w - - 0 39']])('should detect insufficient material', fen => { + expect(insufficientMaterial('standard', fen)).toBe(true); + }); + + test.each([ + ['1n2k1n1/8/8/8/8/8/8/4K3 w - - 0 1'], + ['7K/5k2/7P/6n1/8/8/8/8 b - - 0 40'], + ['1b1b3K/8/5k1P/8/8/8/8/8 b - - 0 40'], + ['b2b3K/8/5k1Q/8/8/8/8/8 b - -'], + ['1b1b3K/8/5k1Q/8/8/8/8/8 b - -'], + ['8/8/5N2/8/6p1/8/5K1p/7k w - - 0 37'], + ['8/8/8/4N3/4k1p1/6K1/8/3b4 w - - 5 59'], + ['8/8/3Q4/2bK4/B7/8/8/k7 b - - 0 67'], + ])('should not detect insufficient material', fen => { + expect(insufficientMaterial('standard', fen)).toBe(false); + }); +});