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