게시판SGF 기보로 학습하는 바둑 LLM 인공지능예제 소스

예제 소스

SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 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>
  );
}