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(' ')}`;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { omok } from './omok';
|
||||
import { othello } from './othello';
|
||||
import { chess } from './chess';
|
||||
import { janggi } from './janggi';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
[othello.id]: othello,
|
||||
[chess.id]: chess,
|
||||
[janggi.id]: janggi,
|
||||
};
|
||||
|
||||
781
packages/games/src/janggi/index.ts
Normal file
781
packages/games/src/janggi/index.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
/** 장기 (docs/games/janggi.md). */
|
||||
import { z } from 'zod';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
ARRANGEMENTS,
|
||||
HAN_BONUS,
|
||||
explainIllegal,
|
||||
hasLegalMove,
|
||||
idx,
|
||||
inCheck,
|
||||
initialBoard,
|
||||
kingsFacing,
|
||||
legalMoves,
|
||||
makeMove,
|
||||
material,
|
||||
other,
|
||||
placeArrangement,
|
||||
positionKey,
|
||||
pseudoMoves,
|
||||
attacks,
|
||||
sqOf,
|
||||
type Arrangement,
|
||||
type Board,
|
||||
type LegalMove,
|
||||
type Piece,
|
||||
type PieceKind,
|
||||
type Side,
|
||||
type Sq,
|
||||
} from './rules';
|
||||
|
||||
export type { Arrangement, Piece, PieceKind, Side, Sq } from './rules';
|
||||
export { ARRANGEMENTS, PIECE_VALUE, HAN_BONUS } from './rules';
|
||||
|
||||
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
|
||||
|
||||
export const janggiOptionsSchema = z.object({
|
||||
scoring: z.boolean().default(true).meta({ title: '점수제(판정이 필요하면 기물 점수로 승패)' }),
|
||||
bikjang: z
|
||||
.enum(['off', 'kjf', 'kja30'])
|
||||
.default('kjf')
|
||||
.meta({ title: '빅장', labels: { off: '없음', kjf: '연맹식(기물이 하나 잡힌 뒤부터)', kja30: '협회식(양쪽 30점 미만일 때)' } }),
|
||||
repetition: z
|
||||
.enum(['forbid', 'lose', 'scoreEnd'])
|
||||
.default('forbid')
|
||||
.meta({ title: '같은 국면 4번째', labels: { forbid: '그 수를 둘 수 없음', lose: '만든 쪽 실격패', scoreEnd: '점수로 판정 종료' } }),
|
||||
allowPass: z.boolean().default(true).meta({ title: '한수쉼 허용' }),
|
||||
setupOrder: z
|
||||
.enum(['hanFirst', 'simultaneous'])
|
||||
.default('hanFirst')
|
||||
.meta({ title: '상차림 순서', labels: { hanFirst: '한이 먼저 공개(연맹식)', simultaneous: '동시에 비공개로' } }),
|
||||
sideAssignment: z
|
||||
.enum(['random', 'hostCho', 'hostHan', 'alternate'])
|
||||
.default('random')
|
||||
.meta({ title: '초·한 정하기', labels: { random: '무작위', hostCho: '방장이 초', hostHan: '방장이 한', alternate: '판마다 번갈아' } }),
|
||||
moveLimit: z.number().int().min(0).max(1000).default(0).meta({ title: '수 제한(양쪽 합산, 0이면 없음)' }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z.enum(['none', 'byoyomi']).default('byoyomi').meta({ title: '시간 방식', labels: { none: '제한 없음', byoyomi: '기본 시간 + 초읽기' } }),
|
||||
mainSec: z.number().int().min(0).max(7200).default(600).meta({ title: '기본 시간(초)' }),
|
||||
periodSec: z.number().int().min(5).max(300).default(30).meta({ title: '초읽기 1회(초)' }),
|
||||
periods: z.number().int().min(1).max(10).default(3).meta({ title: '초읽기 횟수' }),
|
||||
})
|
||||
.default({ kind: 'byoyomi', mainSec: 600, periodSec: 30, periods: 3 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
idleLimitSec: z.number().int().min(60).max(3600).default(600).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
|
||||
setupTimeSec: z.number().int().min(10).max(600).default(60).meta({ title: '상차림 고르는 시간(초)' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
.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(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||
hints: z
|
||||
.enum(['off', 'moves', 'movesAndThreats'])
|
||||
.default('moves')
|
||||
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳', movesAndThreats: '갈 수 있는 곳 + 잡힐 위험' } }),
|
||||
});
|
||||
export type JanggiOptions = z.infer<typeof janggiOptionsSchema>;
|
||||
|
||||
const sqSchema = z.object({ f: z.number().int().min(0).max(8), r: z.number().int().min(0).max(9) });
|
||||
export const janggiActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('chooseSetup'), arrangement: z.enum(['MSSM', 'SMMS', 'SMSM', 'MSMS']) }),
|
||||
z.object({ type: z.literal('move'), from: sqSchema, to: sqSchema }),
|
||||
z.object({ type: z.literal('pass') }),
|
||||
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 JanggiAction = z.infer<typeof janggiActionSchema> | { type: 'timeout' };
|
||||
|
||||
export type JanggiMove =
|
||||
| { kind: 'move'; side: Side; piece: PieceKind; from: Sq; to: Sq; captured: PieceKind | null }
|
||||
| { kind: 'pass'; side: Side };
|
||||
|
||||
export type JanggiReason =
|
||||
| 'checkmate'
|
||||
| 'resign'
|
||||
| 'timeLoss'
|
||||
| 'repetitionLoss'
|
||||
| 'scoreBikjang'
|
||||
| 'scorePasses'
|
||||
| 'scoreMoveLimit'
|
||||
| 'scoreRepetition'
|
||||
| 'drawBikjang'
|
||||
| 'drawPasses'
|
||||
| 'drawRepetition'
|
||||
| 'drawMoveLimit'
|
||||
| 'drawAgreed'
|
||||
| 'aborted';
|
||||
|
||||
export interface JanggiResult {
|
||||
winner: Side | null;
|
||||
reason: JanggiReason;
|
||||
/** Always recorded; 한 includes the 1.5 덤. */
|
||||
scores: Record<Side, number>;
|
||||
moveCount: number;
|
||||
summaryKo: string;
|
||||
}
|
||||
|
||||
export interface JanggiState {
|
||||
options: JanggiOptions;
|
||||
players: Record<Side, PlayerId>;
|
||||
phase: 'setup' | 'playing' | 'finished';
|
||||
/** `startedAt` = when the current chooser's setup clock started. */
|
||||
setup: { choice: Record<Side, Arrangement | null>; startedAt: number };
|
||||
board: Board;
|
||||
/** Board when play began (replayed for undo). */
|
||||
startBoard: Board | null;
|
||||
turn: Side;
|
||||
moves: JanggiMove[];
|
||||
/** 0-based ply index of the first capture. */
|
||||
firstCapturePly: number | null;
|
||||
positionCounts: Record<string, number>;
|
||||
bikjang: { calledBy: Side; ply: number } | null;
|
||||
consecutivePasses: number;
|
||||
inCheck: Side | null;
|
||||
clock: { mainMs: Record<Side, number>; periodsLeft: Record<Side, number>; turnStartedAt: number };
|
||||
undo: { usedBy: Record<Side, number>; pending: { by: Side; plies: 1 | 2 } | null };
|
||||
draw: { pendingBy: Side | null; lastOfferPly: Record<Side, number | null> };
|
||||
result: JanggiResult | null;
|
||||
}
|
||||
|
||||
export interface JanggiLegalMove {
|
||||
from: Sq;
|
||||
to: Sq;
|
||||
/** 포다리 for 포 moves. */
|
||||
via?: Sq;
|
||||
}
|
||||
|
||||
export interface JanggiView {
|
||||
phase: JanggiState['phase'];
|
||||
board: (Piece | null)[];
|
||||
players: Record<Side, PlayerId>;
|
||||
mySide: Side | null;
|
||||
turn: Side;
|
||||
setup: {
|
||||
order: JanggiOptions['setupOrder'];
|
||||
/** Public (or my own) choices; hidden choices are null. */
|
||||
choice: Record<Side, Arrangement | null>;
|
||||
ready: Record<Side, boolean>;
|
||||
/** Sides that still have to choose right now. */
|
||||
waitingFor: Side[];
|
||||
};
|
||||
moves: JanggiMove[];
|
||||
moveCount: number;
|
||||
lastMove: { from: Sq; to: Sq } | null;
|
||||
/** Opponent pieces each side has captured. */
|
||||
captured: Record<Side, PieceKind[]>;
|
||||
/** Live score incl. 덤. */
|
||||
scores: Record<Side, number>;
|
||||
inCheck: Side | null;
|
||||
bikjang: { calledBy: Side } | null;
|
||||
/** A legal move/pass of the side to move would create the 4th repetition. */
|
||||
repeatWarning: boolean;
|
||||
/** Only for the player to move. */
|
||||
legalMoves: JanggiLegalMove[] | null;
|
||||
canPass: boolean;
|
||||
threatened: Sq[];
|
||||
rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>;
|
||||
clock: JanggiState['clock'];
|
||||
undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null };
|
||||
draw: { allowed: boolean; pendingBy: Side | null };
|
||||
result: JanggiResult | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const toIdx = (q: Sq): number => idx(q.f, q.r);
|
||||
const undoLimit = (o: JanggiOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||
const sideOf = (s: JanggiState, p: PlayerId): Side | null => (s.players.cho === p ? 'cho' : s.players.han === p ? 'han' : null);
|
||||
const fmt = (n: number): string => String(n);
|
||||
|
||||
export function scoresOf(b: Board): Record<Side, number> {
|
||||
return { cho: material(b, 'cho'), han: material(b, 'han') + HAN_BONUS };
|
||||
}
|
||||
|
||||
/** Sides that must pick a 상차림 now. */
|
||||
function setupWaiting(s: JanggiState): Side[] {
|
||||
if (s.phase !== 'setup') return [];
|
||||
const { choice } = s.setup;
|
||||
if (s.options.setupOrder === 'hanFirst') return choice.han === null ? ['han'] : choice.cho === null ? ['cho'] : [];
|
||||
return (['cho', 'han'] as const).filter((x) => choice[x] === null);
|
||||
}
|
||||
|
||||
interface MoveOptions {
|
||||
moves: LegalMove[];
|
||||
pass: boolean;
|
||||
/** Some option of the mover would create a 4th repetition. */
|
||||
warn: boolean;
|
||||
}
|
||||
|
||||
/** Legal options for the side to move, including the repetition rule (docs §5.5). */
|
||||
function moveOptions(s: JanggiState): MoveOptions {
|
||||
const side = s.turn;
|
||||
const safe = legalMoves(s.board, side);
|
||||
const check = s.inCheck === side;
|
||||
const passBase = !check && (s.options.allowPass || safe.length === 0);
|
||||
const hot = Object.values(s.positionCounts).some((c) => c >= 3);
|
||||
if (!hot) return { moves: safe, pass: passBase, warn: false };
|
||||
const opp = other(side);
|
||||
const repeats = (key: string) => (s.positionCounts[key] ?? 0) >= 3;
|
||||
const moveRepeats = safe.map((m) => repeats(positionKey(makeMove(s.board, m.from, m.to), opp)));
|
||||
const passRepeats = passBase && repeats(positionKey(s.board, opp));
|
||||
const warn = passRepeats || moveRepeats.some(Boolean);
|
||||
if (s.options.repetition !== 'forbid') return { moves: safe, pass: passBase, warn };
|
||||
const nonRep = safe.filter((_, i) => !moveRepeats[i]);
|
||||
const passOk = passBase && !passRepeats;
|
||||
if (nonRep.length > 0 || passOk) return { moves: nonRep, pass: passOk, warn };
|
||||
// Exception: every option repeats a 4th time → allowed, and advance() ends the game by score.
|
||||
return { moves: safe, pass: passBase, warn };
|
||||
}
|
||||
|
||||
const SCORE_WHY: Record<string, string> = { Bikjang: '빅장', Passes: '연속 한수쉼', MoveLimit: '수 제한', Repetition: '반복수' };
|
||||
|
||||
function finish(s: JanggiState, winner: Side | null, reason: JanggiReason): void {
|
||||
s.phase = 'finished';
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
s.bikjang = null;
|
||||
const scores = scoresOf(s.board);
|
||||
const n = s.moves.length;
|
||||
let summaryKo: string;
|
||||
if (reason.startsWith('score')) {
|
||||
const w = winner!;
|
||||
summaryKo = `${SIDE_KO[w]} 판정승 ${fmt(scores[w])} : ${fmt(scores[other(w)])} (${SCORE_WHY[reason.slice(5)]})`;
|
||||
} else if (reason.startsWith('draw') && reason !== 'drawAgreed') {
|
||||
summaryKo = `무승부 (${SCORE_WHY[reason.slice(4)]}, ${n}수)`;
|
||||
} else {
|
||||
switch (reason) {
|
||||
case 'checkmate':
|
||||
summaryKo = `${SIDE_KO[winner!]} 외통승 (${n}수)`;
|
||||
break;
|
||||
case 'resign':
|
||||
summaryKo = `${SIDE_KO[other(winner!)]} 기권으로 ${SIDE_KO[winner!]} 승 (${n}수)`;
|
||||
break;
|
||||
case 'timeLoss':
|
||||
summaryKo = `${SIDE_KO[other(winner!)]} 시간패로 ${SIDE_KO[winner!]} 승 (${n}수)`;
|
||||
break;
|
||||
case 'repetitionLoss':
|
||||
summaryKo = `${SIDE_KO[other(winner!)]} 반복수 실격패로 ${SIDE_KO[winner!]} 승 (${n}수)`;
|
||||
break;
|
||||
case 'drawAgreed':
|
||||
summaryKo = `합의 무승부 (${n}수)`;
|
||||
break;
|
||||
default:
|
||||
summaryKo = '상차림 중 기권해서 대국이 취소됐어요';
|
||||
}
|
||||
}
|
||||
s.result = { winner, reason, scores, moveCount: n, summaryKo };
|
||||
}
|
||||
|
||||
/** Score judgement (scoring=true) or draw (scoring=false). */
|
||||
function judge(s: JanggiState, why: 'Bikjang' | 'Passes' | 'MoveLimit' | 'Repetition'): void {
|
||||
if (!s.options.scoring) {
|
||||
finish(s, null, `draw${why}` as JanggiReason);
|
||||
return;
|
||||
}
|
||||
const sc = scoresOf(s.board);
|
||||
finish(s, sc.cho > sc.han ? 'cho' : 'han', `score${why}` as JanggiReason);
|
||||
}
|
||||
|
||||
function bikjangCondition(s: JanggiState, ply: number): boolean {
|
||||
switch (s.options.bikjang) {
|
||||
case 'off':
|
||||
return false;
|
||||
case 'kjf':
|
||||
return s.firstCapturePly !== null && s.firstCapturePly < ply;
|
||||
case 'kja30':
|
||||
return material(s.board, 'cho') < 30 && material(s.board, 'han') < 30;
|
||||
}
|
||||
}
|
||||
|
||||
type Ply = { kind: 'move'; from: Sq; to: Sq } | { kind: 'pass' };
|
||||
|
||||
/** Steps 2–7 of docs §8.2 (board, 빅장, 외통, repetition, passes, move limit, 장군 events). No clocks. */
|
||||
function advance(s: JanggiState, side: Side, ply: Ply, events: GameEvent[]): void {
|
||||
const wasFacing = kingsFacing(s.board);
|
||||
const wasInCheck = s.inCheck === side;
|
||||
const n = s.moves.length;
|
||||
if (ply.kind === 'move') {
|
||||
const from = toIdx(ply.from);
|
||||
const to = toIdx(ply.to);
|
||||
const piece = s.board[from]!;
|
||||
const captured = s.board[to]?.kind ?? null;
|
||||
s.board = makeMove(s.board, from, to);
|
||||
s.moves.push({ kind: 'move', side, piece: piece.kind, from: ply.from, to: ply.to, captured });
|
||||
if (captured && s.firstCapturePly === null) s.firstCapturePly = n;
|
||||
events.push({ type: 'moved', side, piece: piece.kind, from: ply.from, to: ply.to, captured });
|
||||
} else {
|
||||
s.moves.push({ kind: 'pass', side });
|
||||
events.push({ type: 'passed', side });
|
||||
}
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
const opp = other(side);
|
||||
s.turn = opp;
|
||||
s.inCheck = inCheck(s.board, opp) ? opp : null;
|
||||
const facing = kingsFacing(s.board);
|
||||
|
||||
// 3. Answer to a pending 빅장.
|
||||
if (s.bikjang && s.bikjang.calledBy !== side) {
|
||||
if (facing) {
|
||||
events.push({ type: 'bik' });
|
||||
judge(s, 'Bikjang');
|
||||
return;
|
||||
}
|
||||
s.bikjang = null;
|
||||
events.push({ type: 'bikjangBlocked', side });
|
||||
}
|
||||
// 4. 외통.
|
||||
if (s.inCheck === opp && !hasLegalMove(s.board, opp)) {
|
||||
events.push({ type: 'janggun', side });
|
||||
finish(s, side, 'checkmate');
|
||||
events.push({ type: 'checkmate', side });
|
||||
return;
|
||||
}
|
||||
// 5. New 빅장.
|
||||
if (facing && !wasFacing && bikjangCondition(s, n)) {
|
||||
s.bikjang = { calledBy: side, ply: n };
|
||||
events.push({ type: 'bikjang', side });
|
||||
}
|
||||
// 6. Repetition, passes, move limit.
|
||||
const key = positionKey(s.board, s.turn);
|
||||
const count = (s.positionCounts[key] ?? 0) + 1;
|
||||
s.positionCounts[key] = count;
|
||||
if (count >= 4) {
|
||||
if (s.options.repetition === 'lose') {
|
||||
finish(s, opp, 'repetitionLoss');
|
||||
return;
|
||||
}
|
||||
// scoreEnd, or the forbid exception (only reachable when every option repeated).
|
||||
judge(s, 'Repetition');
|
||||
return;
|
||||
}
|
||||
s.consecutivePasses = ply.kind === 'pass' ? s.consecutivePasses + 1 : 0;
|
||||
if (s.consecutivePasses >= 2) {
|
||||
judge(s, 'Passes');
|
||||
return;
|
||||
}
|
||||
if (s.options.moveLimit > 0 && s.moves.length >= s.options.moveLimit) {
|
||||
judge(s, 'MoveLimit');
|
||||
return;
|
||||
}
|
||||
// 7. 장군 / 멍군.
|
||||
if (s.inCheck === opp) events.push({ type: 'janggun', side });
|
||||
else if (wasInCheck) events.push({ type: 'meonggun', side });
|
||||
}
|
||||
|
||||
/** Rebuild derived fields from startBoard + moves (undo). */
|
||||
function replay(s: JanggiState, moves: JanggiMove[]): void {
|
||||
s.board = s.startBoard!.slice();
|
||||
s.moves = [];
|
||||
s.turn = 'cho';
|
||||
s.firstCapturePly = null;
|
||||
s.bikjang = null;
|
||||
s.consecutivePasses = 0;
|
||||
s.inCheck = null;
|
||||
s.positionCounts = { [positionKey(s.board, 'cho')]: 1 };
|
||||
const sink: GameEvent[] = [];
|
||||
for (const m of moves) advance(s, m.side, m.kind === 'move' ? { kind: 'move', from: m.from, to: m.to } : { kind: 'pass' }, sink);
|
||||
}
|
||||
|
||||
/** Byoyomi clock (same as 바둑). Returns false when the mover has run out of time. */
|
||||
function spendClock(s: JanggiState, side: Side, now: number): boolean {
|
||||
const tc = s.options.timeControl;
|
||||
if (tc.kind !== 'byoyomi') return true;
|
||||
let elapsed = Math.max(0, now - s.clock.turnStartedAt);
|
||||
const main = s.clock.mainMs[side];
|
||||
if (elapsed <= main) {
|
||||
s.clock.mainMs[side] = main - elapsed;
|
||||
return true;
|
||||
}
|
||||
elapsed -= main;
|
||||
s.clock.mainMs[side] = 0;
|
||||
const used = Math.floor(elapsed / (tc.periodSec * 1000));
|
||||
s.clock.periodsLeft[side] = Math.max(0, s.clock.periodsLeft[side] - used);
|
||||
return s.clock.periodsLeft[side] > 0;
|
||||
}
|
||||
|
||||
function chooseSetup(s: JanggiState, side: Side, arr: Arrangement, now: number, events: GameEvent[], auto: boolean): void {
|
||||
s.setup.choice[side] = arr;
|
||||
const hanFirst = s.options.setupOrder === 'hanFirst';
|
||||
if (hanFirst) {
|
||||
placeArrangement(s.board, side, arr);
|
||||
s.setup.startedAt = now;
|
||||
events.push({ type: 'setupChosen', side, arrangement: arr, auto });
|
||||
} else {
|
||||
events.push({ type: 'setupChosen', side, auto });
|
||||
}
|
||||
const { cho, han } = s.setup.choice;
|
||||
if (cho && han) {
|
||||
if (!hanFirst) {
|
||||
placeArrangement(s.board, 'cho', cho);
|
||||
placeArrangement(s.board, 'han', han);
|
||||
}
|
||||
s.phase = 'playing';
|
||||
s.turn = 'cho';
|
||||
s.startBoard = s.board.slice();
|
||||
s.positionCounts = { [positionKey(s.board, 'cho')]: 1 };
|
||||
s.clock.turnStartedAt = now;
|
||||
events.push({ type: 'setupDone', cho, han });
|
||||
}
|
||||
}
|
||||
|
||||
function pendingResponder(s: JanggiState): Side | null {
|
||||
if (s.undo.pending) return other(s.undo.pending.by);
|
||||
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
||||
return null;
|
||||
}
|
||||
|
||||
const legalToView = (m: LegalMove): JanggiLegalMove =>
|
||||
m.via === undefined ? { from: sqOf(m.from), to: sqOf(m.to) } : { from: sqOf(m.from), to: sqOf(m.to), via: sqOf(m.via) };
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, JanggiOptions> = {
|
||||
id: 'janggi',
|
||||
nameKo: '장기',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: janggiOptionsSchema.parse({}),
|
||||
optionsSchema: janggiOptionsSchema,
|
||||
actionSchema: janggiActionSchema as z.ZodType<JanggiAction>,
|
||||
|
||||
setup({ players, options, rng, now, gameNo }) {
|
||||
const [host, guest] = players as [PlayerId, PlayerId];
|
||||
let hostCho: boolean;
|
||||
switch (options.sideAssignment) {
|
||||
case 'hostCho':
|
||||
hostCho = true;
|
||||
break;
|
||||
case 'hostHan':
|
||||
hostCho = false;
|
||||
break;
|
||||
case 'alternate':
|
||||
hostCho = (gameNo ?? 1) % 2 === 1;
|
||||
break;
|
||||
default:
|
||||
hostCho = rng.int(2) === 0;
|
||||
}
|
||||
const tc = options.timeControl;
|
||||
return {
|
||||
options,
|
||||
players: hostCho ? { cho: host, han: guest } : { cho: guest, han: host },
|
||||
phase: 'setup',
|
||||
setup: { choice: { cho: null, han: null }, startedAt: now },
|
||||
board: initialBoard(),
|
||||
startBoard: null,
|
||||
turn: 'cho',
|
||||
moves: [],
|
||||
firstCapturePly: null,
|
||||
positionCounts: {},
|
||||
bikjang: null,
|
||||
consecutivePasses: 0,
|
||||
inCheck: null,
|
||||
clock: {
|
||||
mainMs: { cho: tc.mainSec * 1000, han: tc.mainSec * 1000 },
|
||||
periodsLeft: { cho: tc.periods, han: tc.periods },
|
||||
turnStartedAt: now,
|
||||
},
|
||||
undo: { usedBy: { cho: 0, han: 0 }, pending: null },
|
||||
draw: { pendingBy: null, lastOfferPly: { cho: null, han: null } },
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
const me = sideOf(s, actor);
|
||||
if (!me) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
|
||||
if (a.type === 'resign') return ok;
|
||||
if (s.phase === 'setup') {
|
||||
if (a.type !== 'chooseSetup' && a.type !== 'timeout') return fail('상차림을 먼저 골라 주세요.');
|
||||
if (s.setup.choice[me]) return fail('이미 상차림을 골랐어요.');
|
||||
if (!setupWaiting(s).includes(me)) return fail('한이 먼저 상차림을 골라요. 잠시 기다려 주세요.');
|
||||
return ok;
|
||||
}
|
||||
switch (a.type) {
|
||||
case 'chooseSetup':
|
||||
return fail('상차림은 이미 끝났어요.');
|
||||
case 'move': {
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
const from = toIdx(a.from);
|
||||
const to = toIdx(a.to);
|
||||
const p = s.board[from];
|
||||
if (!p) return fail('그 자리에는 기물이 없어요.');
|
||||
if (p.side !== me) return fail('내 기물만 움직일 수 있어요.');
|
||||
if (s.board[to]?.side === me) return fail('내 기물이 있는 곳으로는 갈 수 없어요.');
|
||||
if (!pseudoMoves(s.board, from).some((m) => m.to === to)) return fail(explainIllegal(s.board, from, to));
|
||||
if (inCheck(makeMove(s.board, from, to), me)) {
|
||||
return fail(s.inCheck === me ? '장군을 피해야 해요.' : '그 수를 두면 내 궁이 잡혀요(장군).');
|
||||
}
|
||||
if (!moveOptions(s).moves.some((m) => m.from === from && m.to === to)) return fail('같은 수를 너무 많이 반복해서 둘 수 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'pass': {
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
if (s.inCheck === me) return fail('장군을 받은 상태에서는 쉴 수 없어요.');
|
||||
const mo = moveOptions(s);
|
||||
if (mo.pass) return ok;
|
||||
if (!s.options.allowPass && hasLegalMove(s.board, me)) return fail('이 방은 한수쉼을 쓰지 않아요.');
|
||||
return fail('같은 수를 너무 많이 반복해서 쉴 수 없어요.');
|
||||
}
|
||||
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.side === 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.side === 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 'timeout':
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const me = sideOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
if (s.phase === 'setup') {
|
||||
if (a.type === 'resign') {
|
||||
finish(s, null, 'aborted');
|
||||
events.push({ type: 'aborted', side: me });
|
||||
} else if (a.type === 'chooseSetup') {
|
||||
chooseSetup(s, me, a.arrangement, now, events, false);
|
||||
} else if (a.type === 'timeout') {
|
||||
chooseSetup(s, me, ARRANGEMENTS[rng.int(ARRANGEMENTS.length)]!, now, events, true);
|
||||
} else {
|
||||
throw new Error(`janggi: ${a.type} during setup`);
|
||||
}
|
||||
return { state: s, events };
|
||||
}
|
||||
switch (a.type) {
|
||||
case 'move':
|
||||
case 'pass': {
|
||||
if (!spendClock(s, me, now)) {
|
||||
finish(s, other(me), 'timeLoss');
|
||||
events.push({ type: 'timeLoss', side: me });
|
||||
break;
|
||||
}
|
||||
s.clock.turnStartedAt = now;
|
||||
advance(s, me, a.type === 'move' ? { kind: 'move', from: a.from, to: a.to } : { kind: 'pass' }, events);
|
||||
break;
|
||||
}
|
||||
case 'timeout':
|
||||
if (s.options.timeControl.kind === 'byoyomi') {
|
||||
s.clock.mainMs[me] = 0;
|
||||
s.clock.periodsLeft[me] = 0;
|
||||
}
|
||||
finish(s, other(me), 'timeLoss');
|
||||
events.push({ type: 'timeLoss', side: me });
|
||||
break;
|
||||
case 'requestUndo': {
|
||||
const last = s.moves[s.moves.length - 1]!;
|
||||
s.undo.pending = { by: me, plies: last.side === me ? 1 : 2 };
|
||||
events.push({ type: 'undoRequested', side: me });
|
||||
break;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const { by, plies } = s.undo.pending!;
|
||||
s.undo.pending = null;
|
||||
if (a.accept) {
|
||||
const keep = s.moves.slice(0, Math.max(0, s.moves.length - plies));
|
||||
replay(s, keep);
|
||||
s.undo.usedBy[by]++;
|
||||
s.clock.turnStartedAt = now;
|
||||
events.push({ type: 'undoAccepted', side: by, plies });
|
||||
} else {
|
||||
events.push({ type: 'undoDeclined', side: by });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'offerDraw':
|
||||
s.draw.pendingBy = me;
|
||||
s.draw.lastOfferPly[me] = s.moves.length;
|
||||
events.push({ type: 'drawOffered', side: me });
|
||||
break;
|
||||
case 'respondDraw':
|
||||
s.draw.pendingBy = null;
|
||||
if (a.accept) {
|
||||
finish(s, null, 'drawAgreed');
|
||||
events.push({ type: 'drawAccepted' });
|
||||
} 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', side: me });
|
||||
break;
|
||||
case 'resign':
|
||||
finish(s, other(me), 'resign');
|
||||
events.push({ type: 'resigned', side: me });
|
||||
break;
|
||||
case 'chooseSetup':
|
||||
throw new Error('janggi: chooseSetup after setup');
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const mySide = viewer ? sideOf(s, viewer) : null;
|
||||
const hidden = s.phase === 'setup' && s.options.setupOrder === 'simultaneous';
|
||||
const choice = {
|
||||
cho: hidden && mySide !== 'cho' ? null : s.setup.choice.cho,
|
||||
han: hidden && mySide !== 'han' ? null : s.setup.choice.han,
|
||||
};
|
||||
const playing = s.phase === 'playing';
|
||||
const mo = playing ? moveOptions(s) : null;
|
||||
const myTurn = playing && mySide === s.turn;
|
||||
const captured: Record<Side, PieceKind[]> = { cho: [], han: [] };
|
||||
for (const m of s.moves) if (m.kind === 'move' && m.captured) captured[m.side].push(m.captured);
|
||||
let threatened: Sq[] = [];
|
||||
if (myTurn && s.options.hints === 'movesAndThreats') {
|
||||
const opp = other(s.turn);
|
||||
threatened = [];
|
||||
s.board.forEach((p, i) => {
|
||||
if (p && p.side === s.turn && p.kind !== 'king' && attacks(s.board, opp, i)) threatened.push(sqOf(i));
|
||||
});
|
||||
}
|
||||
const last = [...s.moves].reverse().find((m) => m.kind === 'move');
|
||||
const limit = undoLimit(s.options);
|
||||
const o = s.options;
|
||||
return {
|
||||
phase: s.phase,
|
||||
board: s.board,
|
||||
players: s.players,
|
||||
mySide,
|
||||
turn: s.turn,
|
||||
setup: {
|
||||
order: o.setupOrder,
|
||||
choice,
|
||||
ready: { cho: s.setup.choice.cho !== null, han: s.setup.choice.han !== null },
|
||||
waitingFor: setupWaiting(s),
|
||||
},
|
||||
moves: s.moves,
|
||||
moveCount: s.moves.length,
|
||||
lastMove: last?.kind === 'move' ? { from: last.from, to: last.to } : null,
|
||||
captured,
|
||||
scores: scoresOf(s.board),
|
||||
inCheck: s.inCheck,
|
||||
bikjang: s.bikjang ? { calledBy: s.bikjang.calledBy } : null,
|
||||
repeatWarning: mo?.warn ?? false,
|
||||
legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null,
|
||||
canPass: myTurn && !!mo?.pass,
|
||||
threatened,
|
||||
rules: {
|
||||
scoring: o.scoring,
|
||||
bikjang: o.bikjang,
|
||||
repetition: o.repetition,
|
||||
allowPass: o.allowPass,
|
||||
moveLimit: o.moveLimit,
|
||||
hints: o.hints,
|
||||
timeControl: o.timeControl,
|
||||
idleLimitSec: o.idleLimitSec,
|
||||
setupTimeSec: o.setupTimeSec,
|
||||
},
|
||||
clock: s.clock,
|
||||
undo: {
|
||||
allowed: o.undo !== 'off',
|
||||
remaining: {
|
||||
cho: Number.isFinite(limit) ? limit - s.undo.usedBy.cho : null,
|
||||
han: Number.isFinite(limit) ? limit - s.undo.usedBy.han : null,
|
||||
},
|
||||
pending: s.undo.pending,
|
||||
},
|
||||
draw: { allowed: o.drawOffer, pendingBy: s.draw.pendingBy },
|
||||
result: s.result,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.phase === 'setup') return setupWaiting(s).map((x) => s.players[x]);
|
||||
if (s.phase !== 'playing') return [];
|
||||
const out = [s.players[s.turn]];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder && responder !== s.turn) out.push(s.players[responder]);
|
||||
return out;
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase === 'setup') return s.setup.startedAt + s.options.setupTimeSec * 1000;
|
||||
if (s.phase !== 'playing') return null;
|
||||
const tc = s.options.timeControl;
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000;
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
if (s.phase === 'setup') return setupWaiting(s).map((x) => s.players[x]);
|
||||
return s.phase === 'playing' ? [s.players[s.turn]] : [];
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave() {
|
||||
return { type: 'resign' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.result) return null;
|
||||
const { winner, reason, summaryKo, scores } = s.result;
|
||||
if (reason === 'aborted') return { ranking: [], summary: summaryKo, reason: 'abandoned' };
|
||||
const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.cho, s.players.han]];
|
||||
const map: Partial<Record<JanggiReason, GameResult['reason']>> = { resign: 'resign', timeLoss: 'timeout', drawAgreed: 'draw-agreed' };
|
||||
return {
|
||||
ranking,
|
||||
scores: { [s.players.cho]: scores.cho, [s.players.han]: scores.han },
|
||||
summary: summaryKo,
|
||||
reason: map[reason] ?? 'normal',
|
||||
};
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
const me = sideOf(s, p);
|
||||
if (!me || s.phase === 'finished') return [];
|
||||
if (s.phase === 'setup') {
|
||||
return setupWaiting(s).includes(me) ? ARRANGEMENTS.map((arrangement) => ({ type: 'chooseSetup' as const, arrangement })) : [];
|
||||
}
|
||||
const responder = pendingResponder(s);
|
||||
if (responder === me) return s.undo.pending ? [{ type: 'respondUndo', accept: false }] : [{ type: 'respondDraw', accept: false }];
|
||||
if (s.turn !== me) return [];
|
||||
const mo = moveOptions(s);
|
||||
const out: JanggiAction[] = mo.moves.map((m) => ({ type: 'move' as const, from: sqOf(m.from), to: sqOf(m.to) }));
|
||||
if (mo.pass) out.push({ type: 'pass' });
|
||||
return out;
|
||||
},
|
||||
|
||||
secretsOf(s, p) {
|
||||
const me = sideOf(s, p);
|
||||
if (!me || s.phase !== 'setup' || s.options.setupOrder !== 'simultaneous') return [];
|
||||
const c = s.setup.choice[me];
|
||||
return c ? [c] : [];
|
||||
},
|
||||
};
|
||||
563
packages/games/src/janggi/janggi.test.ts
Normal file
563
packages/games/src/janggi/janggi.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||
import { janggi, type JanggiAction, type JanggiOptions, type JanggiState } from './index';
|
||||
import { idx, inCheck, initialBoard, kingsFacing, makeMove, positionKey, type Board, type PieceKind, type Side } from './rules';
|
||||
|
||||
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
|
||||
type P = [Side, PieceKind, number, number];
|
||||
|
||||
function opts(o: Partial<JanggiOptions> = {}): JanggiOptions {
|
||||
return janggi.optionsSchema.parse({ sideAssignment: 'hostCho', ...o });
|
||||
}
|
||||
|
||||
/** Fresh game in setup phase. A = 초, B = 한. */
|
||||
function fresh(o: Partial<JanggiOptions> = {}): JanggiState {
|
||||
return janggi.setup({ players: ['A', 'B'], options: opts(o), rng: rng(), now: 0 });
|
||||
}
|
||||
|
||||
/** Game in play with a custom board. */
|
||||
function custom(pieces: P[], o: Partial<JanggiOptions> = {}, turn: Side = 'cho'): JanggiState {
|
||||
const s = fresh(o);
|
||||
const b: Board = new Array(90).fill(null);
|
||||
for (const [side, kind, f, r] of pieces) b[idx(f, r)] = { side, kind };
|
||||
s.board = b;
|
||||
s.startBoard = b.slice();
|
||||
s.phase = 'playing';
|
||||
s.turn = turn;
|
||||
s.inCheck = inCheck(b, turn) ? turn : null;
|
||||
s.positionCounts = { [positionKey(b, turn)]: 1 };
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Standard opening (both 상차림 chosen), 초 to move. */
|
||||
function standard(o: Partial<JanggiOptions> = {}, cho: string = 'MSSM', han: string = 'MSSM'): JanggiState {
|
||||
let s = fresh(o);
|
||||
s = act(s, 'B', { type: 'chooseSetup', arrangement: han as 'MSSM' });
|
||||
return act(s, 'A', { type: 'chooseSetup', arrangement: cho as 'MSSM' });
|
||||
}
|
||||
|
||||
const pl = (s: JanggiState, side: Side = s.turn) => s.players[side];
|
||||
function act(s: JanggiState, who: string, a: JanggiAction, now = 1000): JanggiState {
|
||||
const v = janggi.validate(s, who, a);
|
||||
if (!v.ok) throw new Error(v.reason);
|
||||
return janggi.apply(s, who, a, { rng: rng(), now }).state;
|
||||
}
|
||||
const mv = (f1: number, r1: number, f2: number, r2: number): JanggiAction => ({ type: 'move', from: { f: f1, r: r1 }, to: { f: f2, r: r2 } });
|
||||
function check(s: JanggiState, f1: number, r1: number, f2: number, r2: number) {
|
||||
return janggi.validate(s, pl(s), mv(f1, r1, f2, r2));
|
||||
}
|
||||
function play(s: JanggiState, f1: number, r1: number, f2: number, r2: number, now = 1000) {
|
||||
return act(s, pl(s), mv(f1, r1, f2, r2), now);
|
||||
}
|
||||
/** Destinations of the piece at (f,r) from the mover's legal list. */
|
||||
function targets(s: JanggiState, f: number, r: number): string[] {
|
||||
return (janggi.legalActions!(s, pl(s)) as JanggiAction[])
|
||||
.filter((a): a is Extract<JanggiAction, { type: 'move' }> => a.type === 'move' && a.from.f === f && a.from.r === r)
|
||||
.map((a) => `${a.to.f},${a.to.r}`)
|
||||
.sort();
|
||||
}
|
||||
const kingsOnly: P[] = [
|
||||
['cho', 'king', 3, 0],
|
||||
['han', 'king', 5, 9],
|
||||
];
|
||||
|
||||
describe('setup (§4)', () => {
|
||||
test('SMSM maps to the correct squares for both seats', () => {
|
||||
const s = standard({}, 'SMSM', 'SMSM');
|
||||
const k = (f: number, r: number) => s.board[idx(f, r)]?.kind;
|
||||
expect([k(1, 0), k(2, 0), k(6, 0), k(7, 0)]).toEqual(['sang', 'ma', 'sang', 'ma']);
|
||||
expect([k(7, 9), k(6, 9), k(2, 9), k(1, 9)]).toEqual(['sang', 'ma', 'sang', 'ma']);
|
||||
expect(s.board[idx(7, 9)]?.side).toBe('han');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['MSSM', ['ma', 'sang', 'sang', 'ma']],
|
||||
['SMMS', ['sang', 'ma', 'ma', 'sang']],
|
||||
['SMSM', ['sang', 'ma', 'sang', 'ma']],
|
||||
['MSMS', ['ma', 'sang', 'ma', 'sang']],
|
||||
])('%s left→right from own seat', (arr, kinds) => {
|
||||
const s = standard({}, arr, arr);
|
||||
expect([[1, 0], [2, 0], [6, 0], [7, 0]].map(([f, r]) => s.board[idx(f!, r!)]?.kind as string | undefined)).toEqual(kinds);
|
||||
expect([[7, 9], [6, 9], [2, 9], [1, 9]].map(([f, r]) => s.board[idx(f!, r!)]?.kind as string | undefined)).toEqual(kinds);
|
||||
});
|
||||
|
||||
test('fixed pieces, 초 moves first, initial position counted once', () => {
|
||||
const s = standard();
|
||||
expect(s.phase).toBe('playing');
|
||||
expect(s.turn).toBe('cho');
|
||||
expect(s.board[idx(4, 1)]).toEqual({ side: 'cho', kind: 'king' });
|
||||
expect(s.board[idx(4, 8)]).toEqual({ side: 'han', kind: 'king' });
|
||||
expect(s.board[idx(1, 7)]).toEqual({ side: 'han', kind: 'po' });
|
||||
expect(s.board.filter(Boolean)).toHaveLength(32);
|
||||
expect(Object.values(s.positionCounts)).toEqual([1]);
|
||||
expect(janggi.view(s, 'A').scores).toEqual({ cho: 72, han: 73.5 });
|
||||
});
|
||||
|
||||
test('hanFirst: 초 must wait, 한 choice is public immediately', () => {
|
||||
const s = fresh();
|
||||
expect(janggi.activePlayers(s)).toEqual(['B']);
|
||||
expect(janggi.validate(s, 'A', { type: 'chooseSetup', arrangement: 'MSSM' }).ok).toBe(false);
|
||||
const s2 = act(s, 'B', { type: 'chooseSetup', arrangement: 'SMMS' }, 5000);
|
||||
expect(janggi.view(s2, 'A').setup.choice.han).toBe('SMMS');
|
||||
expect(janggi.view(s2, null).board[idx(7, 9)]?.kind).toBe('sang');
|
||||
expect(janggi.activePlayers(s2)).toEqual(['A']);
|
||||
expect(janggi.deadline!(s2)).toBe(5000 + 60_000);
|
||||
expect(janggi.validate(s2, 'B', { type: 'chooseSetup', arrangement: 'MSSM' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('simultaneous: no leak of the hidden choice to the opponent or spectators', () => {
|
||||
const s = act(fresh({ setupOrder: 'simultaneous' }), 'A', { type: 'chooseSetup', arrangement: 'MSMS' });
|
||||
expect(janggi.activePlayers(s)).toEqual(['B']);
|
||||
for (const viewer of ['B', null]) {
|
||||
const v = janggi.view(s, viewer);
|
||||
expect(v.setup.choice.cho).toBeNull();
|
||||
expect(v.setup.ready.cho).toBe(true);
|
||||
expect(JSON.stringify(v)).not.toContain('MSMS');
|
||||
}
|
||||
expect(janggi.view(s, 'A').setup.choice.cho).toBe('MSMS');
|
||||
expect(janggi.secretsOf!(s, 'A')).toEqual(['MSMS']);
|
||||
const s2 = act(s, 'B', { type: 'chooseSetup', arrangement: 'SMMS' });
|
||||
expect(s2.phase).toBe('playing');
|
||||
expect(janggi.view(s2, null).setup.choice).toEqual({ cho: 'MSMS', han: 'SMMS' });
|
||||
});
|
||||
|
||||
test('setup timeout picks with the seeded RNG, deterministically', () => {
|
||||
const run = () => {
|
||||
let s = fresh();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const p = janggi.timeoutPlayers!(s)[0]!;
|
||||
const a = janggi.onTimeout(s, p);
|
||||
expect(janggi.validate(s, p, a).ok).toBe(true);
|
||||
s = janggi.apply(s, p, a, { rng: SeededRng.fromSeed([9, 8, 7, 6]), now: 60_000 * (i + 1) }).state;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const a = run();
|
||||
expect(a.phase).toBe('playing');
|
||||
expect(a.setup.choice).toEqual(run().setup.choice);
|
||||
expect(janggi.validate(fresh(), 'A', { type: 'timeout' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('resign during setup aborts without a result ranking', () => {
|
||||
const s = act(fresh(), 'A', { type: 'resign' });
|
||||
expect(janggi.result(s)).toMatchObject({ ranking: [], reason: 'abandoned' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('piece movement (§5.2)', () => {
|
||||
test('마 멱: a blocked first step removes both targets in that direction', () => {
|
||||
const base: P[] = [...kingsOnly, ['cho', 'ma', 4, 4]];
|
||||
expect(targets(custom(base), 4, 4)).toHaveLength(8);
|
||||
const s = custom([...base, ['cho', 'jol', 4, 5]]);
|
||||
expect(targets(s, 4, 4)).not.toContain('3,6');
|
||||
expect(targets(s, 4, 4)).not.toContain('5,6');
|
||||
expect(targets(s, 4, 4)).toHaveLength(6);
|
||||
expect(check(s, 4, 4, 5, 6)).toEqual({ ok: false, reason: '마의 길(멱)이 막혀 있어요.' });
|
||||
});
|
||||
|
||||
test('상 멱: first and second steps both matter', () => {
|
||||
const base: P[] = [...kingsOnly, ['cho', 'sang', 4, 4]];
|
||||
expect(targets(custom(base), 4, 4).sort()).toEqual(['1,2', '1,6', '2,1', '2,7', '6,1', '6,7', '7,2', '7,6'].sort());
|
||||
const first = custom([...base, ['han', 'jol', 4, 5]]);
|
||||
expect(targets(first, 4, 4)).not.toContain('2,7');
|
||||
expect(targets(first, 4, 4)).not.toContain('6,7');
|
||||
const second = custom([...base, ['han', 'jol', 5, 6]]);
|
||||
expect(targets(second, 4, 4)).toContain('2,7');
|
||||
expect(targets(second, 4, 4)).not.toContain('6,7');
|
||||
expect(check(second, 4, 4, 6, 7)).toEqual({ ok: false, reason: '상의 길(멱)이 막혀 있어요.' });
|
||||
});
|
||||
|
||||
test('포: needs a screen, cannot jump or capture 포, may land on any square beyond', () => {
|
||||
const lone = custom([...kingsOnly, ['cho', 'po', 0, 4]]);
|
||||
expect(targets(lone, 0, 4)).toEqual([]);
|
||||
expect(check(lone, 0, 4, 0, 6).ok).toBe(false);
|
||||
const s = custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5], ['han', 'cha', 0, 8]]);
|
||||
expect(targets(s, 0, 4)).toEqual(['0,6', '0,7', '0,8']);
|
||||
const overPo = custom([...kingsOnly, ['cho', 'po', 0, 4], ['han', 'po', 0, 5]]);
|
||||
expect(targets(overPo, 0, 4)).toEqual([]);
|
||||
expect(check(overPo, 0, 4, 0, 7)).toEqual({ ok: false, reason: '포는 포를 넘을 수 없어요.' });
|
||||
const takePo = custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5], ['han', 'po', 0, 7]]);
|
||||
expect(targets(takePo, 0, 4)).toEqual(['0,6']);
|
||||
expect(check(takePo, 0, 4, 0, 7)).toEqual({ ok: false, reason: '포는 포를 잡을 수 없어요.' });
|
||||
});
|
||||
|
||||
test('포 over the palace diagonal (corner → centre screen → corner)', () => {
|
||||
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7], ['han', 'sa', 4, 8]], {}, 'cho');
|
||||
expect(targets(s, 3, 7)).toContain('5,9');
|
||||
const empty = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7]]);
|
||||
expect(targets(empty, 3, 7)).not.toContain('5,9');
|
||||
const poScreen = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7], ['han', 'po', 4, 8]]);
|
||||
expect(targets(poScreen, 3, 7)).not.toContain('5,9');
|
||||
});
|
||||
|
||||
test('차 palace diagonal: corner to corner through an empty centre only', () => {
|
||||
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 8], ['cho', 'cha', 3, 7]]);
|
||||
expect(targets(s, 3, 7)).toContain('4,8');
|
||||
expect(targets(s, 3, 7)).toContain('5,9');
|
||||
const blocked = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 8], ['cho', 'cha', 3, 7], ['han', 'sa', 4, 8]]);
|
||||
expect(targets(blocked, 3, 7)).toContain('4,8');
|
||||
expect(targets(blocked, 3, 7)).not.toContain('5,9');
|
||||
// No diagonal outside the palace or from an edge midpoint.
|
||||
const mid = custom([['cho', 'king', 3, 0], ['han', 'king', 3, 9], ['cho', 'cha', 4, 7]]);
|
||||
expect(targets(mid, 4, 7)).not.toContain('5,8');
|
||||
});
|
||||
|
||||
test('궁·사: no diagonal from edge midpoints, never leave the palace', () => {
|
||||
const s = custom([['cho', 'king', 4, 0], ['cho', 'sa', 3, 1], ['han', 'king', 3, 9]]);
|
||||
expect(targets(s, 4, 0)).toEqual(['3,0', '4,1', '5,0']);
|
||||
expect(targets(s, 3, 1)).toEqual(['3,0', '3,2', '4,1']);
|
||||
const corner = custom([['cho', 'king', 3, 2], ['han', 'king', 5, 9]]);
|
||||
expect(targets(corner, 3, 2)).toEqual(['3,1', '4,1', '4,2']);
|
||||
expect(check(corner, 3, 2, 3, 3)).toEqual({ ok: false, reason: '궁은 궁성 밖으로 나갈 수 없어요.' });
|
||||
const centre = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9]]);
|
||||
expect(targets(centre, 4, 1)).toHaveLength(8);
|
||||
});
|
||||
|
||||
test('졸: no backwards, forward diagonal in the enemy palace, not in its own', () => {
|
||||
const s = custom([...kingsOnly, ['cho', 'jol', 2, 5]]);
|
||||
expect(targets(s, 2, 5)).toEqual(['1,5', '2,6', '3,5']);
|
||||
expect(check(s, 2, 5, 2, 4)).toEqual({ ok: false, reason: '졸은 뒤로 갈 수 없어요.' });
|
||||
const pal = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 9], ['cho', 'jol', 3, 7]]);
|
||||
expect(targets(pal, 3, 7)).toContain('4,8');
|
||||
const centre = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 8], ['cho', 'jol', 4, 8]]);
|
||||
expect(targets(centre, 4, 8)).toEqual(['3,8', '3,9', '4,9', '5,8', '5,9']);
|
||||
const own = custom([['cho', 'king', 3, 0], ['han', 'king', 5, 9], ['han', 'jol', 3, 7]], {}, 'han');
|
||||
expect(targets(own, 3, 7)).toEqual(['2,7', '3,6', '4,7']);
|
||||
// 한 병 in 초's palace goes diagonally toward r=0.
|
||||
const enemy = custom([['cho', 'king', 5, 0], ['han', 'king', 5, 9], ['han', 'jol', 3, 2]], {}, 'han');
|
||||
expect(targets(enemy, 3, 2)).toContain('4,1');
|
||||
// Last rank: sideways only.
|
||||
const last = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 8], ['cho', 'jol', 0, 9]]);
|
||||
expect(targets(last, 0, 9)).toEqual(['1,9']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('장군 (§5.3)', () => {
|
||||
test('moving into check is rejected; 포 pins (one screen left) are respected', () => {
|
||||
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['han', 'cha', 5, 6]]);
|
||||
expect(check(s, 4, 1, 5, 1)).toEqual({ ok: false, reason: '그 수를 두면 내 궁이 잡혀요(장군).' });
|
||||
const pin = custom([['cho', 'king', 4, 1], ['cho', 'sa', 4, 2], ['cho', 'jol', 4, 3], ['han', 'king', 3, 9], ['han', 'po', 4, 6]]);
|
||||
expect(inCheck(pin.board, 'cho')).toBe(false);
|
||||
expect(check(pin, 4, 3, 3, 3).ok).toBe(false);
|
||||
expect(check(pin, 4, 3, 4, 4).ok).toBe(true);
|
||||
// Stepping into a 포 line creates the screen → also illegal.
|
||||
const screen = custom([['cho', 'king', 4, 1], ['cho', 'jol', 3, 4], ['han', 'king', 3, 9], ['han', 'po', 4, 6]]);
|
||||
expect(check(screen, 3, 4, 4, 4).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('pass is refused in check; check must be answered', () => {
|
||||
const s = custom([['cho', 'king', 3, 0], ['han', 'king', 4, 9], ['cho', 'cha', 0, 9]], {}, 'han');
|
||||
expect(s.inCheck).toBe('han');
|
||||
expect(janggi.validate(s, 'B', { type: 'pass' })).toEqual({ ok: false, reason: '장군을 받은 상태에서는 쉴 수 없어요.' });
|
||||
expect(check(s, 4, 9, 5, 9)).toEqual({ ok: false, reason: '장군을 피해야 해요.' });
|
||||
expect(targets(s, 4, 9)).toEqual(['4,8']);
|
||||
});
|
||||
|
||||
test('외통 ends the game', () => {
|
||||
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 8, 8], ['cho', 'cha', 0, 0], ['han', 'king', 4, 9]]);
|
||||
const r = janggi.apply(s, 'A', mv(0, 0, 0, 9), { rng: rng(), now: 1000 });
|
||||
expect(r.state.result).toMatchObject({ winner: 'cho', reason: 'checkmate', summaryKo: '초 외통승 (1수)' });
|
||||
expect(r.events.map((e) => e.type)).toContain('checkmate');
|
||||
expect(janggi.result(r.state)?.ranking).toEqual([['A'], ['B']]);
|
||||
});
|
||||
|
||||
test('janggun / meonggun events', () => {
|
||||
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 0, 0], ['han', 'king', 4, 9], ['han', 'sa', 5, 8]]);
|
||||
const r1 = janggi.apply(s, 'A', mv(0, 0, 0, 9), { rng: rng(), now: 1 });
|
||||
expect(r1.events.map((e) => e.type)).toContain('janggun');
|
||||
const r2 = janggi.apply(r1.state, 'B', mv(4, 9, 4, 8), { rng: rng(), now: 2 });
|
||||
expect(r2.events.map((e) => e.type)).toContain('meonggun');
|
||||
});
|
||||
});
|
||||
|
||||
describe('빅장 (§5.4)', () => {
|
||||
// Kings face on file 4 once the 초 졸 on (4,4) steps aside.
|
||||
const facing: P[] = [
|
||||
['cho', 'king', 4, 1],
|
||||
['cho', 'jol', 4, 4],
|
||||
['cho', 'cha', 0, 0],
|
||||
['cho', 'sa', 3, 0],
|
||||
['han', 'king', 4, 8],
|
||||
['han', 'jol', 0, 6],
|
||||
['han', 'cha', 8, 5],
|
||||
['han', 'sa', 3, 9],
|
||||
];
|
||||
|
||||
test('kjf: facing before any capture does nothing', () => {
|
||||
const s = play(custom(facing), 4, 4, 3, 4);
|
||||
expect(kingsFacing(s.board)).toBe(true);
|
||||
expect(s.bikjang).toBeNull();
|
||||
});
|
||||
|
||||
test('kjf: the first capture itself cannot call 빅장', () => {
|
||||
const s = play(custom([...facing.filter((p) => p[1] !== 'jol' || p[0] !== 'cho'), ['cho', 'ma', 4, 4], ['han', 'jol', 6, 5]]), 4, 4, 6, 5);
|
||||
expect(s.firstCapturePly).toBe(0);
|
||||
expect(kingsFacing(s.board)).toBe(true);
|
||||
expect(s.bikjang).toBeNull();
|
||||
});
|
||||
|
||||
const called = () => {
|
||||
let s = play(custom(facing), 0, 0, 0, 6); // capture
|
||||
s = play(s, 3, 9, 3, 8);
|
||||
return play(s, 4, 4, 3, 4); // 빅장
|
||||
};
|
||||
|
||||
test('kjf: after a capture, facing calls 빅장; blocking continues the game', () => {
|
||||
const s = called();
|
||||
expect(s.bikjang).toEqual({ calledBy: 'cho', ply: 2 });
|
||||
expect(janggi.view(s, 'B').bikjang).toEqual({ calledBy: 'cho' });
|
||||
const blocked = play(s, 8, 5, 4, 5);
|
||||
expect(blocked.phase).toBe('playing');
|
||||
expect(blocked.bikjang).toBeNull();
|
||||
});
|
||||
|
||||
test('kjf: not answering → 빅, score judgement (덤 means no tie)', () => {
|
||||
const s = act(called(), 'B', { type: 'pass' });
|
||||
// 초: 차13 + 사3 + 졸2 = 18; 한: 차13 + 사3 + 1.5 = 17.5
|
||||
expect(s.result).toMatchObject({ winner: 'cho', reason: 'scoreBikjang', scores: { cho: 18, han: 17.5 }, summaryKo: '초 판정승 18 : 17.5 (빅장)' });
|
||||
const noScore = act(
|
||||
(() => {
|
||||
let t = play(custom(facing, { scoring: false }), 0, 0, 0, 6);
|
||||
t = play(t, 3, 9, 3, 8);
|
||||
return play(t, 4, 4, 3, 4);
|
||||
})(),
|
||||
'B',
|
||||
{ type: 'pass' },
|
||||
);
|
||||
expect(noScore.result).toMatchObject({ winner: null, reason: 'drawBikjang' });
|
||||
expect(janggi.result(noScore)?.ranking).toEqual([['A', 'B']]);
|
||||
});
|
||||
|
||||
test('kja30: needs both sides under 30 points', () => {
|
||||
const rich = play(custom([...facing, ['cho', 'cha', 8, 0], ['cho', 'po', 1, 2]], { bikjang: 'kja30' }), 4, 4, 3, 4);
|
||||
expect(rich.bikjang).toBeNull();
|
||||
const poor = play(custom(facing, { bikjang: 'kja30' }), 4, 4, 3, 4);
|
||||
expect(poor.bikjang?.calledBy).toBe('cho');
|
||||
});
|
||||
|
||||
test('off: facing is harmless', () => {
|
||||
let s = play(custom(facing, { bikjang: 'off' }), 0, 0, 0, 6);
|
||||
s = play(s, 3, 9, 3, 8);
|
||||
s = play(s, 4, 4, 3, 4);
|
||||
expect(s.bikjang).toBeNull();
|
||||
expect(act(s, 'B', { type: 'pass' }).phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('repetition (§5.5) and passes (§5.6)', () => {
|
||||
const shuffle: P[] = [...kingsOnly, ['cho', 'cha', 0, 0], ['han', 'cha', 8, 9]];
|
||||
/** 초 차 0,0↔0,1 and 한 차 8,9↔8,8; the 12th ply would repeat the start a 4th time. */
|
||||
function cycle(s: JanggiState, plies: number): JanggiState {
|
||||
const seq: [number, number, number, number][] = [
|
||||
[0, 0, 0, 1],
|
||||
[8, 9, 8, 8],
|
||||
[0, 1, 0, 0],
|
||||
[8, 8, 8, 9],
|
||||
];
|
||||
for (let i = 0; i < plies; i++) s = play(s, ...seq[i % 4]!);
|
||||
return s;
|
||||
}
|
||||
|
||||
test('forbid: the move creating the 4th repetition is refused, others allowed', () => {
|
||||
const s = cycle(custom(shuffle), 11);
|
||||
expect(janggi.view(s, 'B').repeatWarning).toBe(true);
|
||||
expect(check(s, 8, 8, 8, 9)).toEqual({ ok: false, reason: '같은 수를 너무 많이 반복해서 둘 수 없어요.' });
|
||||
expect(targets(s, 8, 8)).not.toContain('8,9');
|
||||
expect(check(s, 8, 8, 8, 7).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('forbid exception: in check with only a repeating move → allowed, then score judgement', () => {
|
||||
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 0, 9], ['han', 'king', 4, 9]], {}, 'han');
|
||||
s.positionCounts[positionKey(makeMove(s.board, idx(4, 9), idx(4, 8)), 'cho')] = 3;
|
||||
expect(check(s, 4, 9, 4, 8).ok).toBe(true);
|
||||
const end = play(s, 4, 9, 4, 8);
|
||||
expect(end.result).toMatchObject({ reason: 'scoreRepetition', winner: 'cho' });
|
||||
});
|
||||
|
||||
test('lose: the side creating the 4th repetition loses', () => {
|
||||
const s = cycle(custom(shuffle, { repetition: 'lose' }), 12);
|
||||
expect(s.result).toMatchObject({ winner: 'cho', reason: 'repetitionLoss' });
|
||||
});
|
||||
|
||||
test('scoreEnd: 4th repetition ends by score', () => {
|
||||
const s = cycle(custom(shuffle, { repetition: 'scoreEnd' }), 12);
|
||||
expect(s.result?.reason).toBe('scoreRepetition');
|
||||
});
|
||||
|
||||
test('both pass in a row → score judgement, or draw without scoring', () => {
|
||||
let s = act(standard(), 'A', { type: 'pass' });
|
||||
s = act(s, 'B', { type: 'pass' });
|
||||
expect(s.result).toMatchObject({ winner: 'han', reason: 'scorePasses', summaryKo: '한 판정승 73.5 : 72 (연속 한수쉼)' });
|
||||
let d = act(standard({ scoring: false }), 'A', { type: 'pass' });
|
||||
d = act(d, 'B', { type: 'pass' });
|
||||
expect(d.result).toMatchObject({ winner: null, reason: 'drawPasses' });
|
||||
});
|
||||
|
||||
test('allowPass=false refuses pass unless there is no legal move', () => {
|
||||
const s = standard({ allowPass: false });
|
||||
expect(janggi.validate(s, 'A', { type: 'pass' })).toEqual({ ok: false, reason: '이 방은 한수쉼을 쓰지 않아요.' });
|
||||
// 초 궁 boxed in by defended 한 pieces, no other 초 piece, not in check → pass allowed.
|
||||
const stuck = custom(
|
||||
[
|
||||
['cho', 'king', 3, 0],
|
||||
['han', 'ma', 4, 0],
|
||||
['han', 'ma', 3, 1],
|
||||
['han', 'ma', 4, 1],
|
||||
['han', 'cha', 3, 5],
|
||||
['han', 'cha', 4, 5],
|
||||
['han', 'jol', 5, 0],
|
||||
['han', 'king', 5, 9],
|
||||
],
|
||||
{ allowPass: false },
|
||||
);
|
||||
expect(stuck.inCheck).toBeNull();
|
||||
expect(janggi.view(stuck, 'A').legalMoves).toEqual([]);
|
||||
expect(janggi.validate(stuck, 'A', { type: 'pass' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('moveLimit reached → score judgement', () => {
|
||||
let s = standard({ moveLimit: 2 });
|
||||
s = play(s, 0, 3, 1, 3);
|
||||
s = play(s, 0, 6, 1, 6);
|
||||
expect(s.result?.reason).toBe('scoreMoveLimit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clocks (§8.5)', () => {
|
||||
test('byoyomi: main time, then periods, then time loss', () => {
|
||||
const tc = { kind: 'byoyomi' as const, mainSec: 60, periodSec: 30, periods: 3 };
|
||||
let s = standard({ timeControl: tc });
|
||||
s.clock.turnStartedAt = 0;
|
||||
expect(janggi.deadline!(s)).toBe(60_000 + 90_000);
|
||||
s = play(s, 0, 3, 1, 3, 50_000); // 초 used 50s
|
||||
expect(s.clock.mainMs.cho).toBe(10_000);
|
||||
s = play(s, 0, 6, 1, 6, 60_000);
|
||||
s = play(s, 1, 3, 0, 3, 60_000 + 35_000); // 10s main + 25s of a period → keeps 3
|
||||
expect(s.clock).toMatchObject({ mainMs: { cho: 0 }, periodsLeft: { cho: 3 } });
|
||||
s = play(s, 1, 6, 0, 6, 100_000);
|
||||
s = play(s, 0, 3, 1, 3, 100_000 + 31_000); // over one period
|
||||
expect(s.clock.periodsLeft.cho).toBe(2);
|
||||
s = play(s, 0, 6, 1, 6, 140_000);
|
||||
expect(janggi.deadline!(s)).toBe(140_000 + 60_000);
|
||||
s = play(s, 1, 3, 0, 3, 140_000 + 61_000); // used both remaining periods
|
||||
expect(s.result).toMatchObject({ winner: 'han', reason: 'timeLoss' });
|
||||
expect(s.board[idx(1, 3)]?.kind).toBe('jol'); // the late move was not played
|
||||
});
|
||||
|
||||
test('timeout action → time loss for the side to move; none uses idle limit', () => {
|
||||
const s = standard();
|
||||
expect(janggi.timeoutPlayers!(s)).toEqual(['A']);
|
||||
const t = act(s, 'A', janggi.onTimeout(s, 'A'));
|
||||
expect(janggi.result(t)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout' });
|
||||
const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 }, idleLimitSec: 300 });
|
||||
expect(janggi.deadline!(n)).toBe(n.clock.turnStartedAt + 300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requests, undo, views', () => {
|
||||
test('undo restores firstCapturePly, 빅장 and repetition counts', () => {
|
||||
const facing: P[] = [
|
||||
['cho', 'king', 4, 1],
|
||||
['cho', 'jol', 4, 4],
|
||||
['cho', 'cha', 0, 0],
|
||||
['han', 'king', 4, 8],
|
||||
['han', 'jol', 0, 6],
|
||||
['han', 'cha', 8, 5],
|
||||
['han', 'sa', 3, 9],
|
||||
];
|
||||
const s0 = custom(facing);
|
||||
const s1 = play(s0, 0, 0, 0, 6);
|
||||
const s2 = play(s1, 3, 9, 3, 8);
|
||||
const s3 = play(s2, 4, 4, 3, 4);
|
||||
expect(s3.bikjang).not.toBeNull();
|
||||
const s4 = play(s3, 8, 5, 4, 5);
|
||||
expect(s4.bikjang).toBeNull();
|
||||
// 한 takes back its block → 빅장 pending again.
|
||||
let u = act(s4, 'B', { type: 'requestUndo' });
|
||||
expect(u.undo.pending).toEqual({ by: 'han', plies: 1 });
|
||||
u = act(u, 'A', { type: 'respondUndo', accept: true });
|
||||
expect(u.bikjang).toEqual(s3.bikjang);
|
||||
expect(u.positionCounts).toEqual(s3.positionCounts);
|
||||
expect(u.board).toEqual(s3.board);
|
||||
expect(u.turn).toBe('han');
|
||||
// 초 takes back two plies (its 빅장 + 한's reply), then its capture.
|
||||
u = act(u, 'A', { type: 'requestUndo' });
|
||||
expect(u.undo.pending?.plies).toBe(1);
|
||||
u = act(u, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(u.positionCounts).toEqual(s2.positionCounts);
|
||||
u = act(u, 'A', { type: 'requestUndo' });
|
||||
expect(u.undo.pending?.plies).toBe(2);
|
||||
u = act(u, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(u.firstCapturePly).toBeNull();
|
||||
expect(u.board).toEqual(s0.board);
|
||||
expect(u.positionCounts).toEqual(s0.positionCounts);
|
||||
expect(u.undo.usedBy).toEqual({ cho: 2, han: 1 });
|
||||
expect(janggi.validate(u, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '아직 무를 수가 없어요.' });
|
||||
});
|
||||
|
||||
test('draw offer: decline, re-offer gap, accept', () => {
|
||||
let s = act(standard({ drawReofferMoves: 1 }), 'A', { type: 'offerDraw' });
|
||||
expect(janggi.activePlayers(s)).toEqual(['A', 'B']);
|
||||
s = act(s, 'B', { type: 'respondDraw', accept: false });
|
||||
expect(janggi.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
|
||||
s = play(play(s, 0, 3, 1, 3), 0, 6, 1, 6);
|
||||
s = act(s, 'A', { type: 'offerDraw' });
|
||||
s = act(s, 'B', { type: 'respondDraw', accept: true });
|
||||
expect(janggi.result(s)).toMatchObject({ ranking: [['A', 'B']], reason: 'draw-agreed' });
|
||||
});
|
||||
|
||||
test('resign during play', () => {
|
||||
const s = act(standard(), 'B', { type: 'resign' });
|
||||
expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' });
|
||||
});
|
||||
|
||||
test('view: no rng, legal moves only for the player to move, threats when enabled', () => {
|
||||
const s = standard({ hints: 'movesAndThreats' });
|
||||
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
|
||||
expect(janggi.view(s, null).legalMoves).toBeNull();
|
||||
expect(janggi.view(s, 'B').legalMoves).toBeNull();
|
||||
const v = janggi.view(s, 'A');
|
||||
expect(v.legalMoves!.length).toBeGreaterThan(20);
|
||||
expect(v.canPass).toBe(true);
|
||||
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]], { hints: 'movesAndThreats' });
|
||||
expect(janggi.view(t, 'A').threatened).toEqual([{ f: 4, r: 4 }]);
|
||||
});
|
||||
|
||||
test('포 legal moves carry their screen for the UI', () => {
|
||||
const v = janggi.view(custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5]]), 'A');
|
||||
expect(v.legalMoves!.find((m) => m.from.f === 0 && m.from.r === 4)?.via).toEqual({ f: 0, r: 5 });
|
||||
});
|
||||
|
||||
test('initial board helper is symmetric', () => {
|
||||
const b = initialBoard();
|
||||
for (let r = 0; r < 10; r++)
|
||||
for (let f = 0; f < 9; f++) expect(b[idx(f, r)]?.kind).toBe(b[idx(f, 9 - r)]?.kind);
|
||||
});
|
||||
});
|
||||
|
||||
describe('random playouts', () => {
|
||||
const kings = (st: unknown) => {
|
||||
const s = st as JanggiState;
|
||||
if (s.phase !== 'setup' && s.board.filter((p) => p?.kind === 'king').length !== 2) throw new Error('king missing');
|
||||
};
|
||||
test.each([
|
||||
['default + limit 200', { moveLimit: 200 }],
|
||||
['no scoring, kja30, simultaneous, limit 150', { scoring: false, bikjang: 'kja30', setupOrder: 'simultaneous', moveLimit: 150 }],
|
||||
['repetition lose, no pass, limit 250', { repetition: 'lose', allowPass: false, moveLimit: 250 }],
|
||||
] as const)('%s: finish, deterministic, JSON-safe', (_n, o) => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const options = opts(o as Partial<JanggiOptions>);
|
||||
const a = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 400 });
|
||||
const b = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, maxSteps: 400 });
|
||||
expect(a.finished).toBe(true);
|
||||
expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState));
|
||||
expect(janggi.result(a.finalState as JanggiState)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults without a move limit stay valid for 300 steps', () => {
|
||||
for (let i = 10; i < 13; i++) {
|
||||
randomPlayout(janggi, { players: ['A', 'B'], options: janggi.defaultOptions, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 300 });
|
||||
}
|
||||
});
|
||||
});
|
||||
347
packages/games/src/janggi/rules.ts
Normal file
347
packages/games/src/janggi/rules.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/** 장기 행마·장군 판정 (docs/games/janggi.md §5). Pure board logic, no state machine. */
|
||||
|
||||
export type Side = 'cho' | 'han';
|
||||
export type PieceKind = 'king' | 'cha' | 'po' | 'ma' | 'sang' | 'sa' | 'jol';
|
||||
export interface Piece {
|
||||
side: Side;
|
||||
kind: PieceKind;
|
||||
}
|
||||
export interface Sq {
|
||||
f: number;
|
||||
r: number;
|
||||
}
|
||||
/** index = r * 9 + f. */
|
||||
export type Board = (Piece | null)[];
|
||||
export type Arrangement = 'MSSM' | 'SMMS' | 'SMSM' | 'MSMS';
|
||||
|
||||
export const FILES = 9;
|
||||
export const RANKS = 10;
|
||||
export const ARRANGEMENTS: readonly Arrangement[] = ['MSSM', 'SMMS', 'SMSM', 'MSMS'];
|
||||
export const PIECE_VALUE: Record<PieceKind, number> = { king: 0, cha: 13, po: 7, ma: 5, sang: 3, sa: 3, jol: 2 };
|
||||
/** 후수(한) 덤. Half-point so score judgements never tie. */
|
||||
export const HAN_BONUS = 1.5;
|
||||
|
||||
export const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
|
||||
export const idx = (f: number, r: number): number => r * FILES + f;
|
||||
export const sqOf = (i: number): Sq => ({ f: i % FILES, r: Math.floor(i / FILES) });
|
||||
export const onBoard = (f: number, r: number): boolean => f >= 0 && f < FILES && r >= 0 && r < RANKS;
|
||||
export const inPalace = (f: number, r: number): boolean => f >= 3 && f <= 5 && (r <= 2 || r >= 7);
|
||||
const isCenter = (f: number, r: number): boolean => f === 4 && (r === 1 || r === 8);
|
||||
/** Palace corners and centres: the points that lie on the drawn X. */
|
||||
const isDiagPoint = (f: number, r: number): boolean => isCenter(f, r) || ((f === 3 || f === 5) && (r === 0 || r === 2 || r === 7 || r === 9));
|
||||
/** One diagonal step along a drawn palace line (corner ↔ centre). */
|
||||
const diagLinked = (f: number, r: number, nf: number, nr: number): boolean =>
|
||||
onBoard(nf, nr) && isDiagPoint(f, r) && isDiagPoint(nf, nr) && (isCenter(f, r) || isCenter(nf, nr));
|
||||
|
||||
const ORTHO: readonly [number, number][] = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
const DIAG: readonly [number, number][] = [
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
];
|
||||
|
||||
/** Back-row slots for 마·상, left→right from that player's own seat. */
|
||||
export const SETUP_SLOTS: Record<Side, Sq[]> = {
|
||||
cho: [
|
||||
{ f: 1, r: 0 },
|
||||
{ f: 2, r: 0 },
|
||||
{ f: 6, r: 0 },
|
||||
{ f: 7, r: 0 },
|
||||
],
|
||||
han: [
|
||||
{ f: 7, r: 9 },
|
||||
{ f: 6, r: 9 },
|
||||
{ f: 2, r: 9 },
|
||||
{ f: 1, r: 9 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Fixed pieces (everything except 마·상). */
|
||||
export function initialBoard(): Board {
|
||||
const b: Board = new Array<Piece | null>(FILES * RANKS).fill(null);
|
||||
const put = (side: Side, kind: PieceKind, f: number, r: number) => {
|
||||
b[idx(f, side === 'cho' ? r : RANKS - 1 - r)] = { side, kind };
|
||||
};
|
||||
for (const side of ['cho', 'han'] as const) {
|
||||
put(side, 'king', 4, 1);
|
||||
put(side, 'sa', 3, 0);
|
||||
put(side, 'sa', 5, 0);
|
||||
put(side, 'cha', 0, 0);
|
||||
put(side, 'cha', 8, 0);
|
||||
put(side, 'po', 1, 2);
|
||||
put(side, 'po', 7, 2);
|
||||
for (const f of [0, 2, 4, 6, 8]) put(side, 'jol', f, 3);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
export function placeArrangement(b: Board, side: Side, arr: Arrangement): void {
|
||||
SETUP_SLOTS[side].forEach((sq, i) => {
|
||||
b[idx(sq.f, sq.r)] = { side, kind: arr[i] === 'M' ? 'ma' : 'sang' };
|
||||
});
|
||||
}
|
||||
|
||||
export interface PseudoMove {
|
||||
to: number;
|
||||
/** 포다리 (screen) for 포 moves. */
|
||||
via?: number;
|
||||
}
|
||||
|
||||
function slide(b: Board, f: number, r: number, df: number, dr: number, diag: boolean, fn: (i: number) => boolean): void {
|
||||
let cf = f;
|
||||
let cr = r;
|
||||
for (;;) {
|
||||
const nf = cf + df;
|
||||
const nr = cr + dr;
|
||||
if (diag ? !diagLinked(cf, cr, nf, nr) : !onBoard(nf, nr)) return;
|
||||
if (!fn(idx(nf, nr))) return;
|
||||
cf = nf;
|
||||
cr = nr;
|
||||
}
|
||||
}
|
||||
|
||||
/** All rays a 차/포 may travel from (f,r): orthogonal lines plus palace diagonals. */
|
||||
function rays(f: number, r: number): { df: number; dr: number; diag: boolean }[] {
|
||||
const out = ORTHO.map(([df, dr]) => ({ df, dr, diag: false }));
|
||||
if (isDiagPoint(f, r)) for (const [df, dr] of DIAG) if (diagLinked(f, r, f + df, r + dr)) out.push({ df, dr, diag: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Moves ignoring check (own-king safety). Captures of the enemy 궁 are included (attack detection). */
|
||||
export function pseudoMoves(b: Board, from: number): PseudoMove[] {
|
||||
const p = b[from];
|
||||
if (!p) return [];
|
||||
const { f, r } = sqOf(from);
|
||||
const out: PseudoMove[] = [];
|
||||
const free = (i: number) => b[i]?.side !== p.side;
|
||||
const add = (nf: number, nr: number) => {
|
||||
if (onBoard(nf, nr) && free(idx(nf, nr))) out.push({ to: idx(nf, nr) });
|
||||
};
|
||||
switch (p.kind) {
|
||||
case 'king':
|
||||
case 'sa':
|
||||
for (const [df, dr] of ORTHO) if (inPalace(f + df, r + dr) && inPalace(f, r)) add(f + df, r + dr);
|
||||
for (const [df, dr] of DIAG) if (diagLinked(f, r, f + df, r + dr)) add(f + df, r + dr);
|
||||
break;
|
||||
case 'cha':
|
||||
for (const ray of rays(f, r)) {
|
||||
slide(b, f, r, ray.df, ray.dr, ray.diag, (i) => {
|
||||
if (!b[i]) {
|
||||
out.push({ to: i });
|
||||
return true;
|
||||
}
|
||||
if (b[i]!.side !== p.side) out.push({ to: i });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'po':
|
||||
for (const ray of rays(f, r)) {
|
||||
let screen = -1;
|
||||
slide(b, f, r, ray.df, ray.dr, ray.diag, (i) => {
|
||||
const q = b[i];
|
||||
if (screen < 0) {
|
||||
if (!q) return true;
|
||||
if (q.kind === 'po') return false;
|
||||
screen = i;
|
||||
return true;
|
||||
}
|
||||
if (!q) {
|
||||
out.push({ to: i, via: screen });
|
||||
return true;
|
||||
}
|
||||
if (q.side !== p.side && q.kind !== 'po') out.push({ to: i, via: screen });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ma':
|
||||
for (const [df, dr] of ORTHO) {
|
||||
if (!onBoard(f + df, r + dr) || b[idx(f + df, r + dr)]) continue;
|
||||
const [sf, sr] = [dr, df]; // perpendicular
|
||||
add(f + 2 * df + sf, r + 2 * dr + sr);
|
||||
add(f + 2 * df - sf, r + 2 * dr - sr);
|
||||
}
|
||||
break;
|
||||
case 'sang':
|
||||
for (const [df, dr] of ORTHO) {
|
||||
if (!onBoard(f + df, r + dr) || b[idx(f + df, r + dr)]) continue;
|
||||
const [sf, sr] = [dr, df];
|
||||
for (const s of [1, -1]) {
|
||||
const mf = f + 2 * df + s * sf;
|
||||
const mr = r + 2 * dr + s * sr;
|
||||
if (!onBoard(mf, mr) || b[idx(mf, mr)]) continue;
|
||||
add(f + 3 * df + 2 * s * sf, r + 3 * dr + 2 * s * sr);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'jol': {
|
||||
const fwd = p.side === 'cho' ? 1 : -1;
|
||||
add(f, r + fwd);
|
||||
add(f - 1, r);
|
||||
add(f + 1, r);
|
||||
const enemyPalace = p.side === 'cho' ? r >= 7 : r <= 2;
|
||||
if (enemyPalace) for (const df of [-1, 1]) if (diagLinked(f, r, f + df, r + fwd)) add(f + df, r + fwd);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findKing(b: Board, side: Side): number {
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const p = b[i];
|
||||
if (p && p.kind === 'king' && p.side === side) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Is square `target` attacked by any piece of `by`? (Facing 궁 is not an attack.) */
|
||||
export function attacks(b: Board, by: Side, target: number): boolean {
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const p = b[i];
|
||||
if (!p || p.side !== by) continue;
|
||||
if (p.kind === 'king' || p.kind === 'sa') {
|
||||
// Cheap reject: palace pieces only reach adjacent palace points.
|
||||
const a = sqOf(i);
|
||||
const t = sqOf(target);
|
||||
if (Math.abs(a.f - t.f) > 1 || Math.abs(a.r - t.r) > 1) continue;
|
||||
}
|
||||
for (const m of pseudoMoves(b, i)) if (m.to === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function inCheck(b: Board, side: Side): boolean {
|
||||
const k = findKing(b, side);
|
||||
return k >= 0 && attacks(b, other(side), k);
|
||||
}
|
||||
|
||||
/** Two 궁 on the same file with nothing between them. */
|
||||
export function kingsFacing(b: Board): boolean {
|
||||
const c = findKing(b, 'cho');
|
||||
const h = findKing(b, 'han');
|
||||
if (c < 0 || h < 0) return false;
|
||||
const a = sqOf(c);
|
||||
const z = sqOf(h);
|
||||
if (a.f !== z.f) return false;
|
||||
const [lo, hi] = a.r < z.r ? [a.r, z.r] : [z.r, a.r];
|
||||
for (let r = lo + 1; r < hi; r++) if (b[idx(a.f, r)]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface LegalMove {
|
||||
from: number;
|
||||
to: number;
|
||||
via?: number;
|
||||
}
|
||||
|
||||
export function makeMove(b: Board, from: number, to: number): Board {
|
||||
const n = b.slice();
|
||||
n[to] = n[from]!;
|
||||
n[from] = null;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Moves that do not leave the mover's own 궁 attacked. */
|
||||
export function legalMoves(b: Board, side: Side): LegalMove[] {
|
||||
const out: LegalMove[] = [];
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (b[i]?.side !== side) continue;
|
||||
for (const m of pseudoMoves(b, i)) {
|
||||
if (!inCheck(makeMove(b, i, m.to), side)) out.push(m.via === undefined ? { from: i, to: m.to } : { from: i, to: m.to, via: m.via });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hasLegalMove(b: Board, side: Side): boolean {
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (b[i]?.side !== side) continue;
|
||||
for (const m of pseudoMoves(b, i)) if (!inCheck(makeMove(b, i, m.to), side)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Material on board (덤 excluded). */
|
||||
export function material(b: Board, side: Side): number {
|
||||
let n = 0;
|
||||
for (const p of b) if (p && p.side === side) n += PIECE_VALUE[p.kind];
|
||||
return n;
|
||||
}
|
||||
|
||||
const CODE: Record<PieceKind, string> = { king: 'k', cha: 'c', po: 'p', ma: 'm', sang: 's', sa: 'a', jol: 'j' };
|
||||
/** Position key = placement + side to move (docs §5.5). */
|
||||
export function positionKey(b: Board, turn: Side): string {
|
||||
let s = turn === 'cho' ? 'C' : 'H';
|
||||
for (const p of b) s += p ? (p.side === 'cho' ? CODE[p.kind].toUpperCase() : CODE[p.kind]) : '.';
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Best-effort Korean explanation for why a piece cannot reach `to` (ignores check). */
|
||||
export function explainIllegal(b: Board, from: number, to: number): string {
|
||||
const p = b[from]!;
|
||||
const a = sqOf(from);
|
||||
const t = sqOf(to);
|
||||
const df = t.f - a.f;
|
||||
const dr = t.r - a.r;
|
||||
const adf = Math.abs(df);
|
||||
const adr = Math.abs(dr);
|
||||
switch (p.kind) {
|
||||
case 'king':
|
||||
case 'sa':
|
||||
if (!inPalace(t.f, t.r)) return `${p.kind === 'king' ? '궁' : '사'}은 궁성 밖으로 나갈 수 없어요.`;
|
||||
if (adf === 1 && adr === 1) return '궁성 대각선이 그려진 곳에서만 대각선으로 갈 수 있어요.';
|
||||
return `${p.kind === 'king' ? '궁' : '사'}은 궁성 안에서 한 칸씩만 움직여요.`;
|
||||
case 'ma':
|
||||
if ((adf === 1 && adr === 2) || (adf === 2 && adr === 1)) return '마의 길(멱)이 막혀 있어요.';
|
||||
return '마는 한 칸 곧게 간 뒤 대각선으로 한 칸 가요.';
|
||||
case 'sang':
|
||||
if ((adf === 2 && adr === 3) || (adf === 3 && adr === 2)) return '상의 길(멱)이 막혀 있어요.';
|
||||
return '상은 한 칸 곧게 간 뒤 대각선으로 두 칸 가요.';
|
||||
case 'jol': {
|
||||
const fwd = p.side === 'cho' ? 1 : -1;
|
||||
if (dr === -fwd) return `${p.side === 'cho' ? '졸' : '병'}은 뒤로 갈 수 없어요.`;
|
||||
return `${p.side === 'cho' ? '졸' : '병'}은 앞이나 옆으로 한 칸만 가요.`;
|
||||
}
|
||||
case 'po': {
|
||||
const between = lineBetween(from, to);
|
||||
if (!between) return '포는 곧은 선(궁성에서는 대각선)을 따라 움직여요.';
|
||||
const pieces = between.map((i) => b[i]).filter((q): q is Piece => !!q);
|
||||
if (pieces.some((q) => q.kind === 'po')) return '포는 포를 넘을 수 없어요.';
|
||||
if (b[to]?.kind === 'po') return '포는 포를 잡을 수 없어요.';
|
||||
if (pieces.length === 0) return '포는 기물 하나(포다리)를 넘어야 움직일 수 있어요.';
|
||||
return '포는 기물을 하나만 넘을 수 있어요.';
|
||||
}
|
||||
case 'cha':
|
||||
return lineBetween(from, to) ? '차는 다른 기물을 넘을 수 없어요.' : '차는 곧은 선(궁성에서는 대각선)을 따라 움직여요.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Squares strictly between two points on one 차/포 line, or null if not on a line. */
|
||||
function lineBetween(from: number, to: number): number[] | null {
|
||||
const a = sqOf(from);
|
||||
const t = sqOf(to);
|
||||
const df = Math.sign(t.f - a.f);
|
||||
const dr = Math.sign(t.r - a.r);
|
||||
const diag = df !== 0 && dr !== 0;
|
||||
if (diag ? Math.abs(t.f - a.f) !== Math.abs(t.r - a.r) : df === 0 && dr === 0) return null;
|
||||
const out: number[] = [];
|
||||
let f = a.f;
|
||||
let r = a.r;
|
||||
for (;;) {
|
||||
const nf = f + df;
|
||||
const nr = r + dr;
|
||||
if (diag ? !diagLinked(f, r, nf, nr) : !onBoard(nf, nr)) return null;
|
||||
if (nf === t.f && nr === t.r) return out;
|
||||
out.push(idx(nf, nr));
|
||||
f = nf;
|
||||
r = nr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user