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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmyprzuzanXRhpomc3Ga1i
This commit is contained in:
2026-07-14 21:10:14 +09:00
co-authored by Claude Opus 4.8
parent 299c788545
commit be5226bd3b
5 changed files with 164 additions and 6 deletions
+8
View File
@@ -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 Pages and GitLab Pages workflows. Each workflow supplies the correct base URL
for its host. for its host.
## Seeded deals
Every game is dealt from a seed, shown in the menu and kept in the URL as
`?seed=<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 ## Setup
Install and run the checked-in final policy: Install and run the checked-in final policy:
+51 -6
View File
@@ -8,10 +8,11 @@ import {
currentHandSorted, currentHandSorted,
encodeAction, encodeAction,
legalActionMask, legalActionMask,
newGame,
previewPlacement, previewPlacement,
resetFromOrder,
step, step,
} from "./game/engine"; } 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 { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy"; 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)); 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() { function App() {
const [state, setState] = useState<GameState>(() => newGame()); const [seed, setSeed] = useState<string>(initialSeed);
const [state, setState] = useState<GameState>(() => gameFromSeed(seed));
const [seedDraft, setSeedDraft] = useState("");
const [history, setHistory] = useState<GameState[]>([]); const [history, setHistory] = useState<GameState[]>([]);
const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION); const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION);
const [policy, setPolicy] = useState<Policy | null>(null); const [policy, setPolicy] = useState<Policy | null>(null);
@@ -55,6 +76,8 @@ function App() {
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const generation = useRef(0); const generation = useRef(0);
useEffect(() => { publishSeed(seed); }, [seed]);
useEffect(() => { useEffect(() => {
function onResize() { setViewportWidth(window.innerWidth); } function onResize() { setViewportWidth(window.innerWidth); }
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
@@ -114,15 +137,24 @@ function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [policy, state]); }, [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; generation.current += 1;
setState(newGame()); setSeed(nextSeed);
setState(gameFromSeed(nextSeed));
setSeedDraft("");
setHistory([]); setHistory([]);
setSelection(EMPTY_SELECTION); setSelection(EMPTY_SELECTION);
setThinking(false); setThinking(false);
setMenuOpen(false); setMenuOpen(false);
} }
function loadSeed() {
const requested = normalizeSeed(seedDraft);
if (requested !== "") restart(requested);
}
function undoTurn() { function undoTurn() {
if (!history.length) return; if (!history.length) return;
generation.current += 1; generation.current += 1;
@@ -215,8 +247,20 @@ function App() {
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button> <button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button>
{menuOpen && ( {menuOpen && (
<div className="menu-popover"> <div className="menu-popover">
<button onClick={restart}>NEW GAME</button> <button onClick={() => restart()}>NEW GAME</button>
<button onClick={undoTurn} disabled={!history.length}>UNDO TURN</button> <button onClick={undoTurn} disabled={!history.length}>UNDO TURN</button>
<label className="menu-seed">
<span>SEED · {seed}</span>
<input
value={seedDraft}
placeholder="load a seed…"
onChange={(event) => setSeedDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }}
aria-label="Load a game by seed"
/>
</label>
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button>
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span> <span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span>
</div> </div>
)} )}
@@ -270,7 +314,8 @@ function App() {
<div className="result-overlay"> <div className="result-overlay">
<div className="result-card"> <div className="result-card">
<p>ROUND COMPLETE</p><h1>{status}</h1><strong>{scores[0]} <i>:</i> {scores[1]}</strong> <p>ROUND COMPLETE</p><h1>{status}</h1><strong>{scores[0]} <i>:</i> {scores[1]}</strong>
<button onClick={restart}>PLAY AGAIN</button> <small>SEED {seed}</small>
<button onClick={() => restart()}>PLAY AGAIN</button>
</div> </div>
</div> </div>
)} )}
+29
View File
@@ -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);
});
});
+35
View File
@@ -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);
}
+41
View File
@@ -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:hover:not(:disabled) { border-color: var(--gold); }
.menu-popover button:disabled { opacity: 0.3; cursor: default; } .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-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 { .table-center {
position: absolute; 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); } .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); } } @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 {
--card-color: #888; --card-color: #888;
position: relative; position: relative;