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:
@@ -1,6 +1,8 @@
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { omok } from './omok';
|
||||
import { othello } from './othello';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
[othello.id]: othello,
|
||||
};
|
||||
|
||||
508
packages/games/src/othello/index.ts
Normal file
508
packages/games/src/othello/index.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
/** 리버시(오셀로) (docs/games/othello.md). */
|
||||
import { z } from 'zod';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||
import { CORNERS, SIZE, countDiscs, flipsFor, hasLegalMove, initialBoard, legalMoves, type Cell, type Disc } from './rules';
|
||||
|
||||
export type { Cell } from './rules';
|
||||
export type Color = 'black' | 'white';
|
||||
const DISC: Record<Color, Disc> = { black: 1, white: 2 };
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
|
||||
export const othelloOptionsSchema = z.object({
|
||||
colorAssignment: z
|
||||
.enum(['random', 'hostBlack', 'hostWhite', 'alternate'])
|
||||
.default('random')
|
||||
.meta({ title: '흑백 정하기', labels: { random: '무작위', hostBlack: '방장이 흑', hostWhite: '방장이 백', alternate: '판마다 번갈아' } }),
|
||||
emptyToWinner: z.boolean().default(false).meta({ title: '끝날 때 남은 빈칸을 이긴 쪽 점수에 더하기(대회 방식)' }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z
|
||||
.enum(['none', 'perMove', 'fischer'])
|
||||
.default('perMove')
|
||||
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
|
||||
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
|
||||
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
|
||||
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
|
||||
})
|
||||
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
timeoutPolicy: z
|
||||
.enum(['autoMove', 'lose'])
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
|
||||
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 착수 허용 횟수(넘으면 시간패)' }),
|
||||
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
.meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||
drawReofferMoves: z.number().int().min(0).max(30).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||
showLegalMoves: z.boolean().default(true).meta({ title: '둘 수 있는 칸 표시' }),
|
||||
showFlipCount: z.boolean().default(false).meta({ title: '둘 수 있는 칸에 뒤집히는 개수 표시' }),
|
||||
});
|
||||
export type OthelloOptions = z.infer<typeof othelloOptionsSchema>;
|
||||
|
||||
export const othelloActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('place'), x: z.number().int().min(0).max(SIZE - 1), y: z.number().int().min(0).max(SIZE - 1) }),
|
||||
z.object({ type: z.literal('requestUndo') }),
|
||||
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('offerDraw') }),
|
||||
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('cancelOffer') }),
|
||||
z.object({ type: z.literal('resign') }),
|
||||
]);
|
||||
/** Client-sendable actions plus the system-only timeout action (not in the schema). */
|
||||
export type OthelloAction = z.infer<typeof othelloActionSchema> | { type: 'timeout' };
|
||||
|
||||
export type OthelloMove =
|
||||
| { kind: 'place'; color: Color; x: number; y: number; flipped: number[]; auto: boolean }
|
||||
| { kind: 'pass'; color: Color };
|
||||
|
||||
export type OthelloReason = 'noMoves' | 'resign' | 'timeLoss' | 'drawAgreed';
|
||||
|
||||
export interface OthelloResult {
|
||||
winner: Color | null;
|
||||
reason: OthelloReason;
|
||||
discs: { black: number; white: number; empty: number };
|
||||
/** Disc counts with `emptyToWinner` applied (winner/draw unchanged). */
|
||||
displayScore: Record<Color, number>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface OthelloState {
|
||||
options: OthelloOptions;
|
||||
board: Cell[];
|
||||
players: Record<Color, PlayerId>;
|
||||
turn: Color;
|
||||
phase: 'playing' | 'finished';
|
||||
/** Automatic passes are recorded too (undo / replay). */
|
||||
moves: OthelloMove[];
|
||||
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; autoMoveStreak: Record<Color, number> };
|
||||
/** `plies` = number of `moves` entries removed on accept (own last place + any later entries). */
|
||||
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: number } | null };
|
||||
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number | null> };
|
||||
result: OthelloResult | null;
|
||||
}
|
||||
|
||||
export interface OthelloLegalMove {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Number of discs that would flip. */
|
||||
flips: number;
|
||||
/** Indices (y*8+x) that would flip, nearest first — for the hover preview. */
|
||||
flipped: number[];
|
||||
}
|
||||
|
||||
export interface OthelloView {
|
||||
size: number;
|
||||
board: Cell[];
|
||||
players: Record<Color, PlayerId>;
|
||||
myColor: Color | null;
|
||||
turn: Color;
|
||||
phase: OthelloState['phase'];
|
||||
discs: { black: number; white: number; empty: number };
|
||||
moves: OthelloMove[];
|
||||
/** Last disc placed (passes skipped). */
|
||||
lastMove: { x: number; y: number; color: Color; flipped: number[] } | null;
|
||||
moveCount: number;
|
||||
/** Only for the player to move; null for the waiting player and spectators. */
|
||||
legalMoves: OthelloLegalMove[] | null;
|
||||
showLegalMoves: boolean;
|
||||
showFlipCount: boolean;
|
||||
emptyToWinner: boolean;
|
||||
timeControl: OthelloOptions['timeControl'];
|
||||
remainingMs: Record<Color, number>;
|
||||
turnStartedAt: number;
|
||||
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
|
||||
draw: { allowed: boolean; pendingBy: Color | null };
|
||||
result: OthelloResult | null;
|
||||
}
|
||||
|
||||
const undoLimit = (o: OthelloOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||
const colorOf = (s: OthelloState, p: PlayerId): Color | null =>
|
||||
s.players.black === p ? 'black' : s.players.white === p ? 'white' : null;
|
||||
export const coordLabel = (x: number, y: number): string => `${'abcdefgh'[x]}${y + 1}`;
|
||||
|
||||
function finish(s: OthelloState, winner: Color | null, reason: OthelloReason): void {
|
||||
s.phase = 'finished';
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
const discs = countDiscs(s.board);
|
||||
const displayScore = { black: discs.black, white: discs.white };
|
||||
if (reason === 'noMoves' && s.options.emptyToWinner && discs.empty > 0) {
|
||||
if (winner) displayScore[winner] += discs.empty;
|
||||
else {
|
||||
displayScore.black += discs.empty / 2;
|
||||
displayScore.white += discs.empty / 2;
|
||||
}
|
||||
}
|
||||
const score = `흑 ${displayScore.black} : 백 ${displayScore.white}`;
|
||||
let summary: string;
|
||||
switch (reason) {
|
||||
case 'noMoves':
|
||||
summary = winner ? `${COLOR_KO[winner]} 승 (${score})` : `무승부 (${score})`;
|
||||
break;
|
||||
case 'resign':
|
||||
summary = `${COLOR_KO[other(winner!)]} 기권으로 ${COLOR_KO[winner!]} 승 (${score})`;
|
||||
break;
|
||||
case 'timeLoss':
|
||||
summary = `${COLOR_KO[other(winner!)]} 시간패로 ${COLOR_KO[winner!]} 승 (${score})`;
|
||||
break;
|
||||
case 'drawAgreed':
|
||||
summary = `합의 무승부 (${score})`;
|
||||
break;
|
||||
}
|
||||
s.result = { winner, reason, discs, displayScore, summary };
|
||||
}
|
||||
|
||||
function finishByCount(s: OthelloState): void {
|
||||
const { black, white } = countDiscs(s.board);
|
||||
finish(s, black > white ? 'black' : white > black ? 'white' : null, 'noMoves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a disc (already validated), flip, then hand the turn over:
|
||||
* opponent can move → their turn; else I can move → opponent passes automatically; else game over.
|
||||
*/
|
||||
function placeDisc(s: OthelloState, color: Color, x: number, y: number, now: number, auto: boolean, events: GameEvent[]): void {
|
||||
const flipped = flipsFor(s.board, x, y, DISC[color]);
|
||||
if (flipped.length === 0) throw new Error(`illegal othello move ${coordLabel(x, y)}`);
|
||||
s.board[y * SIZE + x] = DISC[color];
|
||||
for (const i of flipped) s.board[i] = DISC[color];
|
||||
s.moves.push({ kind: 'place', color, x, y, flipped, auto });
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
events.push({ type: 'placed', color, x, y, flipped, auto });
|
||||
const opp = other(color);
|
||||
s.clock.turnStartedAt = now;
|
||||
if (hasLegalMove(s.board, DISC[opp])) {
|
||||
s.turn = opp;
|
||||
} else if (hasLegalMove(s.board, DISC[color])) {
|
||||
s.moves.push({ kind: 'pass', color: opp });
|
||||
events.push({ type: 'passed', color: opp });
|
||||
s.turn = color;
|
||||
} else {
|
||||
finishByCount(s);
|
||||
events.push({ type: 'gameEnded', winner: s.result!.winner, reason: 'noMoves' });
|
||||
}
|
||||
}
|
||||
|
||||
/** docs §8.4: a legal corner if any (random among them), else uniform over all legal moves. */
|
||||
function chooseAutoMove(s: OthelloState, rng: Rng): number {
|
||||
const legal = legalMoves(s.board, DISC[s.turn]).map((m) => m.i);
|
||||
const corners = legal.filter((i) => CORNERS.includes(i));
|
||||
const pool = corners.length ? corners : legal;
|
||||
return pool[rng.int(pool.length)]!;
|
||||
}
|
||||
|
||||
/** Rebuild the board from the move record (used by undo). */
|
||||
function replayBoard(moves: readonly OthelloMove[]): Cell[] {
|
||||
const b = initialBoard();
|
||||
for (const m of moves) {
|
||||
if (m.kind !== 'place') continue;
|
||||
const flipped = flipsFor(b, m.x, m.y, DISC[m.color]);
|
||||
if (flipped.length === 0) throw new Error('move record does not replay');
|
||||
b[m.y * SIZE + m.x] = DISC[m.color];
|
||||
for (const i of flipped) b[i] = DISC[m.color];
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function pendingResponder(s: OthelloState): Color | null {
|
||||
if (s.undo.pending) return other(s.undo.pending.by);
|
||||
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
||||
return null;
|
||||
}
|
||||
|
||||
function lastPlaceIndex(s: OthelloState, color: Color): number {
|
||||
for (let i = s.moves.length - 1; i >= 0; i--) {
|
||||
const m = s.moves[i]!;
|
||||
if (m.kind === 'place' && m.color === color) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function timeLoss(s: OthelloState, loser: Color, events: GameEvent[]): void {
|
||||
if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0;
|
||||
finish(s, other(loser), 'timeLoss');
|
||||
events.push({ type: 'timeLoss', color: loser });
|
||||
events.push({ type: 'gameEnded', winner: other(loser), reason: 'timeLoss' });
|
||||
}
|
||||
|
||||
export const othello: GameDefinition<OthelloState, OthelloAction, OthelloView, OthelloOptions> = {
|
||||
id: 'othello',
|
||||
nameKo: '오셀로',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: othelloOptionsSchema.parse({}),
|
||||
optionsSchema: othelloOptionsSchema,
|
||||
actionSchema: othelloActionSchema as z.ZodType<OthelloAction>,
|
||||
|
||||
setup({ players, options, rng, now, gameNo }) {
|
||||
const [host, guest] = players as [PlayerId, PlayerId];
|
||||
let hostBlack: boolean;
|
||||
switch (options.colorAssignment) {
|
||||
case 'hostBlack':
|
||||
hostBlack = true;
|
||||
break;
|
||||
case 'hostWhite':
|
||||
hostBlack = false;
|
||||
break;
|
||||
case 'alternate':
|
||||
hostBlack = (gameNo ?? 1) % 2 === 1;
|
||||
break;
|
||||
default:
|
||||
hostBlack = rng.int(2) === 0;
|
||||
}
|
||||
const base = options.timeControl.baseSec * 1000;
|
||||
return {
|
||||
options,
|
||||
board: initialBoard(),
|
||||
players: hostBlack ? { black: host, white: guest } : { black: guest, white: host },
|
||||
turn: 'black',
|
||||
phase: 'playing',
|
||||
moves: [],
|
||||
clock: { remainingMs: { black: base, white: base }, turnStartedAt: now, autoMoveStreak: { black: 0, white: 0 } },
|
||||
undo: { usedBy: { black: 0, white: 0 }, pending: null },
|
||||
draw: { pendingBy: null, lastOfferPly: { black: null, white: null } },
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
const me = colorOf(s, actor);
|
||||
if (!me) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
|
||||
switch (a.type) {
|
||||
case 'place': {
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
if (a.x < 0 || a.y < 0 || a.x >= SIZE || a.y >= SIZE) return fail('판 밖이에요.');
|
||||
if (s.board[a.y * SIZE + a.x] !== 0) return fail('이미 돌이 있는 칸이에요.');
|
||||
if (flipsFor(s.board, a.x, a.y, DISC[me]).length === 0) return fail('여기에 두면 뒤집을 돌이 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'requestUndo': {
|
||||
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
|
||||
if (lastPlaceIndex(s, me) < 0) return fail('아직 무를 수가 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'respondUndo':
|
||||
if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.');
|
||||
return ok;
|
||||
case 'offerDraw': {
|
||||
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
const last = s.draw.lastOfferPly[me];
|
||||
const gap = s.options.drawReofferMoves;
|
||||
if (last !== null && s.moves.slice(last).filter((m) => m.kind === 'place' && m.color === me).length < gap) {
|
||||
return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case 'respondDraw':
|
||||
if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.');
|
||||
return ok;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.');
|
||||
return ok;
|
||||
case 'resign':
|
||||
return ok;
|
||||
case 'timeout':
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = structuredClone(prev);
|
||||
const me = colorOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
case 'place': {
|
||||
if (s.options.timeControl.kind === 'fischer') {
|
||||
s.clock.remainingMs[me] -= now - s.clock.turnStartedAt;
|
||||
if (s.clock.remainingMs[me] <= 0) {
|
||||
timeLoss(s, me, events);
|
||||
break;
|
||||
}
|
||||
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
|
||||
}
|
||||
s.clock.autoMoveStreak[me] = 0;
|
||||
placeDisc(s, me, a.x, a.y, now, false, events);
|
||||
break;
|
||||
}
|
||||
case 'timeout': {
|
||||
const tc = s.options.timeControl.kind;
|
||||
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[me] < s.options.autoMoveLimit) {
|
||||
s.clock.autoMoveStreak[me]++;
|
||||
const i = chooseAutoMove(s, rng);
|
||||
events.push({ type: 'timeoutAutoMove', color: me });
|
||||
placeDisc(s, me, i % SIZE, Math.floor(i / SIZE), now, true, events);
|
||||
} else {
|
||||
timeLoss(s, me, events);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'requestUndo': {
|
||||
s.undo.pending = { by: me, plies: s.moves.length - lastPlaceIndex(s, me) };
|
||||
events.push({ type: 'undoRequested', color: me });
|
||||
break;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const { by, plies } = s.undo.pending!;
|
||||
s.undo.pending = null;
|
||||
if (a.accept) {
|
||||
// Charge the time already used by whoever was thinking, then keep clocks as they are.
|
||||
if (s.options.timeControl.kind === 'fischer') {
|
||||
s.clock.remainingMs[s.turn] = Math.max(0, s.clock.remainingMs[s.turn] - (now - s.clock.turnStartedAt));
|
||||
}
|
||||
s.moves = s.moves.slice(0, s.moves.length - plies);
|
||||
s.board = replayBoard(s.moves);
|
||||
s.undo.usedBy[by]++;
|
||||
s.turn = by;
|
||||
s.clock.turnStartedAt = now;
|
||||
for (const c of ['black', 'white'] as const) {
|
||||
const last = s.draw.lastOfferPly[c];
|
||||
if (last !== null) s.draw.lastOfferPly[c] = Math.min(last, s.moves.length);
|
||||
}
|
||||
events.push({ type: 'undoAccepted', color: by, plies });
|
||||
} else {
|
||||
events.push({ type: 'undoDeclined', color: by });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'offerDraw':
|
||||
s.draw.pendingBy = me;
|
||||
s.draw.lastOfferPly[me] = s.moves.length;
|
||||
events.push({ type: 'drawOffered', color: me });
|
||||
break;
|
||||
case 'respondDraw':
|
||||
s.draw.pendingBy = null;
|
||||
if (a.accept) {
|
||||
finish(s, null, 'drawAgreed');
|
||||
events.push({ type: 'drawAccepted' });
|
||||
events.push({ type: 'gameEnded', winner: null, reason: 'drawAgreed' });
|
||||
} else events.push({ type: 'drawDeclined' });
|
||||
break;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by === me) s.undo.pending = null;
|
||||
if (s.draw.pendingBy === me) s.draw.pendingBy = null;
|
||||
events.push({ type: 'offerCancelled', color: me });
|
||||
break;
|
||||
case 'resign':
|
||||
finish(s, other(me), 'resign');
|
||||
events.push({ type: 'resigned', color: me });
|
||||
events.push({ type: 'gameEnded', winner: other(me), reason: 'resign' });
|
||||
break;
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const myColor = viewer ? colorOf(s, viewer) : null;
|
||||
const limit = undoLimit(s.options);
|
||||
let lastMove: OthelloView['lastMove'] = null;
|
||||
for (let i = s.moves.length - 1; i >= 0; i--) {
|
||||
const m = s.moves[i]!;
|
||||
if (m.kind === 'place') {
|
||||
lastMove = { x: m.x, y: m.y, color: m.color, flipped: m.flipped };
|
||||
break;
|
||||
}
|
||||
}
|
||||
const mine = s.phase === 'playing' && myColor !== null && myColor === s.turn;
|
||||
return {
|
||||
size: SIZE,
|
||||
board: s.board,
|
||||
players: s.players,
|
||||
myColor,
|
||||
turn: s.turn,
|
||||
phase: s.phase,
|
||||
discs: countDiscs(s.board),
|
||||
moves: s.moves,
|
||||
lastMove,
|
||||
moveCount: s.moves.length,
|
||||
legalMoves: mine
|
||||
? legalMoves(s.board, DISC[myColor]).map((m) => ({ x: m.i % SIZE, y: Math.floor(m.i / SIZE), flips: m.flipped.length, flipped: m.flipped }))
|
||||
: null,
|
||||
showLegalMoves: s.options.showLegalMoves,
|
||||
showFlipCount: s.options.showFlipCount,
|
||||
emptyToWinner: s.options.emptyToWinner,
|
||||
timeControl: s.options.timeControl,
|
||||
remainingMs: s.clock.remainingMs,
|
||||
turnStartedAt: s.clock.turnStartedAt,
|
||||
undo: {
|
||||
allowed: s.options.undo !== 'off',
|
||||
remaining: {
|
||||
black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null,
|
||||
white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null,
|
||||
},
|
||||
pending: s.undo.pending,
|
||||
},
|
||||
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
||||
result: s.result,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.phase !== 'playing') return [];
|
||||
const out = [s.players[s.turn]];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder && responder !== s.turn) out.push(s.players[responder]);
|
||||
return out;
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase !== 'playing') return null;
|
||||
const tc = s.options.timeControl;
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
return s.phase === 'playing' ? [s.players[s.turn]] : [];
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave() {
|
||||
return { type: 'resign' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.result) return null;
|
||||
const { winner, reason, summary, displayScore } = s.result;
|
||||
const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.black, s.players.white]];
|
||||
const map: Record<OthelloReason, GameResult['reason']> = {
|
||||
noMoves: 'normal',
|
||||
resign: 'resign',
|
||||
timeLoss: 'timeout',
|
||||
drawAgreed: 'draw-agreed',
|
||||
};
|
||||
return {
|
||||
ranking,
|
||||
scores: { [s.players.black]: displayScore.black, [s.players.white]: displayScore.white },
|
||||
summary,
|
||||
reason: map[reason],
|
||||
};
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
const me = colorOf(s, p);
|
||||
if (!me || s.phase !== 'playing') return [];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder === me) {
|
||||
return s.undo.pending ? [{ type: 'respondUndo', accept: false }] : [{ type: 'respondDraw', accept: false }];
|
||||
}
|
||||
if (s.turn !== me) return [];
|
||||
return legalMoves(s.board, DISC[me]).map((m) => ({ type: 'place' as const, x: m.i % SIZE, y: Math.floor(m.i / SIZE) }));
|
||||
},
|
||||
};
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
90
packages/games/src/othello/rules.ts
Normal file
90
packages/games/src/othello/rules.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Reversi rule core (docs/games/othello.md §4–§5). Board is a flat 64-cell array indexed y*8+x;
|
||||
* 0 empty, 1 black, 2 white. Pure helpers, no state of their own.
|
||||
*/
|
||||
export type Cell = 0 | 1 | 2;
|
||||
export type Disc = 1 | 2;
|
||||
|
||||
export const SIZE = 8;
|
||||
export const CELLS = SIZE * SIZE;
|
||||
|
||||
const DIRS: readonly [number, number][] = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
];
|
||||
|
||||
export const opponentDisc = (d: Disc): Disc => (d === 1 ? 2 : 1);
|
||||
const on = (x: number, y: number) => x >= 0 && y >= 0 && x < SIZE && y < SIZE;
|
||||
|
||||
/** Standard diagonal start: d4 white, e4 black, d5 black, e5 white. */
|
||||
export function initialBoard(): Cell[] {
|
||||
const b = new Array<Cell>(CELLS).fill(0);
|
||||
b[3 * SIZE + 3] = 2;
|
||||
b[3 * SIZE + 4] = 1;
|
||||
b[4 * SIZE + 3] = 1;
|
||||
b[4 * SIZE + 4] = 2;
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discs flipped if `disc` is placed at (x, y), nearest first per direction then by distance overall.
|
||||
* Empty array = not a legal move. Only straight lines from the placed disc count (no chain reaction).
|
||||
*/
|
||||
export function flipsFor(board: readonly Cell[], x: number, y: number, disc: Disc): number[] {
|
||||
if (!on(x, y) || board[y * SIZE + x] !== 0) return [];
|
||||
const opp = opponentDisc(disc);
|
||||
const out: number[] = [];
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const run: number[] = [];
|
||||
let cx = x + dx;
|
||||
let cy = y + dy;
|
||||
while (on(cx, cy) && board[cy * SIZE + cx] === opp) {
|
||||
run.push(cy * SIZE + cx);
|
||||
cx += dx;
|
||||
cy += dy;
|
||||
}
|
||||
if (run.length > 0 && on(cx, cy) && board[cy * SIZE + cx] === disc) out.push(...run);
|
||||
}
|
||||
const dist = (i: number) => Math.max(Math.abs((i % SIZE) - x), Math.abs(Math.floor(i / SIZE) - y));
|
||||
return out.sort((a, b) => dist(a) - dist(b) || a - b);
|
||||
}
|
||||
|
||||
export interface LegalMove {
|
||||
i: number;
|
||||
flipped: number[];
|
||||
}
|
||||
|
||||
export function legalMoves(board: readonly Cell[], disc: Disc): LegalMove[] {
|
||||
const out: LegalMove[] = [];
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
if (board[i] !== 0) continue;
|
||||
const flipped = flipsFor(board, i % SIZE, Math.floor(i / SIZE), disc);
|
||||
if (flipped.length) out.push({ i, flipped });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hasLegalMove(board: readonly Cell[], disc: Disc): boolean {
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
if (board[i] === 0 && flipsFor(board, i % SIZE, Math.floor(i / SIZE), disc).length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function countDiscs(board: readonly Cell[]): { black: number; white: number; empty: number } {
|
||||
let black = 0;
|
||||
let white = 0;
|
||||
for (const c of board) {
|
||||
if (c === 1) black++;
|
||||
else if (c === 2) white++;
|
||||
}
|
||||
return { black, white, empty: CELLS - black - white };
|
||||
}
|
||||
|
||||
export const CORNERS: readonly number[] = [0, SIZE - 1, CELLS - SIZE, CELLS - 1];
|
||||
@@ -12,6 +12,10 @@ export interface CatalogEntry {
|
||||
|
||||
export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'omok', nameKo: '오목', minPlayers: 2, maxPlayers: 2, minutes: '5~15', level: '쉬움', blurb: '먼저 다섯 개를 한 줄로!', available: true },
|
||||
{ id: 'othello', nameKo: '오셀로', minPlayers: 2, maxPlayers: 2, minutes: '10~15', level: '쉬움', blurb: '사이에 낀 돌을 뒤집어 내 색으로!', available: true },
|
||||
{ id: 'chess', nameKo: '체스', minPlayers: 2, maxPlayers: 2, minutes: '10~30', level: '보통', blurb: '상대 킹을 체크메이트하면 승리', available: true },
|
||||
{ id: 'janggi', nameKo: '장기', minPlayers: 2, maxPlayers: 2, minutes: '20~40', level: '보통', blurb: '초와 한의 한판 승부, 장군!', available: true },
|
||||
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
|
||||
];
|
||||
|
||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||
|
||||
Reference in New Issue
Block a user