M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과

- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수,
  무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개)
- 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴)
- e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수
- e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건
  (h1, 랜드마크, 제목 순서 정리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 05:19:05 +09:00
parent aa7f6bffef
commit c5c30d4ada
16 changed files with 1781 additions and 11 deletions

View File

@@ -30,7 +30,7 @@ export function ChatPanel({ room, me, title = true }: { room: RoomView; me: Publ
}, []); }, []);
return ( return (
<section className="chat-panel" aria-label="채팅"> <section className="chat-panel" aria-label="채팅">
{title && <h3>채팅</h3>} {title && <h2 style={{ fontSize: '1.1rem' }}>채팅</h2>}
<div ref={listRef} className="chat-list" aria-live="polite"> <div ref={listRef} className="chat-list" aria-live="polite">
{chat.length === 0 && <p className="muted small">아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.</p>} {chat.length === 0 && <p className="muted small">아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.</p>}
{chat {chat

View File

@@ -150,7 +150,7 @@ export function CodeLabel({ code }: { code: string }) {
export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) { export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) {
return ( return (
<header className="topbar"> <nav className="topbar" aria-label="페이지 메뉴">
{onBack && ( {onBack && (
<button className="icon-btn" onClick={onBack} aria-label="뒤로"> <button className="icon-btn" onClick={onBack} aria-label="뒤로">
←<span className="lbl">뒤로</span> ←<span className="lbl">뒤로</span>
@@ -158,6 +158,6 @@ export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: ()
)} )}
<div className="topbar-title">{title}</div> <div className="topbar-title">{title}</div>
{right} {right}
</header> </nav>
); );
} }

View File

@@ -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<Color, string> = { 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 (
<g opacity={ghost ? 0.5 : 1}>
<circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={ghost ? 0 : 0.25} />
<circle cx={cx} cy={cy} r={r} fill={black ? '#161616' : '#fafaf7'} stroke={black ? '#000' : '#9a9a92'} strokeWidth={0.03} />
<circle cx={cx - r * 0.28} cy={cy - r * 0.3} r={r * 0.32} fill="#fff" opacity={black ? 0.14 : 0.6} />
</g>
);
}
interface Anim {
key: number;
at: number;
from: Color;
delay: Map<number, number>;
}
function OthelloBoard({ view: v, act, pending, tapConfirm }: BoardProps<OthelloView>) {
const myTurn = v.phase === 'playing' && v.myColor === v.turn && v.legalMoves !== null;
const [hover, setHover] = useState<Pt | null>(null);
const [preview, setPreview] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: 3, y: 3 });
const [kbd, setKbd] = useState(false);
const [announce, setAnnounce] = useState('');
const [anim, setAnim] = useState<Anim | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const legal = useMemo(() => {
const m = new Map<number, OthelloLegalMove>();
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<SVGSVGElement>): 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<string, [number, number]> = { 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 (
<div className="board-wrap stack" style={{ gap: 10 }}>
<style>{CSS}</style>
<div
aria-label={`돌 수: 흑 ${v.discs.black}개, 백 ${v.discs.white}개`}
role="img"
style={{ display: 'flex', height: 30, borderRadius: 15, overflow: 'hidden', border: '2px solid var(--border)', fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}
>
<div style={{ flex: `${v.discs.black} 1 0`, minWidth: 48, background: '#161616', color: '#fff', display: 'flex', alignItems: 'center', paddingLeft: 12, transition: 'flex-grow 0.4s' }}>
● {v.discs.black}
</div>
<div
style={{ flex: `${total ? v.discs.white : 1} 1 0`, minWidth: 48, background: '#fafaf7', color: '#161616', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: 12, transition: 'flex-grow 0.4s' }}
>
{v.discs.white} ○
</div>
</div>
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${N + 2 * pad} ${N + 2 * pad}`}
style={{ width: '100%', display: 'block', background: '#123d2b', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
onPointerMove={(e) => {
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}
>
<rect x={0} y={0} width={N} height={N} fill="var(--felt)" />
{Array.from({ length: N + 1 }, (_, i) => (
<g key={i} stroke="#0b2a1d" strokeWidth={i === 0 || i === N ? 0.05 : 0.03} opacity={0.75}>
<line x1={0} y1={i} x2={N} y2={i} />
<line x1={i} y1={0} x2={i} y2={N} />
</g>
))}
{[2, 6].flatMap((x) => [2, 6].map((y) => <circle key={`s${x}${y}`} cx={x} cy={y} r={0.07} fill="#0b2a1d" opacity={0.8} />))}
{Array.from({ length: N }, (_, i) => (
<g key={`c${i}`} fill="#d7eadf" fontSize={0.3} fontWeight={700} textAnchor="middle" dominantBaseline="central">
<text x={i + 0.5} y={-0.28}>
{'abcdefgh'[i]}
</text>
<text x={-0.28} y={i + 0.5}>
{i + 1}
</text>
</g>
))}
{myTurn &&
v.showLegalMoves &&
[...legal.values()].map((l) =>
v.showFlipCount ? (
<g key={`l${l.x}-${l.y}`}>
<circle cx={l.x + 0.5} cy={l.y + 0.5} r={0.2} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
<text x={l.x + 0.5} y={l.y + 0.52} fontSize={0.26} fontWeight={800} textAnchor="middle" dominantBaseline="central" fill={v.myColor === 'black' ? '#fff' : '#111'}>
{l.flips}
</text>
</g>
) : (
<circle key={`l${l.x}-${l.y}`} cx={l.x + 0.5} cy={l.y + 0.5} r={0.14} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
),
)}
{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 (
<g key={`a${anim.key}-${i}`} className="oth-flip" style={{ animationDelay: `${delay}ms` }}>
<Disc color={color} cx={cx} cy={cy} />
<g className="oth-old" style={{ animationDelay: `${delay}ms` }}>
<Disc color={anim.from} cx={cx} cy={cy} />
</g>
</g>
);
}
if (anim && anim.at === i) {
return (
<g key={`a${anim.key}-${i}`} className="oth-drop">
<Disc color={color} cx={cx} cy={cy} />
</g>
);
}
return <Disc key={i} color={color} cx={cx} cy={cy} />;
})}
{[...flipSet].map((i) => (
<circle key={`h${i}`} cx={(i % N) + 0.5} cy={Math.floor(i / N) + 0.5} r={0.44} fill="rgba(255, 224, 102, 0.45)" stroke="#ffd84d" strokeWidth={0.05} />
))}
{v.lastMove && <circle cx={v.lastMove.x + 0.5} cy={v.lastMove.y + 0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
{showGhost && target && v.myColor && <Disc color={v.myColor} cx={target.x + 0.5} cy={target.y + 0.5} ghost />}
{myTurn && kbd && <rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.08} fill="none" stroke="var(--focus)" strokeWidth={0.07} />}
</svg>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{preview && myTurn && (
<div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
{`${label(preview)}에 두기${targetMove ? ` (${targetMove.flips}개 뒤집기)` : ''}`}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
취소
</button>
</div>
)}
</div>
);
}
/** Right-hand panel on PC (below the board on narrow screens). */
function OthelloSide({ view: v, me, players, act, pending, deadline }: BoardProps<OthelloView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(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 (
<>
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && v.myColor && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.kind === 'place' && m.color === v.myColor)}
onClick={() => act({ type: 'requestUndo' })}
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
)}
{outgoing && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">상대의 답을 기다리는 중…</span>
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
<MoveList view={v} />
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? '상대가 무르기를 요청했어요' : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">
{incoming === 'undo' ? `수락하면 상대가 마지막으로 둔 수 전으로 돌아가요(돌 ${undoPlaces}수).` : '수락하면 이 판은 무승부로 끝나요.'}
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
수락
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
거절
</button>
</div>
</Sheet>
)}
{confirm === 'resign' && (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
)}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
</>
);
}
function MoveList({ view: v }: { view: OthelloView }) {
const ref = useRef<HTMLOListElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moveCount]);
if (v.moveCount === 0) return null;
let n = 0;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moves.filter((m) => m.kind === 'place').length}수)</summary>
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{v.moves.map((m, i) => (
<li key={i} className="small">
{m.kind === 'place' ? (
<>
<span className="muted">{++n}.</span> {m.color === 'black' ? '●' : '○'} {label(m)}
{m.auto ? '*' : ''}
</>
) : (
<span className="muted">
{m.color === 'black' ? '●' : '○'} 패스
</span>
)}
</li>
))}
</ol>
</details>
);
}
function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloView; color: Color; players: Record<string, PublicUser>; 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 = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
}
const score = v.result ? v.result.displayScore[color] : v.discs[color];
return (
<div className="card row" style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
<Disc color={color} cx={0} cy={0} r={0.45} />
</svg>
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{id === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small">
{COLOR_KO[color]}
{active ? ' · 두는 중' : ''}
</div>
</div>
<span aria-label={`${COLOR_KO[color]} 돌 ${score}개`} style={{ fontWeight: 800, fontSize: '1.4rem', fontVariantNumeric: 'tabular-nums', minWidth: '2ch', textAlign: 'right' }}>
{score}
</span>
{clock}
</div>
);
}
function statusText(v: OthelloView, me: string | null, players: Record<string, PublicUser>): 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<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
const TIME_PRESETS: [string, string, Partial<OthelloOptions['timeControl']>][] = [
['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<OthelloOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<OthelloOptions>) => onChange({ ...o, ...patch });
const onOff = (v: boolean) => (v ? 'on' : 'off');
return (
<div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>시간</strong>
<Seg
value={timeKey(o.timeControl)}
options={[
...TIME_PRESETS.map(([k, l]) => [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] } });
}}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>흑(먼저 두는 쪽)</strong>
<Seg
value={o.colorAssignment}
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
onChange={(colorAssignment) => set({ colorAssignment })}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>둘 수 있는 칸</strong>
<Seg value={onOff(o.showLegalMoves)} options={[['on', '점으로 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showLegalMoves: k === 'on' })} />
</div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<div className="stack" style={{ gap: 6 }}>
<strong>뒤집히는 개수 숫자</strong>
<Seg value={onOff(o.showFlipCount)} options={[['on', '보여 주기(초보 도움)'], ['off', '안 보여 주기']]} onChange={(k) => set({ showFlipCount: k === 'on' })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>점수 계산</strong>
<Seg value={onOff(o.emptyToWinner)} options={[['off', '돌 개수 그대로'], ['on', '남은 빈칸은 이긴 쪽에(대회 방식)']]} onChange={(k) => set({ emptyToWinner: k === 'on' })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>무르기</strong>
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</div>
{o.timeControl.kind === 'perMove' && (
<div className="stack" style={{ gap: 6 }}>
<strong>시간이 다 되면</strong>
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>무승부 제안</strong>
<Seg value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
</>
)}
</div>
);
}
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 (
<figure style={{ margin: 0, textAlign: 'center' }}>
<svg viewBox={`0 0 ${cells.length} 1`} style={{ width: '100%', maxWidth: 220, display: 'block', margin: '0 auto', background: 'var(--felt)', borderRadius: 6 }} aria-hidden>
{cells.split('').map((c, i) => (
<g key={i}>
<rect x={i} y={0} width={1} height={1} fill="none" stroke="#0b2a1d" strokeWidth={0.04} />
{c !== '.' && <Disc color={c === 'B' ? 'black' : 'white'} cx={i + 0.5} cy={0.5} r={0.38} />}
</g>
))}
{mark !== undefined && <circle cx={mark + 0.5} cy={0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
</svg>
<figcaption className="small muted" style={{ marginTop: 4 }}>
{caption}
</figcaption>
</figure>
);
}
function Rules({ options }: { options?: OthelloOptions }) {
return (
<div className="stack">
<p>
흑과 백이 번갈아 8×8 판의 빈칸에 돌을 하나씩 놓아요. 흑이 먼저 둬요. 오셀로와 같은 규칙이에요. 게임이 끝났을 때 <strong>내 색 돌이 더 많으면</strong> 이겨요.
</p>
<p>
<strong>끼워서 뒤집기</strong>: 상대 돌을 내 돌 두 개 사이에 끼우면, 끼인 상대 돌이 모두 내 색으로 뒤집혀요. 가로, 세로, 대각선 어느 방향이든 돼요.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
<RowDiagram cells="BWW." caption="① 흑 돌 옆에 백 돌이 있어요" />
<RowDiagram cells="BWWB" mark={3} caption="② 끝에 흑을 놓아 백을 끼워요" />
<RowDiagram cells="BBBB" mark={3} caption="③ 끼인 백이 흑으로 뒤집혀요" />
</div>
<p>상대 돌을 하나도 뒤집을 수 없는 칸에는 둘 수 없어요. 뒤집을 수 있는 칸이 있으면 꼭 그중 한 곳에 둬야 해요.</p>
<p>
<strong>패스</strong>: 둘 곳이 하나도 없으면 자동으로 차례가 상대에게 넘어가요. 누를 것은 없어요. 둘 다 둘 곳이 없으면(판이 가득 차도) 게임이 끝나요.
</p>
{options?.emptyToWinner && <p>이 방은 대회 방식이라, 빈칸이 남은 채 끝나면 남은 빈칸 수를 이긴 쪽 점수에 더해요(승패는 그대로예요).</p>}
<p>판의 작은 점은 지금 둘 수 있는 칸이에요(설정에서 끌 수 있어요). PC에서는 마우스를 올리면 뒤집힐 돌이 노랗게 보이고, 한 번 누르면 놓여요.</p>
<p className="muted">팁: 네 귀퉁이(모서리)에 놓은 돌은 다시 뒤집히지 않아서 아주 유리해요.</p>
</div>
);
}
const ui: GameUI<OthelloView, OthelloOptions> = {
Board: OthelloBoard,
Side: OthelloSide,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -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). */ /** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = { export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
omok: () => import('./omok'), omok: () => import('./omok'),
othello: () => import('./othello'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -1,13 +1,14 @@
export const GAME_ICON: Record<string, string> = { export const GAME_ICON: Record<string, string> = {
omok: '⚫', omok: '⚫',
baduk: '⚪', baduk: '⚪',
chess: '♟️', chess: '♞',
janggi: '🀄', janggi: '將',
seotda: '🎴', seotda: '🎴',
poker: '🃏', poker: '🃏',
mahjong: '🀇', mahjong: '🀇',
yut: '🪵', yut: '🪵',
othello: '◐',
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; export const UPCOMING = ['섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -55,6 +55,7 @@ export function Home() {
return ( return (
<main className="page" style={{ gap: 28 }}> <main className="page" style={{ gap: 28 }}>
<h1 className="sr-only">{useSession.getState().siteName}</h1>
{activeRoom && ( {activeRoom && (
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}> <Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}>
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)}) ▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})

View File

@@ -144,7 +144,10 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
return ( return (
<> <>
<div className="room-bar"> <nav className="room-bar" aria-label="방 메뉴">
<h1 className="sr-only">
{g.nameKo} 방 {formatRoomCode(code)}
</h1>
<div className="room-bar-inner"> <div className="room-bar-inner">
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로"> <button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
← 홈 ← 홈
@@ -164,7 +167,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
⋯ 메뉴 ⋯ 메뉴
</button> </button>
</div> </div>
</div> </nav>
{needsRefresh && ( {needsRefresh && (
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}> <div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
<span>새 버전이 있어요.</span> <span>새 버전이 있어요.</span>
@@ -322,7 +325,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
<section className="card stack"> <section className="card stack">
<div className="row" style={{ justifyContent: 'space-between' }}> <div className="row" style={{ justifyContent: 'space-between' }}>
<div> <div>
<h3>게임 설정</h3> <h2>게임 설정</h2>
<p className="muted">{ui.optionsSummary(room.options)}</p> <p className="muted">{ui.optionsSummary(room.options)}</p>
</div> </div>
{isHost && !wide && ( {isHost && !wide && (
@@ -371,7 +374,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
{wide ? settingsCard : seats} {wide ? settingsCard : seats}
{wide && ( {wide && (
<section className="card stack"> <section className="card stack">
<h3>규칙 요약</h3> <h2>규칙 요약</h2>
<ui.Rules options={room.options} /> <ui.Rules options={room.options} />
</section> </section>
)} )}
@@ -516,7 +519,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
}; };
const status = ( const status = (
<div <div
className="card" className="card turn-status"
role="status" role="status"
aria-live="polite" aria-live="polite"
style={{ style={{

View File

@@ -6,6 +6,7 @@
"name": "boardgame-site", "name": "boardgame-site",
"devDependencies": { "devDependencies": {
"@types/bun": "1.4.2", "@types/bun": "1.4.2",
"axe-core": "4.13.0",
"playwright-core": "1.63.0", "playwright-core": "1.63.0",
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
@@ -123,6 +124,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="], "chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="],

58
e2e/a11y.e2e.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Accessibility check with axe-core (docs/10 §2): home, game page, lobby, game screen (PC + phone).
* Fails on "serious"/"critical" violations. Run: bun e2e/a11y.e2e.ts
*/
import { chromium, type Page } from 'playwright-core';
import { mkdtempSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server';
const PORT = 4520;
const ORIGIN = `http://localhost:${PORT}`;
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
const app = startServer({
config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-a11y-')), '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 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);

102
e2e/games.e2e.ts Normal file
View File

@@ -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<void>;
moves: string[][];
}
const PLANS: Record<string, Plan> = {
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);

View File

@@ -14,6 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "1.4.2", "@types/bun": "1.4.2",
"axe-core": "4.13.0",
"playwright-core": "1.63.0", "playwright-core": "1.63.0",
"typescript": "5.9.3" "typescript": "5.9.3"
} }

View File

@@ -1,6 +1,8 @@
import type { AnyGameDefinition } from '@bg/engine'; import type { AnyGameDefinition } from '@bg/engine';
import { omok } from './omok'; import { omok } from './omok';
import { othello } from './othello';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
[othello.id]: othello,
}; };

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

View File

@@ -0,0 +1,378 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { othello, type OthelloAction, type OthelloOptions, type OthelloState } from './index';
import { flipsFor, initialBoard, type Cell } from './rules';
type Color = 'black' | 'white';
const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]);
/** 'd3' → { x: 3, y: 2 } (columns a–h left→right, rows 1–8 top→bottom). */
const at = (l: string) => ({ x: 'abcdefgh'.indexOf(l[0]!), y: Number(l.slice(1)) - 1 });
const idx = (l: string) => at(l).y * 8 + at(l).x;
function make(opts: Partial<OthelloOptions> = {}): OthelloState {
const options = othello.optionsSchema.parse({ colorAssignment: 'hostBlack', ...opts });
return othello.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 });
}
/** Custom position: 8 rows of '.', 'B', 'W' (row 1 first). */
function fromRows(rows: string[], turn: Color = 'black', opts: Partial<OthelloOptions> = {}): OthelloState {
const s = make(opts);
s.board = rows.join('').split('').map((c) => (c === 'B' ? 1 : c === 'W' ? 2 : 0)) as Cell[];
expect(s.board).toHaveLength(64);
s.turn = turn;
return s;
}
function act(s: OthelloState, actor: string, a: OthelloAction, now = 1000) {
const v = othello.validate(s, actor, a);
if (!v.ok) throw new Error(v.reason);
return othello.apply(s, actor, a, { rng: rng(9), now }).state;
}
const play = (s: OthelloState, label: string, now = 1000) => act(s, s.players[s.turn], { type: 'place', ...at(label) }, now);
const tryPlace = (s: OthelloState, label: string) => othello.validate(s, s.players[s.turn], { type: 'place', ...at(label) });
const sorted = (a: { x: number; y: number }[]) => a.map((p) => 'abcdefgh'[p.x]! + (p.y + 1)).sort();
const EMPTY = '........';
describe('setup and basic moves', () => {
test('standard start: d4 W, e4 B, d5 B, e5 W; black first with d3 c4 f5 e6', () => {
const s = make();
expect(s.board[idx('d4')]).toBe(2);
expect(s.board[idx('e4')]).toBe(1);
expect(s.board[idx('d5')]).toBe(1);
expect(s.board[idx('e5')]).toBe(2);
expect(s.turn).toBe('black');
expect(sorted(othello.view(s, 'A').legalMoves!)).toEqual(['c4', 'd3', 'e6', 'f5']);
expect(othello.legalActions!(s, 'A')).toHaveLength(4);
});
test('black d3 flips d4 → 4 : 1', () => {
const s = play(make(), 'd3');
expect(s.board[idx('d4')]).toBe(1);
expect(othello.view(s, null).discs).toEqual({ black: 4, white: 1, empty: 59 });
expect(s.turn).toBe('white');
expect(s.moves[0]).toEqual({ kind: 'place', color: 'black', x: 3, y: 2, flipped: [idx('d4')], auto: false });
});
test.each([
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[1, 1],
[1, -1],
[-1, 1],
[-1, -1],
])('flips in direction (%i, %i)', (dx, dy) => {
const s = fromRows(Array(8).fill(EMPTY));
s.board[(3 + dy) * 8 + 3 + dx] = 2;
s.board[(3 + 2 * dy) * 8 + 3 + 2 * dx] = 1;
const after = act(s, 'A', { type: 'place', x: 3, y: 3 });
expect(after.board[(3 + dy) * 8 + 3 + dx]).toBe(1);
expect((after.moves[0] as { flipped: number[] }).flipped).toEqual([(3 + dy) * 8 + 3 + dx]);
});
test('several directions at once, nearest discs first', () => {
// d4 surrounded by white, black two steps out in all 8 directions (some with longer runs).
const s = fromRows([
'B..B..B.',
'.W.W.W..',
'..WWW...',
'BWW.WWB.',
'..WWW...',
'.W.W.W..',
'B..B..B.',
EMPTY,
]);
const flips = flipsFor(s.board, 3, 3, 1);
expect(flips).toHaveLength(16);
const after = play(s, 'd4');
for (const i of flips) expect(after.board[i]).toBe(1);
// Nearest ring (distance 1) comes before the outer ring (for the flip animation order).
const dist = (i: number) => Math.max(Math.abs((i % 8) - 3), Math.abs(Math.floor(i / 8) - 3));
expect(flips.map(dist)).toEqual([...flips.map(dist)].sort((a, b) => a - b));
});
test('no chain reaction: a newly flipped disc does not flip further lines', () => {
// Black plays a1: b1 flips (a1–b1–c1). b2 is now between b1 and b3 but must stay white.
const s = fromRows(['.WB.....', '.W......', '.B......', EMPTY, EMPTY, EMPTY, EMPTY, '.......W']);
const after = play(s, 'a1');
expect(after.board[idx('b1')]).toBe(1);
expect(after.board[idx('b2')]).toBe(2);
});
test('rejects no-flip squares, occupied squares, off-board and wrong turn', () => {
const s = make();
expect(tryPlace(s, 'a1')).toEqual({ ok: false, reason: '여기에 두면 뒤집을 돌이 없어요.' });
expect(tryPlace(s, 'd4')).toEqual({ ok: false, reason: '이미 돌이 있는 칸이에요.' });
expect(othello.validate(s, 'A', { type: 'place', x: 8, y: 0 })).toEqual({ ok: false, reason: '판 밖이에요.' });
expect(othello.validate(s, 'B', { type: 'place', ...at('d3') })).toEqual({ ok: false, reason: '지금은 내 차례가 아니에요.' });
expect(othello.validate(s, 'Z', { type: 'resign' }).ok).toBe(false);
expect(othello.actionSchema.safeParse({ type: 'place', x: 8, y: 0 }).success).toBe(false);
expect(othello.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
});
});
describe('passes and game end', () => {
// After black a1 white has no move but black still has h5 (flipping h6, h7).
const passRows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, '.......W', '.......W', '.......B'];
test('opponent without a legal move passes automatically and I move again', () => {
const r = othello.apply(fromRows(passRows), 'A', { type: 'place', ...at('a1') }, { rng: rng(), now: 5000 });
const s = r.state;
expect(s.turn).toBe('black');
expect(s.moves.map((m) => m.kind)).toEqual(['place', 'pass']);
expect(s.moves[1]).toEqual({ kind: 'pass', color: 'white' });
expect(r.events.map((e) => e.type)).toEqual(['placed', 'passed']);
expect(s.clock.turnStartedAt).toBe(5000);
expect(othello.view(s, 'A').legalMoves).toEqual([{ x: 7, y: 4, flips: 2, flipped: [idx('h6'), idx('h7')] }]);
});
test('neither side can move → game ends even with empty squares (one colour wiped out)', () => {
let s = play(fromRows(passRows), 'a1');
const r = othello.apply(s, 'A', { type: 'place', ...at('h5') }, { rng: rng(), now: 1 });
s = r.state;
expect(s.phase).toBe('finished');
expect(s.result).toMatchObject({ winner: 'black', reason: 'noMoves', discs: { black: 7, white: 0, empty: 57 } });
expect(s.result!.summary).toBe('흑 승 (흑 7 : 백 0)');
expect(r.events.at(-1)).toMatchObject({ type: 'gameEnded', winner: 'black' });
expect(othello.result(s)).toEqual({ ranking: [['A'], ['B']], scores: { A: 7, B: 0 }, summary: '흑 승 (흑 7 : 백 0)', reason: 'normal' });
expect(othello.deadline!(s)).toBeNull();
expect(othello.activePlayers(s)).toEqual([]);
});
// After black a1: black 3, white 3 (stuck on the bottom row), nobody can move.
const tieRows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, '.....WWW'];
test('equal discs → draw; emptyToWinner splits the empties', () => {
const s = play(fromRows(tieRows), 'a1');
expect(s.result).toMatchObject({ winner: null, discs: { black: 3, white: 3, empty: 58 }, displayScore: { black: 3, white: 3 } });
expect(othello.result(s)?.ranking).toEqual([['A', 'B']]);
const e = play(fromRows(tieRows, 'black', { emptyToWinner: true }), 'a1');
expect(e.result).toMatchObject({ winner: null, displayScore: { black: 32, white: 32 } });
expect(e.result!.summary).toBe('무승부 (흑 32 : 백 32)');
});
test('emptyToWinner adds the empties to the winner only (result unchanged)', () => {
const rows = ['.WB.....', EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, '......WW'];
const plain = play(fromRows(rows), 'a1');
const wof = play(fromRows(rows, 'black', { emptyToWinner: true }), 'a1');
expect(plain.result).toMatchObject({ winner: 'black', displayScore: { black: 3, white: 2 } });
expect(wof.result).toMatchObject({ winner: 'black', displayScore: { black: 62, white: 2 }, discs: { black: 3, white: 2, empty: 59 } });
expect(othello.result(wof)?.scores).toEqual({ A: 62, B: 2 });
});
});
describe('undo, draw, resign', () => {
test('undo own move and the reply; decline keeps the board; limit enforced', () => {
let s = play(play(make({ undo: '1' }), 'd3'), 'c3');
s = act(s, 'A', { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: 'black', plies: 2 });
expect(othello.activePlayers(s)).toEqual(['A', 'B']);
s = act(s, 'B', { type: 'respondUndo', accept: true }, 3000);
expect(s.moves).toEqual([]);
expect(s.board).toEqual(initialBoard());
expect(s.turn).toBe('black');
expect(s.clock.turnStartedAt).toBe(3000);
s = play(play(s, 'd3'), 'c3');
expect(othello.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' });
s = act(s, 'B', { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: 'white', plies: 1 });
s = act(s, 'A', { type: 'respondUndo', accept: false });
expect(s.moves).toHaveLength(2);
// A pending request disappears when the board changes.
s = act(s, 'B', { type: 'requestUndo' });
s = play(s, 'c4');
expect(s.undo.pending).toBeNull();
expect(othello.validate(make(), 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '아직 무를 수가 없어요.' });
expect(othello.validate(play(make({ undo: 'off' }), 'd3'), 'A', { type: 'requestUndo' }).ok).toBe(false);
});
test('undo across an automatic pass restores exactly the position before my last move', () => {
// Search real games for an automatic pass, then undo the move that caused it.
let found = 0;
for (let seed = 1; seed < 400 && found < 5; seed++) {
const r = SeededRng.fromSeed(seedN(seed));
let s = othello.setup({ players: ['A', 'B'], options: othello.optionsSchema.parse({ colorAssignment: 'hostBlack', undo: 'unlimited' }), rng: r, now: 0 });
let before: OthelloState | null = null;
while (s.phase === 'playing') {
const p = s.players[s.turn];
const legal = othello.legalActions!(s, p);
const prev = s;
s = othello.apply(s, p, legal[r.int(legal.length)]!, { rng: r, now: 0 }).state;
if (s.phase === 'playing' && s.moves.at(-1)?.kind === 'pass') {
before = prev;
break;
}
}
if (!before) continue;
found++;
const mover = s.turn;
const opp = mover === 'black' ? 'white' : 'black';
s = act(s, s.players[mover], { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: mover, plies: 2 });
s = act(s, s.players[opp], { type: 'respondUndo', accept: true });
expect(s.board).toEqual(before.board);
expect(s.moves).toEqual(before.moves);
expect(s.turn).toBe(mover);
}
expect(found).toBeGreaterThan(0);
});
test('draw offer: re-offer gap, accept ends drawn with disc counts', () => {
let s = play(make(), 'd3');
s = act(s, 'A', { type: 'offerDraw' });
expect(othello.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
s = act(s, 'B', { type: 'respondDraw', accept: false });
expect(othello.validate(s, 'A', { type: 'offerDraw' })).toEqual({ ok: false, reason: '무승부 제안은 3수를 더 둔 뒤에 다시 할 수 있어요.' });
for (let i = 0; i < 6; i++) s = play(s, sorted(othello.view(s, s.players[s.turn]).legalMoves!)[0]!);
expect(othello.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true);
s = act(s, 'A', { type: 'offerDraw' });
s = act(s, 'A', { type: 'cancelOffer' });
expect(s.draw.pendingBy).toBeNull();
s = act(s, 'B', { type: 'offerDraw' });
s = act(s, 'A', { type: 'respondDraw', accept: true });
expect(othello.result(s)?.reason).toBe('draw-agreed');
expect(s.result?.summary).toMatch(/^합의 무승부 \(흑 \d+ : 백 \d+\)$/);
expect(othello.validate(make({ drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false);
});
test('resign records the disc count; emptyToWinner does not apply', () => {
const s = act(play(make({ emptyToWinner: true }), 'd3'), 'A', { type: 'resign' });
expect(s.result).toMatchObject({ winner: 'white', reason: 'resign', discs: { black: 4, white: 1, empty: 59 }, displayScore: { black: 4, white: 1 } });
expect(s.result?.summary).toBe('흑 기권으로 백 승 (흑 4 : 백 1)');
expect(othello.result(s)?.ranking).toEqual([['B'], ['A']]);
expect(othello.onLeave!(s, 'A')).toEqual({ type: 'resign' });
});
});
describe('time', () => {
test('autoMove prefers a legal corner and is deterministic per seed', () => {
// a1 (via b2) and h8 (via g7) are legal corners besides the usual centre moves.
const rows = [EMPTY, '.W......', '..B.....', '...WB...', '...BW...', '.....B..', '......W.', EMPTY];
const corner = (seed: number) => {
const s = fromRows(rows);
const next = othello.apply(s, 'A', { type: 'timeout' }, { rng: rng(seed), now: 0 }).state;
const m = next.moves[0]!;
return m.kind === 'place' ? { at: 'abcdefgh'[m.x]! + (m.y + 1), auto: m.auto } : null;
};
const picks = new Set<string>();
for (let seed = 1; seed <= 20; seed++) {
const c = corner(seed);
expect(c?.auto).toBe(true);
expect(['a1', 'h8']).toContain(c!.at);
expect(corner(seed)).toEqual(c);
picks.add(c!.at);
}
expect(picks.size).toBe(2);
});
test('without a corner the auto move is any legal move; third consecutive timeout loses', () => {
let s = make();
for (let i = 0; i < 4; i++) {
const p = othello.timeoutPlayers!(s)[0]!;
const a = othello.onTimeout(s, p);
expect(othello.validate(s, p, a).ok).toBe(true);
s = othello.apply(s, p, a, { rng: rng(i), now: i }).state;
}
expect(s.moves.every((m) => m.kind === 'place' && m.auto)).toBe(true);
expect(s.clock.autoMoveStreak).toEqual({ black: 2, white: 2 });
s = othello.apply(s, 'A', { type: 'timeout' }, { rng: rng(), now: 10 }).state;
expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
expect(othello.result(s)?.reason).toBe('timeout');
});
test('a manual move resets the auto-move streak', () => {
let s = act(make(), 'A', { type: 'timeout' });
s = play(s, sorted(othello.view(s, 'B').legalMoves!)[0]!);
s = play(s, sorted(othello.view(s, 'A').legalMoves!)[0]!);
expect(s.clock.autoMoveStreak.black).toBe(0);
});
test('lose policy and idle limit lose on the first timeout', () => {
expect(act(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
const idle = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 }, idleLimitSec: 120 });
expect(othello.deadline!(idle)).toBe(120_000);
expect(act(idle, 'A', { type: 'timeout' }).result?.summary).toBe('흑 시간패로 백 승 (흑 2 : 백 2)');
expect(othello.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
});
test('perMove deadline restarts after every move', () => {
const s = play(make(), 'd3', 7000);
expect(othello.deadline!(s)).toBe(7000 + 30_000);
});
test('fischer: time charged, increment added, late move loses, timeout loses', () => {
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
let s = make({ timeControl: tc });
expect(othello.deadline!(s)).toBe(60_000);
s = play(s, 'd3', 10_000);
expect(s.clock.remainingMs.black).toBe(55_000);
expect(othello.deadline!(s)).toBe(70_000);
const late = play(s, 'c3', 80_000); // white used 70s of 60s
expect(late.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
const t = act(s, 'B', { type: 'timeout' });
expect(t.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
expect(t.clock.remainingMs.white).toBe(0);
});
test('timeout applies only to the player to move, also while a request is pending', () => {
let s = play(make(), 'd3');
s = act(s, 'A', { type: 'requestUndo' });
expect(othello.timeoutPlayers!(s)).toEqual(['B']);
expect(othello.activePlayers(s)).toEqual(['B']);
});
});
describe('views, replay, playouts', () => {
test('legalMoves only for the player to move; no RNG anywhere', () => {
const s = play(make(), 'd3');
expect(othello.view(s, 'B').legalMoves?.length).toBeGreaterThan(0);
expect(othello.view(s, 'A').legalMoves).toBeNull();
expect(othello.view(s, null).legalMoves).toBeNull();
for (const v of [othello.view(s, 'A'), othello.view(s, 'B'), othello.view(s, null)]) {
expect(JSON.stringify(v)).not.toMatch(/rng/i);
}
expect(JSON.stringify(s)).not.toMatch(/rng/i);
expect(othello.view(s, 'A').lastMove).toEqual({ x: 3, y: 2, color: 'black', flipped: [idx('d4')] });
});
test('color assignment', () => {
const opt = (colorAssignment: OthelloOptions['colorAssignment']) => othello.optionsSchema.parse({ colorAssignment });
expect(othello.setup({ players: ['H', 'G'], options: opt('hostWhite'), rng: rng(), now: 0 }).players).toEqual({ black: 'G', white: 'H' });
expect(othello.setup({ players: ['H', 'G'], options: opt('alternate'), rng: rng(), now: 0, gameNo: 2 }).players.black).toBe('G');
const blacks = new Set([1, 2, 3, 4, 5, 6, 7, 8].map((n) => othello.setup({ players: ['H', 'G'], options: opt('random'), rng: rng(n), now: 0 }).players.black));
expect(blacks.size).toBe(2);
});
test('random playouts finish, are deterministic, replay to the same state, and onTimeout is always legal', () => {
for (let i = 0; i < 60; i++) {
const options = othello.optionsSchema.parse({ emptyToWinner: i % 2 === 0 });
const invariant = (st: unknown) => {
const s = st as OthelloState;
for (const p of othello.timeoutPlayers!(s)) {
if (!othello.validate(s, p, othello.onTimeout(s, p)).ok) throw new Error('onTimeout illegal');
}
const d = othello.view(s, null).discs;
if (d.black + d.white + d.empty !== 64 || s.board.filter((c) => c !== 0).length !== 4 + s.moves.filter((m) => m.kind === 'place').length) {
throw new Error('disc count invariant broken');
}
};
const a = randomPlayout(othello, { players: ['A', 'B'], options, seed: seedN(i), invariant, maxSteps: 200 });
const b = randomPlayout(othello, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 200 });
expect(a.finished).toBe(true);
expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState));
expect(othello.result(a.finalState as OthelloState)).not.toBeNull();
// Replaying the recorded actions from the same setup gives the identical final state.
const r = SeededRng.fromSeed(seedN(i));
let s = othello.setup({ players: ['A', 'B'], options, rng: r, now: 1_700_000_000_000 });
let now = 1_700_000_000_000;
for (const { actor, action } of a.trace) {
now += 1000;
s = othello.apply(s, actor, action as OthelloAction, { rng: r, now }).state;
}
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
}
});
});

View 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];

View File

@@ -12,6 +12,10 @@ export interface CatalogEntry {
export const CATALOG: CatalogEntry[] = [ export const CATALOG: CatalogEntry[] = [
{ id: 'omok', nameKo: '오목', minPlayers: 2, maxPlayers: 2, minutes: '5~15', level: '쉬움', blurb: '먼저 다섯 개를 한 줄로!', available: true }, { 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); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);