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:
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user