M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과
- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수, 무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개) - 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴) - e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수 - e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건 (h1, 랜드마크, 제목 순서 정리) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
378
packages/games/src/othello/othello.test.ts
Normal file
378
packages/games/src/othello/othello.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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 and idle limit lose on the first timeout', () => {
|
||||
expect(act(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
|
||||
const idle = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 }, idleLimitSec: 120 });
|
||||
expect(othello.deadline!(idle)).toBe(120_000);
|
||||
expect(act(idle, 'A', { type: 'timeout' }).result?.summary).toBe('흑 시간패로 백 승 (흑 2 : 백 2)');
|
||||
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 });
|
||||
const b = randomPlayout(othello, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 200 });
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user