게시판오목 좌표 LoRA: 15×15와 19×19 격자를 정확히 읽히기예제 소스

예제 소스

오목 좌표 LoRA: 15×15와 19×19 격자를 정확히 읽히기 · 파일 59개

page.tsx

"use client";

import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { OmokBoard } from "@/components/OmokBoard";
import { suggestMove } from "@/lib/ai";
import {
  BLACK,
  DEFAULT_SIZE,
  EMPTY,
  PLAY_SIZES,
  WHITE,
  canPlace,
  emptyBoard,
  playAt,
  rejectLabel,
  replayMoves,
  type GameStatus,
  type PlayBoardSize,
  type Point,
  type Stone,
} from "@/lib/board";
import type { GameRow, MoveRec } from "@/lib/gameTypes";
import {
  gameAuthQs,
  getOrCreateGuestId,
  loadRememberedGame,
  rememberGame,
} from "@/lib/guest";
import { requestVlmMove } from "@/lib/vlmClient";
import styles from "./play.module.css";

type Status =
  | { kind: "idle" }
  | { kind: "thinking" }
  | { kind: "error"; message: string }
  | { kind: "ended"; text: string };

/** 백(AI) 상대: 로컬 휴리스틱 | VLM TCP */
type OpponentMode = "local" | "vlm";

const HUMAN = BLACK;
const AI = WHITE;
const OPPONENT_KEY = "omook_opponent_mode";

let lastAiAskKey = "";

function loadOpponentMode(): OpponentMode {
  if (typeof window === "undefined") return "local";
  const v = localStorage.getItem(OPPONENT_KEY);
  return v === "vlm" ? "vlm" : "local";
}

function saveOpponentMode(mode: OpponentMode): void {
  if (typeof window === "undefined") return;
  localStorage.setItem(OPPONENT_KEY, mode);
}

function opponentLabel(mode: OpponentMode): string {
  return mode === "vlm" ? "VLM" : "로컬 AI";
}

function endText(gs: GameStatus): string {
  if (gs === "black_wins") return "흑 승리";
  if (gs === "white_wins") return "백 승리";
  if (gs === "draw") return "무승부";
  return "";
}

function statusFromGame(g: GameRow): Status {
  if (g.status === "ended") {
    return { kind: "ended", text: g.end_text || "대국 종료" };
  }
  return { kind: "idle" };
}

function gameStatusFromRow(g: GameRow): GameStatus {
  if (g.status !== "ended") return "playing";
  const t = g.end_text || "";
  if (t.includes("흑")) return "black_wins";
  if (t.includes("백")) return "white_wins";
  if (t.includes("무승부")) return "draw";
  return "playing";
}

type PersistSlice = {
  size?: number;
  board?: Stone[];
  toPlay?: Stone;
  lastMove?: Point | null;
  moveNo?: number;
  epoch?: number;
  started?: boolean;
  status?: Status;
  moves?: MoveRec[];
};

export default function PlayPage() {
  const [ready, setReady] = useState(false);
  const [guestId, setGuestId] = useState("");
  const [gameCode, setGameCode] = useState("");
  const [accessKey, setAccessKey] = useState("");
  const [size, setSize] = useState<PlayBoardSize>(DEFAULT_SIZE as PlayBoardSize);
  const [board, setBoard] = useState<Stone[]>(() => emptyBoard(DEFAULT_SIZE));
  const [toPlay, setToPlay] = useState<Stone>(BLACK);
  const [pending, setPending] = useState<Point | null>(null);
  const [lastMove, setLastMove] = useState<Point | null>(null);
  const [gameStatus, setGameStatus] = useState<GameStatus>("playing");
  const [moveNo, setMoveNo] = useState(0);
  const [moves, setMoves] = useState<MoveRec[]>([]);
  const [viewPly, setViewPly] = useState(0);
  const [epoch, setEpoch] = useState(0);
  const [status, setStatus] = useState<Status>({ kind: "idle" });
  const [showResult, setShowResult] = useState(false);
  const [started, setStarted] = useState(false);
  const [opponent, setOpponent] = useState<OpponentMode>("local");

  const boardRef = useRef(board);
  const toPlayRef = useRef(toPlay);
  const sizeRef = useRef(size);
  const gameStatusRef = useRef(gameStatus);
  const moveNoRef = useRef(moveNo);
  const movesRef = useRef(moves);
  const viewPlyRef = useRef(viewPly);
  const epochRef = useRef(epoch);
  const statusRef = useRef(status);
  const startedRef = useRef(started);
  const lastMoveRef = useRef(lastMove);
  const guestIdRef = useRef(guestId);
  const gameCodeRef = useRef(gameCode);
  const accessKeyRef = useRef(accessKey);
  const opponentRef = useRef(opponent);
  const pendingAiResume = useRef(false);

  boardRef.current = board;
  toPlayRef.current = toPlay;
  sizeRef.current = size;
  gameStatusRef.current = gameStatus;
  moveNoRef.current = moveNo;
  movesRef.current = moves;
  viewPlyRef.current = viewPly;
  epochRef.current = epoch;
  statusRef.current = status;
  startedRef.current = started;
  lastMoveRef.current = lastMove;
  guestIdRef.current = guestId;
  gameCodeRef.current = gameCode;
  accessKeyRef.current = accessKey;
  opponentRef.current = opponent;

  const reviewing = viewPly < moves.length;
  const shown = reviewing ? replayMoves(moves, viewPly, size) : null;
  const boardShown = shown?.board ?? board;
  const lastShown = shown?.lastMove ?? lastMove;
  const toPlayShown = shown?.toPlay ?? toPlay;
  const moveNoShown = reviewing ? viewPly : moveNo;

  const banned = useMemo(() => {
    if (reviewing || gameStatus !== "playing" || toPlay !== HUMAN) return [];
    const pts: Point[] = [];
    for (let r = 0; r < size; r++) {
      for (let c = 0; c < size; c++) {
        if (board[r * size + c] !== EMPTY) continue;
        const reason = canPlace(board, r, c, HUMAN, size);
        if (reason === "sansan" || reason === "changmok") pts.push({ r, c });
      }
    }
    return pts;
  }, [board, gameStatus, reviewing, size, toPlay]);

  const persist = useCallback(async (slice?: PersistSlice) => {
    const code = gameCodeRef.current;
    if (!code) return;
    const st = slice?.status ?? statusRef.current;
    const body = {
      guestId: guestIdRef.current,
      accessKey: accessKeyRef.current,
      size: slice?.size ?? sizeRef.current,
      board: slice?.board ?? boardRef.current,
      to_play: slice?.toPlay ?? toPlayRef.current,
      last_move:
        slice?.lastMove !== undefined ? slice.lastMove : lastMoveRef.current,
      move_no: slice?.moveNo ?? moveNoRef.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,
      moves: slice?.moves ?? movesRef.current,
    };
    try {
      await fetch(`/api/game/${code}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
    } catch {
      /* 다음 진입 시 이어두기로 복구 */
    }
  }, []);

  const applyGameRow = useCallback((g: GameRow) => {
    const sz = (PLAY_SIZES as readonly number[]).includes(g.size)
      ? (g.size as PlayBoardSize)
      : (DEFAULT_SIZE as PlayBoardSize);
    rememberGame(g.code, g.access_key);
    setGameCode(g.code);
    setAccessKey(g.access_key);
    setSize(sz);
    setBoard(g.board as Stone[]);
    setToPlay(g.to_play === WHITE ? WHITE : BLACK);
    setLastMove(g.last_move);
    setMoveNo(g.move_no);
    setMoves(g.moves);
    setViewPly(g.moves.length);
    setEpoch(g.epoch);
    setStarted(g.started);
    setGameStatus(gameStatusFromRow(g));
    setStatus(statusFromGame(g));
    setShowResult(g.status === "ended");
    setPending(null);
    lastAiAskKey = "";
  }, []);

  const createAndApply = useCallback(
    async (gid: string, nextSize: PlayBoardSize) => {
      const res = await fetch("/api/game", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ guestId: gid, size: nextSize }),
      });
      if (!res.ok) throw new Error(`새 대국 실패 HTTP ${res.status}`);
      const data = (await res.json()) as { game: GameRow };
      applyGameRow(data.game);
      return data.game;
    },
    [applyGameRow],
  );

  useEffect(() => {
    let cancelled = false;
    (async () => {
      const gid = getOrCreateGuestId();
      setGuestId(gid);
      const remembered = loadRememberedGame();
      try {
        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") {
              applyGameRow(data.game);
              if (data.game.to_play === AI && data.game.started) {
                pendingAiResume.current = true;
              }
              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) {
            applyGameRow(data.game);
            if (data.game.to_play === AI && data.game.started) {
              pendingAiResume.current = true;
            }
            setReady(true);
            return;
          }
        }
        if (!cancelled) {
          await createAndApply(gid, DEFAULT_SIZE as PlayBoardSize);
          setReady(true);
        }
      } catch (e) {
        if (!cancelled) {
          setStatus({ kind: "error", message: String(e) });
          setReady(true);
        }
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [applyGameRow, createAndApply]);

  useEffect(() => {
    setOpponent(loadOpponentMode());
  }, []);

  const resetGame = useCallback(
    async (nextSize?: PlayBoardSize) => {
      const sz = nextSize ?? sizeRef.current;
      const gid = guestIdRef.current || getOrCreateGuestId();
      lastAiAskKey = "";
      pendingAiResume.current = false;
      try {
        await createAndApply(gid, sz);
        setShowResult(false);
        setStatus({ kind: "idle" });
      } catch (e) {
        setStatus({ kind: "error", message: String(e) });
      }
    },
    [createAndApply],
  );

  const applyMove = useCallback(
    (r: number, c: number, stone: Stone): boolean => {
      if (viewPlyRef.current < movesRef.current.length) return false;
      const sz = sizeRef.current;
      const res = playAt(boardRef.current, r, c, stone, sz, gameStatusRef.current);
      if (!res.ok) {
        setStatus({ kind: "error", message: rejectLabel(res.reason) });
        return false;
      }
      const nextPly = moveNoRef.current + 1;
      const rec: MoveRec = { ply: nextPly, color: stone as 1 | 2, r, c };
      const nextMoves = [...movesRef.current, rec];
      movesRef.current = nextMoves;
      moveNoRef.current = nextPly;
      boardRef.current = res.board;
      lastMoveRef.current = res.last;
      startedRef.current = true;

      setBoard(res.board);
      setLastMove(res.last);
      setPending(null);
      setMoveNo(nextPly);
      setMoves(nextMoves);
      setViewPly(nextMoves.length);
      setStarted(true);
      setGameStatus(res.status);

      if (res.status !== "playing") {
        const ended: Status = { kind: "ended", text: endText(res.status) };
        setStatus(ended);
        setShowResult(true);
        void persist({
          board: res.board,
          lastMove: res.last,
          moveNo: nextPly,
          moves: nextMoves,
          toPlay: stone === BLACK ? WHITE : BLACK,
          started: true,
          status: ended,
        });
        return true;
      }
      const nextToPlay = stone === BLACK ? WHITE : BLACK;
      toPlayRef.current = nextToPlay;
      setToPlay(nextToPlay);
      setStatus({ kind: "idle" });
      void persist({
        board: res.board,
        lastMove: res.last,
        moveNo: nextPly,
        moves: nextMoves,
        toPlay: nextToPlay,
        started: true,
        status: { kind: "idle" },
      });
      return true;
    },
    [persist],
  );

  const askAi = useCallback(async () => {
    if (gameStatusRef.current !== "playing") return;
    if (toPlayRef.current !== AI) return;
    if (statusRef.current.kind === "thinking") return;
    if (viewPlyRef.current < movesRef.current.length) return;

    const sz = sizeRef.current;
    const askEpoch = epochRef.current;
    const mode = opponentRef.current;
    const key = `${mode}|${sz}|${moveNoRef.current}|${boardRef.current.join(",")}`;
    if (key === lastAiAskKey) return;
    lastAiAskKey = key;

    setStatus({ kind: "thinking" });

    try {
      let r: number;
      let c: number;
      if (mode === "local") {
        // UI 한 프레임 양보 후 즉시 착수 (VLM 서버 불필요)
        await new Promise((resolve) => setTimeout(resolve, 30));
        if (askEpoch !== epochRef.current) return;
        if (viewPlyRef.current < movesRef.current.length) return;
        const mv = suggestMove(boardRef.current, AI, sz);
        r = mv.r;
        c = mv.c;
      } else {
        const resp = await requestVlmMove({
          board: boardRef.current as number[],
          size: sz,
          aiSide: AI,
        });
        if (askEpoch !== epochRef.current) return;
        if (viewPlyRef.current < movesRef.current.length) return;
        if (!resp.ok) {
          setStatus({ kind: "error", message: resp.error });
          lastAiAskKey = "";
          return;
        }
        r = resp.row;
        c = resp.col;
        if (canPlace(boardRef.current, r, c, AI, sz) !== "none") {
          const fallback = suggestMove(boardRef.current, AI, sz);
          r = fallback.r;
          c = fallback.c;
        }
      }
      applyMove(r, c, AI);
    } catch (e) {
      if (askEpoch !== epochRef.current) return;
      setStatus({ kind: "error", message: String(e) });
      lastAiAskKey = "";
    }
  }, [applyMove]);

  useEffect(() => {
    if (!ready) return;
    if (pendingAiResume.current) {
      pendingAiResume.current = false;
      void askAi();
      return;
    }
    if (
      gameStatus === "playing" &&
      toPlay === AI &&
      status.kind !== "error" &&
      !reviewing
    ) {
      void askAi();
    }
  }, [askAi, gameStatus, toPlay, status.kind, moveNo, ready, reviewing]);

  const maybeResumeAiAfterReview = useCallback(() => {
    if (
      startedRef.current &&
      statusRef.current.kind !== "ended" &&
      statusRef.current.kind !== "thinking" &&
      toPlayRef.current === AI &&
      movesRef.current.length > 0 &&
      viewPlyRef.current >= movesRef.current.length
    ) {
      void askAi();
    }
  }, [askAi]);

  const 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);
    if (next === n && prev < n) maybeResumeAiAfterReview();
  };

  /** 맨 마지막 수로 복귀 (열람만 취소) */
  const resumePlay = () => {
    setViewPly(movesRef.current.length);
    setPending(null);
    maybeResumeAiAfterReview();
  };

  /** 지금 보는 수부터 이어두기 — 이후 기보 삭제 */
  const continueFromView = () => {
    const ply = viewPlyRef.current;
    const all = movesRef.current;
    if (ply >= all.length) {
      maybeResumeAiAfterReview();
      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, sizeRef.current);
    const nextEpoch = (epochRef.current + 1) >>> 0;
    lastAiAskKey = "";

    epochRef.current = nextEpoch;
    movesRef.current = truncated;
    moveNoRef.current = ply;
    boardRef.current = state.board;
    lastMoveRef.current = state.lastMove;
    toPlayRef.current = state.toPlay;
    gameStatusRef.current = state.status;
    startedRef.current = true;
    statusRef.current = state.status === "playing" ? { kind: "idle" } : {
      kind: "ended",
      text: endText(state.status),
    };

    setEpoch(nextEpoch);
    setMoves(truncated);
    setBoard(state.board);
    setLastMove(state.lastMove);
    setToPlay(state.toPlay);
    setMoveNo(ply);
    setViewPly(ply);
    setPending(null);
    setGameStatus(state.status);
    setStarted(true);
    if (state.status !== "playing") {
      setStatus(statusRef.current);
      setShowResult(true);
    } else {
      setStatus({ kind: "idle" });
      setShowResult(false);
    }

    void persist({
      board: state.board,
      lastMove: state.lastMove,
      moveNo: ply,
      moves: truncated,
      toPlay: state.toPlay,
      epoch: nextEpoch,
      started: true,
      status: statusRef.current,
    });

    if (state.status === "playing" && state.toPlay === AI) {
      void askAi();
    }
  };

  const onSelect = (r: number, c: number) => {
    if (reviewing) return;
    if (gameStatus !== "playing" || toPlay !== HUMAN || status.kind === "thinking") return;
    if (!pending || pending.r !== r || pending.c !== c) {
      const reason = canPlace(board, r, c, HUMAN, size);
      if (reason !== "none") {
        setStatus({ kind: "error", message: rejectLabel(reason) });
        setPending(null);
        return;
      }
      setPending({ r, c });
      setStatus({ kind: "idle" });
      return;
    }
    applyMove(r, c, HUMAN);
  };

  const confirmPending = () => {
    if (!pending || reviewing) return;
    applyMove(pending.r, pending.c, HUMAN);
  };

  const interactive =
    !reviewing &&
    gameStatus === "playing" &&
    toPlay === HUMAN &&
    status.kind !== "thinking";
  const sizeLocked = started && gameStatus === "playing";
  const thinking = status.kind === "thinking";

  const statusLabel = (() => {
    if (status.kind === "thinking") return `${opponentLabel(opponent)} 생각 중…`;
    if (status.kind === "error") return status.message;
    if (status.kind === "ended") return status.text;
    if (reviewing) {
      const m = moves[viewPly - 1];
      const who = m ? (m.color === BLACK ? "흑" : "백") : "";
      const mv = m ? `${m.r},${m.c}` : "";
      return `열람 ${viewPly}/${moves.length}` + (m ? ` · ${who} ${mv}` : "");
    }
    const turn = toPlayShown === BLACK ? "흑" : "백";
    const who = toPlayShown === HUMAN ? "당신" : opponentLabel(opponent);
    return `${moveNoShown}수 · ${turn}(${who}) 차례`;
  })();

  const reviewControls = (
    <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.btn}
        onClick={continueFromView}
        disabled={!started || !reviewing || thinking}
        title="지금 보는 수부터 이후 기보를 버리고 이어둡니다"
      >
        이어두기
      </button>
      <button
        type="button"
        className={styles.btnPrimary}
        onClick={resumePlay}
        disabled={!reviewing || thinking}
        title="맨 마지막 수로 돌아가 이어서 둡니다"
      >
        다시 두기
      </button>
    </div>
  );

  if (!ready) {
    return (
      <div className={styles.page}>
        <div className={styles.status}>
          <strong>대국 불러오는 중…</strong>
        </div>
      </div>
    );
  }

  return (
    <div className={styles.page}>
      <header className={styles.top}>
        <h1 className={styles.brand}>Omook</h1>
        <div className={styles.topControls}>
          <select
            className={styles.sizeSelect}
            value={opponent}
            disabled={sizeLocked || thinking}
            aria-label="상대"
            onChange={(e) => {
              const mode = e.target.value as OpponentMode;
              setOpponent(mode);
              saveOpponentMode(mode);
              lastAiAskKey = "";
              if (
                gameStatusRef.current === "playing" &&
                toPlayRef.current === AI &&
                viewPlyRef.current >= movesRef.current.length
              ) {
                void askAi();
              }
            }}
          >
            <option value="local">로컬 AI</option>
            <option value="vlm">VLM</option>
          </select>
          <select
            className={styles.sizeSelect}
            value={size}
            disabled={sizeLocked}
            aria-label="판 크기"
            onChange={(e) => void resetGame(Number(e.target.value) as PlayBoardSize)}
          >
            {PLAY_SIZES.map((s) => (
              <option key={s} value={s}>
                {s}×{s}
              </option>
            ))}
          </select>
        </div>
      </header>

      <div className={styles.boardWrap}>
        <OmokBoard
          board={boardShown}
          size={size}
          lastMove={lastShown}
          pending={reviewing ? null : pending}
          banned={banned}
          onSelect={onSelect}
          interactive={interactive}
        />
      </div>

      <div className={styles.actions}>
        <button type="button" className={styles.btn} onClick={() => void resetGame()}>
          새 대국
        </button>
        <button
          type="button"
          className={styles.btnPrimary}
          disabled={!pending || !interactive}
          onClick={confirmPending}
        >
          착수
        </button>
        <button
          type="button"
          className={styles.btn}
          disabled={!pending || reviewing}
          onClick={() => setPending(null)}
        >
          취소
        </button>
      </div>

      {reviewControls}

      <div className={styles.status}>
        <strong className={thinking ? styles.waiting : undefined}>{statusLabel}</strong>
        <span>당신=흑 · {opponentLabel(opponent)}=백</span>
      </div>

      <nav className={styles.nav}>
        <Link href="/">홈</Link>
        <span className={styles.active}>대국</span>
      </nav>

      {showResult && status.kind === "ended" ? (
        <div className={styles.overlay} role="dialog">
          <div className={styles.dialog}>
            <h2>{status.text}</h2>
            <p>{moveNo}수 만에 종료</p>
            <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.btn}
                onClick={continueFromView}
                disabled={!reviewing || thinking}
              >
                이어두기
              </button>
              <button
                type="button"
                className={styles.btn}
                onClick={resumePlay}
                disabled={!reviewing}
              >
                다시 두기
              </button>
            </div>
            <button type="button" className={styles.btnPrimary} onClick={() => void resetGame()}>
              새 대국
            </button>
          </div>
        </div>
      ) : null}
    </div>
  );
}