What changed, and why
A finished run can build progress, then lose ground โ a staked last dispatch that crit-fails from a real high point back to a low final %. For that case the live end screen shows a "Peaked at X%" stat (issue #146, closing #129), so the floored final number doesn't read "as if the player did nothing."
That stat was live-session-only. The peak lived on the game store as peakProgress, but the run snapshot โ the durable, versioned record a saved (/runs/:id) or shared (/r/:token) run replays from โ never carried it. So a snapshot-backed view rebuilt the end-state without a peak and silently dropped the stat, even for a lost run that clearly peaked. Nothing crashed; the number just wasn't there.
This change threads the peak through the whole snapshot path: into the contract, out at save time, back in on every read (with a migration for runs saved before the field existed), and onto the screen. Six small edits, one per layer. The sections below walk them in the order the data flows.
The contract: a new field, and how old runs read it
RunSnapshot is the shared, versioned record โ client-safe, no runtime imports, so the store and the server both import it. Three edits here. The version goes 1 โ 2, because the shape changed incompatibly. peakProgress joins the interface, documented as a high-water mark that's always โฅ objectiveProgress (the peak only rises; the live total can fall). And migrateSnapshot upgrades an older blob on read.
The version bump, the new field, and the read-time migration.
utils/run-snapshot.ts ยท 189 lines
โฏ 31 lines hidden (lines 1โ31)
โฏ 56 lines hidden (lines 38โ93)
โฏ 10 lines hidden (lines 103โ112)
โฏ 58 lines hidden (lines 132โ189)
Writing it out: the store, and the trust boundary
At end of run the store builds the snapshot and POSTs it. One added line copies the live high-water mark in โ and the store comment notes why it's only meaningful here: a reset zeroes peakProgress, so it has to be captured at the verdict, before any reset.
saveRunSnapshot serializes the live peak into the snapshot.
stores/game.ts ยท 1466 lines
โฏ 1359 lines hidden (lines 1โ1359)
โฏ 97 lines hidden (lines 1370โ1466)
The POST body is untrusted (a direct call can send anything), so sanitizeSnapshot is the boundary that coerces it. It pulls objectiveProgress into a local, then derives the peak as the max of the final % and the (clamped) incoming peak.
Coerce the peak, floored at the final progress.
server/utils/run-snapshot.ts ยท 178 lines
โฏ 154 lines hidden (lines 1โ154)
Reading it back: every storage boundary migrates
A stored blob comes back from storage essentially raw, so the read side is where an old v1 record gets normalized. The rule is simple: **every read path that returns a full snapshot runs it through migrateSnapshot.** There are exactly three such sites, and all three are covered โ so no consumer can ever receive a snapshot with an undefined peak.
toPublicRun (both public reads funnel here), and both getRun adapters.
server/utils/run-snapshot-store.ts ยท 388 lines
โฏ 73 lines hidden (lines 1โ73)
โฏ 72 lines hidden (lines 87โ158)
โฏ 133 lines hidden (lines 163โ295)
โฏ 85 lines hidden (lines 304โ388)
Showing it: the snapshot-backed renderer
A saved or shared run renders through RunView, not EndGameScreen. So the stat lands here, reading the persisted peak instead of the live store, with the same rule and wording as the end screen: show "Peaked at X%" only when the peak sits above the final %.
The stat block, gated by showPeak = snapshot.peakProgress > snapshot.objectiveProgress.
components/RunView.vue ยท 119 lines
โฏ 52 lines hidden (lines 1โ52)
โฏ 42 lines hidden (lines 64โ105)
โฏ 9 lines hidden (lines 111โ119)
Why you can trust it โ the tests
The discriminating cases, each pinned by a test:
- Boundary (sanitizeSnapshot): an absent peak defaults to the final %; a peak < final is floored up to final; an out-of-range peak clamps to 100. - Migration (getRun / getPublicRun of a synthesized v1 blob): reads back with peak == final and version == 2 โ proving the old-run "no peak shown" guarantee end to end. - Write (saveRunSnapshot): a lost run that peaked at 70% and slipped to 40% POSTs peakProgress: 70, distinct from the floored objectiveProgress: 40 โ so the builder reads the peak, not the final. - Render (RunView): shows "Peaked at 70%" on that slipped defeat; hidden on a clean win.
Full unit suite (927) green, nuxt typecheck clean, and the e2e suite green apart from one pre-existing, unrelated landing.spec locator failure that touches no snapshot or RunView code.