From c5c30d4adac7b44a310bfae240a684c4a80c9a56 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sun, 4 Oct 2026 05:19:05 +0900 Subject: [PATCH] =?UTF-8?q?M2:=20=EC=98=A4=EC=85=80=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EA=B2=8C=EC=9E=84=20=EA=B3=B5=ED=86=B5=20E2E,=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B2=80=EC=82=AC(axe)=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수, 무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개) - 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴) - e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수 - e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건 (h1, 랜드마크, 제목 순서 정리) Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatPanel.tsx | 2 +- apps/web/src/components/ui.tsx | 4 +- apps/web/src/games/othello/index.tsx | 618 +++++++++++++++++++++ apps/web/src/games/registry.ts | 1 + apps/web/src/lib/catalog-ui.ts | 7 +- apps/web/src/pages/Home.tsx | 1 + apps/web/src/pages/RoomPage.tsx | 13 +- bun.lock | 3 + e2e/a11y.e2e.ts | 58 ++ e2e/games.e2e.ts | 102 ++++ package.json | 1 + packages/games/src/index.ts | 2 + packages/games/src/othello/index.ts | 508 +++++++++++++++++ packages/games/src/othello/othello.test.ts | 378 +++++++++++++ packages/games/src/othello/rules.ts | 90 +++ packages/shared/src/catalog.ts | 4 + 16 files changed, 1781 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/games/othello/index.tsx create mode 100644 e2e/a11y.e2e.ts create mode 100644 e2e/games.e2e.ts create mode 100644 packages/games/src/othello/index.ts create mode 100644 packages/games/src/othello/othello.test.ts create mode 100644 packages/games/src/othello/rules.ts diff --git a/apps/web/src/components/ChatPanel.tsx b/apps/web/src/components/ChatPanel.tsx index 2e4438c..616d932 100644 --- a/apps/web/src/components/ChatPanel.tsx +++ b/apps/web/src/components/ChatPanel.tsx @@ -30,7 +30,7 @@ export function ChatPanel({ room, me, title = true }: { room: RoomView; me: Publ }, []); return (
- {title &&

채팅

} + {title &&

채팅

}
{chat.length === 0 &&

아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.

} {chat diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index d38fe0d..345eb32 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -150,7 +150,7 @@ export function CodeLabel({ code }: { code: string }) { export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) { return ( -
+
+ ); } diff --git a/apps/web/src/games/othello/index.tsx b/apps/web/src/games/othello/index.tsx new file mode 100644 index 0000000..a6937d7 --- /dev/null +++ b/apps/web/src/games/othello/index.tsx @@ -0,0 +1,618 @@ +/** 오셀로 화면 (docs/games/othello.md §9). */ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { OthelloLegalMove, OthelloOptions, OthelloView } from '@bg/games/othello'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; +import { TurnTimer, useServerNow } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import { toast } from '../../store/toast'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +type Color = 'black' | 'white'; +interface Pt { + x: number; + y: number; +} +const N = 8; +const COLOR_KO: Record = { black: '흑', white: '백' }; +const other = (c: Color): Color => (c === 'black' ? 'white' : 'black'); +const label = (p: Pt) => `${'abcdefgh'[p.x]}${p.y + 1}`; +const FLIP_MS = 240; + +const CSS = ` +@keyframes oth-flip { 0% { transform: scaleX(1); } 50% { transform: scaleX(0.05); } 100% { transform: scaleX(1); } } +@keyframes oth-old { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } } +@keyframes oth-drop { 0% { transform: scale(0.4); opacity: 0.4; } 100% { transform: scale(1); opacity: 1; } } +.oth-flip { transform-box: fill-box; transform-origin: center; animation: oth-flip ${FLIP_MS}ms ease-in-out both; } +.oth-old { animation: oth-old ${FLIP_MS}ms linear both; } +.oth-drop { transform-box: fill-box; transform-origin: center; animation: oth-drop 160ms ease-out both; } +@media (prefers-reduced-motion: reduce) { + .oth-flip, .oth-drop { animation: none; } + .oth-old { display: none; } +}`; + +function Disc({ color, cx, cy, r = 0.4, ghost }: { color: Color; cx: number; cy: number; r?: number; ghost?: boolean }) { + const black = color === 'black'; + return ( + + + + + + ); +} + +interface Anim { + key: number; + at: number; + from: Color; + delay: Map; +} + +function OthelloBoard({ view: v, act, pending, tapConfirm }: BoardProps) { + const myTurn = v.phase === 'playing' && v.myColor === v.turn && v.legalMoves !== null; + const [hover, setHover] = useState(null); + const [preview, setPreview] = useState(null); + const [cursor, setCursor] = useState({ x: 3, y: 3 }); + const [kbd, setKbd] = useState(false); + const [announce, setAnnounce] = useState(''); + const [anim, setAnim] = useState(null); + const svgRef = useRef(null); + const prevCount = useRef(v.moveCount); + + const legal = useMemo(() => { + const m = new Map(); + for (const l of v.legalMoves ?? []) m.set(l.y * N + l.x, l); + return m; + }, [v.legalMoves]); + + // Flip animation, sounds, pass toasts and screen-reader text for new moves (before paint, so no flash). + useLayoutEffect(() => { + const prev = prevCount.current; + prevCount.current = v.moveCount; + setPreview(null); + if (v.moveCount <= prev) { + setAnim(null); + return; + } + const added = v.moves.slice(prev); + const texts: string[] = []; + for (const m of added) { + if (m.kind === 'place') texts.push(`${COLOR_KO[m.color]}이 ${label(m)}에 두어 ${m.flipped.length}개를 뒤집었어요.`); + else { + const msg = + m.color === v.myColor + ? '둘 곳이 없어서 차례가 상대에게 넘어갔어요' + : v.myColor + ? '상대가 둘 곳이 없어서 내 차례가 한 번 더 와요' + : `${COLOR_KO[m.color]}이 둘 곳이 없어서 차례가 넘어갔어요`; + texts.push(msg + '.'); + toast(msg); + } + } + setAnnounce(texts.join(' ')); + const lm = v.lastMove; + if (lm && added.some((m) => m.kind === 'place')) { + sounds.stone(); + const dist = (i: number) => Math.max(Math.abs((i % N) - lm.x), Math.abs(Math.floor(i / N) - lm.y)); + const maxD = Math.max(1, ...lm.flipped.map(dist)); + const step = maxD > 1 ? Math.min(60, (400 - FLIP_MS) / (maxD - 1)) : 0; + setAnim({ key: v.moveCount, at: lm.y * N + lm.x, from: other(lm.color), delay: new Map(lm.flipped.map((i) => [i, (dist(i) - 1) * step])) }); + } else setAnim(null); + if (v.phase === 'playing' && v.myColor === v.turn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + }, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps + + const canPlace = (p: Pt) => myTurn && !pending && legal.has(p.y * N + p.x); + + const place = (p: Pt) => { + act({ type: 'place', x: p.x, y: p.y }); + setPreview(null); + setHover(null); + }; + + const refuse = (p: Pt) => { + const msg = v.board[p.y * N + p.x] !== 0 ? '이미 돌이 있는 칸이에요.' : '여기에 두면 뒤집을 돌이 없어요.'; + haptic([30, 30, 30]); + setAnnounce(msg); + toast(msg); + }; + + const tap = (p: Pt, mouse: boolean) => { + setCursor(p); + if (!myTurn) return; + if (!legal.has(p.y * N + p.x)) { + refuse(p); + return; + } + // Mouse: one click (hover already previews). Touch: optional two-step confirm. + if (mouse || !tapConfirm) { + if (canPlace(p)) place(p); + return; + } + if (preview && preview.x === p.x && preview.y === p.y) place(p); + else setPreview(p); + }; + + const pointToCell = (e: React.PointerEvent): Pt | null => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse()); + const x = Math.floor(loc.x); + const y = Math.floor(loc.y); + return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null; + }; + + const onKey = (e: React.KeyboardEvent) => { + setKbd(true); + const d: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + if (d[e.key]) { + e.preventDefault(); + const next = { x: Math.min(N - 1, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(N - 1, Math.max(0, cursor.y + d[e.key]![1])) }; + const l = legal.get(next.y * N + next.x); + const cell = v.board[next.y * N + next.x]; + setCursor(next); + setAnnounce(`${label(next)} ${cell === 1 ? '흑' : cell === 2 ? '백' : l && v.showLegalMoves ? `둘 수 있음, ${l.flips}개 뒤집힘` : '빈칸'}`); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (!myTurn) return; + if (canPlace(cursor)) place(cursor); + else refuse(cursor); + } + }; + + const target = preview ?? (hover && myTurn ? hover : null); + const targetMove = target ? legal.get(target.y * N + target.x) : undefined; + const showFlips = !!targetMove && (v.showLegalMoves || !!preview); + const flipSet = new Set(showFlips ? targetMove!.flipped : []); + const showGhost = !!target && v.myColor && v.board[target.y * N + target.x] === 0 && (!!targetMove || !v.showLegalMoves); + const total = v.discs.black + v.discs.white; + const pad = 0.55; + + return ( +
+ +
+
+ ● {v.discs.black} +
+
+ {v.discs.white} ○ +
+
+ { + if (e.pointerType !== 'mouse' || !myTurn) return; + const p = pointToCell(e); + if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p); + }} + onPointerLeave={() => setHover(null)} + onPointerUp={(e) => { + setKbd(false); + const p = pointToCell(e); + if (p) tap(p, e.pointerType === 'mouse'); + }} + tabIndex={0} + role="grid" + aria-label={`오셀로 판 8×8. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(cursor)}`} + onKeyDown={onKey} + > + + {Array.from({ length: N + 1 }, (_, i) => ( + + + + + ))} + {[2, 6].flatMap((x) => [2, 6].map((y) => ))} + {Array.from({ length: N }, (_, i) => ( + + + {'abcdefgh'[i]} + + + {i + 1} + + + ))} + {myTurn && + v.showLegalMoves && + [...legal.values()].map((l) => + v.showFlipCount ? ( + + + + {l.flips} + + + ) : ( + + ), + )} + {v.board.map((c, i) => { + if (c === 0) return null; + const color: Color = c === 1 ? 'black' : 'white'; + const cx = (i % N) + 0.5; + const cy = Math.floor(i / N) + 0.5; + const delay = anim?.delay.get(i); + if (anim && delay !== undefined) { + return ( + + + + + + + ); + } + if (anim && anim.at === i) { + return ( + + + + ); + } + return ; + })} + {[...flipSet].map((i) => ( + + ))} + {v.lastMove && } + {showGhost && target && v.myColor && } + {myTurn && kbd && } + +

+ {announce} +

+ {preview && myTurn && ( +
+ + +
+ )} +
+ ); +} + +/** Right-hand panel on PC (below the board on narrow screens). */ +function OthelloSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(null); + const incoming = + v.phase === 'playing' && v.myColor + ? v.undo.pending && v.undo.pending.by !== v.myColor + ? 'undo' + : v.draw.pendingBy && v.draw.pendingBy !== v.myColor + ? 'draw' + : null + : null; + const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor); + const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null; + const top: Color = v.myColor ? other(v.myColor) : 'black'; + const bottom: Color = v.myColor ?? 'white'; + const undoPlaces = v.undo.pending ? v.moves.slice(v.moves.length - v.undo.pending.plies).filter((m) => m.kind === 'place').length : 0; + return ( + <> + + + {v.phase === 'playing' && v.myColor && ( +
+ {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+ )} + {outgoing && ( +
+ 상대의 답을 기다리는 중… + +
+ )} + + {incoming && ( + {}} label="상대의 요청"> +

{incoming === 'undo' ? '상대가 무르기를 요청했어요' : '상대가 무승부를 제안했어요'}

+

+ {incoming === 'undo' ? `수락하면 상대가 마지막으로 둔 수 전으로 돌아가요(돌 ${undoPlaces}수).` : '수락하면 이 판은 무승부로 끝나요.'} +

+
+ + +
+
+ )} + {confirm === 'resign' && ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + )} + {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + + ); +} + +function MoveList({ view: v }: { view: OthelloView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moveCount]); + if (v.moveCount === 0) return null; + let n = 0; + return ( +
+ 기보 ({v.moves.filter((m) => m.kind === 'place').length}수) +
    + {v.moves.map((m, i) => ( +
  1. + {m.kind === 'place' ? ( + <> + {++n}. {m.color === 'black' ? '●' : '○'} {label(m)} + {m.auto ? '*' : ''} + + ) : ( + + {m.color === 'black' ? '●' : '○'} 패스 + + )} +
  2. + ))} +
+
+ ); +} + +function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloView; color: Color; players: Record; deadline: number | null; me: string | null }) { + const id = v.players[color]; + const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; + const active = v.phase === 'playing' && v.turn === color; + const now = useServerNow(500); + const tc = v.timeControl; + let clock: React.ReactNode = null; + if (tc.kind === 'fischer') { + const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0)); + const s = Math.ceil(ms / 1000); + clock = ( + + {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} + + ); + } else if (active) { + const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000; + clock = ; + } + const score = v.result ? v.result.displayScore[color] : v.discs[color]; + return ( +
+ + + + +
+
+ {u.nickname} + {id === me && (나)} +
+
+ {COLOR_KO[color]} + {active ? ' · 두는 중' : ''} +
+
+ + {score} + + {clock} +
+ ); +} + +function statusText(v: OthelloView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요'; + if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요'; + if (v.myColor === v.turn) { + return v.showLegalMoves ? `내 차례예요 (${COLOR_KO[v.turn]}). 점이 있는 칸에 둘 수 있어요` : `내 차례예요 (${COLOR_KO[v.turn]}). 상대 돌을 끼울 수 있는 칸에 둬요`; + } + const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; + return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +const TIME_PRESETS: [string, string, Partial][] = [ + ['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }], + ['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }], + ['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }], + ['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }], + ['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }], + ['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }], + ['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }], + ['none', '시간 제한 없음', { kind: 'none' }], +]; + +function timeKey(tc: OthelloOptions['timeControl']): string { + if (tc.kind === 'none') return 'none'; + if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`; + return `f${Math.round(tc.baseSec / 60)}`; +} + +function timeLabel(tc: OthelloOptions['timeControl']): string { + if (tc.kind === 'none') return '시간 제한 없음'; + if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`; + const m = Math.floor(tc.baseSec / 60); + const sec = tc.baseSec % 60; + return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`; +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const onOff = (v: boolean) => (v ? 'on' : 'off'); + return ( +
+
+ 시간 + [k, l] as [string, string]), + ...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]), + ]} + onChange={(k) => { + const preset = TIME_PRESETS.find((p) => p[0] === k); + if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } }); + }} + /> +
+
+ 흑(먼저 두는 쪽) + set({ colorAssignment })} + /> +
+
+ 둘 수 있는 칸 + set({ showLegalMoves: k === 'on' })} /> +
+ + {more && ( + <> +
+ 뒤집히는 개수 숫자 + set({ showFlipCount: k === 'on' })} /> +
+
+ 점수 계산 + set({ emptyToWinner: k === 'on' })} /> +
+
+ 무르기 + set({ undo })} /> +
+ {o.timeControl.kind === 'perMove' && ( +
+ 시간이 다 되면 + set({ timeoutPolicy })} /> +
+ )} +
+ 무승부 제안 + set({ drawOffer: k === 'on' })} /> +
+ + )} +
+ ); +} + +function optionsSummary(o: OthelloOptions): string { + return ['8×8', timeLabel(o.timeControl), o.showLegalMoves ? '둘 곳 표시' : '둘 곳 숨김', ...(o.emptyToWinner ? ['대회 점수'] : [])].join(' · '); +} + +/** One-row diagram for the rules: cells are 'B' | 'W' | '.' with optional highlighted cell. */ +function RowDiagram({ cells, mark, caption }: { cells: string; mark?: number; caption: string }) { + return ( +
+ + {cells.split('').map((c, i) => ( + + + {c !== '.' && } + + ))} + {mark !== undefined && } + +
+ {caption} +
+
+ ); +} + +function Rules({ options }: { options?: OthelloOptions }) { + return ( +
+

+ 흑과 백이 번갈아 8×8 판의 빈칸에 돌을 하나씩 놓아요. 흑이 먼저 둬요. 오셀로와 같은 규칙이에요. 게임이 끝났을 때 내 색 돌이 더 많으면 이겨요. +

+

+ 끼워서 뒤집기: 상대 돌을 내 돌 두 개 사이에 끼우면, 끼인 상대 돌이 모두 내 색으로 뒤집혀요. 가로, 세로, 대각선 어느 방향이든 돼요. +

+
+ + + +
+

상대 돌을 하나도 뒤집을 수 없는 칸에는 둘 수 없어요. 뒤집을 수 있는 칸이 있으면 꼭 그중 한 곳에 둬야 해요.

+

+ 패스: 둘 곳이 하나도 없으면 자동으로 차례가 상대에게 넘어가요. 누를 것은 없어요. 둘 다 둘 곳이 없으면(판이 가득 차도) 게임이 끝나요. +

+ {options?.emptyToWinner &&

이 방은 대회 방식이라, 빈칸이 남은 채 끝나면 남은 빈칸 수를 이긴 쪽 점수에 더해요(승패는 그대로예요).

} +

판의 작은 점은 지금 둘 수 있는 칸이에요(설정에서 끌 수 있어요). PC에서는 마우스를 올리면 뒤집힐 돌이 노랗게 보이고, 한 번 누르면 놓여요.

+

팁: 네 귀퉁이(모서리)에 놓은 돌은 다시 뒤집히지 않아서 아주 유리해요.

+
+ ); +} + +const ui: GameUI = { + Board: OthelloBoard, + Side: OthelloSide, + statusText, + isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 0b663cb..c353f92 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -3,6 +3,7 @@ import type { GameUI } from './types'; /** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */ export const GAME_UI: Record Promise<{ default: GameUI }>> = { omok: () => import('./omok'), + othello: () => import('./othello'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 8f1f62b..71648ac 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -1,13 +1,14 @@ export const GAME_ICON: Record = { omok: '⚫', baduk: '⚪', - chess: '♟️', - janggi: '🀄', + chess: '♞', + janggi: '將', seotda: '🎴', poker: '🃏', mahjong: '🀇', yut: '🪵', + othello: '◐', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index b86d9b5..ef67034 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -55,6 +55,7 @@ export function Home() { return (
+

{useSession.getState().siteName}

{activeRoom && ( ▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)}) diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 78bfb38..15e580d 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -144,7 +144,10 @@ function Room({ code, me }: { code: string; me: PublicUser }) { return ( <> -
+
+ {needsRefresh && (
새 버전이 있어요. @@ -322,7 +325,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
-

게임 설정

+

게임 설정

{ui.optionsSummary(room.options)}

{isHost && !wide && ( @@ -371,7 +374,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {wide ? settingsCard : seats} {wide && (
-

규칙 요약

+

규칙 요약

)} @@ -516,7 +519,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui }; const status = (
{}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let bad = 0; + +async function audit(page: Page, name: string) { + await page.addScriptTag({ content: AXE }); + const res = await page.evaluate(async () => (window as any).axe.run(document, { resultTypes: ['violations'] })); + const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical'); + const minor = res.violations.length - serious.length; + if (serious.length) { + bad += serious.length; + console.log(`✗ ${name}: ${serious.map((v: any) => `${v.id}(${v.nodes.length}) ${v.nodes[0]?.target?.join(' ')}`).join('; ')}`); + } else console.log(`✓ ${name}${minor ? ` (경미: ${res.violations.map((v: any) => v.id).join(', ')})` : ''}`); +} + +for (const [label, ctxOpts] of [ + ['PC', { viewport: { width: 1440, height: 900 } }], + ['phone', { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }], +] as const) { + for (const theme of ['light', 'dark'] as const) { + const ctx = await browser.newContext({ ...ctxOpts, locale: 'ko-KR', colorScheme: theme }); + const p = await ctx.newPage(); + await p.goto(ORIGIN + '/'); + await p.getByText('게임 고르기').waitFor(); + await audit(p, `${label}/${theme} 홈`); + await p.goto(ORIGIN + '/g/omok'); + await p.getByRole('button', { name: /친구랑 하기/ }).waitFor(); + await audit(p, `${label}/${theme} 게임 소개`); + await p.getByRole('button', { name: /친구랑 하기/ }).click(); + await p.getByLabel('내 이름(닉네임)').fill(`검사${label}${theme}`.slice(0, 12)); + await p.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await p.waitForURL(/\/r\/\d{6}$/); + await p.getByText('친구에게 이 코드를 알려 주세요').waitFor(); + await audit(p, `${label}/${theme} 대기실`); + await ctx.close(); + } +} +await browser.close(); +await app.stop(); +console.log(bad ? `A11Y FAILED (${bad})` : 'A11Y PASSED'); +process.exit(bad ? 1 : 0); diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts new file mode 100644 index 0000000..cb4ba95 --- /dev/null +++ b/e2e/games.e2e.ts @@ -0,0 +1,102 @@ +/** + * M2 smoke E2E for every 2-player game (docs/10 §2): PC host creates the room from the game page, + * a phone friend joins by link, host starts, both see the board, a few legal moves are played via + * the keyboard (role="grid" boards support arrows + Enter) and reach the opponent. + * Run: bun e2e/games.e2e.ts [gameId...] (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4519); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); + +/** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */ +interface Plan { + /** Steps run before normal moves (e.g. janggi formation choice). */ + setup?: (host: Page, guest: Page) => Promise; + moves: string[][]; +} + +const PLANS: Record = { + othello: { + // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left + moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], + }, +}; + +const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; + +async function keys(page: Page, seq: string[]) { + await page.locator('[role="grid"]').first().focus(); + for (const k of seq) await page.keyboard.press(k); +} + +for (const id of games) { + const plan = PLANS[id]; + if (!plan) { + console.log(`✗ ${id}: no plan`); + failed = true; + continue; + } + const errors: string[] = []; + const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage(); + const guest = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage(); + for (const p of [host, guest]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); + try { + await host.goto(`${ORIGIN}/g/${id}`); + await host.getByRole('button', { name: /친구랑 하기/ }).click(); + await host.getByLabel('내 이름(닉네임)').fill('방장'); + await host.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await host.waitForURL(/\/r\/\d{6}$/); + const code = host.url().slice(-6); + await guest.goto(`${ORIGIN}/r/${code}`); + await guest.getByLabel('내 이름(닉네임)').fill('친구'); + await guest.getByRole('button', { name: '이 이름으로 입장하기' }).click(); + await host.getByLabel('자리').getByText('친구').waitFor(); + await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) }); + await host.getByRole('button', { name: '▶ 게임 시작' }).click(); + await host.getByRole('button', { name: '시작하기' }).click(); + await host.locator('[role="grid"]').first().waitFor(); + await guest.locator('[role="grid"]').first().waitFor(); + if (plan.setup) await plan.setup(host, guest); + const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim(); + // Sides may be random: whoever shows "내 차례예요" plays the next sequence. + const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); + for (const seq of plan.moves) { + let mover: Page | null = null; + for (let t = 0; t < 100 && !mover; t++) { + if (await myTurn(host)) mover = host; + else if (await myTurn(guest)) mover = guest; + else await host.waitForTimeout(100); + } + if (!mover) throw new Error('nobody has the turn'); + await keys(mover, seq); + await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 }); + } + await host.screenshot({ path: join(SHOTS, `${id}-1-game-pc.png`) }); + await guest.screenshot({ path: join(SHOTS, `${id}-2-game-phone.png`) }); + if (errors.length) throw new Error(errors.join(' | ')); + console.log(`✓ ${id} (host: ${await status(host)})`); + } catch (e) { + failed = true; + console.log(`✗ ${id}: ${(e as Error).message.split('\n')[0]}`); + await host.screenshot({ path: join(SHOTS, `${id}-fail-host.png`) }).catch(() => {}); + await guest.screenshot({ path: join(SHOTS, `${id}-fail-guest.png`) }).catch(() => {}); + } +} +await browser.close(); +await app.stop(); +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0); diff --git a/package.json b/package.json index 7ac4101..649eb60 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@types/bun": "1.4.2", + "axe-core": "4.13.0", "playwright-core": "1.63.0", "typescript": "5.9.3" } diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 0488b38..da90d25 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -1,6 +1,8 @@ import type { AnyGameDefinition } from '@bg/engine'; import { omok } from './omok'; +import { othello } from './othello'; export const GAMES: Record = { [omok.id]: omok, + [othello.id]: othello, }; diff --git a/packages/games/src/othello/index.ts b/packages/games/src/othello/index.ts new file mode 100644 index 0000000..f6c010e --- /dev/null +++ b/packages/games/src/othello/index.ts @@ -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 = { black: 1, white: 2 }; +const other = (c: Color): Color => (c === 'black' ? 'white' : 'black'); +const COLOR_KO: Record = { 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; + +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 | { 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; + summary: string; +} + +export interface OthelloState { + options: OthelloOptions; + board: Cell[]; + players: Record; + turn: Color; + phase: 'playing' | 'finished'; + /** Automatic passes are recorded too (undo / replay). */ + moves: OthelloMove[]; + clock: { remainingMs: Record; turnStartedAt: number; autoMoveStreak: Record }; + /** `plies` = number of `moves` entries removed on accept (own last place + any later entries). */ + undo: { usedBy: Record; pending: { by: Color; plies: number } | null }; + draw: { pendingBy: Color | null; lastOfferPly: Record }; + 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; + 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; + turnStartedAt: number; + undo: { allowed: boolean; remaining: Record; 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 = { + id: 'othello', + nameKo: '오셀로', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: othelloOptionsSchema.parse({}), + optionsSchema: othelloOptionsSchema, + actionSchema: othelloActionSchema as z.ZodType, + + 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 = { + 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) })); + }, +}; diff --git a/packages/games/src/othello/othello.test.ts b/packages/games/src/othello/othello.test.ts new file mode 100644 index 0000000..1b430fe --- /dev/null +++ b/packages/games/src/othello/othello.test.ts @@ -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 = {}): 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 = {}): 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(); + 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)); + } + }); +}); diff --git a/packages/games/src/othello/rules.ts b/packages/games/src/othello/rules.ts new file mode 100644 index 0000000..6b6921d --- /dev/null +++ b/packages/games/src/othello/rules.ts @@ -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(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]; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index a9cb842..3bc233e 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -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);