예제 소스
장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 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>
);
}