M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ bun test # 규칙·서버 통합·방 단위 테스트
|
|||||||
(cd apps/web && bunx vite build) # 웹 빌드
|
(cd apps/web && bunx vite build) # 웹 빌드
|
||||||
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||||
|
bun e2e/games.e2e.ts # 오셀로·체스·바둑·장기: 방 만들기 → 친구 입장 → 첫 수
|
||||||
|
bun e2e/a11y.e2e.ts # 접근성(axe) 검사
|
||||||
|
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|||||||
859
apps/web/src/games/baduk/index.tsx
Normal file
859
apps/web/src/games/baduk/index.tsx
Normal file
@@ -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<Color, string> = { 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 (
|
||||||
|
<g opacity={ghost ? 0.5 : 1}>
|
||||||
|
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.05} />
|
||||||
|
{color === 'black' ? (
|
||||||
|
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
|
||||||
|
) : (
|
||||||
|
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkMode = 'dead' | 'seki';
|
||||||
|
|
||||||
|
function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>) {
|
||||||
|
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<MarkMode>('dead');
|
||||||
|
const [preview, setPreview] = useState<Pt | null>(null);
|
||||||
|
const [hover, setHover] = useState<Pt | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<Pt>({ 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<Set<number>>(new Set());
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const prevCount = useRef(v.moveCount);
|
||||||
|
const prevDead = useRef<Set<number> | 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<number>();
|
||||||
|
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<number>();
|
||||||
|
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<SVGSVGElement>): Pt | null => {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) return null;
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = e.clientX;
|
||||||
|
pt.y = e.clientY;
|
||||||
|
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||||
|
const x = Math.round(loc.x);
|
||||||
|
const y = Math.round(loc.y);
|
||||||
|
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
setKbd(true);
|
||||||
|
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||||
|
if (d[e.key]) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
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 (
|
||||||
|
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'block',
|
||||||
|
background: 'var(--wood)',
|
||||||
|
borderRadius: 12,
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
cursor: myTurn || marking ? 'pointer' : 'default',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
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) => (
|
||||||
|
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||||
|
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||||
|
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.36} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||||
|
<text x={i} y={-0.62}>{COLS[i]}</text>
|
||||||
|
<text x={i} y={n - 1 + 0.62}>{COLS[i]}</text>
|
||||||
|
<text x={-0.62} y={i}>{n - i}</text>
|
||||||
|
<text x={n - 1 + 0.62} y={i}>{n - i}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{stars(n).map((p) => (
|
||||||
|
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||||
|
))}
|
||||||
|
{/* Ko / superko points for the mover (suicide points are explained on click only). */}
|
||||||
|
{myTurn &&
|
||||||
|
v.illegalPoints
|
||||||
|
.filter((p) => p.reason !== 'suicide')
|
||||||
|
.map((p) => <rect key={`k${p.x}-${p.y}`} x={p.x - 0.18} y={p.y - 0.18} width={0.36} height={0.36} fill="none" stroke="#b91c1c" strokeWidth={0.07} />)}
|
||||||
|
{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 (
|
||||||
|
<g key={i} opacity={isDead ? 0.45 : 1}>
|
||||||
|
<Stone color={color} cx={x} cy={y} r={0.47} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Stones just captured fade out. */}
|
||||||
|
{v.phase === 'playing' &&
|
||||||
|
v.lastCaptured.map((p) => (
|
||||||
|
<g key={`cap${v.moveCount}-${p.x}-${p.y}`} opacity={0}>
|
||||||
|
<animate attributeName="opacity" from={0.7} to={0} dur="0.6s" fill="freeze" />
|
||||||
|
<Stone color={lastColor ? other(lastColor) : 'white'} cx={p.x} cy={p.y} r={0.47} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{/* 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 <rect key={`o${i}`} x={(i % n) - 0.17} y={Math.floor(i / n) - 0.17} width={0.34} height={0.34} fill={o === 1 ? '#111' : '#fafafa'} stroke={o === 1 ? '#000' : '#555'} strokeWidth={0.03} />;
|
||||||
|
})}
|
||||||
|
{[...seki].map((i) => (
|
||||||
|
<path key={`seki${i}`} d={`M ${i % n} ${Math.floor(i / n) - 0.2} l 0.2 0.2 l -0.2 0.2 l -0.2 -0.2 z`} fill="#f59e0b" opacity={0.9} />
|
||||||
|
))}
|
||||||
|
{[...dead].map((i) => (
|
||||||
|
<g key={`x${i}`} stroke={v.board[i] === 1 ? '#fafafa' : '#111'} strokeWidth={0.08}>
|
||||||
|
<line x1={(i % n) - 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) + 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||||
|
<line x1={(i % n) + 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) - 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{[...changed].map((i) => (
|
||||||
|
<circle key={`ch${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.52} fill="none" stroke="#3b82f6" strokeWidth={0.09}>
|
||||||
|
<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="3" />
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
{v.lastMove && lastColor && (
|
||||||
|
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
|
||||||
|
)}
|
||||||
|
{myTurn &&
|
||||||
|
v.atariWarnings.map((p) => (
|
||||||
|
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
|
||||||
|
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
|
||||||
|
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
|
||||||
|
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||||
|
{bubble && <circle cx={bubble.p.x} cy={bubble.p.y} r={0.48} fill="none" stroke="#dc2626" strokeWidth={0.09} />}
|
||||||
|
</svg>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{announce}
|
||||||
|
</p>
|
||||||
|
{bubble && (
|
||||||
|
<div className="card" role="status" style={{ padding: '8px 12px', border: '2px solid var(--danger)', fontWeight: 600 }}>
|
||||||
|
{label(n, bubble.p)}: {bubble.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{marking && (
|
||||||
|
<div className="card stack" style={{ padding: 12, gap: 8 }}>
|
||||||
|
<div className="segmented" role="group" aria-label="표시 종류">
|
||||||
|
<button type="button" aria-pressed={mode === 'dead'} onClick={() => setMode('dead')}>
|
||||||
|
✕ 사석 표시
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={mode === 'seki'} onClick={() => setMode('seki')}>
|
||||||
|
◆ 빅 표시
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="small" style={{ margin: 0 }}>
|
||||||
|
{mode === 'dead'
|
||||||
|
? '죽은 돌 묶음을 누르면 사석(✕)으로 바뀌고, 다시 누르면 살아 있는 돌로 돌아와요.'
|
||||||
|
: '빅(세키) 안의 빈 곳을 누르면 ◆로 표시돼요. 한국·일본식 계산에서는 빅 안의 집을 세지 않아요.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview && myTurn && (
|
||||||
|
<div className="row" style={{ justifyContent: 'center' }}>
|
||||||
|
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||||
|
{`${label(n, preview)}에 두기`}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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 (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="small muted">
|
||||||
|
<th style={{ textAlign: 'left', fontWeight: 600 }}>{heading ? (t ? '집 계산' : '영역 계산') : ''}</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>● 흑</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>○ 백</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(([k, b, w]) => (
|
||||||
|
<tr key={k} className="small">
|
||||||
|
<td>{k}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{b}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{w}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr style={{ fontWeight: 800, borderTop: '2px solid var(--border)' }}>
|
||||||
|
<td>합계</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{score.black.total}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{score.white.total}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="card stack" style={{ padding: 14, gap: 10 }}>
|
||||||
|
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<strong style={{ fontSize: '1.1rem' }}>사석 확인</strong>
|
||||||
|
<TurnTimer deadline={sc.deadlineAt} totalMs={sc.durationMs} mine={!!mine && !sc.agreed[mine]} size={40} />
|
||||||
|
</div>
|
||||||
|
<p className="small" style={{ margin: 0 }}>
|
||||||
|
대국이 멈췄어요. 판 위의 <strong>죽은 돌</strong>을 눌러 ✕ 표시를 맞춘 뒤 두 사람 모두 <strong>동의</strong>하면 계가가 끝나요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.
|
||||||
|
</p>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
흑 {p.black.total} : 백 {p.white.total}
|
||||||
|
</div>
|
||||||
|
<div className="muted small" style={{ textAlign: 'center' }}>{lead}</div>
|
||||||
|
<ScoreTable score={p} heading />
|
||||||
|
<div className="row small" style={{ justifyContent: 'space-around' }}>
|
||||||
|
<span>● 흑 {sc.agreed.black ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||||
|
<span>○ 백 {sc.agreed.white ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||||
|
</div>
|
||||||
|
{mine && (
|
||||||
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
|
<button className="btn btn-primary btn-block" style={{ fontSize: '1.1rem' }} disabled={pending || sc.agreed[mine]} onClick={() => act({ type: 'agreeScore' })}>
|
||||||
|
{sc.agreed[mine] ? '동의했어요 · 상대를 기다려요' : '✔ 이 표시대로 동의'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-block" disabled={pending} onClick={() => setConfirmResume(true)}>
|
||||||
|
이의 – 대국 재개
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirmResume && (
|
||||||
|
<ConfirmSheet
|
||||||
|
title="대국을 다시 둘까요?"
|
||||||
|
body="표시를 모두 지우고 대국으로 돌아가요. 다시 시작하면 상대가 먼저 둬요."
|
||||||
|
confirm="대국 재개"
|
||||||
|
onConfirm={() => act({ type: 'resumePlay' })}
|
||||||
|
onClose={() => setConfirmResume(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadukSide({ view: v, me, players, act, pending, deadline }: BoardProps<BadukView>) {
|
||||||
|
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass'>(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 (
|
||||||
|
<>
|
||||||
|
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||||
|
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||||
|
{oppPassed && (
|
||||||
|
<div className="card" style={{ padding: 10, border: '2px solid var(--warning)' }}>
|
||||||
|
<strong>상대가 패스했어요.</strong> 끝내려면 나도 패스하세요. 더 둘 곳이 있으면 계속 둬도 돼요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{v.phase === 'scoring' && <ScoringPanel view={v} act={act} pending={pending} />}
|
||||||
|
{v.phase === 'finished' && v.result?.score && (
|
||||||
|
<div className="card stack" style={{ padding: 14, gap: 8 }}>
|
||||||
|
<strong style={{ fontSize: '1.15rem' }}>{v.result.summaryKo}</strong>
|
||||||
|
<ScoreTable score={v.result.score} heading />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{v.phase === 'playing' && v.myColor && (
|
||||||
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
|
<button className="btn btn-secondary btn-block" disabled={!myTurn || pending} onClick={() => setConfirm('pass')}>
|
||||||
|
✋ 패스
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||||
|
{v.undo.allowed && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||||
|
onClick={() => act({ type: 'requestUndo' })}
|
||||||
|
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||||
|
>
|
||||||
|
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{v.draw.allowed && (
|
||||||
|
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||||
|
🤝 무승부
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||||
|
🏳 기권
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{v.phase === 'scoring' && v.myColor && (
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||||
|
🏳 기권
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{outgoing && (
|
||||||
|
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||||
|
<span className="small">상대의 답을 기다리는 중…</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||||
|
요청 취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MoveList view={v} />
|
||||||
|
{incoming && (
|
||||||
|
<Sheet onClose={() => {}} label="상대의 요청">
|
||||||
|
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||||
|
<p className="muted">{incoming === 'undo' ? '수락하면 돌과 따낸 돌이 그 전으로 돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||||
|
수락
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
{confirm === 'pass' && (
|
||||||
|
<ConfirmSheet
|
||||||
|
title="패스할까요?"
|
||||||
|
body={v.consecutivePasses > 0 ? '상대도 패스했으니, 패스하면 대국이 멈추고 사석 확인으로 넘어가요.' : '이번 차례에 돌을 두지 않고 넘겨요.'}
|
||||||
|
confirm="패스하기"
|
||||||
|
onConfirm={() => act({ type: 'pass' })}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirm === 'resign' && (
|
||||||
|
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대의 불계승이에요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||||
|
)}
|
||||||
|
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoveList({ view: v }: { view: BadukView }) {
|
||||||
|
const ref = useRef<HTMLOListElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||||
|
}, [v.moveCount]);
|
||||||
|
if (v.moveCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||||
|
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 150, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{v.moves.map((m, i) => (
|
||||||
|
<li key={i} className="small">
|
||||||
|
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {m.pt ? label(v.size, m.pt) : '패스'}
|
||||||
|
{m.captured ? <span className="muted"> ×{m.captured}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||||
|
const id = v.players[color];
|
||||||
|
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||||
|
const active = v.phase === 'playing' && v.turn === color;
|
||||||
|
const now = useServerNow(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 = <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem' }}>{mmss(main - elapsed)}</span>;
|
||||||
|
} 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 = (
|
||||||
|
<span className="stack" style={{ alignItems: 'flex-end', gap: 2 }}>
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: urgent ? '1.6rem' : '1.15rem', color: urgent ? 'var(--danger)' : undefined }}>
|
||||||
|
{periodSecLeft}초
|
||||||
|
</span>
|
||||||
|
<span aria-label={`초읽기 ${left}번 남음`} style={{ letterSpacing: 2 }}>
|
||||||
|
{Array.from({ length: c.periods }, (_, i) => (
|
||||||
|
<span key={i} style={{ color: i < left ? 'var(--primary)' : 'var(--border)' }}>●</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (c.kind === 'fischer') {
|
||||||
|
const ms = c.mainMs[color] - elapsed;
|
||||||
|
clock = (
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
|
||||||
|
);
|
||||||
|
} else if (active) {
|
||||||
|
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period).
|
||||||
|
const beeped = useRef<number | null>(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 (
|
||||||
|
<div
|
||||||
|
className="card row"
|
||||||
|
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${urgentBorder ? 'var(--danger)' : active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
|
||||||
|
>
|
||||||
|
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
|
||||||
|
<Stone color={color} cx={0} cy={0} r={0.9} />
|
||||||
|
</svg>
|
||||||
|
<Avatar user={u} size={32} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{u.nickname}
|
||||||
|
{mine && <span className="muted small"> (나)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="muted small">
|
||||||
|
{COLOR_KO[color]} · 따낸 돌 {v.captures[color]}
|
||||||
|
{color === 'white' && v.rules.komi ? ` · 덤 ${v.rules.komi}` : ''}
|
||||||
|
{active ? ' · 두는 중' : ''}
|
||||||
|
{v.phase === 'scoring' && v.scoring?.agreed[color] ? ' · 동의함' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{clock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(v: BadukView, _me: string | null, players: Record<string, PublicUser>): 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<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="segmented">
|
||||||
|
{options.map(([k, l]) => (
|
||||||
|
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TC = BadukOptions['timeControl'];
|
||||||
|
const TIME_PRESETS: [string, string, Partial<TC>][] = [
|
||||||
|
['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<number, number> = { 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<BadukOptions>) {
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const set = (patch: Partial<BadukOptions>) => onChange({ ...o, ...patch });
|
||||||
|
const setTc = (patch: Partial<TC>) => 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 (
|
||||||
|
<div className="stack" style={{ gap: 16 }}>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>판 크기</strong>
|
||||||
|
<Seg
|
||||||
|
value={o.boardSize}
|
||||||
|
options={[[9, '9줄 (처음이면 추천)'], [13, '13줄'], [19, '19줄']]}
|
||||||
|
onChange={(boardSize) => set({ boardSize, handicap: boardSize === 9 && o.handicap > 5 ? 5 : o.handicap })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>계가 방식</strong>
|
||||||
|
<Seg value={o.scoring} options={[['territory', '집 계산 (한국·일본식)'], ['area', '영역 계산 (중국식)']]} onChange={(scoring) => set({ scoring })} />
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>접바둑</strong>
|
||||||
|
<Seg value={o.handicap} options={handicaps.map((h) => [h, h === 0 ? '맞바둑' : `${h}점`] as [BadukOptions['handicap'], string])} onChange={(handicap) => set({ handicap })} />
|
||||||
|
{o.handicap > 0 && <span className="muted small">흑(하수)이 치석 {o.handicap}개를 미리 놓고, 백이 먼저 둬요. 덤은 0.5집이에요.</span>}
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>시간</strong>
|
||||||
|
<Seg
|
||||||
|
value={tk}
|
||||||
|
options={[
|
||||||
|
...TIME_PRESETS.map(([k, l]) => [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]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>흑백 정하기</strong>
|
||||||
|
<Seg
|
||||||
|
value={o.colorAssignment}
|
||||||
|
options={[['nigiri', '무작위'], ['hostBlack', o.handicap ? '방장이 흑(하수)' : '방장이 흑'], ['hostWhite', o.handicap ? '방장이 백(상수)' : '방장이 백']]}
|
||||||
|
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||||
|
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||||
|
</button>
|
||||||
|
{more && (
|
||||||
|
<>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>덤 (지금 {komiOf(o)})</strong>
|
||||||
|
<Seg value={o.komiAuto ? 'auto' : 'custom'} options={[['auto', '자동'], ['custom', '직접 정하기']]} onChange={(k) => set({ komiAuto: k === 'auto' })} />
|
||||||
|
{!o.komiAuto && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
type="number"
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
|
step={0.5}
|
||||||
|
value={o.komi}
|
||||||
|
onChange={(e) => {
|
||||||
|
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="덤"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>패 규칙</strong>
|
||||||
|
<Seg value={o.koRule} options={[['positionalSuperko', '같은 모양 반복 금지'], ['korean', '한국식(삼패 등은 무승부)']]} onChange={(koRule) => set({ koRule })} />
|
||||||
|
</div>
|
||||||
|
{o.timeControl.kind === 'byoyomi' && (
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>초읽기</strong>
|
||||||
|
<Seg value={o.timeControl.periodSec} options={[[10, '10초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
|
||||||
|
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>무르기</strong>
|
||||||
|
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>도움 표시</strong>
|
||||||
|
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
|
||||||
|
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>사석 확인 제한 시간</strong>
|
||||||
|
<Seg value={o.scoringDeadlineSec} options={[[120, '2분'], [180, '3분'], [300, '5분']]} onChange={(scoringDeadlineSec) => set({ scoringDeadlineSec })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="stack">
|
||||||
|
<p>흑과 백이 번갈아 판의 <strong>교차점</strong>에 돌을 하나씩 놓아요. 흑이 먼저 둬요(접바둑이면 흑이 치석을 미리 놓고 백이 먼저 둬요). 한 번 놓은 돌은 움직이지 않아요.</p>
|
||||||
|
<p>
|
||||||
|
<strong>따내기</strong>: 돌에 상하좌우로 맞닿은 빈 점을 <strong>활로</strong>라고 해요. 상하좌우로 붙은 같은 색 돌은 한 덩어리로 활로를 함께 써요. 상대 돌 덩어리의 활로를 모두 막으면 그 돌을 판에서 들어내고, 따낸 돌로 세어요. 활로가 하나만 남은 상태를 <strong>단수</strong>라고 해요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>자충수 금지</strong>: 놓는 순간 내 돌의 활로가 없어지는 자리에는 둘 수 없어요. 단, 그 수로 상대 돌을 따내서 활로가 생기면 둘 수 있어요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>패</strong>: 돌 하나를 서로 번갈아 따낼 수 있는 모양에서는, 따인 쪽이 <strong>바로</strong> 되따낼 수 없어요. 다른 곳에 한 수 둔 뒤에 되따낼 수 있어요.
|
||||||
|
{koRule === 'positionalSuperko'
|
||||||
|
? ' 이 방은 판 전체 모양이 이전과 똑같아지는 수를 모두 금지해요(동형 반복 금지).'
|
||||||
|
: ' 이 방은 한국식이라 같은 국면이 세 번 나오면(삼패 등) 무승부로 끝나요.'}
|
||||||
|
{' '}둘 수 없는 패 자리는 빨간 네모로 보여 줘요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>끝내기</strong>: 더 둘 곳이 없으면 <strong>패스</strong>해요. 두 사람이 연달아 패스하면 대국이 멈추고 <strong>사석 확인</strong>을 해요. 판에서 죽은 돌을 눌러 ✕ 표시를 맞추고, 두 사람 모두 [동의]하면 점수가 나와요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 결론을 내요.
|
||||||
|
</p>
|
||||||
|
{scoring === 'territory' ? (
|
||||||
|
<p>
|
||||||
|
<strong>집 계산(한국·일본식)</strong>: 내 돌로만 둘러싼 빈 점(집) + 따낸 돌(사석 포함)이 내 점수예요. 백은 먼저 둔 흑의 이득을 메우려고 <strong>덤</strong>을 더 받아요. 빅(서로 잡을 수 없이 공배를 나눠 사는 모양) 안의 집은 세지 않아요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<strong>영역 계산(중국식)</strong>: 판 위의 살아 있는 내 돌 + 내 돌로만 둘러싼 빈 점이 내 점수예요. 따낸 돌은 세지 않으니 끝내기 전에 공배(양쪽 사이의 빈 점)를 메운 뒤 패스하세요. 백은 덤을 더 받고, 접바둑이면 치석 수만큼 더 받아요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>점수가 높은 쪽이 이겨요. 덤이 0.5 단위면 비기는 일이 없어요. 언제든 기권(불계패)할 수 있어요. 처음이라면 <strong>9줄</strong> 판으로 시작해 보세요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui: GameUI<BadukView, BadukOptions> = {
|
||||||
|
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;
|
||||||
@@ -6,6 +6,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
othello: () => import('./othello'),
|
othello: () => import('./othello'),
|
||||||
chess: () => import('./chess'),
|
chess: () => import('./chess'),
|
||||||
janggi: () => import('./janggi'),
|
janggi: () => import('./janggi'),
|
||||||
|
baduk: () => import('./baduk'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
- 디스코드 로그인 실제 연결(앱 정보가 준비된 경우).
|
- 디스코드 로그인 실제 연결(앱 정보가 준비된 경우).
|
||||||
- 첫 부하 테스트·장애 주입 테스트 + 보고서.
|
- 첫 부하 테스트·장애 주입 테스트 + 보고서.
|
||||||
- 테스트 서버 배포(위치가 정해진 경우).
|
- 테스트 서버 배포(위치가 정해진 경우).
|
||||||
|
- **진행 결과(2026-10-04)**: 네 게임 완료(규칙 테스트 체스 40·장기 46·바둑 50·오셀로 31), 게임 공통 E2E(PC 방장+휴대폰 친구),
|
||||||
|
접근성 axe 위반 0, 부하·장애 주입 통과(`reports/load-20261004.md`). 체스960은 chess.js가 지원하지 않아 후순위(M9).
|
||||||
|
디스코드 실제 연결·테스트 서버 배포는 사용자 정보 대기.
|
||||||
|
|
||||||
## M3. 카드 게임 기반 + 섯다·포커
|
## M3. 카드 게임 기반 + 섯다·포커
|
||||||
- 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟.
|
- 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟.
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
- PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화.
|
- PWA(홈 화면 추가, 오프라인 안내), 게임 기록 다시 보기, 게임별 튜토리얼 강화.
|
||||||
- 2차 부하 테스트, 필요 시 방 단위 다중 프로세스 분산 검토.
|
- 2차 부하 테스트, 필요 시 방 단위 다중 프로세스 분산 검토.
|
||||||
- (선택) 혼자 연습용 간단한 컴퓨터 상대.
|
- (선택) 혼자 연습용 간단한 컴퓨터 상대.
|
||||||
|
- 체스960(자체 캐슬링 구현 + perft 검증).
|
||||||
|
|
||||||
## 순서 원칙
|
## 순서 원칙
|
||||||
- 플랫폼(M1)의 안정성 기능이 먼저다. 게임은 그 위에 하나씩 얹는다.
|
- 플랫폼(M1)의 안정성 기능이 먼저다. 게임은 그 위에 하나씩 얹는다.
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ try {
|
|||||||
g.on('pageerror', (e) => errors.push(e.message));
|
g.on('pageerror', (e) => errors.push(e.message));
|
||||||
await g.goto(ORIGIN + '/');
|
await g.goto(ORIGIN + '/');
|
||||||
await g.getByRole('button', { name: '+ 방 만들기' }).click();
|
await g.getByRole('button', { name: '+ 방 만들기' }).click();
|
||||||
|
await g.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click();
|
||||||
await g.getByLabel('내 이름(닉네임)').fill('손님');
|
await g.getByLabel('내 이름(닉네임)').fill('손님');
|
||||||
await g.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
await g.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||||
await g.waitForURL(/\/r\/\d{6}$/);
|
await g.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ const PLANS: Record<string, Plan> = {
|
|||||||
// cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped)
|
// 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']],
|
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: {
|
janggi: {
|
||||||
// Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step.
|
// Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step.
|
||||||
setup: async (host, guest) => {
|
setup: async (host, guest) => {
|
||||||
@@ -38,9 +42,12 @@ const PLANS: Record<string, Plan> = {
|
|||||||
let picked = false;
|
let picked = false;
|
||||||
for (const p of [host, guest]) {
|
for (const p of [host, guest]) {
|
||||||
const btn = p.getByRole('button', { name: /^안상차림/ });
|
const btn = p.getByRole('button', { name: /^안상차림/ });
|
||||||
if ((await btn.count()) && (await btn.isEnabled())) {
|
// The card list disappears right after choosing, so every probe must be short and tolerant.
|
||||||
await btn.click();
|
if ((await btn.count()) && (await btn.isEnabled({ timeout: 500 }).catch(() => false))) {
|
||||||
picked = true;
|
picked = await btn
|
||||||
|
.click({ timeout: 1000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) {
|
if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ try {
|
|||||||
await a.getByText('게임 고르기').waitFor();
|
await a.getByText('게임 고르기').waitFor();
|
||||||
await a.screenshot({ path: join(SHOTS, '0-home-pc.png') });
|
await a.screenshot({ path: join(SHOTS, '0-home-pc.png') });
|
||||||
await a.getByRole('button', { name: '+ 방 만들기' }).click();
|
await a.getByRole('button', { name: '+ 방 만들기' }).click();
|
||||||
|
await a.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click();
|
||||||
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
|
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
|
||||||
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||||
await a.waitForURL(/\/r\/\d{6}$/);
|
await a.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
|||||||
588
packages/games/src/baduk/baduk.test.ts
Normal file
588
packages/games/src/baduk/baduk.test.ts
Normal file
@@ -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<BadukOptions> = {}, 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<Cell>(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<Cell>(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<Cell>(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<number, Record<number, string[]>> = {
|
||||||
|
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<BadukOptions>, 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
539
packages/games/src/baduk/board.ts
Normal file
539
packages/games/src/baduk/board.ts
Normal file
@@ -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<number, Int16Array>();
|
||||||
|
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<number>();
|
||||||
|
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<string> | null; koHash: string | null },
|
||||||
|
): Map<number, IllegalReason> {
|
||||||
|
const { id, chains } = mapChains(board, size);
|
||||||
|
const nb = neighbours(size);
|
||||||
|
const o = oppOf(c);
|
||||||
|
const out = new Map<number, IllegalReason>();
|
||||||
|
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<number>;
|
||||||
|
/** 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<Cell>(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>): 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>): 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<number>();
|
||||||
|
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<number>;
|
||||||
|
oppStones: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For each colour: connected components of (empty ∪ dead ∪ own live stones). */
|
||||||
|
function components(board: readonly Cell[], size: number, dead: ReadonlySet<number>) {
|
||||||
|
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<number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
883
packages/games/src/baduk/index.ts
Normal file
883
packages/games/src/baduk/index.ts
Normal file
@@ -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<Color, Stone> = { black: 1, white: 2 };
|
||||||
|
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||||
|
const COLOR_KO: Record<Color, string> = { 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<typeof badukOptionsSchema>;
|
||||||
|
|
||||||
|
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<typeof badukActionSchema> | { 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<Color, PlayerId>;
|
||||||
|
turn: Color;
|
||||||
|
phase: 'playing' | 'scoring' | 'finished';
|
||||||
|
/** Stones each colour captured during play. */
|
||||||
|
captures: Record<Color, number>;
|
||||||
|
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<string, number>;
|
||||||
|
/** 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<Color, boolean>;
|
||||||
|
startedAt: number;
|
||||||
|
} | null;
|
||||||
|
clock: {
|
||||||
|
mainMs: Record<Color, number>;
|
||||||
|
periodsLeft: Record<Color, number>;
|
||||||
|
inByoyomi: Record<Color, boolean>;
|
||||||
|
turnStartedAt: number;
|
||||||
|
};
|
||||||
|
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
|
||||||
|
draw: { pendingBy: Color | null; lastOfferMove: Record<Color, number | null> };
|
||||||
|
result: BadukResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadukView {
|
||||||
|
size: number;
|
||||||
|
board: Cell[];
|
||||||
|
players: Record<Color, PlayerId>;
|
||||||
|
myColor: Color | null;
|
||||||
|
turn: Color;
|
||||||
|
phase: BadukState['phase'];
|
||||||
|
rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number; atariWarning: boolean };
|
||||||
|
captures: Record<Color, number>;
|
||||||
|
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<Color, number>;
|
||||||
|
periodsLeft: Record<Color, number>;
|
||||||
|
inByoyomi: Record<Color, boolean>;
|
||||||
|
turnStartedAt: number;
|
||||||
|
};
|
||||||
|
undo: { allowed: boolean; remaining: Record<Color, number | null>; 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<Color, boolean>;
|
||||||
|
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<Cell>(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<number, IllegalReason> {
|
||||||
|
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<IllegalReason, string> = {
|
||||||
|
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<BadukState, BadukAction, BadukView, BadukOptions> = {
|
||||||
|
id: 'baduk',
|
||||||
|
nameKo: '바둑',
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 2,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: badukOptionsSchema.parse({}),
|
||||||
|
optionsSchema: badukOptionsSchema,
|
||||||
|
actionSchema: badukActionSchema as z.ZodType<BadukAction>,
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
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<BadukReason, GameResult['reason']> = {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,10 +3,12 @@ import { omok } from './omok';
|
|||||||
import { othello } from './othello';
|
import { othello } from './othello';
|
||||||
import { chess } from './chess';
|
import { chess } from './chess';
|
||||||
import { janggi } from './janggi';
|
import { janggi } from './janggi';
|
||||||
|
import { baduk } from './baduk';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
[othello.id]: othello,
|
[othello.id]: othello,
|
||||||
[chess.id]: chess,
|
[chess.id]: chess,
|
||||||
[janggi.id]: janggi,
|
[janggi.id]: janggi,
|
||||||
|
[baduk.id]: baduk,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user