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:
EJClaw
2026-10-04 05:19:05 +09:00
parent aa7f6bffef
commit c5c30d4ada
16 changed files with 1781 additions and 11 deletions

View 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));
}
});
});