From be5226bd3b275a39f7b246cd3cfb662f07d827e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Tue, 14 Jul 2026 21:10:14 +0900 Subject: [PATCH] Add seeded deals and card movement animations Every game is now dealt from a seed carried in the URL as `?seed=`, shown in the menu, and re-dealable by typing it in, so a deal can be shared or replayed. Seeds are hashed into a mulberry32 stream, independent of the Python shuffle bank. Cards previously teleported between zones: the only motion in the client was the hover lift and the legal-target pulse. Cards are keyed by card id, so a card that just moved into a zone mounts there and now animates in, with the motion disabled under prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LmyprzuzanXRhpomc3Ga1i --- web/README.md | 8 ++++++ web/src/App.tsx | 57 +++++++++++++++++++++++++++++++++---- web/src/game/random.test.ts | 29 +++++++++++++++++++ web/src/game/random.ts | 35 +++++++++++++++++++++++ web/src/styles.css | 41 ++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 web/src/game/random.test.ts create mode 100644 web/src/game/random.ts diff --git a/web/README.md b/web/README.md index 8751838..e2ef8bb 100644 --- a/web/README.md +++ b/web/README.md @@ -13,6 +13,14 @@ Pushes to `main` build and publish the client through the repository's GitHub Pages and GitLab Pages workflows. Each workflow supplies the correct base URL for its host. +## Seeded deals + +Every game is dealt from a seed, shown in the menu and kept in the URL as +`?seed=`. Loading that URL — or typing the seed into the menu — replays +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. + ## Setup Install and run the checked-in final policy: diff --git a/web/src/App.tsx b/web/src/App.tsx index f2776c2..d7e89ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,10 +8,11 @@ import { currentHandSorted, encodeAction, legalActionMask, - newGame, previewPlacement, + resetFromOrder, 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 { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy"; @@ -44,8 +45,28 @@ function handCardStep(viewportWidth: number, count: number): number { return Math.max(minVisible, Math.min(relaxed, fitted)); } +/** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed. */ +function initialSeed(): string { + const fromUrl = new URLSearchParams(window.location.search).get("seed"); + const seed = fromUrl === null ? "" : normalizeSeed(fromUrl); + return seed === "" ? randomSeed() : seed; +} + +function gameFromSeed(seed: string): GameState { + return resetFromOrder(deckOrderFromSeed(seed)); +} + +/** Keep the address bar in sync so the current deal stays shareable. */ +function publishSeed(seed: string): void { + const url = new URL(window.location.href); + url.searchParams.set("seed", seed); + window.history.replaceState(null, "", url); +} + function App() { - const [state, setState] = useState(() => newGame()); + const [seed, setSeed] = useState(initialSeed); + const [state, setState] = useState(() => gameFromSeed(seed)); + const [seedDraft, setSeedDraft] = useState(""); const [history, setHistory] = useState([]); const [selection, setSelection] = useState(EMPTY_SELECTION); const [policy, setPolicy] = useState(null); @@ -55,6 +76,8 @@ function App() { const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const generation = useRef(0); + useEffect(() => { publishSeed(seed); }, [seed]); + useEffect(() => { function onResize() { setViewportWidth(window.innerWidth); } window.addEventListener("resize", onResize); @@ -114,15 +137,24 @@ function App() { return () => window.clearTimeout(timer); }, [policy, state]); - function restart() { + /** 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; - setState(newGame()); + setSeed(nextSeed); + setState(gameFromSeed(nextSeed)); + setSeedDraft(""); setHistory([]); setSelection(EMPTY_SELECTION); setThinking(false); setMenuOpen(false); } + function loadSeed() { + const requested = normalizeSeed(seedDraft); + if (requested !== "") restart(requested); + } + function undoTurn() { if (!history.length) return; generation.current += 1; @@ -215,8 +247,20 @@ function App() { {menuOpen && (
- + + + + {policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}
)} @@ -270,7 +314,8 @@ function App() {

ROUND COMPLETE

{status}

{scores[0]} : {scores[1]} - + SEED {seed} +
)} diff --git a/web/src/game/random.test.ts b/web/src/game/random.test.ts new file mode 100644 index 0000000..2b144ab --- /dev/null +++ b/web/src/game/random.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; + +import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./random"; +import { resetFromOrder } from "./engine"; +import { N_CARDS } from "./types"; + +describe("seeded deals", () => { + test("a seed always produces the same 60-card permutation", () => { + const first = deckOrderFromSeed("lost-cities"); + expect(first).toHaveLength(N_CARDS); + expect(new Set(first).size).toBe(N_CARDS); + expect(deckOrderFromSeed("lost-cities")).toEqual(first); + }); + + test("different seeds deal different games", () => { + expect(deckOrderFromSeed("a")).not.toEqual(deckOrderFromSeed("b")); + }); + + test("a seeded deck order is a playable initial state", () => { + const state = resetFromOrder(deckOrderFromSeed(randomSeed())); + expect(state.drawPtr).toBe(16); + expect(state.toMove).toBe(0); + }); + + test("seed input is trimmed and length-capped", () => { + expect(normalizeSeed(" abc ")).toBe("abc"); + expect(normalizeSeed("x".repeat(50))).toHaveLength(32); + }); +}); diff --git a/web/src/game/random.ts b/web/src/game/random.ts new file mode 100644 index 0000000..4640244 --- /dev/null +++ b/web/src/game/random.ts @@ -0,0 +1,35 @@ +import { shuffledDeck } from "./engine"; + +/** 32-bit string hash (cyrb53-lite) so any text works as a seed. */ +export function hashSeed(seed: string): number { + let hash = 2166136261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +/** Deterministic PRNG; the same seed always yields the same stream. */ +export function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = Math.imul(state ^ (state >>> 15), 1 | state); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +export function deckOrderFromSeed(seed: string): number[] { + return shuffledDeck(mulberry32(hashSeed(seed))); +} + +/** A fresh shareable seed, e.g. "k3f9qa". */ +export function randomSeed(): string { + return Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0"); +} + +export function normalizeSeed(raw: string): string { + return raw.trim().slice(0, 32); +} diff --git a/web/src/styles.css b/web/src/styles.css index c21e102..70e2593 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -149,6 +149,19 @@ button:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; } .menu-popover button:hover:not(:disabled) { border-color: var(--gold); } .menu-popover button:disabled { opacity: 0.3; cursor: default; } .menu-popover span { padding: 7px 4px 2px; color: #757a76; font: 8px Arial, sans-serif; letter-spacing: 0.08em; } +.menu-seed { display: grid; gap: 4px; } +.menu-seed span { padding: 4px 4px 0; color: var(--gold); } +.menu-seed input { + height: 32px; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 4px; + background: #0a1210; + color: #ddd9ce; + font: 11px/1 Arial, sans-serif; + letter-spacing: 0.08em; +} +.menu-seed input:focus-visible { border-color: var(--gold); outline: none; } .table-center { position: absolute; @@ -276,6 +289,34 @@ 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; } +} +@keyframes card-draw { + from { transform: translateY(34px) scale(0.9); opacity: 0; } + to { transform: none; opacity: 1; } +} +/* `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; } + +@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, + .deck-stack.is-draw-target { animation: none; } +} + .card { --card-color: #888; position: relative;