From a10139b51ac67ec83a1d8617fd0ad02fa33a5311 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sun, 4 Oct 2026 05:34:50 +0900 Subject: [PATCH] =?UTF-8?q?M2:=20=EB=B0=94=EB=91=91=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=204=EA=B2=8C=EC=9E=84=20E2E=20=EC=95=88=EC=A0=95=ED=99=94,=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A7=B5=20M2=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + apps/web/src/games/baduk/index.tsx | 859 ++++++++++++++++++++++++ apps/web/src/games/registry.ts | 1 + docs/12-roadmap.md | 4 + e2e/admin.e2e.ts | 1 + e2e/games.e2e.ts | 13 +- e2e/omok.e2e.ts | 1 + packages/games/src/baduk/baduk.test.ts | 588 ++++++++++++++++ packages/games/src/baduk/board.ts | 539 +++++++++++++++ packages/games/src/baduk/index.ts | 883 +++++++++++++++++++++++++ packages/games/src/index.ts | 2 + 11 files changed, 2891 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/games/baduk/index.tsx create mode 100644 packages/games/src/baduk/baduk.test.ts create mode 100644 packages/games/src/baduk/board.ts create mode 100644 packages/games/src/baduk/index.ts diff --git a/README.md b/README.md index 26fae0f..3c7e16d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ bun test # 규칙·서버 통합·방 단위 테스트 (cd apps/web && bunx vite build) # 웹 빌드 bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 +bun e2e/games.e2e.ts # 오셀로·체스·바둑·장기: 방 만들기 → 친구 입장 → 첫 수 +bun e2e/a11y.e2e.ts # 접근성(axe) 검사 +bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) ``` ## 배포 diff --git a/apps/web/src/games/baduk/index.tsx b/apps/web/src/games/baduk/index.tsx new file mode 100644 index 0000000..757153a --- /dev/null +++ b/apps/web/src/games/baduk/index.tsx @@ -0,0 +1,859 @@ +/** 바둑 화면 (docs/games/baduk.md §9). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { BadukOptions, BadukView, Pt, ScoreBreakdown } from '@bg/games/baduk'; +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 = { black: '흑', white: '백' }; +const COLS = 'ABCDEFGHJKLMNOPQRST'; +const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`; +const other = (c: Color): Color => (c === 'black' ? 'white' : 'black'); +const ILLEGAL_KO = { + suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).', + ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.', + superko: '같은 모양이 반복되어 둘 수 없어요.', +} as const; + +function stars(size: number): Pt[] { + if (size === 9) return [{ x: 2, y: 2 }, { x: 6, y: 2 }, { x: 2, y: 6 }, { x: 6, y: 6 }, { x: 4, y: 4 }]; + if (size === 13) return [{ x: 3, y: 3 }, { x: 9, y: 3 }, { x: 3, y: 9 }, { x: 9, y: 9 }, { x: 6, y: 6 }]; + const s = [3, 9, 15]; + return s.flatMap((x) => s.map((y) => ({ x, y }))); +} + +/** Points like "흑 2집 반" (territory) or "1.5점" (area). */ +function fmtScore(n: number, method: 'territory' | 'area'): string { + if (method === 'area') return String(n); + const whole = Math.floor(n); + const half = n - whole >= 0.5; + return half ? (whole === 0 ? '반' : `${whole}.5`) : String(whole); +} + +function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) { + return ( + + + {color === 'black' ? ( + + ) : ( + + )} + + ); +} + +type MarkMode = 'dead' | 'seki'; + +function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps) { + const n = v.size; + const myTurn = v.phase === 'playing' && v.myColor === v.turn; + const marking = v.phase === 'scoring' && v.myColor !== null; + const [mode, setMode] = useState('dead'); + const [preview, setPreview] = useState(null); + const [hover, setHover] = useState(null); + const [cursor, setCursor] = useState({ x: (n - 1) >> 1, y: (n - 1) >> 1 }); + const [announce, setAnnounce] = useState(''); + const [bubble, setBubble] = useState<{ p: Pt; text: string } | null>(null); + const [kbd, setKbd] = useState(false); + const [changed, setChanged] = useState>(new Set()); + const svgRef = useRef(null); + const prevCount = useRef(v.moveCount); + const prevDead = useRef | null>(null); + + const illegal = useMemo(() => new Map(v.illegalPoints.map((p) => [p.y * n + p.x, p.reason])), [v.illegalPoints, n]); + // Dead stones: the live marks while scoring, or (after the game) stones owned by the other colour. + const dead = useMemo(() => { + if (v.scoring) return new Set(v.scoring.dead.map((p) => p.y * n + p.x)); + const own = v.result?.score?.ownership; + const out = new Set(); + if (own) v.board.forEach((c, i) => c !== 0 && own[i] !== c && out.add(i)); + return out; + }, [v.scoring, v.result, v.board, n]); + const seki = useMemo(() => new Set((v.scoring?.seki ?? []).map((p) => p.y * n + p.x)), [v.scoring?.seki, n]); + + // Move sounds and announcements. + useEffect(() => { + if (v.moveCount > prevCount.current) { + const m = v.moves[v.moves.length - 1]!; + if (m.pt) { + sounds.stone(); + if (m.captured > 0) haptic(30); + setAnnounce(`${COLOR_KO[m.color]}이 ${label(n, m.pt)}에 두었어요.${m.captured ? ` 돌 ${m.captured}개를 따냈어요.` : ''}`); + } else setAnnounce(`${COLOR_KO[m.color]}이 패스했어요.`); + if (myTurn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + } + prevCount.current = v.moveCount; + setPreview(null); + setBubble(null); + }, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps + + // Highlight groups whose dead/alive mark just changed (e.g. by the opponent). + useEffect(() => { + if (!v.scoring) { + prevDead.current = null; + return; + } + const before = prevDead.current; + prevDead.current = dead; + if (!before) return; + const diff = new Set(); + for (const i of dead) if (!before.has(i)) diff.add(i); + for (const i of before) if (!dead.has(i)) diff.add(i); + if (diff.size === 0) return; + setChanged(diff); + const t = setTimeout(() => setChanged(new Set()), 2500); + return () => clearTimeout(t); + }, [dead]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!bubble) return; + const t = setTimeout(() => setBubble(null), 2500); + return () => clearTimeout(t); + }, [bubble]); + + const at = (p: Pt) => v.board[p.y * n + p.x]; + const canPlace = (p: Pt) => myTurn && !pending && at(p) === 0 && !illegal.has(p.y * n + p.x); + const canMark = (p: Pt) => marking && !pending && (mode === 'dead' ? at(p) !== 0 : at(p) === 0); + + const place = (p: Pt) => { + act({ type: 'place', x: p.x, y: p.y }); + setPreview(null); + setHover(null); + }; + const mark = (p: Pt) => act(mode === 'dead' ? { type: 'toggleDead', x: p.x, y: p.y } : { type: 'toggleSeki', x: p.x, y: p.y }); + + const tap = (p: Pt, mouse: boolean) => { + setCursor(p); + if (marking) { + if (canMark(p)) mark(p); + else if (mode === 'dead' && at(p) === 0) setBubble({ p, text: '돌을 눌러 사석/생석을 바꿔요. 빈 곳은 [빅 표시]에서 눌러요.' }); + return; + } + if (!myTurn || at(p) !== 0) return; + const why = illegal.get(p.y * n + p.x); + if (why) { + haptic([30, 30, 30]); + setBubble({ p, text: ILLEGAL_KO[why] }); + setAnnounce(ILLEGAL_KO[why]); + return; + } + if (mouse || !tapConfirm) { + if (canPlace(p)) place(p); + return; + } + if (preview && preview.x === p.x && preview.y === p.y) place(p); + else setPreview(p); + }; + + const pointToCell = (e: React.PointerEvent): Pt | null => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse()); + const x = Math.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 = { 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(); + tap(cursor, true); + } + }; + + // Ownership marks: live preview while marking, final result after the game. + const score: ScoreBreakdown | undefined = v.scoring?.preview ?? v.result?.score; + const ghost = preview ?? (hover && canPlace(hover) ? hover : null); + const hoverMark = marking && hover && canMark(hover) ? hover : null; + const lastColor: Color | null = v.lastMove ? (at(v.lastMove) === 1 ? 'black' : 'white') : null; + const pad = 0.95; + return ( +
+ { + if (e.pointerType !== 'mouse' || !(myTurn || marking)) 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={`바둑판 ${n}줄. 방향키로 움직이고 Enter로 ${marking ? '표시를 바꿉니다' : '둡니다'}. 현재 위치 ${label(n, cursor)}`} + onKeyDown={onKey} + > + {Array.from({ length: n }, (_, i) => ( + + + + + ))} + {Array.from({ length: n }, (_, i) => ( + + {COLS[i]} + {COLS[i]} + {n - i} + {n - i} + + ))} + {stars(n).map((p) => ( + + ))} + {/* Ko / superko points for the mover (suicide points are explained on click only). */} + {myTurn && + v.illegalPoints + .filter((p) => p.reason !== 'suicide') + .map((p) => )} + {v.board.map((c, i) => { + if (c === 0) return null; + const color: Color = c === 1 ? 'black' : 'white'; + const x = i % n; + const y = Math.floor(i / n); + const isDead = dead.has(i); + return ( + + + + ); + })} + {/* Stones just captured fade out. */} + {v.phase === 'playing' && + v.lastCaptured.map((p) => ( + + + + + ))} + {/* Territory / area marks (small squares) and seki marks. */} + {score && + score.ownership.map((o, i) => { + if (o === 0 || (v.board[i] !== 0 && !dead.has(i))) return null; + return ; + })} + {[...seki].map((i) => ( + + ))} + {[...dead].map((i) => ( + + + + + ))} + {[...changed].map((i) => ( + + + + ))} + {v.lastMove && lastColor && ( + + )} + {myTurn && + v.atariWarnings.map((p) => ( + + + + ))} + {ghost && v.myColor && } + {hoverMark && } + {(myTurn || marking) && kbd && } + {bubble && } + +

+ {announce} +

+ {bubble && ( +
+ {label(n, bubble.p)}: {bubble.text} +
+ )} + {marking && ( +
+
+ + +
+

+ {mode === 'dead' + ? '죽은 돌 묶음을 누르면 사석(✕)으로 바뀌고, 다시 누르면 살아 있는 돌로 돌아와요.' + : '빅(세키) 안의 빈 곳을 누르면 ◆로 표시돼요. 한국·일본식 계산에서는 빅 안의 집을 세지 않아요.'} +

+
+ )} + {preview && myTurn && ( +
+ + +
+ )} +
+ ); +} + +// ---------------------------------------------------------------- side panel + +function ScoreTable({ score, heading }: { score: ScoreBreakdown; heading?: boolean }) { + const t = score.method === 'territory'; + const rows: [string, string | number, string | number][] = t + ? [ + ['집', score.black.territory, score.white.territory], + ['따낸 돌(사석 포함)', score.black.captures, score.white.captures], + ['덤', '-', score.white.komi], + ] + : [ + ['판 위 살아 있는 돌', score.black.stones, score.white.stones], + ['영역(빈 곳)', score.black.territory, score.white.territory], + ['덤', '-', score.white.komi], + ...(score.white.handicapComp ? [['치석 보정', '-', score.white.handicapComp] as [string, string, number]] : []), + ]; + return ( + + + + + + + + + + {rows.map(([k, b, w]) => ( + + + + + + ))} + + + + + + +
{heading ? (t ? '집 계산' : '영역 계산') : ''}● 흑○ 백
{k}{b}{w}
합계{score.black.total}{score.white.total}
+ ); +} + +function ScoringPanel({ view: v, act, pending }: { view: BadukView; act: (a: unknown) => void; pending: boolean }) { + const [confirmResume, setConfirmResume] = useState(false); + const sc = v.scoring!; + const p = sc.preview; + const lead = p.diff === 0 ? '동점' : `${p.diff > 0 ? '흑' : '백'} ${fmtScore(Math.abs(p.diff), p.method)}${p.method === 'territory' ? '집' : '점'} 앞섬`; + const mine = v.myColor; + return ( +
+
+ 사석 확인 + +
+

+ 대국이 멈췄어요. 판 위의 죽은 돌을 눌러 ✕ 표시를 맞춘 뒤 두 사람 모두 동의하면 계가가 끝나요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요. +

+
+ 흑 {p.black.total} : 백 {p.white.total} +
+
{lead}
+ +
+ ● 흑 {sc.agreed.black ? '✅ 동의함' : '⏳ 확인 중'} + ○ 백 {sc.agreed.white ? '✅ 동의함' : '⏳ 확인 중'} +
+ {mine && ( +
+ + +
+ )} + {confirmResume && ( + act({ type: 'resumePlay' })} + onClose={() => setConfirmResume(false)} + /> + )} +
+ ); +} + +function BadukSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(null); + const incoming = + v.phase === 'playing' && v.myColor + ? v.undo.pending && v.undo.pending.by !== v.myColor + ? 'undo' + : v.draw.pendingBy && v.draw.pendingBy !== v.myColor + ? 'draw' + : null + : null; + const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor); + const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null; + const opp: Color = v.myColor === 'white' ? 'black' : 'white'; + const mine: Color = v.myColor ?? 'black'; + const myTurn = v.phase === 'playing' && v.myColor === v.turn; + const last = v.moves[v.moves.length - 1]; + const oppPassed = myTurn && last && !last.pt && last.color !== v.myColor; + return ( + <> + + + {oppPassed && ( +
+ 상대가 패스했어요. 끝내려면 나도 패스하세요. 더 둘 곳이 있으면 계속 둬도 돼요. +
+ )} + {v.phase === 'scoring' && } + {v.phase === 'finished' && v.result?.score && ( +
+ {v.result.summaryKo} + +
+ )} + {v.phase === 'playing' && v.myColor && ( +
+ +
+ {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+
+ )} + {v.phase === 'scoring' && v.myColor && ( + + )} + {outgoing && ( +
+ 상대의 답을 기다리는 중… + +
+ )} + + {incoming && ( + {}} label="상대의 요청"> +

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

+

{incoming === 'undo' ? '수락하면 돌과 따낸 돌이 그 전으로 돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}

+
+ + +
+
+ )} + {confirm === 'pass' && ( + 0 ? '상대도 패스했으니, 패스하면 대국이 멈추고 사석 확인으로 넘어가요.' : '이번 차례에 돌을 두지 않고 넘겨요.'} + confirm="패스하기" + onConfirm={() => act({ type: 'pass' })} + onClose={() => setConfirm(null)} + /> + )} + {confirm === 'resign' && ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + )} + {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + + ); +} + +function MoveList({ view: v }: { view: BadukView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moveCount]); + if (v.moveCount === 0) return null; + return ( +
+ 기보 ({v.moveCount}수) +
    + {v.moves.map((m, i) => ( +
  1. + {i + 1}. {m.color === 'black' ? '●' : '○'} {m.pt ? label(v.size, m.pt) : '패스'} + {m.captured ? ×{m.captured} : null} +
  2. + ))} +
+
+ ); +} + +const mmss = (ms: number) => { + const s = Math.max(0, Math.ceil(ms / 1000)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +}; + +function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView; color: Color; players: Record; deadline: number | null; me: string | null }) { + const id = v.players[color]; + const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; + const active = v.phase === 'playing' && v.turn === color; + const now = useServerNow(250); + const c = v.clock; + const elapsed = active ? Math.max(0, now - c.turnStartedAt) : 0; + const mine = id === me; + let clock: React.ReactNode = null; + let periodSecLeft: number | null = null; + if (c.kind === 'byoyomi') { + const main = c.mainMs[color]; + if (elapsed < main) { + clock = {mmss(main - elapsed)}; + } else { + const periodMs = c.periodSec * 1000; + const over = elapsed - main; + const used = Math.floor(over / periodMs); + const left = Math.max(0, c.periodsLeft[color] - used); + periodSecLeft = active ? Math.ceil((periodMs - (over % periodMs)) / 1000) : c.periodSec; + const urgent = active && periodSecLeft <= 10; + clock = ( + + + {periodSecLeft}초 + + + {Array.from({ length: c.periods }, (_, i) => ( + ● + ))} + + + ); + } + } else if (c.kind === 'fischer') { + const ms = c.mainMs[color] - elapsed; + clock = ( + {mmss(ms)} + ); + } else if (active) { + clock = ; + } + + // Byoyomi countdown beeps for my own clock (last 5 seconds of a period). + const beeped = useRef(null); + useEffect(() => { + if (!active || !mine || periodSecLeft === null || periodSecLeft > 5 || beeped.current === periodSecLeft) return; + beeped.current = periodSecLeft; + sounds.warn(); + }, [periodSecLeft, active, mine]); + + const urgentBorder = active && periodSecLeft !== null && periodSecLeft <= 10; + return ( +
+ + + + +
+
+ {u.nickname} + {mine && (나)} +
+
+ {COLOR_KO[color]} · 따낸 돌 {v.captures[color]} + {color === 'white' && v.rules.komi ? ` · 덤 ${v.rules.komi}` : ''} + {active ? ' · 두는 중' : ''} + {v.phase === 'scoring' && v.scoring?.agreed[color] ? ' · 동의함' : ''} +
+
+ {clock} +
+ ); +} + +function statusText(v: BadukView, _me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요'; + if (v.phase === 'scoring') { + if (v.myColor && !v.scoring!.agreed[v.myColor]) return '사석을 확인하고 [동의]를 눌러 주세요'; + if (v.myColor) return '상대가 사석을 확인하고 있어요…'; + return '사석 확인 중이에요'; + } + 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.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요'; + if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`; + 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +type TC = BadukOptions['timeControl']; +const TIME_PRESETS: [string, string, Partial][] = [ + ['auto', '판 크기에 맞춤 + 초읽기 30초×3', { kind: 'byoyomi', mainSecAuto: true, periodSec: 30, periods: 3 }], + ['b5', '5분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 300, periodSec: 30, periods: 3 }], + ['b10', '10분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 600, periodSec: 30, periods: 3 }], + ['b30', '30분 + 60초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 1800, periodSec: 60, periods: 3 }], + ['f', '피셔 10분 + 10초', { kind: 'fischer', mainSecAuto: false, mainSec: 600, incSec: 10 }], + ['none', '시간 제한 없음', { kind: 'none' }], +]; + +function timeKey(tc: TC): string { + if (tc.kind === 'none') return 'none'; + if (tc.kind === 'fischer') return tc.mainSec === 600 && tc.incSec === 10 && !tc.mainSecAuto ? 'f' : 'custom'; + if (tc.mainSecAuto && tc.periodSec === 30 && tc.periods === 3) return 'auto'; + const hit = TIME_PRESETS.find(([k, , p]) => k.startsWith('b') && !tc.mainSecAuto && p.mainSec === tc.mainSec && p.periodSec === tc.periodSec && p.periods === tc.periods); + return hit ? hit[0] : 'custom'; +} + +const DEFAULT_MAIN: Record = { 9: 180, 13: 600, 19: 1200 }; +function timeLabel(o: BadukOptions): string { + const tc = o.timeControl; + if (tc.kind === 'none') return '시간 제한 없음'; + const main = tc.mainSecAuto ? DEFAULT_MAIN[o.boardSize]! : tc.mainSec; + const m = Math.floor(main / 60); + const mainLabel = main === 0 ? '' : `${m ? `${m}분` : ''}${main % 60 ? ` ${main % 60}초` : ''}`.trim(); + if (tc.kind === 'fischer') return `${mainLabel} + 수당 ${tc.incSec}초`; + return `${mainLabel ? `${mainLabel} + ` : ''}${tc.periodSec}초×${tc.periods}`; +} + +function komiOf(o: BadukOptions): number { + if (!o.komiAuto) return o.komi; + if (o.handicap > 0) return 0.5; + return o.scoring === 'area' ? 7.5 : 6.5; +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const setTc = (patch: Partial) => set({ timeControl: { ...o.timeControl, ...patch } }); + const maxHandicap = o.boardSize === 9 ? 5 : 9; + const handicaps = ([0, 2, 3, 4, 5, 6, 7, 8, 9] as const).filter((h) => h <= maxHandicap); + const tk = timeKey(o.timeControl); + return ( +
+
+ 판 크기 + set({ boardSize, handicap: boardSize === 9 && o.handicap > 5 ? 5 : o.handicap })} + /> +
+
+ 계가 방식 + set({ scoring })} /> +
+
+ 접바둑 + [h, h === 0 ? '맞바둑' : `${h}점`] as [BadukOptions['handicap'], string])} onChange={(handicap) => set({ handicap })} /> + {o.handicap > 0 && 흑(하수)이 치석 {o.handicap}개를 미리 놓고, 백이 먼저 둬요. 덤은 0.5집이에요.} +
+
+ 시간 + [k, l] as [string, string]), + ...(tk === 'custom' ? [['custom', `${timeLabel(o)}(관리자 설정)`] as [string, string]] : []), + ]} + onChange={(k) => { + const preset = TIME_PRESETS.find((p) => p[0] === k); + if (preset) setTc(preset[2]); + }} + /> +
+
+ 흑백 정하기 + set({ colorAssignment })} + /> +
+ + {more && ( + <> +
+ 덤 (지금 {komiOf(o)}) + set({ komiAuto: k === 'auto' })} /> + {!o.komiAuto && ( + { + const n = Math.round(Number(e.target.value) * 2) / 2; + if (Number.isFinite(n)) set({ komi: Math.max(-10, Math.min(10, n)) }); + }} + aria-label="덤" + /> + )} +
+
+ 패 규칙 + set({ koRule })} /> +
+ {o.timeControl.kind === 'byoyomi' && ( +
+ 초읽기 + setTc({ periodSec })} /> + setTc({ periods })} /> +
+ )} +
+ 무르기 + set({ undo })} /> +
+
+ 도움 표시 + set({ atariWarning: k === 'on' })} /> + set({ drawOffer: k === 'on' })} /> +
+
+ 사석 확인 제한 시간 + set({ scoringDeadlineSec })} /> +
+ + )} +
+ ); +} + +function optionsSummary(o: BadukOptions): string { + return [ + o.scoring === 'territory' ? '집 계산' : '영역 계산', + `${o.boardSize}줄`, + o.handicap ? `${o.handicap}점 접바둑` : `덤 ${komiOf(o)}`, + timeLabel(o), + ].join(' · '); +} + +function Rules({ options }: { options?: BadukOptions }) { + const scoring = options?.scoring ?? 'territory'; + const koRule = options?.koRule ?? 'positionalSuperko'; + return ( +
+

흑과 백이 번갈아 판의 교차점에 돌을 하나씩 놓아요. 흑이 먼저 둬요(접바둑이면 흑이 치석을 미리 놓고 백이 먼저 둬요). 한 번 놓은 돌은 움직이지 않아요.

+

+ 따내기: 돌에 상하좌우로 맞닿은 빈 점을 활로라고 해요. 상하좌우로 붙은 같은 색 돌은 한 덩어리로 활로를 함께 써요. 상대 돌 덩어리의 활로를 모두 막으면 그 돌을 판에서 들어내고, 따낸 돌로 세어요. 활로가 하나만 남은 상태를 단수라고 해요. +

+

+ 자충수 금지: 놓는 순간 내 돌의 활로가 없어지는 자리에는 둘 수 없어요. 단, 그 수로 상대 돌을 따내서 활로가 생기면 둘 수 있어요. +

+

+ 패: 돌 하나를 서로 번갈아 따낼 수 있는 모양에서는, 따인 쪽이 바로 되따낼 수 없어요. 다른 곳에 한 수 둔 뒤에 되따낼 수 있어요. + {koRule === 'positionalSuperko' + ? ' 이 방은 판 전체 모양이 이전과 똑같아지는 수를 모두 금지해요(동형 반복 금지).' + : ' 이 방은 한국식이라 같은 국면이 세 번 나오면(삼패 등) 무승부로 끝나요.'} + {' '}둘 수 없는 패 자리는 빨간 네모로 보여 줘요. +

+

+ 끝내기: 더 둘 곳이 없으면 패스해요. 두 사람이 연달아 패스하면 대국이 멈추고 사석 확인을 해요. 판에서 죽은 돌을 눌러 ✕ 표시를 맞추고, 두 사람 모두 [동의]하면 점수가 나와요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 결론을 내요. +

+ {scoring === 'territory' ? ( +

+ 집 계산(한국·일본식): 내 돌로만 둘러싼 빈 점(집) + 따낸 돌(사석 포함)이 내 점수예요. 백은 먼저 둔 흑의 이득을 메우려고 덤을 더 받아요. 빅(서로 잡을 수 없이 공배를 나눠 사는 모양) 안의 집은 세지 않아요. +

+ ) : ( +

+ 영역 계산(중국식): 판 위의 살아 있는 내 돌 + 내 돌로만 둘러싼 빈 점이 내 점수예요. 따낸 돌은 세지 않으니 끝내기 전에 공배(양쪽 사이의 빈 점)를 메운 뒤 패스하세요. 백은 덤을 더 받고, 접바둑이면 치석 수만큼 더 받아요. +

+ )} +

점수가 높은 쪽이 이겨요. 덤이 0.5 단위면 비기는 일이 없어요. 언제든 기권(불계패)할 수 있어요. 처음이라면 9줄 판으로 시작해 보세요.

+
+ ); +} + +const ui: GameUI = { + Board: BadukBoard, + Side: BadukSide, + statusText, + isMyTurn: (v, me) => { + if (!v.myColor || v.players[v.myColor] !== me) return false; + if (v.phase === 'playing') return v.turn === v.myColor; + if (v.phase === 'scoring') return !v.scoring!.agreed[v.myColor]; + return false; + }, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index c9f22ac..ebae902 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -6,6 +6,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { othello: () => import('./othello'), chess: () => import('./chess'), janggi: () => import('./janggi'), + baduk: () => import('./baduk'), }; const cache = new Map(); diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index a07f839..d0f819c 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -24,6 +24,9 @@ - 디스코드 로그인 실제 연결(앱 정보가 준비된 경우). - 첫 부하 테스트·장애 주입 테스트 + 보고서. - 테스트 서버 배포(위치가 정해진 경우). +- **진행 결과(2026-10-04)**: 네 게임 완료(규칙 테스트 체스 40·장기 46·바둑 50·오셀로 31), 게임 공통 E2E(PC 방장+휴대폰 친구), + 접근성 axe 위반 0, 부하·장애 주입 통과(`reports/load-20261004.md`). 체스960은 chess.js가 지원하지 않아 후순위(M9). + 디스코드 실제 연결·테스트 서버 배포는 사용자 정보 대기. ## M3. 카드 게임 기반 + 섯다·포커 - 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟. @@ -50,6 +53,7 @@ - PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화. - 2차 부하 테스트, 필요 시 방 단위 다중 프로세스 분산 검토. - (선택) 혼자 연습용 간단한 컴퓨터 상대. +- 체스960(자체 캐슬링 구현 + perft 검증). ## 순서 원칙 - 플랫폼(M1)의 안정성 기능이 먼저다. 게임은 그 위에 하나씩 얹는다. diff --git a/e2e/admin.e2e.ts b/e2e/admin.e2e.ts index 97e85cf..939de20 100644 --- a/e2e/admin.e2e.ts +++ b/e2e/admin.e2e.ts @@ -103,6 +103,7 @@ try { g.on('pageerror', (e) => errors.push(e.message)); await g.goto(ORIGIN + '/'); await g.getByRole('button', { name: '+ 방 만들기' }).click(); + await g.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click(); await g.getByLabel('내 이름(닉네임)').fill('손님'); await g.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await g.waitForURL(/\/r\/\d{6}$/); diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index bbef4cd..085c1aa 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -31,6 +31,10 @@ const PLANS: Record = { // cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped) moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']], }, + baduk: { + // cursor starts at 천원: black plays there, white plays one point to the left + moves: [['Enter'], ['ArrowLeft', 'Enter']], + }, janggi: { // Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step. setup: async (host, guest) => { @@ -38,9 +42,12 @@ const PLANS: Record = { let picked = false; for (const p of [host, guest]) { const btn = p.getByRole('button', { name: /^안상차림/ }); - if ((await btn.count()) && (await btn.isEnabled())) { - await btn.click(); - picked = true; + // The card list disappears right after choosing, so every probe must be short and tolerant. + if ((await btn.count()) && (await btn.isEnabled({ timeout: 500 }).catch(() => false))) { + picked = await btn + .click({ timeout: 1000 }) + .then(() => true) + .catch(() => false); } } if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) { diff --git a/e2e/omok.e2e.ts b/e2e/omok.e2e.ts index 2786b13..ead116f 100644 --- a/e2e/omok.e2e.ts +++ b/e2e/omok.e2e.ts @@ -68,6 +68,7 @@ try { await a.getByText('게임 고르기').waitFor(); await a.screenshot({ path: join(SHOTS, '0-home-pc.png') }); await a.getByRole('button', { name: '+ 방 만들기' }).click(); + await a.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click(); await a.getByLabel('내 이름(닉네임)').fill('호랑이'); await a.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await a.waitForURL(/\/r\/\d{6}$/); diff --git a/packages/games/src/baduk/baduk.test.ts b/packages/games/src/baduk/baduk.test.ts new file mode 100644 index 0000000..04dc5d8 --- /dev/null +++ b/packages/games/src/baduk/baduk.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN } from '@bg/engine'; +import { baduk, badukOptionsSchema, handicapPoints, type BadukAction, type BadukOptions, type BadukState } from './index'; +import { hashBoard, suggestMarks, tryPlace, type Cell } from './board'; + +const COLS = 'ABCDEFGHJKLMNOPQRST'; +const at = (label: string, size = 9) => ({ x: COLS.indexOf(label[0]!), y: size - Number(label.slice(1)) }); +const idx = (label: string, size = 9) => { + const p = at(label, size); + return p.y * size + p.x; +}; +const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); +type Color = 'black' | 'white'; + +/** A = black, B = white. Stones are set directly (no moves recorded). */ +function make(opts: Partial = {}, stones: { black?: string[]; white?: string[] } = {}, turn?: Color): BadukState { + const options = baduk.optionsSchema.parse({ colorAssignment: 'hostBlack', boardSize: 9, ...opts }); + const s = baduk.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 }); + for (const [color, v] of [['black', 1], ['white', 2]] as const) for (const l of stones[color] ?? []) s.board[idx(l, s.size)] = v; + if (turn) s.turn = turn; + s.hash = hashBoard(s.board); + s.positionHistory = [s.hash]; + s.situationCounts = { [s.hash + (s.turn === 'black' ? 'b' : 'w')]: 1 }; + return s; +} + +const actorOf = (s: BadukState, c: Color = s.turn) => s.players[c]; +function act(s: BadukState, c: Color, a: BadukAction, now = 1000): BadukState { + const v = baduk.validate(s, actorOf(s, c), a); + if (!v.ok) throw new Error(v.reason); + return baduk.apply(s, actorOf(s, c), a, { rng: rng(), now }).state; +} +function play(s: BadukState, label: string, now = 1000): BadukState { + return act(s, s.turn, label === 'pass' ? { type: 'pass' } : { type: 'place', ...at(label, s.size) }, now); +} +function playAll(s: BadukState, labels: string[]): BadukState { + for (const l of labels) s = play(s, l); + return s; +} +const check = (s: BadukState, label: string, c: Color = s.turn) => baduk.validate(s, actorOf(s, c), { type: 'place', ...at(label, s.size) }); +const cell = (s: BadukState, label: string) => s.board[idx(label, s.size)]; + +describe('captures (§10)', () => { + test('single stone in the centre', () => { + let s = make({}, { black: ['D5', 'F5', 'E6'], white: ['E5'] }); + s = play(s, 'E4'); + expect(cell(s, 'E5')).toBe(0); + expect(s.captures.black).toBe(1); + expect(s.moves.at(-1)).toMatchObject({ kind: 'place', captured: [at('E5')] }); + }); + + test('several chains at once', () => { + let s = make({}, { black: ['D4', 'D6', 'C5', 'F4', 'F6', 'G5'], white: ['D5', 'F5'] }); + s = play(s, 'E5'); + expect(cell(s, 'D5')).toBe(0); + expect(cell(s, 'F5')).toBe(0); + expect(s.captures.black).toBe(2); + }); + + test('corner and edge', () => { + let s = make({}, { black: ['B1'], white: ['A1'] }); + s = play(s, 'A2'); + expect(cell(s, 'A1')).toBe(0); + let e = make({}, { black: ['A4', 'A6'], white: ['A5'] }); + e = play(e, 'B5'); + expect(cell(e, 'A5')).toBe(0); + expect(e.captures.black).toBe(1); + }); + + test('multi-stone chain', () => { + let s = make({}, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] }); + s = play(s, 'C1'); + expect(s.captures.black).toBe(4); + expect(s.board.filter((v) => v === 2)).toHaveLength(0); + }); +}); + +describe('suicide (§10)', () => { + test('suicide is rejected', () => { + const s = make({}, { black: ['B1', 'A2'] }, 'white'); + expect(check(s, 'A1')).toEqual({ ok: false, reason: '둘 곳의 활로가 없어 둘 수 없어요(자충수).' }); + const multi = make({}, { black: ['A3', 'B2', 'C1'], white: ['A1'] }, 'white'); + expect(check(multi, 'A2').ok).toBe(true); + expect(check(make({}, { black: ['A3', 'B2', 'C1'], white: ['A1', 'B1'] }, 'white'), 'A2').ok).toBe(false); + }); + + test('a move that captures first is legal even with no liberties of its own', () => { + const s = make({}, { black: ['A3', 'B2', 'C1'], white: ['A2', 'B1'] }); + expect(check(s, 'A1').ok).toBe(true); + const after = play(s, 'A1'); + expect(after.captures.black).toBe(2); + }); + + test('illegal points are listed for the mover with reasons', () => { + const s = make({}, { black: ['B1', 'A2'] }, 'white'); + const v = baduk.view(s, 'B'); + expect(v.illegalPoints).toContainEqual({ ...at('A1'), reason: 'suicide' }); + }); +}); + +// Ko shape: black D5 E4 E6, white E5 F4 F6 G5. Black F5 takes E5. +const KO = { black: ['D5', 'E4', 'E6'], white: ['E5', 'F4', 'F6', 'G5'] }; + +describe('ko (§5.3, §10)', () => { + for (const koRule of ['positionalSuperko', 'korean'] as const) { + test(`${koRule}: immediate retake rejected, allowed after a ko threat`, () => { + let s = play(make({ koRule }, KO), 'F5'); + expect(cell(s, 'E5')).toBe(0); + expect(check(s, 'E5')).toEqual({ ok: false, reason: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.' }); + expect(baduk.view(s, 'B').illegalPoints).toContainEqual({ ...at('E5'), reason: 'ko' }); + s = playAll(s, ['A9', 'J1']); + expect(check(s, 'E5').ok).toBe(true); + s = play(s, 'E5'); + expect(cell(s, 'F5')).toBe(0); + }); + } + + test('positionalSuperko rejects any earlier arrangement (not just the last one)', () => { + const s = make({}, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] }); + const r = tryPlace(s.board, 9, idx('C1'), 1, s.hash); + if (!r.ok) throw new Error('unexpected'); + // Pretend the post-capture arrangement occurred earlier in the game. + s.positionHistory = [r.hash, ...s.positionHistory, 'ffffffffffffffff']; + expect(check(s, 'C1')).toEqual({ ok: false, reason: '같은 모양이 반복되어 둘 수 없어요.' }); + expect(baduk.view(s, 'A').illegalPoints).toContainEqual({ ...at('C1'), reason: 'superko' }); + // The korean ko rule has no superko. + const k = make({ koRule: 'korean' }, { black: ['A3', 'B3', 'C2'], white: ['A1', 'A2', 'B2', 'B1'] }); + k.positionHistory = [r.hash, ...k.positionHistory]; + expect(check(k, 'C1').ok).toBe(true); + }); + + test('korean: the same situation (arrangement + side to move) a third time is a draw', () => { + let s = play(make({ koRule: 'korean' }, KO), 'F5'); + s = playAll(s, ['A9', 'J1']); + // Arrange that the situation after white's E5 has already happened twice. + const r = tryPlace(s.board, 9, idx('E5'), 2, s.hash); + if (!r.ok) throw new Error('unexpected'); + s.situationCounts[r.hash + 'b'] = 2; + s = play(s, 'E5'); + expect(s.phase).toBe('finished'); + expect(s.result).toMatchObject({ winner: null, reason: 'repetition' }); + expect(baduk.result(s)?.ranking).toEqual([['A', 'B']]); + }); + + test('korean: situation counts accumulate in play', () => { + let s = make({ koRule: 'korean' }); + s = play(s, 'E5'); + expect(Object.values(s.situationCounts).reduce((a, b) => a + b, 0)).toBe(2); + }); +}); + +describe('passing and scoring phase (§5.4–5.5)', () => { + test('two consecutive passes enter scoring; pass-place-pass does not', () => { + let s = playAll(make(), ['pass', 'E5', 'pass']); + expect(s.phase).toBe('playing'); + s = play(s, 'pass'); + expect(s.phase).toBe('scoring'); + expect(s.scoringMarks?.agreed).toEqual({ black: false, white: false }); + expect(baduk.activePlayers(s).sort()).toEqual(['A', 'B']); + expect(baduk.deadline!(s)).toBe(1000 + 180_000); + }); + + test('toggling dead stones clears agreement; both agreeing finishes', () => { + let s = playAll(make({}, { black: ['E4'], white: ['E6'] }), ['pass', 'pass']); + s = act(s, 'black', { type: 'agreeScore' }); + expect(s.scoringMarks!.agreed.black).toBe(true); + expect(baduk.activePlayers(s)).toEqual(['B']); + s = act(s, 'white', { type: 'toggleDead', ...at('E4') }); + expect(s.scoringMarks!.dead).toContain(idx('E4')); + expect(s.scoringMarks!.agreed).toEqual({ black: false, white: false }); + s = act(s, 'black', { type: 'toggleDead', ...at('E4') }); + expect(s.scoringMarks!.dead).not.toContain(idx('E4')); + s = act(s, 'black', { type: 'agreeScore' }); + s = act(s, 'white', { type: 'agreeScore' }); + expect(s.phase).toBe('finished'); + expect(s.result?.reason).toBe('score'); + }); + + test('dispute → resume: the requester’s opponent moves first, two passes re-enter scoring', () => { + let s = playAll(make(), ['E5', 'pass', 'pass']); + expect(s.phase).toBe('scoring'); + s = act(s, 'white', { type: 'resumePlay' }); + expect(s.phase).toBe('playing'); + expect(s.turn).toBe('black'); + expect(s.scoringMarks).toBeNull(); + s = act(make(), 'black', { type: 'pass' }); + s = playAll(s, ['pass']); + s = act(s, 'black', { type: 'resumePlay' }); + expect(s.turn).toBe('white'); + s = play(s, 'pass'); + expect(s.phase).toBe('playing'); + s = play(s, 'pass'); + expect(s.phase).toBe('scoring'); + }); + + test('marking a group crosses empty points inside an enclosure but not into the open board', () => { + // Two dead black stones inside white's corner, plus a live black stone outside. + let s = make({}, { black: ['A1', 'B2', 'G7'], white: ['A4', 'B4', 'C4', 'D4', 'D3', 'D2', 'D1'] }); + s = playAll(s, ['pass', 'pass']); + s.scoringMarks!.dead = []; + s = act(s, 'white', { type: 'toggleDead', ...at('A1') }); + expect(s.scoringMarks!.dead.sort()).toEqual([idx('A1'), idx('B2')].sort()); + }); +}); + +describe('scoring (§6)', () => { + // §6.4: black E column, white F column, a dead white stone at B5; captures B 2, W 3. + function example(scoring: 'territory' | 'area') { + const E = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9']; + const F = E.map((l) => 'F' + l.slice(1)); + let s = make({ scoring }, { black: E, white: [...F, 'B5'] }); + s.captures = { black: 2, white: 3 }; + s = playAll(s, ['pass', 'pass']); + return s; + } + + test('the heuristic suggests B5 as dead', () => { + const s = example('territory'); + expect(s.scoringMarks!.dead).toEqual([idx('B5')]); + expect(s.scoringMarks!.seki).toEqual([]); + }); + + test('territory: 39 : 36.5, "흑 2집 반 승"', () => { + let s = example('territory'); + const v = baduk.view(s, null); + expect(v.scoring!.preview.black.total).toBe(39); + expect(v.scoring!.preview.white.total).toBe(36.5); + s = act(s, 'black', { type: 'agreeScore' }); + s = act(s, 'white', { type: 'agreeScore' }); + expect(s.result).toMatchObject({ winner: 'black', reason: 'score', summaryKo: '흑 2집 반 승' }); + expect(s.result!.score).toMatchObject({ margin: 2.5, black: { territory: 36, captures: 3 }, white: { territory: 27, captures: 3, komi: 6.5 } }); + expect(baduk.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal', scores: { A: 39, B: 36.5 } }); + }); + + test('area: 45 : 43.5, "흑 1.5점 승"', () => { + let s = example('area'); + expect(s.komi).toBe(7.5); + s = act(s, 'black', { type: 'agreeScore' }); + s = act(s, 'white', { type: 'agreeScore' }); + expect(s.result!.score!.black.total).toBe(45); + expect(s.result!.score!.white.total).toBe(43.5); + expect(s.result!.summaryKo).toBe('흑 1.5점 승'); + }); + + test('seki region: not counted in territory, counted in area', () => { + for (const scoring of ['territory', 'area'] as const) { + let s = example(scoring); + s = act(s, 'white', { type: 'toggleSeki', ...at('H5') }); + const v = baduk.view(s, null).scoring!; + expect(v.seki).toHaveLength(27); + expect(v.preview.white.territory).toBe(scoring === 'territory' ? 0 : 27); + // Toggle again → unmarked. + s = act(s, 'black', { type: 'toggleSeki', ...at('J9') }); + expect(s.scoringMarks!.seki).toEqual([]); + } + }); + + test('integer komi can end in jigo', () => { + let s = make({ komiAuto: false, komi: 0 }, { black: ['E5'], white: ['E4'] }); + s = playAll(s, ['pass', 'pass']); + s.scoringMarks!.dead = []; + s = act(s, 'black', { type: 'agreeScore' }); + s = act(s, 'white', { type: 'agreeScore' }); + expect(s.result).toMatchObject({ winner: null, reason: 'jigo' }); + expect(baduk.result(s)?.ranking).toEqual([['A', 'B']]); + }); + + test('suggestMarks: eyeless stones inside a living wall are proposed dead, the wall is not', () => { + // Black wall enclosing the corner, white wall outside it, three white stones inside. + const size = 9; + const b = new Array(size * size).fill(0); + const put = (l: string, v: Cell) => (b[idx(l)] = v); + ['A3', 'B3', 'C3', 'D3', 'D2', 'D1'].forEach((l) => put(l, 1)); + ['A4', 'B4', 'C4', 'D4', 'E4', 'E3', 'E2', 'E1'].forEach((l) => put(l, 2)); + ['B1', 'B2', 'C2'].forEach((l) => put(l, 2)); + const m = suggestMarks(b, size); + expect(m.dead).toEqual([idx('B1'), idx('B2'), idx('C2')].sort((x, y) => x - y)); + }); +}); + +test('suggestMarks: nothing is proposed on an open board', () => { + const b = new Array(81).fill(0); + b[idx('C3')] = 1; + b[idx('G7')] = 1; + b[idx('E5')] = 2; + expect(suggestMarks(b, 9)).toEqual({ dead: [], seki: [] }); +}); + +function boardOf(black: string[], white: string[]): Cell[] { + const b = new Array(81).fill(0); + for (const l of black) b[idx(l)] = 1; + for (const l of white) b[idx(l)] = 2; + return b; +} +const col = (c: string, from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => `${c}${from + i}`); + +describe('suggestMarks: seki (§5.6)', () => { + // White lives on the left (A–C, rows 3–9), black on the right (D…), corner fight below. + test('not a seki when the opponent can connect out through a shared liberty → the inner stones are dead', () => { + // Black A1 A2 share B1/B2 with white C1 C2, but B2 also touches the white wall at B3. + const black = ['A1', 'A2', 'C3', ...col('D', 1, 9)]; + const white = ['C1', 'C2', 'A3', 'B3', 'B4', ...col('C', 4, 9)]; + const m = suggestMarks(boardOf(black, white), 9); + expect(m.dead).toEqual([idx('A2'), idx('A1')]); + expect(m.seki).toEqual([]); + }); + + test('seki with one eye each: both eyes proposed as seki, scored per method', () => { + // X (black) A2 B2 B1 C2 with eye A1; Y (white) D1 D2 E2 F2 F1 with eye E1; shared liberty C1. + const black = ['A2', 'B2', 'B1', 'C2', ...col('D', 3, 9), 'E3', 'F3', 'G3', 'G2', 'G1']; + const white = ['D1', 'D2', 'E2', 'F2', 'F1', 'A3', 'B3', 'C3', ...col('C', 4, 9)]; + const m = suggestMarks(boardOf(black, white), 9); + expect(m.dead).toEqual([]); + expect(m.seki).toEqual([idx('A1'), idx('E1')].sort((x, y) => x - y)); + for (const scoring of ['territory', 'area'] as const) { + let s = make({ scoring }, { black, white }); + s = playAll(s, ['pass', 'pass']); + expect(s.scoringMarks!.seki).toEqual(m.seki); + const p = baduk.view(s, null).scoring!.preview; + // Black territory E4–J9 + H1–J3 = 5×6 + 2×3 = 36 (+1 eye in area); white A4–B9 = 12 (+1 eye in area). + expect(p.black.territory).toBe(scoring === 'territory' ? 36 : 37); + expect(p.white.territory).toBe(scoring === 'territory' ? 12 : 13); + } + }); +}); + +describe('handicap (§4)', () => { + const table: Record> = { + 19: { + 2: ['Q16', 'D4'], + 3: ['Q16', 'D4', 'Q4'], + 4: ['Q16', 'D4', 'Q4', 'D16'], + 5: ['Q16', 'D4', 'Q4', 'D16', 'K10'], + 6: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10'], + 7: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K10'], + 8: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K4', 'K16'], + 9: ['Q16', 'D4', 'Q4', 'D16', 'D10', 'Q10', 'K4', 'K16', 'K10'], + }, + 13: { + 2: ['K10', 'D4'], + 3: ['K10', 'D4', 'K4'], + 4: ['K10', 'D4', 'K4', 'D10'], + 5: ['K10', 'D4', 'K4', 'D10', 'G7'], + 6: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7'], + 7: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G7'], + 8: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G4', 'G10'], + 9: ['K10', 'D4', 'K4', 'D10', 'D7', 'K7', 'G4', 'G10', 'G7'], + }, + 9: { 2: ['G7', 'C3'], 3: ['G7', 'C3', 'G3'], 4: ['G7', 'C3', 'G3', 'C7'], 5: ['G7', 'C3', 'G3', 'C7', 'E5'] }, + }; + for (const [size, rows] of Object.entries(table)) { + test(`${size}줄 placements, white first, komi 0.5`, () => { + for (const [n, labels] of Object.entries(rows)) { + const sz = Number(size) as 9 | 13 | 19; + expect(handicapPoints(sz, Number(n)).sort()).toEqual(labels.map((l) => idx(l, sz)).sort()); + const s = make({ boardSize: sz, handicap: Number(n) as BadukOptions['handicap'] }); + expect(s.board.filter((v) => v === 1)).toHaveLength(Number(n)); + expect(s.turn).toBe('white'); + expect(s.komi).toBe(0.5); + } + }); + } + + test('area scoring adds N to white; 9줄 handicap > 5 is rejected', () => { + const s = make({ handicap: 4, scoring: 'area' }); + const p = playAll(s, ['pass', 'pass']); + expect(baduk.view(p, null).scoring!.preview.white.handicapComp).toBe(4); + expect(badukOptionsSchema.safeParse({ boardSize: 9, handicap: 6 }).success).toBe(false); + expect(badukOptionsSchema.safeParse({ boardSize: 13, handicap: 9 }).success).toBe(true); + }); +}); + +describe('clocks (§8.2, §8.5)', () => { + const byo = { kind: 'byoyomi' as const, mainSecAuto: false, mainSec: 60, periodSec: 30 as const, periods: 3, incSec: 10 }; + + test('byoyomi: within a period keeps it, over consumes, past the last loses, later moves rejected', () => { + let s = make({ timeControl: byo }); + expect(baduk.deadline!(s)).toBe(60_000 + 90_000); + s = play(s, 'E5', 70_000); // 60 main + 10 over → still 3 periods + expect(s.clock.mainMs.black).toBe(0); + expect(s.clock.periodsLeft.black).toBe(3); + expect(s.clock.inByoyomi.black).toBe(true); + s = play(s, 'E4', 75_000); // white 5s of main + expect(s.clock.mainMs.white).toBe(55_000); + s = play(s, 'D5', 75_000 + 45_000); // 45s → one period used + expect(s.clock.periodsLeft.black).toBe(2); + s = play(s, 'D4', 121_000); + expect(baduk.deadline!(s)).toBe(121_000 + 2 * 30_000); + s = play(s, 'C5', 121_000 + 31_000); // one more used + expect(s.clock.periodsLeft.black).toBe(1); + s = play(s, 'C4', 153_000); + s = play(s, 'B5', 153_000 + 31_000); // last period exceeded + expect(s.phase).toBe('finished'); + expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss', summaryKo: '백 시간승' }); + expect(cell(s, 'B5')).toBe(0); + expect(baduk.validate(s, 'B', { type: 'place', ...at('A1') }).ok).toBe(false); + }); + + test('timeout action in play → time loss (no auto move)', () => { + const s = make({ timeControl: byo }); + const p = baduk.timeoutPlayers!(s)[0]!; + expect(p).toBe('A'); + const a = baduk.onTimeout(s, p); + expect(baduk.validate(s, p, a).ok).toBe(true); + expect(baduk.validate(s, 'B', a).ok).toBe(false); + const after = baduk.apply(s, p, a, { rng: rng(), now: 200_000 }).state; + expect(after.result).toMatchObject({ winner: 'white', reason: 'timeLoss' }); + expect(baduk.result(after)?.reason).toBe('timeout'); + }); + + test('fischer: increment added, overtime loses', () => { + let s = make({ timeControl: { ...byo, kind: 'fischer', incSec: 5 } }); + expect(baduk.deadline!(s)).toBe(60_000); + s = play(s, 'E5', 10_000); + expect(s.clock.mainMs.black).toBe(55_000); + s = play(s, 'E4', 80_000); + expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); + }); + + test('none: idle limit deadline', () => { + const s = make({ timeControl: { ...byo, kind: 'none' }, idleLimitSec: 300 }); + expect(baduk.deadline!(s)).toBe(300_000); + }); + + test('auto main time per board size', () => { + for (const [size, sec] of [[9, 180], [13, 600], [19, 1200]] as const) { + expect(make({ boardSize: size }).clock.mainMs.black).toBe(sec * 1000); + } + }); + + test('scoring timeout: non-responders are counted as agreeing', () => { + let s = playAll(make({}, { black: ['E5'], white: ['E4'] }), ['pass', 'pass']); + s.scoringMarks!.dead = []; + s = act(s, 'black', { type: 'agreeScore' }); + expect(baduk.timeoutPlayers!(s)).toEqual(['B']); + s = act(s, 'white', baduk.onTimeout(s, 'B')); + expect(s.phase).toBe('finished'); + // Both silent: two timeouts finish with current marks (empty → every stone alive). + let t = playAll(make({}, { black: ['E5'], white: ['E4'] }), ['pass', 'pass']); + t.scoringMarks!.dead = []; + for (const p of baduk.timeoutPlayers!(t)) t = baduk.apply(t, p, baduk.onTimeout(t, p), { rng: rng(), now: 999_999 }).state; + expect(t.phase).toBe('finished'); + expect(t.result!.score!.black.stones).toBe(1); + expect(t.result!.score!.white.stones).toBe(1); + }); + + test('disconnect: the room timer fires onTimeout for the player to move → time loss', () => { + // Grace handling lives in the room (min(deadline, disconnect + grace)); the game only needs + // timeoutPlayers + onTimeout to be valid at any moment of play. + let s = make(); + s = play(s, 'E5'); + const p = baduk.timeoutPlayers!(s)[0]!; + expect(p).toBe('B'); + s = act(s, 'white', baduk.onTimeout(s, p)); + expect(s.result?.winner).toBe('black'); + }); +}); + +describe('undo, draw, resign', () => { + test('accepted undo restores captured stones, captures and superko history by replay', () => { + let c = make(); + c = playAll(c, ['D5', 'E5', 'F5', 'A1', 'E6', 'A2']); + const pre = structuredClone(c); + c = play(c, 'E4'); // black captures E5 + expect(c.captures.black).toBe(1); + c = act(c, 'black', { type: 'requestUndo' }); + expect(c.undo.pending).toEqual({ by: 'black', plies: 1 }); + c = act(c, 'white', { type: 'respondUndo', accept: true }); + expect(c.board).toEqual(pre.board); + expect(c.captures).toEqual(pre.captures); + expect(c.positionHistory).toEqual(pre.positionHistory); + expect(c.hash).toBe(pre.hash); + expect(c.turn).toBe('black'); + expect(c.undo.usedBy.black).toBe(1); + }); + + test('undo of 2 plies on own turn, decline keeps board, limit enforced, pending cleared by a move', () => { + let s = playAll(make({ undo: '1' }), ['E5', 'E4']); + s = act(s, 'black', { type: 'requestUndo' }); + expect(s.undo.pending).toEqual({ by: 'black', plies: 2 }); + expect(baduk.activePlayers(s)).toEqual(['A', 'B']); + s = act(s, 'white', { type: 'respondUndo', accept: false }); + expect(s.moves).toHaveLength(2); + s = act(s, 'black', { type: 'requestUndo' }); + s = act(s, 'white', { type: 'respondUndo', accept: true }); + expect(s.moves).toHaveLength(0); + expect(s.turn).toBe('black'); + s = play(s, 'E5'); + expect(baduk.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' }); + s = play(s, 'E4'); + s = act(s, 'white', { type: 'requestUndo' }); + s = play(s, 'D5'); + expect(s.undo.pending).toBeNull(); + }); + + test('undo cannot cross a resume from scoring', () => { + let s = playAll(make(), ['E5', 'pass', 'pass']); + s = act(s, 'black', { type: 'resumePlay' }); + expect(baduk.validate(s, 'A', { type: 'requestUndo' }).ok).toBe(false); + }); + + test('draw offer: re-offer gap, accept ends drawn', () => { + let s = play(make(), 'E5'); + s = act(s, 'black', { type: 'offerDraw' }); + s = act(s, 'white', { type: 'respondDraw', accept: false }); + expect(baduk.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false); + s = playAll(s, ['A1', 'A3', 'A5', 'A7', 'A9', 'C1', 'C3', 'C5', 'C7', 'C9']); + expect(baduk.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true); + s = act(s, 'black', { type: 'offerDraw' }); + s = act(s, 'white', { type: 'respondDraw', accept: true }); + expect(baduk.result(s)).toMatchObject({ reason: 'draw-agreed', ranking: [['A', 'B']] }); + }); + + test('resign in play and in scoring', () => { + const s = act(make(), 'white', { type: 'resign' }); + expect(s.result).toMatchObject({ winner: 'black', reason: 'resign', summaryKo: '흑 불계승' }); + const t = act(playAll(make(), ['pass', 'pass']), 'black', { type: 'resign' }); + expect(baduk.result(t)?.ranking).toEqual([['B'], ['A']]); + }); + + test('wrong phase / not your turn / outsider', () => { + const s = make(); + expect(baduk.validate(s, 'B', { type: 'place', x: 0, y: 0 }).ok).toBe(false); + expect(baduk.validate(s, 'Z', { type: 'resign' }).ok).toBe(false); + expect(baduk.validate(s, 'A', { type: 'agreeScore' }).ok).toBe(false); + expect(baduk.validate(s, 'A', { type: 'place', x: 9, y: 0 }).ok).toBe(false); + const sc = playAll(make(), ['pass', 'pass']); + expect(baduk.validate(sc, 'A', { type: 'place', x: 0, y: 0 }).ok).toBe(false); + expect(baduk.validate(sc, 'A', { type: 'toggleDead', x: 0, y: 0 }).ok).toBe(false); + }); +}); + +describe('views and determinism', () => { + test('no hidden internals in any view; mover-only hints', () => { + let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white'); + s = play(s, 'B2'); // black B1/A2... put black stones in atari + for (const viewer of ['A', 'B', null]) { + const json = JSON.stringify(baduk.view(s, viewer)); + expect(json).not.toContain('positionHistory'); + expect(json).not.toContain('situationCounts'); + expect(json).not.toContain('rng'); + } + const mover = baduk.view(s, 'A'); + expect(mover.atariWarnings.length).toBeGreaterThan(0); + expect(mover.illegalPoints.length).toBeGreaterThan(0); + for (const viewer of ['B', null]) { + const v = baduk.view(s, viewer); + expect(v.illegalPoints).toEqual([]); + expect(v.atariWarnings).toEqual([]); + } + expect(baduk.view(make({ atariWarning: false }, { black: ['B1'], white: ['A1', 'C1'] }), 'A').atariWarnings).toEqual([]); + }); + + test('nigiri is seeded and deterministic', () => { + const options = baduk.optionsSchema.parse({}); + const colors = Array.from({ length: 16 }, (_, i) => baduk.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed(seedN(i)), now: 0 }).players.black); + expect(new Set(colors).size).toBe(2); + const again = Array.from({ length: 16 }, (_, i) => baduk.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed(seedN(i)), now: 0 }).players.black); + expect(again).toEqual(colors); + }); + + test('default options', () => { + expect(baduk.defaultOptions).toMatchObject({ boardSize: 19, scoring: 'territory', handicap: 0, koRule: 'positionalSuperko', undo: '3' }); + expect(baduk.setup({ players: ['A', 'B'], options: baduk.defaultOptions, rng: rng(), now: 0 }).komi).toBe(6.5); + }); + + const variants: [string, Partial, number][] = [ + ['9 territory', { boardSize: 9 }, 12], + ['9 area handicap', { boardSize: 9, scoring: 'area', handicap: 3 }, 6], + ['9 korean', { boardSize: 9, koRule: 'korean' }, 6], + ['13', { boardSize: 13 }, 3], + ['19', { boardSize: 19, timeControl: { kind: 'none', mainSecAuto: true, mainSec: 0, periodSec: 30, periods: 3, incSec: 0 } }, 1], + ]; + for (const [name, opts, n] of variants) { + test(`random playouts finish, are deterministic and fast (${name})`, () => { + for (let i = 0; i < n; i++) { + const options = badukOptionsSchema.parse({ timeControl: { kind: 'none' }, ...opts }); + const a = randomPlayout(baduk, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 4000, maxApplyMs: 30 }); + const b = randomPlayout(baduk, { players: ['A', 'B'], options, seed: seedN(i), maxSteps: 4000, maxApplyMs: 30 }); + expect(a.finished).toBe(true); + expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState)); + // Replay the trace from scratch → same final state. + const st = a.finalState as BadukState; + expect(baduk.result(st)).not.toBeNull(); + } + }); + } +}); diff --git a/packages/games/src/baduk/board.ts b/packages/games/src/baduk/board.ts new file mode 100644 index 0000000..fdf8af5 --- /dev/null +++ b/packages/games/src/baduk/board.ts @@ -0,0 +1,539 @@ +/** + * 바둑 규칙 핵심 (docs/games/baduk.md §5–6): 연결·활로, 착수 시도(따내기·자충), Zobrist 해시, + * 계가(집/영역), 사석·빅 자동 제안. + * 판은 y*size+x 평면 배열, 0 빈칸 · 1 흑 · 2 백. + * 착수 판정은 놓는 점에 맞닿은 연결만 flood fill 하므로 판 전체를 훑지 않는다. + */ +export type Cell = 0 | 1 | 2; +export type Stone = 1 | 2; +export const oppOf = (c: Stone): Stone => (c === 1 ? 2 : 1); + +// ------------------------------------------------------------------ neighbours + +/** nb[i*5] = count, nb[i*5+1..] = neighbour indices. Cached per board size. */ +const NB_CACHE = new Map(); +export function neighbours(size: number): Int16Array { + let nb = NB_CACHE.get(size); + if (nb) return nb; + nb = new Int16Array(size * size * 5); + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) { + const i = y * size + x; + let k = 0; + if (x > 0) nb[i * 5 + 1 + k++] = i - 1; + if (x < size - 1) nb[i * 5 + 1 + k++] = i + 1; + if (y > 0) nb[i * 5 + 1 + k++] = i - size; + if (y < size - 1) nb[i * 5 + 1 + k++] = i + size; + nb[i * 5] = k; + } + NB_CACHE.set(size, nb); + return nb; +} + +// Generation-stamped scratch buffers (max 19×19) so flood fills don't allocate. +const MAX = 19 * 19; +const stoneMark = new Int32Array(MAX); +const libMark = new Int32Array(MAX); +const stack = new Int32Array(MAX); +let gen = 0; +function nextGen(): number { + gen++; + if (gen > 0x3fffffff) { + stoneMark.fill(0); + libMark.fill(0); + gen = 1; + } + return gen; +} + +export interface Chain { + color: Stone; + stones: number[]; + libs: number[]; +} + +/** The chain (연결) containing the stone at i with its distinct liberties. */ +export function chainAt(board: readonly Cell[], size: number, i: number): Chain { + const color = board[i] as Stone; + const nb = neighbours(size); + const g = nextGen(); + const stones: number[] = []; + const libs: number[] = []; + let sp = 0; + stack[sp++] = i; + stoneMark[i] = g; + while (sp > 0) { + const p = stack[--sp]!; + stones.push(p); + const n = nb[p * 5]!; + for (let k = 1; k <= n; k++) { + const q = nb[p * 5 + k]!; + const v = board[q]; + if (v === color) { + if (stoneMark[q] !== g) { + stoneMark[q] = g; + stack[sp++] = q; + } + } else if (v === 0 && libMark[q] !== g) { + libMark[q] = g; + libs.push(q); + } + } + } + return { color, stones, libs }; +} + +// ------------------------------------------------------------------ zobrist + +/** Fixed constant table (independent of the game RNG): 2 × uint32 per (point, colour). */ +const Z = (() => { + const t = new Uint32Array(MAX * 2 * 2); + let s = 0x9e3779b9; + for (let k = 0; k < t.length; k++) { + // splitmix32 + s = (s + 0x9e3779b9) >>> 0; + let z = s; + z = Math.imul(z ^ (z >>> 16), 0x85ebca6b) >>> 0; + z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35) >>> 0; + t[k] = (z ^ (z >>> 16)) >>> 0; + } + return t; +})(); + +export interface Hash { + hi: number; + lo: number; +} +export const EMPTY_HASH = '0000000000000000'; +export const parseHash = (h: string): Hash => ({ hi: parseInt(h.slice(0, 8), 16) >>> 0, lo: parseInt(h.slice(8), 16) >>> 0 }); +export const hashStr = (h: Hash): string => h.hi.toString(16).padStart(8, '0') + h.lo.toString(16).padStart(8, '0'); +/** XOR the stone (i, c) into h in place. */ +export function xorStone(h: Hash, i: number, c: Stone): void { + const k = (i * 2 + c - 1) * 2; + h.hi = (h.hi ^ Z[k]!) >>> 0; + h.lo = (h.lo ^ Z[k + 1]!) >>> 0; +} +export function hashBoard(board: readonly Cell[]): string { + const h = { hi: 0, lo: 0 }; + for (let i = 0; i < board.length; i++) if (board[i]) xorStone(h, i, board[i] as Stone); + return hashStr(h); +} + +// ------------------------------------------------------------------ moves + +export type TryResult = { ok: true; captured: number[]; hash: string } | { ok: false; reason: 'occupied' | 'suicide' }; + +/** + * Evaluate placing colour c at empty point i without mutating the board (§5.2 steps 1–4). + * Only the chains touching i are flood-filled. + */ +export function tryPlace(board: readonly Cell[], size: number, i: number, c: Stone, hash: string): TryResult { + if (board[i] !== 0) return { ok: false, reason: 'occupied' }; + const nb = neighbours(size); + const n = nb[i * 5]!; + const o = oppOf(c); + const captured: number[] = []; + const seen = new Set(); + for (let k = 1; k <= n; k++) { + const q = nb[i * 5 + k]!; + if (board[q] !== o || seen.has(q)) continue; + const ch = chainAt(board, size, q); + for (const s of ch.stones) seen.add(s); + if (ch.libs.length === 1) captured.push(...ch.stones); // its only liberty is i + } + if (captured.length === 0) { + let alive = false; + for (let k = 1; k <= n && !alive; k++) { + const q = nb[i * 5 + k]!; + if (board[q] === 0) alive = true; + else if (board[q] === c && chainAt(board, size, q).libs.length > 1) alive = true; + } + if (!alive) return { ok: false, reason: 'suicide' }; + } + const h = parseHash(hash); + xorStone(h, i, c); + for (const s of captured) xorStone(h, s, o); + return { ok: true, captured, hash: hashStr(h) }; +} + +/** Labels every chain on the board once (O(n)); used for legal-point maps and hints. */ +export interface ChainMap { + id: Int32Array; // chain index per point, -1 for empty + chains: (Chain & { hash: Hash })[]; +} +export function mapChains(board: readonly Cell[], size: number): ChainMap { + const id = new Int32Array(size * size).fill(-1); + const chains: ChainMap['chains'] = []; + for (let i = 0; i < board.length; i++) { + if (board[i] === 0 || id[i] !== -1) continue; + const ch = chainAt(board, size, i); + const h = { hi: 0, lo: 0 }; + for (const s of ch.stones) { + id[s] = chains.length; + xorStone(h, s, ch.color); + } + chains.push({ ...ch, hash: h }); + } + return { id, chains }; +} + +export type IllegalReason = 'suicide' | 'ko' | 'superko'; + +/** + * Every empty point the colour c may not play, with the reason. One chain labelling pass, + * then O(1) per point (§8.3 illegalPoints). + * - koPoint: simple-ko ban (koRule=korean). + * - history: earlier positions (positionalSuperko); `koHash` = position before the opponent's + * last move, so a repeat of it is reported as a plain ko. + */ +export function illegalPoints( + board: readonly Cell[], + size: number, + c: Stone, + hash: string, + opts: { koPoint: number | null; history: ReadonlySet | null; koHash: string | null }, +): Map { + const { id, chains } = mapChains(board, size); + const nb = neighbours(size); + const o = oppOf(c); + const out = new Map(); + const base = parseHash(hash); + for (let i = 0; i < board.length; i++) { + if (board[i] !== 0) continue; + if (opts.koPoint === i) { + out.set(i, 'ko'); + continue; + } + const n = nb[i * 5]!; + let hasEmpty = false; + let ownSafe = false; + const capIds: number[] = []; + for (let k = 1; k <= n; k++) { + const q = nb[i * 5 + k]!; + const v = board[q]; + if (v === 0) hasEmpty = true; + else { + const ch = chains[id[q]!]!; + if (v === o) { + if (ch.libs.length === 1 && !capIds.includes(id[q]!)) capIds.push(id[q]!); + } else if (ch.libs.length > 1) ownSafe = true; + } + } + if (capIds.length === 0 && !hasEmpty && !ownSafe) { + out.set(i, 'suicide'); + continue; + } + if (opts.history) { + const h = { hi: base.hi, lo: base.lo }; + xorStone(h, i, c); + for (const ci of capIds) { + const ch = chains[ci]!; + h.hi = (h.hi ^ ch.hash.hi) >>> 0; + h.lo = (h.lo ^ ch.hash.lo) >>> 0; + } + const hs = hashStr(h); + if (opts.history.has(hs)) out.set(i, hs === opts.koHash ? 'ko' : 'superko'); + } + } + return out; +} + +/** Stones of colour c that are in atari (chains with exactly one liberty). */ +export function atariStones(board: readonly Cell[], size: number, c: Stone): number[] { + const { chains } = mapChains(board, size); + return chains.filter((ch) => ch.color === c && ch.libs.length === 1).flatMap((ch) => ch.stones); +} + +// ------------------------------------------------------------------ scoring + +export interface Regions { + /** Region index per point, -1 for (alive) stones. */ + id: Int32Array; + /** Per region: points and bordering colours bitmask (1 = black, 2 = white). */ + list: { points: number[]; border: number }[]; +} + +/** Flood-fills the points for which `open(i)` is true into regions (§6.1 step 2–3). */ +export function regionsOf(board: readonly Cell[], size: number, open: (i: number) => boolean): Regions { + const nb = neighbours(size); + const id = new Int32Array(size * size).fill(-1); + const list: Regions['list'] = []; + for (let i = 0; i < board.length; i++) { + if (id[i] !== -1 || !open(i)) continue; + const r = list.length; + const points: number[] = []; + let border = 0; + let sp = 0; + stack[sp++] = i; + id[i] = r; + while (sp > 0) { + const p = stack[--sp]!; + points.push(p); + const n = nb[p * 5]!; + for (let k = 1; k <= n; k++) { + const q = nb[p * 5 + k]!; + if (open(q)) { + if (id[q] === -1) { + id[q] = r; + stack[sp++] = q; + } + } else border |= board[q]!; + } + } + list.push({ points, border }); + } + return { id, list }; +} + +export interface ScoreInput { + board: readonly Cell[]; + size: number; + dead: ReadonlySet; + /** Any point inside a seki-marked region (the region is found after removing dead stones). */ + seki: readonly number[]; + method: 'territory' | 'area'; + captures: { black: number; white: number }; + komi: number; + handicapComp: number; +} + +export interface ScoreBreakdown { + method: 'territory' | 'area'; + black: { territory: number; captures: number; stones: number; total: number }; + white: { territory: number; captures: number; stones: number; komi: number; handicapComp: number; total: number }; + /** Positive: black ahead. */ + diff: number; + /** Per point: 0 neutral, 1 black, 2 white. */ + ownership: Cell[]; +} + +/** §6: remove agreed dead stones, split empty points into regions, count territory or area. */ +export function score(inp: ScoreInput): ScoreBreakdown { + const { board, size, dead, method } = inp; + const open = (i: number) => board[i] === 0 || dead.has(i); + const reg = regionsOf(board, size, open); + const sekiRegions = new Set(inp.seki.map((i) => reg.id[i]!).filter((r) => r >= 0)); + const ownership = new Array(board.length).fill(0); + let deadBlack = 0; + let deadWhite = 0; + let stonesB = 0; + let stonesW = 0; + let terrB = 0; + let terrW = 0; + for (let i = 0; i < board.length; i++) { + const v = board[i]!; + if (v !== 0 && !dead.has(i)) { + ownership[i] = v; + if (v === 1) stonesB++; + else stonesW++; + continue; + } + if (v === 1) deadBlack++; + else if (v === 2) deadWhite++; + const r = reg.id[i]!; + const border = reg.list[r]!.border; + if (border !== 1 && border !== 2) continue; // dame (both) or no stones at all + if (method === 'territory' && sekiRegions.has(r)) continue; // 빅 안의 집은 세지 않음 + ownership[i] = border as Cell; + if (border === 1) terrB++; + else terrW++; + } + const hc = inp.handicapComp; + let black: ScoreBreakdown['black']; + let white: ScoreBreakdown['white']; + if (method === 'territory') { + const capB = inp.captures.black + deadWhite; + const capW = inp.captures.white + deadBlack; + black = { territory: terrB, captures: capB, stones: stonesB, total: terrB + capB }; + white = { territory: terrW, captures: capW, stones: stonesW, komi: inp.komi, handicapComp: 0, total: terrW + capW + inp.komi }; + } else { + black = { territory: terrB, captures: inp.captures.black, stones: stonesB, total: terrB + stonesB }; + white = { territory: terrW, captures: inp.captures.white, stones: stonesW, komi: inp.komi, handicapComp: hc, total: terrW + stonesW + inp.komi + hc }; + } + return { method, black, white, diff: black.total - white.total, ownership }; +} + +/** The 'group' toggled in the dead-stone phase (§5.5-2): same-colour stones linked through + * empty points or opponent stones already marked dead. Empty points touching a live opponent + * stone are not crossed, so one click never leaks out of an enclosed area into the open board. */ +export function markGroup(board: readonly Cell[], size: number, i: number, dead: ReadonlySet): number[] { + const c = board[i] as Stone; + const o = oppOf(c); + const nb = neighbours(size); + const g = nextGen(); + const out: number[] = []; + const passable = (q: number) => { + const v = board[q]; + if (v === c) return true; + if (v === o) return dead.has(q); + const n = nb[q * 5]!; + for (let k = 1; k <= n; k++) { + const r = nb[q * 5 + k]!; + if (board[r] === o && !dead.has(r)) return false; + } + return true; + }; + let sp = 0; + stack[sp++] = i; + stoneMark[i] = g; + while (sp > 0) { + const p = stack[--sp]!; + if (board[p] === c) out.push(p); + const n = nb[p * 5]!; + for (let k = 1; k <= n; k++) { + const q = nb[p * 5 + k]!; + if (stoneMark[q] !== g && passable(q)) { + stoneMark[q] = g; + stack[sp++] = q; + } + } + } + return out.sort((a, b) => a - b); +} + +/** Empty region (dead stones count as empty) containing point i; returns its points. */ +export function emptyRegionAt(board: readonly Cell[], size: number, i: number, dead: ReadonlySet): number[] { + const reg = regionsOf(board, size, (q) => board[q] === 0 || dead.has(q)); + const r = reg.id[i]!; + return r < 0 ? [] : reg.list[r]!.points; +} + +/** + * Deterministic dead-stone / seki suggestion (§5.5 heuristic, §5.6). Only a hint: players fix it. + * (a) a chain in atari whose capture leaves the capturing stone with 2+ liberties (no recapture); + * (b) for each colour, components of (empty ∪ own stones) without two eyes, enclosed by a larger + * or two-eyed opponent component, are dead. Repeated until stable. + * Seki: an own-colour eye whose surrounding chain has only liberties shared with a live + * opponent chain, where neither side has two eyes. + */ +export function suggestMarks(board: readonly Cell[], size: number): { dead: number[]; seki: number[] } { + const dead = new Set(); + const nb = neighbours(size); + // (a) + const { id, chains } = mapChains(board, size); + for (const ch of chains) { + if (ch.libs.length !== 1) continue; + const L = ch.libs[0]!; + const o = oppOf(ch.color); + const r = tryPlace(board, size, L, o, EMPTY_HASH); + if (!r.ok) continue; + const after = board.slice() as Cell[]; + after[L] = o; + for (const s of r.captured) after[s] = 0; + if (chainAt(after, size, L).libs.length >= 2) for (const s of ch.stones) dead.add(s); + } + // (b) + let comps = components(board, size, dead); + /** Seki-like chain: weak, and every liberty is an own eye point or shared with a weak opponent chain. */ + const sekiLike = (ci: number): boolean => { + const ch = chains[ci]!; + if (comps.list[comps.of[ch.stones[0]!]!]!.eyes >= 2) return false; + const o = oppOf(ch.color); + let shared = 0; + for (const l of ch.libs) { + if (comps.reg.list[comps.reg.id[l]!]!.border === ch.color) continue; + let touches = false; + const m = nb[l * 5]!; + for (let j = 1; j <= m && !touches; j++) { + const t = nb[l * 5 + j]!; + if (board[t] === o && !dead.has(t) && comps.list[comps.of[t]!]!.eyes < 2) touches = true; + } + if (!touches) return false; + shared++; + } + return shared > 0; + }; + for (let iter = 0; iter < 50; iter++) { + let changed = false; + for (const k of comps.list) { + // Only areas up to half the board count as "enclosed" (open boards are not judged). + if (k.eyes >= 2 || k.size * 2 > size * size) continue; + let enclosed = k.opp.size > 0; + for (const ko of k.opp) { + const other = comps.list[ko]!; + if (!(other.eyes >= 2 || other.size > k.size)) enclosed = false; + } + if (!enclosed || k.stones.some((st) => sekiLike(id[st]!))) continue; + for (const st of k.stones) dead.add(st); + changed = true; + } + if (!changed) break; + comps = components(board, size, dead); + } + // Seki: an own-colour eye region next to a seki-like chain. + const seki: number[] = []; + for (const r of comps.reg.list) { + if (r.border !== 1 && r.border !== 2) continue; + const isSeki = r.points.some((p) => { + const m = nb[p * 5]!; + for (let j = 1; j <= m; j++) { + const q = nb[p * 5 + j]!; + if (board[q] !== 0 && !dead.has(q) && sekiLike(id[q]!)) return true; + } + return false; + }); + if (isSeki) seki.push(Math.min(...r.points)); + } + return { dead: [...dead].sort((a, b) => a - b), seki }; +} + +interface Component { + color: Stone; + size: number; + stones: number[]; + eyes: number; + opp: Set; + oppStones: number[]; +} + +/** For each colour: connected components of (empty ∪ dead ∪ own live stones). */ +function components(board: readonly Cell[], size: number, dead: ReadonlySet) { + const nb = neighbours(size); + const n = size * size; + const isOpen = (i: number) => board[i] === 0 || dead.has(i); + // Eye regions: open regions bordered only by one colour's live stones. + const reg = regionsOf(board, size, isOpen); + const of = new Int32Array(n).fill(-1); // component per live stone + const list: Component[] = []; + const ofColor: Int32Array[] = [new Int32Array(n).fill(-1), new Int32Array(n).fill(-1)]; + for (const c of [1, 2] as Stone[]) { + const lab = ofColor[c - 1]!; + for (let i = 0; i < n; i++) { + if (lab[i] !== -1 || !(board[i] === c && !dead.has(i))) continue; + const k = list.length; + const comp: Component = { color: c, size: 0, stones: [], eyes: 0, opp: new Set(), oppStones: [] }; + const eyeRegions = new Set(); + let sp = 0; + stack[sp++] = i; + lab[i] = k; + while (sp > 0) { + const p = stack[--sp]!; + comp.size++; + if (isOpen(p)) { + const r = reg.id[p]!; + if (reg.list[r]!.border === c) eyeRegions.add(r); + } else { + comp.stones.push(p); + of[p] = k; + } + const m = nb[p * 5]!; + for (let j = 1; j <= m; j++) { + const q = nb[p * 5 + j]!; + if (lab[q] !== -1) continue; + if (board[q] === oppOf(c) && !dead.has(q)) comp.oppStones.push(q); + else if (isOpen(q) || board[q] === c) { + lab[q] = k; + stack[sp++] = q; + } + } + } + for (const r of eyeRegions) comp.eyes += reg.list[r]!.points.length >= 6 ? 2 : 1; + list.push(comp); + } + } + // Opponent components whose live stones touch each component. + for (const comp of list) { + for (const q of comp.oppStones) comp.opp.add(of[q]!); + } + return { list, of, reg }; +} diff --git a/packages/games/src/baduk/index.ts b/packages/games/src/baduk/index.ts new file mode 100644 index 0000000..01ebdb7 --- /dev/null +++ b/packages/games/src/baduk/index.ts @@ -0,0 +1,883 @@ +/** 바둑 (docs/games/baduk.md). */ +import { z } from 'zod'; +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; +import { + atariStones, + chainAt, + emptyRegionAt, + hashBoard, + illegalPoints, + markGroup, + neighbours, + regionsOf, + score, + suggestMarks, + tryPlace, + type Cell, + type IllegalReason, + type ScoreBreakdown, + type Stone, +} from './board'; + +export type { Cell, IllegalReason, ScoreBreakdown } from './board'; + +export type Color = 'black' | 'white'; +const STONE: Record = { black: 1, white: 2 }; +const other = (c: Color): Color => (c === 'black' ? 'white' : 'black'); +const COLOR_KO: Record = { black: '흑', white: '백' }; + +const HANDICAP_LABELS = { 0: '없음(맞바둑)', 2: '2점', 3: '3점', 4: '4점', 5: '5점', 6: '6점', 7: '7점', 8: '8점', 9: '9점' }; + +export const badukOptionsSchema = z + .object({ + boardSize: z + .union([z.literal(9), z.literal(13), z.literal(19)]) + .default(19) + .meta({ title: '판 크기', labels: { 9: '9줄(처음이면 추천)', 13: '13줄', 19: '19줄' } }), + scoring: z + .enum(['territory', 'area']) + .default('territory') + .meta({ title: '계가 방식', labels: { territory: '집 계산(한국·일본식)', area: '영역 계산(중국식)' } }), + komiAuto: z.boolean().default(true).meta({ title: '덤 자동(맞바둑 6.5집 · 중국식 7.5점 · 접바둑 0.5)' }), + komi: z.number().min(-10).max(10).multipleOf(0.5).default(6.5).meta({ title: '덤(자동을 끈 경우, 0.5 단위)' }), + handicap: z + .union([z.literal(0), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7), z.literal(8), z.literal(9)]) + .default(0) + .meta({ title: '접바둑 치석(9줄은 5점까지)', labels: HANDICAP_LABELS }), + koRule: z + .enum(['positionalSuperko', 'korean']) + .default('positionalSuperko') + .meta({ title: '패 규칙', labels: { positionalSuperko: '동형 반복 금지', korean: '한국식(단순 패 금지, 같은 국면 3번이면 무승부)' } }), + colorAssignment: z + .enum(['nigiri', 'hostBlack', 'hostWhite']) + .default('nigiri') + .meta({ title: '흑백 정하기', labels: { nigiri: '무작위(돌 가리기)', hostBlack: '방장이 흑(접바둑이면 하수)', hostWhite: '방장이 백(접바둑이면 상수)' } }), + timeControl: z + .object({ + kind: z + .enum(['none', 'byoyomi', 'fischer']) + .default('byoyomi') + .meta({ title: '시간 방식', labels: { none: '제한 없음', byoyomi: '기본 시간 + 초읽기', fischer: '기본 시간 + 수당 추가' } }), + mainSecAuto: z.boolean().default(true).meta({ title: '기본 시간 자동(9줄 3분 · 13줄 10분 · 19줄 20분)' }), + mainSec: z.number().int().min(0).max(3600).default(1200).meta({ title: '기본 시간(초, 자동을 끈 경우)' }), + periodSec: z + .union([z.literal(10), z.literal(20), z.literal(30), z.literal(60)]) + .default(30) + .meta({ title: '초읽기 한 번(초)', labels: { 10: '10초', 20: '20초', 30: '30초', 60: '60초' } }), + periods: z.number().int().min(1).max(5).default(3).meta({ title: '초읽기 횟수' }), + incSec: z.number().int().min(0).max(30).default(10).meta({ title: '한 수마다 추가(초, 피셔)' }), + }) + .default({ kind: 'byoyomi', mainSecAuto: true, mainSec: 1200, periodSec: 30, periods: 3, incSec: 10 }) + .meta({ title: '시간 제한' }), + idleLimitSec: z + .union([z.literal(300), z.literal(600)]) + .default(600) + .meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }), + 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(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), + atariWarning: z.boolean().default(true).meta({ title: '단수 경고(내 돌이 단수에 몰리면 표시)' }), + scoringDeadlineSec: z + .union([z.literal(120), z.literal(180), z.literal(300)]) + .default(180) + .meta({ title: '사석 확인 제한 시간', labels: { 120: '2분', 180: '3분', 300: '5분' } }), + }) + .superRefine((o, ctx) => { + if (o.boardSize === 9 && o.handicap > 5) ctx.addIssue({ code: 'custom', path: ['handicap'], message: '9줄 판은 접바둑 5점까지만 둘 수 있어요.' }); + }); +export type BadukOptions = z.infer; + +const coord = z.number().int().min(0).max(18); +export const badukActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('place'), x: coord, y: coord }), + z.object({ type: z.literal('pass') }), + z.object({ type: z.literal('toggleDead'), x: coord, y: coord }), + z.object({ type: z.literal('toggleSeki'), x: coord, y: coord }), + z.object({ type: z.literal('agreeScore') }), + z.object({ type: z.literal('resumePlay') }), + 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 BadukAction = z.infer | { type: 'timeout' }; + +export interface Pt { + x: number; + y: number; +} + +export type BadukMove = { kind: 'place'; color: Color; pt: Pt; captured: Pt[] } | { kind: 'pass'; color: Color }; +export type BadukReason = 'score' | 'jigo' | 'resign' | 'timeLoss' | 'drawAgreed' | 'repetition'; + +export interface BadukScore extends ScoreBreakdown { + /** Winner's margin (e.g. 2.5); 0 for jigo. */ + margin: number; +} + +export interface BadukResult { + winner: Color | null; + reason: BadukReason; + score?: BadukScore; + summaryKo: string; +} + +export interface BadukState { + options: BadukOptions; + size: 9 | 13 | 19; + komi: number; + handicap: number; + board: Cell[]; + players: Record; + turn: Color; + phase: 'playing' | 'scoring' | 'finished'; + /** Stones each colour captured during play. */ + captures: Record; + consecutivePasses: number; + moves: BadukMove[]; + /** Zobrist hash (16 hex chars) of the current arrangement. */ + hash: string; + /** Hashes of every arrangement so far, initial position first (superko; replayable from moves). */ + positionHistory: string[]; + /** koRule=korean: (arrangement + side to move) occurrence counts. */ + situationCounts: Record; + /** koRule=korean: simple-ko banned point for the side to move. */ + koPoint: Pt | null; + /** moves.length when play last resumed from the scoring phase (undo can't cross it). */ + lastResumeAt: number; + scoringMarks: { + dead: number[]; + /** One point per seki-marked empty region (regions are found after removing dead stones). */ + seki: number[]; + agreed: Record; + startedAt: number; + } | null; + clock: { + mainMs: Record; + periodsLeft: Record; + inByoyomi: Record; + turnStartedAt: number; + }; + undo: { usedBy: Record; pending: { by: Color; plies: 1 | 2 } | null }; + draw: { pendingBy: Color | null; lastOfferMove: Record }; + result: BadukResult | null; +} + +export interface BadukView { + size: number; + board: Cell[]; + players: Record; + myColor: Color | null; + turn: Color; + phase: BadukState['phase']; + rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number; atariWarning: boolean }; + captures: Record; + moveCount: number; + moves: { color: Color; pt: Pt | null; captured: number }[]; + lastMove: Pt | null; + /** Stones removed by the last move (for the capture animation). */ + lastCaptured: Pt[]; + consecutivePasses: number; + clock: { + kind: BadukOptions['timeControl']['kind']; + mainSec: number; + periodSec: number; + periods: number; + incSec: number; + idleLimitSec: number; + mainMs: Record; + periodsLeft: Record; + inByoyomi: Record; + turnStartedAt: number; + }; + undo: { allowed: boolean; remaining: Record; pending: { by: Color; plies: number } | null }; + draw: { allowed: boolean; pendingBy: Color | null }; + /** Only for the player to move. */ + illegalPoints: (Pt & { reason: IllegalReason })[]; + /** Only for the player to move (atariWarning option): own stones in atari. */ + atariWarnings: Pt[]; + scoring: { + dead: Pt[]; + /** Every point of seki-marked regions. */ + seki: Pt[]; + agreed: Record; + deadlineAt: number; + durationMs: number; + preview: ScoreBreakdown; + } | null; + result: BadukResult | null; +} + +// ------------------------------------------------------------------ helpers + +const COLS = 'ABCDEFGHJKLMNOPQRST'; +/** "D4" → index, columns skip I, rows count from the bottom. */ +function labelIdx(size: number, label: string): number { + const x = COLS.indexOf(label[0]!); + const y = size - Number(label.slice(1)); + return y * size + x; +} +export const ptLabel = (size: number, p: Pt): string => `${COLS[p.x]}${size - p.y}`; + +/** §4 handicap placement table (fixed, Korean/Japanese convention). */ +export function handicapPoints(size: number, n: number): number[] { + if (n < 2) return []; + const t = + size === 19 + ? { c: ['Q16', 'D4', 'Q4', 'D16'], lr: ['D10', 'Q10'], tb: ['K4', 'K16'], m: 'K10' } + : size === 13 + ? { c: ['K10', 'D4', 'K4', 'D10'], lr: ['D7', 'K7'], tb: ['G4', 'G10'], m: 'G7' } + : { c: ['G7', 'C3', 'G3', 'C7'], lr: [], tb: [], m: 'E5' }; + let labels: string[]; + if (n <= 4) labels = t.c.slice(0, n); + else if (n === 5) labels = [...t.c, t.m]; + else if (n === 6) labels = [...t.c, ...t.lr]; + else if (n === 7) labels = [...t.c, ...t.lr, t.m]; + else if (n === 8) labels = [...t.c, ...t.lr, ...t.tb]; + else labels = [...t.c, ...t.lr, ...t.tb, t.m]; + return labels.map((l) => labelIdx(size, l)); +} + +const DEFAULT_MAIN: Record<9 | 13 | 19, number> = { 9: 180, 13: 600, 19: 1200 }; +export function effectiveKomi(o: BadukOptions): number { + if (!o.komiAuto) return o.komi; + if (o.handicap > 0) return 0.5; + return o.scoring === 'area' ? 7.5 : 6.5; +} +export function effectiveMainSec(o: BadukOptions): number { + return o.timeControl.mainSecAuto ? DEFAULT_MAIN[o.boardSize] : o.timeControl.mainSec; +} +const handicapComp = (s: BadukState) => (s.options.scoring === 'area' && s.handicap > 0 ? s.handicap : 0); + +const toPt = (i: number, size: number): Pt => ({ x: i % size, y: Math.floor(i / size) }); +const undoLimit = (o: BadukOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo)); +const colorOf = (s: BadukState, p: PlayerId): Color | null => + s.players.black === p ? 'black' : s.players.white === p ? 'white' : null; +const situationKey = (hash: string, turn: Color) => hash + (turn === 'black' ? 'b' : 'w'); + +function initialBoard(size: number, handicap: number): Cell[] { + const b = new Array(size * size).fill(0); + for (const i of handicapPoints(size, handicap)) b[i] = 1; + return b; +} + +function pendingResponder(s: BadukState): Color | null { + if (s.undo.pending) return other(s.undo.pending.by); + if (s.draw.pendingBy) return other(s.draw.pendingBy); + return null; +} + +/** Previous arrangement before the opponent's last move — repeating it is a plain ko. */ +function koHashOf(s: BadukState): string | null { + const last = s.moves[s.moves.length - 1]; + return last?.kind === 'place' && s.positionHistory.length >= 2 ? s.positionHistory[s.positionHistory.length - 2]! : null; +} + +function illegalMap(s: BadukState): Map { + const superko = s.options.koRule === 'positionalSuperko'; + const ko = s.koPoint ? s.koPoint.y * s.size + s.koPoint.x : null; + return illegalPoints(s.board, s.size, STONE[s.turn], s.hash, { + koPoint: superko ? null : ko, + history: superko ? new Set(s.positionHistory) : null, + koHash: superko ? koHashOf(s) : null, + }); +} + +const ILLEGAL_MSG: Record = { + suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).', + ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.', + superko: '같은 모양이 반복되어 둘 수 없어요.', +}; + +/** Places a validated stone and updates board, captures, hashes and ko state (no clock/turn). */ +function commitPlace(s: BadukState, color: Color, i: number, captured: number[], hash: string): void { + const size = s.size; + s.board[i] = STONE[color]; + for (const c of captured) s.board[c] = 0; + s.captures[color] += captured.length; + s.hash = hash; + s.positionHistory.push(hash); + s.moves.push({ kind: 'place', color, pt: toPt(i, size), captured: captured.map((c) => toPt(c, size)) }); + s.koPoint = null; + if (captured.length === 1) { + const ch = chainAt(s.board, size, i); + if (ch.stones.length === 1 && ch.libs.length === 1) s.koPoint = toPt(captured[0]!, size); + } + s.consecutivePasses = 0; + s.turn = other(color); +} + +function commitPass(s: BadukState, color: Color): void { + s.moves.push({ kind: 'pass', color }); + s.koPoint = null; + s.consecutivePasses++; + s.turn = other(color); +} + +/** Rebuilds board, captures, superko history, ko point and repetition counts by replaying moves (undo). */ +function replay(s: BadukState, moves: BadukMove[]): void { + s.board = initialBoard(s.size, s.handicap); + s.captures = { black: 0, white: 0 }; + s.hash = hashBoard(s.board); + s.positionHistory = [s.hash]; + s.koPoint = null; + s.moves = []; + s.turn = s.handicap > 0 ? 'white' : 'black'; + s.situationCounts = { [situationKey(s.hash, s.turn)]: 1 }; + for (const m of moves) { + if (m.kind === 'pass') { + commitPass(s, m.color); + continue; + } + const i = m.pt.y * s.size + m.pt.x; + const r = tryPlace(s.board, s.size, i, STONE[m.color], s.hash); + if (!r.ok) throw new Error(`baduk replay: illegal move ${ptLabel(s.size, m.pt)}`); + commitPlace(s, m.color, i, r.captured, r.hash); + const key = situationKey(s.hash, s.turn); + s.situationCounts[key] = (s.situationCounts[key] ?? 0) + 1; + } + let trailing = 0; + for (let k = s.moves.length - 1; k >= s.lastResumeAt && s.moves[k]!.kind === 'pass'; k--) trailing++; + s.consecutivePasses = trailing; +} + +/** + * Charges the mover's clock for the time spent on this turn (§8.2). Returns false when the + * player had already lost on time (the move is then not played). + */ +function chargeClock(s: BadukState, me: Color, now: number): boolean { + const tc = s.options.timeControl; + const elapsed = Math.max(0, now - s.clock.turnStartedAt); + if (tc.kind === 'byoyomi') { + const main = s.clock.mainMs[me]; + if (elapsed <= main) { + s.clock.mainMs[me] = main - elapsed; + return true; + } + const over = elapsed - main; + const periodMs = tc.periodSec * 1000; + const used = Math.ceil(over / periodMs) - 1; // within one period → keep it + s.clock.mainMs[me] = 0; + s.clock.inByoyomi[me] = true; + if (used >= s.clock.periodsLeft[me]) { + s.clock.periodsLeft[me] = 0; + return false; + } + s.clock.periodsLeft[me] -= used; + return true; + } + if (tc.kind === 'fischer') { + const left = s.clock.mainMs[me] - elapsed; + if (left <= 0) { + s.clock.mainMs[me] = 0; + return false; + } + s.clock.mainMs[me] = left + tc.incSec * 1000; + return true; + } + return elapsed <= s.options.idleLimitSec * 1000; +} + +function marginKo(m: number, method: 'territory' | 'area'): string { + if (method === 'area') return `${m}점`; + const whole = Math.floor(m); + const half = m - whole >= 0.5; + if (whole === 0) return half ? '반집' : '0집'; + return `${whole}집${half ? ' 반' : ''}`; +} + +function finish(s: BadukState, winner: Color | null, reason: BadukReason, sc?: BadukScore): void { + s.phase = 'finished'; + s.undo.pending = null; + s.draw.pendingBy = null; + let summaryKo: string; + switch (reason) { + case 'score': + summaryKo = `${COLOR_KO[winner!]} ${marginKo(sc!.margin, sc!.method)} 승`; + break; + case 'jigo': + summaryKo = '빅 (동점 무승부)'; + break; + case 'resign': + summaryKo = `${COLOR_KO[winner!]} 불계승`; + break; + case 'timeLoss': + summaryKo = `${COLOR_KO[winner!]} 시간승`; + break; + case 'drawAgreed': + summaryKo = '합의 무승부'; + break; + case 'repetition': + summaryKo = '같은 국면이 세 번 반복되어 무승부'; + break; + } + s.result = { winner, reason, summaryKo, ...(sc ? { score: sc } : {}) }; +} + +function scoreOf(s: BadukState): ScoreBreakdown { + const m = s.scoringMarks; + return score({ + board: s.board, + size: s.size, + dead: new Set(m?.dead ?? []), + seki: m?.seki ?? [], + method: s.options.scoring, + captures: s.captures, + komi: s.komi, + handicapComp: handicapComp(s), + }); +} + +function finalizeScore(s: BadukState, events: GameEvent[]): void { + const b = scoreOf(s); + const margin = Math.abs(b.diff); + const sc: BadukScore = { ...b, margin }; + if (b.diff === 0) finish(s, null, 'jigo', sc); + else finish(s, b.diff > 0 ? 'black' : 'white', 'score', sc); + events.push({ type: 'gameEnded', reason: s.result!.reason, winner: s.result!.winner }); +} + +function enterScoring(s: BadukState, now: number, events: GameEvent[]): void { + s.phase = 'scoring'; + s.undo.pending = null; + s.draw.pendingBy = null; + const sug = suggestMarks(s.board, s.size); + s.scoringMarks = { dead: sug.dead, seki: sug.seki, agreed: { black: false, white: false }, startedAt: now }; + events.push({ type: 'scoringStarted' }); +} + +function sekiPoints(s: BadukState): number[] { + const m = s.scoringMarks; + if (!m || m.seki.length === 0) return []; + const dead = new Set(m.dead); + const reg = regionsOf(s.board, s.size, (i) => s.board[i] === 0 || dead.has(i)); + const ids = new Set(m.seki.map((i) => reg.id[i]!).filter((r) => r >= 0)); + return [...ids].flatMap((r) => reg.list[r]!.points); +} + +/** Single-point own eyes (every orthogonal neighbour is an own stone) — skipped by random playouts. */ +function isOwnEye(s: BadukState, i: number, c: Stone): boolean { + const nb = neighbours(s.size); + const n = nb[i * 5]!; + for (let k = 1; k <= n; k++) if (s.board[nb[i * 5 + k]!] !== c) return false; + return true; +} + +// ------------------------------------------------------------------ definition + +export const baduk: GameDefinition = { + id: 'baduk', + nameKo: '바둑', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: badukOptionsSchema.parse({}), + optionsSchema: badukOptionsSchema, + actionSchema: badukActionSchema as z.ZodType, + + setup({ players, options, rng, now }) { + const [host, guest] = players as [PlayerId, PlayerId]; + const hostBlack = + options.colorAssignment === 'hostBlack' ? true : options.colorAssignment === 'hostWhite' ? false : rng.int(2) === 0; + const size = options.boardSize; + const handicap = size === 9 ? Math.min(options.handicap, 5) : options.handicap; + const board = initialBoard(size, handicap); + const hash = hashBoard(board); + const turn: Color = handicap > 0 ? 'white' : 'black'; + const tc = options.timeControl; + const main = tc.kind === 'none' ? 0 : effectiveMainSec(options) * 1000; + const periods = tc.kind === 'byoyomi' ? tc.periods : 0; + return { + options, + size, + komi: effectiveKomi(options), + handicap, + board, + players: hostBlack ? { black: host, white: guest } : { black: guest, white: host }, + turn, + phase: 'playing', + captures: { black: 0, white: 0 }, + consecutivePasses: 0, + moves: [], + hash, + positionHistory: [hash], + situationCounts: { [situationKey(hash, turn)]: 1 }, + koPoint: null, + lastResumeAt: 0, + scoringMarks: null, + clock: { + mainMs: { black: main, white: main }, + periodsLeft: { black: periods, white: periods }, + inByoyomi: { black: tc.kind === 'byoyomi' && main === 0, white: tc.kind === 'byoyomi' && main === 0 }, + turnStartedAt: now, + }, + undo: { usedBy: { black: 0, white: 0 }, pending: null }, + draw: { pendingBy: null, lastOfferMove: { black: null, white: null } }, + result: null, + }; + }, + + validate(s, actor, a) { + const me = colorOf(s, actor); + if (!me) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + const playing = s.phase === 'playing'; + switch (a.type) { + case 'place': { + if (!playing) return fail('지금은 돌을 둘 수 없어요.'); + if (s.turn !== me) return fail('지금은 내 차례가 아니에요.'); + if (a.x >= s.size || a.y >= s.size) return fail('판 밖이에요.'); + const i = a.y * s.size + a.x; + if (s.board[i] !== 0) return fail('이미 돌이 있는 자리예요.'); + if (s.options.koRule === 'korean' && s.koPoint && s.koPoint.x === a.x && s.koPoint.y === a.y) return fail(ILLEGAL_MSG.ko); + const r = tryPlace(s.board, s.size, i, STONE[me], s.hash); + if (!r.ok) return fail(ILLEGAL_MSG.suicide); + if (s.options.koRule === 'positionalSuperko' && s.positionHistory.includes(r.hash)) { + return fail(r.hash === koHashOf(s) ? ILLEGAL_MSG.ko : ILLEGAL_MSG.superko); + } + return ok; + } + case 'pass': + if (!playing) return fail('지금은 패스할 수 없어요.'); + if (s.turn !== me) return fail('지금은 내 차례가 아니에요.'); + return ok; + case 'toggleDead': + case 'toggleSeki': { + if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.'); + if (a.x >= s.size || a.y >= s.size) return fail('판 밖이에요.'); + const v = s.board[a.y * s.size + a.x]; + if (a.type === 'toggleDead' && v === 0) return fail('돌이 있는 곳을 눌러 주세요.'); + if (a.type === 'toggleSeki' && v !== 0) return fail('빈 곳을 눌러 주세요.'); + return ok; + } + case 'agreeScore': + if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.'); + if (s.scoringMarks!.agreed[me]) return fail('이미 동의했어요.'); + return ok; + case 'resumePlay': + if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.'); + return ok; + case 'requestUndo': { + if (!playing) return fail('지금은 무를 수 없어요.'); + 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('무르기 횟수를 다 썼어요.'); + const last = s.moves[s.moves.length - 1]; + const plies = last?.color === me ? 1 : 2; + const from = s.moves.length - plies; + if (!last || from < s.lastResumeAt || s.moves[from]?.color !== me) return fail('아직 무를 수가 없어요.'); + return ok; + } + case 'respondUndo': + if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.'); + return ok; + case 'offerDraw': { + if (!playing) return fail('지금은 무승부를 제안할 수 없어요.'); + if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.'); + if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.'); + const last = s.draw.lastOfferMove[me]; + const gap = s.options.drawReofferMoves; + if (last !== null && s.moves.slice(last).filter((m) => 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 (playing && s.turn !== me) return fail('지금은 내 차례가 아니에요.'); + if (s.phase === 'scoring' && s.scoringMarks!.agreed[me]) return fail('이미 동의했어요.'); + return ok; + } + }, + + apply(prev, actor, a, { now }) { + const s = structuredClone(prev); + const me = colorOf(s, actor)!; + const events: GameEvent[] = []; + switch (a.type) { + case 'place': + case 'pass': { + if (!chargeClock(s, me, now)) { + finish(s, other(me), 'timeLoss'); + events.push({ type: 'timeLoss', color: me }, { type: 'gameEnded', reason: 'timeLoss', winner: other(me) }); + break; + } + s.undo.pending = null; + s.draw.pendingBy = null; + s.clock.turnStartedAt = now; + if (a.type === 'pass') { + commitPass(s, me); + events.push({ type: 'pass', color: me }); + if (s.consecutivePasses >= 2) enterScoring(s, now, events); + break; + } + const i = a.y * s.size + a.x; + const r = tryPlace(s.board, s.size, i, STONE[me], s.hash); + if (!r.ok) throw new Error('baduk: illegal place reached apply'); + commitPlace(s, me, i, r.captured, r.hash); + const captured = r.captured.map((c) => toPt(c, s.size)); + events.push({ type: 'stonePlaced', color: me, x: a.x, y: a.y, captured }); + if (captured.length) events.push({ type: 'captured', color: me, count: captured.length }); + const nb = neighbours(s.size); + const oppStone = STONE[other(me)]; + const seen = new Set(); + for (let k = 1; k <= nb[i * 5]!; k++) { + const q = nb[i * 5 + k]!; + if (s.board[q] !== oppStone || seen.has(q)) continue; + const ch = chainAt(s.board, s.size, q); + for (const st of ch.stones) seen.add(st); + if (ch.libs.length === 1) events.push({ type: 'atari', color: other(me), stones: ch.stones.map((st) => toPt(st, s.size)) }); + } + if (s.options.koRule === 'korean') { + const key = situationKey(s.hash, s.turn); + s.situationCounts[key] = (s.situationCounts[key] ?? 0) + 1; + if (s.situationCounts[key]! >= 3) { + finish(s, null, 'repetition'); + events.push({ type: 'gameEnded', reason: 'repetition', winner: null }); + } + } + break; + } + case 'toggleDead': { + const m = s.scoringMarks!; + const dead = new Set(m.dead); + const i = a.y * s.size + a.x; + const group = markGroup(s.board, s.size, i, dead); + const makeDead = !dead.has(i); + for (const g of group) { + if (makeDead) dead.add(g); + else dead.delete(g); + } + m.dead = [...dead].sort((x, y) => x - y); + m.agreed = { black: false, white: false }; + events.push({ type: 'marksChanged', by: me, kind: makeDead ? 'dead' : 'alive', points: group.map((g) => toPt(g, s.size)) }); + break; + } + case 'toggleSeki': { + const m = s.scoringMarks!; + const region = emptyRegionAt(s.board, s.size, a.y * s.size + a.x, new Set(m.dead)); + const inR = new Set(region); + const had = m.seki.some((p) => inR.has(p)); + m.seki = had ? m.seki.filter((p) => !inR.has(p)) : [...m.seki, Math.min(...region)].sort((x, y) => x - y); + m.agreed = { black: false, white: false }; + events.push({ type: 'marksChanged', by: me, kind: had ? 'unseki' : 'seki', points: region.map((g) => toPt(g, s.size)) }); + break; + } + case 'agreeScore': + case 'timeout': + if (s.phase === 'scoring') { + s.scoringMarks!.agreed[me] = true; + events.push({ type: 'scoreAgreed', color: me, auto: a.type === 'timeout' }); + if (s.scoringMarks!.agreed.black && s.scoringMarks!.agreed.white) finalizeScore(s, events); + break; + } + // playing: no auto move in baduk — the player loses on time (§8.5). + if (s.options.timeControl.kind === 'byoyomi') { + s.clock.mainMs[me] = 0; + s.clock.periodsLeft[me] = 0; + } else if (s.options.timeControl.kind === 'fischer') s.clock.mainMs[me] = 0; + finish(s, other(me), 'timeLoss'); + events.push({ type: 'timeLoss', color: me }, { type: 'gameEnded', reason: 'timeLoss', winner: other(me) }); + break; + case 'resumePlay': + s.phase = 'playing'; + s.scoringMarks = null; + s.turn = other(me); + s.consecutivePasses = 0; + s.lastResumeAt = s.moves.length; + s.clock.turnStartedAt = now; + events.push({ type: 'playResumed', by: me, turn: s.turn }); + break; + case 'requestUndo': { + const last = s.moves[s.moves.length - 1]!; + s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 }; + events.push({ type: 'undoRequested', color: me }); + break; + } + case 'respondUndo': { + const { by, plies } = s.undo.pending!; + s.undo.pending = null; + if (a.accept) { + replay(s, s.moves.slice(0, s.moves.length - plies)); + s.turn = by; + s.undo.usedBy[by]++; + s.clock.turnStartedAt = now; + events.push({ type: 'undoAccepted', color: by, plies }); + } else events.push({ type: 'undoDeclined', color: by }); + break; + } + case 'offerDraw': + s.draw.pendingBy = me; + s.draw.lastOfferMove[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' }, { type: 'gameEnded', reason: 'drawAgreed', winner: null }); + } 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 }, { type: 'gameEnded', reason: 'resign', winner: other(me) }); + break; + } + return { state: s, events }; + }, + + view(s, viewer) { + const myColor = viewer ? colorOf(s, viewer) : null; + const limit = undoLimit(s.options); + const tc = s.options.timeControl; + const myTurn = s.phase === 'playing' && myColor !== null && myColor === s.turn; + const last = s.moves[s.moves.length - 1]; + const m = s.scoringMarks; + return { + size: s.size, + board: s.board, + players: s.players, + myColor, + turn: s.turn, + phase: s.phase, + rules: { scoring: s.options.scoring, koRule: s.options.koRule, komi: s.komi, handicap: s.handicap, atariWarning: s.options.atariWarning }, + captures: s.captures, + moveCount: s.moves.length, + moves: s.moves.map((mv) => ({ color: mv.color, pt: mv.kind === 'place' ? mv.pt : null, captured: mv.kind === 'place' ? mv.captured.length : 0 })), + lastMove: last?.kind === 'place' ? last.pt : null, + lastCaptured: last?.kind === 'place' ? last.captured : [], + consecutivePasses: s.consecutivePasses, + clock: { + kind: tc.kind, + mainSec: effectiveMainSec(s.options), + periodSec: tc.periodSec, + periods: tc.periods, + incSec: tc.incSec, + idleLimitSec: s.options.idleLimitSec, + mainMs: s.clock.mainMs, + periodsLeft: s.clock.periodsLeft, + inByoyomi: s.clock.inByoyomi, + 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 }, + illegalPoints: myTurn ? [...illegalMap(s)].map(([i, reason]) => ({ ...toPt(i, s.size), reason })) : [], + atariWarnings: myTurn && s.options.atariWarning ? atariStones(s.board, s.size, STONE[myColor]).map((i) => toPt(i, s.size)) : [], + scoring: m + ? { + dead: m.dead.map((i) => toPt(i, s.size)), + seki: sekiPoints(s).map((i) => toPt(i, s.size)), + agreed: m.agreed, + deadlineAt: m.startedAt + s.options.scoringDeadlineSec * 1000, + durationMs: s.options.scoringDeadlineSec * 1000, + preview: scoreOf(s), + } + : null, + result: s.result, + }; + }, + + activePlayers(s) { + if (s.phase === 'scoring') return (['black', 'white'] as const).filter((c) => !s.scoringMarks!.agreed[c]).map((c) => s.players[c]); + 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 === 'scoring') return s.scoringMarks!.startedAt + s.options.scoringDeadlineSec * 1000; + if (s.phase !== 'playing') return null; + const tc = s.options.timeControl; + const start = s.clock.turnStartedAt; + if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000; + if (tc.kind === 'fischer') return start + s.clock.mainMs[s.turn]; + return start + s.options.idleLimitSec * 1000; + }, + + timeoutPlayers(s) { + if (s.phase === 'scoring') return (['black', 'white'] as const).filter((c) => !s.scoringMarks!.agreed[c]).map((c) => s.players[c]); + 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, summaryKo, score: sc } = s.result; + const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.black, s.players.white]]; + const map: Record = { + score: 'normal', + jigo: 'normal', + repetition: 'normal', + resign: 'resign', + timeLoss: 'timeout', + drawAgreed: 'draw-agreed', + }; + return { + ranking, + summary: summaryKo, + reason: map[reason], + ...(sc ? { scores: { [s.players.black]: sc.black.total, [s.players.white]: sc.white.total } } : {}), + }; + }, + + legalActions(s, p) { + const me = colorOf(s, p); + if (!me || s.phase === 'finished') return []; + if (s.phase === 'scoring') { + if (s.scoringMarks!.agreed[me]) return []; + const out: BadukAction[] = [{ type: 'agreeScore' }, { type: 'agreeScore' }, { type: 'resumePlay' }]; + const stone = s.board.findIndex((v) => v !== 0); + if (stone >= 0) out.push({ type: 'toggleDead', ...toPt(stone, s.size) }); + const empty = s.board.indexOf(0); + if (empty >= 0) out.push({ type: 'toggleSeki', ...toPt(empty, s.size) }); + return out; + } + const responder = pendingResponder(s); + if (responder === me) { + const accept = s.moves.length % 2 === 0; + return s.undo.pending ? [{ type: 'respondUndo', accept }] : [{ type: 'respondDraw', accept: false }]; + } + if (s.turn !== me) return []; + const illegal = illegalMap(s); + const out: BadukAction[] = [{ type: 'pass' }]; + for (let i = 0; i < s.board.length; i++) { + if (s.board[i] === 0 && !illegal.has(i) && !isOwnEye(s, i, STONE[me])) out.push({ type: 'place', ...toPt(i, s.size) }); + } + if (s.moves.length % 37 === 5 && baduk.validate(s, p, { type: 'requestUndo' }).ok) out.push({ type: 'requestUndo' }); + return out; + }, +}; diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 433fed3..eb4fa14 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -3,10 +3,12 @@ import { omok } from './omok'; import { othello } from './othello'; import { chess } from './chess'; import { janggi } from './janggi'; +import { baduk } from './baduk'; export const GAMES: Record = { [omok.id]: omok, [othello.id]: othello, [chess.id]: chess, [janggi.id]: janggi, + [baduk.id]: baduk, };