import { describe, expect, test } from 'bun:test'; import { SeededRng, randomPlayout, seedN } from '@bg/engine'; import { othello, type OthelloAction, type OthelloOptions, type OthelloState } from './index'; import { flipsFor, initialBoard, type Cell } from './rules'; type Color = 'black' | 'white'; const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]); /** 'd3' → { x: 3, y: 2 } (columns a–h left→right, rows 1–8 top→bottom). */ const at = (l: string) => ({ x: 'abcdefgh'.indexOf(l[0]!), y: Number(l.slice(1)) - 1 }); const idx = (l: string) => at(l).y * 8 + at(l).x; function make(opts: Partial = {}): OthelloState { const options = othello.optionsSchema.parse({ colorAssignment: 'hostBlack', ...opts }); return othello.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 }); } /** Custom position: 8 rows of '.', 'B', 'W' (row 1 first). */ function fromRows(rows: string[], turn: Color = 'black', opts: Partial = {}): OthelloState { const s = make(opts); s.board = rows.join('').split('').map((c) => (c === 'B' ? 1 : c === 'W' ? 2 : 0)) as Cell[]; expect(s.board).toHaveLength(64); s.turn = turn; return s; } function act(s: OthelloState, actor: string, a: OthelloAction, now = 1000) { const v = othello.validate(s, actor, a); if (!v.ok) throw new Error(v.reason); return othello.apply(s, actor, a, { rng: rng(9), now }).state; } const play = (s: OthelloState, label: string, now = 1000) => act(s, s.players[s.turn], { type: 'place', ...at(label) }, now); const tryPlace = (s: OthelloState, label: string) => othello.validate(s, s.players[s.turn], { type: 'place', ...at(label) }); const sorted = (a: { x: number; y: number }[]) => a.map((p) => 'abcdefgh'[p.x]! + (p.y + 1)).sort(); const EMPTY = '........'; describe('setup and basic moves', () => { test('standard start: d4 W, e4 B, d5 B, e5 W; black first with d3 c4 f5 e6', () => { const s = make(); expect(s.board[idx('d4')]).toBe(2); expect(s.board[idx('e4')]).toBe(1); expect(s.board[idx('d5')]).toBe(1); expect(s.board[idx('e5')]).toBe(2); expect(s.turn).toBe('black'); expect(sorted(othello.view(s, 'A').legalMoves!)).toEqual(['c4', 'd3', 'e6', 'f5']); expect(othello.legalActions!(s, 'A')).toHaveLength(4); }); test('black d3 flips d4 → 4 : 1', () => { const s = play(make(), 'd3'); expect(s.board[idx('d4')]).toBe(1); expect(othello.view(s, null).discs).toEqual({ black: 4, white: 1, empty: 59 }); expect(s.turn).toBe('white'); expect(s.moves[0]).toEqual({ kind: 'place', color: 'black', x: 3, y: 2, flipped: [idx('d4')], auto: false }); }); test.each([ [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1], ])('flips in direction (%i, %i)', (dx, dy) => { const s = fromRows(Array(8).fill(EMPTY)); s.board[(3 + dy) * 8 + 3 + dx] = 2; s.board[(3 + 2 * dy) * 8 + 3 + 2 * dx] = 1; const after = act(s, 'A', { type: 'place', x: 3, y: 3 }); expect(after.board[(3 + dy) * 8 + 3 + dx]).toBe(1); expect((after.moves[0] as { flipped: number[] }).flipped).toEqual([(3 + dy) * 8 + 3 + dx]); }); test('several directions at once, nearest discs first', () => { // d4 surrounded by white, black two steps out in all 8 directions (some with longer runs). const s = fromRows([ 'B..B..B.', '.W.W.W..', '..WWW...', 'BWW.WWB.', '..WWW...', '.W.W.W..', 'B..B..B.', EMPTY, ]); const flips = flipsFor(s.board, 3, 3, 1); expect(flips).toHaveLength(16); const after = play(s, 'd4'); for (const i of flips) expect(after.board[i]).toBe(1); // Nearest ring (distance 1) comes before the outer ring (for the flip animation order). const dist = (i: number) => Math.max(Math.abs((i % 8) - 3), Math.abs(Math.floor(i / 8) - 3)); expect(flips.map(dist)).toEqual([...flips.map(dist)].sort((a, b) => a - b)); }); test('no chain reaction: a newly flipped disc does not flip further lines', () => { // Black plays a1: b1 flips (a1–b1–c1). b2 is now between b1 and b3 but must stay white. const s = fromRows(['.WB.....', '.W......', '.B......', EMPTY, EMPTY, EMPTY, EMPTY, '.......W']); const after = play(s, 'a1'); expect(after.board[idx('b1')]).toBe(1); expect(after.board[idx('b2')]).toBe(2); }); test('rejects no-flip squares, occupied squares, off-board and wrong turn', () => { const s = make(); expect(tryPlace(s, 'a1')).toEqual({ ok: false, reason: '여기에 두면 뒤집을 돌이 없어요.' }); expect(tryPlace(s, 'd4')).toEqual({ ok: false, reason: '이미 돌이 있는 칸이에요.' }); expect(othello.validate(s, 'A', { type: 'place', x: 8, y: 0 })).toEqual({ ok: false, reason: '판 밖이에요.' }); expect(othello.validate(s, 'B', { type: 'place', ...at('d3') })).toEqual({ ok: false, reason: '지금은 내 차례가 아니에요.' }); expect(othello.validate(s, 'Z', { type: 'resign' }).ok).toBe(false); expect(othello.actionSchema.safeParse({ type: 'place', x: 8, y: 0 }).success).toBe(false); expect(othello.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); }); }); describe('passes and game end', () => { // After black a1 white has no move but black still has h5 (flipping h6, h7). const passRows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, '.......W', '.......W', '.......B']; test('opponent without a legal move passes automatically and I move again', () => { const r = othello.apply(fromRows(passRows), 'A', { type: 'place', ...at('a1') }, { rng: rng(), now: 5000 }); const s = r.state; expect(s.turn).toBe('black'); expect(s.moves.map((m) => m.kind)).toEqual(['place', 'pass']); expect(s.moves[1]).toEqual({ kind: 'pass', color: 'white' }); expect(r.events.map((e) => e.type)).toEqual(['placed', 'passed']); expect(s.clock.turnStartedAt).toBe(5000); expect(othello.view(s, 'A').legalMoves).toEqual([{ x: 7, y: 4, flips: 2, flipped: [idx('h6'), idx('h7')] }]); }); test('neither side can move → game ends even with empty squares (one colour wiped out)', () => { let s = play(fromRows(passRows), 'a1'); const r = othello.apply(s, 'A', { type: 'place', ...at('h5') }, { rng: rng(), now: 1 }); s = r.state; expect(s.phase).toBe('finished'); expect(s.result).toMatchObject({ winner: 'black', reason: 'noMoves', discs: { black: 7, white: 0, empty: 57 } }); expect(s.result!.summary).toBe('흑 승 (흑 7 : 백 0)'); expect(r.events.at(-1)).toMatchObject({ type: 'gameEnded', winner: 'black' }); expect(othello.result(s)).toEqual({ ranking: [['A'], ['B']], scores: { A: 7, B: 0 }, summary: '흑 승 (흑 7 : 백 0)', reason: 'normal' }); expect(othello.deadline!(s)).toBeNull(); expect(othello.activePlayers(s)).toEqual([]); }); // After black a1: black 3, white 3 (stuck on the bottom row), nobody can move. const tieRows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, '.....WWW']; test('equal discs → draw; emptyToWinner splits the empties', () => { const s = play(fromRows(tieRows), 'a1'); expect(s.result).toMatchObject({ winner: null, discs: { black: 3, white: 3, empty: 58 }, displayScore: { black: 3, white: 3 } }); expect(othello.result(s)?.ranking).toEqual([['A', 'B']]); const e = play(fromRows(tieRows, 'black', { emptyToWinner: true }), 'a1'); expect(e.result).toMatchObject({ winner: null, displayScore: { black: 32, white: 32 } }); expect(e.result!.summary).toBe('무승부 (흑 32 : 백 32)'); }); test('emptyToWinner adds the empties to the winner only (result unchanged)', () => { const rows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, '......WW']; const plain = play(fromRows(rows), 'a1'); const wof = play(fromRows(rows, 'black', { emptyToWinner: true }), 'a1'); expect(plain.result).toMatchObject({ winner: 'black', displayScore: { black: 3, white: 2 } }); expect(wof.result).toMatchObject({ winner: 'black', displayScore: { black: 62, white: 2 }, discs: { black: 3, white: 2, empty: 59 } }); expect(othello.result(wof)?.scores).toEqual({ A: 62, B: 2 }); }); }); describe('undo, draw, resign', () => { test('undo own move and the reply; decline keeps the board; limit enforced', () => { let s = play(play(make({ undo: '1' }), 'd3'), 'c3'); s = act(s, 'A', { type: 'requestUndo' }); expect(s.undo.pending).toEqual({ by: 'black', plies: 2 }); expect(othello.activePlayers(s)).toEqual(['A', 'B']); s = act(s, 'B', { type: 'respondUndo', accept: true }, 3000); expect(s.moves).toEqual([]); expect(s.board).toEqual(initialBoard()); expect(s.turn).toBe('black'); expect(s.clock.turnStartedAt).toBe(3000); s = play(play(s, 'd3'), 'c3'); expect(othello.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' }); s = act(s, 'B', { type: 'requestUndo' }); expect(s.undo.pending).toEqual({ by: 'white', plies: 1 }); s = act(s, 'A', { type: 'respondUndo', accept: false }); expect(s.moves).toHaveLength(2); // A pending request disappears when the board changes. s = act(s, 'B', { type: 'requestUndo' }); s = play(s, 'c4'); expect(s.undo.pending).toBeNull(); expect(othello.validate(make(), 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '아직 무를 수가 없어요.' }); expect(othello.validate(play(make({ undo: 'off' }), 'd3'), 'A', { type: 'requestUndo' }).ok).toBe(false); }); test('undo across an automatic pass restores exactly the position before my last move', () => { // Search real games for an automatic pass, then undo the move that caused it. let found = 0; for (let seed = 1; seed < 400 && found < 5; seed++) { const r = SeededRng.fromSeed(seedN(seed)); let s = othello.setup({ players: ['A', 'B'], options: othello.optionsSchema.parse({ colorAssignment: 'hostBlack', undo: 'unlimited' }), rng: r, now: 0 }); let before: OthelloState | null = null; while (s.phase === 'playing') { const p = s.players[s.turn]; const legal = othello.legalActions!(s, p); const prev = s; s = othello.apply(s, p, legal[r.int(legal.length)]!, { rng: r, now: 0 }).state; if (s.phase === 'playing' && s.moves.at(-1)?.kind === 'pass') { before = prev; break; } } if (!before) continue; found++; const mover = s.turn; const opp = mover === 'black' ? 'white' : 'black'; s = act(s, s.players[mover], { type: 'requestUndo' }); expect(s.undo.pending).toEqual({ by: mover, plies: 2 }); s = act(s, s.players[opp], { type: 'respondUndo', accept: true }); expect(s.board).toEqual(before.board); expect(s.moves).toEqual(before.moves); expect(s.turn).toBe(mover); } expect(found).toBeGreaterThan(0); }); test('draw offer: re-offer gap, accept ends drawn with disc counts', () => { let s = play(make(), 'd3'); s = act(s, 'A', { type: 'offerDraw' }); expect(othello.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false); s = act(s, 'B', { type: 'respondDraw', accept: false }); expect(othello.validate(s, 'A', { type: 'offerDraw' })).toEqual({ ok: false, reason: '무승부 제안은 3수를 더 둔 뒤에 다시 할 수 있어요.' }); for (let i = 0; i < 6; i++) s = play(s, sorted(othello.view(s, s.players[s.turn]).legalMoves!)[0]!); expect(othello.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true); s = act(s, 'A', { type: 'offerDraw' }); s = act(s, 'A', { type: 'cancelOffer' }); expect(s.draw.pendingBy).toBeNull(); s = act(s, 'B', { type: 'offerDraw' }); s = act(s, 'A', { type: 'respondDraw', accept: true }); expect(othello.result(s)?.reason).toBe('draw-agreed'); expect(s.result?.summary).toMatch(/^합의 무승부 \(흑 \d+ : 백 \d+\)$/); expect(othello.validate(make({ drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false); }); test('resign records the disc count; emptyToWinner does not apply', () => { const s = act(play(make({ emptyToWinner: true }), 'd3'), 'A', { type: 'resign' }); expect(s.result).toMatchObject({ winner: 'white', reason: 'resign', discs: { black: 4, white: 1, empty: 59 }, displayScore: { black: 4, white: 1 } }); expect(s.result?.summary).toBe('흑 기권으로 백 승 (흑 4 : 백 1)'); expect(othello.result(s)?.ranking).toEqual([['B'], ['A']]); expect(othello.onLeave!(s, 'A')).toEqual({ type: 'resign' }); }); }); describe('time', () => { test('autoMove prefers a legal corner and is deterministic per seed', () => { // a1 (via b2) and h8 (via g7) are legal corners besides the usual centre moves. const rows = [EMPTY, '.W......', '..B.....', '...WB...', '...BW...', '.....B..', '......W.', EMPTY]; const corner = (seed: number) => { const s = fromRows(rows); const next = othello.apply(s, 'A', { type: 'timeout' }, { rng: rng(seed), now: 0 }).state; const m = next.moves[0]!; return m.kind === 'place' ? { at: 'abcdefgh'[m.x]! + (m.y + 1), auto: m.auto } : null; }; const picks = new Set(); for (let seed = 1; seed <= 20; seed++) { const c = corner(seed); expect(c?.auto).toBe(true); expect(['a1', 'h8']).toContain(c!.at); expect(corner(seed)).toEqual(c); picks.add(c!.at); } expect(picks.size).toBe(2); }); test('without a corner the auto move is any legal move; third consecutive timeout loses', () => { let s = make(); for (let i = 0; i < 4; i++) { const p = othello.timeoutPlayers!(s)[0]!; const a = othello.onTimeout(s, p); expect(othello.validate(s, p, a).ok).toBe(true); s = othello.apply(s, p, a, { rng: rng(i), now: i }).state; } expect(s.moves.every((m) => m.kind === 'place' && m.auto)).toBe(true); expect(s.clock.autoMoveStreak).toEqual({ black: 2, white: 2 }); s = othello.apply(s, 'A', { type: 'timeout' }, { rng: rng(), now: 10 }).state; expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' }); expect(othello.result(s)?.reason).toBe('timeout'); }); test('a manual move resets the auto-move streak', () => { let s = act(make(), 'A', { type: 'timeout' }); s = play(s, sorted(othello.view(s, 'B').legalMoves!)[0]!); s = play(s, sorted(othello.view(s, 'A').legalMoves!)[0]!); expect(s.clock.autoMoveStreak.black).toBe(0); }); test('lose policy loses on the first timeout; no time control has no deadline', () => { expect(act(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).result).toMatchObject({ winner: 'white', reason: 'timeLoss' }); const none = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }); expect(othello.deadline!(none)).toBeNull(); expect(othello.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false); }); test('perMove deadline restarts after every move', () => { const s = play(make(), 'd3', 7000); expect(othello.deadline!(s)).toBe(7000 + 30_000); }); test('fischer: time charged, increment added, late move loses, timeout loses', () => { const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 }; let s = make({ timeControl: tc }); expect(othello.deadline!(s)).toBe(60_000); s = play(s, 'd3', 10_000); expect(s.clock.remainingMs.black).toBe(55_000); expect(othello.deadline!(s)).toBe(70_000); const late = play(s, 'c3', 80_000); // white used 70s of 60s expect(late.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); const t = act(s, 'B', { type: 'timeout' }); expect(t.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); expect(t.clock.remainingMs.white).toBe(0); }); test('timeout applies only to the player to move, also while a request is pending', () => { let s = play(make(), 'd3'); s = act(s, 'A', { type: 'requestUndo' }); expect(othello.timeoutPlayers!(s)).toEqual(['B']); expect(othello.activePlayers(s)).toEqual(['B']); }); }); describe('views, replay, playouts', () => { test('legalMoves only for the player to move; no RNG anywhere', () => { const s = play(make(), 'd3'); expect(othello.view(s, 'B').legalMoves?.length).toBeGreaterThan(0); expect(othello.view(s, 'A').legalMoves).toBeNull(); expect(othello.view(s, null).legalMoves).toBeNull(); for (const v of [othello.view(s, 'A'), othello.view(s, 'B'), othello.view(s, null)]) { expect(JSON.stringify(v)).not.toMatch(/rng/i); } expect(JSON.stringify(s)).not.toMatch(/rng/i); expect(othello.view(s, 'A').lastMove).toEqual({ x: 3, y: 2, color: 'black', flipped: [idx('d4')] }); }); test('color assignment', () => { const opt = (colorAssignment: OthelloOptions['colorAssignment']) => othello.optionsSchema.parse({ colorAssignment }); expect(othello.setup({ players: ['H', 'G'], options: opt('hostWhite'), rng: rng(), now: 0 }).players).toEqual({ black: 'G', white: 'H' }); expect(othello.setup({ players: ['H', 'G'], options: opt('alternate'), rng: rng(), now: 0, gameNo: 2 }).players.black).toBe('G'); const blacks = new Set([1, 2, 3, 4, 5, 6, 7, 8].map((n) => othello.setup({ players: ['H', 'G'], options: opt('random'), rng: rng(n), now: 0 }).players.black)); expect(blacks.size).toBe(2); }); test('random playouts finish, are deterministic, replay to the same state, and onTimeout is always legal', () => { for (let i = 0; i < 60; i++) { const options = othello.optionsSchema.parse({ emptyToWinner: i % 2 === 0 }); const invariant = (st: unknown) => { const s = st as OthelloState; for (const p of othello.timeoutPlayers!(s)) { if (!othello.validate(s, p, othello.onTimeout(s, p)).ok) throw new Error('onTimeout illegal'); } const d = othello.view(s, null).discs; if (d.black + d.white + d.empty !== 64 || s.board.filter((c) => c !== 0).length !== 4 + s.moves.filter((m) => m.kind === 'place').length) { throw new Error('disc count invariant broken'); } }; const a = randomPlayout(othello, { players: ['A', 'B'], options, seed: seedN(i), invariant, maxSteps: 200, maxApplyMs: 30 }); const b = randomPlayout(othello, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 200, maxApplyMs: 30 }); expect(a.finished).toBe(true); expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState)); expect(othello.result(a.finalState as OthelloState)).not.toBeNull(); // Replaying the recorded actions from the same setup gives the identical final state. const r = SeededRng.fromSeed(seedN(i)); let s = othello.setup({ players: ['A', 'B'], options, rng: r, now: 1_700_000_000_000 }); let now = 1_700_000_000_000; for (const { actor, action } of a.trace) { now += 1000; s = othello.apply(s, actor, action as OthelloAction, { rng: r, now }).state; } expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState)); } }); });