Pause between rounds, summarise the match, and localise to Korean

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
This commit is contained in:
2026-07-15 18:11:19 +09:00
co-authored by Claude Opus 4.8
parent 267db367d8
commit c8fa89dab9
7 changed files with 342 additions and 230 deletions
+82 -66
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Board } from "./components/Board"; import { Board } from "./components/Board";
import { Card, CardBack } from "./components/Card"; import { Card, CardBack } from "./components/Card";
import { ResultCard } from "./components/ResultCard"; import { ResultCard, RoundOverlay } from "./components/Scoreboard";
import { cardColor, cardName } from "./game/cards"; import { cardColor, cardName } from "./game/cards";
import { import {
boardScore, boardScore,
@@ -13,10 +13,12 @@ import {
} from "./game/engine"; } from "./game/engine";
import { import {
N_ROUNDS, N_ROUNDS,
matchAdvanceRound,
matchFromOrders, matchFromOrders,
matchLegalActionMask, matchLegalActionMask,
matchScore, matchScore,
matchStep, matchStep,
roundScore,
type MatchState, type MatchState,
} from "./game/match"; } from "./game/match";
import { MODEL_CODENAME, MODEL_HASH } from "./model/policy"; import { MODEL_CODENAME, MODEL_HASH } from "./model/policy";
@@ -35,7 +37,8 @@ function loadMode(): Mode {
const requested = new URLSearchParams(window.location.search).get("rounds"); const requested = new URLSearchParams(window.location.search).get("rounds");
if (requested === "3") return 3; if (requested === "3") return 3;
if (requested === "1") return 1; 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, // 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 [seedDraft, setSeedDraft] = useState("");
const [resultOpen, setResultOpen] = useState(initial.resultOpen); const [resultOpen, setResultOpen] = useState(initial.resultOpen);
const [policy, setPolicy] = useState<Policy | null>(null); const [policy, setPolicy] = useState<Policy | null>(null);
const [modelMessage, setModelMessage] = useState("LOADING BOREALIS"); const [modelMessage, setModelMessage] = useState("BOREALIS 불러오는 중");
const [thinking, setThinking] = useState(false); const [thinking, setThinking] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
@@ -209,7 +212,7 @@ function App() {
loadPolicy().then(({ policy: loaded, warning }) => { loadPolicy().then(({ policy: loaded, warning }) => {
if (cancelled) return; if (cancelled) return;
setPolicy(loaded); setPolicy(loaded);
setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · BOREALIS`); setModelMessage(warning ?? "BOREALIS");
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
@@ -226,7 +229,8 @@ function App() {
const rivalSuspended = canRedo; const rivalSuspended = canRedo;
useEffect(() => { 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 currentGeneration = generation.current;
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
setThinking(true); setThinking(true);
@@ -240,7 +244,7 @@ function App() {
action = await fallback.action(match); action = await fallback.action(match);
if (generation.current !== currentGeneration) return; if (generation.current !== currentGeneration) return;
setPolicy(fallback); setPolicy(fallback);
setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)"); setModelMessage("휴리스틱 대체 (모델 오류)");
} }
if (generation.current !== currentGeneration) return; if (generation.current !== currentGeneration) return;
pushFrame({ pushFrame({
@@ -250,7 +254,7 @@ function App() {
}); });
} catch (error) { } catch (error) {
console.error("AI action failed even with heuristic fallback", error); console.error("AI action failed even with heuristic fallback", error);
setModelMessage("MODEL INFERENCE ERROR"); setModelMessage("모델 추론 오류");
} finally { } finally {
if (generation.current === currentGeneration) setThinking(false); if (generation.current === currentGeneration) setThinking(false);
} }
@@ -258,6 +262,17 @@ function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [policy, rivalSuspended, match, state.toMove]); }, [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 /** 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. */ * reproduces the same deal, so `restart(seed)` also replays the current one. */
function restart(nextSeed: string = randomSeed(), nextMode: Mode = mode) { function restart(nextSeed: string = randomSeed(), nextMode: Mode = mode) {
@@ -352,8 +367,8 @@ function App() {
const outcome = scores[0] === scores[1] const outcome = scores[0] === scores[1]
? "DRAW" ? "무승부"
: scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"; : scores[0] > scores[1] ? "승리" : "패배";
useEffect(() => { useEffect(() => {
if (!match.done || loggedGame.current === gameId.current) return; if (!match.done || loggedGame.current === gameId.current) return;
@@ -402,7 +417,7 @@ function App() {
coinFlips: match.coinFlips.slice(0, mode), coinFlips: match.coinFlips.slice(0, mode),
moves, moves,
finalScores: scores, finalScores: scores,
roundScores: match.carry, roundScores: match.roundHistory,
outcome, outcome,
finalState: match, finalState: match,
policy: modelMessage, policy: modelMessage,
@@ -424,24 +439,26 @@ function App() {
// turn the normal prompt still applies: you can simply play on from here. // turn the normal prompt still applies: you can simply play on from here.
const reviewingRival = rivalSuspended && (match.done || state.toMove === 1); const reviewingRival = rivalSuspended && (match.done || state.toMove === 1);
const status = reviewingRival const status = reviewingRival
? `Reviewing ply ${state.stepCount} — redo, or play on from here` ? "되돌리는 중 — 다시 진행하려면 앞으로"
: match.done : match.done
? outcome ? outcome
: state.toMove === 1 : match.roundComplete
? thinking ? "The rival is thinking ···" : "The rival's turn" ? `${match.roundIdx + 1}라운드 종료`
: selection.handSlot === null : state.toMove === 1
? "Play or discard a card" ? thinking ? "상대가 생각 중이에요" : "상대 차례"
: selection.placeType === null : selection.handSlot === null
? `Choose a destination for ${cardName(selectedCard!)}` ? "낼 카드나 버릴 카드를 고르세요"
: "Draw — deck or a discard pile"; : selection.placeType === null
? `${cardName(selectedCard!)} 놓을 곳을 고르세요`
: "카드를 뽑으세요 — 덱 또는 버림패";
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="score-plaque score-plaque--rival" aria-label={`Rival score ${scores[1]}`}> <section className="score-plaque score-plaque--rival" aria-label={`상대 점수 ${scores[1]}`}>
<div><strong>THE RIVAL</strong><small>{modelMessage}</small></div><b>{scores[1]}</b> <div><strong></strong><small>{modelMessage}</small></div><b>{scores[1]}</b>
</section> </section>
<div className="opponent-hand" aria-label="Rival hand"> <div className="opponent-hand" aria-label="상대 손패">
{opponentHand.map((card) => ( {opponentHand.map((card) => (
state.handPublic[card] state.handPublic[card]
? <Card mini card={card} key={card} innerRef={cardRef(card)} /> ? <Card mini card={card} key={card} innerRef={cardRef(card)} />
@@ -450,56 +467,50 @@ function App() {
</div> </div>
<div className="menu-wrap"> <div className="menu-wrap">
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button> <button className="menu-button" onClick={() => setMenuOpen((open) => !open)}></button>
{menuOpen && ( {menuOpen && (
<div className="menu-popover"> <div className="menu-popover">
<button onClick={() => restart()}>NEW GAME</button> <button onClick={() => restart()}> </button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button> <button onClick={() => restart(seed)}> </button>
<div className="menu-modes" role="group" aria-label="Game length"> <div className="menu-modes" role="group" aria-label="게임 길이">
<button
className={mode === 1 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 1)}
>
ONE DEAL
</button>
<button <button
className={mode === 3 ? "is-active" : ""} className={mode === 3 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 3)} onClick={() => restart(randomSeed(), 3)}
> >
MATCH · 3 ROUNDS 3
</button>
<button
className={mode === 1 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 1)}
>
</button> </button>
</div> </div>
<span>
A MATCH IS DECIDED ON THE SUMMED TOTAL · WHOEVER LEADS ON POINTS
OPENS THE NEXT ROUND
</span>
<label className="menu-seed"> <label className="menu-seed">
<span>DEAL SEED · {seed}</span> <span> · {seed}</span>
<input <input
value={seedDraft} value={seedDraft}
placeholder="load a deal seed…" placeholder="시드 입력…"
onChange={(event) => setSeedDraft(event.target.value)} onChange={(event) => setSeedDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }} onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }}
aria-label="Load a deal by seed" aria-label="시드로 딜 불러오기"
/> />
</label> </label>
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button> <button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}> </button>
<span>THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC</span>
<label className="menu-toggle"> <label className="menu-toggle">
<input <input
type="checkbox" type="checkbox"
checked={undoEnabled} checked={undoEnabled}
onChange={(event) => setUndoEnabled(event.target.checked)} onChange={(event) => setUndoEnabled(event.target.checked)}
/> />
<span>ENABLE UNDO / REDO · WITHIN YOUR TURN</span> <span> / · </span>
</label> </label>
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span>
</div> </div>
)} )}
</div> </div>
{mode === N_ROUNDS && ( {mode === N_ROUNDS && (
<div className="round-strip" aria-label={`Round ${match.roundIdx + 1} of ${N_ROUNDS}`}> <div className="round-strip" aria-label={`${N_ROUNDS}라운드 중 ${match.roundIdx + 1}라운드`}>
{Array.from({ length: N_ROUNDS }, (_, round) => ( {Array.from({ length: N_ROUNDS }, (_, round) => (
<span <span
key={round} key={round}
@@ -507,12 +518,10 @@ function App() {
round < match.roundIdx ? "is-done" : round === match.roundIdx ? "is-live" : "" round < match.roundIdx ? "is-done" : round === match.roundIdx ? "is-live" : ""
} }
> >
R{round + 1} {round + 1}
</span> </span>
))} ))}
<em> <em> {match.carry[0]} : {match.carry[1]}</em>
{match.carry[0]} : {match.carry[1]} BANKED
</em>
</div> </div>
)} )}
@@ -535,11 +544,11 @@ function App() {
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`} className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`}
disabled={!legalDraw(DRAW_DECK)} disabled={!legalDraw(DRAW_DECK)}
onClick={() => commit(DRAW_DECK)} onClick={() => commit(DRAW_DECK)}
aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`} aria-label={`덱에서 뽑기, ${N_CARDS - state.drawPtr}장 남음`}
> >
<CardBack innerRef={deckRef} /> <CardBack innerRef={deckRef} />
<strong>{N_CARDS - state.drawPtr}</strong> <strong>{N_CARDS - state.drawPtr}</strong>
<span>CARDS LEFT</span> <span> </span>
</button> </button>
<div className="prompt-row"> <div className="prompt-row">
@@ -548,7 +557,7 @@ function App() {
</p> </p>
</div> </div>
<section className="human-hand" aria-label="Your hand"> <section className="human-hand" aria-label="내 손패">
{renderedHand.map(({ card, slot }, position) => ( {renderedHand.map(({ card, slot }, position) => (
<Card <Card
card={card} card={card}
@@ -562,29 +571,29 @@ function App() {
))} ))}
</section> </section>
<section className="score-plaque score-plaque--human" aria-label={`Your score ${scores[0]}`}> <section className="score-plaque score-plaque--human" aria-label={`내 점수 ${scores[0]}`}>
<div><strong>YOU <i /></strong><small>{state.toMove === 0 && !match.done ? "YOUR TURN" : "EXPEDITION LEAD"}</small></div><b>{scores[0]}</b> <div><strong> <i /></strong><small>{state.toMove === 0 && !match.done ? "내 차례" : "현재 점수"}</small></div><b>{scores[0]}</b>
</section> </section>
<div className="control-stack"> <div className="control-stack">
{undoEnabled && ( {undoEnabled && (
<> <>
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)"> <button type="button" onClick={undo} disabled={!canUndo} aria-label="한 단계 되돌리기" title="되돌리기 (←)">
<span>UNDO</span> <span></span>
</button> </button>
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)"> <button type="button" onClick={redo} disabled={!canRedo} aria-label="한 단계 다시" title="다시 (→)">
<span>REDO</span> <span></span>
</button> </button>
</> </>
)} )}
<button <button
type="button" type="button"
className={`control-stack__score ${state.done && !resultOpen ? "is-available" : ""}`} className={`control-stack__score ${match.done && !resultOpen ? "is-available" : ""}`}
onClick={() => setResultOpen(true)} onClick={() => setResultOpen(true)}
disabled={!state.done || resultOpen} disabled={!match.done || resultOpen}
aria-hidden={!state.done || resultOpen} aria-hidden={!match.done || resultOpen}
> >
SCORE
</button> </button>
</div> </div>
@@ -592,15 +601,22 @@ function App() {
after React has already removed them from the table. */} after React has already removed them from the table. */}
<div className="motion-overlay" ref={overlayRef} aria-hidden="true" /> <div className="motion-overlay" ref={overlayRef} aria-hidden="true" />
{state.done && resultOpen && ( {match.roundComplete && (
<RoundOverlay
state={state}
roundIdx={match.roundIdx}
totalRounds={mode}
matchTotals={scores}
onContinue={advanceRound}
/>
)}
{match.done && resultOpen && (
<ResultCard <ResultCard
state={state} state={state}
outcome={outcome} outcome={outcome}
seed={seed} roundHistory={mode > 1 ? match.roundHistory : [roundScore(match)]}
carry={match.carry}
roundIdx={match.roundIdx}
totalRounds={mode} totalRounds={mode}
onReview={() => setResultOpen(false)}
onPlayAgain={() => restart()} onPlayAgain={() => restart()}
/> />
)} )}
-127
View File
@@ -1,127 +0,0 @@
import { COLOR_GLYPHS, COLOR_HEX, EXPEDITION_NAMES } from "../game/cards";
import { scoreBreakdown } from "../game/scoring";
import type { GameState } from "../game/types";
interface ResultCardProps {
state: GameState;
outcome: string;
seed: string;
/**
* Rounds already banked, when this ends a match. The table below breaks down the
* board in front of you -- the final round -- but a match is decided on the sum,
* so the headline and the total have to carry the earlier rounds or they name
* the wrong winner.
*/
carry?: [number, number];
roundIdx?: number;
totalRounds?: number;
onReview: () => void;
onPlayAgain: () => void;
}
function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
export function ResultCard({
state,
outcome,
seed,
carry = [0, 0],
roundIdx = 0,
totalRounds = 1,
onReview,
onPlayAgain,
}: ResultCardProps) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
const isMatch = totalRounds > 1;
const matchTotals: [number, number] = [carry[0] + you.total, carry[1] + rival.total];
return (
<div className="result-overlay">
<section className="result-card" aria-label="Final score">
<header className="result-card__head">
<p>{isMatch ? `MATCH COMPLETE · ${totalRounds} ROUNDS` : "ROUND COMPLETE"}</p>
<h1>{outcome}</h1>
<strong>{matchTotals[0]} <i>:</i> {matchTotals[1]}</strong>
{isMatch && (
<small className="result-card__banked">
ROUND {roundIdx + 1} · {signed(you.total)} : {signed(rival.total)}
{" · BANKED "}
{signed(carry[0])} : {signed(carry[1])}
</small>
)}
</header>
<table className="result-table">
<thead>
<tr>
<th scope="col">EXPEDITION</th>
<th scope="col" colSpan={2}>YOU</th>
<th scope="col" colSpan={2}>THE RIVAL</th>
</tr>
<tr className="result-table__subhead">
<th scope="col" />
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
</tr>
</thead>
<tbody>
{EXPEDITION_NAMES.map((name, color) => {
const mine = you.colors[color];
const theirs = rival.colors[color];
return (
<tr key={name}>
<th scope="row" style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
<i>{COLOR_GLYPHS[color]}</i>{name}
</th>
<td className="result-table__detail">
{mine.cards ? `${mine.cards}${mine.handshakes ? ` ·×${mine.handshakes + 1}` : ""}` : "—"}
</td>
<td className={mine.score < 0 ? "is-negative" : mine.score > 0 ? "is-positive" : ""}>
{mine.cards ? signed(mine.score) : "—"}
</td>
<td className="result-table__detail">
{theirs.cards ? `${theirs.cards}${theirs.handshakes ? ` ·×${theirs.handshakes + 1}` : ""}` : "—"}
</td>
<td className={theirs.score < 0 ? "is-negative" : theirs.score > 0 ? "is-positive" : ""}>
{theirs.cards ? signed(theirs.score) : "—"}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<th scope="row">{isMatch ? `ROUND ${roundIdx + 1}` : "TOTAL"}</th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
{isMatch && (
<tr className="result-table__match">
<th scope="row">MATCH TOTAL</th>
<td className="result-table__detail" />
<td>{signed(matchTotals[0])}</td>
<td className="result-table__detail" />
<td>{signed(matchTotals[1])}</td>
</tr>
)}
</tfoot>
</table>
<footer className="result-card__foot">
<small>DEAL SEED {seed}</small>
<div>
<button type="button" onClick={onReview}>REVIEW GAME</button>
<button type="button" className="is-primary" onClick={onPlayAgain}>PLAY AGAIN</button>
</div>
</footer>
</section>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { COLOR_GLYPHS, COLOR_HEX, EXPEDITION_NAMES_KO } from "../game/cards";
import { scoreBreakdown } from "../game/scoring";
import type { GameState } from "../game/types";
function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
function scoreClass(value: number): string {
return value < 0 ? "is-negative" : value > 0 ? "is-positive" : "";
}
/** Per-expedition breakdown of one round's board: cards (with the wager multiplier)
* and score, for both players. */
function ColorTable({ state }: { state: GameState }) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
return (
<table className="result-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" colSpan={2}></th>
<th scope="col" colSpan={2}></th>
</tr>
<tr className="result-table__subhead">
<th scope="col" />
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{EXPEDITION_NAMES_KO.map((name, color) => {
const mine = you.colors[color];
const theirs = rival.colors[color];
const cards = (c: { cards: number; handshakes: number }) =>
c.cards ? `${c.cards}${c.handshakes ? ` ·×${c.handshakes + 1}` : ""}` : "—";
return (
<tr key={name}>
<th scope="row" style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
<i>{COLOR_GLYPHS[color]}</i>{name}
</th>
<td className="result-table__detail">{cards(mine)}</td>
<td className={scoreClass(mine.score)}>{mine.cards ? signed(mine.score) : "—"}</td>
<td className="result-table__detail">{cards(theirs)}</td>
<td className={scoreClass(theirs.score)}>{theirs.cards ? signed(theirs.score) : "—"}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<th scope="row"></th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
</tfoot>
</table>
);
}
/** The pause between rounds: the finished round's per-colour scores and where the
* match stands, held until the player chooses to deal the next round. */
export function RoundOverlay({
state,
roundIdx,
totalRounds,
matchTotals,
onContinue,
}: {
state: GameState;
roundIdx: number;
totalRounds: number;
matchTotals: [number, number];
onContinue: () => void;
}) {
return (
<div className="result-overlay">
<section className="result-card" aria-label={`${roundIdx + 1}라운드 결과`}>
<header className="result-card__head">
<p>{roundIdx + 1} </p>
<strong>{matchTotals[0]} <i>:</i> {matchTotals[1]}</strong>
<small className="result-card__banked"> · {matchTotals[0]} · {matchTotals[1]}</small>
</header>
<ColorTable state={state} />
<footer className="result-card__foot">
<button type="button" className="is-primary" onClick={onContinue}>
{roundIdx + 1 >= totalRounds - 1 ? "마지막 라운드로" : `${roundIdx + 2}라운드 시작`}
</button>
</footer>
</section>
</div>
);
}
/** The end of the game: the outcome, the final round's per-colour breakdown, and --
* for a match -- a single table summarising all three rounds. */
export function ResultCard({
state,
outcome,
roundHistory,
totalRounds,
onPlayAgain,
}: {
state: GameState;
outcome: string;
/** Each finished round's [you, rival] total, including this last one. */
roundHistory: [number, number][];
totalRounds: number;
onPlayAgain: () => void;
}) {
const isMatch = totalRounds > 1;
const totals = roundHistory.reduce(
(acc, [you, rival]) => [acc[0] + you, acc[1] + rival] as [number, number],
[0, 0] as [number, number],
);
return (
<div className="result-overlay">
<section className="result-card" aria-label="최종 결과">
<header className="result-card__head">
<p>{isMatch ? `${totalRounds}라운드 매치 종료` : "게임 종료"}</p>
<h1>{outcome}</h1>
<strong>{totals[0]} <i>:</i> {totals[1]}</strong>
</header>
{isMatch ? (
<table className="result-table result-table--rounds">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{roundHistory.map(([you, rival], round) => (
<tr key={round}>
<th scope="row">{round + 1}</th>
<td className={scoreClass(you)}>{signed(you)}</td>
<td className={scoreClass(rival)}>{signed(rival)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="result-table__match">
<th scope="row"></th>
<td>{signed(totals[0])}</td>
<td>{signed(totals[1])}</td>
</tr>
</tfoot>
</table>
) : (
<ColorTable state={state} />
)}
<footer className="result-card__foot">
<button type="button" className="is-primary" onClick={onPlayAgain}> </button>
</footer>
</section>
</div>
);
}
+1
View File
@@ -2,6 +2,7 @@ import { CARDS_PER_COLOR } from "./types";
export const COLOR_NAMES = ["Red", "Blue", "Green", "Gold", "Violet"] as const; export const COLOR_NAMES = ["Red", "Blue", "Green", "Gold", "Violet"] as const;
export const EXPEDITION_NAMES = ["Volcano", "Ocean", "Jungle", "Desert", "Cavern"] as const; export const EXPEDITION_NAMES = ["Volcano", "Ocean", "Jungle", "Desert", "Cavern"] as const;
export const EXPEDITION_NAMES_KO = ["화산", "바다", "정글", "사막", "동굴"] as const;
export const COLOR_HEX = ["#b94737", "#3569a7", "#397b4e", "#b37d20", "#704487"] as const; export const COLOR_HEX = ["#b94737", "#3569a7", "#397b4e", "#b37d20", "#704487"] as const;
export const COLOR_GLYPHS = ["▲", "≋", "♧", "◆", "⬟"] as const; export const COLOR_GLYPHS = ["▲", "≋", "♧", "◆", "⬟"] as const;
+40 -10
View File
@@ -34,6 +34,16 @@ export interface MatchState {
* round one, played at a carry of zero. * round one, played at a carry of zero.
*/ */
totalRounds: number; totalRounds: number;
/**
* The round's deck is exhausted and its board is being shown, but the match has
* not rolled into the next round yet. The engine advances rounds atomically; the
* client holds here so the finished board and its per-colour scores are visible
* before `matchAdvanceRound` deals the next one. Never set on the last round --
* that sets `done` instead.
*/
roundComplete: boolean;
/** Each finished round's [you, rival] total, in order, for the final summary. */
roundHistory: [number, number][];
done: boolean; done: boolean;
} }
@@ -68,6 +78,8 @@ export function matchFromOrders(
roundIdx: 0, roundIdx: 0,
carry, carry,
totalRounds, totalRounds,
roundComplete: false,
roundHistory: [],
done: false, done: false,
}; };
} }
@@ -80,28 +92,45 @@ export function matchScore(state: MatchState): [number, number] {
export function matchLegalActionMask(state: MatchState): boolean[] { export function matchLegalActionMask(state: MatchState): boolean[] {
const mask = legalActionMask(state.round); const mask = legalActionMask(state.round);
return state.done ? mask.map(() => false) : mask; // No moves while the match is over or a finished round is being shown.
return state.done || state.roundComplete ? mask.map(() => false) : mask;
}
/** Score of the round currently on the board, by player. */
export function roundScore(state: MatchState): [number, number] {
return boardScore(state.round);
} }
/** /**
* Play one ply. Rolls into the next round when the deck runs out. * Play one ply. When the deck runs out this holds on the finished board
* * (`roundComplete`) rather than rolling straight into the next round, so the
* Rounds one and two pay nothing -- they only bank into `carry`. Only the sum * client can show the round's scores; `matchAdvanceRound` continues. The last
* decides the match. * round sets `done` instead. Rounds one and two only bank into `carry` -- the
* summed total decides the match.
*/ */
export function matchStep(state: MatchState, action: number): MatchState { export function matchStep(state: MatchState, action: number): MatchState {
const played = step(state.round, action); const played = step(state.round, action);
if (!played.done || state.done) { if (!played.done || state.done || state.roundComplete) {
return { ...state, round: played }; return { ...state, round: played };
} }
const board = boardScore(played); const board = boardScore(played);
const banked: [number, number] = [state.carry[0] + board[0], state.carry[1] + board[1]];
if (state.roundIdx >= state.totalRounds - 1) { if (state.roundIdx >= state.totalRounds - 1) {
return { ...state, round: played, done: true }; return {
...state,
round: played,
roundHistory: [...state.roundHistory, board],
done: true,
};
} }
return { ...state, round: played, roundComplete: true };
}
/** Bank the finished round and deal the next one. No-op unless roundComplete. */
export function matchAdvanceRound(state: MatchState): MatchState {
if (!state.roundComplete) return state;
const board = boardScore(state.round);
const banked: [number, number] = [state.carry[0] + board[0], state.carry[1] + board[1]];
const roundIdx = state.roundIdx + 1; const roundIdx = state.roundIdx + 1;
return { return {
...state, ...state,
@@ -111,6 +140,7 @@ export function matchStep(state: MatchState, action: number): MatchState {
), ),
roundIdx, roundIdx,
carry: banked, carry: banked,
done: false, roundHistory: [...state.roundHistory, board],
roundComplete: false,
}; };
} }
+49 -27
View File
@@ -3,10 +3,12 @@ import { describe, expect, it } from "vitest";
import fixture from "./match-parity-fixture.json"; import fixture from "./match-parity-fixture.json";
import { import {
N_ROUNDS, N_ROUNDS,
matchAdvanceRound,
matchFromOrders, matchFromOrders,
matchLegalActionMask, matchLegalActionMask,
matchScore, matchScore,
matchStep, matchStep,
roundScore,
startingPlayer, startingPlayer,
type MatchState, type MatchState,
} from "./match"; } from "./match";
@@ -37,6 +39,9 @@ function toMatch(row: FixtureRow): MatchState {
carry: [row.match.carry[0], row.match.carry[1]], carry: [row.match.carry[0], row.match.carry[1]],
// The fixture is generated from the JAX match, which is always three rounds. // The fixture is generated from the JAX match, which is always three rounds.
totalRounds: N_ROUNDS, totalRounds: N_ROUNDS,
// Not part of the observation; only present so the type is satisfied.
roundComplete: false,
roundHistory: [],
done: row.match.done, done: row.match.done,
}; };
} }
@@ -82,19 +87,52 @@ describe("match observation parity with JAX", () => {
}); });
}); });
describe("match rules", () => { /** Play a full match, advancing past each round-complete pause. */
it("plays exactly three rounds and then ends", () => { function playMatch(start: MatchState): { final: MatchState; pauses: number } {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips); let match = start;
const seen = new Set<number>(); let pauses = 0;
for (let ply = 0; ply < 1400 && !match.done; ply += 1) {
for (let ply = 0; ply < 1400 && !match.done; ply += 1) { if (match.roundComplete) {
seen.add(match.roundIdx); pauses += 1;
match = matchStep(match, firstLegal(match)); match = matchAdvanceRound(match);
continue;
} }
match = matchStep(match, firstLegal(match));
}
return { final: match, pauses };
}
expect(match.done).toBe(true); describe("match rules", () => {
expect(seen).toEqual(new Set([0, 1, 2])); it("pauses at each round end, then plays exactly three rounds", () => {
expect(match.roundIdx).toBe(N_ROUNDS - 1); const start = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
const { final, pauses } = playMatch(start);
// Two roll-overs for three rounds; the third ends the match, not a pause.
expect(pauses).toBe(N_ROUNDS - 1);
expect(final.done).toBe(true);
expect(final.roundIdx).toBe(N_ROUNDS - 1);
expect(final.roundHistory.length).toBe(N_ROUNDS);
});
it("holds the finished board while round-complete, offering no legal move", () => {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
while (!match.roundComplete && !match.done) match = matchStep(match, firstLegal(match));
expect(match.roundComplete).toBe(true);
expect(match.roundIdx).toBe(0); // not advanced yet
expect(matchLegalActionMask(match).some(Boolean)).toBe(false);
// The running total already includes the round being shown.
expect(matchScore(match)).toEqual(roundScore(match));
});
it("banks each round exactly once into the history and carry", () => {
const { final } = playMatch(matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips));
const summed = final.roundHistory.reduce(
(acc, [you, rival]) => [acc[0] + you, acc[1] + rival] as [number, number],
[0, 0] as [number, number],
);
// carry banks rounds 1..2; the third is still on the board at match end.
const board = roundScore(final);
expect(matchScore(final)).toEqual([summed[0], summed[1]]);
expect([final.carry[0] + board[0], final.carry[1] + board[1]]).toEqual(matchScore(final));
}); });
it("lets whoever has more points begin, and flips a coin when level", () => { it("lets whoever has more points begin, and flips a coin when level", () => {
@@ -103,20 +141,4 @@ describe("match rules", () => {
expect(startingPlayer([30, 30], 1, [0, 0, 0])).toBe(0); // level falls back to the coin expect(startingPlayer([30, 30], 1, [0, 0, 0])).toBe(0); // level falls back to the coin
expect(startingPlayer([30, 30], 1, [1, 1, 1])).toBe(1); expect(startingPlayer([30, 30], 1, [1, 1, 1])).toBe(1);
}); });
it("keeps the running total continuous across a round boundary", () => {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
for (let ply = 0; ply < 1400 && !match.done; ply += 1) {
const before = match.roundIdx;
const next = matchStep(match, firstLegal(match));
if (next.roundIdx !== before) {
// The finished round folds into carry and the fresh board is empty, so
// the roll-over itself moves nothing.
expect(matchScore(next)).toEqual([next.carry[0], next.carry[1]]);
}
match = next;
}
expect(match.done).toBe(true);
});
}); });
+3
View File
@@ -65,6 +65,9 @@ function isMatchState(value: unknown): value is MatchState {
Number.isInteger(match.roundIdx) && match.roundIdx! >= 0 && match.roundIdx! < 3 && Number.isInteger(match.roundIdx) && match.roundIdx! >= 0 && match.roundIdx! < 3 &&
isNumberArray(match.carry, 2) && isNumberArray(match.carry, 2) &&
(match.totalRounds === 1 || match.totalRounds === 3) && (match.totalRounds === 1 || match.totalRounds === 3) &&
typeof match.roundComplete === "boolean" &&
Array.isArray(match.roundHistory) &&
match.roundHistory.every((entry) => isNumberArray(entry, 2)) &&
typeof match.done === "boolean"; typeof match.done === "boolean";
} }