M2: 체스·장기 추가
- 체스(chess.js 1.4.0, BSD-2): 캐슬링·앙파상·승진·체크메이트·스테일메이트·3회 반복·50/75수·기물 부족, 피셔 시계(상대가 메이트 불가면 시간패 대신 무승부), 무르기·무승부·기권, 직접 그린 SVG 기물, 클릭·드래그 이동, 승진 선택, 판 돌리기 (테스트 40개, perft 포함) - 장기: 궁성 대각·포다리·멱·장군 합법성·빅장(KJF/KJA/끔)·반복 수 처리·한수쉼·점수 판정(한 덤 1.5), 상차림 단계(순서/동시 비공개), 초읽기, 팔각 기물(한자+한글), 한 쪽이면 판 뒤집기 (테스트 46개) - 게임 공통 E2E에 체스·장기 추가(PC 방장 + 휴대폰 친구, 상차림→첫 수) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
378
packages/games/src/chess/chess.test.ts
Normal file
378
packages/games/src/chess/chess.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||
import { chess, type ChessAction, type ChessOptions, type ChessState } from './index';
|
||||
import { START_FEN, adapterPerft, cannotMate, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules';
|
||||
|
||||
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
|
||||
|
||||
/** White = 'A', black = 'B'. Both clocks already running unless `fresh`. */
|
||||
function make(opts: Partial<ChessOptions> = {}, fen = START_FEN, fresh = false): ChessState {
|
||||
const options = chess.optionsSchema.parse({ colorAssignment: 'hostWhite', ...opts });
|
||||
const s = chess.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 });
|
||||
s.startFen = fullFen(load(fen));
|
||||
s.fen = s.startFen;
|
||||
s.repetition = { [keyOf(load(fen))]: 1 };
|
||||
if (!fresh) s.clock.started = { white: true, black: true };
|
||||
return s;
|
||||
}
|
||||
|
||||
const turnPlayer = (s: ChessState) => (s.fen.split(' ')[1] === 'w' ? 'A' : 'B');
|
||||
|
||||
function act(s: ChessState, actor: string, a: ChessAction, now = 1000): ChessState {
|
||||
const v = chess.validate(s, actor, a);
|
||||
if (!v.ok) throw new Error(`${JSON.stringify(a)}: ${v.reason}`);
|
||||
return chess.apply(s, actor, a, { rng: rng(), now }).state;
|
||||
}
|
||||
|
||||
/** Play UCI moves ("e2e4", "a7a8q") for whoever is to move. */
|
||||
function play(s: ChessState, ...ucis: string[]): ChessState {
|
||||
for (const u of ucis) {
|
||||
const a: ChessAction = { type: 'move', from: u.slice(0, 2), to: u.slice(2, 4), ...(u[4] ? { promotion: u[4] as 'q' } : {}) };
|
||||
s = act(s, turnPlayer(s), a);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const tryMove = (s: ChessState, uci: string) =>
|
||||
chess.validate(s, turnPlayer(s), { type: 'move', from: uci.slice(0, 2), to: uci.slice(2, 4), ...(uci[4] ? { promotion: uci[4] as 'q' } : {}) });
|
||||
const canCastle = (s: ChessState, side: 'king' | 'queen') => chess.validate(s, turnPlayer(s), { type: 'castle', side }).ok;
|
||||
|
||||
describe('perft (§8.0)', () => {
|
||||
const KIWI = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1';
|
||||
test('initial position depth 1-4 (chess.js generator on adapter-loaded position)', () => {
|
||||
expect([1, 2, 3, 4].map((d) => load(START_FEN).perft(d))).toEqual([20, 400, 8902, 197281]);
|
||||
});
|
||||
test('Kiwipete depth 1-3', () => {
|
||||
expect([1, 2, 3].map((d) => load(KIWI).perft(d))).toEqual([48, 2039, 97862]);
|
||||
});
|
||||
test('adapter move list (promotion collapse, castling) reproduces perft', () => {
|
||||
expect([1, 2, 3].map((d) => adapterPerft(START_FEN, d))).toEqual([20, 400, 8902]);
|
||||
expect([1, 2].map((d) => adapterPerft(KIWI, d))).toEqual([48, 2039]);
|
||||
// Promotion-heavy position (chessprogramming.org "position 4"): 6 / 264.
|
||||
expect([1, 2].map((d) => adapterPerft('r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1', d))).toEqual([6, 264]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('castling (§5.2)', () => {
|
||||
test('both sides allowed in the base position; castle action and king two-step both work', () => {
|
||||
const s = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1');
|
||||
expect(canCastle(s, 'king')).toBe(true);
|
||||
expect(canCastle(s, 'queen')).toBe(true);
|
||||
const a = act(s, 'A', { type: 'castle', side: 'king' });
|
||||
expect(a.fen.split(' ')[0]).toBe('r3k2r/8/8/8/8/8/8/R4RK1');
|
||||
expect(a.moves[0]!.san).toBe('O-O');
|
||||
const b = play(s, 'e1c1');
|
||||
expect(b.fen.split(' ')[0]).toBe('r3k2r/8/8/8/8/8/8/2KR3R');
|
||||
});
|
||||
test('forbidden while in check, through or into an attacked square', () => {
|
||||
const inCheck = make({}, 'k3r3/8/8/8/8/8/8/R3K2R w KQ - 0 1');
|
||||
expect(canCastle(inCheck, 'king')).toBe(false);
|
||||
expect(canCastle(inCheck, 'queen')).toBe(false);
|
||||
expect(tryMove(inCheck, 'e1g1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
|
||||
const through = make({}, 'k4r2/8/8/8/8/8/8/R3K2R w KQ - 0 1');
|
||||
expect(canCastle(through, 'king')).toBe(false);
|
||||
expect(canCastle(through, 'queen')).toBe(true);
|
||||
const into = make({}, 'k5r1/8/8/8/8/8/8/R3K2R w KQ - 0 1');
|
||||
expect(canCastle(into, 'king')).toBe(false);
|
||||
const d1 = make({}, 'k2r4/8/8/8/8/8/8/R3K2R w KQ - 0 1');
|
||||
expect(canCastle(d1, 'queen')).toBe(false);
|
||||
});
|
||||
test('queenside is allowed when only b1 is attacked', () => {
|
||||
expect(canCastle(make({}, 'kr6/8/8/8/8/8/8/R3K2R w KQ - 0 1'), 'queen')).toBe(true);
|
||||
});
|
||||
test('rook or king that has moved loses the right', () => {
|
||||
const base = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1');
|
||||
const rook = play(base, 'h1h2', 'a8a7', 'h2h1', 'a7a8');
|
||||
expect(canCastle(rook, 'king')).toBe(false);
|
||||
expect(canCastle(rook, 'queen')).toBe(true);
|
||||
const king = play(base, 'e1e2', 'a8a7', 'e2e1', 'a7a8');
|
||||
expect(canCastle(king, 'king')).toBe(false);
|
||||
expect(canCastle(king, 'queen')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('en passant (§5.2)', () => {
|
||||
test('only immediately after the double step', () => {
|
||||
const s = play(make({}, '4k3/3p4/8/4P3/8/8/8/4K3 b - - 0 1'), 'd7d5');
|
||||
expect(tryMove(s, 'e5d6').ok).toBe(true);
|
||||
const done = play(s, 'e5d6');
|
||||
expect(done.fen.split(' ')[0]).toBe('4k3/8/3P4/8/8/8/8/4K3');
|
||||
expect(done.moves[1]!.captured).toBe('p');
|
||||
const late = play(s, 'e1e2', 'e8e7');
|
||||
expect(tryMove(late, 'e5d6')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' });
|
||||
});
|
||||
test('illegal when it exposes the own king along the rank', () => {
|
||||
const s = play(make({}, '7k/3p4/8/K3P2r/8/8/8/8 b - - 0 1'), 'd7d5');
|
||||
expect(tryMove(s, 'e5d6')).toEqual({ ok: false, reason: '킹이 공격받게 되어 둘 수 없어요.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('promotion (§5.2)', () => {
|
||||
const P = '7k/P7/8/8/8/8/8/K7 w - - 0 1';
|
||||
test('all four pieces, missing piece rejected, capture-promotion', () => {
|
||||
for (const p of ['q', 'r', 'b', 'n']) {
|
||||
const s = play(make({}, P), `a7a8${p}`);
|
||||
expect(s.fen.split(' ')[0]!.startsWith(p.toUpperCase())).toBe(true);
|
||||
}
|
||||
expect(tryMove(make({}, P), 'a7a8')).toEqual({ ok: false, reason: '승진할 기물을 골라 주세요.' });
|
||||
const cap = play(make({}, '1r5k/P7/8/8/8/8/8/K7 w - - 0 1'), 'a7b8n');
|
||||
expect(cap.moves[0]!.san).toBe('axb8=N');
|
||||
});
|
||||
test('autoQueen: promotion may be omitted and becomes a queen', () => {
|
||||
const s = play(make({ autoQueen: true }, P), 'a7a8');
|
||||
expect(s.fen.startsWith('Q6k')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game end (§5.3, §6)', () => {
|
||||
test("scholar's mate: checkmate in 4, PGN and summary", () => {
|
||||
const s = play(make(), 'e2e4', 'e7e5', 'f1c4', 'b8c6', 'd1h5', 'g8f6', 'h5f7');
|
||||
expect(s.result).toMatchObject({ winner: 'white', reason: 'checkmate', moveCount: 4, summary: '백 승 (체크메이트, 4수)' });
|
||||
expect(s.result!.pgn).toContain('1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0');
|
||||
expect(chess.result(s)).toEqual({ ranking: [['A'], ['B']], summary: '백 승 (체크메이트, 4수)', reason: 'normal' });
|
||||
});
|
||||
test('queen stalemate', () => {
|
||||
const s = play(make({}, 'k7/8/2K5/8/8/8/8/1Q6 w - - 0 1'), 'b1b6');
|
||||
expect(s.result).toMatchObject({ winner: null, reason: 'stalemate' });
|
||||
expect(chess.result(s)!.ranking).toEqual([['A', 'B']]);
|
||||
});
|
||||
test('rejections: wrong turn, wrong piece, impossible move, not a player', () => {
|
||||
const s = make();
|
||||
expect(chess.validate(s, 'B', { type: 'move', from: 'e7', to: 'e5' })).toEqual({ ok: false, reason: '지금은 상대 차례예요.' });
|
||||
expect(tryMove(s, 'e7e5')).toEqual({ ok: false, reason: '내 기물을 골라 주세요.' });
|
||||
expect(tryMove(s, 'e2e5')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' });
|
||||
expect(chess.validate(s, 'Z', { type: 'resign' }).ok).toBe(false);
|
||||
const check = make({}, '4k3/8/8/8/8/8/3P4/r3K3 w - - 0 1');
|
||||
expect(tryMove(check, 'd2d3')).toEqual({ ok: false, reason: '체크를 먼저 막아야 해요.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('repetition (§6 3)', () => {
|
||||
test('auto: third occurrence draws; unusable e.p. square is ignored', () => {
|
||||
const s = make();
|
||||
const after = play(s, 'e2e4', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8');
|
||||
expect(after.phase).toBe('playing');
|
||||
const end = play(after, 'f3g1');
|
||||
expect(end.result?.reason).toBe('threefold');
|
||||
expect(positionKey(load('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').fen())).toBe(
|
||||
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq -',
|
||||
);
|
||||
});
|
||||
test('different castling rights make a different position', () => {
|
||||
const s = make({}, '4k3/8/8/8/8/8/8/4K2R w K - 0 1');
|
||||
const cycle = ['h1h2', 'e8d8', 'h2h1', 'd8e8'];
|
||||
const twice = play(s, ...cycle, ...cycle);
|
||||
// The start placement has now been seen 3×, but the first time with castling rights → no draw yet.
|
||||
expect(twice.phase).toBe('playing');
|
||||
expect(twice.repetition[keyOf(load('4k3/8/8/8/8/8/8/4K2R w K - 0 1'))]).toBe(1);
|
||||
expect(twice.repetition[keyOf(load('4k3/8/8/8/8/8/8/4K2R w - - 0 1'))]).toBe(2);
|
||||
expect(play(twice, 'h1h2').result?.reason).toBe('threefold');
|
||||
});
|
||||
test('fide: threefold is claimable by the side to move, fivefold is automatic', () => {
|
||||
const cycle = ['g1f3', 'g8f6', 'f3g1', 'f6g8'];
|
||||
let s = play(make({ drawMode: 'fide' }), ...cycle, ...cycle);
|
||||
expect(s.phase).toBe('playing');
|
||||
expect(s.claimable).toBe('threefold');
|
||||
expect(chess.validate(s, 'B', { type: 'claimDraw' }).ok).toBe(false);
|
||||
const claimed = act(s, 'A', { type: 'claimDraw' });
|
||||
expect(claimed.result?.reason).toBe('threefold');
|
||||
s = play(s, ...cycle, ...cycle);
|
||||
expect(s.result?.reason).toBe('fivefold');
|
||||
expect(chess.validate(make(), 'A', { type: 'claimDraw' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('50/75 moves (§6 4)', () => {
|
||||
test('auto: 100th quiet ply draws, but checkmate on that ply wins', () => {
|
||||
expect(play(make({}, 'k7/8/8/8/8/8/8/KR6 w - - 99 80'), 'b1b2').result?.reason).toBe('fiftyMove');
|
||||
expect(play(make({}, '7k/8/6K1/8/8/8/8/R7 w - - 99 80'), 'a1a8').result?.reason).toBe('checkmate');
|
||||
});
|
||||
test('fide: claimable at 100, automatic at 150', () => {
|
||||
const s = play(make({ drawMode: 'fide' }, 'k7/8/8/8/8/8/8/KR6 w - - 99 80'), 'b1b2');
|
||||
expect(s.phase).toBe('playing');
|
||||
expect(s.claimable).toBe('fiftyMove');
|
||||
expect(act(s, 'B', { type: 'claimDraw' }).result?.reason).toBe('fiftyMove');
|
||||
expect(play(make({ drawMode: 'fide' }, 'k7/8/8/8/8/8/8/KR6 w - - 149 80'), 'b1b2').result?.reason).toBe('seventyFiveMove');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insufficient material (§6 2)', () => {
|
||||
test.each([
|
||||
['K vs K', '8/8/8/4k3/8/8/8/4K3 w - - 0 1', true],
|
||||
['KB vs K', '8/8/8/4k3/8/8/8/4KB2 w - - 0 1', true],
|
||||
['KN vs K', '8/8/8/4k3/8/8/8/4KN2 w - - 0 1', true],
|
||||
['KB vs KB same colour', '8/8/8/4k3/8/3b4/8/4KB2 w - - 0 1', true],
|
||||
['KB vs KB opposite colour', '8/8/8/4k3/8/2b5/8/4KB2 w - - 0 1', false],
|
||||
['KNN vs K', '8/8/8/4k3/8/8/8/3NKN2 w - - 0 1', false],
|
||||
['KP vs K', '8/8/8/4k3/8/8/4P3/4K3 w - - 0 1', false],
|
||||
])('%s', (_n, fen, dead) => {
|
||||
expect(insufficientMaterial(load(fen))).toBe(dead);
|
||||
});
|
||||
test('capturing the last piece ends the game drawn', () => {
|
||||
const s = play(make({}, 'k7/8/8/8/8/8/1q6/K7 w - - 0 1'), 'a1b2');
|
||||
expect(s.result?.reason).toBe('insufficientMaterial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clocks and time (§8.5)', () => {
|
||||
const tc = { kind: 'fischer' as const, baseMin: 1, incSec: 5 };
|
||||
test('first move is free, increment added after a move, a late move loses instead of being played', () => {
|
||||
let s = make({ timeControl: tc }, START_FEN, true);
|
||||
expect(chess.deadline!(s)).toBe(30_000);
|
||||
s = play1(s, 'e2e4', 10_000); // white first move: no charge
|
||||
expect(s.clock.remainingMs.white).toBe(60_000);
|
||||
expect(chess.deadline!(s)).toBe(40_000); // black's first-move limit
|
||||
s = play1(s, 'e7e5', 20_000);
|
||||
expect(chess.deadline!(s)).toBe(80_000); // white's main clock now runs
|
||||
s = play1(s, 'g1f3', 30_000);
|
||||
expect(s.clock.remainingMs.white).toBe(55_000);
|
||||
s = play1(s, 'b8c6', 100_000); // black used 70s of 60s
|
||||
expect(s.moves).toHaveLength(3);
|
||||
expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
|
||||
expect(chess.result(s)!.reason).toBe('timeout');
|
||||
});
|
||||
test('first-move limit expiry aborts with an empty ranking (white or black)', () => {
|
||||
const s = make({}, START_FEN, true);
|
||||
const t = act(s, 'A', chess.onTimeout(s, 'A'));
|
||||
expect(t.result?.reason).toBe('aborted');
|
||||
expect(chess.result(t)!.ranking).toEqual([]);
|
||||
const b = play1(make({}, START_FEN, true), 'e2e4', 1000);
|
||||
expect(act(b, 'B', { type: 'timeout' }).result?.reason).toBe('aborted');
|
||||
const late = play1(make({ firstMoveSec: 15 }, START_FEN, true), 'e2e4', 16_000);
|
||||
expect(late.result?.reason).toBe('aborted');
|
||||
});
|
||||
test('time-out exception: opponent with a bare king draws, K+N vs K+Q still wins', () => {
|
||||
const bare = act(make({ timeControl: tc }, '4k3/8/8/8/8/8/8/Q3K3 w - - 0 1'), 'A', { type: 'timeout' });
|
||||
expect(bare.result).toMatchObject({ winner: null, reason: 'timeoutVsInsufficient' });
|
||||
const knight = act(make({ timeControl: tc }, '4k3/8/5n2/8/8/8/8/Q3K3 w - - 0 1'), 'A', { type: 'timeout' });
|
||||
expect(knight.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
|
||||
expect(cannotMate(load('4k3/8/5n2/8/8/8/8/4K3 w - - 0 1'), 'b')).toBe(true);
|
||||
expect(cannotMate(load('4k3/8/2b5/8/8/8/8/4KB2 w - - 0 1'), 'b')).toBe(true);
|
||||
expect(cannotMate(load('4k3/8/3b4/8/8/8/8/4KB2 w - - 0 1'), 'b')).toBe(false);
|
||||
});
|
||||
test('disconnect grace expiry: the runner fires onTimeout for the player to move → time loss', () => {
|
||||
const s = make({ timeControl: tc });
|
||||
expect(chess.timeoutPlayers!(s)).toEqual(['A']);
|
||||
const a = chess.onTimeout(s, 'A');
|
||||
expect(chess.validate(s, 'A', a).ok).toBe(true);
|
||||
expect(chess.validate(s, 'B', a).ok).toBe(false);
|
||||
expect(act(s, 'A', a).result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
|
||||
});
|
||||
test('no time control: idle limit deadline', () => {
|
||||
const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 }, idleLimitSec: 300 });
|
||||
expect(chess.deadline!(s)).toBe(300_000);
|
||||
});
|
||||
});
|
||||
|
||||
function play1(s: ChessState, uci: string, now: number): ChessState {
|
||||
return act(s, turnPlayer(s), { type: 'move', from: uci.slice(0, 2), to: uci.slice(2, 4) }, now);
|
||||
}
|
||||
|
||||
describe('requests', () => {
|
||||
test('undo 1 ply / 2 plies restores position and repetition; decline changes nothing', () => {
|
||||
let s = play(make({ undo: '3' }), 'g1f3', 'g8f6', 'f3g1', 'f6g8');
|
||||
expect(s.repetition[positionKey(START_FEN)]).toBe(2);
|
||||
s = act(s, 'A', { type: 'requestUndo' });
|
||||
expect(s.undo.pending).toEqual({ by: 'white', plies: 2 });
|
||||
expect(chess.activePlayers(s)).toEqual(['A', 'B']);
|
||||
const declined = act(s, 'B', { type: 'respondUndo', accept: false });
|
||||
expect(declined.fen).toBe(s.fen);
|
||||
expect(declined.moves).toHaveLength(4);
|
||||
expect(declined.undo.usedBy.white).toBe(0);
|
||||
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(s.moves).toHaveLength(2);
|
||||
expect(s.fen).toBe(play(make(), 'g1f3', 'g8f6').fen);
|
||||
expect(s.repetition[positionKey(START_FEN)]).toBe(1);
|
||||
expect(Object.values(s.repetition).reduce((a, b) => a + b, 0)).toBe(3);
|
||||
s = play(s, 'f3g1');
|
||||
s = act(s, 'A', { type: 'requestUndo' });
|
||||
expect(s.undo.pending?.plies).toBe(1);
|
||||
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(s.moves).toHaveLength(2);
|
||||
expect(turnPlayer(s)).toBe('A');
|
||||
});
|
||||
test('undo limit and pending request cleared by a move', () => {
|
||||
let s = play(make({ undo: '1' }), 'e2e4');
|
||||
s = act(act(s, 'A', { type: 'requestUndo' }), 'B', { type: 'respondUndo', accept: true });
|
||||
s = play(s, 'd2d4');
|
||||
expect(chess.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' });
|
||||
s = play(s, 'd7d5');
|
||||
s = act(s, 'B', { type: 'requestUndo' });
|
||||
s = play(s, 'c2c4');
|
||||
expect(s.undo.pending).toBeNull();
|
||||
expect(chess.validate(make({ undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false);
|
||||
});
|
||||
test('draw offer: survives the offerer moving, auto-declined when the opponent moves, re-offer gap, accept', () => {
|
||||
let s = act(make(), 'A', { type: 'offerDraw' });
|
||||
s = play(s, 'e2e4');
|
||||
expect(s.draw.pendingBy).toBe('white');
|
||||
s = play(s, 'e7e5');
|
||||
expect(s.draw.pendingBy).toBeNull();
|
||||
expect(chess.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
|
||||
s = play(s, 'g1f3', 'b8c6', 'f1c4', 'g8f6', 'b1c3');
|
||||
expect(chess.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true);
|
||||
s = act(s, 'B', { type: 'offerDraw' });
|
||||
s = act(s, 'A', { type: 'respondDraw', accept: true });
|
||||
expect(chess.result(s)).toMatchObject({ ranking: [['A', 'B']], reason: 'draw-agreed' });
|
||||
});
|
||||
test('resign: loss after your first move, abort before it', () => {
|
||||
const s = play(make({}, START_FEN, true), 'e2e4');
|
||||
expect(chess.result(act(s, 'A', { type: 'resign' }))!.ranking).toEqual([['B'], ['A']]);
|
||||
expect(act(s, 'B', { type: 'resign' }).result?.reason).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('views and playouts', () => {
|
||||
test('legalMoves / threatened only for the player to move; no rng anywhere', () => {
|
||||
const s = make({ hints: 'movesAndThreats' }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
|
||||
const mine = chess.view(s, 'A');
|
||||
expect(mine.legalMoves?.length).toBeGreaterThan(0);
|
||||
expect(mine.threatened).toEqual(['d2']);
|
||||
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) {
|
||||
expect(v.legalMoves).toBeNull();
|
||||
expect(v.threatened).toBeNull();
|
||||
}
|
||||
expect(chess.view(make(), 'A').threatened).toBeNull();
|
||||
for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) expect(JSON.stringify(v)).not.toMatch(/rng/i);
|
||||
expect(JSON.stringify(s)).not.toMatch(/rng/i);
|
||||
expect(threatenedSquares(load('4k3/8/8/3q4/8/8/3N4/K2Q4 w - - 0 1'))).toEqual([]);
|
||||
});
|
||||
test('view shape: board, check square, captured pieces and material, promotion flag', () => {
|
||||
const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5');
|
||||
const v = chess.view(s, null);
|
||||
expect(v.board[0]).toBe('r');
|
||||
expect(v.board[63]).toBe('R');
|
||||
expect(v.captured).toEqual({ white: ['p'], black: ['p'] });
|
||||
expect(v.materialDiff).toBe(0);
|
||||
expect(v.lastMove).toEqual({ from: 'd5', to: 'e5' });
|
||||
expect(v.checkSquare).toBe('e1');
|
||||
const p = chess.view(make({}, '7k/P7/8/8/8/8/8/K7 w - - 0 1'), 'A');
|
||||
expect(p.legalMoves).toContainEqual({ from: 'a7', to: 'a8', promotion: true });
|
||||
expect(p.legalMoves!.filter((m) => m.from === 'a7')).toHaveLength(1);
|
||||
});
|
||||
test('colour assignment', () => {
|
||||
const p = (o: Partial<ChessOptions>, gameNo?: number) =>
|
||||
chess.setup({ players: ['H', 'G'], options: chess.optionsSchema.parse(o), rng: rng(), now: 0, gameNo }).players.white;
|
||||
expect(p({ colorAssignment: 'hostWhite' })).toBe('H');
|
||||
expect(p({ colorAssignment: 'hostBlack' })).toBe('G');
|
||||
expect([1, 2].map((n) => p({ colorAssignment: 'alternate' }, n))).toEqual(['H', 'G']);
|
||||
});
|
||||
test('random playouts finish, are deterministic and JSON round-trip', () => {
|
||||
const runs: [Partial<ChessOptions>, number][] = [
|
||||
[{}, 4],
|
||||
[{ drawMode: 'fide', undo: '3' }, 3],
|
||||
];
|
||||
for (const [o, n] of runs) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const options = chess.optionsSchema.parse(o);
|
||||
const a = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
|
||||
expect(a.finished).toBe(true);
|
||||
expect(chess.result(a.finalState as ChessState)).not.toBeNull();
|
||||
if (i === 0) {
|
||||
const b = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
|
||||
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
643
packages/games/src/chess/index.ts
Normal file
643
packages/games/src/chess/index.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 체스 (docs/games/chess.md). Standard chess only: chess.js 1.4.0 has no Chess960 castling support, so the
|
||||
* `variant` option is left out until a 960-capable generator is chosen (§8.0).
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Chess, Square } from 'chess.js';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
START_FEN,
|
||||
boardArray,
|
||||
buildPgn,
|
||||
cannotMate,
|
||||
halfmoveClock,
|
||||
insufficientMaterial,
|
||||
isSquare,
|
||||
kingSquare,
|
||||
legalMoves,
|
||||
load,
|
||||
materialDiff,
|
||||
other,
|
||||
fullFen,
|
||||
keyOf,
|
||||
positionKey,
|
||||
pseudoReachable,
|
||||
threatenedSquares,
|
||||
toSide,
|
||||
type Color,
|
||||
type LegalMove,
|
||||
type Promo,
|
||||
} from './rules';
|
||||
|
||||
export type { Color, LegalMove, Promo } from './rules';
|
||||
const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' };
|
||||
|
||||
export const chessOptionsSchema = z.object({
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z
|
||||
.enum(['fischer', 'none'])
|
||||
.default('fischer')
|
||||
.meta({ title: '시간 방식', labels: { fischer: '기본 시간 + 수당 추가', none: '시간 제한 없음' } }),
|
||||
baseMin: z.number().int().min(1).max(90).default(10).meta({ title: '기본 시간(분)' }),
|
||||
incSec: z.number().int().min(0).max(60).default(5).meta({ title: '한 수마다 추가(초)' }),
|
||||
})
|
||||
.default({ kind: 'fischer', baseMin: 10, incSec: 5 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
drawMode: z
|
||||
.enum(['auto', 'fide'])
|
||||
.default('auto')
|
||||
.meta({
|
||||
title: '반복·50수 무승부',
|
||||
labels: { auto: '자동 무승부(3회 반복·50수)', fide: 'FIDE(주장해야 무승부, 5회 반복·75수는 자동)' },
|
||||
}),
|
||||
colorAssignment: z
|
||||
.enum(['random', 'hostWhite', 'hostBlack', 'alternate'])
|
||||
.default('random')
|
||||
.meta({ title: '백흑 정하기', labels: { random: '무작위', hostWhite: '방장이 백', hostBlack: '방장이 흑', alternate: '판마다 번갈아' } }),
|
||||
autoQueen: z.boolean().default(false).meta({ title: '승진하면 퀸으로 자동 선택' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('1')
|
||||
.meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||
drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||
firstMoveSec: z
|
||||
.union([z.literal(15), z.literal(30), z.literal(60)])
|
||||
.default(30)
|
||||
.meta({ title: '첫 수 제한(넘기면 대국 취소)', labels: { 15: '15초', 30: '30초', 60: '60초' } }),
|
||||
idleLimitSec: z
|
||||
.union([z.literal(300), z.literal(600)])
|
||||
.default(600)
|
||||
.meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }),
|
||||
hints: z
|
||||
.enum(['off', 'moves', 'movesAndThreats'])
|
||||
.default('moves')
|
||||
.meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시', movesAndThreats: '갈 수 있는 칸 + 위험한 기물 경고' } }),
|
||||
});
|
||||
export type ChessOptions = z.infer<typeof chessOptionsSchema>;
|
||||
|
||||
const sq = z.string().regex(/^[a-h][1-8]$/);
|
||||
export const chessActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('move'), from: sq, to: sq, promotion: z.enum(['q', 'r', 'b', 'n']).optional() }),
|
||||
z.object({ type: z.literal('castle'), side: z.enum(['king', 'queen']) }),
|
||||
z.object({ type: z.literal('claimDraw') }),
|
||||
z.object({ type: z.literal('requestUndo') }),
|
||||
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('offerDraw') }),
|
||||
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('cancelOffer') }),
|
||||
z.object({ type: z.literal('resign') }),
|
||||
]);
|
||||
/** Client-sendable actions plus the system-only timeout action (not in the schema). */
|
||||
export type ChessAction = z.infer<typeof chessActionSchema> | { type: 'timeout' };
|
||||
|
||||
export type ChessReason =
|
||||
| 'checkmate'
|
||||
| 'resign'
|
||||
| 'timeLoss'
|
||||
| 'stalemate'
|
||||
| 'insufficientMaterial'
|
||||
| 'threefold'
|
||||
| 'fivefold'
|
||||
| 'fiftyMove'
|
||||
| 'seventyFiveMove'
|
||||
| 'drawAgreed'
|
||||
| 'timeoutVsInsufficient'
|
||||
| 'aborted';
|
||||
|
||||
export interface ChessResult {
|
||||
winner: Color | null;
|
||||
reason: ChessReason;
|
||||
/** Full moves played. */
|
||||
moveCount: number;
|
||||
pgn: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ChessMoveRecord {
|
||||
uci: string;
|
||||
san: string;
|
||||
color: Color;
|
||||
at: number;
|
||||
fenAfter: string;
|
||||
/** Normalised repetition key of fenAfter. */
|
||||
key: string;
|
||||
captured?: string;
|
||||
}
|
||||
|
||||
export interface ChessState {
|
||||
options: ChessOptions;
|
||||
players: Record<Color, PlayerId>;
|
||||
startFen: string;
|
||||
/** Current position (single source of truth). */
|
||||
fen: string;
|
||||
moves: ChessMoveRecord[];
|
||||
/** positionKey → occurrences, including the start position. */
|
||||
repetition: Record<string, number>;
|
||||
phase: 'playing' | 'finished';
|
||||
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; started: Record<Color, boolean> };
|
||||
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
|
||||
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number | null> };
|
||||
/** drawMode=fide: what the side to move may claim right now. */
|
||||
claimable: 'threefold' | 'fiftyMove' | null;
|
||||
result: ChessResult | null;
|
||||
}
|
||||
|
||||
export interface ChessView {
|
||||
fen: string;
|
||||
/** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */
|
||||
board: (string | null)[];
|
||||
players: Record<Color, PlayerId>;
|
||||
myColor: Color | null;
|
||||
turn: Color;
|
||||
phase: ChessState['phase'];
|
||||
moves: { san: string; uci: string; color: Color }[];
|
||||
lastMove: { from: string; to: string } | null;
|
||||
/** King square of the side to move when it is in check. */
|
||||
checkSquare: string | null;
|
||||
timeControl: ChessOptions['timeControl'];
|
||||
firstMoveSec: number;
|
||||
idleLimitSec: number;
|
||||
remainingMs: Record<Color, number>;
|
||||
turnStartedAt: number;
|
||||
started: Record<Color, boolean>;
|
||||
/** Pieces captured *by* each colour (lowercase letters). */
|
||||
captured: Record<Color, string[]>;
|
||||
/** White material minus black material. */
|
||||
materialDiff: number;
|
||||
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
|
||||
draw: { allowed: boolean; pendingBy: Color | null };
|
||||
drawMode: ChessOptions['drawMode'];
|
||||
claimable: ChessState['claimable'];
|
||||
autoQueen: boolean;
|
||||
hints: ChessOptions['hints'];
|
||||
/** Only for the player to move. */
|
||||
legalMoves: LegalMove[] | null;
|
||||
/** Only for the player to move with hints=movesAndThreats. */
|
||||
threatened: string[] | null;
|
||||
result: ChessResult | null;
|
||||
}
|
||||
|
||||
const undoLimit = (o: ChessOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||
const colorOf = (s: ChessState, p: PlayerId): Color | null =>
|
||||
s.players.white === p ? 'white' : s.players.black === p ? 'black' : null;
|
||||
const turnOf = (s: ChessState): Color => (s.fen.split(' ')[1] === 'b' ? 'black' : 'white');
|
||||
|
||||
const REASON_KO: Record<ChessReason, string> = {
|
||||
checkmate: '체크메이트',
|
||||
resign: '기권',
|
||||
timeLoss: '시간패',
|
||||
stalemate: '스테일메이트',
|
||||
insufficientMaterial: '기물 부족',
|
||||
threefold: '3회 반복',
|
||||
fivefold: '5회 반복',
|
||||
fiftyMove: '50수 규칙',
|
||||
seventyFiveMove: '75수 규칙',
|
||||
drawAgreed: '합의',
|
||||
timeoutVsInsufficient: '시간 초과, 상대 기물 부족',
|
||||
aborted: '대국 취소',
|
||||
};
|
||||
|
||||
function finish(s: ChessState, winner: Color | null, reason: ChessReason, events: GameEvent[], who?: Color): void {
|
||||
s.phase = 'finished';
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
s.claimable = null;
|
||||
const moveCount = Math.ceil(s.moves.length / 2);
|
||||
let summary: string;
|
||||
if (reason === 'aborted') summary = `대국 취소 (${COLOR_KO[who ?? turnOf(s)]}이 첫 수를 두지 않았어요)`;
|
||||
else if (winner) summary = `${COLOR_KO[winner]} 승 (${REASON_KO[reason]}, ${moveCount}수)`;
|
||||
else summary = `무승부 (${REASON_KO[reason]}, ${moveCount}수)`;
|
||||
const tag = reason === 'aborted' ? '*' : winner === 'white' ? '1-0' : winner === 'black' ? '0-1' : '1/2-1/2';
|
||||
const pgn = buildPgn(
|
||||
s.startFen,
|
||||
s.moves.map((m) => m.san),
|
||||
{ White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] },
|
||||
tag,
|
||||
);
|
||||
s.result = { winner, reason, moveCount, pgn, summary };
|
||||
events.push({ type: 'gameEnded', winner, reason });
|
||||
}
|
||||
|
||||
/** Rebuild repetition counts from the start position and the stored positions after each move. */
|
||||
function rebuildRepetition(s: ChessState): void {
|
||||
s.repetition = {};
|
||||
for (const k of [keyOf(load(s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** §8.2 end order: checkmate > stalemate > insufficient > (75/5-fold) > (auto: 50/3-fold) → claimable. */
|
||||
function judge(s: ChessState, c: Chess, events: GameEvent[]): void {
|
||||
const mover = other(turnOf(s));
|
||||
if (c.isCheckmate()) {
|
||||
events.push({ type: 'checkmate', color: mover });
|
||||
return finish(s, mover, 'checkmate', events);
|
||||
}
|
||||
if (c.isStalemate()) {
|
||||
events.push({ type: 'stalemate' });
|
||||
return finish(s, null, 'stalemate', events);
|
||||
}
|
||||
if (insufficientMaterial(c)) return finish(s, null, 'insufficientMaterial', events);
|
||||
const key = keyOf(c);
|
||||
const reps = s.repetition[key] ?? 0;
|
||||
const half = halfmoveClock(s.fen);
|
||||
if (s.options.drawMode === 'fide') {
|
||||
if (half >= 150) return finish(s, null, 'seventyFiveMove', events);
|
||||
if (reps >= 5) return finish(s, null, 'fivefold', events);
|
||||
} else {
|
||||
if (half >= 100) return finish(s, null, 'fiftyMove', events);
|
||||
if (reps >= 3) return finish(s, null, 'threefold', events);
|
||||
}
|
||||
refreshClaimable(s, key);
|
||||
if (s.claimable) events.push({ type: 'drawClaimable', reason: s.claimable });
|
||||
}
|
||||
|
||||
function refreshClaimable(s: ChessState, key: string): void {
|
||||
if (s.phase !== 'playing' || s.options.drawMode !== 'fide') {
|
||||
s.claimable = null;
|
||||
return;
|
||||
}
|
||||
const reps = s.repetition[key] ?? 0;
|
||||
s.claimable = reps >= 3 ? 'threefold' : halfmoveClock(s.fen) >= 100 ? 'fiftyMove' : null;
|
||||
}
|
||||
|
||||
/** Time ran out for `loser`: loss, unless the opponent cannot possibly mate (FIDE 6.9). */
|
||||
function flag(s: ChessState, loser: Color, events: GameEvent[]): void {
|
||||
if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0;
|
||||
events.push({ type: 'timeLoss', color: loser });
|
||||
const winner = other(loser);
|
||||
if (cannotMate(load(s.fen), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events);
|
||||
else finish(s, winner, 'timeLoss', events);
|
||||
}
|
||||
|
||||
function resolveMove(s: ChessState, a: ChessAction): { from: Square; to: Square; promotion?: Promo } | null {
|
||||
if (a.type === 'move') return { from: a.from as Square, to: a.to as Square, promotion: a.promotion };
|
||||
if (a.type !== 'castle') return null;
|
||||
const m = legalMoves(load(s.fen)).find((x) => x.castle === a.side);
|
||||
return m ? { from: m.from as Square, to: m.to as Square } : null;
|
||||
}
|
||||
|
||||
function pendingResponder(s: ChessState): Color | null {
|
||||
if (s.undo.pending) return other(s.undo.pending.by);
|
||||
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOptions> = {
|
||||
id: 'chess',
|
||||
nameKo: '체스',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: chessOptionsSchema.parse({}),
|
||||
optionsSchema: chessOptionsSchema,
|
||||
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
||||
|
||||
setup({ players, options, rng, now, gameNo }) {
|
||||
const [host, guest] = players as [PlayerId, PlayerId];
|
||||
let hostWhite: boolean;
|
||||
switch (options.colorAssignment) {
|
||||
case 'hostWhite':
|
||||
hostWhite = true;
|
||||
break;
|
||||
case 'hostBlack':
|
||||
hostWhite = false;
|
||||
break;
|
||||
case 'alternate':
|
||||
hostWhite = (gameNo ?? 1) % 2 === 1;
|
||||
break;
|
||||
default:
|
||||
hostWhite = rng.int(2) === 0;
|
||||
}
|
||||
const base = options.timeControl.baseMin * 60_000;
|
||||
return {
|
||||
options,
|
||||
players: hostWhite ? { white: host, black: guest } : { white: guest, black: host },
|
||||
startFen: START_FEN,
|
||||
fen: START_FEN,
|
||||
moves: [],
|
||||
repetition: { [positionKey(START_FEN)]: 1 },
|
||||
phase: 'playing',
|
||||
clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } },
|
||||
undo: { usedBy: { white: 0, black: 0 }, pending: null },
|
||||
draw: { pendingBy: null, lastOfferPly: { white: null, black: null } },
|
||||
claimable: null,
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
const me = colorOf(s, actor);
|
||||
if (!me) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
|
||||
const turn = turnOf(s);
|
||||
switch (a.type) {
|
||||
case 'move':
|
||||
case 'castle': {
|
||||
if (turn !== me) return fail('지금은 상대 차례예요.');
|
||||
const c = load(s.fen);
|
||||
if (a.type === 'castle') {
|
||||
return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.');
|
||||
}
|
||||
if (!isSquare(a.from) || !isSquare(a.to)) return fail('판 밖이에요.');
|
||||
const piece = c.get(a.from);
|
||||
if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.');
|
||||
const m = legalMoves(c, a.from).find((x) => x.to === a.to);
|
||||
if (!m) {
|
||||
if (piece.type === 'k' && Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0)) === 2 && a.from[1] === a.to[1]) {
|
||||
return fail('지금은 캐슬링할 수 없어요.');
|
||||
}
|
||||
if (pseudoReachable(c, a.from, a.to)) {
|
||||
return fail(c.inCheck() ? '체크를 먼저 막아야 해요.' : '킹이 공격받게 되어 둘 수 없어요.');
|
||||
}
|
||||
return fail('그 기물은 그렇게 움직일 수 없어요.');
|
||||
}
|
||||
if (m.promotion && !a.promotion && !s.options.autoQueen) return fail('승진할 기물을 골라 주세요.');
|
||||
return ok;
|
||||
}
|
||||
case 'claimDraw':
|
||||
if (s.options.drawMode !== 'fide') return fail('이 방은 반복·50수 무승부가 자동이에요.');
|
||||
if (turn !== me) return fail('무승부 주장은 내 차례에만 할 수 있어요.');
|
||||
if (!s.claimable) return fail('지금은 무승부를 주장할 수 없어요.');
|
||||
return ok;
|
||||
case 'requestUndo':
|
||||
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
|
||||
if (!s.moves.some((m) => m.color === me)) return fail('아직 무를 수가 없어요.');
|
||||
return ok;
|
||||
case 'respondUndo':
|
||||
if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.');
|
||||
return ok;
|
||||
case 'offerDraw': {
|
||||
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
const last = s.draw.lastOfferPly[me];
|
||||
const gap = s.options.drawReofferMoves;
|
||||
if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < gap) {
|
||||
return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case 'respondDraw':
|
||||
if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.');
|
||||
return ok;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.');
|
||||
return ok;
|
||||
case 'resign':
|
||||
return ok;
|
||||
case 'timeout':
|
||||
if (turn !== me) return fail('지금은 상대 차례예요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
const s = structuredClone(prev);
|
||||
const me = colorOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
case 'move':
|
||||
case 'castle': {
|
||||
// 1) Clock. A colour's first move only has the first-move limit; its main clock is not charged.
|
||||
const elapsed = now - s.clock.turnStartedAt;
|
||||
if (!s.clock.started[me]) {
|
||||
if (elapsed > s.options.firstMoveSec * 1000) {
|
||||
finish(s, null, 'aborted', events, me);
|
||||
break;
|
||||
}
|
||||
s.clock.started[me] = true;
|
||||
} else if (s.options.timeControl.kind === 'fischer') {
|
||||
s.clock.remainingMs[me] -= elapsed;
|
||||
if (s.clock.remainingMs[me] <= 0) {
|
||||
flag(s, me, events);
|
||||
break;
|
||||
}
|
||||
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
|
||||
}
|
||||
// 2) Move.
|
||||
const mv = resolveMove(s, a)!;
|
||||
const c = load(s.fen);
|
||||
const isPromo = c.get(mv.from)?.type === 'p' && (mv.to[1] === '8' || mv.to[1] === '1');
|
||||
const done = c.move({ from: mv.from, to: mv.to, promotion: isPromo ? (mv.promotion ?? 'q') : undefined });
|
||||
s.fen = fullFen(c);
|
||||
const key = keyOf(c);
|
||||
s.moves.push({
|
||||
uci: done.lan,
|
||||
san: done.san,
|
||||
color: me,
|
||||
at: now,
|
||||
fenAfter: s.fen,
|
||||
key,
|
||||
...(done.captured ? { captured: done.captured } : {}),
|
||||
});
|
||||
s.repetition[key] = (s.repetition[key] ?? 0) + 1;
|
||||
s.clock.turnStartedAt = now;
|
||||
s.undo.pending = null;
|
||||
if (s.draw.pendingBy && s.draw.pendingBy !== me) {
|
||||
s.draw.pendingBy = null;
|
||||
events.push({ type: 'drawDeclined', auto: true });
|
||||
}
|
||||
const check = c.inCheck();
|
||||
events.push({ type: 'moved', color: me, san: done.san, from: done.from, to: done.to, captured: done.captured ?? null, check });
|
||||
if (done.isPromotion()) events.push({ type: 'promoted', color: me, piece: done.promotion });
|
||||
if (done.isKingsideCastle() || done.isQueensideCastle()) {
|
||||
events.push({ type: 'castled', color: me, side: done.isKingsideCastle() ? 'king' : 'queen' });
|
||||
}
|
||||
if (done.isEnPassant()) events.push({ type: 'enPassant', color: me });
|
||||
if (check) events.push({ type: 'check', color: other(me) });
|
||||
// 3) End of game / claimable.
|
||||
judge(s, c, events);
|
||||
break;
|
||||
}
|
||||
case 'timeout':
|
||||
if (!s.clock.started[me]) finish(s, null, 'aborted', events, me);
|
||||
else flag(s, me, events);
|
||||
break;
|
||||
case 'claimDraw':
|
||||
events.push({ type: 'drawClaimed', color: me, reason: s.claimable });
|
||||
finish(s, null, s.claimable!, events);
|
||||
break;
|
||||
case 'requestUndo': {
|
||||
const last = s.moves[s.moves.length - 1]!;
|
||||
s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 };
|
||||
events.push({ type: 'undoRequested', color: me, plies: s.undo.pending.plies });
|
||||
break;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const { by, plies } = s.undo.pending!;
|
||||
s.undo.pending = null;
|
||||
if (!a.accept) {
|
||||
events.push({ type: 'undoDeclined', color: by });
|
||||
break;
|
||||
}
|
||||
// The clock is not rewound, but the side to move is charged for the time it spent until now.
|
||||
const onTurn = turnOf(s);
|
||||
if (s.clock.started[onTurn] && s.options.timeControl.kind === 'fischer') {
|
||||
s.clock.remainingMs[onTurn] = Math.max(0, s.clock.remainingMs[onTurn] - (now - s.clock.turnStartedAt));
|
||||
}
|
||||
s.moves.splice(s.moves.length - plies, plies);
|
||||
s.fen = s.moves.length ? s.moves[s.moves.length - 1]!.fenAfter : s.startFen;
|
||||
rebuildRepetition(s);
|
||||
s.undo.usedBy[by]++;
|
||||
s.clock.turnStartedAt = now;
|
||||
refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(load(s.startFen)));
|
||||
events.push({ type: 'undoAccepted', color: by, plies });
|
||||
break;
|
||||
}
|
||||
case 'offerDraw':
|
||||
s.draw.pendingBy = me;
|
||||
s.draw.lastOfferPly[me] = s.moves.length;
|
||||
events.push({ type: 'drawOffered', color: me });
|
||||
break;
|
||||
case 'respondDraw':
|
||||
s.draw.pendingBy = null;
|
||||
if (a.accept) {
|
||||
events.push({ type: 'drawAccepted' });
|
||||
finish(s, null, 'drawAgreed', events);
|
||||
} else events.push({ type: 'drawDeclined' });
|
||||
break;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by === me) s.undo.pending = null;
|
||||
if (s.draw.pendingBy === me) s.draw.pendingBy = null;
|
||||
events.push({ type: 'offerCancelled', color: me });
|
||||
break;
|
||||
case 'resign':
|
||||
// Before your own first move a resignation just cancels the game (no result recorded).
|
||||
if (!s.clock.started[me]) finish(s, null, 'aborted', events, me);
|
||||
else {
|
||||
events.push({ type: 'resigned', color: me });
|
||||
finish(s, other(me), 'resign', events);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const myColor = viewer ? colorOf(s, viewer) : null;
|
||||
const turn = turnOf(s);
|
||||
const c = load(s.fen);
|
||||
const mover = s.phase === 'playing' && myColor !== null && myColor === turn;
|
||||
const limit = undoLimit(s.options);
|
||||
const last = s.moves[s.moves.length - 1];
|
||||
const captured: Record<Color, string[]> = { white: [], black: [] };
|
||||
for (const m of s.moves) if (m.captured) captured[m.color].push(m.captured);
|
||||
return {
|
||||
fen: s.fen,
|
||||
board: boardArray(s.fen),
|
||||
players: s.players,
|
||||
myColor,
|
||||
turn,
|
||||
phase: s.phase,
|
||||
moves: s.moves.map((m) => ({ san: m.san, uci: m.uci, color: m.color })),
|
||||
lastMove: last ? { from: last.uci.slice(0, 2), to: last.uci.slice(2, 4) } : null,
|
||||
checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
|
||||
timeControl: s.options.timeControl,
|
||||
firstMoveSec: s.options.firstMoveSec,
|
||||
idleLimitSec: s.options.idleLimitSec,
|
||||
remainingMs: s.clock.remainingMs,
|
||||
turnStartedAt: s.clock.turnStartedAt,
|
||||
started: s.clock.started,
|
||||
captured,
|
||||
materialDiff: materialDiff(c),
|
||||
undo: {
|
||||
allowed: s.options.undo !== 'off',
|
||||
remaining: {
|
||||
white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null,
|
||||
black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null,
|
||||
},
|
||||
pending: s.undo.pending,
|
||||
},
|
||||
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
||||
drawMode: s.options.drawMode,
|
||||
claimable: s.claimable,
|
||||
autoQueen: s.options.autoQueen,
|
||||
hints: s.options.hints,
|
||||
legalMoves: mover ? legalMoves(c) : null,
|
||||
threatened: mover && s.options.hints === 'movesAndThreats' ? threatenedSquares(c) : null,
|
||||
result: s.result,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.phase !== 'playing') return [];
|
||||
const turn = turnOf(s);
|
||||
const out = [s.players[turn]];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder && responder !== turn) out.push(s.players[responder]);
|
||||
return out;
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase !== 'playing') return null;
|
||||
const turn = turnOf(s);
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (!s.clock.started[turn]) return start + s.options.firstMoveSec * 1000;
|
||||
if (s.options.timeControl.kind === 'fischer') return start + s.clock.remainingMs[turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
return s.phase === 'playing' ? [s.players[turnOf(s)]] : [];
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave() {
|
||||
return { type: 'resign' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.result) return null;
|
||||
const { winner, reason, summary } = s.result;
|
||||
let ranking: PlayerId[][];
|
||||
if (reason === 'aborted') ranking = [];
|
||||
else if (winner) ranking = [[s.players[winner]], [s.players[other(winner)]]];
|
||||
else ranking = [[s.players.white, s.players.black]];
|
||||
const map: Record<ChessReason, GameResult['reason']> = {
|
||||
checkmate: 'normal',
|
||||
stalemate: 'normal',
|
||||
insufficientMaterial: 'normal',
|
||||
threefold: 'normal',
|
||||
fivefold: 'normal',
|
||||
fiftyMove: 'normal',
|
||||
seventyFiveMove: 'normal',
|
||||
resign: 'resign',
|
||||
timeLoss: 'timeout',
|
||||
timeoutVsInsufficient: 'timeout',
|
||||
drawAgreed: 'draw-agreed',
|
||||
aborted: 'abandoned',
|
||||
};
|
||||
return { ranking, summary, reason: map[reason] };
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
const me = colorOf(s, p);
|
||||
if (!me || s.phase !== 'playing') return [];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder === me) {
|
||||
return s.undo.pending
|
||||
? [
|
||||
{ type: 'respondUndo', accept: true },
|
||||
{ type: 'respondUndo', accept: false },
|
||||
]
|
||||
: [{ type: 'respondDraw', accept: false }];
|
||||
}
|
||||
if (turnOf(s) !== me) return [];
|
||||
const out: ChessAction[] = [];
|
||||
for (const m of legalMoves(load(s.fen))) {
|
||||
if (m.promotion) for (const promotion of ['q', 'r', 'b', 'n'] as const) out.push({ type: 'move', from: m.from, to: m.to, promotion });
|
||||
else out.push({ type: 'move', from: m.from, to: m.to });
|
||||
}
|
||||
if (s.claimable) out.push({ type: 'claimDraw' });
|
||||
for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) {
|
||||
if (chess.validate(s, p, extra).ok) out.push(extra);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
232
packages/games/src/chess/rules.ts
Normal file
232
packages/games/src/chess/rules.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* chess.js adapter (docs/games/chess.md §8.0). Move generation/validation comes from chess.js (BSD-2-Clause,
|
||||
* Copyright (c) 2025 Jeff Hlywa); position keys, draw rules, insufficient material and the FIDE 6.9
|
||||
* time-out exception are decided here with our own rules.
|
||||
*/
|
||||
import { Chess, DEFAULT_POSITION, type Move, type PieceSymbol, type Square } from 'chess.js';
|
||||
|
||||
export type Color = 'white' | 'black';
|
||||
export type Promo = 'q' | 'r' | 'b' | 'n';
|
||||
export const START_FEN = DEFAULT_POSITION;
|
||||
export const toSide = (c: Color): 'w' | 'b' => (c === 'white' ? 'w' : 'b');
|
||||
export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black');
|
||||
export const other = (c: Color): Color => (c === 'white' ? 'black' : 'white');
|
||||
|
||||
export const PIECE_VALUE: Record<PieceSymbol, number> = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 };
|
||||
|
||||
/** One entry per (from, to); promotions are a single entry with `promotion: true` (client shows a picker). */
|
||||
export interface LegalMove {
|
||||
from: string;
|
||||
to: string;
|
||||
promotion?: true;
|
||||
castle?: 'king' | 'queen';
|
||||
}
|
||||
|
||||
export const isSquare = (s: string): s is Square => /^[a-h][1-8]$/.test(s);
|
||||
|
||||
export function load(fen: string): Chess {
|
||||
return new Chess(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repetition key: placement, side to move, castling rights, en-passant square. chess.js's fen() already prints
|
||||
* the e.p. square only when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation.
|
||||
*/
|
||||
export function positionKey(fen: string): string {
|
||||
return fen.split(' ').slice(0, 4).join(' ');
|
||||
}
|
||||
export const keyOf = (c: Chess): string => positionKey(c.fen());
|
||||
/** Stored FEN keeps the e.p. square whenever a double step just happened (for accurate rejection messages). */
|
||||
export const fullFen = (c: Chess): string => c.fen({ forceEnpassantSquare: true });
|
||||
|
||||
export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0);
|
||||
export const turnOf = (fen: string): Color => (fen.split(' ')[1] === 'b' ? 'black' : 'white');
|
||||
|
||||
function toLegal(m: Move): LegalMove {
|
||||
const out: LegalMove = { from: m.from, to: m.to };
|
||||
if (m.isPromotion()) out.promotion = true;
|
||||
if (m.isKingsideCastle()) out.castle = 'king';
|
||||
if (m.isQueensideCastle()) out.castle = 'queen';
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Legal moves for the side to move (optionally from one square), promotions collapsed. */
|
||||
export function legalMoves(c: Chess, square?: Square): LegalMove[] {
|
||||
const raw = square ? c.moves({ square, verbose: true }) : c.moves({ verbose: true });
|
||||
const out: LegalMove[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of raw) {
|
||||
const k = m.from + m.to;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(toLegal(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Count leaf nodes through the adapter (legalMoves → expand promotions → chess.move). */
|
||||
export function adapterPerft(fen: string, depth: number): number {
|
||||
const c = load(fen);
|
||||
const walk = (d: number): number => {
|
||||
const moves = legalMoves(c);
|
||||
let n = 0;
|
||||
for (const m of moves) {
|
||||
for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) {
|
||||
if (d === 1) {
|
||||
n++;
|
||||
continue;
|
||||
}
|
||||
c.move({ from: m.from, to: m.to, promotion });
|
||||
n += walk(d - 1);
|
||||
c.undo();
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
return depth === 0 ? 1 : walk(depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can `from → to` be reached by the piece's movement pattern, ignoring checks? Used only to pick the rejection
|
||||
* message ("킹이 공격받게 되어…" vs "그렇게 움직일 수 없어요"). Castling is handled separately.
|
||||
*/
|
||||
export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
|
||||
const p = c.get(from);
|
||||
if (!p || from === to) return false;
|
||||
const target = c.get(to);
|
||||
if (target && target.color === p.color) return false;
|
||||
const fx = from.charCodeAt(0) - 97;
|
||||
const fy = Number(from[1]) - 1;
|
||||
const tx = to.charCodeAt(0) - 97;
|
||||
const ty = Number(to[1]) - 1;
|
||||
const dx = tx - fx;
|
||||
const dy = ty - fy;
|
||||
const adx = Math.abs(dx);
|
||||
const ady = Math.abs(dy);
|
||||
const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}` as Square;
|
||||
const clear = () => {
|
||||
const sx = Math.sign(dx);
|
||||
const sy = Math.sign(dy);
|
||||
for (let x = fx + sx, y = fy + sy; x !== tx || y !== ty; x += sx, y += sy) if (c.get(sq(x, y))) return false;
|
||||
return true;
|
||||
};
|
||||
switch (p.type) {
|
||||
case 'n':
|
||||
return (adx === 1 && ady === 2) || (adx === 2 && ady === 1);
|
||||
case 'k':
|
||||
return Math.max(adx, ady) === 1;
|
||||
case 'r':
|
||||
return (dx === 0 || dy === 0) && clear();
|
||||
case 'b':
|
||||
return adx === ady && clear();
|
||||
case 'q':
|
||||
return (dx === 0 || dy === 0 || adx === ady) && clear();
|
||||
case 'p': {
|
||||
const dir = p.color === 'w' ? 1 : -1;
|
||||
if (dx === 0) {
|
||||
if (target) return false;
|
||||
if (dy === dir) return true;
|
||||
return dy === 2 * dir && fy === (p.color === 'w' ? 1 : 6) && !c.get(sq(fx, fy + dir));
|
||||
}
|
||||
if (adx !== 1 || dy !== dir) return false;
|
||||
return !!target || c.fen({ forceEnpassantSquare: true }).split(' ')[3] === to;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface PieceAt {
|
||||
square: Square;
|
||||
color: 'w' | 'b';
|
||||
type: PieceSymbol;
|
||||
}
|
||||
|
||||
export function pieces(c: Chess): PieceAt[] {
|
||||
const out: PieceAt[] = [];
|
||||
for (const row of c.board()) for (const p of row) if (p) out.push(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic dead-position draws (§6 무승부 2): K vs K, KB vs K, KN vs K, and kings plus bishops only where every
|
||||
* bishop stands on the same square colour. KNN vs K is *not* included.
|
||||
*/
|
||||
export function insufficientMaterial(c: Chess): boolean {
|
||||
const minors = pieces(c).filter((p) => p.type !== 'k');
|
||||
if (minors.length === 0) return true;
|
||||
if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true;
|
||||
if (minors.every((p) => p.type === 'b')) {
|
||||
const colors = new Set(minors.map((p) => c.squareColor(p.square)));
|
||||
return colors.size === 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* FIDE 6.9 `cannotMate(side)` (§6 무승부 6): true only for the conservative, certain cases. Everything else is
|
||||
* treated as "mate is possible" (so the flagged player loses).
|
||||
*/
|
||||
export function cannotMate(c: Chess, side: 'w' | 'b'): boolean {
|
||||
const all = pieces(c).filter((p) => p.type !== 'k');
|
||||
const mine = all.filter((p) => p.color === side);
|
||||
const theirs = all.filter((p) => p.color !== side);
|
||||
if (mine.length === 0) return true;
|
||||
if (mine.length === 1 && theirs.length === 0 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')) return true;
|
||||
if (all.every((p) => p.type === 'b')) {
|
||||
return new Set(all.map((p) => c.squareColor(p.square))).size === 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */
|
||||
export function threatenedSquares(c: Chess): string[] {
|
||||
const us = c.turn();
|
||||
const them = us === 'w' ? 'b' : 'w';
|
||||
return pieces(c)
|
||||
.filter((p) => p.color === us && p.type !== 'k' && c.isAttacked(p.square, them) && !c.isAttacked(p.square, us))
|
||||
.map((p) => p.square);
|
||||
}
|
||||
|
||||
export function kingSquare(c: Chess, side: 'w' | 'b'): Square | null {
|
||||
return c.findPiece({ type: 'k', color: side })[0] ?? null;
|
||||
}
|
||||
|
||||
/** White material minus black material (Q9 R5 B3 N3 P1). */
|
||||
export function materialDiff(c: Chess): number {
|
||||
let d = 0;
|
||||
for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type];
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 64 FEN piece letters (uppercase = white), index = (8 - rank) * 8 + file, i.e. a8 first. */
|
||||
export function boardArray(fen: string): (string | null)[] {
|
||||
const out: (string | null)[] = [];
|
||||
for (const ch of fen.split(' ')[0]!) {
|
||||
if (ch === '/') continue;
|
||||
if (/\d/.test(ch)) for (let i = 0; i < Number(ch); i++) out.push(null);
|
||||
else out.push(ch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** PGN text from stored SAN moves (no replay needed). */
|
||||
export function buildPgn(startFen: string, sans: string[], tags: Record<string, string>, result: string): string {
|
||||
const head: Record<string, string> = { Event: '같이 놀자 보드게임', Variant: 'Standard', ...tags, Result: result };
|
||||
if (startFen !== START_FEN) {
|
||||
head.SetUp = '1';
|
||||
head.FEN = startFen;
|
||||
}
|
||||
const lines = Object.entries(head).map(([k, v]) => `[${k} "${v.replace(/["\\]/g, '')}"]`);
|
||||
const [, side, , , , full] = startFen.split(' ');
|
||||
let no = Number(full ?? 1);
|
||||
let black = side === 'b';
|
||||
const parts: string[] = [];
|
||||
sans.forEach((san, i) => {
|
||||
if (!black) parts.push(`${no}. ${san}`);
|
||||
else parts.push(i === 0 ? `${no}... ${san}` : san);
|
||||
if (black) no++;
|
||||
black = !black;
|
||||
});
|
||||
parts.push(result);
|
||||
return `${lines.join('\n')}\n\n${parts.join(' ')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user