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
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
Install and run the checked-in final policy:
+51 -6
View File
@@ -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<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 [selection, setSelection] = useState<Selection>(EMPTY_SELECTION);
const [policy, setPolicy] = useState<Policy | null>(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() {
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button>
{menuOpen && (
<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>
<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>
</div>
)}
@@ -270,7 +314,8 @@ function App() {
<div className="result-overlay">
<div className="result-card">
<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>
)}
+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: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;