M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
588
packages/games/src/baduk/baduk.test.ts
Normal file
588
packages/games/src/baduk/baduk.test.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
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 hints', () => {
|
||||
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.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({ atariWarning: false }, { black: ['B1'], white: ['A1', 'C1'] }), 'A').atariWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user