From eee6071a8b32a6f6b63b698dfb29b60c04310177 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:14:59 -0400 Subject: [PATCH 01/25] Design v1.10: Quality of Life Four small features from the post-v1.9 backlog, grouped because none of them changes the shape of the game: peak-based legacy cores on the leaderboard, a buy-to-next-milestone button, minigame personal bests, and badge progress bars. Owner decisions: the leaderboard shows best-ever cores rather than 0 (which also makes the existing `.value > 0` filter correct instead of needing a special case); the milestone button is all-or-nothing and stays disabled with its cost visible; personal bests only, no global board. Three things the spec's self-review corrected, all found by checking the code rather than trusting the design conversation: - evaluate() lives in shared/state.js, not gameRules.js, and returns early when elapsedSec < 1. - A peak maintained only in evaluate() would be WRONG. /api/actions applies a batch with no evaluation between actions, so a Migrate followed by a Singularity in one batch destroys the cores before anything observes them. The peak is therefore also captured in singularity() immediately before it zeroes, through one shared helper, with a test that fails if that call site is ever removed as redundant. - shared/reducer.js does not use computeMults at all, so buy() cannot reach the discounted milestone thresholds today. computeMults' threshold expression is extracted into an exported milestoneThresholds(meta, config) used by both, rather than copied. Also records why the client must not compute the milestone target: the infiniteloop shard upgrade discounts the thresholds, so a client on stale config would visibly miss the milestone it promised. Co-Authored-By: Claude Opus 5 --- .../specs/2026-08-08-v1.10-qol-design.md | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-v1.10-qol-design.md 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. From cd364915a192ea3a03fea2c84dcb798d67bb04be Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:23:21 -0400 Subject: [PATCH 02/25] Plan v1.10 Quality of Life Ten tasks across four features, each ending in an independently testable deliverable. Written against the approved spec. The self-review pass found and fixed four things: - Two steps told the implementer to look a value up rather than giving it. Replaced with the real ones: migrateGain = floor(sqrt(lifetimeRun / 1e6) * mult), so 1e8 grants exactly 10 cores, and meta.shardUpgrades is the shard levels property. - The spec asks for an "exactly on a threshold" boundary case that no task covered - sitting on 25 must target 50, not re-buy 25. - The spec requires finish to report a new best only when the score actually beat the prior one; that behaviour was implemented but untested. - Passing ctx into the badge case was a step with no code, and the obvious shortcut - hand-rolling a partial ctx - would satisfy every achievement that exists today and break silently on the next one added. Co-Authored-By: Claude Opus 5 --- .../superpowers/plans/2026-08-08-v1.10-qol.md | 1109 +++++++++++++++++ 1 file changed, 1109 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-v1.10-qol.md 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..4454b06 --- /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: 'racks', 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: 'racks', 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: 'racks', 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: 'racks', 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: 'racks', 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: 'racks', 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: 'racks', 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. From 3c2fefa7920fe407a0ff8280051c0e361f34ab95 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:28:31 -0400 Subject: [PATCH 03/25] Add the v1.10 execution notes and session-handoff record The SDD workspace is git-ignored and dies with the worktree, so the ledger needs a mirror that is committed and pushed. This is the file a new session reads to find out where the work got to. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-08-v1.10-qol-notes.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md 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..d885be6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -0,0 +1,39 @@ +# 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 + +_Each task appends here: what changed, decisions made, anything deferred._ + From d18f85d63733422c583974979a5198153f92437a Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:31:22 -0400 Subject: [PATCH 04/25] Track peak Legacy Cores so a Singularity cannot erase a standing Co-Authored-By: Claude Opus 5 --- shared/reducer.js | 3 +- shared/state.js | 23 ++++++++++++++- tests/reducer.meta.test.js | 57 +++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/shared/reducer.js b/shared/reducer.js index f543f0d..314b719 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 { 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'; @@ -143,6 +143,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..2b1d5c6 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 @@ -323,5 +323,26 @@ export function evaluate(state, config, lastEvaluatedAt, now) { s.server.boost = null; } + recordLegacyCorePeak(s.meta); + 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/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: {} }; From 65f73a8290d0669360992d17289a80c46a28c7ba Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:32:02 -0400 Subject: [PATCH 05/25] Task 1 complete: Track peak Legacy Cores Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 index d885be6..59c8725 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -37,3 +37,9 @@ it, symlink it from another worktree's `node_modules`. _Each task appends here: what changed, decisions made, anything deferred._ +### Task 1: Track peak Legacy Cores +- Commits: d18f85d +- Changed: Added `meta.stats.bestLegacyCores` stat and `recordLegacyCorePeak()` helper; called from both `evaluate()` and `singularity()` as specified +- Decisions/deviations: none +- Tests: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js tests/state.test.js tests/gameRules.test.js` → 56 passed; specifically `npx vitest run tests/reducer.meta.test.js -t bestLegacyCores` → 5 passed + From ca15591da2ad90a368ecd5e65a27dc5e3f931ed8 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:38:25 -0400 Subject: [PATCH 06/25] Fix: Move recordLegacyCorePeak call before evaluate() early-return guard The call must run BEFORE the 1-second early-return guard to ensure the peak is recorded on every evaluate() call, including sub-1-second gaps. This also fixes report inaccuracies about call placement. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 8 ++++---- shared/state.js | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) 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 index 59c8725..3a25ca1 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -38,8 +38,8 @@ it, symlink it from another worktree's `node_modules`. _Each task appends here: what changed, decisions made, anything deferred._ ### Task 1: Track peak Legacy Cores -- Commits: d18f85d -- Changed: Added `meta.stats.bestLegacyCores` stat and `recordLegacyCorePeak()` helper; called from both `evaluate()` and `singularity()` as specified -- Decisions/deviations: none -- Tests: `TEST_BACKEND=sqlite npx vitest run tests/reducer.meta.test.js tests/state.test.js tests/gameRules.test.js` → 56 passed; specifically `npx vitest run tests/reducer.meta.test.js -t bestLegacyCores` → 5 passed +- Commits: d18f85d (implementation), 65f73a8 (notes), 0adaea5 (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 diff --git a/shared/state.js b/shared/state.js index 2b1d5c6..9ce8e53 100644 --- a/shared/state.js +++ b/shared/state.js @@ -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 @@ -323,8 +324,6 @@ export function evaluate(state, config, lastEvaluatedAt, now) { s.server.boost = null; } - recordLegacyCorePeak(s.meta); - return { state: s, gained }; } From e07cdef40fc64c9a9514bf2acc7651aaf9b96b01 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:41:01 -0400 Subject: [PATCH 07/25] Correct the Task 1 fix sha in the handoff notes --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3a25ca1..ab96463 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -38,7 +38,7 @@ it, symlink it from another worktree's `node_modules`. _Each task appends here: what changed, decisions made, anything deferred._ ### Task 1: Track peak Legacy Cores -- Commits: d18f85d (implementation), 65f73a8 (notes), 0adaea5 (fix) +- 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 From e15fe5fadb13b70ab48cc9e92202e7de3c0259a3 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:43:03 -0400 Subject: [PATCH 08/25] Leaderboard shows peak Legacy Cores, so prestiging no longer erases you Co-Authored-By: Claude Opus 5 --- .../components/social/LeaderboardSection.jsx | 2 +- server/leaderboardService.js | 8 ++++- tests/api.social.test.js | 34 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) 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/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/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(); + }); }); From 52f5675dd6553f2b0e564aa363ed8f03d29dddee Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:43:51 -0400 Subject: [PATCH 09/25] Task 2 ledger: Leaderboard reads the peak Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 index ab96463..795bb87 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -43,3 +43,9 @@ _Each task appends here: what changed, decisions made, anything deferred._ - 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 + From e254e7715b3fdf9497079ae862d5235a74603135 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:48:25 -0400 Subject: [PATCH 10/25] Add buy mode 'milestone', with the target computed server-side --- shared/gameRules.js | 15 +++++++- shared/reducer.js | 15 ++++++-- tests/reducer.economy.test.js | 65 ++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 5 deletions(-) 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 314b719..236e09c 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -1,5 +1,5 @@ import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, UPGRADE_DEFS, SINGULARITY_DEFS } from './gameData.js'; -import { costForN, maxAffordable, computeEffects, migrateGain, xpForLevel } from './gameRules.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'; @@ -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,13 @@ function buy(s, action, config, now) { return err('cooldown_active'); } - const n = resolveBuyCount(mode, def, laneState.owned, s.run.credits); + 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'); const cost = costForN(def, laneState.owned, n); 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(); From 17bb40b6e17f0debd51549812f211e2807d829b4 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:49:07 -0400 Subject: [PATCH 11/25] Task 3 ledger notes: server-side buy mode 'milestone' --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 795bb87..6e1436f 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -49,3 +49,9 @@ _Each task appends here: what changed, decisions made, anything deferred._ - 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 + From 9a859f8e357aeb7de17c9bfa57e091dd0fd74361 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 13:52:37 -0400 Subject: [PATCH 12/25] Record v1.10 session handoff: Tasks 1-3 done, resume at Task 4 Also fixes a defect in the plan that the Task 3 implementer caught: the lane is called 'tiers', not 'racks'. Seven test snippets named a lane that does not exist, including the Task 10 smoke check. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-08-v1.10-qol-notes.md | 39 +++++++++++++++++++ .../superpowers/plans/2026-08-08-v1.10-qol.md | 14 +++---- 2 files changed, 46 insertions(+), 7 deletions(-) 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 index 6e1436f..615114b 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -35,6 +35,45 @@ it, symlink it from another worktree's `node_modules`. ## Task ledger +## STATUS AT SESSION END (2026-08-08) + +**Tasks 1, 2 and 3 are COMPLETE** — implemented, reviewed and pushed. Tasks +4–10 are NOT started. Resume at **Task 4**. + +Suite at the stopping point: **711 vitest (SQLite), 0 failures.** Postgres and +smoke not re-run since Task 1; run both before trusting the branch. + +Review outcomes so far: +- Task 1 — clean after 2 fix rounds (the peak update was placed after + `evaluate()`'s `elapsedSec < 1` guard instead of before it, and the report + then claimed there had been no deviation; both corrected). +- Task 2 — clean, no fix rounds. +- Task 3 — clean, no fix rounds. + +**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 (for the final whole-branch review to triage): +- 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. +- 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. +- Task 3: one test derives its expected multiplier from post-buy `meta` rather + than pre-buy; inert because `buy()` never mutates `meta`. + +## Task ledger + _Each task appends here: what changed, decisions made, anything deferred._ ### Task 1: Track peak Legacy Cores diff --git a/docs/superpowers/plans/2026-08-08-v1.10-qol.md b/docs/superpowers/plans/2026-08-08-v1.10-qol.md index 4454b06..c0113b1 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol.md @@ -300,7 +300,7 @@ describe("buy mode 'milestone'", () => { const s = initialState(); s.run.racks[0].owned = 18; s.run.credits = 1e12; - const out = applyAction(s, { type: 'buy', lane: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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 }); @@ -314,7 +314,7 @@ describe("buy mode 'milestone'", () => { s.run.racks[0].owned = 0; s.run.credits = 1e12; const thresholds = milestoneThresholds(s.meta, config); - const out = applyAction(s, { type: 'buy', lane: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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); }); @@ -323,7 +323,7 @@ describe("buy mode 'milestone'", () => { const s = initialState(); s.run.racks[0].owned = 18; s.run.credits = 1; - const out = applyAction(s, { type: 'buy', lane: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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); @@ -333,7 +333,7 @@ describe("buy mode 'milestone'", () => { const s = initialState(); s.run.racks[0].owned = 1000; // the last MILESTONES entry s.run.credits = 1e12; - const out = applyAction(s, { type: 'buy', lane: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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'); }); @@ -343,7 +343,7 @@ describe("buy mode 'milestone'", () => { 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: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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); }); @@ -362,7 +362,7 @@ Add one more boundary case — sitting exactly on a threshold must target the const s = initialState(); s.run.racks[0].owned = 25; s.run.credits = 1e12; - const out = applyAction(s, { type: 'buy', lane: 'racks', index: 0, mode: 'milestone' }, config, Date.now()); + 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); }); @@ -1058,7 +1058,7 @@ 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: 'racks', index: 0, mode: 'milestone' }` +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. From cae4ba8f2d2c24c92909750a8b0bd9f7c7de9ce9 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:18:43 -0400 Subject: [PATCH 13/25] Add a buy-to-next-milestone button to Racks, Grid and Overclock --- client/src/game/components/GridPanel.jsx | 18 ++++++++++++++++++ client/src/game/components/OverclockPanel.jsx | 18 ++++++++++++++++++ client/src/game/components/RacksPanel.jsx | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/client/src/game/components/GridPanel.jsx b/client/src/game/components/GridPanel.jsx index 83dd191..0a90815 100644 --- a/client/src/game/components/GridPanel.jsx +++ b/client/src/game/components/GridPanel.jsx @@ -54,6 +54,24 @@ export default function GridPanel({ run, gridMult, thresholds, onBuy }) { Max{maxN >= 1 ? ` +${maxN}` : ''}
+ {(() => { + 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 ( + + ); + })()} ); })} From 64109d67a7627bcc2ec14ca9fc8c63a4e24c8e24 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:19:26 -0400 Subject: [PATCH 14/25] Task 4 notes: milestone buttons ledger entry --- docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 615114b..89e120a 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -94,3 +94,9 @@ _Each task appends here: what changed, decisions made, anything deferred._ - 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 + From 6b93d18cdfc6cdb56894b6d81b71a2161af57475 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:22:45 -0400 Subject: [PATCH 15/25] Upgrade panels read live config maximums, not the static defs UpgradesPanel and SingularityPanel now read each upgrade's maximum level from the live config.upgrades.maxLevels instead of the static definition, so admin balance edits in the dashboard are immediately reflected in these panels. This mirrors the pattern already implemented correctly in ColdStoragePanel. Both panels now accept config as a prop, threaded from RackStack.jsx. Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 4 ++-- client/src/game/components/SingularityPanel.jsx | 10 +++++++--- client/src/game/components/UpgradesPanel.jsx | 10 +++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index 0908fae..8cc61fc 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -1137,10 +1137,10 @@ export default function RackStack({ user }) { /> )} - {activeTab === 'upgrades' && } + {activeTab === 'upgrades' && } {activeTab === 'singularity' && ( - setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} /> + setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} /> )} {activeTab === 'goals' && ( 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 (
@@ -21,14 +21,18 @@ export default function SingularityPanel({ meta, singularityGain, onOpenSingular
{meta.singularityShards} Shards available
{SINGULARITY_DEFS.map((u) => { const level = meta.shardUpgrades[u.id] || 0; - const maxed = level >= u.maxLevel; + // Max level is read live from config.upgrades.maxLevels (admin-tunable, + // same source the reducer's buyShardUpgrade() 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.singularityShards >= cost; return (
{u.name}
-
Lv {level}/{u.maxLevel}
+
Lv {level}/{maxLevel}
{u.desc}
{desc}
+ {typeof best === 'number' && ( +
+ best {best} {unit} +
+ )}
); } From 0140da610c5f707ac71278c56233549d10161eb5 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:43:01 -0400 Subject: [PATCH 21/25] Derive achievement unlocks from progress, so the bar cannot disagree Co-Authored-By: Claude Opus 5 --- shared/achievements.js | 75 +++++++++++++++++++++++++++----------- tests/achievements.test.js | 54 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 22 deletions(-) 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/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]; From dbd1815b8121dbe60c0c9e04e68198fb4aa0d096 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:44:02 -0400 Subject: [PATCH 22/25] Show progress toward each locked badge Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 1 + client/src/game/components/SocialPanel.jsx | 4 +-- .../components/social/AchievementsSection.jsx | 29 +++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index 572ab5a..6e0b079 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -1216,6 +1216,7 @@ export default function RackStack({ user }) { {activeTab === 'social' && ( )} - {section === 'badges' && } + {section === 'badges' && }
); } diff --git a/client/src/game/components/social/AchievementsSection.jsx b/client/src/game/components/social/AchievementsSection.jsx index 6624c03..45880ef 100644 --- a/client/src/game/components/social/AchievementsSection.jsx +++ b/client/src/game/components/social/AchievementsSection.jsx @@ -1,5 +1,6 @@ import { cardBg, cardBorder, textMain, textDim } from '../../theme.js'; -import { ACHIEVEMENT_DEFS } from '@shared/achievements.js'; +import { ACHIEVEMENT_DEFS, achievementProgress } from '@shared/achievements.js'; +import { fmt } from '@shared/gameRules.js'; import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js'; function unlockedDate(ms) { @@ -10,7 +11,12 @@ function unlockedDate(ms) { // The badge case. Achievements are pure prestige (spec §6.3) - there is // deliberately no Claim button anywhere here, because they unlock // automatically in the reducer the moment their condition is met. -export default function AchievementsSection({ achievements }) { +// +// `ctx` is the goalCtx-shaped object RackStack builds once per render and +// already hands to GoalsPanel; it drives the progress bar on each still-locked +// scalar badge. It is optional only so this component keeps rendering the case +// itself if a caller has no ctx to give - the bars simply don't appear. +export default function AchievementsSection({ achievements, ctx }) { const held = achievements && typeof achievements === 'object' ? achievements : {}; const unlockedCount = ACHIEVEMENT_DEFS.filter( (d) => Object.prototype.hasOwnProperty.call(held, d.id), @@ -43,6 +49,25 @@ export default function AchievementsSection({ achievements }) { {def.name}
{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)}
)} From 6366d622fcb219ee6f15d5ce69dc90c66da74488 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:49:06 -0400 Subject: [PATCH 23/25] v1.10.0: smoke coverage, changelog and version Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 54 ++++++ Dockerfile | 2 +- package.json | 2 +- tests/e2e/smoke-v110.mjs | 376 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/smoke-v110.mjs 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/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/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; +}); From ff974606899ce277265b29b37d686405c230b769 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:50:13 -0400 Subject: [PATCH 24/25] Triage the deferred minors: driver-parity net, and thresholds off the hot buy path getMinigameBests joins the INTERFACE array db.interface.test.js iterates - 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. buy() no longer derives the milestone thresholds for modes that never read them; 'max' and the integer modes were paying for a full computeEffects pass on every tap. Co-Authored-By: Claude Opus 5 --- shared/reducer.js | 5 ++++- tests/db.interface.test.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/shared/reducer.js b/shared/reducer.js index 236e09c..fddcbd6 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -58,7 +58,10 @@ function buy(s, action, config, now) { return err('cooldown_active'); } - const thresholds = milestoneThresholds(s.meta, config); + // 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 diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js index c8bd503..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', From 1f7967ad4f2baf8039a2966987cdfff61dd689c9 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 17:51:23 -0400 Subject: [PATCH 25/25] Record v1.10 completion: all 10 tasks done, minors triaged Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-08-v1.10-qol-notes.md | 116 +++++++++++++++--- 1 file changed, 101 insertions(+), 15 deletions(-) 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 index 4095265..0310bbe 100644 --- a/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.10-qol-notes.md @@ -35,23 +35,28 @@ it, symlink it from another worktree's `node_modules`. ## Task ledger -## STATUS AT SESSION END (2026-08-08) +## STATUS: ALL 10 TASKS COMPLETE (2026-08-08) -**Tasks 1–6 are COMPLETE** — implemented, reviewed and pushed. **Tasks 7, 8, 9 -and 10 are NOT started. Resume at Task 7.** +**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. -Suite at the stopping point: **716 vitest (SQLite), 0 failures**; Postgres -`tests/db.interface.test.js` 4/4. The FULL Postgres suite and the smoke suites -have NOT been re-run since Task 1 — do that before trusting the branch. +Full verification at the end of the branch, all three run after the last code +change: -Remaining work: -- **Task 7** — show the bests in the Games tab (client; needs a - `fetchMinigameBests()` helper in `client/src/game/api.js`, which Task 6 - deliberately did NOT add). -- **Task 8** — achievements expose `progress`/`target` (shared). -- **Task 9** — badge progress bars (client). -- **Task 10** — smoke suite `tests/e2e/smoke-v110.mjs`, changelog, version bump - to 1.10.0, then the final whole-branch review. +| 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 @@ -80,20 +85,26 @@ 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 (for the final whole-branch review to triage): +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 @@ -135,3 +146,78 @@ _Each task appends here: what changed, decisions made, anything deferred._ - 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. +