Save & Exit brings you back to the same dungeon (§8) - #209
Merged
Conversation
Save & Exit on floor 3 -> Continue -> ensureFloor(3) found an empty cache and built a NEW floor. Different layout, monsters back on their feet, looted containers refilled, smashed props whole. getSaveSnapshot() persisted 14 fields but never G.floors, and dungeon.js generated from bare Math.random() with no seed, so the same floor could not be reproduced at all. At risk: XP, ordinary items (-> Point via burn) and Glitch Shards, which are also sold at $1 per 5. Never at risk: the weekly Paper and Golden Key, guarded by the saved run caps AND the server's per-week records. TWO HALVES, EITHER ALONE FIXES NOTHING 1. The layout is a pure function of a seed. One runSeed chosen at newGame() and saved with the run; a floor's seed is hash(runSeed, cycle, act, abyss, depth). Same seed, same floor — the map costs zero bytes to save. 2. What the player did to it is a delta: which enemies died, which props broke, which containers were opened and what is still in them, which rooms are lit, and where they stood. Indices into the generated arrays, which is only sound BECAUSE of (1). Measured at 2.6 KB for a whole five-floor bunker with every prop smashed and every container opened. Firestore's limit is 1 MB. Math.random is swapped for the seeded stream around generation rather than threaded through, because randomness for one floor is spread across four files and the engine has no module system to share an rng through (rule 1). Generation is synchronous, so the swap is contained and a finally restores it. TWO GUARDS, BOTH OF WHICH EARNED THEIR PLACE A snapshot does not always keep describing the run it was taken from: the shell rewrites campaignActIndex/depth on it when a bunker is cleared and again when a raid finishes. Replaying those floors into the NEXT bunker would start it with the monsters already dead and the containers already emptied. The save now records a floorsKey (cycle, act, abyss, raid) and the delta is dropped unless it still matches. Regeneration can also legitimately differ — a Premium Sector cache appears only while that act's blueprint is owned, which lives outside this save. So each floor stores the array lengths its indices came from, and a mismatch drops the delta rather than killing the wrong monsters. A PRE-EXISTING BUG THIS SURFACED The snapshot written when a raid finishes was cached DURING the raid, so it still said raid:true. enterSavedSession() restores RAID_MODE from that flag, so the next Continue resumed as a raid — and clearing that bunker took the raid branch again, fired nullstate-raid-cleared, and advanced nothing. The campaign silently stopped progressing. Fixed in the same place the campaign path already fixed up its own snapshot. Also removes _wtShade(), the one function in the engine that was defined and never called (found by scanning all 14 engine files against each other and against the app). npm run test:seeded — 15 assertions, fails on the old engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
0xward
added a commit
that referenced
this pull request
Jul 31, 2026
Daily Contracts answer "why play today". They do not answer "why open this at all today", and those are different questions: a player with one spare minute opens the app or does not, and if they do not, what breaks is the habit rather than the session. Seven escalating days, breaking it resets to 1, no claim step — opening the app IS the event. Guests included, since a guest id keys the same records and is exactly the player a retention mechanic is for. 1: 80 Point 2: 2 t1 3: +1 energy 4: 3 t1 5: 150 Point 6: 4 t1 7: 8 t1 Every rung is t1 shards, energy or Point on purpose: shard tier is act-gated, so a t2 reward would hand a new player a currency they cannot spend. Day 7 is 8 t1 because the first weapon evolution costs exactly 8 — a full week is worth one evolution, which is the ratchet §4 asks for and a prize a player can name. Sized so a week of merely opening the app is worth about one day of playing it. The whole decision (continuation, restart, or repeat?) happens inside one RTDB transaction, so two tabs cannot both advance it or both be paid. A grant that throws still counts the day: a re-claimable day is worse than one missed grant. Also fixes a flaky test shipped in #209 — test-seeded-floors.js failed about one run in four because a floor averages 1.6 lockable containers and a fixed depth sometimes generated none. It now retries the mount rather than probing, since every fresh mount draws a new RUN_SEED and the probe's floor is not the next mount's floor. npm run test:streak (31, stubbed RTDB) and test:streak-ui (16, real browser).
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.
Closes the last known bug in
GAME-DESIGN.md— §8 flips from[CHANGE]to[TODAY].No smart contract, ABI, or contract address is touched.
The bug
Save & Exit on floor 3 → Continue →
ensureFloor(3)found an empty cache and built a new floor. Different layout, monsters back on their feet, looted containers refilled, smashed props whole.getSaveSnapshot()persisted 14 fields but neverG.floors, anddungeon.jsgenerated from bareMath.random()with no seed — so the same floor could not be reproduced at all.At risk: XP, ordinary items (→ NullState Point via burn), and Glitch Shards, which are also sold at $1 per 5.
Never at risk: the weekly Paper and Golden Key — guarded both by the saved run caps and by the server's 1-per-wallet-per-week records.
The fix, in two halves
Either half alone fixes nothing.
1. The layout is a pure function of a seed. One
runSeedchosen atnewGame()and saved with the run; a floor's seed ishash(runSeed, cycle, act, abyss, depth). Same seed, same floor — the map costs zero bytes to persist.2. What the player did to it is a delta. Which enemies died, which props broke, which containers were opened and what is still inside them, which rooms are lit, and where they stood. Indices into the generated arrays — sound only because of (1).
Measured: 2.6 KB for a whole five-floor bunker with every prop smashed and every container opened. Firestore's limit is 1 MB.
Why
Math.randomis swapped rather than threadedRandomness for one floor is spread across four files —
dungeon.jslays out rooms,game.jspicks archetypes and elites,props.jsplaces and rolls decor,entities.jsjitters each enemy. The engine is plain<script>tags with no module system (rule 1 in §10), so there is no shared rng to import and threading one would mean touching every constructor. Generation is synchronous and finishes inside one call, so the global is swapped for exactly that window and afinallyrestores it.Two guards, both of which earned their place
A snapshot does not always keep describing the run it was taken from. The shell rewrites
campaignActIndex/depthon it when a bunker is cleared (so ENTER points at the next bunker) and again when a raid finishes. Replaying those floors into the new bunker would start it with the monsters already dead and the containers already emptied. The save now records afloorsKey— cycle, act, abyss, raid — and the delta is dropped unless it still matches.Regeneration can legitimately differ. A Premium Sector cache appears only while that act's blueprint is owned, and ownership lives outside this save. Each floor stores the array lengths its indices came from; a mismatch drops the delta rather than killing the wrong monsters.
A pre-existing bug this surfaced
The snapshot written when a raid finishes was cached during the raid, so it still said
raid: true.enterSavedSession()restoresRAID_MODEfrom that flag — so the next Continue resumed as a raid, and clearing that bunker took the raid branch again, firednullstate-raid-cleared, and advanced nothing.The campaign silently stopped progressing after any raid. Fixed in the same place the campaign path already fixed up its own snapshot, for the same reason.
Dead code
Scanned all 14 engine files against each other and against the app for functions defined but never referenced. Exactly one:
_wtShade(). Removed.Verification
npm run test:seeded— 15 assertions, fails on the old engine (5 fail with seeding disabled):Full suite:
test:seeded15/15 ·test:raid17/17 ·test:contracts13/13 ·test:floorsall passed ·test:inventory7/7 ·test:traversable0 failures in 100 floors ·tsc --noEmitclean ·lint34 errors (unchanged baseline) ·audit·check:copy·check:market·check:cssvars·check:attributionall pass ·buildsucceeds.Docs:
GAME-DESIGN.md§8 rewritten as shipped, build-order item 8 struck through, andgame-mechanics.md's "Known limitation" note replaced with what now actually happens.Generated by Claude Code