From c8fa89dab9cb91f4f9efb23bf7c9580ad508a313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 15 Jul 2026 18:11:19 +0900 Subject: [PATCH] Pause between rounds, summarise the match, and localise to Korean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-round match is now the default. The bigger fix is that round transitions were invisible: the engine advances rounds atomically, so a round-ending move flashed straight to the next round's board and its scores were never shown. The client now holds at each round end (matchStep sets roundComplete instead of rolling over; matchAdvanceRound continues on a button). While held, the finished board stays up and an overlay shows that round's per-expedition breakdown and the running match total -- no legal move, the rival waits, and nothing advances until you choose to. The last round ends the match rather than pausing. The end panel now summarises the whole match in one table -- each round's total and the sum -- built from a new roundHistory on the match state. A single deal still shows its per-expedition breakdown as before. All UI text is Korean now: 승리 / 패배 / 무승부, the prompts, the menu, the score plaques, both score tables. Removed the explanatory noise that was cluttering the menu (the "seed shuffles the deck" and "match is decided on the total" blurbs, the policy/provider line). Verified end to end in a browser: default is three rounds, each round end pauses on its colour scores, and the final panel shows the three-round summary. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh --- web/src/App.tsx | 148 +++++++++++++---------- web/src/components/ResultCard.tsx | 127 -------------------- web/src/components/Scoreboard.tsx | 167 ++++++++++++++++++++++++++ web/src/game/cards.ts | 1 + web/src/game/match.ts | 50 ++++++-- web/src/game/matchObservation.test.ts | 76 +++++++----- web/src/game/persistence.ts | 3 + 7 files changed, 342 insertions(+), 230 deletions(-) delete mode 100644 web/src/components/ResultCard.tsx create mode 100644 web/src/components/Scoreboard.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 87cf0fa..1ea5b60 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Board } from "./components/Board"; import { Card, CardBack } from "./components/Card"; -import { ResultCard } from "./components/ResultCard"; +import { ResultCard, RoundOverlay } from "./components/Scoreboard"; import { cardColor, cardName } from "./game/cards"; import { boardScore, @@ -13,10 +13,12 @@ import { } from "./game/engine"; import { N_ROUNDS, + matchAdvanceRound, matchFromOrders, matchLegalActionMask, matchScore, matchStep, + roundScore, type MatchState, } from "./game/match"; import { MODEL_CODENAME, MODEL_HASH } from "./model/policy"; @@ -35,7 +37,8 @@ function loadMode(): Mode { const requested = new URLSearchParams(window.location.search).get("rounds"); if (requested === "3") return 3; if (requested === "1") return 1; - return window.localStorage.getItem(MODE_KEY) === "3" ? 3 : 1; + // The classic three-round match is the default; a stored "1" opts down to a deal. + return window.localStorage.getItem(MODE_KEY) === "1" ? 1 : 3; } // Off by default: taking moves back is a training aid, not how the game is played, @@ -137,7 +140,7 @@ function App() { const [seedDraft, setSeedDraft] = useState(""); const [resultOpen, setResultOpen] = useState(initial.resultOpen); const [policy, setPolicy] = useState(null); - const [modelMessage, setModelMessage] = useState("LOADING BOREALIS"); + const [modelMessage, setModelMessage] = useState("BOREALIS 불러오는 중"); const [thinking, setThinking] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); @@ -209,7 +212,7 @@ function App() { loadPolicy().then(({ policy: loaded, warning }) => { if (cancelled) return; setPolicy(loaded); - setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · BOREALIS`); + setModelMessage(warning ?? "BOREALIS"); }); return () => { cancelled = true; }; }, []); @@ -226,7 +229,8 @@ function App() { const rivalSuspended = canRedo; useEffect(() => { - if (!policy || rivalSuspended || match.done || state.toMove !== 1) return; + // No move while a finished round is being shown -- the player advances it. + if (!policy || rivalSuspended || match.done || match.roundComplete || state.toMove !== 1) return; const currentGeneration = generation.current; const timer = window.setTimeout(async () => { setThinking(true); @@ -240,7 +244,7 @@ function App() { action = await fallback.action(match); if (generation.current !== currentGeneration) return; setPolicy(fallback); - setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)"); + setModelMessage("휴리스틱 대체 (모델 오류)"); } if (generation.current !== currentGeneration) return; pushFrame({ @@ -250,7 +254,7 @@ function App() { }); } catch (error) { console.error("AI action failed even with heuristic fallback", error); - setModelMessage("MODEL INFERENCE ERROR"); + setModelMessage("모델 추론 오류"); } finally { if (generation.current === currentGeneration) setThinking(false); } @@ -258,6 +262,17 @@ function App() { return () => window.clearTimeout(timer); }, [policy, rivalSuspended, match, state.toMove]); + /** Deal the next round from the round-complete pause. */ + function advanceRound() { + generation.current += 1; + resetMotion(); + setFrames((items) => [...items.slice(0, cursor + 1), { + state: matchAdvanceRound(match), + selection: EMPTY_SELECTION, + }]); + setCursor(cursor + 1); + } + /** Deal a game. Without a seed this rolls a fresh one; the same seed always * reproduces the same deal, so `restart(seed)` also replays the current one. */ function restart(nextSeed: string = randomSeed(), nextMode: Mode = mode) { @@ -352,8 +367,8 @@ function App() { const outcome = scores[0] === scores[1] - ? "DRAW" - : scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"; + ? "무승부" + : scores[0] > scores[1] ? "승리" : "패배"; useEffect(() => { if (!match.done || loggedGame.current === gameId.current) return; @@ -402,7 +417,7 @@ function App() { coinFlips: match.coinFlips.slice(0, mode), moves, finalScores: scores, - roundScores: match.carry, + roundScores: match.roundHistory, outcome, finalState: match, policy: modelMessage, @@ -424,24 +439,26 @@ function App() { // turn the normal prompt still applies: you can simply play on from here. const reviewingRival = rivalSuspended && (match.done || state.toMove === 1); const status = reviewingRival - ? `Reviewing ply ${state.stepCount} — redo, or play on from here` + ? "되돌리는 중 — 다시 진행하려면 앞으로" : match.done ? outcome - : state.toMove === 1 - ? thinking ? "The rival is thinking ···" : "The rival's turn" - : selection.handSlot === null - ? "Play or discard a card" - : selection.placeType === null - ? `Choose a destination for ${cardName(selectedCard!)}` - : "Draw — deck or a discard pile"; + : match.roundComplete + ? `${match.roundIdx + 1}라운드 종료` + : state.toMove === 1 + ? thinking ? "상대가 생각 중이에요" : "상대 차례" + : selection.handSlot === null + ? "낼 카드나 버릴 카드를 고르세요" + : selection.placeType === null + ? `${cardName(selectedCard!)} 놓을 곳을 고르세요` + : "카드를 뽑으세요 — 덱 또는 버림패"; return (
-
-
THE RIVAL{modelMessage}
{scores[1]} +
+
상대{modelMessage}
{scores[1]}
-
+
{opponentHand.map((card) => ( state.handPublic[card] ? @@ -450,56 +467,50 @@ function App() {
- + {menuOpen && (
- - -
- + + +
+
- - A MATCH IS DECIDED ON THE SUMMED TOTAL · WHOEVER LEADS ON POINTS - OPENS THE NEXT ROUND - - - THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC + - {policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}
)}
{mode === N_ROUNDS && ( -
+
{Array.from({ length: N_ROUNDS }, (_, round) => ( - R{round + 1} + {round + 1} ))} - - {match.carry[0]} : {match.carry[1]} BANKED - + 누적 {match.carry[0]} : {match.carry[1]}
)} @@ -535,11 +544,11 @@ function App() { className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`} disabled={!legalDraw(DRAW_DECK)} onClick={() => commit(DRAW_DECK)} - aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`} + aria-label={`덱에서 뽑기, ${N_CARDS - state.drawPtr}장 남음`} > {N_CARDS - state.drawPtr} - CARDS LEFT + 남은 카드
@@ -548,7 +557,7 @@ function App() {

-
+
{renderedHand.map(({ card, slot }, position) => ( -
-
YOU {state.toMove === 0 && !match.done ? "YOUR TURN" : "EXPEDITION LEAD"}
{scores[0]} +
+
{state.toMove === 0 && !match.done ? "내 차례" : "현재 점수"}
{scores[0]}
{undoEnabled && ( <> - - )}
@@ -592,15 +601,22 @@ function App() { after React has already removed them from the table. */}