Files
joke-app/apps/web/src/games/omok/index.tsx
EJClaw 242ab3ad71 M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS),
  어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가
- 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값,
  옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성
- 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격,
  한 수 제한 초)을 옵션으로 꺼냄
- 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값
- 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용),
  관리자 작업 기록(전/후 값)
- 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부
- 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개,
  e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:52:38 +09:00

446 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 오목 화면 (docs/games/omok.md §9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { OmokOptions, OmokView, Pt } from '@bg/games/omok';
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 type { BoardProps, GameUI, OptionsProps } from '../types';
type Color = 'black' | 'white';
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
const COLS = 'ABCDEFGHIJKLMNOPQRS';
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
const RULE_KO: Record<OmokOptions['ruleSet'], string> = { korean: '한국식', renju: '렌주룰', free: '자유룰' };
function stars(size: number): Pt[] {
const s = size === 19 ? [3, 9, 15] : [3, 7, 11];
return s.flatMap((x) => s.map((y) => ({ x, y })));
}
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
return (
<g opacity={ghost ? 0.55 : 1}>
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.06} />
{color === 'black' ? (
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
) : (
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
)}
</g>
);
}
function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps<OmokView>) {
const n = v.size;
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
const [preview, setPreview] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
const [announce, setAnnounce] = useState('');
const [kbd, setKbd] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const prevTurn = useRef(v.turn);
const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]);
// Sounds, vibration and screen-reader announcements on new moves.
useEffect(() => {
if (v.moveCount > prevCount.current && v.lastMove) {
sounds.stone();
const who = v.moves[v.moves.length - 1]!.color;
setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`);
}
if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
prevCount.current = v.moveCount;
prevTurn.current = v.turn;
setPreview(null);
}, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps
const canPlace = (p: Pt) =>
myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2));
const place = (p: Pt) => {
act({ type: 'place', x: p.x, y: p.y });
setPreview(null);
};
const tap = (p: Pt) => {
setCursor(p);
if (!myTurn) return;
if (v.board[p.y * n + p.x] !== 0) return;
if (forbidden.has(p.y * n + p.x)) {
haptic([30, 30, 30]);
setAnnounce('여기는 금수 자리라 둘 수 없어요.');
}
if (!tapConfirm) {
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.round(loc.x);
const y = Math.round(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();
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (canPlace(cursor)) place(cursor);
}
};
const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x));
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 opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null;
const pad = 0.6;
return (
<div className="stack" style={{ gap: 10 }}>
<PlayerBar view={v} color={opp ?? 'white'} players={players} deadline={deadline} me={me} />
<div className="board-wrap">
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default' }}
onPointerUp={(e) => {
setKbd(false);
const p = pointToCell(e);
if (p) tap(p);
}}
tabIndex={0}
role="grid"
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
onKeyDown={onKey}
>
{Array.from({ length: n }, (_, i) => (
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
<line x1={0} y1={i} x2={n - 1} y2={i} />
<line x1={i} y1={0} x2={i} y2={n - 1} />
</g>
))}
{stars(n).map((p) => (
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
))}
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
{v.forbidden.map((p) => (
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
</g>
))}
{v.board.map((c, i) =>
c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />,
)}
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
{[...winSet].map((i) => (
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
))}
{preview && v.myColor && <Stone color={v.myColor} cx={preview.x} cy={preview.y} r={0.46} ghost />}
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} className="kbd-cursor" />}
</svg>
</div>
<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)}>
{forbidden.has(preview.y * n + preview.x) ? '여기는 둘 수 없어요' : `${label(n, preview)}에 두기`}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
취소
</button>
</div>
)}
<PlayerBar view={v} color={v.myColor ?? 'black'} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && v.myColor && (
<div className="row" style={{ justifyContent: 'center' }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
onClick={() => act({ type: 'requestUndo' })}
>
↩ 무르기{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' }}>
<span>상대의 답을 기다리는 중이에요…</span>
<button className="btn btn-ghost" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">{incoming === 'undo' ? '수락하면 돌이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</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)} />
)}
</div>
);
}
function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; 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 : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
}
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="-1 -1 2 2" aria-hidden>
<Stone color={color} cx={0} cy={0} r={0.9} />
</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>
{clock}
</div>
);
}
function statusText(v: OmokView, 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) {
if (v.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요';
return `내 차례예요 (${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<OmokOptions['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: OmokOptions['timeControl']): string {
if (tc.kind === 'none') return 'none';
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
return `f${Math.round(tc.baseSec / 60)}`;
}
function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<OmokOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>규칙</strong>
<Seg value={o.ruleSet} options={[['korean', '한국식(쌍삼 금지)'], ['renju', '렌주룰'], ['free', '자유룰']]} onChange={(ruleSet) => set({ ruleSet })} />
</div>
<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>
<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={o.boardSize} options={[[15, '15줄'], [19, '19줄']]} onChange={(boardSize) => set({ boardSize })} />
</div>
{o.ruleSet === 'free' && (
<div className="stack" style={{ gap: 6 }}>
<strong>자유룰 승리</strong>
<Seg value={o.freeWin} options={[['fivePlus', '5목 이상'], ['exactFive', '정확히 5목']]} onChange={(freeWin) => set({ freeWin })} />
</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={o.firstMoveCenter} options={[['auto', '렌주만'], ['on', '항상'], ['off', '안 함']]} onChange={(firstMoveCenter) => set({ firstMoveCenter })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>도움 표시</strong>
<Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} />
<Seg value={o.hints} options={[['warn', '위험한 자리 알려 주기'], ['off', '알려 주지 않기']]} onChange={(hints) => set({ hints })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
</>
)}
</div>
);
}
function timeLabel(tc: OmokOptions['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 optionsSummary(o: OmokOptions): string {
const t = timeLabel(o.timeControl);
return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · ');
}
function Rules({ options }: { options?: OmokOptions }) {
const rule = options?.ruleSet ?? 'korean';
return (
<div className="stack">
<p>흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 <strong>5개</strong>를 먼저 이으면 이겨요. 흑이 먼저 둬요.</p>
{rule === 'korean' && (
<p>
<strong>한국식</strong>: 흑과 백 모두 <strong>쌍삼(열린 3을 두 개 동시에 만드는 수)</strong>을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요.
</p>
)}
{rule === 'renju' && (
<p>
<strong>렌주룰</strong>: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요.
</p>
)}
{rule === 'free' && (
<p>
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
</p>
)}
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).</p>
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
</div>
);
}
const ui: GameUI<OmokView, OmokOptions> = {
Board: OmokBoard,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
Options,
optionsSummary,
Rules,
};
export default ui;