Files
joke-app/packages/games/src/othello/othello.test.ts
EJClaw 19c163f4d2 시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:42:46 +09:00

378 lines
19 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 { 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<OthelloOptions> = {}): 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<OthelloOptions> = {}): 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<string>();
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));
}
});
});