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