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:
EJClaw
2026-10-04 05:24:11 +09:00
parent c5c30d4ada
commit 62a6caa6a7
11 changed files with 4822 additions and 0 deletions

View 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);
});

View 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;
},
};

View 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(' ')}`;
}

View File

@@ -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,
};

View 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] : [];
},
};

View 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 });
}
});
});

View 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;
}
}