예제 소스
바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 169개
page.tsx
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ModelSelector } from "@/components/ModelSelector";
import { GoBoard } from "@/components/GoBoard";
import { AtariEffect, type AtariTrigger } from "@/components/AtariEffect";
import {
BLACK,
WHITE,
atariStonesAfter,
coordLabel,
emptyBoard,
replayMoves,
scoreKoreanTerritory,
tryPlay,
type Point,
type Stone,
} from "@/lib/board";
import type { GameRow, GameWinner, MoveRec } from "@/lib/gameTypes";
import type { KatagoAnalysis } from "@/lib/katagoTypes";
import type { PlayerStats } from "@/lib/ranking";
import {
getOrCreateGuestId,
loadAccessKeyForCode,
loadRememberedGame,
rememberGame,
} from "@/lib/guest";
import { requestVlmMove } from "@/lib/vlmClient";
import styles from "./play.module.css";
const KOMI = 6.5;
const HUMAN = BLACK;
const VLM = WHITE;
const EARLY_PASS_MOVE_LIMIT = 50;
/** Strict Mode 이중 마운트에서 같은 수에 VLM을 두 번 부르지 않음 */
let lastVlmAskKey = "";
/** 바둑돌 소리 재생 */
function playStoneSound(color: Stone, captured: boolean = false): void {
if (typeof window === "undefined") return;
try {
let soundFile: string;
if (captured) {
soundFile = "/sounds/stone_capture.wav";
} else if (color === BLACK) {
soundFile = "/sounds/stone_black.wav";
} else {
soundFile = "/sounds/stone_white.wav";
}
console.log("🔊 Playing stone sound:", soundFile, "captured:", captured);
const audio = new Audio(soundFile);
audio.volume = 1.0;
audio.play().catch((err) => {
console.warn("⚠️ Stone sound play failed:", err.message || err);
});
} catch (err) {
console.error("❌ Stone sound error:", err);
}
}
function gameAuthQs(guestId: string, accessKey?: string | null): string {
const q = new URLSearchParams();
if (guestId) q.set("guestId", guestId);
if (accessKey) q.set("key", accessKey);
const s = q.toString();
return s ? `?${s}` : "";
}
type Status =
| { kind: "idle" }
| { kind: "thinking" }
| { kind: "error"; message: string }
| { kind: "ended"; text: string };
type PersistSlice = {
board: Stone[];
toPlay: Stone;
ko: Point | null;
lastMove: Point | null;
moveNo: number;
capB: number;
capW: number;
passStreak: number;
epoch: number;
started: boolean;
status: Status;
winner: GameWinner | null;
moves: MoveRec[];
};
function applyGame(g: GameRow, setters: {
setBoard: (b: Stone[]) => void;
setToPlay: (t: Stone) => void;
setKo: (k: Point | null) => void;
setLastMove: (p: Point | null) => void;
setMoveNo: (n: number) => void;
setCapB: (n: number) => void;
setCapW: (n: number) => void;
setPassStreak: (n: number) => void;
setEpoch: (n: number) => void;
setStarted: (v: boolean) => void;
setMoves: (m: MoveRec[]) => void;
setStatus: (s: Status) => void;
setGameCode: (c: string) => void;
setAccessKey: (k: string) => void;
setViewPly: (n: number) => void;
}) {
setters.setGameCode(g.code);
setters.setAccessKey(g.access_key);
setters.setBoard(g.board as Stone[]);
setters.setToPlay(g.to_play as Stone);
setters.setKo(g.ko);
setters.setLastMove(g.last_move);
setters.setMoveNo(g.move_no);
setters.setCapB(g.cap_b);
setters.setCapW(g.cap_w);
setters.setPassStreak(g.pass_streak);
setters.setEpoch(g.epoch);
setters.setStarted(g.started);
setters.setMoves(g.moves || []);
setters.setViewPly((g.moves || []).length);
if (g.status === "ended" && g.end_text) {
setters.setStatus({ kind: "ended", text: g.end_text });
} else {
setters.setStatus({ kind: "idle" });
}
rememberGame(g.code, g.access_key);
}
function gameWantsVlm(g: GameRow): boolean {
return Boolean(g.started && g.status !== "ended" && g.to_play === VLM);
}
function PlayInner() {
const router = useRouter();
const search = useSearchParams();
const [ready, setReady] = useState(false);
const [gameCode, setGameCode] = useState("");
const [accessKey, setAccessKey] = useState("");
const [guestId, setGuestId] = useState("");
const [board, setBoard] = useState<Stone[]>(() => emptyBoard());
const [toPlay, setToPlay] = useState<Stone>(BLACK);
const [ko, setKo] = useState<Point | null>(null);
const [pending, setPending] = useState<Point | null>(null);
const [lastMove, setLastMove] = useState<Point | null>(null);
const [moveNo, setMoveNo] = useState(0);
const [capB, setCapB] = useState(0);
const [capW, setCapW] = useState(0);
const [passStreak, setPassStreak] = useState(0);
const [rejects, setRejects] = useState<Array<[number, number]>>([]);
const [status, setStatus] = useState<Status>({ kind: "idle" });
const [epoch, setEpoch] = useState(() => Date.now() & 0xffffffff);
const [started, setStarted] = useState(false);
const [moves, setMoves] = useState<MoveRec[]>([]);
const [viewPly, setViewPly] = useState(0);
const [analysis, setAnalysis] = useState<KatagoAnalysis | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [player, setPlayer] = useState<PlayerStats | null>(null);
const [atariFx, setAtariFx] = useState<AtariTrigger | null>(null);
const boardRef = useRef(board);
const koRef = useRef(ko);
const rejectsRef = useRef(rejects);
const passStreakRef = useRef(passStreak);
const moveNoRef = useRef(moveNo);
const capBRef = useRef(capB);
const capWRef = useRef(capW);
const movesRef = useRef(moves);
const gameCodeRef = useRef(gameCode);
const accessKeyRef = useRef(accessKey);
const guestIdRef = useRef(guestId);
const epochRef = useRef(epoch);
const startedRef = useRef(started);
const statusRef = useRef(status);
const toPlayRef = useRef(toPlay);
const lastMoveRef = useRef(lastMove);
const koStateRef = useRef(ko);
const pendingVlmResume = useRef(false);
const viewPlyRef = useRef(viewPly);
viewPlyRef.current = viewPly;
boardRef.current = board;
koRef.current = ko;
rejectsRef.current = rejects;
passStreakRef.current = passStreak;
moveNoRef.current = moveNo;
capBRef.current = capB;
capWRef.current = capW;
movesRef.current = moves;
gameCodeRef.current = gameCode;
accessKeyRef.current = accessKey;
guestIdRef.current = guestId;
epochRef.current = epoch;
startedRef.current = started;
statusRef.current = status;
toPlayRef.current = toPlay;
lastMoveRef.current = lastMove;
koStateRef.current = ko;
const setters = useMemo(
() => ({
setBoard,
setToPlay,
setKo,
setLastMove,
setMoveNo,
setCapB,
setCapW,
setPassStreak,
setEpoch,
setStarted,
setMoves,
setStatus,
setGameCode,
setAccessKey,
setViewPly,
}),
[],
);
const persist = useCallback(async (slice?: Partial<PersistSlice>) => {
const code = gameCodeRef.current;
if (!code) return;
const st = slice?.status ?? statusRef.current;
const body = {
guestId: guestIdRef.current,
accessKey: accessKeyRef.current,
board: slice?.board ?? boardRef.current,
to_play: slice?.toPlay ?? toPlayRef.current,
ko: slice?.ko !== undefined ? slice.ko : koStateRef.current,
last_move: slice?.lastMove !== undefined ? slice.lastMove : lastMoveRef.current,
move_no: slice?.moveNo ?? moveNoRef.current,
cap_b: slice?.capB ?? capBRef.current,
cap_w: slice?.capW ?? capWRef.current,
pass_streak: slice?.passStreak ?? passStreakRef.current,
epoch: slice?.epoch ?? epochRef.current,
started: slice?.started ?? startedRef.current,
status: st.kind === "ended" ? ("ended" as const) : ("playing" as const),
end_text: st.kind === "ended" ? st.text : null,
winner: st.kind === "ended" ? (slice?.winner ?? null) : null,
moves: slice?.moves ?? movesRef.current,
};
try {
const res = await fetch(`/api/game/${code}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const data = (await res.json()) as { player?: PlayerStats };
if (data.player) setPlayer(data.player);
}
} catch {
// 저장 실패는 다음 진입 시 자동 이어두기로 최대한 복구한다.
}
}, []);
const syncUrl = useCallback(
(code: string, key: string) => {
const q = new URLSearchParams();
q.set("g", code);
q.set("k", key);
router.replace(`/play?${q.toString()}`, { scroll: false });
},
[router],
);
useEffect(() => {
let cancelled = false;
(async () => {
const gid = getOrCreateGuestId();
setGuestId(gid);
void fetch(`/api/player?clientId=${encodeURIComponent(gid)}`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { player?: PlayerStats } | null) => {
if (!cancelled && data?.player) setPlayer(data.player);
})
.catch(() => undefined);
const fromUrl = (search.get("g") || "").trim().toUpperCase();
const keyFromUrl = (search.get("k") || "").trim();
const remembered = loadRememberedGame();
try {
if (fromUrl) {
const key = keyFromUrl || loadAccessKeyForCode(fromUrl) || remembered?.accessKey;
const res = await fetch(
`/api/game/${fromUrl}${gameAuthQs(gid, key)}`,
);
if (res.ok) {
const data = (await res.json()) as { game: GameRow };
if (!cancelled) {
applyGame(data.game, setters);
if (gameWantsVlm(data.game)) pendingVlmResume.current = true;
syncUrl(data.game.code, data.game.access_key);
setReady(true);
return;
}
} else if (res.status === 403 && !cancelled) {
setStatus({
kind: "error",
message: "남의 대국 코드로는 열 수 없습니다. 본인 기기이거나 이어두기 링크가 필요합니다.",
});
}
}
if (remembered) {
const res = await fetch(
`/api/game/${remembered.code}${gameAuthQs(gid, remembered.accessKey)}`,
);
if (res.ok) {
const data = (await res.json()) as { game: GameRow };
if (!cancelled && data.game.status === "playing") {
applyGame(data.game, setters);
if (gameWantsVlm(data.game)) pendingVlmResume.current = true;
syncUrl(data.game.code, data.game.access_key);
setReady(true);
return;
}
}
}
const latest = await fetch(`/api/game?guestId=${encodeURIComponent(gid)}`);
if (latest.ok) {
const data = (await latest.json()) as { game: GameRow | null };
if (!cancelled && data.game) {
applyGame(data.game, setters);
if (gameWantsVlm(data.game)) pendingVlmResume.current = true;
syncUrl(data.game.code, data.game.access_key);
setReady(true);
return;
}
}
const created = await fetch("/api/game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ guestId: gid }),
});
if (created.ok) {
const data = (await created.json()) as { game: GameRow };
if (!cancelled) {
applyGame(data.game, setters);
syncUrl(data.game.code, data.game.access_key);
}
}
} catch {
if (!cancelled) setStatus({ kind: "error", message: "기보 서버 연결 실패" });
} finally {
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [search, setters, syncUrl]);
function isEarlyPassPhase(afterMoveNo: number): boolean {
const empty = boardRef.current.filter((s) => s === 0).length;
return afterMoveNo < EARLY_PASS_MOVE_LIMIT || empty >= 250;
}
const reviewing = viewPly < moves.length;
const shown = reviewing ? replayMoves(moves, viewPly) : null;
const boardShown = shown?.board ?? board;
const lastShown = shown ? shown.lastMove : lastMove;
const atLivePly = viewPly >= moves.length;
const needsVlmAfterReview =
started &&
atLivePly &&
toPlay === VLM &&
status.kind !== "ended" &&
status.kind !== "thinking" &&
moves.length > 0;
/** 맨 마지막 수로 돌아가기 (다시 두기) */
const canResumePlay = reviewing || needsVlmAfterReview;
/** 지금 보는 수부터 그 뒤 기보를 버리고 이어두기 */
const canContinueFromView =
started &&
reviewing &&
status.kind !== "thinking";
const humanTurn =
started &&
!reviewing &&
toPlay === HUMAN &&
status.kind !== "thinking" &&
status.kind !== "ended";
const statusLine = useMemo(() => {
if (reviewing) {
const m = moves[viewPly - 1];
const who = m ? (m.color === BLACK ? "흑" : "백") : "";
const mv = m?.pass
? "패스"
: m?.r != null && m?.c != null
? coordLabel(m.r, m.c)
: "";
return `열람 ${viewPly}/${moves.length}` + (m ? ` · ${who} ${mv}` : "");
}
if (status.kind === "thinking") return "VLM 생각 중…";
if (status.kind === "error") return status.message;
if (status.kind === "ended") return status.text.split("\n")[0];
if (!started) return "새 대국을 시작해 주세요";
const turn = toPlay === BLACK ? "흑" : "백";
if (lastMove) {
return `${moveNo}수 · ${turn} 차례 · 직전 ${coordLabel(lastMove.r, lastMove.c)}`;
}
return `${moveNo}수 · ${turn} 차례`;
}, [status, started, lastMove, moveNo, toPlay, reviewing, viewPly, moves]);
const finishWithScore = useCallback(
(boardNow: Stone[], bCap: number, wCap: number, reason: string) => {
const sc = scoreKoreanTerritory(boardNow, bCap, wCap, KOMI);
const text = `${reason}\n${sc.summary}`;
const ended: Status = { kind: "ended", text };
setStatus(ended);
void persist({
board: boardNow,
capB: bCap,
capW: wCap,
status: ended,
started: true,
winner: sc.winner,
});
},
[persist],
);
const announceAtari = useCallback((boardAfter: Stone[], r: number, c: number, who: Stone) => {
if (atariStonesAfter(boardAfter, r, c, who) <= 0) return;
setAtariFx((prev) => ({ id: (prev?.id ?? 0) + 1, who: who === BLACK ? 1 : 2 }));
}, []);
const pushMove = useCallback((rec: MoveRec) => {
const next = [...movesRef.current, rec];
setMoves(next);
movesRef.current = next;
if (viewPlyRef.current >= next.length - 1) setViewPly(next.length);
return next;
}, []);
const askVlm = useCallback(
async (boardNow: Stone[], rejectList: Array<[number, number]> = []) => {
const key = `${epochRef.current}:${moveNoRef.current}`;
if (lastVlmAskKey === key) return;
lastVlmAskKey = key;
setStatus({ kind: "thinking" });
setPending(null);
const reply = await requestVlmMove({
board: boardNow,
aiSide: VLM,
gameEpoch: epochRef.current,
rejects: rejectList,
ko: koRef.current,
});
if (!reply.ok) {
lastVlmAskKey = "";
setStatus({ kind: "error", message: reply.error });
return;
}
if (reply.pass) {
const streak = passStreakRef.current + 1;
const nextMove = moveNoRef.current + 1;
const nextMoves = pushMove({ ply: nextMove, color: VLM, pass: true });
setPassStreak(streak);
setMoveNo(nextMove);
setLastMove(null);
if (streak >= 2) {
if (isEarlyPassPhase(nextMove)) {
setPassStreak(0);
setToPlay(HUMAN);
setStatus({
kind: "error",
message: "초반 쌍패스는 종료하지 않습니다. 계속 두세요.",
});
void persist({
moveNo: nextMove,
passStreak: 0,
lastMove: null,
toPlay: HUMAN,
moves: nextMoves,
});
return;
}
finishWithScore(boardNow, capBRef.current, capWRef.current, "양측 패스");
return;
}
setToPlay(HUMAN);
if (isEarlyPassPhase(nextMove)) {
setStatus({
kind: "error",
message: "VLM이 초반에 패스했습니다. 계속 두셔도 됩니다.",
});
} else {
setStatus({ kind: "idle" });
}
void persist({
moveNo: nextMove,
passStreak: streak,
lastMove: null,
toPlay: HUMAN,
moves: nextMoves,
});
return;
}
let played = tryPlay(boardNow, reply.row, reply.col, VLM, koRef.current);
let usedRejects = rejectList;
let placedR = reply.row;
let placedC = reply.col;
if (!played.ok) {
usedRejects = [...rejectList, [reply.row, reply.col] as [number, number]].slice(-48);
setRejects(usedRejects);
setStatus({
kind: "error",
message: `VLM 불법수 ${coordLabel(reply.row, reply.col)} — 재요청`,
});
const retry = await requestVlmMove({
board: boardNow,
aiSide: VLM,
gameEpoch: epochRef.current,
rejects: usedRejects,
ko: koRef.current,
});
if (!retry.ok) {
lastVlmAskKey = "";
setStatus({ kind: "error", message: retry.error });
return;
}
if (retry.pass) {
const streak = passStreakRef.current + 1;
const nextMove = moveNoRef.current + 1;
const nextMoves = pushMove({ ply: nextMove, color: VLM, pass: true });
setPassStreak(streak);
setMoveNo(nextMove);
if (streak >= 2) {
if (isEarlyPassPhase(nextMove)) {
setPassStreak(0);
setToPlay(HUMAN);
setStatus({
kind: "error",
message: "초반 쌍패스는 종료하지 않습니다. 계속 두세요.",
});
void persist({
moveNo: nextMove,
passStreak: 0,
toPlay: HUMAN,
moves: nextMoves,
});
return;
}
finishWithScore(boardNow, capBRef.current, capWRef.current, "양측 패스");
return;
}
setToPlay(HUMAN);
setStatus({
kind: "error",
message: "VLM이 불법수 후 패스했습니다. 계속 두셔도 됩니다.",
});
void persist({
moveNo: nextMove,
passStreak: streak,
toPlay: HUMAN,
moves: nextMoves,
});
return;
}
played = tryPlay(boardNow, retry.row, retry.col, VLM, koRef.current);
placedR = retry.row;
placedC = retry.col;
if (!played.ok) {
setStatus({
kind: "error",
message: `VLM 수 실패 (${played.reason}). 패스하거나 새 대국을 하세요.`,
});
setToPlay(HUMAN);
return;
}
setLastMove({ r: placedR, c: placedC });
} else {
setLastMove({ r: placedR, c: placedC });
}
const nextMove = moveNoRef.current + 1;
const nextCapW = capWRef.current + played.captured;
const nextMoves = pushMove({
ply: nextMove,
color: VLM,
r: placedR,
c: placedC,
});
setBoard(played.board);
setKo(played.koPoint);
setMoveNo(nextMove);
setPassStreak(0);
setCapW(nextCapW);
setToPlay(HUMAN);
setRejects([]);
setStatus({ kind: "idle" });
// 바둑돌 소리 재생
playStoneSound(VLM, played.captured > 0);
announceAtari(played.board, placedR, placedC, VLM);
void persist({
board: played.board,
ko: played.koPoint,
lastMove: { r: placedR, c: placedC },
moveNo: nextMove,
passStreak: 0,
capW: nextCapW,
toPlay: HUMAN,
moves: nextMoves,
});
},
[announceAtari, finishWithScore, persist, pushMove],
);
useEffect(() => {
if (!ready || !pendingVlmResume.current) return;
if (!started || toPlay !== VLM) return;
if (status.kind === "ended" || status.kind === "thinking") return;
pendingVlmResume.current = false;
void askVlm(boardRef.current, []);
}, [ready, started, toPlay, status.kind, askVlm]);
function maybeResumeVlmAfterReview() {
if (statusRef.current.kind === "error") {
setStatus({ kind: "idle" });
}
if (
startedRef.current &&
statusRef.current.kind !== "ended" &&
statusRef.current.kind !== "thinking" &&
toPlayRef.current === VLM &&
movesRef.current.length > 0
) {
void askVlm(boardRef.current, rejectsRef.current);
}
}
function stepView(delta: number) {
const n = movesRef.current.length;
const prev = viewPlyRef.current;
const next = Math.max(0, Math.min(n, prev + delta));
setViewPly(next);
setPending(null);
setAnalysis(null);
if (next === n && prev < n) {
maybeResumeVlmAfterReview();
}
}
/** 맨 마지막 수로 복귀 후 계속 (타임머신 열람만 취소) */
function resumePlay() {
setViewPly(movesRef.current.length);
setPending(null);
setAnalysis(null);
maybeResumeVlmAfterReview();
}
/** 지금 보는 수부터 이어두기 — 이후 수는 버리고 그 지점에서 대국 재개 */
function continueFromView() {
const ply = viewPlyRef.current;
const all = movesRef.current;
if (ply >= all.length) {
maybeResumeVlmAfterReview();
return;
}
if (statusRef.current.kind === "thinking") return;
const truncated = all.slice(0, ply).map((m, i) => ({ ...m, ply: i + 1 }));
const state = replayMoves(truncated, ply);
const nextEpoch = (epochRef.current + 1) >>> 0;
lastVlmAskKey = "";
epochRef.current = nextEpoch;
movesRef.current = truncated;
moveNoRef.current = ply;
boardRef.current = state.board;
koRef.current = state.ko;
koStateRef.current = state.ko;
lastMoveRef.current = state.lastMove;
capBRef.current = state.capB;
capWRef.current = state.capW;
passStreakRef.current = state.passStreak;
toPlayRef.current = state.toPlay;
statusRef.current = { kind: "idle" };
setEpoch(nextEpoch);
setMoves(truncated);
setBoard(state.board);
setKo(state.ko);
setLastMove(state.lastMove);
setCapB(state.capB);
setCapW(state.capW);
setPassStreak(state.passStreak);
setToPlay(state.toPlay);
setMoveNo(ply);
setViewPly(ply);
setPending(null);
setAnalysis(null);
setRejects([]);
setStatus({ kind: "idle" });
setStarted(true);
void persist({
board: state.board,
ko: state.ko,
lastMove: state.lastMove,
moveNo: ply,
capB: state.capB,
capW: state.capW,
passStreak: state.passStreak,
toPlay: state.toPlay,
moves: truncated,
epoch: nextEpoch,
started: true,
status: { kind: "idle" },
});
if (state.toPlay === VLM) {
void askVlm(state.board, []);
}
}
async function runAnalysis() {
if (analyzing || !started || moveNo <= 0) return;
setAnalyzing(true);
setAnalysis(null);
const ply = viewPlyRef.current > 0 ? viewPlyRef.current : movesRef.current.length;
const slice = movesRef.current.slice(0, ply);
try {
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moves: slice, komi: KOMI, maxVisits: 80 }),
});
const data = (await res.json()) as KatagoAnalysis & { error?: string; detail?: string };
if (!res.ok) {
setStatus({
kind: "error",
message: data.error || data.detail || "KataGo 분석 실패",
});
return;
}
setAnalysis(data);
} catch {
setStatus({ kind: "error", message: "KataGo 분석 요청 실패" });
} finally {
setAnalyzing(false);
}
}
const analysisLine = useMemo(() => {
if (analyzing) return "KataGo 형세 분석 중… (약 5~10초)";
if (!analysis) return null;
const lead = analysis.scoreLeadBlack;
const who = lead >= 0 ? "흑" : "백";
const pts = Math.abs(lead).toFixed(1);
const wrB = (analysis.winrateBlack * 100).toFixed(1);
const wrW = (analysis.winrateWhite * 100).toFixed(1);
const { blackLikely, whiteLikely, contested } = analysis.summary;
return `KataGo · ${who} ${pts}집 우세 · 승률 흑 ${wrB}% / 백 ${wrW}% · 세력(흑/백/미정) ${blackLikely}/${whiteLikely}/${contested}`;
}, [analysis, analyzing]);
function placeHuman(r: number, c: number) {
if (!humanTurn) return;
setAnalysis(null);
const res = tryPlay(boardRef.current, r, c, HUMAN, koRef.current);
if (!res.ok) {
setStatus({ kind: "error", message: `둘 수 없는 자리: ${res.reason}` });
setPending(null);
return;
}
const nextMove = moveNoRef.current + 1;
const nextCapB = capBRef.current + res.captured;
const nextMoves = pushMove({ ply: nextMove, color: HUMAN, r, c });
setBoard(res.board);
setKo(res.koPoint);
setLastMove({ r, c });
setMoveNo(nextMove);
setPassStreak(0);
setCapB(nextCapB);
setToPlay(VLM);
setPending(null);
setRejects([]);
setStatus({ kind: "idle" });
// 바둑돌 소리 재생
playStoneSound(HUMAN, res.captured > 0);
announceAtari(res.board, r, c, HUMAN);
void persist({
board: res.board,
ko: res.koPoint,
lastMove: { r, c },
moveNo: nextMove,
passStreak: 0,
capB: nextCapB,
toPlay: VLM,
moves: nextMoves,
started: true,
});
void askVlm(res.board, []);
}
function onSelect(r: number, c: number) {
if (!humanTurn) return;
if (pending && pending.r === r && pending.c === c) {
placeHuman(r, c);
return;
}
setPending({ r, c });
setStatus({ kind: "idle" });
}
function onPlace() {
if (!pending || !humanTurn) return;
placeHuman(pending.r, pending.c);
}
function onPass() {
if (!humanTurn) return;
setPending(null);
const nextStreak = passStreakRef.current + 1;
const nextMove = moveNoRef.current + 1;
const nextMoves = pushMove({ ply: nextMove, color: HUMAN, pass: true });
setPassStreak(nextStreak);
setMoveNo(nextMove);
setLastMove(null);
if (nextStreak >= 2) {
if (isEarlyPassPhase(nextMove)) {
setPassStreak(0);
setStatus({
kind: "error",
message: "초반 쌍패스는 종료하지 않습니다. VLM에게 다시 둡니다.",
});
setToPlay(VLM);
void persist({
moveNo: nextMove,
passStreak: 0,
lastMove: null,
toPlay: VLM,
moves: nextMoves,
});
void askVlm(boardRef.current, []);
return;
}
finishWithScore(boardRef.current, capBRef.current, capWRef.current, "양측 패스");
return;
}
setToPlay(VLM);
void persist({
moveNo: nextMove,
passStreak: nextStreak,
lastMove: null,
toPlay: VLM,
moves: nextMoves,
});
void askVlm(boardRef.current, []);
}
function onResign() {
const ended: Status = { kind: "ended", text: "기권 — VLM(백) 불계승" };
setStatus(ended);
void persist({ status: ended, started: true, winner: "white" });
}
function onScoreNow() {
finishWithScore(boardRef.current, capBRef.current, capWRef.current, "수동 계가");
}
async function startCurrent() {
setStarted(true);
setStatus({ kind: "idle" });
void persist({ started: true, status: { kind: "idle" } });
}
async function newGame() {
const gid = guestId || getOrCreateGuestId();
const created = await fetch("/api/game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ guestId: gid }),
});
if (!created.ok) {
setStatus({ kind: "error", message: "새 대국 생성 실패" });
return;
}
const data = (await created.json()) as { game: GameRow; player?: PlayerStats };
applyGame(data.game, setters);
if (data.player) setPlayer(data.player);
lastVlmAskKey = "";
setPending(null);
setRejects([]);
setStarted(true);
setStatus({ kind: "idle" });
syncUrl(data.game.code, data.game.access_key);
void persist({
board: emptyBoard(),
toPlay: BLACK,
ko: null,
lastMove: null,
moveNo: 0,
capB: 0,
capW: 0,
passStreak: 0,
started: true,
status: { kind: "idle" },
moves: [],
epoch: data.game.epoch,
});
}
if (!ready) {
return (
<main className={styles.page}>
<p className={styles.status}>기보 불러오는 중…</p>
</main>
);
}
return (
<main className={styles.page}>
<header className={styles.top}>
<h1 className={styles.brand}>MariaGO</h1>
<button type="button" className={styles.iconBtn} aria-label="알림">
🔔
</button>
</header>
{player ? (
<section className={styles.rankCard} title={`클라이언트 ID: ${player.client_id}`}>
<div>
<span className={styles.rankLabel}>{player.rank_label}</span>
<span className={styles.clientId}>ID {player.client_id.slice(0, 8)}</span>
</div>
<div className={styles.rankStats}>
<strong>
승점 {player.rank_points}/{player.promotion_target}
</strong>
<span>
{player.total_games}전 {player.wins}승 {player.losses}패 {player.draws}무
</span>
</div>
</section>
) : null}
<div className={styles.boardWrap}>
<GoBoard
board={boardShown}
lastMove={lastShown}
pending={reviewing ? null : pending}
ownership={analysis?.ownership ?? null}
onSelect={onSelect}
interactive={humanTurn}
/>
<AtariEffect trigger={atariFx} />
</div>
<div className={styles.reviewRow}>
<button
type="button"
className={styles.btn}
onClick={() => stepView(-1)}
disabled={viewPly <= 0}
>
이전
</button>
<span className={styles.reviewPos}>
{viewPly}/{moves.length}
</span>
<button
type="button"
className={styles.btn}
onClick={() => stepView(1)}
disabled={viewPly >= moves.length}
>
이후
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={continueFromView}
disabled={!canContinueFromView}
title="지금 보는 수부터 이어 둡니다. 이후 기보는 버려집니다."
>
이어두기
</button>
<button
type="button"
className={styles.btn}
onClick={resumePlay}
disabled={!canResumePlay}
title="맨 마지막 수로 돌아가 이어서 둡니다."
>
다시 두기
</button>
</div>
<div className={styles.actions}>
{!started ? (
<button
type="button"
className={styles.btnPrimary}
style={{ gridColumn: "1 / -1" }}
onClick={() => void startCurrent()}
>
대국 시작
</button>
) : (
<>
<button type="button" className={styles.btn} onClick={onResign} disabled={status.kind === "ended"}>
기권
</button>
<button type="button" className={styles.btn} onClick={onPass} disabled={!humanTurn}>
패스
</button>
<button type="button" className={styles.btnPrimary} onClick={onPlace} disabled={!humanTurn || !pending}>
착수
</button>
</>
)}
</div>
<div className={styles.status}>
<span className={status.kind === "thinking" || analyzing ? styles.waiting : undefined}>
<strong>
{analysisLine ||
(pending && humanTurn
? `예비수 ${coordLabel(pending.r, pending.c)} · 같은 곳 다시 탭 또는 착수`
: statusLine)}
</strong>
</span>
{started && status.kind !== "ended" ? (
<div className={styles.statusActions}>
<button
type="button"
className={styles.btn}
style={{ minHeight: 32, padding: "0 10px" }}
onClick={() => void runAnalysis()}
disabled={analyzing || moveNo <= 0}
>
{analyzing ? "분석…" : "형세"}
</button>
<button type="button" className={styles.btn} style={{ minHeight: 32, padding: "0 10px" }} onClick={onScoreNow}>
계가
</button>
</div>
) : started ? (
<button type="button" className={styles.btn} style={{ minHeight: 32, padding: "0 10px" }} onClick={() => void newGame()}>
새 대국
</button>
) : null}
</div>
<nav className={styles.nav}>
<Link href="/">로비</Link>
<Link href="/kibo">기보</Link>
<div className={styles.navModel}>
<ModelSelector disabled={status.kind === "thinking"} />
</div>
</nav>
{status.kind === "ended" && !reviewing ? (
<div className={styles.overlay}>
<div className={styles.dialog}>
<h2>대국 종료</h2>
<p className={styles.scoreText}>{status.text}</p>
<p className={styles.scoreNote}>
코드 {gameCode} · 한국식(계목) 어림 · 덤 {KOMI}
</p>
{player ? (
<p className={styles.rankResult}>
{player.rank_label} · 승점 {player.rank_points}/{player.promotion_target}
<br />
전체 {player.total_games}전 {player.wins}승 {player.losses}패 {player.draws}무
</p>
) : null}
<div className={styles.reviewRow} style={{ padding: "0 0 16px" }}>
<button
type="button"
className={styles.btn}
onClick={() => stepView(-1)}
disabled={viewPly <= 0}
>
이전
</button>
<span className={styles.reviewPos}>
{viewPly}/{moves.length}
</span>
<button
type="button"
className={styles.btn}
onClick={() => stepView(1)}
disabled={viewPly >= moves.length}
>
이후
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={continueFromView}
disabled={!canContinueFromView}
title="지금 보는 수부터 다시 둡니다. 이후 기보는 버려집니다."
>
이어두기
</button>
<button
type="button"
className={styles.btn}
onClick={resumePlay}
disabled={!canResumePlay}
title="맨 마지막 수로 이동합니다."
>
다시 두기
</button>
</div>
<button type="button" className={styles.btnPrimary} onClick={() => void newGame()}>
새 대국
</button>
</div>
</div>
) : null}
</main>
);
}
export default function PlayPage() {
return (
<Suspense
fallback={
<main className={styles.page}>
<p className={styles.status}>기보 불러오는 중…</p>
</main>
}
>
<PlayInner />
</Suspense>
);
}