diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9600fa..0c65697 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,59 @@
# Changelog
+## v1.10.0
+
+- **Triggering a Singularity deleted you from the Legacy Cores leaderboard.**
+ The board read `meta.legacyCores` — the cores you are holding *right now* —
+ and then dropped every row whose value was zero. A Singularity spends every
+ core you have. So the moment a player performed the most demanding action in
+ the game, the board stopped listing them entirely, and the only way to appear
+ on it was to have never used what it was measuring.
+
+ The board now reads a new lifetime stat, `meta.stats.bestLegacyCores`, and is
+ labelled **Legacy Cores (best)**. The zero-filter is untouched and is now
+ simply correct rather than special-cased: a player who reset has a non-zero
+ best, an account that never played does not.
+
+ There is no migration and nothing to backfill. The stat is maintained by one
+ helper called from `evaluate()`, so an existing save seeds itself from its
+ current cores the first time the server reconciles it — and from
+ `singularity()` too, immediately before it zeroes the value, because
+ `POST /api/actions` applies a whole batch with no evaluation in between and a
+ Migrate-then-Singularity batch would otherwise destroy the peak before
+ anything observed it.
+
+- **A buy-to-next-milestone button on Racks, Grid and Overclock.** Every 25,
+ 50, 100, 200, 500 and 1000 units doubles that lane's output, and reaching the
+ next one previously meant arithmetic and repeated Buy 10s. The new button
+ buys exactly the remainder — no more, no less.
+
+ The **server** computes the target, not the client. The `infiniteloop` shard
+ upgrade discounts those thresholds, so a client working from a stale config
+ would ask for the wrong number; the button sends `mode: 'milestone'` and the
+ reducer resolves it against the same discounted thresholds that decide the
+ multiplier you actually earn. The cost on the label is display only. A jump
+ you cannot afford is refused whole — there is no partial buy — and a lane
+ past its final threshold reports the new `no_milestone` rather than
+ pretending it could not afford one.
+
+- **Minigame personal bests.** Each card in the Games tab shows your best score
+ for that game, and finishing a run that beats it says so. Derived server-side
+ from the `minigame_sessions` rows that already existed, so every score you
+ set before this shipped is already there — again, no migration.
+
+- **Progress bars on locked badges.** A badge you have not earned now shows how
+ far along you are. The bar and the unlock read the same number: an
+ achievement now declares `progress` and `target` instead of a hand-written
+ boolean, and the unlock is derived from them, so the two cannot drift apart.
+ The two achievements that are genuinely yes/no (Jackpot, Showed Up) stay
+ boolean and show no bar. No threshold moved.
+
+- **The Upgrades and Singularity panels read live config maximums.** Both took
+ an upgrade's ceiling from the static definition, so an admin raising a max
+ level never reached them — a purchasable upgrade could read as maxed out.
+ They now read `config.upgrades.maxLevels`, as the Cold Storage panel already
+ did.
+
## v1.9.1
- **The SuperTokens login button signed you in and then left you logged out.**
diff --git a/Dockerfile b/Dockerfile
index 427f2a4..86697df 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
# published image's version label from that tag - so this literal only
# affects locally-built images, not what GHCR publishes.
-LABEL org.opencontainers.image.version="1.9.1"
+LABEL org.opencontainers.image.version="1.10.0"
VOLUME ["/app/data"]
EXPOSE 3000
diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx
index 0908fae..6e0b079 100644
--- a/client/src/RackStack.jsx
+++ b/client/src/RackStack.jsx
@@ -8,7 +8,8 @@ import { cardBorder, textDim, textMain, teal, amber, danger, inset } from './gam
import { TABS } from './game/data/tabs.js';
import {
fetchState, fetchConfig, makeActionQueue, startMinigame, finishMinigame,
- fetchEvent, setLeaderboardOptOut, fetchLeaderboard, setTourCompleted,
+ fetchMinigameBests, fetchEvent, setLeaderboardOptOut, fetchLeaderboard,
+ setTourCompleted,
} from './game/api.js';
import { evaluate } from '@shared/state.js';
import { applyAction, EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js';
@@ -119,6 +120,11 @@ export default function RackStack({ user }) {
const [rejectToast, setRejectToast] = useState(null);
const [activeTab, setActiveTab] = useState('racks');
const [minigame, setMinigame] = useState(null);
+ // Best finished score per game, keyed by the server's game string. Not part
+ // of canonical `state`: it is derived server-side from minigame_sessions
+ // rows, so it neither reconciles nor belongs in the save. `{}` until the
+ // fetch lands, and refreshed after every finished round.
+ const [minigameBests, setMinigameBests] = useState({});
const [profileOpen, setProfileOpen] = useState(false);
// v1.6 guided tours. `toursCompleted` mirrors users.tours_completed, which
// arrives on the `user` prop from App.jsx's /api/me fetch; `activeTour` is
@@ -229,6 +235,18 @@ export default function RackStack({ user }) {
return () => clearTimeout(t);
}, [rejectToast]);
+ // Personal bests: fetched once at mount and again after each finished round,
+ // so a record the player just set is on the card by the time the result
+ // modal is dismissed. A failure leaves the previous value standing - the
+ // bests line is decoration, and blanking it on a transient 401 mid-refresh
+ // would read as a lost score.
+ const refreshMinigameBests = useCallback(async () => {
+ const res = await fetchMinigameBests();
+ if (res && !res.error && res.bests) setMinigameBests(res.bests);
+ }, []);
+
+ useEffect(() => { refreshMinigameBests(); }, [refreshMinigameBests]);
+
// Stable label for the current anomaly window - chosen once per window
// (keyed on nextAnomalyAt) rather than re-rolled every render, matching
// the old client-rolled-event feel even though timing itself is now canon.
@@ -855,7 +873,13 @@ export default function RackStack({ user }) {
? `${mg.pairsFound}/${pairCount} pairs matched — +${wafers} wafers`
: `${mg.pairsFound}/${pairCount} pairs matched — no payout, not fully matched`;
} else text = `${mg.score} stabilizations — +${wafers} wafers`;
+ // newBest is the server's comparison against every prior finished
+ // score for this game, made before this round was written. Appended to
+ // the existing result modal rather than raised as a second popup: the
+ // player is already looking at exactly this number.
+ if (res.newBest) text += ' — NEW BEST!';
setModal({ type: 'minigameResult', text });
+ refreshMinigameBests();
} else {
showToast(REJECT_MESSAGES[res && res.error] || 'Session expired');
}
@@ -1137,10 +1161,10 @@ export default function RackStack({ user }) {
/>
)}
- {activeTab === 'upgrades' && }
+ {activeTab === 'upgrades' && }
{activeTab === 'singularity' && (
- setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} />
+ setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} />
)}
{activeTab === 'goals' && (
@@ -1155,6 +1179,7 @@ export default function RackStack({ user }) {
onStartBalance={startBalanceGame}
cooldowns={state.server.gameCooldowns}
minigamesConfig={config.data.minigames}
+ bests={minigameBests}
/>
)}
@@ -1191,6 +1216,7 @@ export default function RackStack({ user }) {
{activeTab === 'social' && (
{ state, wafers }
+// POST /api/minigame/finish { sessionId, metric } -> { state, wafers, newBest }
+// newBest: true iff this run's (clamped) score beat every prior finished
+// score for this game, per GET /api/minigame/bests - false on ties.
// | 410 { error: 'gone' } | 404 { error: 'not_found' } | 429 { error: 'cooldown_active' }
export function finishMinigame(sessionId, metric) {
return postJSON('/api/minigame/finish', { sessionId, metric });
}
+// GET /api/minigame/bests -> { bests: { : number } }
+// Derived server-side from finished sessions, so it is populated with scores
+// the player set before this feature existed - no backfill was needed.
+export function fetchMinigameBests() {
+ return request('/api/minigame/bests');
+}
+
// PUT /api/me/username { username } -> { ok, username }
// | 400 { error: 'invalid_username' } | 409 { error: 'taken' }
export function setUsername(name) {
diff --git a/client/src/game/components/GamesPanel.jsx b/client/src/game/components/GamesPanel.jsx
index 0cf6bbc..1c83cda 100644
--- a/client/src/game/components/GamesPanel.jsx
+++ b/client/src/game/components/GamesPanel.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Zap, Bug, Cable, Flame } from 'lucide-react';
import { cardBg, cardBorder, textMain, textDim, amber, teal, violet, danger } from '../theme.js';
-function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay, cooldownUntil }) {
+function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay, cooldownUntil, best, unit }) {
const [, forceTick] = useState(0);
useEffect(() => {
if (!cooldownUntil || Date.now() >= cooldownUntil) return undefined;
@@ -18,6 +18,11 @@ function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay
{title}
{desc}
+ {typeof best === 'number' && (
+
+ best {best} {unit}
+
+ )}
+ {(() => {
+ const next = nextMilestone(g.owned, thresholds);
+ if (next === null) return null; // past the last milestone
+ const n = next - g.owned;
+ const costN = costForN(def, g.owned, n);
+ const affordable = run.credits >= costN;
+ return (
+
+ );
+ })()}
);
})}
diff --git a/client/src/game/components/OverclockPanel.jsx b/client/src/game/components/OverclockPanel.jsx
index a21e36a..0bf64e4 100644
--- a/client/src/game/components/OverclockPanel.jsx
+++ b/client/src/game/components/OverclockPanel.jsx
@@ -78,6 +78,24 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
Max{maxN >= 1 ? ` +${maxN}` : ''}
+ {(() => {
+ const next = nextMilestone(o.owned, thresholds);
+ if (next === null) return null; // past the last milestone
+ const n = next - o.owned;
+ const costN = costForN(def, o.owned, n);
+ const affordable = run.credits >= costN && !onCooldown;
+ return (
+
+ );
+ })()}
);
})}
diff --git a/client/src/game/components/RacksPanel.jsx b/client/src/game/components/RacksPanel.jsx
index 22365aa..0618888 100644
--- a/client/src/game/components/RacksPanel.jsx
+++ b/client/src/game/components/RacksPanel.jsx
@@ -88,6 +88,24 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e
Max{maxN >= 1 ? ` +${maxN}` : ''}
+ {(() => {
+ const next = nextMilestone(ts.owned, thresholds);
+ if (next === null) return null; // past the last milestone
+ const n = next - ts.owned;
+ const costN = costForN(def, ts.owned, n);
+ const affordable = run.credits >= costN;
+ return (
+
+ );
+ })()}
);
})}
diff --git a/client/src/game/components/SingularityPanel.jsx b/client/src/game/components/SingularityPanel.jsx
index 1cbb60f..9a70294 100644
--- a/client/src/game/components/SingularityPanel.jsx
+++ b/client/src/game/components/SingularityPanel.jsx
@@ -2,7 +2,7 @@ import { Sparkles, Gem } from 'lucide-react';
import { cardBg, violet, textMain, textDim } from '../theme.js';
import { SINGULARITY_DEFS } from '../data/upgrades.js';
-export default function SingularityPanel({ meta, singularityGain, onOpenSingularityConfirm, onBuyShard }) {
+export default function SingularityPanel({ meta, config, singularityGain, onOpenSingularityConfirm, onBuyShard }) {
return (
{UPGRADE_DEFS.map((u) => {
const level = meta.upgrades[u.id] || 0;
- const maxed = level >= u.maxLevel;
+ // Max level is read live from config.upgrades.maxLevels (admin-tunable,
+ // same source the reducer's buyUpgrade() enforces) rather than the
+ // static u.maxLevel on the def.
+ const maxLevel = (config.upgrades.maxLevels && config.upgrades.maxLevels[u.id]) ?? u.maxLevel;
+ const maxed = level >= maxLevel;
const cost = Math.ceil(u.baseCost * Math.pow(u.costMult, level));
const afford = meta.wafers >= cost;
return (
{u.name}
-
Lv {level}/{u.maxLevel}
+
Lv {level}/{maxLevel}
{u.desc}
{def.desc}
+ {!unlocked && ctx && (() => {
+ const p = achievementProgress(def, ctx);
+ if (!p || !(p.target > 0)) return null; // boolean badge, no bar
+ // Clamped for the width only: achievementProgress deliberately
+ // does not clamp `current`, and an unclamped ratio would draw a
+ // bar wider than its track.
+ const pct = Math.max(0, Math.min(1, p.current / p.target)) * 100;
+ return (
+
+
+
+
+ {/* fmt is mandatory, not cosmetic: flops_p's target is 1e15. */}
+
+ {fmt(p.current)} / {fmt(p.target)}
+
+
+ );
+ })()}
{unlocked && unlockedDate(at) && (
{unlockedDate(at)}
)}
diff --git a/client/src/game/components/social/LeaderboardSection.jsx b/client/src/game/components/social/LeaderboardSection.jsx
index 3b5cb62..9a71295 100644
--- a/client/src/game/components/social/LeaderboardSection.jsx
+++ b/client/src/game/components/social/LeaderboardSection.jsx
@@ -9,7 +9,7 @@ import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js';
const BOARDS = [
{ key: 'allTimeFlops', label: 'FLOPS', format: (v) => `${fmt(v)} all-time` },
{ key: 'level', label: 'Level', format: (v) => `lv ${v}` },
- { key: 'legacyCores', label: 'Cores', format: (v) => `${fmt(v)} cores` },
+ { key: 'legacyCores', label: 'Legacy Cores (best)', format: (v) => `${fmt(v)} cores` },
{ key: 'singularities', label: 'Singularities', format: (v) => `${fmt(v)}x` },
{ key: 'tapes', label: 'Tapes', format: (v) => `${fmt(v)} tapes` },
{ key: 'latestEventRung', label: 'Last event', format: (v) => `${v} rungs` },
diff --git a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md
new file mode 100644
index 0000000..0310bbe
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md
@@ -0,0 +1,223 @@
+# v1.10 — execution notes and session handoff
+
+Companion to `2026-08-08-v1.10-qol.md`. **Committed and pushed on purpose:**
+the SDD workspace under `.superpowers/sdd/` is git-ignored and does not
+survive the worktree, so this file is the record that crosses a session
+boundary.
+
+- **Plan:** `docs/superpowers/plans/2026-08-08-v1.10-qol.md` (10 tasks)
+- **Spec:** `docs/superpowers/specs/2026-08-08-v1.10-qol-design.md`
+- **Branch:** `v1.10-qol-spec` — carries the spec, the plan and the
+ implementation. Merge base `45841e6`.
+
+## How to resume in a new session
+
+1. `git log --oneline 45841e6..HEAD` on this branch — every completed task
+ committed its own work, so the commits are the truth.
+2. Read the task ledger below for what was done, deferred or parked.
+3. Pick up at the first task with no **complete** line.
+4. Re-run `TEST_BACKEND=sqlite npx vitest run` before trusting anything.
+
+Postgres tests need a container runtime; this machine has podman, not docker:
+
+```bash
+systemctl --user start podman.socket
+export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
+export TESTCONTAINERS_RYUK_DISABLED=true
+```
+
+Playwright is not a dependency of this repo. If `npm run smoke` cannot find
+it, symlink it from another worktree's `node_modules`.
+
+## Baseline before this work
+
+698 vitest (SQLite) / 724 (Postgres) / 50 smoke, all green at `45841e6`.
+
+## Task ledger
+
+## STATUS: ALL 10 TASKS COMPLETE (2026-08-08)
+
+**Every task is implemented and committed.** Tasks 1–6 landed in the first
+session; 7, 8, 9 and 10 landed in the second, plus a triage pass over the
+deferred minors below.
+
+Full verification at the end of the branch, all three run after the last code
+change:
+
+| suite | result |
+|---|---|
+| `TEST_BACKEND=sqlite npx vitest run` | **720 passed**, 29 skipped, 0 failed |
+| `npx vitest run` (Postgres, podman) | **746 passed**, 3 skipped, 0 failed |
+| `TEST_BACKEND=sqlite npm run smoke` | **57 PASS**, 0 FAIL, 0 SKIP, exit 0 |
+
+Against the `45841e6` baseline of 698/724/50, that is +22 SQLite, +22 Postgres
+and +7 smoke checks. The smoke run resolved Playwright, so check 6 of
+`smoke-v110.mjs` really did drive the built client rather than SKIPping.
+
+Remaining before release: the release ritual at the foot of the plan — merge
+the PR, then tag **`main`** (never the branch) as `v1.10.0` and push the tag.
+The tag push is what triggers the GHCR publish.
+
+Review outcomes:
+- Task 1 — clean after 2 fix rounds (peak update placed after `evaluate()`'s
+ `elapsedSec < 1` guard instead of before, and the report then claimed no
+ deviation; both corrected).
+- Tasks 2, 3, 4, 5, 6 — clean, no fix rounds.
+
+**Three defects in the PLAN were found by implementers.** The plan is prose I
+wrote against the codebase and it was wrong three times; treat its code
+snippets as a strong draft, not gospel, and verify signatures before use:
+1. The lane is `tiers`, not `racks` (fixed in the plan).
+2. `createMinigameSession(userId, game)` is 2-arg, not the 4-arg form the plan
+ showed.
+3. `requireAuth` populates `req.user.sub`, NOT `req.user.id`. The plan's route
+ snippets used `.id`. **Task 7 and Task 10 snippets may repeat this.**
+
+**A defect in the plan, found by the Task 3 implementer and now fixed in the
+plan file:** the lane is called **`tiers`**, not `racks`. The plan's test
+snippets said `lane: 'racks'`, which does not exist. All 7 occurrences are
+corrected, but be aware the same mistake may lurk in prose elsewhere —
+`LANE_DEFS` in `shared/reducer.js` is the authority.
+
+Human ruling recorded before execution: **the plan governs** on two points a
+reviewer may flag — Task 4 duplicates the milestone-button block across three
+panels instead of extracting a shared component, and Task 1 calls
+`recordLegacyCorePeak` from two sites. Park such findings with a ruling rather
+than "fixing" them.
+
+Deferred minors — **triaged in commit `ff97460`**, dispositions inline below:
+- Task 2: the "still hides an account that has never earned a core" test passes
+ whether the board reads current or peak cores. It is a filter-boundary test,
+ not a regression test; its sibling is the strict one.
+ **KEPT AS IS** — it is testing the filter, which is what it is for, and
+ `smoke-v110.mjs` check 2 now covers the same boundary end-to-end.
+- Task 3: `buy()` computes `milestoneThresholds()` on every call, including for
+ the pre-existing `'max'` and integer modes that do not use it — a wasted
+ `computeEffects` on the hot buy path. Correctness unaffected.
+ **FIXED** — the thresholds are now derived only when `mode === 'milestone'`;
+ the other modes get `null`, which `resolveBuyCount` never dereferences.
+- Task 3: one test derives its expected multiplier from post-buy `meta` rather
+ than pre-buy; inert because `buy()` never mutates `meta`.
+ **KEPT AS IS** — inert, and rewriting it would add no coverage.
+- Task 6: `getMinigameBests` was not added to the `INTERFACE` array in
+ `tests/db.interface.test.js` that the driver-parity test iterates over. The
+ function has its own direct tests, so this is not a hole today — but that
+ array is the net that catches one driver gaining a method the other lacks,
+ and this repo has been bitten by driver drift before. Worth adding.
+ **FIXED** — added to the array.
+
+## Task ledger
+
+_Each task appends here: what changed, decisions made, anything deferred._
+
+### Task 1: Track peak Legacy Cores
+- Commits: d18f85d (implementation), 65f73a8 (notes), ca15591 (fix)
+- Changed: Added `meta.stats.bestLegacyCores` stat and `recordLegacyCorePeak()` helper; called from both `evaluate()` (before 1-sec early-return guard) and `singularity()` (before zeroing cores). Fixed: moved evaluate() call from end of function to before the early-return guard per brief Step 7.
+- Decisions/deviations: Initial placement violated brief requirement; corrected. Durable across Migrate/Singularity; wiped by hardReset (correct behavior).
+- Tests: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js tests/state.test.js` → 49 passed
+
+### Task 2: Leaderboard reads the peak
+- Commits: e15fe5f
+- Changed: Updated `legacyCores` board reader in `server/leaderboardService.js` to read `(meta.stats && meta.stats.bestLegacyCores) || 0`; added tests to `tests/api.social.test.js`; updated label in `client/src/game/components/social/LeaderboardSection.jsx` to "Legacy Cores (best)"
+- Decisions/deviations: none
+- Tests: `TEST_BACKEND=sqlite npx vitest run tests/api.social.test.js` → 9 passed, full suite → 705 passed
+
+### Task 3: Server-side buy mode 'milestone'
+- Commits: e254e77
+- Changed: Extracted `milestoneThresholds(meta, config)` out of `computeMults` in `shared/gameRules.js` (single source of the discounted-thresholds expression); `shared/reducer.js`'s `resolveBuyCount` gained a `thresholds` param and a `mode === 'milestone'` branch (`nextMilestone(owned, thresholds) - owned`, or `0` if none), and `buy()` distinguishes `no_milestone` (past final threshold) from `insufficient_credits` (unaffordable jump) on the two `n === 0` cases.
+- Decisions/deviations: The brief's test snippets used a `racks` lane (`s.run.racks[0]`, `lane: 'racks'`) that doesn't exist in this codebase — the actual lane/property name is `tiers` (`shared/reducer.js`'s `LANE_DEFS`, `shared/state.js`). Used `lane: 'tiers'` / `s.run.tiers[0]` instead, values and assertions otherwise unchanged. Also matched this test file's existing conventions: `DEFAULT_CONFIG` instead of a bare `config`, `result.ok`/`result.error` off `applyAction`'s actual `{ state, result }` return shape instead of the brief's `out.ok`/`out.error`, and the file's fixed `NOW` constant instead of `Date.now()`. Full detail in `.superpowers/sdd/2026-08-08-v1.10-qol/task-3-report.md` (git-ignored, this session only).
+- Tests: `TEST_BACKEND=sqlite npx vitest run tests/reducer.economy.test.js` → 58 passed; full suite `TEST_BACKEND=sqlite npx vitest run` → 711 passed, 29 skipped, 0 failed
+
+### Task 4: Milestone buttons in the three lane panels
+- Commits: cae4ba8
+- Changed: Added a `→ {next}: {n} for {fmt(costN)}` button calling `onBuy(i, 'milestone')` to `RacksPanel.jsx`, `GridPanel.jsx` and `OverclockPanel.jsx`, each as a full-width row below the existing 1/10/Max grid; renders nothing when `nextMilestone` is `null`. Display-only cost calc, no purchase-count logic client-side.
+- Decisions/deviations: Placed as a full-width row under the existing 3-button grid rather than a literal 4th cell (brief's snippet had no container, label is variable-length and long); no new `@shared/gameRules.js` import added since all three panels already re-import `nextMilestone`/`costForN`/`fmt` via `../helpers.js`'s barrel re-export; OverclockPanel's button also gates on `!onCooldown`, matching its sibling +10/Max buttons in that panel (brief's snippet, written against RacksPanel, didn't need this). Full detail in `.superpowers/sdd/2026-08-08-v1.10-qol/task-4-report.md` (git-ignored, this session only).
+- Tests: `cd client && npm run build` → PASS (1573 modules, 1.10s); `TEST_BACKEND=sqlite npx vitest run` → 711 passed, 29 skipped, 0 failed
+
+### Task 5: Panels read live config maximums
+- Commits: 6b93d18
+- Changed: Updated `UpgradesPanel.jsx` and `SingularityPanel.jsx` to read each upgrade's maximum level from live `config.upgrades.maxLevels[id]` instead of static definition's `maxLevel`; threaded `config` prop from `RackStack.jsx` to both panels, following the pattern already established in `ColdStoragePanel.jsx`.
+- Decisions/deviations: none
+- Tests: `cd client && npm run build` → PASS (1573 modules); `TEST_BACKEND=sqlite npx vitest run` → 711 passed, 29 skipped, 0 failed
+
+### Task 6: Minigame personal bests - data layer and API
+- Commits: 6472f73
+- Changed: Added `getMinigameBests(userId)` (MAX(score) GROUP BY game over finished `minigame_sessions`) to both `server/db/driver.sqlite.js` and `server/db/driver.pg.js` (pg coerces with `Number()`), re-exported from `server/db/index.js`; added `GET /api/minigame/bests -> { bests: { : number } }` and a `newBest` boolean on `POST /api/minigame/finish` (read before writing the session's score) to `server/routes/api.js`; documented the new field on `finishMinigame` in `client/src/game/api.js`.
+- Decisions/deviations: brief's test/route snippets used a 4-arg `createMinigameSession` and `req.user.id` that don't match this codebase - used the real 2-arg signature and `req.user.sub`; cleared the win cooldown via `putSave` (this file's existing state-mutation precedent) rather than a second real `start` call, since the 30s cooldown would 429 it. Full detail in `.superpowers/sdd/2026-08-08-v1.10-qol/task-6-report.md` (git-ignored, this session only).
+- Tests: `TEST_BACKEND=sqlite npx vitest run tests/db.interface.test.js` → 4 passed; `TEST_BACKEND=sqlite npx vitest run tests/api.test.js` → 33 passed; Postgres (podman) `npx vitest run tests/db.interface.test.js` → 4 passed; full SQLite suite `TEST_BACKEND=sqlite npx vitest run` → 716 passed, 29 skipped, 0 failed
+
+### Task 7: Personal bests in the Games tab
+- Commits: 3ba9aa7
+- Changed: added `fetchMinigameBests()` to `client/src/game/api.js`; `GamesPanel`
+ gained a `bests` prop (defaulting to `{}`) and each `GameCard` a `best`/`unit`
+ pair rendered between the description and the Play button; `RackStack.jsx`
+ holds `minigameBests` state, fetches through a `useCallback`'d
+ `refreshMinigameBests` on mount and again after every finished round, and
+ appends `— NEW BEST!` to the existing `minigameResult` modal text when the
+ server reports `newBest`.
+- Decisions/deviations: a card with no entry in `bests` renders NO best line
+ rather than `best 0` — a game you have never played should not claim a score.
+ The brief said to surface `newBest` "through whatever toast mechanism the file
+ already uses for minigame rewards"; that mechanism is the result MODAL
+ (`setModal({ type: 'minigameResult' })`), not `showToast` (which this file
+ reserves for rejections), so the flag extends the modal's text instead of
+ raising a second popup over the same number. A failed refresh leaves the
+ previous bests standing rather than blanking them.
+- Tests: `cd client && npm run build` → PASS
+
+### Task 8: Achievements expose progress
+- Commits: 0140da6
+- Changed: 17 of the 19 `ACHIEVEMENT_DEFS` now carry `progress`/`target` instead
+ of `condition`; `jackpot` and `event_joined` stay boolean. Added
+ `isAchievementMet(def, ctx)` and `achievementProgress(def, ctx)`;
+ `checkAchievements` routes through the former, leaving its try/catch intact so
+ a throwing progress function still counts as unmet.
+- Decisions/deviations: the pre-existing shape test in this file asserted
+ `typeof d.condition === 'function'` for EVERY def and would have failed on the
+ converted ones — relaxed to "scalar or boolean", with the strict
+ exactly-one-of assertion living in the new describe block. Added a fourth test
+ beyond the brief's three: every scalar def must report a finite `current` and
+ a positive `target` on a fresh save, because a NaN current or a zero target
+ renders as a `NaN%` bar width that React writes to the DOM without complaint.
+ No threshold moved.
+- Tests: `TEST_BACKEND=sqlite npx vitest run tests/achievements.test.js` → 18
+ passed; full SQLite suite → 720 passed, 0 failed
+
+### Task 9: Badge progress bars
+- Commits: dbd1815
+- Changed: `AchievementsSection.jsx` renders a tier-coloured bar plus
+ `fmt(current) / fmt(target)` under each still-locked scalar badge; `ctx`
+ threaded `RackStack.jsx` → `SocialPanel` → `AchievementsSection`.
+- Decisions/deviations: the brief said to build a `goalCtx` in `SocialPanel` if
+ it did not already hold one — it holds neither a ctx nor the `state`/`config`
+ needed to build one (it receives `meta`), so rather than importing `goalCtx`
+ and rebuilding it there, the ctx `RackStack.jsx` already computes once per
+ render (line ~1065, the same object it passes to `GoalsPanel`) is passed down.
+ Same object, no second construction, and it satisfies the brief's real
+ requirement of never hand-rolling a partial ctx. `ctx` is optional on
+ `AchievementsSection` so the badge case still renders without it; the bar also
+ guards `target > 0` against a divide-by-zero width.
+- Tests: `cd client && npm run build` → PASS
+
+### Task 10: Smoke suite, docs and release
+- Commits: 6366d62 (task), ff97460 (deferred-minor triage)
+- Changed: new `tests/e2e/smoke-v110.mjs` (port 3810, `/tmp/e2e-v110.db`, the
+ `smoke-v16.mjs` harness shape) with 7 checks — the two leaderboard ones, the
+ two milestone-buy ones, two for `/api/minigame/bests` (max-not-latest, and
+ auth), and the Playwright one over the built client. `CHANGELOG.md` gained a
+ `## v1.10.0` section led by the leaderboard fix; `package.json` and the
+ `Dockerfile` LABEL both go to 1.10.0. `client/package.json` deliberately NOT
+ bumped — `client/vite.config.js` reads the root as the single authority.
+- Decisions/deviations: the brief's 6 checks became 7 — the auth check on
+ `/api/minigame/bests` was added because the route is new and nothing else
+ end-to-end proves it is behind `requireAuth`. Both leaderboard accounts are
+ seeded and the board fetched ONCE before any other check touches
+ `/api/leaderboard`: the payload is cached for `social.leaderboardCacheMs`, so
+ seeding the second account after a first fetch would leave it out of a stale
+ board and make the "absent" check pass for entirely the wrong reason. The
+ Playwright check marks the onboarding tour complete first, or the tutorial
+ overlay covers the panel under test. `FIRST_MILESTONE` is read from
+ `MILESTONES[0]` rather than hardcoded as 25.
+- Tests: see the status table at the top — 720 SQLite / 746 Postgres / 57 smoke,
+ all after the final code change.
+
diff --git a/docs/superpowers/plans/2026-08-08-v1.10-qol.md b/docs/superpowers/plans/2026-08-08-v1.10-qol.md
new file mode 100644
index 0000000..c0113b1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-v1.10-qol.md
@@ -0,0 +1,1109 @@
+# v1.10 Quality of Life — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship four independent quality-of-life changes — peak-based Legacy
+Cores on the leaderboard, a buy-to-next-milestone button, minigame personal
+bests, and badge progress bars.
+
+**Architecture:** Every change extends an existing mechanism rather than adding
+one. The leaderboard gains a lifetime stat maintained through a single helper;
+the milestone button adds a third `mode` to the existing `buy` action so the
+*server* computes the target; personal bests are derived from
+`minigame_sessions` rows that already exist; achievement progress replaces the
+boolean `condition` with a `progress`/`target` pair the unlock is derived from.
+
+**Tech Stack:** Node 20, Express, React 18 + Vite, vitest, better-sqlite3 and
+node-postgres (both backends must pass), Playwright for smoke suites.
+
+**Spec:** `docs/superpowers/specs/2026-08-08-v1.10-qol-design.md`
+
+## Global Constraints
+
+- **`shared/` must not import from `client/`** and must stay free of runtime
+ dependencies.
+- **The server is authoritative.** Anything the client computes is a request,
+ never a fact. The client must never compute the milestone target.
+- **Both backends must pass:** `npm run test:all` (SQLite and Postgres).
+ Postgres needs a container runtime; see the repo's test docs.
+- **No balance changes.** `MILESTONES`, `milestoneMult`, costs and rewards are
+ untouched.
+- **No schema migration.** `meta` is JSON inside the save; `minigame_sessions`
+ already has every column needed.
+- **Docs change in the task that changes the behaviour**, not afterwards.
+- **Commit after every task.**
+
+---
+
+### Task 1: Track peak Legacy Cores
+
+**Files:**
+- Modify: `shared/state.js` (add helper; call it in `evaluate()` at line 182)
+- Modify: `shared/reducer.js` (call it in `singularity()`, ~line 142)
+- Test: `tests/reducer.meta.test.js`
+
+**Interfaces:**
+- Produces: `recordLegacyCorePeak(meta)` exported from `shared/state.js`.
+ Mutates `meta.stats.bestLegacyCores` in place. Returns nothing.
+- Produces: `meta.stats.bestLegacyCores` (number), read by Task 2.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `tests/reducer.meta.test.js`:
+
+```js
+import { initialState, recordLegacyCorePeak } from '../shared/state.js';
+
+describe('bestLegacyCores', () => {
+ it('rises with legacyCores and never falls', () => {
+ const s = initialState();
+ s.meta.legacyCores = 40;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(40);
+
+ s.meta.legacyCores = 10;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(40);
+ });
+
+ it('backfills a pre-v1.10 save that has no bestLegacyCores', () => {
+ const s = initialState();
+ delete s.meta.stats.bestLegacyCores;
+ s.meta.legacyCores = 77;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(77);
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests and verify they fail**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js -t bestLegacyCores`
+Expected: FAIL — `recordLegacyCorePeak` is not exported.
+
+- [ ] **Step 3: Add the helper and the initial stat**
+
+In `shared/state.js`, add to the `stats` object inside `initialState()`
+alongside `lifetimeFlopsAllTime`:
+
+```js
+bestLegacyCores: 0,
+```
+
+Then add the exported helper:
+
+```js
+/**
+ * Raises meta.stats.bestLegacyCores to the current legacyCores if it is
+ * higher. Called from two places, and both are required:
+ *
+ * - evaluate(), which makes the stat self-backfilling: an existing save with
+ * no bestLegacyCores is seeded from its current cores on the first
+ * reconcile, so there is no migration.
+ * - singularity(), immediately before it zeroes legacyCores. POST
+ * /api/actions applies a BATCH with no evaluation between actions, so a
+ * Migrate that grants cores followed by a Singularity that spends them
+ * would otherwise destroy the peak before anything observed it.
+ */
+export function recordLegacyCorePeak(meta) {
+ if (!meta || !meta.stats) return;
+ const current = typeof meta.legacyCores === 'number' ? meta.legacyCores : 0;
+ const best = typeof meta.stats.bestLegacyCores === 'number' ? meta.stats.bestLegacyCores : 0;
+ meta.stats.bestLegacyCores = Math.max(best, current);
+}
+```
+
+- [ ] **Step 4: Run the tests and verify they pass**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js -t bestLegacyCores`
+Expected: PASS
+
+- [ ] **Step 5: Write the failing test for the two call sites**
+
+```js
+it('is updated by evaluate(), including on a save that never had the stat', () => {
+ const s = initialState();
+ delete s.meta.stats.bestLegacyCores;
+ s.meta.legacyCores = 55;
+ const out = evaluate(s, config, Date.now() - 5000, Date.now());
+ expect(out.state.meta.stats.bestLegacyCores).toBe(55);
+});
+
+it('survives a Singularity that zeroes legacyCores', () => {
+ const s = initialState();
+ s.meta.legacyCores = 100;
+ const out = applyAction(s, { type: 'singularity' }, config, Date.now());
+ expect(out.state.meta.legacyCores).toBe(0);
+ expect(out.state.meta.stats.bestLegacyCores).toBe(100);
+});
+
+it('survives Migrate then Singularity applied in ONE batch, with no evaluate between', () => {
+ // The test that fails if the singularity() call site is ever removed as
+ // "redundant with evaluate()". /api/actions applies batches.
+ //
+ // migrateGain = floor(sqrt(lifetimeRun / 1e6) * legacyGainMult), so 1e8
+ // grants 10 cores at the default multiplier - comfortably above the
+ // `shardsGained > 0` floor singularity() requires.
+ let s = initialState();
+ s.run.lifetimeRun = 1e8;
+ s = applyAction(s, { type: 'migrate' }, config, Date.now()).state;
+ const granted = s.meta.legacyCores;
+ expect(granted).toBe(10);
+
+ s = applyAction(s, { type: 'singularity' }, config, Date.now()).state;
+ expect(s.meta.legacyCores).toBe(0);
+ expect(s.meta.stats.bestLegacyCores).toBe(10);
+});
+```
+
+- [ ] **Step 6: Run and verify they fail**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js -t bestLegacyCores`
+Expected: FAIL — the peak is 0 after a Singularity.
+
+- [ ] **Step 7: Wire both call sites**
+
+In `shared/state.js`, inside `evaluate()`, **before** the
+`if (elapsedSec < 1) return { state: s, gained: 0 };` guard:
+
+```js
+recordLegacyCorePeak(s.meta);
+```
+
+In `shared/reducer.js`, import it and call it in `singularity()` immediately
+before the zeroing line:
+
+```js
+function singularity(s) {
+ const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0));
+ if (shardsGained <= 0) return err('invalid_target');
+
+ recordLegacyCorePeak(s.meta); // before the line below destroys the value
+ s.run = initialState().run;
+ s.meta.legacyCores = 0;
+ ...
+```
+
+- [ ] **Step 8: Run the full reducer and state suites**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js tests/state.test.js tests/gameRules.test.js`
+Expected: PASS, no regressions.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add shared/state.js shared/reducer.js tests/reducer.meta.test.js
+git commit -m "Track peak Legacy Cores so a Singularity cannot erase a standing"
+```
+
+---
+
+### Task 2: Leaderboard reads the peak
+
+**Files:**
+- Modify: `server/leaderboardService.js:26` (the `legacyCores` board)
+- Modify: `client/src/game/components/social/LeaderboardSection.jsx` (label)
+- Test: `tests/api.social.test.js`
+
+**Interfaces:**
+- Consumes: `meta.stats.bestLegacyCores` from Task 1.
+- Produces: no API shape change. The board key stays `legacyCores`.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `tests/api.social.test.js`:
+
+```js
+it('keeps a player on the legacyCores board after a Singularity zeroes them', async () => {
+ const user = await makeUser();
+ const s = initialState();
+ s.meta.legacyCores = 0; // spent in a Singularity
+ s.meta.stats.bestLegacyCores = 250; // but they earned 250
+ await putSave(user.id, s, Date.now());
+
+ const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(user));
+ const row = res.body.boards.legacyCores.find((r) => r.userId === user.id);
+ expect(row).toBeDefined();
+ expect(row.value).toBe(250);
+});
+
+it('still hides an account that has never earned a core', async () => {
+ const user = await makeUser();
+ await putSave(user.id, initialState(), Date.now());
+
+ const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(user));
+ expect(res.body.boards.legacyCores.find((r) => r.userId === user.id)).toBeUndefined();
+});
+```
+
+- [ ] **Step 2: Run and verify the first test fails**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/api.social.test.js -t legacyCores`
+Expected: FAIL — the player is absent, because the board reads `meta.legacyCores` (0)
+and the `.value > 0` filter drops them.
+
+- [ ] **Step 3: Change the board's reader**
+
+In `server/leaderboardService.js`, change only the `legacyCores` entry:
+
+```js
+ // Peak rather than current: singularity() zeroes meta.legacyCores, and the
+ // `.value > 0` filter below would then drop the player from the board
+ // entirely - so the game's most demanding action read as being erased.
+ // Reading the peak makes that filter correct rather than special-cased: a
+ // player who reset has a non-zero best, an account that never played does
+ // not.
+ ['legacyCores', (meta) => (meta.stats && meta.stats.bestLegacyCores) || 0],
+```
+
+Leave `.filter((r) => r.value > 0)` untouched.
+
+- [ ] **Step 4: Run and verify both tests pass**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/api.social.test.js`
+Expected: PASS
+
+- [ ] **Step 5: Relabel the board in the UI**
+
+In `client/src/game/components/social/LeaderboardSection.jsx`, find the label
+for the `legacyCores` board and change it to `Legacy Cores (best)`. Match the
+surrounding label style exactly; change no other board.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/leaderboardService.js client/src/game/components/social/LeaderboardSection.jsx tests/api.social.test.js
+git commit -m "Leaderboard shows peak Legacy Cores, so prestiging no longer erases you"
+```
+
+---
+
+### Task 3: Server-side `mode: 'milestone'`
+
+**Files:**
+- Modify: `shared/gameRules.js` (extract `milestoneThresholds`, use it in `computeMults`)
+- Modify: `shared/reducer.js` (`resolveBuyCount`, `buy`)
+- Test: `tests/reducer.economy.test.js`
+
+**Interfaces:**
+- Produces: `milestoneThresholds(meta, config)` exported from
+ `shared/gameRules.js`, returning `number[]` — the MILESTONES array with the
+ `infiniteloop` discount applied.
+- Produces: `buy` accepts `mode: 'milestone'`. New error code `no_milestone`
+ when the lane is past its final threshold.
+
+- [ ] **Step 1: Write the failing tests**
+
+```js
+describe("buy mode 'milestone'", () => {
+ it('buys exactly enough to reach the next threshold', () => {
+ const s = initialState();
+ s.run.racks[0].owned = 18;
+ s.run.credits = 1e12;
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ expect(out.ok).toBe(true);
+ expect(out.state.run.racks[0].owned).toBe(25); // first MILESTONES entry
+ });
+
+ it('uses the DISCOUNTED thresholds when infiniteloop is owned', () => {
+ // infiniteloop lowers milestone thresholds by 10% per level. A client
+ // computing the target from stale config would overshoot; this test is
+ // what fails if that calculation ever moves client-side.
+ const s = initialState();
+ s.meta.shardUpgrades = { ...(s.meta.shardUpgrades || {}), infiniteloop: 5 };
+ s.run.racks[0].owned = 0;
+ s.run.credits = 1e12;
+ const thresholds = milestoneThresholds(s.meta, config);
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ expect(out.state.run.racks[0].owned).toBe(thresholds[0]);
+ expect(thresholds[0]).toBeLessThan(25);
+ });
+
+ it('refuses when the full jump is unaffordable, changing nothing', () => {
+ const s = initialState();
+ s.run.racks[0].owned = 18;
+ s.run.credits = 1;
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ expect(out.ok).toBe(false);
+ expect(out.error).toBe('insufficient_credits');
+ expect(out.state.run.racks[0].owned).toBe(18);
+ });
+
+ it('reports no_milestone past the final threshold', () => {
+ const s = initialState();
+ s.run.racks[0].owned = 1000; // the last MILESTONES entry
+ s.run.credits = 1e12;
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ expect(out.ok).toBe(false);
+ expect(out.error).toBe('no_milestone');
+ });
+
+ it('doubles the lane multiplier once the milestone lands', () => {
+ const s = initialState();
+ s.run.racks[0].owned = 24;
+ s.run.credits = 1e12;
+ const before = milestoneMult(24, milestoneThresholds(s.meta, config));
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ const after = milestoneMult(out.state.run.racks[0].owned, milestoneThresholds(s.meta, config));
+ expect(after).toBe(before * 2);
+ });
+});
+```
+
+Import `milestoneThresholds` and `milestoneMult` from `../shared/gameRules.js`.
+`meta.shardUpgrades` is the correct property name (`shared/state.js:29`), and
+`computeEffects` reads `infiniteloop` from it at `shared/gameRules.js:67`.
+
+Add one more boundary case — sitting exactly on a threshold must target the
+*next* one, not re-buy the current:
+
+```js
+ it('targets the NEXT threshold when already exactly on one', () => {
+ const s = initialState();
+ s.run.racks[0].owned = 25;
+ s.run.credits = 1e12;
+ const out = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, config, Date.now());
+ expect(out.ok).toBe(true);
+ expect(out.state.run.racks[0].owned).toBe(50);
+ });
+```
+
+- [ ] **Step 2: Run and verify they fail**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.economy.test.js -t milestone`
+Expected: FAIL — `milestoneThresholds` is not exported and the mode is invalid.
+
+- [ ] **Step 3: Extract the thresholds helper**
+
+In `shared/gameRules.js`, add:
+
+```js
+/**
+ * The milestone thresholds with the `infiniteloop` discount applied.
+ *
+ * Extracted from computeMults so the reducer can reach the same numbers
+ * without computing a full multiplier bundle it does not need. There must be
+ * exactly one expression of this: a second copy and the buy target would drift
+ * from the multiplier the player actually earns.
+ */
+export function milestoneThresholds(meta, config) {
+ const eff = computeEffects(meta, config);
+ return MILESTONES.map((t) => Math.max(1, Math.round(t * eff.milestoneDiscount)));
+}
+```
+
+Then in `computeMults`, replace the inline expression with a call:
+
+```js
+const thresholds = milestoneThresholds(meta, config);
+```
+
+- [ ] **Step 4: Add the mode**
+
+In `shared/reducer.js`, import `milestoneThresholds` and `nextMilestone`, then
+change `resolveBuyCount` to take thresholds:
+
+```js
+function resolveBuyCount(mode, def, owned, credits, thresholds) {
+ if (mode === 'max') return maxAffordable(def, owned, credits);
+ if (mode === 'milestone') {
+ const next = nextMilestone(owned, thresholds);
+ return next === null ? 0 : next - owned;
+ }
+ if (typeof mode === 'number' && Number.isInteger(mode) && mode > 0) return mode;
+ return -1;
+}
+```
+
+In `buy()`, compute the thresholds and distinguish the two zero cases:
+
+```js
+ const thresholds = milestoneThresholds(s.meta, config);
+ const n = resolveBuyCount(mode, def, laneState.owned, s.run.credits, thresholds);
+ if (n < 0) return err('invalid_target');
+ // A milestone request returning 0 means the lane is past its final
+ // threshold - a different situation from not affording the jump, and the
+ // button renders differently for each.
+ if (n === 0 && mode === 'milestone') return err('no_milestone');
+ if (n === 0) return err('insufficient_credits');
+```
+
+- [ ] **Step 5: Run and verify they pass**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/reducer.economy.test.js`
+Expected: PASS
+
+- [ ] **Step 6: Run the whole suite for regressions**
+
+Run: `TEST_BACKEND=sqlite npx vitest run`
+Expected: PASS — `computeMults` now delegates, so any drift shows up here.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add shared/gameRules.js shared/reducer.js tests/reducer.economy.test.js
+git commit -m "Add buy mode 'milestone', with the target computed server-side"
+```
+
+---
+
+### Task 4: Milestone buttons in the three lane panels
+
+**Files:**
+- Modify: `client/src/game/components/RacksPanel.jsx`
+- Modify: `client/src/game/components/GridPanel.jsx`
+- Modify: `client/src/game/components/OverclockPanel.jsx`
+
+**Interfaces:**
+- Consumes: `mode: 'milestone'` from Task 3.
+- All three panels already receive `thresholds` as a prop and already call
+ `onBuy(index, mode)` with `1`, `10` and `'max'`. Add `'milestone'`; change
+ neither signature.
+
+- [ ] **Step 1: Add the button to RacksPanel**
+
+`RacksPanel` already destructures `thresholds`. Next to the existing Buy Max
+button, add:
+
+```jsx
+{(() => {
+ const next = nextMilestone(laneState.owned, thresholds);
+ if (next === null) return null; // past the last milestone
+ const n = next - laneState.owned;
+ const costN = costForN(def, laneState.owned, n);
+ const affordable = run.credits >= costN;
+ return (
+
+ );
+})()}
+```
+
+Import `nextMilestone`, `costForN` and `fmt` from `@shared/gameRules.js` if not
+already imported. The cost shown here is display only — the server recomputes
+it, so a stale config makes the label briefly wrong but can never buy the wrong
+amount.
+
+- [ ] **Step 2: Build and check it renders**
+
+Run: `cd client && npm run build`
+Expected: builds with no errors. (A fresh worktree needs `npm ci` in `client/`
+first.)
+
+- [ ] **Step 3: Repeat for GridPanel and OverclockPanel**
+
+Apply the same block in both, using each panel's own local names for the def,
+lane state and `onBuy` handler. Do not factor it into a shared component yet —
+the three panels already duplicate their Buy 1 / Buy 10 / Buy Max blocks, and
+introducing one shared control for only the new button would leave the file
+half-converted and harder to read.
+
+- [ ] **Step 4: Build again**
+
+Run: `cd client && npm run build`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/game/components/RacksPanel.jsx client/src/game/components/GridPanel.jsx client/src/game/components/OverclockPanel.jsx
+git commit -m "Add a buy-to-next-milestone button to Racks, Grid and Overclock"
+```
+
+---
+
+### Task 5: Panels read live config maximums
+
+**Files:**
+- Modify: `client/src/game/components/UpgradesPanel.jsx`
+- Modify: `client/src/game/components/SingularityPanel.jsx`
+
+**Interfaces:**
+- Consumes: `config.upgrades.maxLevels[id]`, already served by `GET /api/config`.
+
+Both panels read an upgrade's ceiling from the static definition's `maxLevel`
+instead of the live config, so admin balance edits never reach them.
+`ColdStoragePanel.jsx` does it correctly and carries a comment noting the
+contrast — follow that file's pattern exactly.
+
+- [ ] **Step 1: Read the correct pattern**
+
+Open `client/src/game/components/ColdStoragePanel.jsx` and find where it reads
+`config.upgrades.maxLevels`. Copy that shape.
+
+- [ ] **Step 2: Fix UpgradesPanel**
+
+Replace every read of `def.maxLevel` with the live value, falling back to the
+static one only when the config has no entry:
+
+```js
+const maxLevel = (config.upgrades.maxLevels && config.upgrades.maxLevels[def.id]) ?? def.maxLevel;
+```
+
+Use `maxLevel` for the "maxed" check, the level display and the disabled state.
+
+- [ ] **Step 3: Fix SingularityPanel the same way**
+
+- [ ] **Step 4: Build**
+
+Run: `cd client && npm run build`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/game/components/UpgradesPanel.jsx client/src/game/components/SingularityPanel.jsx
+git commit -m "Upgrade panels read live config maximums, not the static defs"
+```
+
+---
+
+### Task 6: Minigame personal bests — data layer and API
+
+**Files:**
+- Modify: `server/db/driver.sqlite.js` (~line 346, beside `finishMinigameSession`)
+- Modify: `server/db/driver.pg.js` (~line 415, beside `finishMinigameSession`)
+- Modify: `server/db/index.js:18-19` (re-export)
+- Modify: `server/routes/api.js` (new route; `finish` returns `newBest`)
+- Test: `tests/db.interface.test.js`, `tests/api.test.js`
+
+**Interfaces:**
+- Produces: `getMinigameBests(userId)` on both drivers → `Promise>`,
+ finished sessions only.
+- Produces: `GET /api/minigame/bests` → `{ bests: { [game]: number } }`.
+- Produces: `POST /api/minigame/finish` response gains `newBest: boolean`.
+
+- [ ] **Step 1: Write the failing driver test**
+
+Add to `tests/db.interface.test.js` so it runs against whichever backend is
+selected:
+
+```js
+it('returns the maximum finished score per game, ignoring unfinished sessions', async () => {
+ const user = await upsertUser({ provider: 'github', providerId: 'bests-1', username: 'bests', avatarUrl: null });
+ await createMinigameSession('s1', user.id, 'rush', Date.now());
+ await finishMinigameSession('s1', 40);
+ await createMinigameSession('s2', user.id, 'rush', Date.now());
+ await finishMinigameSession('s2', 120);
+ await createMinigameSession('s3', user.id, 'rush', Date.now()); // never finished
+ await createMinigameSession('s4', user.id, 'debug', Date.now());
+ await finishMinigameSession('s4', 7);
+
+ const rows = await getMinigameBests(user.id);
+ const byGame = Object.fromEntries(rows.map((r) => [r.game, r.best]));
+ expect(byGame.rush).toBe(120);
+ expect(byGame.debug).toBe(7);
+});
+
+it('returns an empty list for a player who has never played', async () => {
+ const user = await upsertUser({ provider: 'github', providerId: 'bests-2', username: 'bests2', avatarUrl: null });
+ expect(await getMinigameBests(user.id)).toEqual([]);
+});
+```
+
+Check `createMinigameSession`'s real signature in the driver and match it.
+
+- [ ] **Step 2: Run and verify it fails**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/db.interface.test.js -t bests`
+Expected: FAIL — `getMinigameBests` is not exported.
+
+- [ ] **Step 3: Implement on the SQLite driver**
+
+```js
+ /**
+ * Best finished score per game for one player. Derived from the session
+ * rows rather than stored on the save, so it is already populated with
+ * every score the player has ever set.
+ */
+ async getMinigameBests(userId) {
+ return db.prepare(`
+ SELECT game, MAX(score) AS best
+ FROM minigame_sessions
+ WHERE user_id = ? AND finished_at IS NOT NULL AND score IS NOT NULL
+ GROUP BY game
+ `).all(userId);
+ },
+```
+
+- [ ] **Step 4: Implement on the Postgres driver**
+
+```js
+ async getMinigameBests(userId) {
+ // MAX() returns numeric; coerce so both drivers return JS numbers.
+ const rows = await all(`
+ SELECT game, MAX(score) AS best
+ FROM minigame_sessions
+ WHERE user_id = $1 AND finished_at IS NOT NULL AND score IS NOT NULL
+ GROUP BY game
+ `, [userId]);
+ return rows.map((r) => ({ game: r.game, best: Number(r.best) }));
+ },
+```
+
+Use whatever the file's existing multi-row helper is called (`all`, `many`, …) —
+check a neighbouring method rather than assuming.
+
+- [ ] **Step 5: Re-export it**
+
+Add `getMinigameBests` to the destructured export list in `server/db/index.js`
+beside `finishMinigameSession`.
+
+- [ ] **Step 6: Run the driver tests on BOTH backends**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/db.interface.test.js`
+Then: `npx vitest run tests/db.interface.test.js` (Postgres)
+Expected: PASS on both. This is the step that catches a Postgres `MAX()`
+returning a string.
+
+- [ ] **Step 7: Write the failing API test**
+
+```js
+it('GET /api/minigame/bests returns the best per game', async () => {
+ const user = await makeUser();
+ await createMinigameSession('api-b1', user.id, 'rush', Date.now());
+ await finishMinigameSession('api-b1', 99);
+
+ const res = await request(app).get('/api/minigame/bests').set('Cookie', cookieFor(user));
+ expect(res.status).toBe(200);
+ expect(res.body.bests.rush).toBe(99);
+});
+
+it('requires auth', async () => {
+ expect((await request(app).get('/api/minigame/bests')).status).toBe(401);
+});
+
+it('reports newBest only when the run actually beat the previous best', async () => {
+ const user = await makeUser();
+
+ const first = await request(app).post('/api/minigame/start')
+ .set('Cookie', cookieFor(user)).send({ game: 'rush' });
+ const a = await request(app).post('/api/minigame/finish')
+ .set('Cookie', cookieFor(user))
+ .send({ sessionId: first.body.sessionId, metric: 50 });
+ expect(a.body.newBest).toBe(true); // nothing to beat, so 50 is a record
+
+ // A lower score is not a record. Clear the cooldown the same way the other
+ // minigame tests in this file do before starting a second session.
+ const second = await request(app).post('/api/minigame/start')
+ .set('Cookie', cookieFor(user)).send({ game: 'rush' });
+ const b = await request(app).post('/api/minigame/finish')
+ .set('Cookie', cookieFor(user))
+ .send({ sessionId: second.body.sessionId, metric: 20 });
+ expect(b.body.newBest).toBe(false);
+});
+```
+
+The minigame cooldown will block the second `start` unless it is bypassed —
+look at how the existing minigame tests in `tests/api.test.js` handle it (they
+already start more than one session) and follow that, rather than inventing a
+new mechanism.
+
+- [ ] **Step 8: Add the route**
+
+In `server/routes/api.js`, beside the other minigame routes:
+
+```js
+// GET /api/minigame/bests -> { bests: { : number } }
+// Derived from minigame_sessions, so it reflects every score already played.
+router.get('/api/minigame/bests', requireAuth, async (req, res, next) => {
+ try {
+ const rows = await getMinigameBests(req.user.id);
+ const bests = {};
+ for (const r of rows) bests[r.game] = r.best;
+ res.json({ bests });
+ } catch (e) { next(e); }
+});
+```
+
+- [ ] **Step 9: Report a new best from `finish`**
+
+In the `POST /api/minigame/finish` handler, read the prior best **before**
+writing this session's score, then include the comparison in the response:
+
+```js
+ // Read before finishing: once finishMinigameSession has written this
+ // score, MAX() would include it and every run would look like a record.
+ const priorRows = await getMinigameBests(req.user.id);
+ const prior = priorRows.find((r) => r.game === session.game);
+ const newBest = clamped > (prior ? prior.best : 0);
+```
+
+Add `newBest` to the object the handler already returns alongside
+`state` and `wafers`, and update the `finishMinigame` doc comment in
+`client/src/game/api.js` to document the new field.
+
+- [ ] **Step 10: Run the API tests**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/api.test.js -t minigame`
+Expected: PASS
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add server/db/driver.sqlite.js server/db/driver.pg.js server/db/index.js server/routes/api.js client/src/game/api.js tests/db.interface.test.js tests/api.test.js
+git commit -m "Derive minigame personal bests from session history"
+```
+
+---
+
+### Task 7: Show personal bests in the Games tab
+
+**Files:**
+- Modify: `client/src/game/api.js` (add `fetchMinigameBests`)
+- Modify: `client/src/game/components/GamesPanel.jsx`
+- Modify: `client/src/RackStack.jsx` (fetch bests, pass down, refresh on finish)
+
+**Interfaces:**
+- Consumes: `GET /api/minigame/bests` and the `newBest` flag from Task 6.
+- `GamesPanel` gains two props: `bests` (object, `{}` while loading) and each
+ `GameCard` gains `best` (number or undefined) and `unit` (string).
+
+- [ ] **Step 1: Add the API wrapper**
+
+In `client/src/game/api.js`, following the documented convention of the file:
+
+```js
+// GET /api/minigame/bests -> { bests: { : number } }
+// Derived server-side from finished sessions, so it is populated with scores
+// the player set before this feature existed.
+export function fetchMinigameBests() {
+ return request('/api/minigame/bests');
+}
+```
+
+- [ ] **Step 2: Render the best on each card**
+
+In `GamesPanel.jsx`, add `best` and `unit` to `GameCard` and render between the
+description and the button:
+
+```jsx
+{typeof best === 'number' && (
+
+ best {best} {unit}
+
+)}
+```
+
+Pass them from `GamesPanel`, whose signature gains `bests`:
+
+```jsx
+best={bests.rush} unit="taps"
+best={bests.debug} unit="squashed"
+best={bests.match} unit="pairs"
+best={bests.balance} unit="points"
+```
+
+Confirm each game's key matches the `game` string the server stores for it.
+
+- [ ] **Step 3: Fetch and refresh in RackStack.jsx**
+
+Load bests once on mount, and re-fetch after a minigame finishes so a new
+record appears immediately:
+
+```js
+const [bests, setBests] = useState({});
+useEffect(() => {
+ fetchMinigameBests().then((r) => { if (r && !r.error) setBests(r.bests); });
+}, []);
+```
+
+In the existing finish handler, after a successful `finishMinigame` response,
+call the same fetch again. If the response carries `newBest: true`, surface it
+through whatever toast mechanism the file already uses for minigame rewards —
+do not add a new notification system.
+
+- [ ] **Step 4: Build**
+
+Run: `cd client && npm run build`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/game/api.js client/src/game/components/GamesPanel.jsx client/src/RackStack.jsx
+git commit -m "Show minigame personal bests, and call out a new record"
+```
+
+---
+
+### Task 8: Achievements expose progress
+
+**Files:**
+- Modify: `shared/achievements.js`
+- Test: `tests/achievements.test.js`
+
+**Interfaces:**
+- Produces: each entry in `ACHIEVEMENT_DEFS` now carries **either**
+ `progress: (ctx) => number` plus `target: number`, **or** a boolean
+ `condition: (ctx) => boolean`. Never both.
+- Produces: `isAchievementMet(def, ctx) => boolean` and
+ `achievementProgress(def, ctx) => { current, target } | null` (null for
+ boolean achievements), both exported.
+
+- [ ] **Step 1: Write the failing tests**
+
+```js
+describe('achievement progress', () => {
+ it('every def is either scalar or explicitly boolean, never both and never neither', () => {
+ for (const def of ACHIEVEMENT_DEFS) {
+ const scalar = typeof def.progress === 'function' && typeof def.target === 'number';
+ const boolean = typeof def.condition === 'function';
+ expect(scalar !== boolean, `${def.id} must be exactly one of scalar or boolean`).toBe(true);
+ }
+ });
+
+ it('a scalar achievement unlocks exactly at its target, not before', () => {
+ // The migration safety net: proves the rewrite moved no thresholds.
+ const def = ACHIEVEMENT_DEFS.find((d) => d.id === 'ten_migrates');
+ const below = ctxWithStat('migrates', 9);
+ const at = ctxWithStat('migrates', 10);
+ expect(isAchievementMet(def, below)).toBe(false);
+ expect(isAchievementMet(def, at)).toBe(true);
+ });
+
+ it('reports progress for a scalar achievement and null for a boolean one', () => {
+ const scalar = ACHIEVEMENT_DEFS.find((d) => d.id === 'ten_migrates');
+ expect(achievementProgress(scalar, ctxWithStat('migrates', 3))).toEqual({ current: 3, target: 10 });
+
+ const boolean = ACHIEVEMENT_DEFS.find((d) => d.id === 'jackpot');
+ expect(achievementProgress(boolean, ctxWithStat('migrates', 3))).toBeNull();
+ });
+});
+```
+
+Write `ctxWithStat(key, value)` as a small local helper building the same ctx
+shape `goalCtx` produces — read `goalCtx` in `shared/goals.js` and mirror it.
+
+- [ ] **Step 2: Run and verify they fail**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/achievements.test.js -t progress`
+Expected: FAIL — the helpers do not exist and every def is boolean.
+
+- [ ] **Step 3: Convert the 17 scalar definitions**
+
+Replace `condition` with `progress` + `target`. Do not change any threshold.
+Full list, in file order:
+
+```js
+{ id: 'first_migrate', ..., progress: (c) => st(c, 'migrates'), target: 1 },
+{ id: 'ten_migrates', ..., progress: (c) => st(c, 'migrates'), target: 10 },
+{ id: 'first_singularity', ..., progress: (c) => st(c, 'singularities'), target: 1 },
+{ id: 'five_singularities', ..., progress: (c) => st(c, 'singularities'), target: 5 },
+{ id: 'deep_scrub', ..., progress: (c) => st(c, 'deepJobsCompletedLifetime'), target: 1 },
+{ id: 'tape_master', ..., progress: (c) => Math.max(0, ...Object.values((c.meta.coldStorage && c.meta.coldStorage.upgrades) || {})), target: 10 },
+{ id: 'level_10', ..., progress: (c) => c.meta.level || 0, target: 10 },
+{ id: 'level_25', ..., progress: (c) => c.meta.level || 0, target: 25 },
+{ id: 'level_50', ..., progress: (c) => c.meta.level || 0, target: 50 },
+{ id: 'flops_g', ..., progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e9 },
+{ id: 'flops_t', ..., progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e12 },
+{ id: 'flops_p', ..., progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e15 },
+{ id: 'gamer', ..., progress: (c) => st(c, 'minigamesWon'), target: 100 },
+{ id: 'event_champion', ..., progress: (c) => st(c, 'eventTopRungs'), target: 1 },
+{ id: 'streak_week', ..., progress: (c) => st(c, 'bestStreak'), target: 7 },
+{ id: 'contractor', ..., progress: (c) => st(c, 'contractsCompletedLifetime'), target: 50 },
+{ id: 'completionist', ..., progress: (c) => GOAL_DEFS.filter((g) => c.meta.goalsCompleted[g.id]).length, target: GOAL_DEFS.length },
+```
+
+`Math.max(0, ...[])` is `0`, so `tape_master` is safe on a save with no Cold
+Storage upgrades.
+
+Leave `jackpot` and `event_joined` exactly as they are — genuinely boolean.
+
+- [ ] **Step 4: Add the two helpers**
+
+```js
+/**
+ * Whether an achievement is met. Scalar achievements derive this from their
+ * own progress, so the bar the player sees and the unlock can never disagree -
+ * there is one threshold, not two.
+ */
+export function isAchievementMet(def, ctx) {
+ if (typeof def.condition === 'function') return !!def.condition(ctx);
+ return def.progress(ctx) >= def.target;
+}
+
+/**
+ * { current, target } for a scalar achievement, or null for a boolean one that
+ * has no meaningful bar.
+ */
+export function achievementProgress(def, ctx) {
+ if (typeof def.condition === 'function') return null;
+ return { current: def.progress(ctx), target: def.target };
+}
+```
+
+- [ ] **Step 5: Route the sweep through the helper**
+
+In `checkAchievements`, replace `met = !!def.condition(ctx);` with:
+
+```js
+ met = isAchievementMet(def, ctx);
+```
+
+Leave the surrounding `try/catch` exactly as it is — a malformed save must
+still not take down the request.
+
+- [ ] **Step 6: Run the achievement tests, then the full suite**
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/achievements.test.js`
+Then: `TEST_BACKEND=sqlite npx vitest run`
+Expected: PASS. Existing unlock tests are the real regression check here.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add shared/achievements.js tests/achievements.test.js
+git commit -m "Derive achievement unlocks from progress, so the bar cannot disagree"
+```
+
+---
+
+### Task 9: Badge progress bars
+
+**Files:**
+- Modify: `client/src/game/components/social/AchievementsSection.jsx`
+- Modify: `client/src/game/components/SocialPanel.jsx` (pass the ctx through)
+
+**Interfaces:**
+- Consumes: `achievementProgress(def, ctx)` from Task 8.
+- `AchievementsSection` gains one prop: `ctx` — the same object `goalCtx`
+ produces, which `SocialPanel` already has access to for the contracts
+ section. Check how it obtains it and reuse that, rather than rebuilding it.
+
+- [ ] **Step 1: Render the bar**
+
+In `AchievementsSection.jsx`, inside the card and after the `desc` line:
+
+```jsx
+{!unlocked && (() => {
+ const p = achievementProgress(def, ctx);
+ if (!p) return null; // boolean achievement, no bar
+ const pct = Math.max(0, Math.min(1, p.current / p.target)) * 100;
+ return (
+
+
+
+
+
+ {fmt(p.current)} / {fmt(p.target)}
+
+
+ );
+})()}
+```
+
+Import `achievementProgress` from `@shared/achievements.js` and `fmt` from
+`@shared/gameRules.js`. `fmt` is mandatory, not cosmetic — `flops_p` has a
+target of 1e15 and renders unreadably without it.
+
+- [ ] **Step 2: Pass `ctx` from SocialPanel**
+
+`SocialPanel` renders `` today. Add
+the ctx it already has available for the contracts section:
+
+```jsx
+
+```
+
+If `SocialPanel` does not already hold a `goalCtx`-shaped object, build it
+where the panel receives `state` and `config` using the same `goalCtx(state,
+config, now)` call the contracts section relies on — import it from
+`@shared/goals.js`. Do not construct a partial ctx by hand: `progress`
+functions read `meta.level`, `meta.coldStorage` and `meta.goalsCompleted`, and
+a hand-rolled object that happens to satisfy today's achievements will break
+silently the next time one is added.
+
+- [ ] **Step 3: Build**
+
+Run: `cd client && npm run build`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/game/components/social/AchievementsSection.jsx client/src/game/components/SocialPanel.jsx
+git commit -m "Show progress toward each locked badge"
+```
+
+---
+
+### Task 10: Smoke suite, docs and release
+
+**Files:**
+- Create: `tests/e2e/smoke-v110.mjs`
+- Modify: `CHANGELOG.md`, `package.json`, `Dockerfile`
+
+**Interfaces:**
+- Consumes: everything above.
+
+- [ ] **Step 1: Write the smoke suite**
+
+Copy the harness shape from `tests/e2e/smoke-v19.mjs` — same server spawn,
+same Playwright resolution with SKIP when unavailable, same
+`PASS`/`FAIL`/`=== ERRORS ===` reporting and non-zero exit.
+
+Note the filename: the glob in `package.json` is `tests/e2e/smoke-v1*.mjs`, and
+`smoke-v110.mjs` matches it.
+
+Cover, via API where possible and the built client where necessary:
+
+1. A save with `bestLegacyCores: 250` and `legacyCores: 0` appears on the
+ `legacyCores` board at 250.
+2. A save that has never earned a core does not appear on that board.
+3. `POST /api/actions` with `{ type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }`
+ and ample credits lands exactly on the first threshold.
+4. The same action with 1 credit returns `insufficient_credits` and changes
+ nothing.
+5. `GET /api/minigame/bests` returns the maximum of two finished sessions.
+6. Over the built client: the milestone button renders on the Racks panel and
+ is disabled when unaffordable.
+
+- [ ] **Step 2: Run it, and the whole smoke suite**
+
+Run: `cd client && npm run build` then `TEST_BACKEND=sqlite node tests/e2e/smoke-v110.mjs`
+Then: `TEST_BACKEND=sqlite npm run smoke`
+Expected: all PASS, exit 0. Rebuild the client before running smoke — a stale
+`client/dist` will fail assertions against source you have already fixed.
+
+- [ ] **Step 3: Run both backends in full**
+
+Run: `TEST_BACKEND=sqlite npx vitest run` and `npx vitest run`
+Expected: PASS on both.
+
+- [ ] **Step 4: Changelog and version**
+
+Add a `## v1.10.0` section to `CHANGELOG.md` covering the four features, led by
+the leaderboard fix and stating plainly what was wrong. Set `"version": "1.10.0"`
+in `package.json` and the matching `LABEL org.opencontainers.image.version`
+in the `Dockerfile`. Do **not** bump `client/package.json` — `client/vite.config.js`
+reads the root as the single version authority.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/e2e/smoke-v110.mjs CHANGELOG.md package.json Dockerfile
+git commit -m "v1.10.0: smoke coverage, changelog and version"
+```
+
+- [ ] **Step 6: Final verification before review**
+
+Run: `npm run test:all` and `TEST_BACKEND=sqlite npm run smoke`
+Expected: green on both backends and all smoke suites. Record the counts —
+they go in the PR description.
+
+---
+
+## Release ritual
+
+Merge the PR, then tag **`main`** — never the branch — as `v1.10.0` and push
+the tag. That push is what triggers the GHCR publish; a merge alone publishes
+nothing, and a tag without the leading `v` does not match the workflow's
+`v*.*.*` glob and will silently publish nothing either.
diff --git a/docs/superpowers/specs/2026-08-08-v1.10-qol-design.md b/docs/superpowers/specs/2026-08-08-v1.10-qol-design.md
new file mode 100644
index 0000000..e8607c7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-08-v1.10-qol-design.md
@@ -0,0 +1,375 @@
+# v1.10 — Quality of Life
+
+**Status: APPROVED 2026-08-08.** Design agreed with the owner; ready for an
+implementation plan.
+
+Four small, independent features drawn from the post-v1.9 idea backlog
+(`2026-08-08-post-v1.9-idea-backlog.md`), grouped because each is a few hours
+of work and none of them changes the shape of the game.
+
+**Goal:** fix a defect that makes the game's biggest achievement look like
+being erased, remove a large amount of repetitive clicking, and give two
+existing systems the feedback they already deserve.
+
+**Non-goal:** no new currency, no balance changes, no new progression. The
+milestone button only automates purchases a player could already make one at a
+time.
+
+---
+
+## Constraints inherited from the existing design
+
+- **The server is authoritative and evaluates lazily.** State is computed
+ forward from the last-seen timestamp when a request arrives. Anything the
+ client computes and sends is a request, never a fact.
+- **`meta.stats` is the home for lifetime values that survive prestige.** It
+ already carries `lifetimeFlopsAllTime`, `migrates`, `singularities` and
+ `bestStreak`, so a new "best ever" stat is an established shape, not a new
+ concept.
+- **`shared/` must not import from `client/`** and must stay free of runtime
+ dependencies. Achievement definitions live in `shared/achievements.js` and
+ name their icons as strings, resolved client-side.
+- **Both backends are tested.** `npm run test:all` must stay green on SQLite
+ and Postgres.
+- **Docs change in the same task that changes behaviour.**
+
+---
+
+## 1. Leaderboard — show peak legacy cores, not current
+
+### The defect
+
+`server/leaderboardService.js` builds every board and then applies:
+
+```js
+.filter((r) => r.value > 0)
+```
+
+`singularity()` in `shared/reducer.js` does:
+
+```js
+s.run = initialState().run;
+s.meta.legacyCores = 0;
+s.meta.singularityShards += shardsGained;
+s.meta.stats.singularities += 1;
+```
+
+So a player who has just triggered a Singularity has `legacyCores === 0` and is
+**removed from the `legacyCores` board entirely** — not shown at 0, absent.
+The most demanding thing in the game currently reads as being erased.
+
+**Only that board is affected.** Verified against the five boards:
+
+| board | reads | survives Singularity? |
+|---|---|---|
+| `allTimeFlops` | `stats.lifetimeFlopsAllTime` | yes |
+| `level` | `meta.level` | yes — `singularity()` does not touch it |
+| `legacyCores` | `meta.legacyCores` | **no — zeroed** |
+| `singularities` | `stats.singularities` | yes, increments |
+| `tapes` | `meta.coldStorage.tapes` | yes |
+
+The filter itself is not the bug and must stay: without it every registered
+account that has never played would sit on every board at 0.
+
+### The fix
+
+Add `meta.stats.bestLegacyCores`, updated through a single exported helper in
+`shared/state.js`:
+
+```js
+export function recordLegacyCorePeak(meta) {
+ const stats = meta.stats;
+ stats.bestLegacyCores = Math.max(stats.bestLegacyCores || 0, meta.legacyCores || 0);
+}
+```
+
+One implementation, called from **two** places — and both are required:
+
+1. **`evaluate()`** (`shared/state.js:182`). This is what makes the stat
+ self-backfilling: every existing player's first reconcile after upgrade
+ seeds the peak from their current cores. No migration, no backfill script,
+ no release-ordering hazard. Note `evaluate()` returns early when
+ `elapsedSec < 1`, so the call must sit before that guard or a rapid
+ sequence of calls can skip it.
+
+2. **`singularity()`**, immediately *before* `s.meta.legacyCores = 0`. This is
+ the correctness half, and it is not redundant. `POST /api/actions` applies a
+ **batch** of actions with no evaluation between them, so a Migrate that
+ grants cores followed by a Singularity that spends them — both are
+ `IMMEDIATE` actions, but they can still land in one batch when a flush is
+ already in flight — would destroy the peak before any evaluation observed
+ it. `singularity()` is the only place cores are destroyed, so capturing
+ there closes the hole completely.
+
+Two call sites rather than one is a deliberate, narrow exception to this
+project's usual "one place" rule: the logic lives in one function, and the
+batch case is covered by an explicit test (Migrate then Singularity in a single
+`applyAction` sequence must preserve the peak).
+
+`leaderboardService.js` then reads the new stat:
+
+```js
+['legacyCores', (meta) => (meta.stats && meta.stats.bestLegacyCores) || 0],
+```
+
+The `.value > 0` filter stays untouched and **becomes correct**: a reset player
+has a non-zero best and appears; an account that has never earned a core still
+has 0 and is still hidden.
+
+The board is relabelled to make the change in meaning visible — "Legacy Cores
+(best)". Consistent with `allTimeFlops`, which is already an all-time measure.
+
+**No schema change and no API payload change.** `meta` is stored as JSON in the
+save, and the board key is unchanged.
+
+---
+
+## 2. Buy to the next milestone
+
+### What already exists
+
+Milestones are a first-class concept, not something this feature invents:
+
+- `MILESTONES = [25, 50, 100, 200, 500, 1000]` in `shared/gameData.js`
+- `milestoneMult(owned, thresholds)` returns `2 ** count` — **every milestone
+ doubles that lane's output**
+- `nextMilestone(owned, thresholds)` already returns the next threshold or
+ `null`
+- `costForN(def, owned, n)` already prices a bulk purchase
+- `buy` already accepts a count. `resolveBuyCount(mode, def, owned, credits)`
+ handles `'max'` and any positive integer.
+
+So the target is unambiguous and the arithmetic exists. What is missing is a
+mode and a button.
+
+### Server
+
+`resolveBuyCount` gains a third mode:
+
+```js
+if (mode === 'milestone') {
+ const next = nextMilestone(owned, thresholds);
+ return next === null ? 0 : next - owned;
+}
+```
+
+`thresholds` must be passed in — `resolveBuyCount` does not take them today,
+and `shared/reducer.js` does not currently use `computeMults` at all. They are
+computed **with the discount applied** inside `computeMults`
+(`shared/gameRules.js:74`):
+
+```js
+milestoneDiscount: Math.max(0.3, 1 - 0.10 * (sv.infiniteloop || 0)),
+const thresholds = MILESTONES.map((t) => Math.max(1, Math.round(t * eff.milestoneDiscount)));
+```
+
+Extract that line into an exported `milestoneThresholds(meta, config)` and have
+`computeMults` call it, so `buy()` can reach the same values without computing
+a full multiplier bundle it does not need. Extraction rather than a second copy
+is the point: two expressions of the discounted thresholds would be exactly the
+drift this project keeps paying for.
+
+**This is why the client must not compute the count.** The `infiniteloop`
+Singularity upgrade moves the thresholds, so a client working from a stale
+config would ask for the wrong number of units and visibly miss the milestone
+it promised. The server owns the target; the client sends an intent.
+
+A lane already past its last threshold returns 0, which the existing
+`n === 0 → insufficient_credits` branch rejects. That error code is wrong for
+this case; add `no_milestone` so the button can render "maxed" rather than
+"can't afford".
+
+### Client
+
+Racks, Grid and Overclock panels each gain a button that dispatches
+`{ type: 'buy', lane, index, mode: 'milestone' }`.
+
+All-or-nothing, as agreed: the button shows the unit count and total cost, and
+renders **disabled when the full jump is unaffordable** — the cost stays
+visible so the milestone works as a savings target. It never degrades into
+"buy as many as I can afford", because `Buy Max` already does that and a button
+that silently declines to deliver the doubling it advertised is worse than a
+disabled one.
+
+### Bundled bug fix
+
+`UpgradesPanel.jsx` and `SingularityPanel.jsx` read upgrade maximums from the
+static definition's `maxLevel` rather than the live
+`config.upgrades.maxLevels[id]`, so admin balance edits never reach them.
+`ColdStoragePanel.jsx` does it correctly and carries a comment noting the
+contrast.
+
+This is fixed here rather than deferred again, because it is the same class of
+mistake this feature is built to avoid: a panel computing targets from numbers
+the authoritative server does not agree with.
+
+---
+
+## 3. Minigame personal bests
+
+### What already exists
+
+```sql
+CREATE TABLE minigame_sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id),
+ game TEXT NOT NULL,
+ started_at INTEGER NOT NULL,
+ finished_at INTEGER,
+ score INTEGER
+);
+```
+
+`POST /api/minigame/finish` already clamps the client-reported metric and
+persists it through `finishMinigameSession(sessionId, clamped)`. **Every score
+every player has ever set is already recorded.**
+
+### The feature
+
+Personal best per game, derived rather than stored:
+
+```sql
+SELECT game, MAX(score) AS best
+FROM minigame_sessions
+WHERE user_id = ? AND finished_at IS NOT NULL
+GROUP BY game
+```
+
+Served by a new `GET /api/minigame/bests` behind `requireAuth`. `GamesPanel`
+displays each game's best; `POST /api/minigame/finish` additionally returns
+whether this run beat the previous best, so the UI can mark a new record at the
+moment it happens rather than on the next page load.
+
+Deriving from history rather than adding a `meta` field means the feature
+**ships populated with real scores** instead of showing every player a blank
+slate on day one. No new table, no new write path, nothing to backfill.
+
+### Scope, and why it stops here
+
+Personal best only — no global board in this release. Minigame scores are
+client-reported and merely clamped server-side (e.g.
+`Math.min(metric, gameConf.durationSec * gameConf.maxTapsPerSec)`), so a global
+competitive board would be only as honest as the clamp: a modified client could
+sit permanently at the theoretical maximum with every legitimate player tied
+below it. That is acceptable when you can only beat yourself, and not
+acceptable when it is a public ranking.
+
+A global board stays in the backlog, to be taken up by a release that can also
+harden scoring.
+
+---
+
+## 4. Badge progress bars
+
+### Current shape
+
+`shared/achievements.js` defines each achievement with a boolean predicate:
+
+```js
+{ id: 'ten_migrates', ..., condition: (c) => st(c, 'migrates') >= 10 }
+```
+
+`AchievementsSection.jsx` already renders **every** achievement, with locked
+ones dimmed (`opacity: 0.5`). So this feature adds a bar inside cards that
+already exist; it is not a new surface.
+
+### Approach: derive the condition from the progress
+
+Rather than adding `progress`/`target` **alongside** `condition` — which puts
+the same threshold in two places and invites exactly the drift this project has
+been bitten by repeatedly — scalar achievements are redefined as:
+
+```js
+{ id: 'ten_migrates', ..., progress: (c) => st(c, 'migrates'), target: 10 }
+```
+
+and the condition is derived once, centrally:
+
+```js
+const isUnlocked = (def, ctx) => (def.condition ? def.condition(ctx) : def.progress(ctx) >= def.target);
+```
+
+One source of truth. The bar and the unlock cannot disagree, by construction
+rather than by test.
+
+**17 of the 19 achievements are expressible this way**, including two that do
+not look scalar at first glance:
+
+- `tape_master` — "max out any tape-tree upgrade" is
+ `max(Object.values(coldStorage.upgrades))` against a target of 10.
+- `completionist` — "complete every static goal" is
+ `count(goalsCompleted)` against `GOAL_DEFS.length`.
+
+`progress` is a function rather than a stat key because several achievements
+read outside `stats` (`meta.level`, `meta.coldStorage`).
+
+**Two remain genuinely boolean** and keep an explicit `condition` with no bar:
+`jackpot` (a specific block claimed) and `event_joined` (participation exists).
+
+### Rendering
+
+A thin bar on locked cards showing `clamp(progress / target)`, with
+`current / target` in the existing mono style. Unlocked cards keep the unlock
+date they show today. Cards for the two boolean achievements render exactly as
+they do now.
+
+Large targets (`flops_p` is 1e15) must use the existing `fmt()` helper, or the
+label is unreadable.
+
+---
+
+## Testing
+
+**Leaderboard**
+- `bestLegacyCores` rises with cores and never falls.
+- A Singularity zeroes `legacyCores` and **preserves** `bestLegacyCores`.
+- A save with no `bestLegacyCores` (the pre-v1.10 shape) is backfilled from
+ current cores on first evaluation — the upgrade path, asserted explicitly.
+- **Migrate then Singularity applied in one `applyAction` batch, with no
+ evaluation between them, preserves the peak.** This is the test that fails if
+ the `singularity()` call site is ever dropped as "redundant".
+- A player who has prestiged appears on the board; an account that has never
+ earned a core does not.
+
+**Milestone buying**
+- Boundaries: exactly at a threshold, one below, already past the last one
+ (`no_milestone`), and unaffordable (`insufficient_credits`, no state change).
+- With `infiniteloop` levels applied, the count is computed against the
+ **discounted** thresholds — the test that fails if the client ever takes over
+ this calculation.
+- Buying to a milestone produces the doubling `milestoneMult` promises.
+
+**Minigame bests**
+- Best reflects the maximum across sessions, not the latest.
+- Unfinished sessions are excluded.
+- A player with no sessions gets an empty result, not an error.
+- `finish` reports a new best only when the score actually exceeds the prior.
+
+**Achievements**
+- Every def is either scalar (`progress` + `target`) or explicitly boolean
+ (`condition`) — so a new achievement cannot be added in a shape the UI is
+ unable to render.
+- For each scalar def, the derived unlock agrees with the previous behaviour at
+ the boundary (target − 1 locked, target unlocked). This is the migration
+ safety net: it proves the rewrite changed no thresholds.
+
+**Both backends**, plus a smoke suite covering the milestone button through the
+built client and the bests endpoint.
+
+---
+
+## Out of scope
+
+- Global minigame leaderboards (backlog).
+- Any change to the other four leaderboard boards.
+- Any balance change, including to `MILESTONES` or the milestone multiplier.
+- The remaining backlog items: hazards, Grid downtime, overclock rework, third
+ prestige, unique items, shard sinks, event theming, account linking.
+
+## Standing obligation
+
+If any of this ships a feature tour, its steps must also be appended to
+`client/src/game/data/tours/onboarding.js` — completing the onboarding tour
+marks every registered tour complete, which is only correct while onboarding
+remains a superset. No test catches a violation. None of the four features
+currently plans a tour.
diff --git a/package.json b/package.json
index 1a91dee..e3b9ca1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
- "version": "1.9.1",
+ "version": "1.10.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js
index 9a9b8b4..95cdc29 100644
--- a/server/db/driver.pg.js
+++ b/server/db/driver.pg.js
@@ -416,6 +416,22 @@ export async function createPgDriver({ url }) {
await run('UPDATE minigame_sessions SET finished_at = $1, score = $2 WHERE id = $3', [Date.now(), score, id]);
},
+ /**
+ * Best finished score per game for one player. Derived from the session
+ * rows rather than stored on the save, so it is already populated with
+ * every score the player has ever set.
+ */
+ async getMinigameBests(userId) {
+ // MAX() returns numeric; coerce so both drivers return JS numbers.
+ const rows = await all(`
+ SELECT game, MAX(score) AS best
+ FROM minigame_sessions
+ WHERE user_id = $1 AND finished_at IS NOT NULL AND score IS NOT NULL
+ GROUP BY game
+ `, [userId]);
+ return rows.map((r) => ({ game: r.game, best: Number(r.best) }));
+ },
+
/**
* Returns the singleton config row (id=1): { id, version, data, updated_at,
* updated_by }, or undefined if no config has been seeded yet. `data` is
diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js
index fd9a452..63d9ecb 100644
--- a/server/db/driver.sqlite.js
+++ b/server/db/driver.sqlite.js
@@ -347,6 +347,20 @@ export async function createSqliteDriver({ path: dbPath }) {
db.prepare('UPDATE minigame_sessions SET finished_at = ?, score = ? WHERE id = ?').run(Date.now(), score, id);
},
+ /**
+ * Best finished score per game for one player. Derived from the session
+ * rows rather than stored on the save, so it is already populated with
+ * every score the player has ever set.
+ */
+ async getMinigameBests(userId) {
+ return db.prepare(`
+ SELECT game, MAX(score) AS best
+ FROM minigame_sessions
+ WHERE user_id = ? AND finished_at IS NOT NULL AND score IS NOT NULL
+ GROUP BY game
+ `).all(userId);
+ },
+
/**
* Returns the singleton config row (id=1): { id, version, data, updated_at,
* updated_by }, or undefined if no config has been seeded yet. `data` is
diff --git a/server/db/index.js b/server/db/index.js
index d94d384..3ba2021 100644
--- a/server/db/index.js
+++ b/server/db/index.js
@@ -16,7 +16,7 @@ export const {
upsertUser, getUserById, getAllUsersWithSaves, getSave, putSave, deleteSave,
getRoles, setRoles, getToursCompleted, setToursCompleted, setUsername,
dedupeUsernames, createMinigameSession, getMinigameSession,
- getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow,
+ getOpenMinigameSession, finishMinigameSession, getMinigameBests, getConfigRow, putConfigRow,
getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent,
setEventStatus, deleteEvent, upsertParticipation, getParticipation,
updateParticipationProgress, listParticipation, setLeaderboardOptOut,
diff --git a/server/leaderboardService.js b/server/leaderboardService.js
index 582ae62..5b9bf6b 100644
--- a/server/leaderboardService.js
+++ b/server/leaderboardService.js
@@ -23,7 +23,13 @@ export function invalidateLeaderboards() {
const BOARDS = [
['allTimeFlops', (meta) => (meta.stats && meta.stats.lifetimeFlopsAllTime) || 0],
['level', (meta) => meta.level || 0],
- ['legacyCores', (meta) => meta.legacyCores || 0],
+ // Peak rather than current: singularity() zeroes meta.legacyCores, and the
+ // `.value > 0` filter below would then drop the player from the board
+ // entirely - so the game's most demanding action read as being erased.
+ // Reading the peak makes that filter correct rather than special-cased: a
+ // player who reset has a non-zero best, an account that never played does
+ // not.
+ ['legacyCores', (meta) => (meta.stats && meta.stats.bestLegacyCores) || 0],
['singularities', (meta) => (meta.stats && meta.stats.singularities) || 0],
['tapes', (meta) => (meta.coldStorage && meta.coldStorage.tapes) || 0],
];
diff --git a/server/routes/api.js b/server/routes/api.js
index c7a095c..1280b80 100644
--- a/server/routes/api.js
+++ b/server/routes/api.js
@@ -8,7 +8,7 @@ import {
import {
getUserById, getAllUsersWithSaves, getRoles, setRoles, setUsername,
createMinigameSession, getMinigameSession, getOpenMinigameSession,
- finishMinigameSession, putSave,
+ finishMinigameSession, getMinigameBests, putSave,
listEvents, getEvent, getActiveEvent, putEvent, setEventStatus, deleteEvent,
listParticipation, listLeaderboard, setLeaderboardOptOut,
getToursCompleted, setToursCompleted,
@@ -352,7 +352,9 @@ router.post('/api/minigame/finish', requireAuth, async (req, res, next) => {
// Without the lock a concurrent GET /api/state would load the same state,
// then overwrite it after this handler's putSave, erasing the wafer credit
// and the cooldown stamp while this request still returned 200 with them.
- const { state, wafers, onCooldown } = await withUserLock(req.user.sub, async () => {
+ const {
+ state, wafers, onCooldown, newBest,
+ } = await withUserLock(req.user.sub, async () => {
const { state: loaded } = await loadEvaluateAndSchedule(req.user.sub, now);
// Re-check the cooldown against the freshly-evaluated state, not just at
@@ -372,15 +374,35 @@ router.post('/api/minigame/finish', requireAuth, async (req, res, next) => {
}
await putSave(req.user.sub, loaded, now);
+
+ // Read before finishing: once finishMinigameSession has written this
+ // score, MAX() would include it and every run would look like a record.
+ const priorRows = await getMinigameBests(req.user.sub);
+ const prior = priorRows.find((r) => r.game === session.game);
+ const isNewBest = clamped > (prior ? prior.best : 0);
+
await finishMinigameSession(sessionId, clamped);
- return { state: loaded, wafers: earned, onCooldown: cooling };
+ return {
+ state: loaded, wafers: earned, onCooldown: cooling, newBest: isNewBest,
+ };
});
if (onCooldown) {
return res.status(429).json({ error: 'cooldown_active' });
}
- res.json({ state, wafers });
+ res.json({ state, wafers, newBest });
+ } catch (e) { next(e); }
+});
+
+// GET /api/minigame/bests -> { bests: { : number } }
+// Derived from minigame_sessions, so it reflects every score already played.
+router.get('/api/minigame/bests', requireAuth, async (req, res, next) => {
+ try {
+ const rows = await getMinigameBests(req.user.sub);
+ const bests = {};
+ for (const r of rows) bests[r.game] = r.best;
+ res.json({ bests });
} catch (e) { next(e); }
});
diff --git a/shared/achievements.js b/shared/achievements.js
index a73d188..a278aee 100644
--- a/shared/achievements.js
+++ b/shared/achievements.js
@@ -20,26 +20,38 @@ const st = (ctx, key) => {
// `icon` is a lucide-react icon NAME, resolved to a component on the client
// (client/src/game/data/achievementIcons.js). shared/ must not import from
// client/, and must stay free of runtime dependencies.
+//
+// Every def carries EXACTLY ONE of two forms, never both and never neither
+// (tests/achievements.test.js enforces this):
+//
+// - SCALAR: `progress: (ctx) => number` plus `target: number`. The unlock is
+// derived from the progress (see isAchievementMet), so the bar the player
+// sees and the condition that fires can never disagree - there is one
+// threshold expressed once, not a bar and a condition kept in sync by hand.
+// - BOOLEAN: `condition: (ctx) => boolean`, for the two achievements that
+// are genuinely a yes/no with no meaningful "partway" to draw.
export const ACHIEVEMENT_DEFS = [
- { id: 'first_migrate', name: 'Fresh Rack', desc: 'Complete your first Migrate', icon: 'RefreshCw', tier: 'bronze', condition: (c) => st(c, 'migrates') >= 1 },
- { id: 'ten_migrates', name: 'Serial Rebuilder', desc: 'Complete 10 Migrates', icon: 'RefreshCw', tier: 'silver', condition: (c) => st(c, 'migrates') >= 10 },
- { id: 'first_singularity', name: 'Event Horizon', desc: 'Trigger your first Singularity', icon: 'Sparkles', tier: 'silver', condition: (c) => st(c, 'singularities') >= 1 },
- { id: 'five_singularities', name: 'Heat Death', desc: 'Trigger 5 Singularities', icon: 'Sparkles', tier: 'gold', condition: (c) => st(c, 'singularities') >= 5 },
+ { id: 'first_migrate', name: 'Fresh Rack', desc: 'Complete your first Migrate', icon: 'RefreshCw', tier: 'bronze', progress: (c) => st(c, 'migrates'), target: 1 },
+ { id: 'ten_migrates', name: 'Serial Rebuilder', desc: 'Complete 10 Migrates', icon: 'RefreshCw', tier: 'silver', progress: (c) => st(c, 'migrates'), target: 10 },
+ { id: 'first_singularity', name: 'Event Horizon', desc: 'Trigger your first Singularity', icon: 'Sparkles', tier: 'silver', progress: (c) => st(c, 'singularities'), target: 1 },
+ { id: 'five_singularities', name: 'Heat Death', desc: 'Trigger 5 Singularities', icon: 'Sparkles', tier: 'gold', progress: (c) => st(c, 'singularities'), target: 5 },
{ id: 'jackpot', name: 'Jackpot', desc: 'Claim the block-16 jackpot in Cold Storage', icon: 'Gift', tier: 'silver', condition: (c) => !!(c.meta.coldStorage && c.meta.coldStorage.blocksClaimed && c.meta.coldStorage.blocksClaimed[15]) },
- { id: 'deep_scrub', name: 'Deep Scrub', desc: 'Complete a Deep Archive Scrub', icon: 'Archive', tier: 'silver', condition: (c) => st(c, 'deepJobsCompletedLifetime') >= 1 },
- { id: 'tape_master', name: 'Tape Master', desc: 'Max out any tape-tree upgrade', icon: 'Layers', tier: 'gold', condition: (c) => Object.values((c.meta.coldStorage && c.meta.coldStorage.upgrades) || {}).some((lv) => lv >= 10) },
- { id: 'level_10', name: 'Junior Sysadmin', desc: 'Reach level 10', icon: 'ChevronsUp', tier: 'bronze', condition: (c) => (c.meta.level || 0) >= 10 },
- { id: 'level_25', name: 'Senior Sysadmin', desc: 'Reach level 25', icon: 'ChevronsUp', tier: 'silver', condition: (c) => (c.meta.level || 0) >= 25 },
- { id: 'level_50', name: 'Principal Sysadmin', desc: 'Reach level 50', icon: 'ChevronsUp', tier: 'gold', condition: (c) => (c.meta.level || 0) >= 50 },
- { id: 'flops_g', name: 'Gigaflop', desc: 'Earn 1G FLOPS all-time', icon: 'Cpu', tier: 'bronze', condition: (c) => st(c, 'lifetimeFlopsAllTime') >= 1e9 },
- { id: 'flops_t', name: 'Teraflop', desc: 'Earn 1T FLOPS all-time', icon: 'Cpu', tier: 'silver', condition: (c) => st(c, 'lifetimeFlopsAllTime') >= 1e12 },
- { id: 'flops_p', name: 'Petaflop', desc: 'Earn 1P FLOPS all-time', icon: 'Cpu', tier: 'gold', condition: (c) => st(c, 'lifetimeFlopsAllTime') >= 1e15 },
- { id: 'gamer', name: 'Cycle Burner', desc: 'Win 100 minigames', icon: 'Gamepad2', tier: 'silver', condition: (c) => st(c, 'minigamesWon') >= 100 },
+ { id: 'deep_scrub', name: 'Deep Scrub', desc: 'Complete a Deep Archive Scrub', icon: 'Archive', tier: 'silver', progress: (c) => st(c, 'deepJobsCompletedLifetime'), target: 1 },
+ // Math.max(0, ...[]) is 0, so a save with no Cold Storage upgrades reports
+ // no progress rather than -Infinity.
+ { id: 'tape_master', name: 'Tape Master', desc: 'Max out any tape-tree upgrade', icon: 'Layers', tier: 'gold', progress: (c) => Math.max(0, ...Object.values((c.meta.coldStorage && c.meta.coldStorage.upgrades) || {})), target: 10 },
+ { id: 'level_10', name: 'Junior Sysadmin', desc: 'Reach level 10', icon: 'ChevronsUp', tier: 'bronze', progress: (c) => c.meta.level || 0, target: 10 },
+ { id: 'level_25', name: 'Senior Sysadmin', desc: 'Reach level 25', icon: 'ChevronsUp', tier: 'silver', progress: (c) => c.meta.level || 0, target: 25 },
+ { id: 'level_50', name: 'Principal Sysadmin', desc: 'Reach level 50', icon: 'ChevronsUp', tier: 'gold', progress: (c) => c.meta.level || 0, target: 50 },
+ { id: 'flops_g', name: 'Gigaflop', desc: 'Earn 1G FLOPS all-time', icon: 'Cpu', tier: 'bronze', progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e9 },
+ { id: 'flops_t', name: 'Teraflop', desc: 'Earn 1T FLOPS all-time', icon: 'Cpu', tier: 'silver', progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e12 },
+ { id: 'flops_p', name: 'Petaflop', desc: 'Earn 1P FLOPS all-time', icon: 'Cpu', tier: 'gold', progress: (c) => st(c, 'lifetimeFlopsAllTime'), target: 1e15 },
+ { id: 'gamer', name: 'Cycle Burner', desc: 'Win 100 minigames', icon: 'Gamepad2', tier: 'silver', progress: (c) => st(c, 'minigamesWon'), target: 100 },
{ id: 'event_joined', name: 'Showed Up', desc: 'Take part in a live event', icon: 'Trophy', tier: 'bronze', condition: (c) => !!c.meta.eventProgress || (Array.isArray(c.meta.pendingEventClaims) && c.meta.pendingEventClaims.length > 0) },
- { id: 'event_champion', name: 'Event Champion', desc: 'Claim the top rung of a live event ladder', icon: 'Crown', tier: 'gold', condition: (c) => st(c, 'eventTopRungs') >= 1 },
- { id: 'streak_week', name: 'Perfect Uptime', desc: 'Reach a 7-day login streak', icon: 'Flame', tier: 'silver', condition: (c) => st(c, 'bestStreak') >= 7 },
- { id: 'contractor', name: 'Under Contract', desc: 'Complete 50 daily contracts', icon: 'ClipboardCheck', tier: 'gold', condition: (c) => st(c, 'contractsCompletedLifetime') >= 50 },
- { id: 'completionist', name: 'Completionist', desc: 'Complete every static goal', icon: 'ListChecks', tier: 'gold', condition: (c) => GOAL_DEFS.every((g) => c.meta.goalsCompleted[g.id]) },
+ { id: 'event_champion', name: 'Event Champion', desc: 'Claim the top rung of a live event ladder', icon: 'Crown', tier: 'gold', progress: (c) => st(c, 'eventTopRungs'), target: 1 },
+ { id: 'streak_week', name: 'Perfect Uptime', desc: 'Reach a 7-day login streak', icon: 'Flame', tier: 'silver', progress: (c) => st(c, 'bestStreak'), target: 7 },
+ { id: 'contractor', name: 'Under Contract', desc: 'Complete 50 daily contracts', icon: 'ClipboardCheck', tier: 'gold', progress: (c) => st(c, 'contractsCompletedLifetime'), target: 50 },
+ { id: 'completionist', name: 'Completionist', desc: 'Complete every static goal', icon: 'ListChecks', tier: 'gold', progress: (c) => GOAL_DEFS.filter((g) => c.meta.goalsCompleted[g.id]).length, target: GOAL_DEFS.length },
];
const TIER_ORDER = { gold: 0, silver: 1, bronze: 2 };
@@ -49,6 +61,27 @@ export function achievementDef(id) {
return ACHIEVEMENT_DEFS.find((d) => d.id === id) || null;
}
+/**
+ * Whether an achievement is met. Scalar achievements derive this from their
+ * own progress, so the bar the player sees and the unlock can never disagree -
+ * there is one threshold, not two.
+ */
+export function isAchievementMet(def, ctx) {
+ if (typeof def.condition === 'function') return !!def.condition(ctx);
+ return def.progress(ctx) >= def.target;
+}
+
+/**
+ * { current, target } for a scalar achievement, or null for a boolean one that
+ * has no meaningful bar. `current` is NOT clamped to `target` - a player who
+ * has 300 of 100 minigame wins reports 300, and it is the caller's job to clamp
+ * the width it draws.
+ */
+export function achievementProgress(def, ctx) {
+ if (typeof def.condition === 'function') return null;
+ return { current: def.progress(ctx), target: def.target };
+}
+
/**
* Unlocks every newly-met achievement on `state`, stamping `now`, and returns
* the ids unlocked by THIS call (so a caller can toast them). Already-held ids
@@ -56,9 +89,9 @@ export function achievementDef(id) {
* the reducer path pass the already-structuredClone'd state.
*
* Deliberately pays nothing: achievements are pure prestige (spec §6.3).
- * A condition that throws on a malformed save must not take down the whole
- * request, so each runs inside a try/catch and a throwing condition simply
- * counts as unmet.
+ * A condition (or progress function) that throws on a malformed save must not
+ * take down the whole request, so each runs inside a try/catch and a throwing
+ * def simply counts as unmet.
*/
export function checkAchievements(state, config, now) {
const ctx = goalCtx(state, config, now);
@@ -68,7 +101,7 @@ export function checkAchievements(state, config, now) {
if (Object.prototype.hasOwnProperty.call(held, def.id)) continue;
let met = false;
try {
- met = !!def.condition(ctx);
+ met = isAchievementMet(def, ctx);
} catch {
met = false;
}
diff --git a/shared/gameRules.js b/shared/gameRules.js
index 9c913c4..ab7dcfc 100644
--- a/shared/gameRules.js
+++ b/shared/gameRules.js
@@ -69,9 +69,22 @@ export function computeEffects(meta, config) {
};
}
+/**
+ * The milestone thresholds with the `infiniteloop` discount applied.
+ *
+ * Extracted from computeMults so the reducer can reach the same numbers
+ * without computing a full multiplier bundle it does not need. There must be
+ * exactly one expression of this: a second copy and the buy target would drift
+ * from the multiplier the player actually earns.
+ */
+export function milestoneThresholds(meta, config) {
+ const eff = computeEffects(meta, config);
+ return MILESTONES.map((t) => Math.max(1, Math.round(t * eff.milestoneDiscount)));
+}
+
export function computeMults(meta, config, boostMult = 1) {
const eff = computeEffects(meta, config);
- const thresholds = MILESTONES.map((t) => Math.max(1, Math.round(t * eff.milestoneDiscount)));
+ const thresholds = milestoneThresholds(meta, config);
const base = (1 + (meta.legacyCores || 0) * 0.05) * eff.firmwareMult * eff.engineMult
* eff.levelBonusMult * boostMult * config.production.globalMult;
// coldFusionMult folded in here (not applied ad-hoc by each caller) so
diff --git a/shared/reducer.js b/shared/reducer.js
index f543f0d..fddcbd6 100644
--- a/shared/reducer.js
+++ b/shared/reducer.js
@@ -1,6 +1,6 @@
import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, UPGRADE_DEFS, SINGULARITY_DEFS } from './gameData.js';
-import { costForN, maxAffordable, computeEffects, migrateGain, xpForLevel } from './gameRules.js';
-import { initialState } from './state.js';
+import { costForN, maxAffordable, computeEffects, migrateGain, xpForLevel, milestoneThresholds, nextMilestone } from './gameRules.js';
+import { initialState, recordLegacyCorePeak } from './state.js';
import { goalCtx, GOAL_DEFS, REPEATABLE_DEFS } from './goals.js';
import { TOTAL_BLOCKS, JOB_TYPES, TAPE_UPGRADE_DEFS } from './coldStorageData.js';
import { computeColdStorageEffects, blockReward, jobDurationSec, jobReward } from './coldStorage.js';
@@ -31,8 +31,12 @@ function validIndex(index, length) {
return Number.isInteger(index) && index >= 0 && index < length;
}
-function resolveBuyCount(mode, def, owned, credits) {
+function resolveBuyCount(mode, def, owned, credits, thresholds) {
if (mode === 'max') return maxAffordable(def, owned, credits);
+ if (mode === 'milestone') {
+ const next = nextMilestone(owned, thresholds);
+ return next === null ? 0 : next - owned;
+ }
if (typeof mode === 'number' && Number.isInteger(mode) && mode > 0) return mode;
return -1; // signals an invalid mode
}
@@ -54,8 +58,16 @@ function buy(s, action, config, now) {
return err('cooldown_active');
}
- const n = resolveBuyCount(mode, def, laneState.owned, s.run.credits);
+ // Only 'milestone' reads the thresholds, and deriving them costs a full
+ // computeEffects pass - not something every Buy 1 tap should pay for. The
+ // other modes are handed null, which resolveBuyCount never dereferences.
+ const thresholds = mode === 'milestone' ? milestoneThresholds(s.meta, config) : null;
+ const n = resolveBuyCount(mode, def, laneState.owned, s.run.credits, thresholds);
if (n < 0) return err('invalid_target');
+ // A milestone request returning 0 means the lane is past its final
+ // threshold - a different situation from not affording the jump, and the
+ // button renders differently for each.
+ if (n === 0 && mode === 'milestone') return err('no_milestone');
if (n === 0) return err('insufficient_credits');
const cost = costForN(def, laneState.owned, n);
@@ -143,6 +155,7 @@ function singularity(s) {
const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0));
if (shardsGained <= 0) return err('invalid_target');
+ recordLegacyCorePeak(s.meta);
s.run = initialState().run;
s.meta.legacyCores = 0;
s.meta.singularityShards += shardsGained;
diff --git a/shared/state.js b/shared/state.js
index afa01c6..9ce8e53 100644
--- a/shared/state.js
+++ b/shared/state.js
@@ -32,7 +32,7 @@ export function initialState() {
migrates: 0, minigamesWon: 0, singularities: 0, totalWafersEarned: 0, lifetimeFlopsAllTime: 0,
blocksClaimedLifetime: 0, jobsCompletedLifetime: 0, deepJobsCompletedLifetime: 0,
tapesEarnedLifetime: 0,
- contractsCompletedLifetime: 0, bestStreak: 0, eventTopRungs: 0,
+ contractsCompletedLifetime: 0, bestStreak: 0, eventTopRungs: 0, bestLegacyCores: 0,
},
// v1.5 Social: the day's three contract TYPE IDS are deliberately not
// stored - they're re-derived from `dateKey` by shared/contracts.js's
@@ -182,6 +182,7 @@ export function migrateSave(raw) {
export function evaluate(state, config, lastEvaluatedAt, now) {
const s = structuredClone(state);
const elapsedSec = Math.max(0, (now - lastEvaluatedAt) / 1000);
+ recordLegacyCorePeak(s.meta);
if (elapsedSec < 1) return { state: s, gained: 0 };
// The overheat flag is a one-shot signal for the client toast: truthy only
@@ -325,3 +326,22 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
return { state: s, gained };
}
+
+/**
+ * Raises meta.stats.bestLegacyCores to the current legacyCores if it is
+ * higher. Called from two places, and both are required:
+ *
+ * - evaluate(), which makes the stat self-backfilling: an existing save with
+ * no bestLegacyCores is seeded from its current cores on the first
+ * reconcile, so there is no migration.
+ * - singularity(), immediately before it zeroes legacyCores. POST
+ * /api/actions applies a BATCH with no evaluation between actions, so a
+ * Migrate that grants cores followed by a Singularity that spends them
+ * would otherwise destroy the peak before anything observed it.
+ */
+export function recordLegacyCorePeak(meta) {
+ if (!meta || !meta.stats) return;
+ const current = typeof meta.legacyCores === 'number' ? meta.legacyCores : 0;
+ const best = typeof meta.stats.bestLegacyCores === 'number' ? meta.stats.bestLegacyCores : 0;
+ meta.stats.bestLegacyCores = Math.max(best, current);
+}
diff --git a/tests/achievements.test.js b/tests/achievements.test.js
index 2b465d9..fb3b298 100644
--- a/tests/achievements.test.js
+++ b/tests/achievements.test.js
@@ -3,10 +3,21 @@ import { DEFAULT_CONFIG } from '../shared/configSchema.js';
import { initialState } from '../shared/state.js';
import {
ACHIEVEMENT_DEFS, achievementDef, checkAchievements, topBadges,
+ isAchievementMet, achievementProgress,
} from '../shared/achievements.js';
+import { goalCtx } from '../shared/goals.js';
const NOW = 1_000_000;
+// The same ctx object checkAchievements builds, with one lifetime stat set -
+// built through goalCtx rather than hand-rolled, so a progress function that
+// starts reading a field outside meta.stats keeps working here.
+function ctxWithStat(key, value) {
+ const s = initialState();
+ s.meta.stats[key] = value;
+ return goalCtx(s, DEFAULT_CONFIG, NOW);
+}
+
describe('ACHIEVEMENT_DEFS', () => {
it('has unique ids, a valid tier, and a complete shape', () => {
const ids = ACHIEVEMENT_DEFS.map((d) => d.id);
@@ -18,7 +29,9 @@ describe('ACHIEVEMENT_DEFS', () => {
expect(typeof d.desc).toBe('string');
expect(typeof d.icon).toBe('string'); // lucide icon NAME, not a component
expect(['bronze', 'silver', 'gold']).toContain(d.tier);
- expect(typeof d.condition).toBe('function');
+ // Exactly one of the two forms - see the scalar/boolean test below.
+ const scalar = typeof d.progress === 'function' && typeof d.target === 'number';
+ expect(scalar || typeof d.condition === 'function').toBe(true);
}
});
it('carries no reward field of any kind - achievements are pure prestige', () => {
@@ -99,6 +112,45 @@ describe('checkAchievements', () => {
});
});
+describe('achievement progress', () => {
+ it('every def is either scalar or explicitly boolean, never both and never neither', () => {
+ for (const def of ACHIEVEMENT_DEFS) {
+ const scalar = typeof def.progress === 'function' && typeof def.target === 'number';
+ const boolean = typeof def.condition === 'function';
+ expect(scalar !== boolean, `${def.id} must be exactly one of scalar or boolean`).toBe(true);
+ }
+ });
+
+ it('a scalar achievement unlocks exactly at its target, not before', () => {
+ // The migration safety net: proves the rewrite moved no thresholds.
+ const def = ACHIEVEMENT_DEFS.find((d) => d.id === 'ten_migrates');
+ const below = ctxWithStat('migrates', 9);
+ const at = ctxWithStat('migrates', 10);
+ expect(isAchievementMet(def, below)).toBe(false);
+ expect(isAchievementMet(def, at)).toBe(true);
+ });
+
+ it('reports progress for a scalar achievement and null for a boolean one', () => {
+ const scalar = ACHIEVEMENT_DEFS.find((d) => d.id === 'ten_migrates');
+ expect(achievementProgress(scalar, ctxWithStat('migrates', 3))).toEqual({ current: 3, target: 10 });
+
+ const boolean = ACHIEVEMENT_DEFS.find((d) => d.id === 'jackpot');
+ expect(achievementProgress(boolean, ctxWithStat('migrates', 3))).toBeNull();
+ });
+
+ it('every scalar def reports a finite current and a positive target on a fresh save', () => {
+ // Guards the bar's arithmetic: a NaN current or a zero target renders as a
+ // NaN% width, which React writes to the DOM without complaint.
+ const ctx = ctxWithStat('migrates', 0);
+ for (const def of ACHIEVEMENT_DEFS) {
+ const p = achievementProgress(def, ctx);
+ if (p === null) continue;
+ expect(Number.isFinite(p.current), `${def.id} current`).toBe(true);
+ expect(p.target, `${def.id} target`).toBeGreaterThan(0);
+ }
+ });
+});
+
describe('topBadges', () => {
it('returns at most three ids, gold first', () => {
const gold = ACHIEVEMENT_DEFS.filter((d) => d.tier === 'gold')[0];
diff --git a/tests/api.social.test.js b/tests/api.social.test.js
index 2ab70e2..8ab5dc7 100644
--- a/tests/api.social.test.js
+++ b/tests/api.social.test.js
@@ -26,14 +26,19 @@ afterAll(async () => {
});
let seq = 0;
-async function seedPlayer({
- flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {},
-} = {}) {
+async function makeUser() {
seq += 1;
const u = await upsertUser({
provider: 'discord', providerId: `lb${seq}`, username: `lbuser${seq}`,
avatarUrl: `https://x/${seq}.png`,
});
+ return u;
+}
+
+async function seedPlayer({
+ flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {},
+} = {}) {
+ const u = await makeUser();
const s = initialState();
s.meta.stats.lifetimeFlopsAllTime = flops;
s.meta.level = level;
@@ -156,4 +161,27 @@ describe('GET /api/leaderboard', () => {
expect(res.status).toBe(200);
expect(res.body.boards.allTimeFlops.map((r) => r.username)).not.toContain('nosave');
});
+
+ it('keeps a player on the legacyCores board after a Singularity zeroes them', async () => {
+ const user = await makeUser();
+ const s = initialState();
+ s.meta.legacyCores = 0; // spent in a Singularity
+ s.meta.stats.bestLegacyCores = 250; // but they earned 250
+ await putSave(user.id, s, Date.now());
+ invalidateLeaderboards();
+
+ const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(user));
+ const row = res.body.boards.legacyCores.find((r) => r.userId === user.id);
+ expect(row).toBeDefined();
+ expect(row.value).toBe(250);
+ });
+
+ it('still hides an account that has never earned a core', async () => {
+ const user = await makeUser();
+ await putSave(user.id, initialState(), Date.now());
+ invalidateLeaderboards();
+
+ const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(user));
+ expect(res.body.boards.legacyCores.find((r) => r.userId === user.id)).toBeUndefined();
+ });
});
diff --git a/tests/api.test.js b/tests/api.test.js
index c25fd1a..a040587 100644
--- a/tests/api.test.js
+++ b/tests/api.test.js
@@ -523,6 +523,56 @@ describe('minigames', () => {
expect(finishRes.body.state.meta.stats.minigamesWon).toBe(0);
expect(finishRes.body.state.server.gameCooldowns.match).toBe(0);
});
+
+ it('reports newBest only when the run actually beat the previous best', async () => {
+ const user = await makeUser();
+
+ const first = await request(app).post('/api/minigame/start')
+ .set('Cookie', cookieFor(user)).send({ game: 'rush' });
+ const a = await request(app).post('/api/minigame/finish')
+ .set('Cookie', cookieFor(user))
+ .send({ sessionId: first.body.sessionId, metric: 50 });
+ expect(a.status).toBe(200);
+ expect(a.body.newBest).toBe(true); // nothing to beat, so 50 is a record
+
+ // The win above set the 30s cooldown on rush, which would block a second
+ // start for the same game. Clear it directly via putSave - the same
+ // technique other tests in this file use to mutate saved state directly
+ // (e.g. backdating meta.coldStorage.trackStartedAt above) - rather than
+ // inventing a new bypass mechanism.
+ const clearedState = a.body.state;
+ clearedState.server.gameCooldowns.rush = 0;
+ await putSave(user.id, clearedState, Date.now());
+
+ const second = await request(app).post('/api/minigame/start')
+ .set('Cookie', cookieFor(user)).send({ game: 'rush' });
+ expect(second.status).toBe(200);
+ const b = await request(app).post('/api/minigame/finish')
+ .set('Cookie', cookieFor(user))
+ .send({ sessionId: second.body.sessionId, metric: 20 });
+ expect(b.status).toBe(200);
+ expect(b.body.newBest).toBe(false); // 20 does not beat the prior best of 50
+ });
+});
+
+describe('GET /api/minigame/bests', () => {
+ it('returns the best per game', async () => {
+ const user = await makeUser();
+ const startRes = await request(app).post('/api/minigame/start')
+ .set('Cookie', cookieFor(user)).send({ game: 'rush' });
+ await request(app).post('/api/minigame/finish')
+ .set('Cookie', cookieFor(user))
+ .send({ sessionId: startRes.body.sessionId, metric: 99 });
+
+ const res = await request(app).get('/api/minigame/bests').set('Cookie', cookieFor(user));
+ expect(res.status).toBe(200);
+ // rush: durationSec=10, maxTapsPerSec=15 -> bound is 150; 99 is under it.
+ expect(res.body.bests.rush).toBe(99);
+ });
+
+ it('requires auth', async () => {
+ expect((await request(app).get('/api/minigame/bests')).status).toBe(401);
+ });
});
describe('GET /api/me', () => {
diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js
index 1c7cbe7..4f56d0e 100644
--- a/tests/db.interface.test.js
+++ b/tests/db.interface.test.js
@@ -16,7 +16,8 @@ const INTERFACE = [
'upsertUser', 'getUserById', 'getAllUsersWithSaves', 'getSave', 'putSave',
'deleteSave', 'getRoles', 'setRoles', 'getToursCompleted', 'setToursCompleted',
'setUsername', 'dedupeUsernames', 'createMinigameSession', 'getMinigameSession',
- 'getOpenMinigameSession', 'finishMinigameSession', 'getConfigRow', 'putConfigRow',
+ 'getOpenMinigameSession', 'finishMinigameSession', 'getMinigameBests',
+ 'getConfigRow', 'putConfigRow',
'getConfigHistory', 'listEvents', 'getEvent', 'getActiveEvent', 'putEvent',
'setEventStatus', 'deleteEvent', 'upsertParticipation', 'getParticipation',
'updateParticipationProgress', 'listParticipation', 'setLeaderboardOptOut',
@@ -39,3 +40,32 @@ describe('db facade', () => {
await result;
});
});
+
+describe('getMinigameBests', () => {
+ it('returns the maximum finished score per game, ignoring unfinished sessions', async () => {
+ const {
+ upsertUser, createMinigameSession, finishMinigameSession, getMinigameBests,
+ } = await import('../server/db/index.js');
+
+ const user = await upsertUser({ provider: 'github', providerId: 'bests-1', username: 'bests', avatarUrl: null });
+ const s1 = await createMinigameSession(user.id, 'rush');
+ await finishMinigameSession(s1.id, 40);
+ const s2 = await createMinigameSession(user.id, 'rush');
+ await finishMinigameSession(s2.id, 120);
+ await createMinigameSession(user.id, 'rush'); // never finished
+ const s4 = await createMinigameSession(user.id, 'debug');
+ await finishMinigameSession(s4.id, 7);
+
+ const rows = await getMinigameBests(user.id);
+ const byGame = Object.fromEntries(rows.map((r) => [r.game, r.best]));
+ expect(byGame.rush).toBe(120);
+ expect(byGame.debug).toBe(7);
+ });
+
+ it('returns an empty list for a player who has never played', async () => {
+ const { upsertUser, getMinigameBests } = await import('../server/db/index.js');
+
+ const user = await upsertUser({ provider: 'github', providerId: 'bests-2', username: 'bests2', avatarUrl: null });
+ expect(await getMinigameBests(user.id)).toEqual([]);
+ });
+});
diff --git a/tests/e2e/smoke-v110.mjs b/tests/e2e/smoke-v110.mjs
new file mode 100644
index 0000000..2cfa3ba
--- /dev/null
+++ b/tests/e2e/smoke-v110.mjs
@@ -0,0 +1,376 @@
+#!/usr/bin/env node
+// v1.10 Quality of Life - end-to-end smoke suite (Task 10).
+//
+// Covers:
+//
+// 1. A save with bestLegacyCores 250 and legacyCores 0 - a player who has
+// spent everything in a Singularity - is on the legacyCores board at 250.
+// This is the bug v1.10 exists to fix: before it, the board read the
+// current cores and the `.value > 0` filter deleted them outright.
+// 2. A save that has never earned a core is still absent from that board, so
+// the fix widened the board's reader without weakening its filter.
+// 3. POST /api/actions with { type: 'buy', lane: 'tiers', index: 0,
+// mode: 'milestone' } and ample credits lands EXACTLY on the first
+// threshold (25) - not one over, not one under.
+// 4. The same action with 1 credit is rejected as insufficient_credits and
+// changes nothing. The server owns the target; a client that asked for a
+// jump it cannot pay for gets no partial buy.
+// 5. GET /api/minigame/bests returns the maximum across two finished
+// sessions, not the latest.
+// 6. Over the built client: the milestone button renders on the Racks panel
+// and is disabled when the jump is unaffordable.
+//
+// Same harness shape as smoke-v16.mjs - boots a real `node server/index.js`
+// against a scratch SQLite file, seeds users/saves through server/db.js and
+// mints JWT cookies via server/auth.js. Checks 1-5 are API invariants and need
+// no browser; check 6 uses the same Playwright resolution the other suites do
+// and SKIPs rather than fails when no browser can be resolved.
+//
+// Every check prints `PASS ` or `FAIL : `. At the end:
+// `=== ERRORS ===` followed by each failure, or `NONE`. Exits non-zero if
+// anything failed. The server child process is always killed on the way out.
+
+import { spawn } from 'node:child_process';
+import { rmSync, existsSync, readdirSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.join(__dirname, '..', '..');
+
+const PORT = 3810;
+const BASE_URL = `http://localhost:${PORT}`;
+const DB_PATH = '/tmp/e2e-v110.db';
+const JWT_SECRET = '1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f80';
+
+for (const ext of ['', '-wal', '-shm']) {
+ try { rmSync(DB_PATH + ext, { force: true }); } catch (e) { /* ignore */ }
+}
+
+process.env.JWT_SECRET = JWT_SECRET;
+process.env.DB_PATH = DB_PATH;
+process.env.NODE_ENV = 'test';
+
+const {
+ upsertUser, putSave, driver, createMinigameSession, finishMinigameSession,
+} = await import(path.join(REPO_ROOT, 'server', 'db.js'));
+const { issueToken, COOKIE_NAME } = await import(path.join(REPO_ROOT, 'server', 'auth.js'));
+const { initialState } = await import(path.join(REPO_ROOT, 'shared', 'state.js'));
+const { MILESTONES } = await import(path.join(REPO_ROOT, 'shared', 'gameData.js'));
+const { ONBOARDING_TOUR_ID } = await import(path.join(REPO_ROOT, 'shared', 'tours.js'));
+
+// The first milestone at the DEFAULT config - no infiniteloop shard upgrade is
+// seeded anywhere in this suite, so the discount is 1x and the threshold is the
+// raw MILESTONES entry.
+const FIRST_MILESTONE = MILESTONES[0];
+
+// Multiple processes hold this same SQLite file open (this harness for
+// seeding, plus the spawned server for real traffic); busy_timeout is a
+// SQLite-only pragma (Postgres uses MVCC instead), so only apply it against
+// the SQLite driver.
+if (driver.__backend === 'sqlite') {
+ driver.__raw.pragma('busy_timeout = 5000');
+}
+
+let serverProc = null;
+let shuttingDown = false;
+
+function killServer() {
+ if (serverProc && !serverProc.killed) {
+ try { serverProc.kill('SIGTERM'); } catch (e) { /* ignore */ }
+ }
+}
+process.on('exit', killServer);
+process.on('SIGINT', () => { killServer(); process.exit(130); });
+process.on('SIGTERM', () => { killServer(); process.exit(143); });
+
+async function startServer() {
+ serverProc = spawn(process.execPath, [path.join(REPO_ROOT, 'server', 'index.js')], {
+ cwd: REPO_ROOT,
+ env: { ...process.env, PORT: String(PORT) },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let out = '';
+ serverProc.stdout.on('data', (d) => { out += d.toString(); });
+ serverProc.stderr.on('data', (d) => { out += d.toString(); });
+ serverProc.on('exit', (code, signal) => {
+ if (code !== null && code !== 0 && !shuttingDown) {
+ console.error(`\n[server] exited early (code=${code} signal=${signal}); output:\n${out}`);
+ }
+ });
+
+ const deadline = Date.now() + 15000;
+ for (;;) {
+ try {
+ const res = await fetch(`${BASE_URL}/`);
+ if (res.ok || res.status === 404) break;
+ } catch (e) { /* not up yet */ }
+ if (Date.now() > deadline) {
+ throw new Error(`server did not become ready within 15s; output:\n${out}`);
+ }
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => setTimeout(r, 150));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Playwright resolution: plain import first, scratchpad fallback second.
+// Mirrors smoke-v12..v19 so this suite behaves the same way in CI.
+// ---------------------------------------------------------------------------
+
+function findScratchpadPlaywright() {
+ const found = [];
+ const tmp = '/tmp';
+ let claudeDirs = [];
+ try {
+ claudeDirs = readdirSync(tmp).filter((d) => d.startsWith('claude-') || d === 'e2e-verify');
+ } catch (e) {
+ return found;
+ }
+ function walk(dir, depth) {
+ if (depth > 6) return;
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch (e) {
+ return;
+ }
+ for (const ent of entries) {
+ if (!ent.isDirectory()) continue;
+ const full = path.join(dir, ent.name);
+ if (ent.name === 'playwright' && full.includes('node_modules')) {
+ const idx = path.join(full, 'index.mjs');
+ if (existsSync(idx)) found.push(idx);
+ }
+ if (ent.name !== 'playwright') walk(full, depth + 1);
+ }
+ }
+ for (const d of claudeDirs) walk(path.join(tmp, d), 0);
+ return found;
+}
+
+async function loadPlaywrightOrNull() {
+ try {
+ return await import('playwright');
+ } catch (e) {
+ for (const c of findScratchpadPlaywright()) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ return await import(`file://${c}`);
+ } catch (e2) { /* try the next candidate */ }
+ }
+ return null;
+ }
+}
+
+const failures = [];
+
+async function check(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ console.log(`FAIL ${name}: ${e && e.message ? e.message : e}`);
+ failures.push({ name, message: e && e.message ? e.message : String(e) });
+ }
+}
+
+function assert(cond, message) {
+ if (!cond) throw new Error(message);
+}
+
+let seq = 0;
+async function seedUser(mutate) {
+ seq += 1;
+ const user = await upsertUser({
+ provider: 'discord', providerId: `v110-${seq}`, username: `v110user${seq}`, avatarUrl: null,
+ });
+ const s = initialState();
+ if (mutate) mutate(s);
+ await putSave(user.id, s, Date.now());
+ return user;
+}
+
+function cookieFor(user) {
+ const token = issueToken({ id: user.id, username: user.username, avatar_url: user.avatar_url });
+ return `${COOKIE_NAME}=${token}`;
+}
+
+async function api(user, urlPath, opts = {}) {
+ const res = await fetch(`${BASE_URL}${urlPath}`, {
+ ...opts,
+ headers: {
+ 'content-type': 'application/json',
+ cookie: cookieFor(user),
+ ...(opts.headers || {}),
+ },
+ });
+ const text = await res.text();
+ let body = null;
+ try { body = JSON.parse(text); } catch (e) { /* not json */ }
+ return { status: res.status, body };
+}
+
+async function main() {
+ await startServer();
+
+ // --- 1-2: the leaderboard reads the PEAK ---------------------------------
+ //
+ // Both accounts are seeded and the board is fetched ONCE, before any other
+ // check touches /api/leaderboard. The payload is cached server-side for
+ // social.leaderboardCacheMs, so seeding the second account after a first
+ // fetch would leave it out of a stale board and make check 2 pass for
+ // entirely the wrong reason.
+
+ const spentUser = await seedUser((s) => {
+ s.meta.legacyCores = 0; // spent in a Singularity
+ s.meta.stats.bestLegacyCores = 250; // but they earned 250
+ });
+ const freshUser = await seedUser(); // never earned a core
+ const boardRes = await api(spentUser, '/api/leaderboard');
+ const legacyBoard = boardRes.body && boardRes.body.boards
+ ? boardRes.body.boards.legacyCores
+ : null;
+
+ await check('a player who spent every core is still on the legacyCores board, at their peak', async () => {
+ assert(boardRes.status === 200, `expected 200 from /api/leaderboard, got ${boardRes.status}`);
+ assert(Array.isArray(legacyBoard), `expected a legacyCores board, got ${JSON.stringify(legacyBoard)}`);
+ const row = legacyBoard.find((r) => r.userId === spentUser.id);
+ assert(row, 'the player who spent their cores is missing from the board');
+ assert(row.value === 250, `expected 250, got ${row.value}`);
+ });
+
+ await check('an account that never earned a core is still absent from that board', async () => {
+ assert(Array.isArray(legacyBoard), 'no legacyCores board to check');
+ const row = legacyBoard.find((r) => r.userId === freshUser.id);
+ assert(!row, `expected no row, got ${JSON.stringify(row)}`);
+ });
+
+ // --- 3-4: buy mode 'milestone', computed server-side ----------------------
+
+ await check(`a milestone buy lands exactly on the first threshold (${FIRST_MILESTONE})`, async () => {
+ const u = await seedUser((s) => { s.run.credits = 1e12; });
+ const res = await api(u, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({
+ actions: [{ type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }],
+ }),
+ });
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ const result = res.body.results[0];
+ assert(result.ok, `expected ok, got ${JSON.stringify(result)}`);
+ const owned = res.body.state.run.tiers[0].owned;
+ assert(
+ owned === FIRST_MILESTONE,
+ `expected exactly ${FIRST_MILESTONE} owned, got ${owned}`,
+ );
+ });
+
+ await check('an unaffordable milestone buy is refused and changes nothing', async () => {
+ const u = await seedUser((s) => { s.run.credits = 1; });
+ const before = await api(u, '/api/state');
+ const ownedBefore = before.body.run.tiers[0].owned;
+
+ const res = await api(u, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({
+ actions: [{ type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }],
+ }),
+ });
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ const result = res.body.results[0];
+ assert(result.ok === false, `expected a rejection, got ${JSON.stringify(result)}`);
+ assert(
+ result.error === 'insufficient_credits',
+ `expected insufficient_credits, got ${result.error}`,
+ );
+
+ const after = await api(u, '/api/state');
+ assert(
+ after.body.run.tiers[0].owned === ownedBefore,
+ `owned changed on a refused buy: ${ownedBefore} -> ${after.body.run.tiers[0].owned}`,
+ );
+ });
+
+ // --- 5: minigame personal bests -------------------------------------------
+
+ await check('GET /api/minigame/bests returns the maximum finished score, not the latest', async () => {
+ const u = await seedUser();
+ const first = await createMinigameSession(u.id, 'rush');
+ await finishMinigameSession(first.id, 120);
+ const second = await createMinigameSession(u.id, 'rush');
+ await finishMinigameSession(second.id, 40); // lower, and later
+
+ const res = await api(u, '/api/minigame/bests');
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ assert(res.body.bests.rush === 120, `expected 120, got ${res.body.bests.rush}`);
+ });
+
+ await check('GET /api/minigame/bests requires auth', async () => {
+ const res = await fetch(`${BASE_URL}/api/minigame/bests`);
+ assert(res.status === 401, `expected 401, got ${res.status}`);
+ });
+
+ // --- 6: the milestone button over the real client -------------------------
+
+ const pw = await loadPlaywrightOrNull();
+ if (!pw) {
+ console.log('SKIP the milestone button renders on Racks and is disabled when unaffordable (no Playwright available)');
+ } else {
+ const browser = await pw.chromium.launch();
+ try {
+ await check('the milestone button renders on Racks and is disabled when unaffordable', async () => {
+ // No credits, so the 25-rack jump is unaffordable and the button must
+ // render disabled rather than not render at all - "you cannot afford
+ // this yet" and "there is nothing left to buy" are different states.
+ const u = await seedUser((s) => { s.run.credits = 0; });
+ // Mark the onboarding tour done: a fresh account otherwise opens with
+ // the tutorial overlay covering the panel under test.
+ await api(u, '/api/me/tours', {
+ method: 'PUT',
+ body: JSON.stringify({ tourId: ONBOARDING_TOUR_ID, completed: true }),
+ });
+
+ const ctx = await browser.newContext();
+ await ctx.addCookies([{
+ name: COOKIE_NAME,
+ value: issueToken({ id: u.id, username: u.username, avatar_url: u.avatar_url }),
+ url: BASE_URL,
+ }]);
+ const page = await ctx.newPage();
+ await page.goto(BASE_URL);
+
+ // Racks is the default tab. The label is `→ : for `.
+ const btn = page.locator(`button:has-text("→ ${FIRST_MILESTONE}:")`).first();
+ await btn.waitFor({ state: 'visible', timeout: 15000 });
+ const label = (await btn.textContent()).trim();
+ assert(
+ label.startsWith(`→ ${FIRST_MILESTONE}: ${FIRST_MILESTONE} for `),
+ `unexpected label "${label}"`,
+ );
+ assert(await btn.isDisabled(), 'the button was enabled with zero credits');
+
+ await ctx.close();
+ });
+ } finally {
+ await browser.close();
+ }
+ }
+
+ console.log('\n=== ERRORS ===');
+ if (failures.length === 0) {
+ console.log('NONE');
+ } else {
+ for (const f of failures) console.log(`${f.name}: ${f.message}`);
+ }
+ shuttingDown = true;
+ killServer();
+ process.exitCode = failures.length === 0 ? 0 : 1;
+}
+
+main().catch((e) => {
+ console.error(e);
+ shuttingDown = true;
+ killServer();
+ process.exitCode = 1;
+});
diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js
index da8da7a..52b8d1d 100644
--- a/tests/reducer.economy.test.js
+++ b/tests/reducer.economy.test.js
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_CONFIG } from '../shared/configSchema.js';
import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from '../shared/gameData.js';
-import { costForN, maxAffordable } from '../shared/gameRules.js';
+import { costForN, maxAffordable, milestoneThresholds, milestoneMult } from '../shared/gameRules.js';
import { initialState } from '../shared/state.js';
import { applyAction } from '../shared/reducer.js';
import { computeColdStorageEffects } from '../shared/coldStorage.js';
@@ -153,6 +153,69 @@ describe('reducer: buy (overclock)', () => {
});
});
+describe("buy mode 'milestone'", () => {
+ it('buys exactly enough to reach the next threshold', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 18;
+ s.run.credits = 1e12;
+ const { state, result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ expect(result.ok).toBe(true);
+ expect(state.run.tiers[0].owned).toBe(25); // first MILESTONES entry
+ });
+
+ it('uses the DISCOUNTED thresholds when infiniteloop is owned', () => {
+ // infiniteloop lowers milestone thresholds by 10% per level. A client
+ // computing the target from stale config would overshoot; this test is
+ // what fails if that calculation ever moves client-side.
+ const s = initialState();
+ s.meta.shardUpgrades = { ...(s.meta.shardUpgrades || {}), infiniteloop: 5 };
+ s.run.tiers[0].owned = 0;
+ s.run.credits = 1e12;
+ const thresholds = milestoneThresholds(s.meta, DEFAULT_CONFIG);
+ const { state } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ expect(state.run.tiers[0].owned).toBe(thresholds[0]);
+ expect(thresholds[0]).toBeLessThan(25);
+ });
+
+ it('refuses when the full jump is unaffordable, changing nothing', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 18;
+ s.run.credits = 1;
+ const { state, result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('insufficient_credits');
+ expect(state.run.tiers[0].owned).toBe(18);
+ });
+
+ it('reports no_milestone past the final threshold', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 1000; // the last MILESTONES entry
+ s.run.credits = 1e12;
+ const { result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('no_milestone');
+ });
+
+ it('doubles the lane multiplier once the milestone lands', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 24;
+ s.run.credits = 1e12;
+ const before = milestoneMult(24, milestoneThresholds(s.meta, DEFAULT_CONFIG));
+ const { state } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ const after = milestoneMult(state.run.tiers[0].owned, milestoneThresholds(state.meta, DEFAULT_CONFIG));
+ expect(after).toBe(before * 2);
+ });
+
+ it('targets the NEXT threshold when already exactly on one', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 25;
+ s.run.credits = 1e12;
+ const { state, result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 'milestone' }, DEFAULT_CONFIG, NOW);
+ expect(result.ok).toBe(true);
+ expect(state.run.tiers[0].owned).toBe(50);
+ });
+});
+
describe('reducer: collect', () => {
it('collects a ready tier into credits and zeroes ready', () => {
const s = initialState();
diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js
index 0f229c6..bc57c7a 100644
--- a/tests/reducer.meta.test.js
+++ b/tests/reducer.meta.test.js
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_CONFIG } from '../shared/configSchema.js';
-import { initialState } from '../shared/state.js';
+import { initialState, evaluate, recordLegacyCorePeak } from '../shared/state.js';
import { applyAction, scheduleAnomaly } from '../shared/reducer.js';
const NOW = 1_000_000;
@@ -266,6 +266,61 @@ describe('reducer: hardReset', () => {
});
});
+describe('bestLegacyCores', () => {
+ it('rises with legacyCores and never falls', () => {
+ const s = initialState();
+ s.meta.legacyCores = 40;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(40);
+
+ s.meta.legacyCores = 10;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(40);
+ });
+
+ it('backfills a pre-v1.10 save that has no bestLegacyCores', () => {
+ const s = initialState();
+ delete s.meta.stats.bestLegacyCores;
+ s.meta.legacyCores = 77;
+ recordLegacyCorePeak(s.meta);
+ expect(s.meta.stats.bestLegacyCores).toBe(77);
+ });
+
+ it('is updated by evaluate(), including on a save that never had the stat', () => {
+ const s = initialState();
+ delete s.meta.stats.bestLegacyCores;
+ s.meta.legacyCores = 55;
+ const out = evaluate(s, DEFAULT_CONFIG, Date.now() - 5000, Date.now());
+ expect(out.state.meta.stats.bestLegacyCores).toBe(55);
+ });
+
+ it('survives a Singularity that zeroes legacyCores', () => {
+ const s = initialState();
+ s.meta.legacyCores = 100;
+ const out = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW);
+ expect(out.state.meta.legacyCores).toBe(0);
+ expect(out.state.meta.stats.bestLegacyCores).toBe(100);
+ });
+
+ it('survives Migrate then Singularity applied in ONE batch, with no evaluate between', () => {
+ // The test that fails if the singularity() call site is ever removed as
+ // "redundant with evaluate()". /api/actions applies batches.
+ //
+ // migrateGain = floor(sqrt(lifetimeRun / 1e6) * legacyGainMult), so 1e8
+ // grants 10 cores at the default multiplier - comfortably above the
+ // `shardsGained > 0` floor singularity() requires.
+ let s = initialState();
+ s.run.lifetimeRun = 1e8;
+ s = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW).state;
+ const granted = s.meta.legacyCores;
+ expect(granted).toBe(10);
+
+ s = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW).state;
+ expect(s.meta.legacyCores).toBe(0);
+ expect(s.meta.stats.bestLegacyCores).toBe(10);
+ });
+});
+
describe('scheduleAnomaly', () => {
it('mutates the passed server object with next/expires derived from config + rng', () => {
const server = { nextAnomalyAt: 0, anomalyExpiresAt: 0, boost: null, lastVentAt: 0, gameCooldowns: {} };