Files
joke-app/packages/games/src/baduk/baduk.test.ts
EJClaw 4cb71d8cbe 위협 가이드 제거: 오목 위험 자리, 체스·장기 위험 기물 경고, 바둑 단수 경고
- 오목: 상대 3·4를 막으라는 빨간 동그라미·노란 점선(hints 옵션, view.hints, threatHints) 삭제
- 체스·장기: hints 'movesAndThreats'(주황 점선 위험 기물) 삭제 → 'off' / 'moves'만 남김
- 바둑: atariWarning 옵션과 atariWarnings(깜빡이는 단수 돌) 삭제
- 도움말 문구·설정 화면·docs 정리, 테스트는 "어떤 view에도 위협 표시 없음"으로 바꿈
- 갈 수 있는 칸 점, 금수 ×, 둘 수 없는 점 같은 규칙 표시는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:34:58 +09:00

584 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { baduk, badukOptionsSchema, handicapPoints, type BadukAction, type BadukOptions, type BadukState } from './index';
import { hashBoard, suggestMarks, tryPlace, type Cell } from './board';
const COLS = 'ABCDEFGHJKLMNOPQRST';
const at = (label: string, size = 9) => ({ x: COLS.indexOf(label[0]!), y: size - Number(label.slice(1)) });
const idx = (label: string, size = 9) => {
const p = at(label, size);
return p.y * size + p.x;
};
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
type Color = 'black' | 'white';
/** A = black, B = white. Stones are set directly (no moves recorded). */
function make(opts: Partial<BadukOptions> = {}, stones: { black?: string[]; white?: string[] } = {}, turn?: Color): BadukState {
const options = baduk.optionsSchema.parse({ colorAssignment: 'hostBlack', boardSize: 9, ...opts });
const s = baduk.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 });
for (const [color, v] of [['black', 1], ['white', 2]] as const) for (const l of stones[color] ?? []) s.board[idx(l, s.size)] = v;
if (turn) s.turn = turn;
s.hash = hashBoard(s.board);
s.positionHistory = [s.hash];
s.situationCounts = { [s.hash + (s.turn === 'black' ? 'b' : 'w')]: 1 };
return s;
}
const actorOf = (s: BadukState, c: Color = s.turn) => s.players[c];
function act(s: BadukState, c: Color, a: BadukAction, now = 1000): BadukState {
const v = baduk.validate(s, actorOf(s, c), a);
if (!v.ok) throw new Error(v.reason);
return baduk.apply(s, actorOf(s, c), a, { rng: rng(), now }).state;
}
function play(s: BadukState, label: string, now = 1000): BadukState {
return act(s, s.turn, label === 'pass' ? { type: 'pass' } : { type: 'place', ...at(label, s.size) }, now);
}
function playAll(s: BadukState, labels: string[]): BadukState {
for (const l of labels) s = play(s, l);
return s;
}
const check = (s: BadukState, label: string, c: Color = s.turn) => baduk.validate(s, actorOf(s, c), { type: 'place', ...at(label, s.size) });
const cell = (s: BadukState, label: string) => s.board[idx(label, s.size)];
describe('captures (§10)', () => {
test('single stone in the centre', () => {
let s = make({}, { black: ['D5', 'F5', 'E6'], white: ['E5'] });
s = play(s, 'E4');
expect(cell(s, 'E5')).toBe(0);
expect(s.captures.black).toBe(1);
expect(s.moves.at(-1)).toMatchObject({ kind: 'place', captured: [at('E5')] });
});
test('several chains at once', () => {
let s = make({}, { black: ['D4', 'D6', 'C5', 'F4', 'F6', 'G5'], white: ['D5', 'F5'] });
s = play(s, 'E5');
expect(cell(s, 'D5')).toBe(0);
expect(cell(s, 'F5')).toBe(0);
expect(s.captures.black).toBe(2);
});
test('corner and edge', () => {
let s = make({}, { black: ['B1'], white: ['A1'] });
s = play(s, 'A2');
expect(cell(s, 'A1')).toBe(0);
let e = make({}, { black: ['A4', 'A6'], white: ['A5'] });
e = play(e, 'B5');
expect(cell(e, 'A5')).toBe(0);
expect(e.captures.black).toBe(1);
});
test('multi-stone chain', () => {
let s = make({}, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] });
s = play(s, 'C1');
expect(s.captures.black).toBe(4);
expect(s.board.filter((v) => v === 2)).toHaveLength(0);
});
});
describe('suicide (§10)', () => {
test('suicide is rejected', () => {
const s = make({}, { black: ['B1', 'A2'] }, 'white');
expect(check(s, 'A1')).toEqual({ ok: false, reason: '둘 곳의 활로가 없어 둘 수 없어요(자충수).' });
const multi = make({}, { black: ['A3', 'B2', 'C1'], white: ['A1'] }, 'white');
expect(check(multi, 'A2').ok).toBe(true);
expect(check(make({}, { black: ['A3', 'B2', 'C1'], white: ['A1', 'B1'] }, 'white'), 'A2').ok).toBe(false);
});
test('a move that captures first is legal even with no liberties of its own', () => {
const s = make({}, { black: ['A3', 'B2', 'C1'], white: ['A2', 'B1'] });
expect(check(s, 'A1').ok).toBe(true);
const after = play(s, 'A1');
expect(after.captures.black).toBe(2);
});
test('illegal points are listed for the mover with reasons', () => {
const s = make({}, { black: ['B1', 'A2'] }, 'white');
const v = baduk.view(s, 'B');
expect(v.illegalPoints).toContainEqual({ ...at('A1'), reason: 'suicide' });
});
});
// Ko shape: black D5 E4 E6, white E5 F4 F6 G5. Black F5 takes E5.
const KO = { black: ['D5', 'E4', 'E6'], white: ['E5', 'F4', 'F6', 'G5'] };
describe('ko (§5.3, §10)', () => {
for (const koRule of ['positionalSuperko', 'korean'] as const) {
test(`${koRule}: immediate retake rejected, allowed after a ko threat`, () => {
let s = play(make({ koRule }, KO), 'F5');
expect(cell(s, 'E5')).toBe(0);
expect(check(s, 'E5')).toEqual({ ok: false, reason: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.' });
expect(baduk.view(s, 'B').illegalPoints).toContainEqual({ ...at('E5'), reason: 'ko' });
s = playAll(s, ['A9', 'J1']);
expect(check(s, 'E5').ok).toBe(true);
s = play(s, 'E5');
expect(cell(s, 'F5')).toBe(0);
});
}
test('positionalSuperko rejects any earlier arrangement (not just the last one)', () => {
const s = make({}, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] });
const r = tryPlace(s.board, 9, idx('C1'), 1, s.hash);
if (!r.ok) throw new Error('unexpected');
// Pretend the post-capture arrangement occurred earlier in the game.
s.positionHistory = [r.hash, ...s.positionHistory, 'ffffffffffffffff'];
expect(check(s, 'C1')).toEqual({ ok: false, reason: '같은 모양이 반복되어 둘 수 없어요.' });
expect(baduk.view(s, 'A').illegalPoints).toContainEqual({ ...at('C1'), reason: 'superko' });
// The korean ko rule has no superko.
const k = make({ koRule: 'korean' }, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] });
k.positionHistory = [r.hash, ...k.positionHistory];
expect(check(k, 'C1').ok).toBe(true);
});
test('korean: the same situation (arrangement + side to move) a third time is a draw', () => {
let s = play(make({ koRule: 'korean' }, KO), 'F5');
s = playAll(s, ['A9', 'J1']);
// Arrange that the situation after white's E5 has already happened twice.
const r = tryPlace(s.board, 9, idx('E5'), 2, s.hash);
if (!r.ok) throw new Error('unexpected');
s.situationCounts[r.hash + 'b'] = 2;
s = play(s, 'E5');
expect(s.phase).toBe('finished');
expect(s.result).toMatchObject({ winner: null, reason: 'repetition' });
expect(baduk.result(s)?.ranking).toEqual([['A', 'B']]);
});
test('korean: situation counts accumulate in play', () => {
let s = make({ koRule: 'korean' });
s = play(s, 'E5');
expect(Object.values(s.situationCounts).reduce((a, b) => a + b, 0)).toBe(2);
});
});
describe('passing and scoring phase (§5.4–5.5)', () => {
test('two consecutive passes enter scoring; pass-place-pass does not', () => {
let s = playAll(make(), ['pass', 'E5', 'pass']);
expect(s.phase).toBe('playing');
s = play(s, 'pass');
expect(s.phase).toBe('scoring');
expect(s.scoringMarks?.agreed).toEqual({ black: false, white: false });
expect(baduk.activePlayers(s).sort()).toEqual(['A', 'B']);
expect(baduk.deadline!(s)).toBe(1000 + 180_000);
});
test('toggling dead stones clears agreement; both agreeing finishes', () => {
let s = playAll(make({}, { black: ['E4'], white: ['E6'] }), ['pass', 'pass']);
s = act(s, 'black', { type: 'agreeScore' });
expect(s.scoringMarks!.agreed.black).toBe(true);
expect(baduk.activePlayers(s)).toEqual(['B']);
s = act(s, 'white', { type: 'toggleDead', ...at('E4') });
expect(s.scoringMarks!.dead).toContain(idx('E4'));
expect(s.scoringMarks!.agreed).toEqual({ black: false, white: false });
s = act(s, 'black', { type: 'toggleDead', ...at('E4') });
expect(s.scoringMarks!.dead).not.toContain(idx('E4'));
s = act(s, 'black', { type: 'agreeScore' });
s = act(s, 'white', { type: 'agreeScore' });
expect(s.phase).toBe('finished');
expect(s.result?.reason).toBe('score');
});
test('dispute → resume: the requester’s opponent moves first, two passes re-enter scoring', () => {
let s = playAll(make(), ['E5', 'pass', 'pass']);
expect(s.phase).toBe('scoring');
s = act(s, 'white', { type: 'resumePlay' });
expect(s.phase).toBe('playing');
expect(s.turn).toBe('black');
expect(s.scoringMarks).toBeNull();
s = act(make(), 'black', { type: 'pass' });
s = playAll(s, ['pass']);
s = act(s, 'black', { type: 'resumePlay' });
expect(s.turn).toBe('white');
s = play(s, 'pass');
expect(s.phase).toBe('playing');
s = play(s, 'pass');
expect(s.phase).toBe('scoring');
});
test('marking a group crosses empty points inside an enclosure but not into the open board', () => {
// Two dead black stones inside white's corner, plus a live black stone outside.
let s = make({}, { black: ['A1', 'B2', 'G7'], white: ['A4', 'B4', 'C4', 'D4', 'D3', 'D2', 'D1'] });
s = playAll(s, ['pass', 'pass']);
s.scoringMarks!.dead = [];
s = act(s, 'white', { type: 'toggleDead', ...at('A1') });
expect(s.scoringMarks!.dead.sort()).toEqual([idx('A1'), idx('B2')].sort());
});
});
describe('scoring (§6)', () => {
// §6.4: black E column, white F column, a dead white stone at B5; captures B 2, W 3.
function example(scoring: 'territory' | 'area') {
const E = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9'];
const F = E.map((l) => 'F' + l.slice(1));
let s = make({ scoring }, { black: E, white: [...F, 'B5'] });
s.captures = { black: 2, white: 3 };
s = playAll(s, ['pass', 'pass']);
return s;
}
test('the heuristic suggests B5 as dead', () => {
const s = example('territory');
expect(s.scoringMarks!.dead).toEqual([idx('B5')]);
expect(s.scoringMarks!.seki).toEqual([]);
});
test('territory: 39 : 36.5, "흑 2집 반 승"', () => {
let s = example('territory');
const v = baduk.view(s, null);
expect(v.scoring!.preview.black.total).toBe(39);
expect(v.scoring!.preview.white.total).toBe(36.5);
s = act(s, 'black', { type: 'agreeScore' });
s = act(s, 'white', { type: 'agreeScore' });
expect(s.result).toMatchObject({ winner: 'black', reason: 'score', summaryKo: '흑 2집 반 승' });
expect(s.result!.score).toMatchObject({ margin: 2.5, black: { territory: 36, captures: 3 }, white: { territory: 27, captures: 3, komi: 6.5 } });
expect(baduk.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal', scores: { A: 39, B: 36.5 } });
});
test('area: 45 : 43.5, "흑 1.5점 승"', () => {
let s = example('area');
expect(s.komi).toBe(7.5);
s = act(s, 'black', { type: 'agreeScore' });
s = act(s, 'white', { type: 'agreeScore' });
expect(s.result!.score!.black.total).toBe(45);
expect(s.result!.score!.white.total).toBe(43.5);
expect(s.result!.summaryKo).toBe('흑 1.5점 승');
});
test('seki region: not counted in territory, counted in area', () => {
for (const scoring of ['territory', 'area'] as const) {
let s = example(scoring);
s = act(s, 'white', { type: 'toggleSeki', ...at('H5') });
const v = baduk.view(s, null).scoring!;
expect(v.seki).toHaveLength(27);
expect(v.preview.white.territory).toBe(scoring === 'territory' ? 0 : 27);
// Toggle again → unmarked.
s = act(s, 'black', { type: 'toggleSeki', ...at('J9') });
expect(s.scoringMarks!.seki).toEqual([]);
}
});
test('integer komi can end in jigo', () => {
let s = make({ komiAuto: false, komi: 0 }, { black: ['E5'], white: ['E4'] });
s = playAll(s, ['pass', 'pass']);
s.scoringMarks!.dead = [];
s = act(s, 'black', { type: 'agreeScore' });
s = act(s, 'white', { type: 'agreeScore' });
expect(s.result).toMatchObject({ winner: null, reason: 'jigo' });
expect(baduk.result(s)?.ranking).toEqual([['A', 'B']]);
});
test('suggestMarks: eyeless stones inside a living wall are proposed dead, the wall is not', () => {
// Black wall enclosing the corner, white wall outside it, three white stones inside.
const size = 9;
const b = new Array<Cell>(size * size).fill(0);
const put = (l: string, v: Cell) => (b[idx(l)] = v);
['A3', 'B3', 'C3', 'D3', 'D2', 'D1'].forEach((l) => put(l, 1));
['A4', 'B4', 'C4', 'D4', 'E4', 'E3', 'E2', 'E1'].forEach((l) => put(l, 2));
['B1', 'B2', 'C2'].forEach((l) => put(l, 2));
const m = suggestMarks(b, size);
expect(m.dead).toEqual([idx('B1'), idx('B2'), idx('C2')].sort((x, y) => x - y));
});
});
test('suggestMarks: nothing is proposed on an open board', () => {
const b = new Array<Cell>(81).fill(0);
b[idx('C3')] = 1;
b[idx('G7')] = 1;
b[idx('E5')] = 2;
expect(suggestMarks(b, 9)).toEqual({ dead: [], seki: [] });
});
function boardOf(black: string[], white: string[]): Cell[] {
const b = new Array<Cell>(81).fill(0);
for (const l of black) b[idx(l)] = 1;
for (const l of white) b[idx(l)] = 2;
return b;
}
const col = (c: string, from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => `${c}${from + i}`);
describe('suggestMarks: seki (§5.6)', () => {
// White lives on the left (A–C, rows 3–9), black on the right (D…), corner fight below.
test('not a seki when the opponent can connect out through a shared liberty → the inner stones are dead', () => {
// Black A1 A2 share B1/B2 with white C1 C2, but B2 also touches the white wall at B3.
const black = ['A1', 'A2', 'C3', ...col('D', 1, 9)];
const white = ['C1', 'C2', 'A3', 'B3', 'B4', ...col('C', 4, 9)];
const m = suggestMarks(boardOf(black, white), 9);
expect(m.dead).toEqual([idx('A2'), idx('A1')]);
expect(m.seki).toEqual([]);
});
test('seki with one eye each: both eyes proposed as seki, scored per method', () => {
// X (black) A2 B2 B1 C2 with eye A1; Y (white) D1 D2 E2 F2 F1 with eye E1; shared liberty C1.
const black = ['A2', 'B2', 'B1', 'C2', ...col('D', 3, 9), 'E3', 'F3', 'G3', 'G2', 'G1'];
const white = ['D1', 'D2', 'E2', 'F2', 'F1', 'A3', 'B3', 'C3', ...col('C', 4, 9)];
const m = suggestMarks(boardOf(black, white), 9);
expect(m.dead).toEqual([]);
expect(m.seki).toEqual([idx('A1'), idx('E1')].sort((x, y) => x - y));
for (const scoring of ['territory', 'area'] as const) {
let s = make({ scoring }, { black, white });
s = playAll(s, ['pass', 'pass']);
expect(s.scoringMarks!.seki).toEqual(m.seki);
const p = baduk.view(s, null).scoring!.preview;
// Black territory E4–J9 + H1–J3 = 5×6 + 2×3 = 36 (+1 eye in area); white A4–B9 = 12 (+1 eye in area).
expect(p.black.territory).toBe(scoring === 'territory' ? 36 : 37);
expect(p.white.territory).toBe(scoring === 'territory' ? 12 : 13);
}
});
});
describe('handicap (§4)', () => {
const table: Record<number, Record<number, string[]>> = {
19: {
2: ['Q16', 'D4'],
3: ['Q16', 'D4', 'Q4'],
4: ['Q16', 'D4', 'Q4', 'D16'],
5: ['Q16', 'D4', 'Q4', 'D16', 'K10'],
6: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10'],
7: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K10'],
8: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K4', 'K16'],
9: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K4', 'K16', 'K10'],
},
13: {
2: ['K10', 'D4'],
3: ['K10', 'D4', 'K4'],
4: ['K10', 'D4', 'K4', 'D10'],
5: ['K10', 'D4', 'K4', 'D10', 'G7'],
6: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7'],
7: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G7'],
8: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G4', 'G10'],
9: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G4', 'G10', 'G7'],
},
9: { 2: ['G7', 'C3'], 3: ['G7', 'C3', 'G3'], 4: ['G7', 'C3', 'G3', 'C7'], 5: ['G7', 'C3', 'G3', 'C7', 'E5'] },
};
for (const [size, rows] of Object.entries(table)) {
test(`${size}줄 placements, white first, komi 0.5`, () => {
for (const [n, labels] of Object.entries(rows)) {
const sz = Number(size) as 9 | 13 | 19;
expect(handicapPoints(sz, Number(n)).sort()).toEqual(labels.map((l) => idx(l, sz)).sort());
const s = make({ boardSize: sz, handicap: Number(n) as BadukOptions['handicap'] });
expect(s.board.filter((v) => v === 1)).toHaveLength(Number(n));
expect(s.turn).toBe('white');
expect(s.komi).toBe(0.5);
}
});
}
test('area scoring adds N to white; 9줄 handicap > 5 is rejected', () => {
const s = make({ handicap: 4, scoring: 'area' });
const p = playAll(s, ['pass', 'pass']);
expect(baduk.view(p, null).scoring!.preview.white.handicapComp).toBe(4);
expect(badukOptionsSchema.safeParse({ boardSize: 9, handicap: 6 }).success).toBe(false);
expect(badukOptionsSchema.safeParse({ boardSize: 13, handicap: 9 }).success).toBe(true);
});
});
describe('clocks (§8.2, §8.5)', () => {
const byo = { kind: 'byoyomi' as const, mainSecAuto: false, mainSec: 60, periodSec: 30 as const, periods: 3, incSec: 10 };
test('byoyomi: within a period keeps it, over consumes, past the last loses, later moves rejected', () => {
let s = make({ timeControl: byo });
expect(baduk.deadline!(s)).toBe(60_000 + 90_000);
s = play(s, 'E5', 70_000); // 60 main + 10 over → still 3 periods
expect(s.clock.mainMs.black).toBe(0);
expect(s.clock.periodsLeft.black).toBe(3);
expect(s.clock.inByoyomi.black).toBe(true);
s = play(s, 'E4', 75_000); // white 5s of main
expect(s.clock.mainMs.white).toBe(55_000);
s = play(s, 'D5', 75_000 + 45_000); // 45s → one period used
expect(s.clock.periodsLeft.black).toBe(2);
s = play(s, 'D4', 121_000);
expect(baduk.deadline!(s)).toBe(121_000 + 2 * 30_000);
s = play(s, 'C5', 121_000 + 31_000); // one more used
expect(s.clock.periodsLeft.black).toBe(1);
s = play(s, 'C4', 153_000);
s = play(s, 'B5', 153_000 + 31_000); // last period exceeded
expect(s.phase).toBe('finished');
expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss', summaryKo: '백 시간승' });
expect(cell(s, 'B5')).toBe(0);
expect(baduk.validate(s, 'B', { type: 'place', ...at('A1') }).ok).toBe(false);
});
test('timeout action in play → time loss (no auto move)', () => {
const s = make({ timeControl: byo });
const p = baduk.timeoutPlayers!(s)[0]!;
expect(p).toBe('A');
const a = baduk.onTimeout(s, p);
expect(baduk.validate(s, p, a).ok).toBe(true);
expect(baduk.validate(s, 'B', a).ok).toBe(false);
const after = baduk.apply(s, p, a, { rng: rng(), now: 200_000 }).state;
expect(after.result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
expect(baduk.result(after)?.reason).toBe('timeout');
});
test('fischer: increment added, overtime loses', () => {
let s = make({ timeControl: { ...byo, kind: 'fischer', incSec: 5 } });
expect(baduk.deadline!(s)).toBe(60_000);
s = play(s, 'E5', 10_000);
expect(s.clock.mainMs.black).toBe(55_000);
s = play(s, 'E4', 80_000);
expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
});
test('none: idle limit deadline', () => {
const s = make({ timeControl: { ...byo, kind: 'none' }, idleLimitSec: 300 });
expect(baduk.deadline!(s)).toBe(300_000);
});
test('auto main time per board size', () => {
for (const [size, sec] of [[9, 180], [13, 600], [19, 1200]] as const) {
expect(make({ boardSize: size }).clock.mainMs.black).toBe(sec * 1000);
}
});
test('scoring timeout: non-responders are counted as agreeing', () => {
let s = playAll(make({}, { black: ['E5'], white: ['E4'] }), ['pass', 'pass']);
s.scoringMarks!.dead = [];
s = act(s, 'black', { type: 'agreeScore' });
expect(baduk.timeoutPlayers!(s)).toEqual(['B']);
s = act(s, 'white', baduk.onTimeout(s, 'B'));
expect(s.phase).toBe('finished');
// Both silent: two timeouts finish with current marks (empty → every stone alive).
let t = playAll(make({}, { black: ['E5'], white: ['E4'] }), ['pass', 'pass']);
t.scoringMarks!.dead = [];
for (const p of baduk.timeoutPlayers!(t)) t = baduk.apply(t, p, baduk.onTimeout(t, p), { rng: rng(), now: 999_999 }).state;
expect(t.phase).toBe('finished');
expect(t.result!.score!.black.stones).toBe(1);
expect(t.result!.score!.white.stones).toBe(1);
});
test('disconnect: the room timer fires onTimeout for the player to move → time loss', () => {
// Grace handling lives in the room (min(deadline, disconnect + grace)); the game only needs
// timeoutPlayers + onTimeout to be valid at any moment of play.
let s = make();
s = play(s, 'E5');
const p = baduk.timeoutPlayers!(s)[0]!;
expect(p).toBe('B');
s = act(s, 'white', baduk.onTimeout(s, p));
expect(s.result?.winner).toBe('black');
});
});
describe('undo, draw, resign', () => {
test('accepted undo restores captured stones, captures and superko history by replay', () => {
let c = make();
c = playAll(c, ['D5', 'E5', 'F5', 'A1', 'E6', 'A2']);
const pre = structuredClone(c);
c = play(c, 'E4'); // black captures E5
expect(c.captures.black).toBe(1);
c = act(c, 'black', { type: 'requestUndo' });
expect(c.undo.pending).toEqual({ by: 'black', plies: 1 });
c = act(c, 'white', { type: 'respondUndo', accept: true });
expect(c.board).toEqual(pre.board);
expect(c.captures).toEqual(pre.captures);
expect(c.positionHistory).toEqual(pre.positionHistory);
expect(c.hash).toBe(pre.hash);
expect(c.turn).toBe('black');
expect(c.undo.usedBy.black).toBe(1);
});
test('undo of 2 plies on own turn, decline keeps board, limit enforced, pending cleared by a move', () => {
let s = playAll(make({ undo: '1' }), ['E5', 'E4']);
s = act(s, 'black', { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: 'black', plies: 2 });
expect(baduk.activePlayers(s)).toEqual(['A', 'B']);
s = act(s, 'white', { type: 'respondUndo', accept: false });
expect(s.moves).toHaveLength(2);
s = act(s, 'black', { type: 'requestUndo' });
s = act(s, 'white', { type: 'respondUndo', accept: true });
expect(s.moves).toHaveLength(0);
expect(s.turn).toBe('black');
s = play(s, 'E5');
expect(baduk.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' });
s = play(s, 'E4');
s = act(s, 'white', { type: 'requestUndo' });
s = play(s, 'D5');
expect(s.undo.pending).toBeNull();
});
test('undo cannot cross a resume from scoring', () => {
let s = playAll(make(), ['E5', 'pass', 'pass']);
s = act(s, 'black', { type: 'resumePlay' });
expect(baduk.validate(s, 'A', { type: 'requestUndo' }).ok).toBe(false);
});
test('draw offer: re-offer gap, accept ends drawn', () => {
let s = play(make(), 'E5');
s = act(s, 'black', { type: 'offerDraw' });
s = act(s, 'white', { type: 'respondDraw', accept: false });
expect(baduk.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
s = playAll(s, ['A1', 'A3', 'A5', 'A7', 'A9', 'C1', 'C3', 'C5', 'C7', 'C9']);
expect(baduk.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true);
s = act(s, 'black', { type: 'offerDraw' });
s = act(s, 'white', { type: 'respondDraw', accept: true });
expect(baduk.result(s)).toMatchObject({ reason: 'draw-agreed', ranking: [['A', 'B']] });
});
test('resign in play and in scoring', () => {
const s = act(make(), 'white', { type: 'resign' });
expect(s.result).toMatchObject({ winner: 'black', reason: 'resign', summaryKo: '흑 불계승' });
const t = act(playAll(make(), ['pass', 'pass']), 'black', { type: 'resign' });
expect(baduk.result(t)?.ranking).toEqual([['B'], ['A']]);
});
test('wrong phase / not your turn / outsider', () => {
const s = make();
expect(baduk.validate(s, 'B', { type: 'place', x: 0, y: 0 }).ok).toBe(false);
expect(baduk.validate(s, 'Z', { type: 'resign' }).ok).toBe(false);
expect(baduk.validate(s, 'A', { type: 'agreeScore' }).ok).toBe(false);
expect(baduk.validate(s, 'A', { type: 'place', x: 9, y: 0 }).ok).toBe(false);
const sc = playAll(make(), ['pass', 'pass']);
expect(baduk.validate(sc, 'A', { type: 'place', x: 0, y: 0 }).ok).toBe(false);
expect(baduk.validate(sc, 'A', { type: 'toggleDead', x: 0, y: 0 }).ok).toBe(false);
});
});
describe('views and determinism', () => {
test('no hidden internals in any view; mover-only illegal points; no atari warning', () => {
let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white');
s = play(s, 'B2'); // black B1/A2... put black stones in atari
for (const viewer of ['A', 'B', null]) {
const json = JSON.stringify(baduk.view(s, viewer));
expect(json).not.toContain('positionHistory');
expect(json).not.toContain('situationCounts');
expect(json).not.toContain('rng');
}
const mover = baduk.view(s, 'A');
expect(mover.illegalPoints.length).toBeGreaterThan(0);
for (const viewer of ['B', null]) expect(baduk.view(s, viewer).illegalPoints).toEqual([]);
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(baduk.view(s, viewer))).not.toMatch(/atari/i);
});
test('nigiri is seeded and deterministic', () => {
const options = baduk.optionsSchema.parse({});
const colors = Array.from({ length: 16 }, (_, i) => baduk.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed(seedN(i)), now: 0 }).players.black);
expect(new Set(colors).size).toBe(2);
const again = Array.from({ length: 16 }, (_, i) => baduk.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed(seedN(i)), now: 0 }).players.black);
expect(again).toEqual(colors);
});
test('default options', () => {
expect(baduk.defaultOptions).toMatchObject({ boardSize: 19, scoring: 'territory', handicap: 0, koRule: 'positionalSuperko', undo: '3' });
expect(baduk.setup({ players: ['A', 'B'], options: baduk.defaultOptions, rng: rng(), now: 0 }).komi).toBe(6.5);
});
const variants: [string, Partial<BadukOptions>, number][] = [
['9 territory', { boardSize: 9 }, 12],
['9 area handicap', { boardSize: 9, scoring: 'area', handicap: 3 }, 6],
['9 korean', { boardSize: 9, koRule: 'korean' }, 6],
['13', { boardSize: 13 }, 3],
['19', { boardSize: 19, timeControl: { kind: 'none', mainSecAuto: true, mainSec: 0, periodSec: 30, periods: 3, incSec: 0 } }, 1],
];
for (const [name, opts, n] of variants) {
test(`random playouts finish, are deterministic and fast (${name})`, () => {
for (let i = 0; i < n; i++) {
const options = badukOptionsSchema.parse({ timeControl: { kind: 'none' }, ...opts });
const a = randomPlayout(baduk, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 4000, maxApplyMs: 30 });
const b = randomPlayout(baduk, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 4000, maxApplyMs: 30 });
expect(a.finished).toBe(true);
expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState));
// Replay the trace from scratch → same final state.
const st = a.finalState as BadukState;
expect(baduk.result(st)).not.toBeNull();
}
});
}
});