The Daily Rift — seeded runs, one a day, with streaks and sharing - #24
Merged
Conversation
Groundwork for the Daily Rift: a run can now be reproduced exactly from a
seed. Adds `rng: () => number` as a third injected port alongside `now` and
`stash`, defaulting to `Math.random`:
new Game(cb, { seed: 20260731 }) // reproducible
new Game(cb) // unseeded, as before
All ~59 simulation-side `Math.random()` calls now route through it:
- game.ts and the composed modules use `this.rng()` / `this.game.rng()`
- systems that already receive `game` use `game.rng()`; CombatSystem's
private rollDie/resolveCombatRoll take an explicit rng parameter
- dataLoader's pure content pickers (shuffleInPlace, buildOffer,
tierForFloor, Boon/Brand.pickThree, FloorEvent/Npc/Omen.random) take an
optional `rng` defaulting to Math.random, threaded from the call sites
Cosmetic randomness in renderer.ts and particles.ts is deliberately left on
Math.random — it never affects outcomes, and seeding it would make replays
look eerily identical without being any fairer.
Resuming a seeded run would otherwise silently become a *different* dungeon,
because a PRNG's stream position isn't captured by a state snapshot. Game
therefore counts its draws (`rngCalls`, serialized) and `restoreRngPosition`
fast-forwards a restored run to the same point. `rng`/`rngBase` join
SAVE_SKIP as live closures; `seed`/`rngCalls` are saved as run data.
Unseeded runs resolve `Math.random` lazily at call time rather than
capturing the reference, so tests and hosts can still stub the global.
src/rng.ts adds mulberry32 (`makeRng`), a stable FNV-1a `hashSeed` for
turning a date or share-code into a seed, and `dailySeedString` (UTC day, so
every player gets the same rift on the same date).
Tests: same seed → byte-identical run fingerprint; different seeds diverge;
unseeded stays random; stream position survives a restore; helper coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
…sharing Builds the retention hook on top of the seeded RNG: a single run per day against the same dungeon as every other player, keyed off the UTC calendar date (`hashSeed(dailySeedString())`). - Start screen gains a Daily Rift button showing today's date and the current streak; it disables itself once the day is spent. - Daily runs are rebuilt from scratch with the seed (the page-load Game was generated unseeded) and locked to the default difficulty with no NG+ heat, so scores are actually comparable. Class and curse stay player choices — the dungeon is what's shared, not the loadout. - One attempt per day: the first result is the one banked, and replaying neither double-counts the streak nor overwrites the record. - Streaks advance on consecutive UTC days, reset after a gap, and keep a best-ever. Month boundaries handled by date arithmetic, not string maths. - The death/victory screen's existing share box becomes a multi-line daily summary (date, outcome, XP, kills/lines/combo, streak) when the run was a daily; normal runs keep the old one-liner. launchWithModifier grew `fixedDifficulty`/`skipHeat` options rather than a parallel code path, so the daily reuses the whole existing setup flow. - types.ts: DailyResult / DailyState - storage.ts: loadDaily / dailyPlayed / recordDaily (streak logic) - start-modal, game-over-modal, ui.ts: daily card and share payload - main.ts: beginDaily, dailyStartInfo, finishDaily - tests: 6 streak cases incl. gaps, month rollover, and same-day replay - README: Core-systems row; ports list now names the seed Verified live: the button renders today's date, and starting a daily skips the difficulty picker straight to class selection, with no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The retention hook: a single seeded run per day against the same dungeon as every other player, with a streak to keep and a result worth sharing.
Two commits — the seedable simulation that makes it possible, then the feature itself.
1/2 — Seedable simulation (
47fc7f5)rng: () => numberbecomes a third injected port alongsidenowandstash:All ~59 simulation-side
Math.random()calls route through it:game.tsand the composed modules usethis.rng()/this.game.rng()gameusegame.rng();CombatSystem's privaterollDie/resolveCombatRolltake an explicit rng parameterdataLoader's pure content pickers (shuffleInPlace,buildOffer,tierForFloor,Boon/Brand.pickThree,FloorEvent/Npc/Omen.random) take an optionalrngdefaulting toMath.random, threaded from the call sitesCosmetic randomness in
renderer.ts/particles.tsdeliberately stays onMath.random— it never affects outcomes.Two subtleties worth reviewing:
Gamecounts its draws (rngCalls, serialized) andrestoreRngPosition()fast-forwards a restored run to the same point.rng/rngBasejoinSAVE_SKIPas live closures;seed/rngCallsare saved as run data.Math.randomlazily rather than capturing the reference, so tests and hosts that stub the global still take effect on an already-constructedGame. (Eight existing tests do exactly this.)New
src/rng.ts: mulberry32 (makeRng), a stable FNV-1ahashSeed, anddailySeedString(UTC day).2/2 — The Daily Rift (
92f7d27)launchWithModifiergainedfixedDifficulty/skipHeatoptions rather than a parallel code path, so the daily reuses the whole existing setup flow.Design note
I let players pick class and curse on dailies rather than fixing the entire loadout. Fully fixing it would make scores more strictly comparable, but removes the decision-making that makes a run interesting. Easy to tighten later if leaderboard purity matters more than variety.
Verification
npm run verifygreen: 456/456 tests (10 new seed/rng cases, 6 new streak cases covering gaps, month rollover and same-day replay), coverage ~86.1% stmts / 78.4% branches / 85.4% funcs / 88.9% lines, lint + validate-data + headless + build + smoke all pass.Verified live in-browser: the Daily button renders today's date, and starting a daily correctly skips the difficulty picker straight to class selection, with no page errors.
🤖 Generated with Claude Code
Generated by Claude Code