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 = {}, 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(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(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(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> = { 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 hints', () => { let s = make({ atariWarning: true }, { 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.atariWarnings.length).toBeGreaterThan(0); expect(mover.illegalPoints.length).toBeGreaterThan(0); for (const viewer of ['B', null]) { const v = baduk.view(s, viewer); expect(v.illegalPoints).toEqual([]); expect(v.atariWarnings).toEqual([]); } expect(baduk.view(make({}, { black: ['B1'], white: ['A1', 'C1'] }), 'A').atariWarnings).toEqual([]); // off by default }); 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, 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(); } }); } });