게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개

page.tsx

"use client";

import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { JanggiBoard } from "@/components/JanggiBoard";
import { KiboListModal } from "@/components/KiboListModal";
import { MoreGames } from "@/components/MoreGames";
import type { AiResponse } from "@/lib/ai";
import { Game, type GameSnapshot } from "@/lib/game";
import type { GameListItem, GameRow, MoveRec } from "@/lib/gameTypes";
import { getOrCreateGuestId, loadRememberedGame, rememberGame } from "@/lib/guest";
import {
  endTextFromGame,
  gameFromMoves,
  movesFromHistory,
  parseSnapshot,
  settingsFromRow,
} from "@/lib/kibo";
import {
  DEFAULT_SETTINGS,
  FORMATIONS,
  formationNameKo,
  inBoard,
  opposite,
  pieceEmpty,
  pieceGlyph,
  resultText,
  sideNameKo,
  type Formation,
  type GameMode,
  type GameSettings,
  type Move,
  type Pos,
  type Side,
} from "@/lib/types";
import styles from "./play.module.css";

const AI_VS_AI_MAX_PLY = 400;
const AI_SPEEDS = [
  { ms: 100, label: "빠르게" },
  { ms: 600, label: "보통" },
  { ms: 1500, label: "천천히" },
];

function isHumanTurn(mode: GameMode, side: Side): boolean {
  if (mode === "ai_vs_ai") return false;
  if (mode === "human_vs_human") return true;
  if (mode === "human_cho") return side === "cho";
  return side === "han";
}

function checkKingPos(game: Game): Pos | null {
  if (game.result() !== "playing") return null;
  const side = game.sideToMove();
  if (game.isCheck(side)) {
    const k = game.kingPos(side);
    return inBoard(k) ? k : null;
  }
  const other = opposite(side);
  if (game.isCheck(other)) {
    const k = game.kingPos(other);
    return inBoard(k) ? k : null;
  }
  return null;
}

function PlayInner() {
  const search = useSearchParams();
  const gameRef = useRef(new Game());
  const [tick, setTick] = useState(0);
  const [ready, setReady] = useState(false);
  const [guestId, setGuestId] = useState("");
  const [gameCode, setGameCode] = useState("");
  const [accessKey, setAccessKey] = useState("");
  const [epoch, setEpoch] = useState(0);
  const [settings, setSettings] = useState<GameSettings>(DEFAULT_SETTINGS);
  const [selected, setSelected] = useState<Pos | null>(null);
  const [targets, setTargets] = useState<Pos[]>([]);
  const [thinking, setThinking] = useState(false);
  const [showSetup, setShowSetup] = useState(false);
  const [draft, setDraft] = useState<GameSettings>(DEFAULT_SETTINGS);
  const [viewPly, setViewPly] = useState(0);
  const [showMore, setShowMore] = useState(false);
  const [showKibo, setShowKibo] = useState(false);
  const [aiPaused, setAiPaused] = useState(false);
  const [aiDelay, setAiDelay] = useState(600);

  const workerRef = useRef<Worker | null>(null);
  const requestIdRef = useRef(0);
  const thinkingRef = useRef(false);
  const guestIdRef = useRef("");
  const gameCodeRef = useRef("");
  const accessKeyRef = useRef("");
  const settingsRef = useRef(settings);
  const epochRef = useRef(0);
  const viewPlyRef = useRef(0);
  const persistTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const bump = useCallback(() => setTick((t) => t + 1), []);

  const game = gameRef.current;
  settingsRef.current = settings;
  epochRef.current = epoch;
  viewPlyRef.current = viewPly;
  guestIdRef.current = guestId;
  gameCodeRef.current = gameCode;
  accessKeyRef.current = accessKey;

  const liveMoves = useMemo(() => {
    void tick;
    return movesFromHistory(game.history());
  }, [tick, game]);

  const reviewing = viewPly < liveMoves.length;
  const reviewGame = useMemo(() => {
    if (!reviewing) return null;
    return gameFromMoves(settings, liveMoves, viewPly);
  }, [reviewing, settings, liveMoves, viewPly]);

  const display = reviewGame ?? game;
  const displaySnap = useMemo(() => {
    void tick;
    return display.snapshot();
  }, [tick, display, viewPly, reviewing]);

  const flipped = settings.mode === "human_han";
  const humanTurn =
    !reviewing &&
    game.result() === "playing" &&
    isHumanTurn(settings.mode, game.sideToMove()) &&
    !thinking;

  const refreshSelection = useCallback(() => {
    setSelected(null);
    setTargets([]);
  }, []);

  const persistNow = useCallback(async (opts?: { started?: boolean; moves?: MoveRec[]; snapshot?: GameSnapshot }) => {
    const code = gameCodeRef.current;
    if (!code) return;
    const s = settingsRef.current;
    const g = gameRef.current;
    const moves = opts?.moves ?? movesFromHistory(g.history());
    const snapshot = JSON.stringify(opts?.snapshot ?? g.snapshot());
    const ended = g.result() !== "playing";
    try {
      await fetch(`/api/game/${code}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          guestId: guestIdRef.current,
          accessKey: accessKeyRef.current,
          cho_formation: s.choFormation,
          han_formation: s.hanFormation,
          mode: s.mode,
          ai_depth: s.aiDepth,
          ai_depth_han: s.hanAiDepth,
          bikjang: s.bikjangEnabled,
          snapshot,
          move_no: moves.length,
          epoch: epochRef.current,
          started: opts?.started ?? true,
          status: ended ? "ended" : "playing",
          end_text: endTextFromGame(g),
          moves,
        }),
      });
    } catch {
      /* ignore */
    }
  }, []);

  const schedulePersist = useCallback(() => {
    if (persistTimer.current) clearTimeout(persistTimer.current);
    persistTimer.current = setTimeout(() => {
      void persistNow();
    }, 200);
  }, [persistNow]);

  const applyAiMove = useCallback(
    (move: Move) => {
      thinkingRef.current = false;
      setThinking(false);
      if (viewPlyRef.current < movesFromHistory(game.history()).length) return;
      if (move.pass) game.passTurn();
      else game.tryMove(move.from, move.to);
      refreshSelection();
      setViewPly(game.history().length);
      bump();
      schedulePersist();
    },
    [bump, game, refreshSelection, schedulePersist],
  );

  useEffect(() => {
    const worker = new Worker(new URL("../../lib/ai.worker.ts", import.meta.url));
    workerRef.current = worker;
    worker.onmessage = (event: MessageEvent<AiResponse>) => {
      if (event.data.requestId !== requestIdRef.current) return;
      applyAiMove(event.data.move);
    };
    return () => {
      worker.terminate();
      workerRef.current = null;
    };
  }, [applyAiMove]);

  const requestAi = useCallback(() => {
    if (reviewing) return;
    if (game.result() !== "playing") return;
    if (isHumanTurn(settings.mode, game.sideToMove())) return;
    if (thinkingRef.current) return;
    thinkingRef.current = true;
    setThinking(true);
    const requestId = ++requestIdRef.current;
    const depth =
      settings.mode === "ai_vs_ai" && game.sideToMove() === "han" ? settings.hanAiDepth : settings.aiDepth;
    const worker = workerRef.current;
    if (worker) {
      worker.postMessage({
        requestId,
        snapshot: game.snapshot(),
        depth,
      });
    } else {
      void import("@/lib/ai").then(({ chooseMove }) => {
        if (requestId !== requestIdRef.current) return;
        applyAiMove(chooseMove(game.snapshot(), depth));
      });
    }
  }, [applyAiMove, game, reviewing, settings.aiDepth, settings.hanAiDepth, settings.mode]);

  const aiVsAi = settings.mode === "ai_vs_ai";
  const plyLimitReached = aiVsAi && game.history().length >= AI_VS_AI_MAX_PLY;

  useEffect(() => {
    if (!ready || showSetup || reviewing) return;
    if (game.result() !== "playing") return;
    if (isHumanTurn(settings.mode, game.sideToMove())) return;
    if (aiVsAi && (aiPaused || plyLimitReached)) return;
    const t = window.setTimeout(() => requestAi(), aiVsAi ? aiDelay : 80);
    return () => window.clearTimeout(t);
  }, [
    tick,
    ready,
    showSetup,
    reviewing,
    settings.mode,
    game,
    requestAi,
    aiVsAi,
    aiPaused,
    aiDelay,
    plyLimitReached,
  ]);

  const loadRowIntoGame = useCallback(
    (row: GameRow) => {
      rememberGame(row.code, row.access_key);
      const nextSettings = settingsFromRow(row);
      setGameCode(row.code);
      setAccessKey(row.access_key);
      setSettings(nextSettings);
      setDraft(nextSettings);
      setAiPaused(nextSettings.mode === "ai_vs_ai");
      setEpoch(row.epoch);
      const snap = parseSnapshot(row.snapshot);
      // 스냅샷이 있으면 저장 시점 국면을 그대로 복원 (반복수 키 포함)
      const rebuilt =
        snap && (snap.history?.length ?? 0) === row.moves.length
          ? Game.fromSnapshot(snap)
          : row.moves.length > 0
            ? gameFromMoves(nextSettings, row.moves)
            : snap
              ? Game.fromSnapshot(snap)
              : gameFromMoves(nextSettings, []);
      gameRef.current = rebuilt;
      requestIdRef.current += 1;
      thinkingRef.current = false;
      setThinking(false);
      refreshSelection();
      // 저장된 마지막 수로 바로 진입 (열람은 이전/이후로)
      setViewPly(rebuilt.history().length);
      setShowSetup(false);
      bump();
    },
    [bump, refreshSelection],
  );

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const gid = getOrCreateGuestId();
        if (cancelled) return;
        setGuestId(gid);

        const fromUrl = search.get("g")?.trim().toUpperCase();
        const keyFromUrl = search.get("k")?.trim();
        if (fromUrl) {
          const res = await fetch(
            `/api/game/${fromUrl}?guestId=${encodeURIComponent(gid)}&key=${encodeURIComponent(keyFromUrl || "")}`,
          );
          if (res.ok) {
            const data = (await res.json()) as { game: GameRow };
            if (!cancelled) {
              loadRowIntoGame(data.game);
              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 (data.game && !cancelled) {
            loadRowIntoGame(data.game);
            setReady(true);
            return;
          }
        }

        const remembered = loadRememberedGame();
        if (remembered) {
          const res = await fetch(
            `/api/game/${remembered.code}?guestId=${encodeURIComponent(gid)}&key=${encodeURIComponent(remembered.accessKey)}`,
          );
          if (res.ok) {
            const data = (await res.json()) as { game: GameRow };
            if (!cancelled) {
              loadRowIntoGame(data.game);
              setReady(true);
              return;
            }
          }
        }

        if (!cancelled) {
          setShowSetup(true);
          setReady(true);
        }
      } catch {
        if (!cancelled) {
          setShowSetup(true);
          setReady(true);
        }
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [loadRowIntoGame, search]);

  const openKiboItem = useCallback(
    async (item: GameListItem) => {
      const gid = guestIdRef.current || getOrCreateGuestId();
      rememberGame(item.code, item.access_key);
      const res = await fetch(
        `/api/game/${item.code}?guestId=${encodeURIComponent(gid)}&key=${encodeURIComponent(item.access_key)}`,
      );
      if (!res.ok) {
        window.alert("기보를 열 수 없습니다.");
        return;
      }
      const data = (await res.json()) as { game: GameRow };
      loadRowIntoGame(data.game);
    },
    [loadRowIntoGame],
  );

  const createServerGame = useCallback(
    async (next: GameSettings, g: Game) => {
      const gid = guestIdRef.current || getOrCreateGuestId();
      setGuestId(gid);
      const res = await fetch("/api/game", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          guestId: gid,
          cho_formation: next.choFormation,
          han_formation: next.hanFormation,
          mode: next.mode,
          ai_depth: next.aiDepth,
          ai_depth_han: next.hanAiDepth,
          bikjang: next.bikjangEnabled,
          snapshot: JSON.stringify(g.snapshot()),
        }),
      });
      if (!res.ok) throw new Error("새 대국 저장 실패");
      const data = (await res.json()) as { game: GameRow };
      rememberGame(data.game.code, data.game.access_key);
      gameCodeRef.current = data.game.code;
      accessKeyRef.current = data.game.access_key;
      epochRef.current = data.game.epoch;
      setGameCode(data.game.code);
      setAccessKey(data.game.access_key);
      setEpoch(data.game.epoch);
      await persistNow({ started: true, moves: [], snapshot: g.snapshot() });
    },
    [persistNow],
  );

  const startGame = async (next: GameSettings) => {
    requestIdRef.current += 1;
    thinkingRef.current = false;
    setThinking(false);
    setSettings(next);
    setAiPaused(false);
    const g = new Game();
    g.newGame(next.choFormation, next.hanFormation);
    g.setBikjangEnabled(next.bikjangEnabled);
    gameRef.current = g;
    refreshSelection();
    setViewPly(0);
    setShowSetup(false);
    bump();
    try {
      await createServerGame(next, g);
    } catch (e) {
      window.alert(String(e));
    }
  };

  const onSelect = (pos: Pos) => {
    if (!humanTurn) return;
    if (selected) {
      if (game.tryMove(selected, pos)) {
        refreshSelection();
        setViewPly(game.history().length);
        bump();
        schedulePersist();
        return;
      }
    }
    const piece = game.at(pos);
    if (!pieceEmpty(piece) && piece.side === game.sideToMove()) {
      setSelected(pos);
      setTargets(game.legalTargets(pos));
      return;
    }
    refreshSelection();
  };

  const undoMove = () => {
    if (thinking || reviewing) return;
    if (aiVsAi && !aiPaused) return;
    if (!game.undo()) return;
    if (settings.mode !== "human_vs_human" && !aiVsAi) game.undo();
    refreshSelection();
    setViewPly(game.history().length);
    bump();
    schedulePersist();
  };

  const passMove = () => {
    if (!humanTurn || !game.passTurn()) return;
    refreshSelection();
    setViewPly(game.history().length);
    bump();
    schedulePersist();
  };

  const resignGame = () => {
    if (!humanTurn) return;
    if (!window.confirm("이번 대국을 기권할까요?")) return;
    game.resign(game.sideToMove());
    bump();
    schedulePersist();
  };

  const claimBikjang = () => {
    if (!game.claimBikjang()) return;
    bump();
    schedulePersist();
    window.alert("빅장으로 무승부입니다.");
  };

  const stepView = (delta: number) => {
    const n = liveMoves.length;
    const next = Math.max(0, Math.min(n, viewPly + delta));
    setViewPly(next);
    refreshSelection();
  };

  const continueFromView = () => {
    if (!reviewing || thinking) return;
    const ply = viewPly;
    const truncated = liveMoves.slice(0, ply).map((m, i) => ({ ...m, ply: i + 1 }));
    const rebuilt = gameFromMoves(settings, truncated);
    gameRef.current = rebuilt;
    setEpoch((e) => (e + 1) >>> 0);
    requestIdRef.current += 1;
    thinkingRef.current = false;
    setThinking(false);
    setViewPly(ply);
    refreshSelection();
    bump();
    void persistNow({ started: true, moves: truncated, snapshot: rebuilt.snapshot() });
  };

  const turnSource = display;
  const turnLabel =
    turnSource.result() !== "playing"
      ? resultText(turnSource.result())
      : `${sideNameKo(turnSource.sideToMove())} 차례 · ${
          reviewing
            ? "열람"
            : aiVsAi
              ? `AI (깊이 ${turnSource.sideToMove() === "han" ? settings.hanAiDepth : settings.aiDepth})`
              : isHumanTurn(settings.mode, turnSource.sideToMove())
                ? "당신"
                : "컴퓨터"
        }`;

  let checkLabel = "";
  if (turnSource.result() === "playing") {
    if (turnSource.isCheck(turnSource.sideToMove())) checkLabel = "장군!";
    else if (turnSource.isBikjang()) checkLabel = "빅장 (궁이 마주 봄)";
  }

  const last = turnSource.lastMove();
  const choScore = (turnSource.pieceScore("cho") / 10).toFixed(1);
  const hanScore = (turnSource.pieceScore("han") / 10).toFixed(1);

  const captureText = (side: Side) => {
    const pieces = turnSource.capturedBy(side);
    const head = side === "cho" ? "초: " : "한: ";
    if (!pieces.length) return head + "없음";
    return head + pieces.map((p) => pieceGlyph(p)).join(" ");
  };

  const canBikjang =
    humanTurn &&
    game.bikjangEnabled() &&
    game.isBikjang() &&
    !game.isCheck(game.sideToMove());

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

  return (
    <div className={styles.page}>
      <div className={styles.topBar}>
        <h1 className={styles.brand}>장기</h1>
        <Link href="/" className={styles.homeLink}>
          홈
        </Link>
      </div>

      <div className={styles.layout}>
        <JanggiBoard
          squares={displaySnap.squares}
          selected={reviewing ? null : selected}
          targets={reviewing ? [] : targets}
          lastMove={
            last && !last.pass
              ? { from: last.from, to: last.to, pass: false }
              : last?.pass
                ? { from: last.from, to: last.to, pass: true }
                : null
          }
          checkKing={checkKingPos(turnSource)}
          flipped={flipped}
          interactive={humanTurn}
          onSelect={onSelect}
        />

        <aside className={styles.side}>
          <p className={styles.turn}>{turnLabel}</p>
          <p className={styles.check}>{checkLabel}</p>
          <p className={styles.score}>
            점수 초 {choScore} · 한 {hanScore}
            {gameCode ? ` · ${gameCode}` : ""}
          </p>
          {thinking ? <p className={styles.thinking}>컴퓨터가 수를 고르는 중...</p> : null}
          {aiVsAi && !reviewing && game.result() === "playing" ? (
            <div className={styles.reviewRow}>
              <button
                type="button"
                className={styles.btnPrimary}
                disabled={plyLimitReached}
                onClick={() => setAiPaused((p) => !p)}
              >
                {aiPaused ? "재개" : "일시정지"}
              </button>
              <select
                className={styles.speedSelect}
                value={aiDelay}
                onChange={(e) => setAiDelay(Number(e.target.value))}
                aria-label="진행 속도"
              >
                {AI_SPEEDS.map((s) => (
                  <option key={s.ms} value={s.ms}>
                    {s.label}
                  </option>
                ))}
              </select>
            </div>
          ) : null}
          {plyLimitReached && game.result() === "playing" ? (
            <p className={styles.thinking}>{AI_VS_AI_MAX_PLY}수에 도달해 멈췄습니다.</p>
          ) : null}
          {reviewing ? (
            <p className={styles.thinking}>
              기보 열람 {viewPly}/{liveMoves.length}
            </p>
          ) : null}

          <div className={styles.reviewRow}>
            <button type="button" className={styles.btn} disabled={viewPly <= 0} onClick={() => stepView(-1)}>
              이전
            </button>
            <span className={styles.reviewPos}>
              {viewPly}/{liveMoves.length}
            </span>
            <button
              type="button"
              className={styles.btn}
              disabled={viewPly >= liveMoves.length}
              onClick={() => stepView(1)}
            >
              이후
            </button>
          </div>
          <div className={styles.reviewRow}>
            <button
              type="button"
              className={styles.btn}
              disabled={!reviewing || thinking}
              onClick={continueFromView}
            >
              이어 두기
            </button>
            <button
              type="button"
              className={styles.btnPrimary}
              disabled={thinking}
              onClick={() => {
                setDraft(settings);
                setShowSetup(true);
              }}
            >
              새게임
            </button>
          </div>

          <p className={styles.section}>잡은 말</p>
          <p className={styles.captured}>{captureText("cho")}</p>
          <p className={styles.captured}>{captureText("han")}</p>

          <p className={styles.section}>기보</p>
          <ul className={styles.history}>
            {display.history().map((m, i) => (
              <li key={`${i}-${m.pass ? "p" : `${m.from.file}${m.from.rank}-${m.to.file}${m.to.rank}`}`}>
                {i + 1}. {display.moveText(m)}
              </li>
            ))}
          </ul>

          <div className={styles.actions}>
            <button
              type="button"
              className={styles.btn}
              disabled={thinking || reviewing || !game.history().length || (aiVsAi && !aiPaused)}
              onClick={undoMove}
            >
              무르기
            </button>
            <button
              type="button"
              className={styles.btn}
              disabled={!humanTurn || game.isCheck(game.sideToMove())}
              onClick={passMove}
            >
              한수쉼
            </button>
            <button type="button" className={styles.btn} disabled={!canBikjang} onClick={claimBikjang}>
              빅장 선언
            </button>
            <button type="button" className={styles.btn} disabled={!humanTurn} onClick={resignGame}>
              기권
            </button>
          </div>
        </aside>
      </div>

      <nav className={styles.nav}>
        <Link href="/">홈</Link>
        <span className={styles.active}>대국</span>
        <button type="button" className={styles.navBtn} onClick={() => setShowKibo(true)}>
          기보
        </button>
        <button type="button" className={styles.navBtn} onClick={() => setShowMore(true)}>
          다른 게임
        </button>
      </nav>

      <MoreGames open={showMore} onClose={() => setShowMore(false)} excludeId="janggy" />
      <KiboListModal open={showKibo} onClose={() => setShowKibo(false)} onSelect={(g) => void openKiboItem(g)} />

      {showSetup ? (
        <div className={styles.modalBackdrop}>
          <div className={styles.modal} role="dialog" aria-modal="true" aria-label="새 대국">
            <h2>새 대국</h2>
            <p>상·마 배치를 고른 뒤 대국을 시작하세요. 초(楚)가 먼저 둡니다.</p>

            <div className={styles.formRow}>
              <label htmlFor="cho-f">초 배치</label>
              <select
                id="cho-f"
                value={draft.choFormation}
                onChange={(e) =>
                  setDraft((d) => ({ ...d, choFormation: e.target.value as Formation }))
                }
              >
                {FORMATIONS.map((f) => (
                  <option key={f} value={f}>
                    {formationNameKo(f)}
                  </option>
                ))}
              </select>
            </div>

            <div className={styles.formRow}>
              <label htmlFor="han-f">한 배치</label>
              <select
                id="han-f"
                value={draft.hanFormation}
                onChange={(e) =>
                  setDraft((d) => ({ ...d, hanFormation: e.target.value as Formation }))
                }
              >
                {FORMATIONS.map((f) => (
                  <option key={f} value={f}>
                    {formationNameKo(f)}
                  </option>
                ))}
              </select>
            </div>

            <div className={styles.formRow}>
              <label htmlFor="mode">대국 방식</label>
              <select
                id="mode"
                value={draft.mode}
                onChange={(e) => setDraft((d) => ({ ...d, mode: e.target.value as GameMode }))}
              >
                <option value="human_cho">사람과 컴퓨터 (내가 초)</option>
                <option value="human_han">사람과 컴퓨터 (내가 한)</option>
                <option value="human_vs_human">사람과 사람</option>
                <option value="ai_vs_ai">AI 대 AI (관전)</option>
              </select>
            </div>

            <div className={styles.formRow}>
              <label htmlFor="depth">{draft.mode === "ai_vs_ai" ? "초 AI 깊이" : "컴퓨터 깊이"}</label>
              <select
                id="depth"
                value={draft.aiDepth}
                onChange={(e) => setDraft((d) => ({ ...d, aiDepth: Number(e.target.value) }))}
              >
                {[1, 2, 3, 4].map((n) => (
                  <option key={n} value={n}>
                    {n} 수 앞
                  </option>
                ))}
              </select>
            </div>

            {draft.mode === "ai_vs_ai" ? (
              <div className={styles.formRow}>
                <label htmlFor="depth-han">한 AI 깊이</label>
                <select
                  id="depth-han"
                  value={draft.hanAiDepth}
                  onChange={(e) => setDraft((d) => ({ ...d, hanAiDepth: Number(e.target.value) }))}
                >
                  {[1, 2, 3, 4].map((n) => (
                    <option key={n} value={n}>
                      {n} 수 앞
                    </option>
                  ))}
                </select>
              </div>
            ) : null}

            <label className={styles.checkRow}>
              <input
                type="checkbox"
                checked={draft.bikjangEnabled}
                onChange={(e) => setDraft((d) => ({ ...d, bikjangEnabled: e.target.checked }))}
              />
              빅장이면 무승부 선언 가능
            </label>

            <div className={styles.modalActions}>
              <button
                type="button"
                className={styles.btn}
                onClick={() => setShowSetup(false)}
                disabled={!gameCode}
              >
                취소
              </button>
              <button type="button" className={styles.btnPrimary} onClick={() => void startGame(draft)}>
                대국 시작
              </button>
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
}

export default function PlayPage() {
  return (
    <Suspense
      fallback={
        <div className={styles.page}>
          <p className={styles.thinking}>대국 불러오는 중…</p>
        </div>
      }
    >
      <PlayInner />
    </Suspense>
  );
}