diff --git a/web/README.md b/web/README.md index e2ef8bb..f17151e 100644 --- a/web/README.md +++ b/web/README.md @@ -21,6 +21,23 @@ the exact same deal, so a game can be shared, replayed, or reported with a bug. Seeds are arbitrary text; the deal is derived from a deterministic PRNG in `src/game/random.ts` and is independent of the Python shuffle bank. +The seed shuffles the deck and nothing else. The policy is deterministic: it +always plays its highest-logit legal action, so the same deal and the same moves +reproduce the same game. + +## Hints, undo, and review + +`HINT` asks the same policy that drives the rival what it would play in your +seat, and highlights the card, its destination, and the draw source (with the +policy's confidence when the ONNX model is loaded). + +Undo and redo work per *action*, not per turn: choosing a card, choosing its +destination, and drawing are separate steps, and each is undone on its own. The +rival's moves are steps too, so a finished game can be stepped through from the +result screen (`REVIEW GAME`) all the way back to the deal. While undone moves +are pending the rival is suspended; `PLAY FROM HERE` drops them and resumes play +from the position on screen. + ## Setup Install and run the checked-in final policy: diff --git a/web/src/App.tsx b/web/src/App.tsx index d7e89ac..5eeb31c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Board } from "./components/Board"; +import { Board, type BoardHint } from "./components/Board"; import { Card, CardBack } from "./components/Card"; +import { ResultCard } from "./components/ResultCard"; +import { describeAction } from "./game/actions"; import { cardColor, cardName } from "./game/cards"; import { boardScore, currentHandSorted, + decodeAction, encodeAction, legalActionMask, previewPlacement, @@ -13,12 +16,30 @@ import { step, } from "./game/engine"; import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./game/random"; -import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; +import { DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy"; +import { useCardMotion } from "./ui/useCardMotion"; + +interface Hint { + action: number; + text: string; + probability: number | null; +} type Selection = { handSlot: number | null; placeType: PlaceType | null }; const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null }; +/** + * One undoable step. A turn is several of these — picking a card, choosing where + * it goes, then drawing — and each is undone on its own, so undo never throws + * away a whole turn's thinking. A rival move is one frame too, which is what + * makes a finished game reviewable ply by ply. + */ +interface Frame { + state: GameState; + selection: Selection; +} + // The hand row is centered, so the binding constraint is the score plaque // column on the left (mirrored as a keep-out on the right to stay centered). // Available width for the hand = viewport - 2 * (plaque right edge + gutter). @@ -52,8 +73,8 @@ function initialSeed(): string { return seed === "" ? randomSeed() : seed; } -function gameFromSeed(seed: string): GameState { - return resetFromOrder(deckOrderFromSeed(seed)); +function openingFrame(seed: string): Frame { + return { state: resetFromOrder(deckOrderFromSeed(seed)), selection: EMPTY_SELECTION }; } /** Keep the address bar in sync so the current deal stays shareable. */ @@ -65,18 +86,27 @@ function publishSeed(seed: string): void { function App() { const [seed, setSeed] = useState(initialSeed); - const [state, setState] = useState(() => gameFromSeed(seed)); + const [frames, setFrames] = useState(() => [openingFrame(seed)]); + const [cursor, setCursor] = useState(0); const [seedDraft, setSeedDraft] = useState(""); - const [history, setHistory] = useState([]); - const [selection, setSelection] = useState(EMPTY_SELECTION); + const [resultOpen, setResultOpen] = useState(true); const [policy, setPolicy] = useState(null); const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO"); const [thinking, setThinking] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); + const [hint, setHint] = useState(null); + const [hintPending, setHintPending] = useState(false); const generation = useRef(0); + const { cardRef, deckRef, overlayRef, resetMotion } = useCardMotion(); + + const { state, selection } = frames[cursor]; + const canUndo = cursor > 0; + const canRedo = cursor < frames.length - 1; + const latestState = useRef(state); useEffect(() => { publishSeed(seed); }, [seed]); + useEffect(() => { latestState.current = state; }, [state]); useEffect(() => { function onResize() { setViewportWidth(window.innerWidth); } @@ -107,8 +137,19 @@ function App() { return () => { cancelled = true; }; }, []); + /** Record a new step and make it the position on screen. Anything that had + * been undone is discarded — acting is what branches the game. */ + function pushFrame(frame: Frame) { + setFrames((items) => [...items.slice(0, cursor + 1), frame]); + setCursor(cursor + 1); + } + + // Suspended while there are undone moves ahead: otherwise stepping back into + // the rival's turn would instantly replay the very move being reviewed. + const rivalSuspended = canRedo; + useEffect(() => { - if (!policy || state.done || state.toMove !== 1) return; + if (!policy || rivalSuspended || state.done || state.toMove !== 1) return; const currentGeneration = generation.current; const timer = window.setTimeout(async () => { setThinking(true); @@ -125,8 +166,7 @@ function App() { setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)"); } if (generation.current !== currentGeneration) return; - setHistory((items) => [...items, state]); - setState(step(state, action)); + pushFrame({ state: step(state, action), selection: EMPTY_SELECTION }); } catch (error) { console.error("AI action failed even with heuristic fallback", error); setModelMessage("MODEL INFERENCE ERROR"); @@ -135,17 +175,19 @@ function App() { } }, 620); return () => window.clearTimeout(timer); - }, [policy, state]); + }, [policy, rivalSuspended, state]); /** 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()) { generation.current += 1; + resetMotion(); setSeed(nextSeed); - setState(gameFromSeed(nextSeed)); + setFrames([openingFrame(nextSeed)]); + setCursor(0); setSeedDraft(""); - setHistory([]); - setSelection(EMPTY_SELECTION); + setResultOpen(true); + setHint(null); setThinking(false); setMenuOpen(false); } @@ -155,48 +197,82 @@ function App() { if (requested !== "") restart(requested); } - function undoTurn() { - if (!history.length) return; + function moveCursor(next: number) { + if (next < 0 || next >= frames.length) return; generation.current += 1; - let index = history.length - 1; - while (index > 0 && history[index].toMove !== 0) index -= 1; - setState(history[index]); - setHistory(history.slice(0, index)); - setSelection(EMPTY_SELECTION); + setCursor(next); + setHint(null); setThinking(false); setMenuOpen(false); } + const undo = () => moveCursor(cursor - 1); + const redo = () => moveCursor(cursor + 1); + + /** Leave the reviewed position as the live one and let the rival play on. */ + function resumeFromHere() { + setFrames((items) => items.slice(0, cursor + 1)); + setMenuOpen(false); + } + + /** Ask the policy driving the rival what it would do in your seat. */ + async function requestHint() { + if (!policy || state.done || state.toMove !== 0 || hintPending) return; + const position = state; + setHintPending(true); + setMenuOpen(false); + try { + const ranked = await policy.rank(position).catch(async (error) => { + console.error("hint inference failed; falling back to heuristic", error); + return fallbackHeuristicPolicy().rank(position); + }); + const [best] = ranked; + // The position can move on while inference runs — a hint for a stale + // position would point at the wrong hand slot. + if (best === undefined || latestState.current !== position) return; + setHint({ + action: best.action, + text: describeAction(position, best.action), + probability: best.probability, + }); + } catch (error) { + console.error("hint failed", error); + } finally { + setHintPending(false); + } + } + + /** A hint answers one position; drop it as soon as anything is done or undone. */ + useEffect(() => { setHint(null); }, [cursor, frames]); + function chooseCard(handSlot: number) { if (state.toMove !== 0 || state.done) return; - setSelection((current) => { - if (current.handSlot !== handSlot) return { handSlot, placeType: null }; - if (current.placeType !== null) return { handSlot, placeType: null }; - return EMPTY_SELECTION; - }); + const next = selection.handSlot !== handSlot || selection.placeType !== null + ? { handSlot, placeType: null } + : EMPTY_SELECTION; + pushFrame({ state, selection: next }); } function choosePlace(placeType: PlaceType) { if (selection.handSlot === null) return; - setSelection({ ...selection, placeType }); + pushFrame({ state, selection: { ...selection, placeType } }); } function cancelPlace() { - setSelection((current) => current.handSlot === null ? current : { ...current, placeType: null }); + if (selection.handSlot === null || selection.placeType === null) return; + pushFrame({ state, selection: { ...selection, placeType: null } }); } useEffect(() => { function onKeyDown(event: KeyboardEvent) { - if (event.key !== "Escape") return; - setSelection((current) => { - if (current.placeType !== null) return { ...current, placeType: null }; - if (current.handSlot !== null) return EMPTY_SELECTION; - return current; - }); + if (event.target instanceof HTMLInputElement) return; + if (event.key === "Escape") undo(); + if (event.key === "ArrowLeft") undo(); + if (event.key === "ArrowRight") redo(); } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, []); + }); function legalDraw(drawSource: number): boolean { if (selection.handSlot === null || selection.placeType === null) return false; @@ -206,9 +282,7 @@ function App() { function commit(drawSource: number) { if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return; const action = encodeAction(selection.handSlot, selection.placeType, drawSource); - setHistory((items) => [...items, state]); - setState(step(state, action)); - setSelection(EMPTY_SELECTION); + pushFrame({ state: step(state, action), selection: EMPTY_SELECTION }); } // Cards actually drawn in the hand row (the selected card moves into the @@ -224,12 +298,37 @@ function App() { { length: 6 }, (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)], ).some(Boolean); - const status = state.done - ? scores[0] === scores[1] ? "DRAW" : scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS" - : 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"; + + const hintMove = hint === null ? null : decodeAction(hint.action); + const hintedCard = hintMove === null ? undefined : humanHand[hintMove.handSlot]; + const boardHint: BoardHint | null = hintMove === null || hintedCard === undefined ? null : { + color: cardColor(hintedCard), + place: hintMove.placeType, + drawSource: hintMove.drawSource, + }; + const hintConfidence = hint !== null && hint.probability !== null + ? `${Math.round(hint.probability * 100)}%` + : null; + const canHint = policy !== null && !state.done && state.toMove === 0; + + const outcome = scores[0] === scores[1] + ? "DRAW" + : scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"; + // While the rival is paused on its own turn (or on the final position) nothing + // will happen until the move is redone or play is resumed — say so. On your own + // turn the normal prompt still applies: you can simply play on from here. + const reviewingRival = rivalSuspended && (state.done || state.toMove === 1); + const status = reviewingRival + ? `Reviewing ply ${state.stepCount} — redo, or play on from here` + : state.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"; return (
@@ -239,7 +338,9 @@ function App() {
{opponentHand.map((card) => ( - state.handPublic[card] ? : + state.handPublic[card] + ? + : ))}
@@ -248,19 +349,19 @@ function App() { {menuOpen && (
- + - + THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC {policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}
)} @@ -273,6 +374,8 @@ function App() { selectedPlace={selection.placeType} canPlay={canPlaySelected} canDraw={legalDraw} + hint={boardHint} + cardRef={cardRef} onChoosePlace={choosePlace} onCancelPlace={cancelPlace} onDraw={commit} @@ -281,17 +384,29 @@ function App() { -

{status}

+
+

+ {hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status} +

+ +
{renderedHand.map(({ card, slot }, position) => ( @@ -299,9 +414,11 @@ function App() { card={card} key={card} selected={selection.handSlot === slot} + hinted={hintedCard === card} disabled={state.toMove !== 0 || state.done} onClick={() => chooseCard(slot)} style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }} + innerRef={cardRef(card)} /> ))}
@@ -310,14 +427,35 @@ function App() {
YOU {state.toMove === 0 && !state.done ? "YOUR TURN" : "EXPEDITION LEAD"}
{scores[0]} - {state.done && ( -
-
-

ROUND COMPLETE

{status}

{scores[0]} : {scores[1]} - SEED {seed} - -
-
+
+ + + {rivalSuspended && ( + + )} + {state.done && !resultOpen && ( + + )} +
+ + {/* Cards in flight back to the deck (undo of a draw) are re-parented here + after React has already removed them from the table. */} +
); diff --git a/web/src/components/Board.tsx b/web/src/components/Board.tsx index 7ba576d..1648d65 100644 --- a/web/src/components/Board.tsx +++ b/web/src/components/Board.tsx @@ -9,12 +9,21 @@ import { import { cardsOnBoard } from "../game/engine"; import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types"; +/** Where the hint says to put the card and where to draw from, if hinting. */ +export interface BoardHint { + color: number; + place: PlaceType; + drawSource: number; +} + interface BoardProps { state: GameState; selectedColor: number | null; selectedPlace: PlaceType | null; canPlay: boolean; canDraw: (source: number) => boolean; + hint: BoardHint | null; + cardRef: (card: number) => (element: HTMLElement | null) => void; onChoosePlace: (place: PlaceType) => void; onCancelPlace: () => void; onDraw: (source: number) => void; @@ -49,6 +58,8 @@ export function Board({ selectedPlace, canPlay, canDraw, + hint, + cardRef, onChoosePlace, onCancelPlace, onDraw, @@ -64,6 +75,9 @@ export function Board({ const drawTarget = selectedPlace !== null && canDraw(color + 1); const discardChosen = selectedPlace === DISCARD && selectedColor === color; const playChosen = selectedPlace === PLAY && selectedColor === color; + const hintPlay = hint?.place === PLAY && hint.color === color; + const hintDiscard = hint?.place === DISCARD && hint.color === color; + const hintDraw = hint?.drawSource === color + 1; return (
{opponent.length ? (
- {opponent.map((card) => )} + {opponent.map((card) => )}
) : ( {COLOR_GLYPHS[color]} @@ -83,26 +97,30 @@ export function Board({ {name} ; + return ( + + ); } -export function CardBack({ count }: { count?: number }) { +export function CardBack({ + count, + innerRef, +}: { + count?: number; + innerRef?: (element: HTMLElement | null) => void; +}) { return ( -
+
{count !== undefined && {count}}
diff --git a/web/src/components/ResultCard.tsx b/web/src/components/ResultCard.tsx new file mode 100644 index 0000000..940f1fc --- /dev/null +++ b/web/src/components/ResultCard.tsx @@ -0,0 +1,91 @@ +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; + onReview: () => void; + onPlayAgain: () => void; +} + +function signed(value: number): string { + return value > 0 ? `+${value}` : String(value); +} + +export function ResultCard({ state, outcome, seed, onReview, onPlayAgain }: ResultCardProps) { + const you = scoreBreakdown(state, 0); + const rival = scoreBreakdown(state, 1); + + return ( +
+
+
+

ROUND COMPLETE

+

{outcome}

+ {you.total} : {rival.total} +
+ + + + + + + + + + + + + + + + + {EXPEDITION_NAMES.map((name, color) => { + const mine = you.colors[color]; + const theirs = rival.colors[color]; + return ( + + + + + + + + ); + })} + + + + + + + + +
EXPEDITIONYOUTHE RIVAL
+ CARDSSCORECARDSSCORE
+ {COLOR_GLYPHS[color]}{name} + + {mine.cards ? `${mine.cards}${mine.handshakes ? ` ·×${mine.handshakes + 1}` : ""}` : "—"} + 0 ? "is-positive" : ""}> + {mine.cards ? signed(mine.score) : "—"} + + {theirs.cards ? `${theirs.cards}${theirs.handshakes ? ` ·×${theirs.handshakes + 1}` : ""}` : "—"} + 0 ? "is-positive" : ""}> + {theirs.cards ? signed(theirs.score) : "—"} +
TOTAL + {signed(you.total)} + {signed(rival.total)}
+ +
+ DEAL SEED {seed} +
+ + +
+
+
+
+ ); +} diff --git a/web/src/game/actions.ts b/web/src/game/actions.ts new file mode 100644 index 0000000..5bd70fb --- /dev/null +++ b/web/src/game/actions.ts @@ -0,0 +1,17 @@ +import { EXPEDITION_NAMES, cardColor, cardName } from "./cards"; +import { currentHandSorted, decodeAction } from "./engine"; +import { DRAW_DECK, PLAY, type GameState } from "./types"; + +/** Human-readable move, e.g. "Play Red 7 · draw from the Ocean discard". */ +export function describeAction(state: GameState, action: number): string { + const { handSlot, placeType, drawSource } = decodeAction(action); + const card = currentHandSorted(state)[handSlot]; + if (card === undefined) return "—"; + const place = placeType === PLAY + ? `Play ${cardName(card)} on ${EXPEDITION_NAMES[cardColor(card)]}` + : `Discard ${cardName(card)}`; + const draw = drawSource === DRAW_DECK + ? "draw from the deck" + : `draw the ${EXPEDITION_NAMES[drawSource - 1]} discard`; + return `${place} · ${draw}`; +} diff --git a/web/src/game/scoring.test.ts b/web/src/game/scoring.test.ts new file mode 100644 index 0000000..f212278 --- /dev/null +++ b/web/src/game/scoring.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; + +import { encodeAction, legalActionMask, resetFromOrder, step } from "./engine"; +import { scoreBreakdown } from "./scoring"; +import { DRAW_DECK, N_CARDS, PLAY } from "./types"; +import { boardScore } from "./engine"; + +const orderedDeck = Array.from({ length: N_CARDS }, (_, index) => index); + +describe("score breakdown", () => { + test("an untouched board scores nothing everywhere", () => { + const breakdown = scoreBreakdown(resetFromOrder(orderedDeck), 0); + expect(breakdown.total).toBe(0); + expect(breakdown.colors).toHaveLength(5); + expect(breakdown.colors.every((color) => color.cards === 0 && color.score === 0)).toBe(true); + }); + + test("an opened expedition costs 20 and handshakes multiply it", () => { + // Player 0 holds cards 0..7 of the red suit: three handshakes then 2,3,4,5. + let state = resetFromOrder(orderedDeck); + const mask = legalActionMask(state); + expect(mask[encodeAction(0, PLAY, DRAW_DECK)]).toBe(true); + state = step(state, encodeAction(0, PLAY, DRAW_DECK)); // red handshake + + const red = scoreBreakdown(state, 0).colors[0]; + expect(red.cards).toBe(1); + expect(red.handshakes).toBe(1); + expect(red.rankSum).toBe(0); + // (0 - 20) * (1 + 1) = -40 + expect(red.score).toBe(-40); + }); + + test("totals agree with the engine's board score", () => { + let state = resetFromOrder(orderedDeck); + for (let ply = 0; ply < 12; ply += 1) { + const action = legalActionMask(state).findIndex((legal, index) => legal && index % 6 === 0); + state = step(state, action); + } + const engine = boardScore(state); + expect(scoreBreakdown(state, 0).total).toBe(engine[0]); + expect(scoreBreakdown(state, 1).total).toBe(engine[1]); + }); +}); diff --git a/web/src/game/scoring.ts b/web/src/game/scoring.ts new file mode 100644 index 0000000..0707cde --- /dev/null +++ b/web/src/game/scoring.ts @@ -0,0 +1,35 @@ +import { cardRank, isHandshake } from "./cards"; +import { cardsOnBoard } from "./engine"; +import { N_COLORS, type GameState } from "./types"; + +export interface ColorScore { + color: number; + cards: number; + handshakes: number; + rankSum: number; + score: number; +} + +export interface PlayerScore { + colors: ColorScore[]; + total: number; +} + +/** Per-color scoring, mirroring `score_breakdown` in + * `src/lost_cities_jax/human_play.py`: an opened expedition costs 20, is + * multiplied by 1 + handshakes, and earns 20 more at eight cards. */ +export function scoreBreakdown(state: GameState, player: number): PlayerScore { + const colors: ColorScore[] = []; + let total = 0; + for (let color = 0; color < N_COLORS; color += 1) { + const cards = cardsOnBoard(state, player, color); + const handshakes = cards.filter(isHandshake).length; + const rankSum = cards.reduce((sum, card) => sum + cardRank(card), 0); + const score = cards.length === 0 + ? 0 + : (rankSum - 20) * (1 + handshakes) + (cards.length >= 8 ? 20 : 0); + total += score; + colors.push({ color, cards: cards.length, handshakes, rankSum, score }); + } + return { colors, total }; +} diff --git a/web/src/model/policy.ts b/web/src/model/policy.ts index d793388..6ffae92 100644 --- a/web/src/model/policy.ts +++ b/web/src/model/policy.ts @@ -9,47 +9,69 @@ export type ExecutionProvider = "webgpu" | "wasm" | "heuristic"; const MODEL_URL = `${import.meta.env.BASE_URL}models/jax-ppo.onnx`; +/** A legal action with the policy's confidence in it, if the policy has one. */ +export interface RankedAction { + action: number; + probability: number | null; +} + export interface Policy { readonly provider: ExecutionProvider; + /** Legal actions, best first. Drives both the rival's move and the hint. */ + rank(state: GameState): Promise; action(state: GameState): Promise; } -class OnnxPolicy implements Policy { +abstract class RankingPolicy implements Policy { + abstract readonly provider: ExecutionProvider; + abstract rank(state: GameState): Promise; + + async action(state: GameState): Promise { + const [best] = await this.rank(state); + if (best === undefined) throw new Error("state has no legal actions"); + return best.action; + } +} + +/** Softmax over the legal actions only, so the reported confidence is a share + * of what the policy could actually have chosen. */ +function softmaxOverLegal(scores: number[], legal: boolean[]): RankedAction[] { + const legalScores = scores.filter((_, action) => legal[action]); + const max = Math.max(...legalScores); + const total = legalScores.reduce((sum, score) => sum + Math.exp(score - max), 0); + return scores + .flatMap((score, action) => + legal[action] ? [{ action, probability: Math.exp(score - max) / total }] : [], + ) + .sort((left, right) => right.probability - left.probability); +} + +class OnnxPolicy extends RankingPolicy { constructor( private readonly session: ort.InferenceSession, readonly provider: ExecutionProvider, - ) {} + ) { + super(); + } - async action(state: GameState): Promise { + async rank(state: GameState): Promise { const obs = observation(state, state.toMove); const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) }); const logits = result.logits?.data; if (!logits) throw new Error("ONNX model did not return a logits output"); const legal = legalActionMask(state); - let bestAction = -1; - let bestLogit = Number.NEGATIVE_INFINITY; - for (let action = 0; action < legal.length; action += 1) { - const logit = Number(logits[action]); - if (legal[action] && logit > bestLogit) { - bestAction = action; - bestLogit = logit; - } - } - if (bestAction < 0) throw new Error("model received a state without legal actions"); - return bestAction; + return softmaxOverLegal(legal.map((_, action) => Number(logits[action])), legal); } } -export class HeuristicPolicy implements Policy { +export class HeuristicPolicy extends RankingPolicy { readonly provider = "heuristic" as const; - async action(state: GameState): Promise { + async rank(state: GameState): Promise { const legal = legalActionMask(state); const hand = currentHandSorted(state); - let bestAction = -1; - let bestScore = Number.NEGATIVE_INFINITY; - for (let action = 0; action < legal.length; action += 1) { - if (!legal[action]) continue; + const ranked = legal.flatMap((isLegal, action) => { + if (!isLegal) return []; const handSlot = Math.floor(action / 12); const place = Math.floor((action % 12) / 6); const draw = action % 6; @@ -57,13 +79,12 @@ export class HeuristicPolicy implements Policy { let score = place === PLAY ? cardRank(card) + (isHandshake(card) ? 7 : 0) : -cardRank(card); if (draw !== DRAW_DECK) score += 2; if (place === DISCARD && draw > 0 && draw - 1 === cardColor(card)) score -= 100; - if (score > bestScore) { - bestScore = score; - bestAction = action; - } - } - if (bestAction < 0) throw new Error("state has no legal actions"); - return bestAction; + return [{ action, score }]; + }); + // No calibrated probability to report — this is a hand-written score. + return ranked + .sort((left, right) => right.score - left.score) + .map(({ action }) => ({ action, probability: null })); } } diff --git a/web/src/styles.css b/web/src/styles.css index 70e2593..7179f38 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -289,31 +289,55 @@ button.lane__discard:disabled { opacity: 1; } .lane__discard.is-chosen .card { box-shadow: 0 0 0 2px var(--gold), 0 0 28px rgba(201, 163, 75, 0.25); } @keyframes target-pulse { 50% { filter: brightness(1.22); } } -/* Cards are keyed by card id, so only a card that just moved into a zone - mounts there — these entry animations are its motion between zones. */ -@keyframes card-place { - from { transform: translateY(-30px) scale(0.94) rotate(-1.5deg); opacity: 0; } - to { transform: none; opacity: 1; } +/* Card travel between zones is played by the motion layer (src/ui/useCardMotion), + which measures each card's old and new position and animates the difference. + Only the flip cover it fades in and out lives here. */ +.card__flip-cover { + position: absolute; + inset: -2px; + z-index: 12; + display: grid; + place-items: center; + border: 2px solid #67552d; + border-radius: 11px; + background: repeating-linear-gradient(135deg, #20252a 0 5px, #181c20 5px 10px); + color: var(--gold); + opacity: 0; + pointer-events: none; } -@keyframes card-draw { - from { transform: translateY(34px) scale(0.9); opacity: 0; } - to { transform: none; opacity: 1; } +.card--compact .card__flip-cover { border-radius: 9px; } +.card__flip-cover i { font: 24px/1 Georgia, serif; font-style: normal; } +.card--back .card__flip-cover { display: none; } + +.motion-overlay { + position: fixed; + inset: 0; + z-index: 30; + pointer-events: none; } -/* `backwards`, not `both`: a lingering final transform would outrank the - hover-lift and .card--selected transforms once the animation ends. */ -.card-stack .card, -.lane__discard .card { animation: card-place 0.26s ease-out backwards; } -.human-hand .card, -.opponent-hand .card { animation: card-draw 0.3s ease-out backwards; } + +/* The hinted card, its destination, and its draw source. */ +.card--hinted { box-shadow: 0 0 0 3px var(--gold), 0 0 26px rgba(201, 163, 75, 0.35); } +.lane__zone.is-hinted, +.lane__discard.is-hinted, +.deck-stack.is-hinted { animation: target-pulse 1.6s ease-in-out infinite; } +.lane__zone.is-hinted .lane__ghost, +.lane__discard.is-hinted .lane__ghost { + border-color: var(--gold); + border-style: dashed; + opacity: 0.9; +} +.lane__zone.is-hinted .card, +.lane__discard.is-hinted .card, +.deck-stack.is-hinted .card { box-shadow: 0 0 0 2px var(--gold), 0 0 26px rgba(201, 163, 75, 0.3); } @media (prefers-reduced-motion: reduce) { - .card-stack .card, - .lane__discard .card, - .human-hand .card, - .opponent-hand .card { animation: none; } .lane__zone.is-target, .lane__discard.is-target, .lane__discard.is-draw-target, + .lane__zone.is-hinted, + .lane__discard.is-hinted, + .deck-stack.is-hinted, .deck-stack.is-draw-target { animation: none; } } @@ -386,6 +410,23 @@ button.card:disabled { cursor: default; } .card--compact .card__center--handshake i { font-size: 31px; } .card--compact .card__center small { font-size: 6px; } +/* Same footprint as a card back, so a revealed rival card sits in the hand row + instead of bursting out of it. */ +.card--mini { + width: 44px; + height: 58px; + border-width: 2px; + border-radius: 5px; +} +.card--mini .card__corner { display: none; } +.card--mini .card__center { inset: 4px; } +.card--mini .card__center b { font-size: 20px; } +.card--mini .card__center i { margin-top: 2px; font-size: 11px; } +.card--mini .card__center--handshake i { font-size: 19px; } +.card--mini .card__center small { display: none; } +.card--mini::after { inset: 3px; border-radius: 3px; } +.card--mini .card__flip-cover { border-radius: 5px; } + .card--back { width: 44px; height: 58px; @@ -431,12 +472,20 @@ button.card:disabled { cursor: default; } .deck-stack > span { margin-top: 5px; color: #7e817c; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; } .deck-stack.is-draw-target .card { box-shadow: 0 0 0 3px var(--gold), 7px 8px 0 #171b1e, 0 0 32px rgba(201, 163, 75, 0.3); } -.turn-prompt { +/* Insets keep the row clear of the history bar parked at the right. */ +.prompt-row { position: absolute; - right: 260px; + right: 330px; bottom: 202px; - left: 260px; + left: 330px; z-index: 7; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; +} +.turn-prompt { + min-width: 0; margin: 0; overflow: hidden; color: #dedbd3; @@ -446,6 +495,22 @@ button.card:disabled { cursor: default; } white-space: nowrap; } .turn-prompt--thinking { color: #c6b887; } +.turn-prompt--review { color: #9fb4c8; } + +.hint-button { + flex: 0 0 auto; + height: 32px; + padding: 0 14px; + border: 1px solid var(--gold-dim); + border-radius: 9px; + background: rgba(7, 13, 12, 0.9); + font: 700 9px Arial, sans-serif; + letter-spacing: 0.16em; + cursor: pointer; +} +.hint-button:hover:not(:disabled) { border-color: var(--gold); } +.hint-button:disabled { opacity: 0.35; cursor: default; } +.hint-button.is-active { border-color: var(--gold); color: var(--gold); } .human-hand { position: absolute; @@ -471,18 +536,113 @@ button.card:disabled { cursor: default; } backdrop-filter: blur(10px); } .result-card { - width: min(430px, calc(100% - 40px)); - padding: 42px; + width: min(520px, calc(100% - 40px)); + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 32px 34px 26px; border: 1px solid var(--gold-dim); border-radius: 18px; background: #0b1211; - text-align: center; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55); +} +.result-card__head { text-align: center; } +.result-card__head p { margin: 0; color: #8d8d84; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; } +.result-card__head h1 { margin: 10px 0 14px; color: #eee9dd; font: 700 32px Georgia, serif; } +.result-card__head strong { display: block; color: var(--gold); font: 700 26px Georgia, serif; } +.result-card__head i { color: #716b5b; font-style: normal; } + +.result-table { + width: 100%; + margin: 24px 0 20px; + border-collapse: collapse; + font: 12px Arial, sans-serif; +} +.result-table th, +.result-table td { padding: 7px 6px; text-align: right; } +.result-table thead th { + color: #8d8d84; + font: 700 9px Arial, sans-serif; + letter-spacing: 0.14em; +} +.result-table thead tr:first-child th { border-bottom: 1px solid rgba(226, 220, 202, 0.14); } +.result-table__subhead th { padding-top: 6px; color: #6d716e; } +.result-table tbody th[scope="row"] { + display: flex; + align-items: center; + gap: 8px; + color: #cfcabd; + font: 700 11px Arial, sans-serif; + letter-spacing: 0.08em; + text-align: left; +} +.result-table tbody th[scope="row"] i { + color: var(--lane-color); + font: 15px/1 Georgia, serif; + font-style: normal; +} +.result-table tbody td { color: #ddd8ca; font: 700 13px Georgia, serif; } +.result-table__detail { color: #7d817c !important; font: 11px Arial, sans-serif !important; } +.result-table td.is-positive { color: #86c08c; } +.result-table td.is-negative { color: #c98b7f; } +.result-table tfoot th, +.result-table tfoot td { + padding-top: 12px; + border-top: 1px solid rgba(226, 220, 202, 0.14); + color: var(--gold); + font: 700 15px Georgia, serif; +} +.result-table tfoot th { text-align: left; letter-spacing: 0.1em; } + +.result-card__foot { display: grid; gap: 14px; justify-items: center; } +.result-card__foot small { color: #6d716e; font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; } +.result-card__foot div { display: flex; gap: 10px; } +.result-card button { + height: 42px; + padding: 0 24px; + border: 1px solid var(--gold-dim); + border-radius: 10px; + background: transparent; + color: #ece8dc; + font: 700 10px Arial, sans-serif; + letter-spacing: 0.12em; + cursor: pointer; +} +.result-card button:hover { border-color: var(--gold); } +.result-card button.is-primary { border-color: var(--gold); color: var(--gold); } + +/* Sits in the band above the hand: the hand row is centered and its width is + computed against the score plaques, so a wide bar down at the hand's level + would overlap the leftmost/rightmost cards. */ +.history-bar { + position: absolute; + right: 24px; + bottom: 196px; + z-index: 12; + display: flex; + align-items: center; + gap: 8px; +} +.history-bar button { + height: 32px; + padding: 0 11px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--gold-dim); + border-radius: 9px; + background: rgba(7, 13, 12, 0.92); + font: 700 13px/1 Georgia, serif; + cursor: pointer; +} +.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; } +.history-bar button:hover:not(:disabled) { border-color: var(--gold); } +.history-bar button:disabled { opacity: 0.3; cursor: default; } +.history-bar__resume { + border-color: var(--gold) !important; + color: var(--gold); + font: 700 9px Arial, sans-serif; + letter-spacing: 0.14em; } -.result-card p { color: #8d8d84; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; } -.result-card h1 { margin: 16px 0; color: #eee9dd; font: 700 34px Georgia, serif; } -.result-card strong { display: block; margin: 24px; color: var(--gold); font: 700 28px Georgia, serif; } -.result-card i { color: #716b5b; font-style: normal; } -.result-card button { height: 42px; padding: 0 28px; border: 1px solid var(--gold); border-radius: 10px; background: transparent; color: #ece8dc; font: 700 10px Arial, sans-serif; letter-spacing: 0.12em; cursor: pointer; } @media (max-width: 1250px) { .table-center { width: 600px; } @@ -490,7 +650,8 @@ button.card:disabled { cursor: default; } .lane__ghost, .card--compact { width: 78px; height: 110px; } .human-hand .card { width: 98px; height: 142px; margin-left: 7px; } .human-hand { height: 146px; } - .turn-prompt { bottom: 177px; } + .prompt-row { bottom: 177px; } + .history-bar { bottom: 172px; } .table-center { bottom: 210px; } /* Narrow viewports: the plaques are the keep-out that squeezes the hand row, so they become compact chips (model-name line dropped) to give the cards @@ -513,7 +674,8 @@ button.card:disabled { cursor: default; } .table-center { top: 64px; bottom: 196px; } .human-hand { bottom: 18px; height: 138px; } .human-hand .card { width: 94px; height: 134px; } - .turn-prompt { bottom: 165px; } + .prompt-row { bottom: 165px; } + .history-bar { bottom: 160px; } .score-plaque--human { bottom: 9px; } .deck-stack { transform: scale(0.86); } } diff --git a/web/src/ui/useCardMotion.ts b/web/src/ui/useCardMotion.ts new file mode 100644 index 0000000..86c56a3 --- /dev/null +++ b/web/src/ui/useCardMotion.ts @@ -0,0 +1,188 @@ +import { useLayoutEffect, useRef } from "react"; + +/** + * Cards are keyed by card id, so the same card that moves between zones (hand → + * expedition, discard pile → hand, board → hand on undo) is the same logical + * element even though React mounts it in a different subtree. This hook records + * where every card was painted last render and, after the DOM updates, plays the + * difference as real motion (FLIP): the card is placed back at its old position + * and animated to its new one, so it travels instead of teleporting. + * + * A card arriving from the deck was not on screen before, so it flies out of the + * deck stack face-down and flips over on the way in. Undoing a deck draw sends it + * back the same way: the detached element is re-parented into an overlay and + * animated home. + */ + +const MOVE_MS = 400; +const FLIP_MS = 520; +const EASE_OUT = "cubic-bezier(0.22, 0.61, 0.36, 1)"; +const EASE_IN_OUT = "cubic-bezier(0.45, 0.05, 0.55, 0.95)"; +const DEAL_STAGGER_MS = 45; + +export interface CardMotion { + cardRef: (card: number) => (element: HTMLElement | null) => void; + deckRef: (element: HTMLElement | null) => void; + overlayRef: (element: HTMLDivElement | null) => void; + /** Drop the recorded layout so a fresh deal animates in rather than flying + * every card of the previous game back to the deck. */ + resetMotion: () => void; +} + +function center(rect: DOMRect): { x: number; y: number } { + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +} + +function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** Slide a card from where it used to be to where it now is. */ +function animateMove(element: HTMLElement, from: DOMRect, to: DOMRect): void { + const dx = from.left - to.left; + const dy = from.top - to.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + element.animate( + [ + { transform: `translate(${dx}px, ${dy}px)`, zIndex: 30 }, + { transform: `translate(${dx * 0.4}px, ${dy * 0.4}px) scale(1.06)`, zIndex: 30, offset: 0.5 }, + { transform: "none", zIndex: 30 }, + ], + { duration: MOVE_MS, easing: EASE_OUT }, + ); +} + +/** Fly a card out of the deck and flip it face-up on the way. The cover hides + * the mirrored face while the card is turned past edge-on. */ +function animateFromDeck(element: HTMLElement, deck: DOMRect, to: DOMRect, delay: number): void { + const start = center(deck); + const end = center(to); + const dx = start.x - end.x; + const dy = start.y - end.y; + const timing = { duration: FLIP_MS, easing: EASE_OUT, delay, fill: "backwards" as const }; + element.animate( + [ + { + transform: `translate(${dx}px, ${dy}px) perspective(900px) rotateY(-180deg) scale(0.82)`, + zIndex: 30, + }, + { transform: "none", zIndex: 30 }, + ], + timing, + ); + element.querySelector(".card__flip-cover")?.animate( + [ + { opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.46 }, + { opacity: 0, offset: 0.54 }, + { opacity: 0, offset: 1 }, + ], + timing, + ); +} + +/** Undo of a deck draw: the card has left the tree, so re-parent the detached + * element into the overlay and fly it back into the deck, flipping face-down. */ +function animateToDeck( + element: HTMLElement, + from: DOMRect, + deck: DOMRect, + overlay: HTMLDivElement, +): void { + const start = center(from); + const end = center(deck); + const dx = end.x - start.x; + const dy = end.y - start.y; + + Object.assign(element.style, { + position: "fixed", + left: `${from.left}px`, + top: `${from.top}px`, + width: `${from.width}px`, + height: `${from.height}px`, + margin: "0", + pointerEvents: "none", + }); + overlay.append(element); + + const timing = { duration: FLIP_MS, easing: EASE_IN_OUT }; + const animation = element.animate( + [ + { transform: "none" }, + { + transform: `translate(${dx}px, ${dy}px) perspective(900px) rotateY(180deg) scale(0.82)`, + }, + ], + timing, + ); + element.querySelector(".card__flip-cover")?.animate( + [ + { opacity: 0, offset: 0 }, + { opacity: 0, offset: 0.46 }, + { opacity: 1, offset: 0.54 }, + { opacity: 1, offset: 1 }, + ], + timing, + ); + animation.finished.then(() => element.remove(), () => element.remove()); +} + +export function useCardMotion(): CardMotion { + const elements = useRef(new Map()); + const rects = useRef(new Map()); + const deck = useRef(null); + const overlay = useRef(null); + const dealing = useRef(true); + + // No dependency list: every render is a chance for a card to have moved. + useLayoutEffect(() => { + const reduced = prefersReducedMotion(); + const deckRect = deck.current?.getBoundingClientRect() ?? null; + const next = new Map(); + let dealt = 0; + + for (const [card, element] of [...elements.current]) { + // React re-attaches the ref when a card remounts in another zone, so a + // still-detached element means the card left the table (back to the deck). + if (!element.isConnected) { + const from = rects.current.get(card); + elements.current.delete(card); + if (!reduced && from && deckRect && overlay.current) { + animateToDeck(element, from, deckRect, overlay.current); + } + continue; + } + + const to = element.getBoundingClientRect(); + next.set(card, to); + if (reduced) continue; + + const from = rects.current.get(card); + if (from) { + animateMove(element, from, to); + } else if (deckRect) { + animateFromDeck(element, deckRect, to, dealing.current ? dealt * DEAL_STAGGER_MS : 0); + dealt += 1; + } + } + + rects.current = next; + dealing.current = false; + }); + + const cardRef = useRef((card: number) => (element: HTMLElement | null) => { + // Ignore unmount (null): the element is kept so a card that left the tree can + // still be flown back to the deck, and it is dropped once that is handled. + if (element) elements.current.set(card, element); + }).current; + + return { + cardRef, + deckRef: (element) => { deck.current = element; }, + overlayRef: (element) => { overlay.current = element; }, + resetMotion: () => { + rects.current.clear(); + dealing.current = true; + }, + }; +}