From 8ef765bee400bcbfae9129c61c5a1165ec2befe4 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 31 Jul 2026 19:35:29 -0400 Subject: [PATCH 01/11] Add v1.5 Social & Retention design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands §6 of the master expansion spec into an implementable design: daily contracts board, global leaderboards, achievements/badges, and the daily streak. Records the four owner-resolved design forks (single Social tab, rate-scaled contract targets, explicit streak claim with missed-day reset, admin-tunable `social` config section). Co-Authored-By: Claude Opus 5 --- .../specs/2026-07-31-v1.5-social-design.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-v1.5-social-design.md diff --git a/docs/superpowers/specs/2026-07-31-v1.5-social-design.md b/docs/superpowers/specs/2026-07-31-v1.5-social-design.md new file mode 100644 index 0000000..4d0ef44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-v1.5-social-design.md @@ -0,0 +1,363 @@ +# RackStack v1.5 — Social & Retention Design + +**Date:** 2026-07-31 +**Status:** Approved by owner (NeverEndingCode) — four open design forks resolved 2026-07-31. +**Scope:** The fourth and final phase of the v1.2–v1.5 expansion. Expands §6 of +`docs/superpowers/specs/2026-07-25-rackstack-expansion-design.md` (approved 2026-07-25) into an +implementable design. Nothing here contradicts that document; where it adds detail, the detail is +new, not a revision. + +--- + +## 1. Goal + +Add the retention layer: a **daily contracts board**, **global leaderboards**, **achievements & +badges**, and a **daily streak**. All four are bonuses layered on the existing economy — none of +them gates content, and none of them introduces a new currency. + +## 2. Inherited constraints + +These are carried forward from v1.2–v1.4 and are not up for renegotiation in this phase: + +- **All math lives in `shared/`** — pure ESM, zero runtime dependencies, consumed identically by + the server (authoritative) and the client (rendering). The client never computes a payout. +- **Server-authoritative via lazy evaluation.** Rewards are claimed through reducer actions + validated in `POST /api/actions`. Every handler keeps the `(s, action, config, now)` contract, + mutates the already-`structuredClone`d `s`, and returns `{ok:true, …}` or `err('code')`. +- **No new reducer error strings.** Reuse `invalid_target`, `not_met`, `max_level`, + `insufficient_credits`, `cooldown_active`. +- **Every index or key taken from an action payload is validated before any property access.** + Reuse `validIndex(index, length)`. User-supplied ids are resolved with `.find()`, never as bare + object keys. This class of bug has been found three times in prior reviews. +- **Numbers are config-driven.** New tunables go in `shared/configSchema.js` so the Balancing tab + edits them and live events can overlay them. +- The stored config document is never written with event modifiers merged in. + +## 3. The four design decisions + +Resolved by the owner before this document was written: + +| Fork | Decision | +| - | - | +| UI surface | **One `Social` tab** with three inner sections (Contracts / Leaderboard / Achievements), using `ProfileView.jsx`'s existing inner-toggle pattern. Streak claim lives in the sticky header. | +| Contract targets | **Scale off live rate/stats** at generation time, snapshotted for the day. | +| Streak | **Explicit claim** action; a fully missed UTC calendar day resets to day 1. No grace (deferred to §12 Future of the master spec). | +| Tunables | **Yes** — a new `social` config section, so contracts/streak are admin-tunable and event-overlayable. | + +--- + +## 4. New shared modules + +Four small, single-purpose modules rather than one `social.js`, so each can be understood and +tested independently: + +### 4.1 `shared/daily.js` + +The UTC day boundary, shared by contracts and the streak so they can never drift apart. + +- `utcDateKey(ms) -> 'YYYY-MM-DD'` — the UTC calendar day containing `ms`. +- `daysBetweenDateKeys(a, b) -> number` — whole UTC days from `a` to `b`; `null` if either is + malformed. Pure string/`Date.UTC` arithmetic, so DST is structurally irrelevant. + +### 4.2 `shared/contracts.js` + +- `CONTRACT_DEFS` — six definitions, each `{ id, metric, lane, desc(target), target(ctx, config) }`. + `metric` names a **monotonic lifetime counter**, so a delta against a baseline is always + well-defined: + + | id | metric | lane | target | + | - | - | - | - | + | `c_flops` | `lifetimeFlopsAllTime` | base | `max(totalOutputPerSec * social.contractFlopsSeconds, social.contractFlopsMin)` | + | `c_minigames` | `minigamesWon` | base | `social.contractMinigamesTarget` | + | `c_wafers` | `totalWafersEarned` | base | `round(social.contractWafersBase * social.contractWafersGrowth ^ level)` | + | `c_blocks` | `blocksClaimedLifetime` | cold | `social.contractBlocksTarget` | + | `c_tapes` | `tapesEarnedLifetime` | cold | `social.contractTapesBase + social.contractTapesPerLevel * level` | + | `c_jobs` | `jobsCompletedLifetime` | cold | `1` | + +- `dailyContractTypes(dateKey, coldUnlocked) -> [id, id, id]` — deterministic from the date. An + FNV-1a hash of `dateKey` seeds a `mulberry32` PRNG, which shuffles the pool and takes three + distinct ids. **Every player gets the same three types on the same date**, satisfying §6.1 of + the master spec. + + **Locked-lane substitution.** Three of the six defs are in the `cold` lane and are impossible + for a player who hasn't unlocked Cold Storage (rack tier 5). Handing such a player two dead + contracts is worse than a small deviation from "identical for everyone", so when + `coldUnlocked` is false, each `cold` pick is deterministically replaced by the next unused + `base`-lane id from the same shuffled order. The substitution is a pure function of + `(dateKey, coldUnlocked)` — two locked players still match each other exactly, and a player + who unlocks Cold Storage mid-day keeps the day's snapshot until the next rollover (§4.2.1). + +- `contractProgress(def, meta, baseline, target) -> { current, target, met }` — same shape and + semantics as `rungProgress` in `shared/events.js`: `current = max(0, metric(meta) - baseline)`. + +- `rolloverContracts(state, config, now) -> boolean` — idempotent. No-op when + `state.meta.contracts.dateKey === utcDateKey(now)`. Otherwise selects the day's three types, + **snapshots both the targets and the per-metric baselines**, and resets `claimed` to + `[false, false, false]`. Returns whether it rolled over. + +#### 4.2.1 Why targets are snapshotted, not recomputed + +`c_flops`'s target derives from the player's current output. Recomputing it on every read would +move the goalposts every time the player bought a rack — the contract would recede as fast as it +was approached. Snapshotting at rollover fixes the target for the day. The same applies to the +level-scaled targets. + +#### 4.2.2 Where rollover runs + +`rolloverContracts` is called from `server/stateService.js`'s `loadEvaluateAndSchedule`, **after** +`evaluate()` (so `totalOutputPerSec` reflects the just-closed gap) and after +`joinEventIfEligible` (so an event's config overlay is already in force when targets are +computed). This is the same convention `scheduleAnomaly` and `joinEventIfEligible` already +follow: a pure mutation of the freshly-evaluated state, on the single load path that both +`GET /api/state` and `POST /api/actions` share. + +### 4.3 `shared/streak.js` + +- `STREAK_REWARDS(day, config, ctx) -> { flops?, wafers?, tapes? }` — escalating: + days 1–3 pay FLOPS (`social.streakFlopsSeconds` seconds of current output), days 4–6 pay + wafers (`social.streakWaferBase + social.streakWaferPerDay * day`), day 7 pays + `social.streakDay7Tapes` tapes plus the day-6 wafer amount. +- `nextStreakCount(streak, today) -> number` — `1` if there is no `lastClaimDate` or the gap is + anything other than exactly 1 day; otherwise `min(count + 1, social.streakMaxDay)`. Staying at + day 7 while unbroken falls out of the `min`. + +### 4.4 `shared/achievements.js` + +- `ACHIEVEMENT_DEFS` — ~18 entries, each `{ id, name, desc, icon, tier, condition(ctx) -> bool }`. + Pure prestige: **no payout**, per §6.3 of the master spec. `tier` (`bronze`/`silver`/`gold`) + drives badge ordering on leaderboard rows. + + Covering: first Migrate, 10 Migrates, first Singularity, 5 Singularities, the block-16 jackpot, + a Deep Archive Scrub, a maxed tape-tree upgrade, levels 10/25/50, 1G/1T/1P lifetime FLOPS, + 100 minigames won, first event joined, an event top rung, a 7-day streak, 50 contracts + completed, and every static goal complete. + +- `checkAchievements(state, config, now) -> string[]` — builds the same `ctx` the goal system + already uses (`goalCtx(state, config, now)` from `shared/goals.js`, which carries `run`, `meta`, + `totalOutputPerSec` and `unlockedUpTo`), scans the defs, writes `state.meta.achievements[id] = + now` for every newly-met one not already present, and returns the newly-unlocked ids. Pure with + respect to everything else; safe to call repeatedly. Reusing `goalCtx` means an achievement + condition and a goal condition are written against the identical context object. + + **Called from two places**, because the two ways state advances are disjoint: + 1. `applyAction`, after a handler returns `ok` — covers everything driven by player action. + 2. `loadEvaluateAndSchedule`, after `evaluate()` — covers thresholds crossed by offline + accrual (the lifetime-FLOPS tiers), which no action touches. + + Newly-unlocked ids ride back on the action result as `unlockedAchievements` (omitted when + empty) so the client can toast them. + +--- + +## 5. Config: the `social` section + +Added to `DEFAULT_CONFIG` with a matching `TUNABLES` entry (min/max/integer) for every leaf, so +the Balancing tab picks them up with no dashboard changes and live events can overlay them — +a "double streak rewards" weekend becomes an authoring exercise, not a code change. + +```js +social: { + // Contracts + contractFlopsSeconds: 600, // FLOPS target = N seconds of current output + contractFlopsMin: 500, // floor; see §5.1 + contractMinigamesTarget: 3, + contractBlocksTarget: 4, + contractTapesBase: 15, + contractTapesPerLevel: 1, + contractWafersBase: 5, + contractWafersGrowth: 1.15, + contractRewardWafers: 6, // paid per completed contract + contractRewardTapes: 4, + contractRewardLevelScalePct: 0.05, + // Streak + streakMaxDay: 7, + streakFlopsSeconds: 300, + streakWaferBase: 4, + streakWaferPerDay: 2, + streakDay7Tapes: 25, + // Leaderboard + leaderboardCacheMs: 60000, + leaderboardLimit: 50, +} +``` + +### 5.1 The `contractFlopsMin` floor + +The owner chose rate-scaled targets without the clamped variant. A pure rate scale has one +degenerate case: a player with zero output — a brand-new save, or the instant after a Migrate — +gets a target of `0 * 600 = 0`, which is already met, so the contract auto-completes for free. + +`contractFlopsMin` is therefore included as a **correctness guard, not a re-litigation of the +decision**: it is a `max()` floor only, with no upper clamp, so the target still scales purely +with the player's rate everywhere above the floor. If the owner would rather have the +zero-output auto-complete, setting `contractFlopsMin: 0` in the Balancing tab restores it with +no code change. + +--- + +## 6. Canonical state additions + +Per §7 of the master spec (`v1.5 contracts{…}, meta.achievements, streak{count, lastClaimDate}`): + +```js +meta.contracts = { + dateKey: null, // 'YYYY-MM-DD' UTC; null until the first rollover + targets: [0, 0, 0], + baseline: {}, // { [metric]: number } for the three selected types + claimed: [false, false, false], +} +meta.achievements = {} // { [id]: unlockedAtMs } +meta.streak = { count: 0, lastClaimDate: null } + +meta.stats.contractsCompletedLifetime = 0 +meta.stats.bestStreak = 0 +meta.stats.eventTopRungs = 0 // feeds the event-champion achievement +``` + +The day's three **type ids are not stored** — they are re-derived from `dateKey` via +`dailyContractTypes`, which is what guarantees the client and server agree on them. The +consequence is accepted and documented: if `CONTRACT_DEFS` changes in a future release, the +displayed types for the in-flight day may shift under a player mid-day while their snapshotted +targets and baselines stay put. Contracts are a daily bonus, the window is at most 24h, and the +alternative (storing indices that can dangle against a changed def list) fails worse. + +`migrateSave()` defaults every one of the above for pre-v1.5 saves, idempotently, matching the +existing `coldStorage`/`pendingEventClaims` padding style: arrays pinned to length 3 and to +`boolean`/`number` element types, objects merged over the fresh defaults, and a non-array or +non-object value from a corrupt or hand-edited save replaced outright rather than passed through. + +--- + +## 7. Reducer actions + +### `{ type: 'claimContract', index }` + +- `invalid_target` — `index` fails `validIndex(index, 3)`; `meta.contracts.dateKey` is not + today's key; that slot is already `claimed`. +- `not_met` — `contractProgress(...).met === false`. +- On success: credits `social.contractRewardWafers` wafers and `social.contractRewardTapes` + tapes, each scaled by `1 + social.contractRewardLevelScalePct * level`. Wafers land in + `meta.wafers` **and** `stats.totalWafersEarned`; tapes land in `meta.coldStorage.tapes` **and** + `stats.tapesEarnedLifetime` — matching the crediting precedent `claimGoal` and `claimEventRung` + already set. Bumps `stats.contractsCompletedLifetime`, sets `claimed[index] = true`. + +### `{ type: 'claimStreak' }` + +- `invalid_target` — `meta.streak.lastClaimDate` is already today's key. +- On success: `count = nextStreakCount(streak, today)`, pays `STREAK_REWARDS(count, …)` (FLOPS to + `run.credits` **and** `run.lifetimeRun`; wafers and tapes as above), sets + `lastClaimDate = today`, updates `stats.bestStreak`. + +There is deliberately **no `cooldown_active`** on either: both are gated on a calendar-day key, +not a rolling window, and `invalid_target` is the existing string for "already done". + +### Change to `claimEventRung` + +Increments `stats.eventTopRungs` when `index === ladder.length - 1`, feeding the event-champion +achievement through the same condition-driven path as every other achievement rather than +special-casing badge grants inside the event code. + +--- + +## 8. Leaderboards + +`GET /api/leaderboard` (auth: any signed-in user) → + +```js +{ + generatedAt: 1753900000000, + boards: { + allTimeFlops: [{ userId, username, avatarUrl, value, badges: [id] }, …], + level: [ … ], + legacyCores: [ … ], + singularities: [ … ], + tapes: [ … ], + latestEventRung:[ … ], + }, +} +``` + +- Aggregated in a new `server/leaderboardService.js` from `getAllUsersWithSaves()` — extended to + select `leaderboard_opt_out` — skipping opted-out users and users with no save row. +- **Respects the live opt-out column**, the one v1.4 shipped (`users.leaderboard_opt_out`) for + exactly this purpose. Not the `event_participation.opted_out` join-time snapshot. +- `badges` is **at most three** achievement ids — the player's unlocked defs sorted by `tier` + (gold, then silver, then bronze) and, within a tier, by `ACHIEVEMENT_DEFS` order — rendered as + mini-icons on the row. +- `latestEventRung` comes from `event_participation` for the most recently-started event, reusing + `listLeaderboard(eventId, limit)`, which already applies the same opt-out filter. +- Each board is capped at `social.leaderboardLimit`. +- The whole payload is cached in-module for `social.leaderboardCacheMs` and rebuilt on the first + request after expiry. Time-based invalidation only — a board up to 60s stale is fine. + +**Performance note.** Rebuilding parses every user's save JSON. For a friends-server (tens of +users) behind a 60s cache this is negligible, and it keeps canonical saves as the single source +of truth with no denormalized counters to drift. This is the documented reason the endpoint is +cache-fronted rather than index-backed; if the user count ever made it hot, the fix is +materialized columns, not a different cache. + +--- + +## 9. Client + +- **`tabs.js`** gains `{ id: 'social', label: 'Social', Icon: Users }`, placed before the event + tab. Always visible and never locked — contracts and the streak work from level 0. (Contrast + the event tab, which `RackStack.jsx` filters out of the array entirely when there's no window.) +- **`SocialPanel.jsx`** — the shell, owning the three-way inner toggle and lazily fetching the + leaderboard when its section is first opened. Its sections are separate focused files: + - `social/ContractsSection.jsx` — the three contracts with progress bars and Claim buttons, + plus a countdown to the next UTC rollover. + - `social/LeaderboardSection.jsx` — board selector, ranked rows with avatar/username/value and + badge mini-icons, the caller's own row highlighted, and the opt-out toggle (reusing the + existing `PUT /api/me/leaderboard-opt-out`, which is authoritative). + - `social/AchievementsSection.jsx` — the badge case: every def, unlocked ones lit with their + date, locked ones dimmed with their description. +- **`StreakBanner.jsx`** — sticky-header slot beside `EventBanner`, showing the current day and a + Claim button while claimable, following the established surge-banner pattern. +- **`api.js`** gains `fetchLeaderboard()`, and adds **`claimContract` and `claimStreak` to the + `IMMEDIATE` set** — both are reward claims exactly like `claimGoal`/`claimEventRung`, and both + are day-boundary-sensitive, so sitting queued for up to a second matters at the edge. +- **Achievement toast** on unlock, reusing `AnomalyToast.jsx`'s pattern, driven by the + `unlockedAchievements` field on action results. +- **Admin:** no new dashboard tab. `AdminBalancing.jsx` is `TUNABLES`-driven, so the `social.*` + knobs appear there automatically. + +--- + +## 10. Testing + +Following §10 of the master spec: + +- **Shared modules (table-driven vitest):** `tests/daily.test.js` (UTC key math, day gaps, + malformed input), `tests/contracts.test.js` (determinism across simulated players for a fixed + date, locked-lane substitution determinism, target snapshotting, rollover idempotence), + `tests/streak.test.js` (advance/reset/cap), `tests/achievements.test.js` (each condition, no + double-unlock, no payout). +- **Reducer:** `tests/reducer.social.test.js` — claim happy paths crediting every reward kind and + both counters per kind, double-claim rejection, unmet rejection, stale-`dateKey` rejection, + malformed indices (`'__proto__'`, `'push'`, `-1`, `1.5`, `999`) rejected without throwing, + input state never mutated. +- **Migration:** a pre-v1.5 save fixture loaded through the v1.5 `migrateSave`, asserting nothing + lost and every new field defaulted; plus corrupt-shape inputs for each new field. +- **API:** `tests/api.social.test.js` — leaderboard shape, opt-out exclusion, cache reuse within + the window and rebuild after it, auth required. +- **E2E:** `tests/e2e/smoke-v15.mjs`, following `smoke-v14.mjs`'s structure (spawned server, + seeded users/state, PASS/FAIL per check, non-zero exit, cleanup on exit/SIGINT/SIGTERM). + +--- + +## 11. Rollout + +One release via the existing tag → GHCR pipeline. CHANGELOG `## v1.5.0`, README section, both +`package.json`s and the Dockerfile's `org.opencontainers.image.version` label bumped to `1.5.0`. +All state changes are additive and defaulted by `migrateSave`, so no migration step is required +beyond the usual "back up `rackstack.db` before upgrading". + +Release ritual (unchanged): merge the PR, then tag `main` — never the branch — and push the tag; +that push triggers the GHCR publish workflow. + +## 12. Explicitly out of scope + +Carried from §12 of the master spec, plus this phase's own deferrals: contract rerolls, streak +grace tokens, spendable event currency, friend lists / follows, cross-server boards, and +achievement rewards of any kind (achievements stay pure prestige). From 928410185bc58063b8b5952baea7a533c10cc4b0 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 31 Jul 2026 19:43:37 -0400 Subject: [PATCH 02/11] Add v1.5 Social implementation plan Twelve TDD tasks from the design spec: day-cycle helpers and the social config section, canonical state additions, the four shared modules (daily/contracts/streak/achievements), the two reducer actions plus the automatic achievement sweep, load-path rollover, the leaderboard service and route, three client tasks, and release prep. Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-31-v1.5-social.md | 2043 +++++++++++++++++ 1 file changed, 2043 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-v1.5-social.md diff --git a/docs/superpowers/plans/2026-07-31-v1.5-social.md b/docs/superpowers/plans/2026-07-31-v1.5-social.md new file mode 100644 index 0000000..ea87ea5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-v1.5-social.md @@ -0,0 +1,2043 @@ +# RackStack v1.5 — Social & Retention 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:** Add the retention layer from spec `docs/superpowers/specs/2026-07-31-v1.5-social-design.md` — a daily contracts board, global leaderboards, achievements/badges, and a daily streak — as the fourth and final phase of the v1.2–v1.5 expansion. + +**Architecture:** Four small pure `shared/` modules (`daily.js`, `contracts.js`, `streak.js`, `achievements.js`) hold all the math, so client and server compute identically. Contracts are **deterministic from the UTC date** with targets and baselines **snapshotted at rollover** (so the goalposts can't move mid-day); rollover runs in the single load path both `GET /api/state` and `POST /api/actions` already share. Rewards are claimed via reducer actions (`claimContract`, `claimStreak`) reusing the exact `claimGoal` pattern. Achievements are pure prestige, unlocked automatically by a `checkAchievements` sweep hooked into both `applyAction` and the load path. Leaderboards are aggregated server-side from canonical saves behind a time-based cache. + +**Tech Stack:** Same as v1.4 — Node 20 ESM, Express 4, better-sqlite3, React 18 + Vite 5, vitest + supertest, Playwright for e2e. + +## Global Constraints + +- `shared/` modules: pure ESM, **zero runtime dependencies**. All social *math* lives in `shared/` so client and server compute identically. +- Every mutating handler in `shared/reducer.js` keeps the existing contract: `(s, action, config, now)`, mutates the already-`structuredClone`d `s`, returns `{ok:true, ...}` or `err('code')`; `applyAction` never throws. +- **No new reducer error strings.** Reuse `invalid_target`, `not_met`, `max_level`, `insufficient_credits`, `cooldown_active`. Neither new action uses `cooldown_active` — both are gated on a calendar-day key, and `invalid_target` is the existing string for "already done". +- **Every index/key taken from an action payload must be validated before any property access** — reuse `validIndex(index, length)`. This class of bug (`index: '__proto__'`/`'push'`) has been found three times in prior reviews; do not reintroduce it. +- **User-supplied ids are resolved with `.find(d => d.id === id)`**, never as bare object keys. Any lookup table keyed by a string that could come from a save or a payload is built with `Object.create(null)` and guarded with `Object.prototype.hasOwnProperty.call`, matching `EVENT_METRICS` and `HANDLERS`. +- Reward crediting follows the established precedent exactly: FLOPS → `run.credits` **and** `run.lifetimeRun`; wafers → `meta.wafers` **and** `stats.totalWafersEarned`; tapes → `meta.coldStorage.tapes` **and** `stats.tapesEarnedLifetime`. +- Achievements are **pure prestige — no payout, ever.** +- New tunables are added to **both** `DEFAULT_CONFIG` and `TUNABLES` in `shared/configSchema.js`. `validateConfig` rejects any key not in `TUNABLES`, so a `DEFAULT_CONFIG` addition without the matching `TUNABLES` entry makes every config write fail. +- Commit style: short imperative summaries ending with `Co-Authored-By: Claude Opus 5 `. +- `npm test` from repo root and `cd client && npm run build` must stay clean throughout. + +## File Structure + +**Create:** +- `shared/daily.js` — UTC day-boundary math. Sole owner of the day boundary shared by contracts and streak. +- `shared/contracts.js` — contract defs, deterministic daily selection, target scaling, progress, rollover. +- `shared/streak.js` — streak advance rules and the reward table. +- `shared/achievements.js` — achievement defs and the unlock sweep. +- `server/leaderboardService.js` — cross-user aggregation + cache for `GET /api/leaderboard`. +- `client/src/game/components/SocialPanel.jsx` — Social tab shell + inner toggle. +- `client/src/game/components/social/ContractsSection.jsx` +- `client/src/game/components/social/LeaderboardSection.jsx` +- `client/src/game/components/social/AchievementsSection.jsx` +- `client/src/game/components/StreakBanner.jsx` +- `tests/daily.test.js`, `tests/contracts.test.js`, `tests/streak.test.js`, `tests/achievements.test.js`, `tests/state.social.test.js`, `tests/reducer.social.test.js`, `tests/api.social.test.js` +- `tests/e2e/smoke-v15.mjs` + +**Modify:** +- `shared/configSchema.js` — the `social` section + `TUNABLES` entries. +- `shared/state.js` — `meta.contracts` / `meta.achievements` / `meta.streak` / new stats + `migrateSave` defaulting. +- `shared/reducer.js` — `claimContract`, `claimStreak`, the `checkAchievements` hook in `applyAction`, `stats.eventTopRungs` in `claimEventRung`. +- `server/stateService.js` — rollover + offline achievement sweep on the load path. +- `server/db.js` — `getAllUsersWithSaves()` gains `leaderboard_opt_out`; add `getLatestEventId()`. +- `server/routes/api.js` — `GET /api/leaderboard`. +- `client/src/game/data/tabs.js`, `client/src/game/api.js`, `client/src/game/constants.js`, `client/src/RackStack.jsx` +- `README.md`, `CHANGELOG.md`, `package.json`, `client/package.json`, `Dockerfile` + +--- + +### Task 1: Day-cycle helpers and the `social` config section + +Two small foundations every later task depends on. Grouped because neither is independently shippable and a reviewer would gate them together. + +**Files:** +- Create: `shared/daily.js` +- Modify: `shared/configSchema.js` +- Test: `tests/daily.test.js`, `tests/configSchema.test.js` + +**Interfaces:** +- Produces: + - `utcDateKey(ms) -> 'YYYY-MM-DD'` — the UTC calendar day containing `ms`. Returns `null` for a non-finite input. + - `daysBetweenDateKeys(a, b) -> number|null` — whole UTC days from `a` to `b` (negative if `b` precedes `a`); `null` if either key is malformed. + - `DEFAULT_CONFIG.social` — the 17 knobs below, each with a matching `TUNABLES` entry. + +- [ ] **Step 1: Write the failing test** — `tests/daily.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { utcDateKey, daysBetweenDateKeys } from '../shared/daily.js'; + +describe('utcDateKey', () => { + it('formats the UTC calendar day', () => { + expect(utcDateKey(Date.UTC(2026, 6, 31, 12, 0, 0))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 0, 1, 0, 0, 0))).toBe('2026-01-01'); + }); + it('zero-pads month and day', () => { + expect(utcDateKey(Date.UTC(2026, 8, 5, 23, 59, 59))).toBe('2026-09-05'); + }); + it('uses UTC, not local time, at both edges of the day', () => { + expect(utcDateKey(Date.UTC(2026, 6, 31, 0, 0, 0))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 6, 31, 23, 59, 59, 999))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 7, 1, 0, 0, 0))).toBe('2026-08-01'); + }); + it('returns null for non-finite input', () => { + for (const bad of [NaN, Infinity, null, undefined, 'x']) { + expect(utcDateKey(bad)).toBeNull(); + } + }); +}); + +describe('daysBetweenDateKeys', () => { + it('counts whole days forward', () => { + expect(daysBetweenDateKeys('2026-07-30', '2026-07-31')).toBe(1); + expect(daysBetweenDateKeys('2026-07-31', '2026-07-31')).toBe(0); + expect(daysBetweenDateKeys('2026-07-25', '2026-07-31')).toBe(6); + }); + it('counts backwards as negative', () => { + expect(daysBetweenDateKeys('2026-07-31', '2026-07-30')).toBe(-1); + }); + it('crosses month and year boundaries', () => { + expect(daysBetweenDateKeys('2026-07-31', '2026-08-01')).toBe(1); + expect(daysBetweenDateKeys('2026-12-31', '2027-01-01')).toBe(1); + expect(daysBetweenDateKeys('2028-02-28', '2028-03-01')).toBe(2); // leap year + }); + it('returns null for malformed keys instead of NaN', () => { + for (const bad of ['', 'nope', '2026-13-01', '2026-7-1', null, undefined, 42, '__proto__']) { + expect(daysBetweenDateKeys(bad, '2026-07-31')).toBeNull(); + expect(daysBetweenDateKeys('2026-07-31', bad)).toBeNull(); + } + }); +}); +``` + +And add to `tests/configSchema.test.js`: + +```js +import { DEFAULT_CONFIG, TUNABLES, validateConfig } from '../shared/configSchema.js'; + +describe('social config section', () => { + it('every social leaf has a matching TUNABLES entry', () => { + const paths = new Set(TUNABLES.map((t) => t.path)); + for (const key of Object.keys(DEFAULT_CONFIG.social)) { + expect(paths.has(`social.${key}`)).toBe(true); + } + }); + it('DEFAULT_CONFIG still validates with the new section', () => { + expect(validateConfig(DEFAULT_CONFIG)).toEqual({ ok: true }); + }); + it('every social default sits inside its declared range', () => { + for (const t of TUNABLES.filter((t) => t.path.startsWith('social.'))) { + const v = DEFAULT_CONFIG.social[t.path.slice('social.'.length)]; + expect(v).toBeGreaterThanOrEqual(t.min); + expect(v).toBeLessThanOrEqual(t.max); + if (t.integer) expect(Number.isInteger(v)).toBe(true); + } + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/daily.test.js tests/configSchema.test.js` → FAIL (module missing / `social` undefined). + +- [ ] **Step 3: Implement `shared/daily.js`** + +```js +// The UTC day boundary, owned in exactly one place so the contracts board +// (shared/contracts.js) and the daily streak (shared/streak.js) can never +// drift apart on when "tomorrow" starts. All arithmetic goes through +// Date.UTC, so local time and DST are structurally irrelevant. + +const DATE_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function utcDateKey(ms) { + if (typeof ms !== 'number' || !Number.isFinite(ms)) return null; + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// Parses a 'YYYY-MM-DD' key to a UTC epoch, rejecting anything that isn't +// exactly that shape AND that doesn't round-trip (so '2026-02-31' and +// '2026-13-01', which Date.UTC would silently roll over into a different +// real date, are rejected rather than quietly accepted). +function parseDateKey(key) { + if (typeof key !== 'string') return null; + const m = DATE_KEY_RE.exec(key); + if (!m) return null; + const [, y, mo, d] = m; + const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d)); + if (!Number.isFinite(ms)) return null; + return utcDateKey(ms) === key ? ms : null; +} + +const DAY_MS = 24 * 3600 * 1000; + +export function daysBetweenDateKeys(a, b) { + const from = parseDateKey(a); + const to = parseDateKey(b); + if (from === null || to === null) return null; + return Math.round((to - from) / DAY_MS); +} +``` + +- [ ] **Step 4: Add the `social` section to `shared/configSchema.js`** — inside `DEFAULT_CONFIG`, after `batchQueue`: + +```js + // v1.5 Social & Retention. Every leaf here is admin-tunable via the + // Balancing tab (which is TUNABLES-driven, so it needs no dashboard + // change) and overlayable by a live event's modifiers - a "double streak + // rewards" weekend is an authoring exercise, not a code change. + social: { + contractFlopsSeconds: 600, + contractFlopsMin: 500, + contractMinigamesTarget: 3, + contractBlocksTarget: 4, + contractTapesBase: 15, + contractTapesPerLevel: 1, + contractWafersBase: 5, + contractWafersGrowth: 1.15, + contractRewardWafers: 6, + contractRewardTapes: 4, + contractRewardLevelScalePct: 0.05, + streakMaxDay: 7, + streakFlopsSeconds: 300, + streakWaferBase: 4, + streakWaferPerDay: 2, + streakDay7Tapes: 25, + leaderboardCacheMs: 60000, + leaderboardLimit: 50, + }, +``` + +and append to `TUNABLES`: + +```js + { path: 'social.contractFlopsSeconds', label: 'Contract FLOPS target (seconds of output)', min: 1, max: 86400, integer: true }, + // A floor, never a cap: a player at zero output (a fresh save, or the + // instant after a Migrate) would otherwise get a target of 0, which is + // already met, and the contract would auto-complete for free. Set to 0 to + // deliberately restore that behaviour. + { path: 'social.contractFlopsMin', label: 'Contract FLOPS target floor', min: 0, max: 1e12, integer: false }, + { path: 'social.contractMinigamesTarget', label: 'Contract target: minigames won', min: 1, max: 100, integer: true }, + { path: 'social.contractBlocksTarget', label: 'Contract target: blocks claimed', min: 1, max: 100, integer: true }, + { path: 'social.contractTapesBase', label: 'Contract target: tapes base', min: 1, max: 10000, integer: true }, + { path: 'social.contractTapesPerLevel', label: 'Contract target: tapes per level', min: 0, max: 1000, integer: false }, + { path: 'social.contractWafersBase', label: 'Contract target: wafers base', min: 1, max: 10000, integer: true }, + { path: 'social.contractWafersGrowth', label: 'Contract target: wafers growth per level', min: 1, max: 3, integer: false }, + { path: 'social.contractRewardWafers', label: 'Contract reward: wafers', min: 0, max: 10000, integer: true }, + { path: 'social.contractRewardTapes', label: 'Contract reward: tapes', min: 0, max: 10000, integer: true }, + { path: 'social.contractRewardLevelScalePct', label: 'Contract reward scaling per level', min: 0, max: 1, integer: false }, + { path: 'social.streakMaxDay', label: 'Streak length (days)', min: 1, max: 30, integer: true }, + { path: 'social.streakFlopsSeconds', label: 'Streak FLOPS reward (seconds of output)', min: 0, max: 86400, integer: true }, + { path: 'social.streakWaferBase', label: 'Streak wafer reward base', min: 0, max: 10000, integer: true }, + { path: 'social.streakWaferPerDay', label: 'Streak wafer reward per day', min: 0, max: 1000, integer: true }, + { path: 'social.streakDay7Tapes', label: 'Streak final-day tape reward', min: 0, max: 10000, integer: true }, + { path: 'social.leaderboardCacheMs', label: 'Leaderboard cache TTL (ms)', min: 0, max: 3600000, integer: true }, + { path: 'social.leaderboardLimit', label: 'Leaderboard rows per board', min: 1, max: 500, integer: true }, +``` + +- [ ] **Step 5: Run to verify pass** — `npx vitest run tests/daily.test.js tests/configSchema.test.js` → PASS. Then full `npm test` (existing config tests must still pass; `upgradeConfig` carries the new defaults automatically because it rebuilds from `DEFAULT_CONFIG`). + +- [ ] **Step 6: Commit** — `"Add UTC day-cycle helpers and the social config section"`. + +--- + +### Task 2: Canonical state — contracts, achievements, streak, new stats + +Pins the state shape every later task reads and writes. + +**Files:** +- Modify: `shared/state.js` +- Test: `tests/state.social.test.js` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces (on `initialState()`): + - `meta.contracts = { dateKey: null, targets: [0,0,0], baseline: {}, claimed: [false,false,false] }` + - `meta.achievements = {}` — `{ [id]: unlockedAtMs }` + - `meta.streak = { count: 0, lastClaimDate: null }` + - `meta.stats.contractsCompletedLifetime = 0`, `meta.stats.bestStreak = 0`, `meta.stats.eventTopRungs = 0` +- `migrateSave()` defaults all of the above idempotently. + +The **type ids are deliberately not stored** — they are re-derived from `dateKey` in Task 3, which is what guarantees client and server agree on them. + +- [ ] **Step 1: Write the failing test** — `tests/state.social.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { initialState, migrateSave } from '../shared/state.js'; + +describe('v1.5 state additions', () => { + it('initialState seeds contracts, achievements, streak and the new stats', () => { + const s = initialState(); + expect(s.meta.contracts).toEqual({ + dateKey: null, targets: [0, 0, 0], baseline: {}, claimed: [false, false, false], + }); + expect(s.meta.achievements).toEqual({}); + expect(s.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + expect(s.meta.stats.contractsCompletedLifetime).toBe(0); + expect(s.meta.stats.bestStreak).toBe(0); + expect(s.meta.stats.eventTopRungs).toBe(0); + }); + + it('migrateSave defaults the v1.5 fields on a pre-v1.5 save without losing data', () => { + const preV15 = { + run: { credits: 5 }, + meta: { wafers: 3, level: 7, coldStorage: { tapes: 99 }, eventProgress: null }, + }; + const s = migrateSave(preV15); + expect(s.meta.contracts.dateKey).toBeNull(); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + expect(s.meta.achievements).toEqual({}); + expect(s.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + expect(s.meta.stats.contractsCompletedLifetime).toBe(0); + // existing data preserved + expect(s.meta.wafers).toBe(3); + expect(s.meta.level).toBe(7); + expect(s.meta.coldStorage.tapes).toBe(99); + }); + + it('migrateSave preserves in-flight v1.5 progress', () => { + const s = initialState(); + s.meta.contracts = { + dateKey: '2026-07-31', targets: [100, 3, 4], + baseline: { lifetimeFlopsAllTime: 50 }, claimed: [true, false, false], + }; + s.meta.achievements = { first_migrate: 1234 }; + s.meta.streak = { count: 4, lastClaimDate: '2026-07-31' }; + const out = migrateSave(s); + expect(out.meta.contracts).toEqual(s.meta.contracts); + expect(out.meta.achievements).toEqual({ first_migrate: 1234 }); + expect(out.meta.streak).toEqual({ count: 4, lastClaimDate: '2026-07-31' }); + }); + + it('migrateSave repairs corrupt/hand-edited shapes rather than passing them through', () => { + const bad = migrateSave({ + meta: { + contracts: { dateKey: 5, targets: 'nope', baseline: null, claimed: [true] }, + achievements: [1, 2, 3], + streak: 'nope', + }, + }); + expect(bad.meta.contracts.dateKey).toBeNull(); // non-string key discarded + expect(bad.meta.contracts.targets).toEqual([0, 0, 0]); // non-array replaced + expect(bad.meta.contracts.baseline).toEqual({}); // null replaced + expect(bad.meta.contracts.claimed).toEqual([true, false, false]); // padded to 3 + expect(bad.meta.achievements).toEqual({}); // array replaced + expect(bad.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + }); + + it('is idempotent', () => { + const once = migrateSave(initialState()); + expect(migrateSave(once)).toEqual(once); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/state.social.test.js` → FAIL. + +- [ ] **Step 3: Implement in `shared/state.js`** — add to `initialState()`'s `meta` block: + +```js + // 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 dailyContractTypes(), which is what guarantees + // the client and server agree on them without a sync step. `targets` + // and `baseline` ARE stored, because both are snapshotted at rollover: + // recomputing a rate-scaled target on every read would move the + // goalposts every time the player bought a rack. + contracts: { + dateKey: null, + targets: [0, 0, 0], + baseline: {}, + claimed: [false, false, false], + }, + // Pure prestige - no payout, ever (spec §6.3). { [id]: unlockedAtMs }. + achievements: {}, + streak: { count: 0, lastClaimDate: null }, +``` + +and to `meta.stats`: `contractsCompletedLifetime: 0, bestStreak: 0, eventTopRungs: 0,`. + +Then in `migrateSave`, after the existing `coldStorage` block: + +```js + // v1.5: every field below is defaulted AND shape-checked. A corrupt or + // hand-edited save must never hand a non-array to claimContract's + // validIndex path or a non-object to the baseline lookup - same reasoning + // as pendingEventClaims' explicit array pinning above. + const srcContracts = (srcMeta.contracts && typeof srcMeta.contracts === 'object' + && !Array.isArray(srcMeta.contracts)) ? srcMeta.contracts : {}; + const padTo3 = (arr, fill) => { + const list = Array.isArray(arr) ? arr.slice(0, 3) : []; + while (list.length < 3) list.push(fill); + return list; + }; + meta.contracts = { + dateKey: typeof srcContracts.dateKey === 'string' ? srcContracts.dateKey : null, + targets: padTo3(srcContracts.targets, 0).map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : 0)), + baseline: (srcContracts.baseline && typeof srcContracts.baseline === 'object' + && !Array.isArray(srcContracts.baseline)) ? { ...srcContracts.baseline } : {}, + claimed: padTo3(srcContracts.claimed, false).map((b) => b === true), + }; + meta.achievements = (srcMeta.achievements && typeof srcMeta.achievements === 'object' + && !Array.isArray(srcMeta.achievements)) ? { ...srcMeta.achievements } : {}; + const srcStreak = (srcMeta.streak && typeof srcMeta.streak === 'object' + && !Array.isArray(srcMeta.streak)) ? srcMeta.streak : {}; + meta.streak = { + count: typeof srcStreak.count === 'number' && Number.isFinite(srcStreak.count) ? srcStreak.count : 0, + lastClaimDate: typeof srcStreak.lastClaimDate === 'string' ? srcStreak.lastClaimDate : null, + }; +``` + +- [ ] **Step 4: Run to verify pass** — `npx vitest run tests/state.social.test.js` → PASS, then full `npm test` (`tests/state.test.js`'s existing migration assertions must still pass). + +- [ ] **Step 5: Commit** — `"Add contracts, achievements and streak to canonical state"`. + +--- + +### Task 3: `shared/contracts.js` — defs, deterministic selection, targets, rollover + +**Files:** +- Create: `shared/contracts.js` +- Test: `tests/contracts.test.js` + +**Interfaces:** +- Consumes: `shared/daily.js` (`utcDateKey`), `shared/goals.js` (`goalCtx`). +- Produces: + - `CONTRACT_DEFS` — array of `{ id, metric, lane, desc(target), target(ctx, config) }`. `lane` is `'base'` or `'cold'`. + - `contractDef(id) -> def|null` — `.find()` lookup, never a bare-key index. + - `dailyContractTypes(dateKey, coldUnlocked) -> [id, id, id]` + - `contractProgress(def, meta, baseline, target) -> { current, target, met }` + - `rolloverContracts(state, config, now) -> boolean` + - `contractsForState(meta) -> [{ def, target, claimed, index }]` — the three resolved defs for the stored `dateKey`, for rendering and for the reducer. Returns `[]` when `meta.contracts.dateKey` is null. + +- [ ] **Step 1: Write the failing test** — `tests/contracts.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { + CONTRACT_DEFS, contractDef, dailyContractTypes, + contractProgress, rolloverContracts, contractsForState, +} from '../shared/contracts.js'; + +const NOW = Date.UTC(2026, 6, 31, 12, 0, 0); // 2026-07-31 + +describe('CONTRACT_DEFS', () => { + it('has six defs with unique ids and a valid lane', () => { + expect(CONTRACT_DEFS).toHaveLength(6); + const ids = CONTRACT_DEFS.map((d) => d.id); + expect(new Set(ids).size).toBe(6); + for (const d of CONTRACT_DEFS) { + expect(['base', 'cold']).toContain(d.lane); + expect(typeof d.metric).toBe('string'); + expect(typeof d.desc).toBe('function'); + expect(typeof d.target).toBe('function'); + } + }); + it('has exactly three base-lane defs, so substitution always has enough to draw from', () => { + expect(CONTRACT_DEFS.filter((d) => d.lane === 'base')).toHaveLength(3); + }); + it('contractDef resolves by id and fails closed on prototype keys', () => { + expect(contractDef('c_flops').id).toBe('c_flops'); + for (const bad of ['nope', '__proto__', 'toString', 'constructor', '', null, 42]) { + expect(contractDef(bad)).toBeNull(); + } + }); +}); + +describe('dailyContractTypes', () => { + it('is deterministic: the same date gives the same three types every call', () => { + const a = dailyContractTypes('2026-07-31', true); + for (let i = 0; i < 20; i++) expect(dailyContractTypes('2026-07-31', true)).toEqual(a); + }); + it('returns three distinct known ids', () => { + for (const key of ['2026-01-01', '2026-07-31', '2026-12-25', '2027-03-14']) { + const picked = dailyContractTypes(key, true); + expect(picked).toHaveLength(3); + expect(new Set(picked).size).toBe(3); + for (const id of picked) expect(contractDef(id)).not.toBeNull(); + } + }); + it('varies across dates', () => { + const seen = new Set(); + for (let d = 1; d <= 28; d++) { + seen.add(dailyContractTypes(`2026-02-${String(d).padStart(2, '0')}`, true).join(',')); + } + expect(seen.size).toBeGreaterThan(3); + }); + it('substitutes base-lane defs for locked cold-lane picks, deterministically', () => { + for (const key of ['2026-01-01', '2026-07-31', '2026-12-25']) { + const locked = dailyContractTypes(key, false); + expect(locked).toHaveLength(3); + expect(new Set(locked).size).toBe(3); + for (const id of locked) expect(contractDef(id).lane).toBe('base'); + expect(dailyContractTypes(key, false)).toEqual(locked); // stable + } + }); + it('returns [] for a malformed date key rather than throwing', () => { + for (const bad of ['', 'nope', null, undefined, 42, '__proto__']) { + expect(dailyContractTypes(bad, true)).toEqual([]); + } + }); +}); + +describe('contractProgress', () => { + const def = contractDef('c_minigames'); + const meta = { stats: { minigamesWon: 9 } }; + it('measures the delta since baseline, floored at zero', () => { + expect(contractProgress(def, meta, 4, 3)).toEqual({ current: 5, target: 3, met: true }); + expect(contractProgress(def, meta, 8, 3)).toEqual({ current: 1, target: 3, met: false }); + expect(contractProgress(def, meta, 20, 3)).toEqual({ current: 0, target: 3, met: false }); + }); + it('treats a missing baseline as zero', () => { + expect(contractProgress(def, meta, undefined, 3).current).toBe(9); + }); +}); + +describe('rolloverContracts', () => { + it('populates dateKey, targets, baselines and clears claimed on a fresh state', () => { + const s = initialState(); + s.meta.stats.minigamesWon = 11; + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW)).toBe(true); + expect(s.meta.contracts.dateKey).toBe('2026-07-31'); + expect(s.meta.contracts.targets).toHaveLength(3); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + // baselines snapshot the CURRENT counters, so pre-existing progress + // never counts toward today's contracts + const types = dailyContractTypes('2026-07-31', false); + for (const id of types) { + const def = contractDef(id); + expect(s.meta.contracts.baseline[def.metric]).toBe(s.meta.stats[def.metric] ?? 0); + } + }); + + it('is a no-op within the same UTC day, preserving snapshotted targets and claims', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [true, false, false]; + const before = structuredClone(s.meta.contracts); + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW + 3600 * 1000)).toBe(false); + expect(s.meta.contracts).toEqual(before); + }); + + it('rolls over on the next UTC day and resets claims', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [true, true, true]; + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW + 24 * 3600 * 1000)).toBe(true); + expect(s.meta.contracts.dateKey).toBe('2026-08-01'); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + }); + + it('snapshots the FLOPS target so buying racks mid-day cannot move it', () => { + const s = initialState(); + s.run.tiers[0].owned = 10; + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const snapshot = [...s.meta.contracts.targets]; + s.run.tiers[0].owned = 10000; // output explodes + rolloverContracts(s, DEFAULT_CONFIG, NOW + 3600 * 1000); + expect(s.meta.contracts.targets).toEqual(snapshot); + }); + + it('never produces a zero FLOPS target for a zero-output player', () => { + const s = initialState(); // no racks owned -> totalOutputPerSec === 0 + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const resolved = contractsForState(s.meta); + for (const c of resolved) { + expect(c.target).toBeGreaterThan(0); + } + }); +}); + +describe('contractsForState', () => { + it('returns [] before the first rollover', () => { + expect(contractsForState(initialState().meta)).toEqual([]); + }); + it('resolves three defs with their snapshotted targets and claim flags', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [false, true, false]; + const resolved = contractsForState(s.meta); + expect(resolved).toHaveLength(3); + expect(resolved.map((c) => c.index)).toEqual([0, 1, 2]); + expect(resolved.map((c) => c.claimed)).toEqual([false, true, false]); + expect(resolved.map((c) => c.target)).toEqual(s.meta.contracts.targets); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/contracts.test.js` → FAIL (module missing). + +- [ ] **Step 3: Implement `shared/contracts.js`** + +```js +// Daily contracts board (v1.5, spec §6.1 + design §4.2). +// +// Three contracts a day, rotating at midnight UTC, generated +// DETERMINISTICALLY from the date: every player gets the same three contract +// TYPES on the same day, while the numeric targets scale to each player's own +// progress. That determinism is why the type ids aren't stored in the save - +// they're re-derived from `meta.contracts.dateKey`, so the client and the +// server can never disagree about what today's contracts are. + +import { utcDateKey } from './daily.js'; +import { goalCtx } from './goals.js'; + +// Every metric here is a MONOTONIC lifetime counter on meta.stats, so a +// delta against a snapshotted baseline is always well-defined and can never +// go backwards. `coldStorage.tapes` (a spendable balance) is deliberately +// NOT usable for this reason - `tapesEarnedLifetime` is its monotonic twin. +// +// `lane` gates availability: the three 'cold' defs are impossible for a +// player who hasn't unlocked Cold Storage (rack tier 5), so +// dailyContractTypes substitutes base-lane defs for them rather than handing +// such a player two dead contracts. There must always be at least three +// base-lane defs for that substitution to have enough to draw from - +// tests/contracts.test.js asserts this. +export const CONTRACT_DEFS = [ + { + id: 'c_flops', metric: 'lifetimeFlopsAllTime', lane: 'base', + desc: (target, fmt) => `Earn ${fmt(target)} FLOPS`, + target: (ctx, config) => Math.max( + ctx.totalOutputPerSec * config.social.contractFlopsSeconds, + config.social.contractFlopsMin, + ), + }, + { + id: 'c_minigames', metric: 'minigamesWon', lane: 'base', + desc: (target) => `Win ${target} minigame${target === 1 ? '' : 's'}`, + target: (ctx, config) => config.social.contractMinigamesTarget, + }, + { + id: 'c_wafers', metric: 'totalWafersEarned', lane: 'base', + desc: (target, fmt) => `Earn ${fmt(target)} Wafers`, + target: (ctx, config) => Math.round( + config.social.contractWafersBase + * Math.pow(config.social.contractWafersGrowth, ctx.meta.level || 0), + ), + }, + { + id: 'c_blocks', metric: 'blocksClaimedLifetime', lane: 'cold', + desc: (target) => `Claim ${target} Cold Storage block${target === 1 ? '' : 's'}`, + target: (ctx, config) => config.social.contractBlocksTarget, + }, + { + id: 'c_tapes', metric: 'tapesEarnedLifetime', lane: 'cold', + desc: (target, fmt) => `Earn ${fmt(target)} Tapes`, + target: (ctx, config) => Math.round( + config.social.contractTapesBase + + config.social.contractTapesPerLevel * (ctx.meta.level || 0), + ), + }, + { + id: 'c_jobs', metric: 'jobsCompletedLifetime', lane: 'cold', + desc: () => 'Complete a Cold Storage job', + target: () => 1, + }, +]; + +/** `.find()` lookup - `id` may come from a save or a payload, so it is never used as a bare key. */ +export function contractDef(id) { + if (typeof id !== 'string' || id === '') return null; + return CONTRACT_DEFS.find((d) => d.id === id) || null; +} + +// FNV-1a over the date key, then mulberry32. Both are tiny, well-known, and +// stable across engines - the requirement is only that every player's client +// and the server derive the SAME shuffle from the same date, not that the +// bits are cryptographically good. +function hashSeed(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The day's three contract type ids, a pure function of (dateKey, + * coldUnlocked). Fisher-Yates over CONTRACT_DEFS seeded by the date, take + * three. When Cold Storage is locked, each 'cold' pick is replaced by the + * next unused 'base' id from the SAME shuffled order - so two locked players + * still match each other exactly, and the result is stable across calls. + * Returns [] for a malformed key rather than throwing. + */ +export function dailyContractTypes(dateKey, coldUnlocked) { + if (typeof dateKey !== 'string' || !DATE_KEY_RE.test(dateKey)) return []; + + const rng = mulberry32(hashSeed(dateKey)); + const order = CONTRACT_DEFS.map((d) => d.id); + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + + const picked = []; + const used = new Set(); + const takeBase = () => order.find((id) => !used.has(id) && contractDef(id).lane === 'base'); + + for (const id of order) { + if (picked.length === 3) break; + if (used.has(id)) continue; + let chosen = id; + if (!coldUnlocked && contractDef(id).lane === 'cold') { + chosen = takeBase(); + if (!chosen) continue; // no base def left; skip this pick + } + used.add(chosen); + picked.push(chosen); + } + return picked; +} + +export function contractProgress(def, meta, baseline, target) { + const stats = (meta && meta.stats) || {}; + const raw = Object.prototype.hasOwnProperty.call(stats, def.metric) ? stats[def.metric] : 0; + const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; + const base = typeof baseline === 'number' && Number.isFinite(baseline) ? baseline : 0; + const current = Math.max(0, value - base); + return { current, target, met: current >= target }; +} + +/** Cold Storage unlocks with the Server Room (rack tier index 4) - same gate RackStack.jsx uses. */ +function isColdUnlocked(state) { + const t = state.run && state.run.tiers && state.run.tiers[4]; + return !!(t && t.owned >= 1); +} + +/** + * Rolls the board over to `now`'s UTC day if it isn't already there. + * Idempotent - returns false and touches nothing when the stored dateKey is + * already today's. Snapshots BOTH the targets and the per-metric baselines + * (see the design doc §4.2.1: a rate-scaled target recomputed on every read + * would recede as fast as the player approached it). + * + * Mutates `state` in place, matching the scheduleAnomaly/joinEventIfEligible + * convention on the server's load path. + */ +export function rolloverContracts(state, config, now) { + const today = utcDateKey(now); + if (today === null) return false; + if (state.meta.contracts && state.meta.contracts.dateKey === today) return false; + + const ctx = goalCtx(state, config, now); + const types = dailyContractTypes(today, isColdUnlocked(state)); + const baseline = {}; + const targets = []; + for (const id of types) { + const def = contractDef(id); + const raw = state.meta.stats[def.metric]; + baseline[def.metric] = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; + targets.push(def.target(ctx, config)); + } + + state.meta.contracts = { + dateKey: today, + targets, + baseline, + claimed: [false, false, false], + }; + return true; +} + +/** + * The three resolved contracts for the CURRENTLY STORED dateKey - the single + * place both the reducer and the client turn `meta.contracts` into something + * with defs attached, so they can't disagree. Returns [] before the first + * rollover. `coldUnlocked` is re-derived from the stored baseline's metrics + * rather than from live run state, so the day's snapshot stays stable even if + * the player unlocks Cold Storage mid-day. + */ +export function contractsForState(meta) { + const c = meta && meta.contracts; + if (!c || typeof c.dateKey !== 'string') return []; + const hadCold = CONTRACT_DEFS.some( + (d) => d.lane === 'cold' && Object.prototype.hasOwnProperty.call(c.baseline || {}, d.metric), + ); + const types = dailyContractTypes(c.dateKey, hadCold); + return types.map((id, index) => ({ + def: contractDef(id), + target: c.targets[index], + claimed: c.claimed[index] === true, + index, + })); +} +``` + +- [ ] **Step 4: Run to verify pass** — `npx vitest run tests/contracts.test.js` → PASS, then full `npm test`. + +- [ ] **Step 5: Commit** — `"Add deterministic daily contracts board"`. + +--- + +### Task 4: `shared/streak.js` — advance rules and reward table + +**Files:** +- Create: `shared/streak.js` +- Test: `tests/streak.test.js` + +**Interfaces:** +- Consumes: `shared/daily.js` (`daysBetweenDateKeys`). +- Produces: + - `nextStreakCount(streak, today, config) -> number` + - `streakReward(day, config, ctx) -> { flops: number, wafers: number, tapes: number }` + - `canClaimStreak(streak, today) -> boolean` + +- [ ] **Step 1: Write the failing test** — `tests/streak.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { nextStreakCount, streakReward, canClaimStreak } from '../shared/streak.js'; + +const cfg = DEFAULT_CONFIG; + +describe('nextStreakCount', () => { + it('starts at day 1 when there is no prior claim', () => { + expect(nextStreakCount({ count: 0, lastClaimDate: null }, '2026-07-31', cfg)).toBe(1); + }); + it('advances by one on a consecutive day', () => { + expect(nextStreakCount({ count: 3, lastClaimDate: '2026-07-30' }, '2026-07-31', cfg)).toBe(4); + }); + it('caps at streakMaxDay and stays there while unbroken', () => { + expect(nextStreakCount({ count: 7, lastClaimDate: '2026-07-30' }, '2026-07-31', cfg)).toBe(7); + }); + it('resets to 1 after a fully missed day', () => { + expect(nextStreakCount({ count: 6, lastClaimDate: '2026-07-29' }, '2026-07-31', cfg)).toBe(1); + expect(nextStreakCount({ count: 6, lastClaimDate: '2026-07-01' }, '2026-07-31', cfg)).toBe(1); + }); + it('resets to 1 on a malformed or future lastClaimDate', () => { + expect(nextStreakCount({ count: 5, lastClaimDate: 'nope' }, '2026-07-31', cfg)).toBe(1); + expect(nextStreakCount({ count: 5, lastClaimDate: '2026-08-05' }, '2026-07-31', cfg)).toBe(1); + }); +}); + +describe('canClaimStreak', () => { + it('is false only when already claimed today', () => { + expect(canClaimStreak({ lastClaimDate: '2026-07-31' }, '2026-07-31')).toBe(false); + expect(canClaimStreak({ lastClaimDate: '2026-07-30' }, '2026-07-31')).toBe(true); + expect(canClaimStreak({ lastClaimDate: null }, '2026-07-31')).toBe(true); + }); +}); + +describe('streakReward', () => { + const ctx = { totalOutputPerSec: 100, meta: { level: 0 } }; + it('pays FLOPS on days 1-3', () => { + for (const day of [1, 2, 3]) { + const r = streakReward(day, cfg, ctx); + expect(r.flops).toBeGreaterThan(0); + expect(r.wafers).toBe(0); + expect(r.tapes).toBe(0); + } + }); + it('escalates the FLOPS payout across days 1-3', () => { + expect(streakReward(2, cfg, ctx).flops).toBeGreaterThan(streakReward(1, cfg, ctx).flops); + expect(streakReward(3, cfg, ctx).flops).toBeGreaterThan(streakReward(2, cfg, ctx).flops); + }); + it('pays escalating wafers on days 4-6', () => { + for (const day of [4, 5, 6]) { + const r = streakReward(day, cfg, ctx); + expect(r.wafers).toBeGreaterThan(0); + expect(r.tapes).toBe(0); + } + expect(streakReward(6, cfg, ctx).wafers).toBeGreaterThan(streakReward(4, cfg, ctx).wafers); + }); + it('pays tapes plus wafers on the final day', () => { + const r = streakReward(7, cfg, ctx); + expect(r.tapes).toBe(cfg.social.streakDay7Tapes); + expect(r.wafers).toBeGreaterThan(0); + }); + it('never pays a negative or non-finite amount', () => { + for (let day = 1; day <= cfg.social.streakMaxDay; day++) { + const r = streakReward(day, cfg, { totalOutputPerSec: 0, meta: { level: 0 } }); + for (const v of Object.values(r)) { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + } + } + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/streak.test.js` → FAIL. + +- [ ] **Step 3: Implement `shared/streak.js`** + +```js +// Daily streak (v1.5, spec §6.4 + design §4.3). +// +// A 7-day escalating claim that stays at the day-7 reward while unbroken. A +// fully missed UTC calendar day resets to day 1 - the same day boundary the +// contracts board uses (shared/daily.js), deliberately, so a player who +// shows up once a day satisfies both at once. Rewards are a bonus, never a +// content gate. + +import { daysBetweenDateKeys } from './daily.js'; + +export function canClaimStreak(streak, today) { + return !streak || streak.lastClaimDate !== today; +} + +export function nextStreakCount(streak, today, config) { + const max = config.social.streakMaxDay; + const last = streak && streak.lastClaimDate; + const gap = daysBetweenDateKeys(last, today); + // gap === null covers "never claimed" and a malformed stored key; gap <= 0 + // covers a stored date at or after today (clock skew / hand-edited save). + if (gap === null || gap !== 1) return 1; + const count = typeof streak.count === 'number' && Number.isFinite(streak.count) ? streak.count : 0; + return Math.min(count + 1, max); +} + +/** + * Reward for reaching `day`. Days 1..3 pay FLOPS scaled to the player's own + * output (so it stays meaningful across the whole progression curve), days + * 4..(max-1) pay wafers, the final day pays tapes on top of the wafer + * amount. Every branch returns all three keys so callers never have to + * test for absence. + */ +export function streakReward(day, config, ctx) { + const s = config.social; + const out = { flops: 0, wafers: 0, tapes: 0 }; + const clamped = Math.max(1, Math.min(day, s.streakMaxDay)); + const waferAmount = s.streakWaferBase + s.streakWaferPerDay * clamped; + + if (clamped >= s.streakMaxDay) { + out.tapes = s.streakDay7Tapes; + out.wafers = waferAmount; + } else if (clamped > 3) { + out.wafers = waferAmount; + } else { + out.flops = Math.max(0, ctx.totalOutputPerSec * s.streakFlopsSeconds * clamped); + } + return out; +} +``` + +- [ ] **Step 4: Run to verify pass** — `npx vitest run tests/streak.test.js` → PASS, then full `npm test`. + +- [ ] **Step 5: Commit** — `"Add daily streak advance rules and reward table"`. + +--- + +### Task 5: `shared/achievements.js` — defs and the unlock sweep + +**Files:** +- Create: `shared/achievements.js` +- Test: `tests/achievements.test.js` + +**Interfaces:** +- Consumes: `shared/goals.js` (`goalCtx`, `GOAL_DEFS`). +- Produces: + - `ACHIEVEMENT_DEFS` — `{ id, name, desc, icon, tier, condition(ctx) -> boolean }`, `tier ∈ {'bronze','silver','gold'}`. + - `achievementDef(id) -> def|null` + - `checkAchievements(state, config, now) -> string[]` — writes `state.meta.achievements[id] = now` for newly-met defs, returns their ids. + - `topBadges(achievements, limit = 3) -> string[]` — unlocked ids sorted gold → silver → bronze, then by `ACHIEVEMENT_DEFS` order. + +`icon` is a **lucide-react icon name string** (e.g. `'Trophy'`), not a component — `shared/` must not import from `client/`. + +- [ ] **Step 1: Write the failing test** — `tests/achievements.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { + ACHIEVEMENT_DEFS, achievementDef, checkAchievements, topBadges, +} from '../shared/achievements.js'; + +const NOW = 1_000_000; + +describe('ACHIEVEMENT_DEFS', () => { + it('has unique ids, a valid tier, and a complete shape', () => { + const ids = ACHIEVEMENT_DEFS.map((d) => d.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.length).toBeGreaterThanOrEqual(15); + for (const d of ACHIEVEMENT_DEFS) { + expect(typeof d.name).toBe('string'); + expect(d.name.length).toBeGreaterThan(0); + 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'); + } + }); + it('carries no reward field of any kind - achievements are pure prestige', () => { + for (const d of ACHIEVEMENT_DEFS) { + expect(d.reward).toBeUndefined(); + expect(d.wafers).toBeUndefined(); + expect(d.xp).toBeUndefined(); + } + }); + it('achievementDef fails closed on prototype keys', () => { + for (const bad of ['nope', '__proto__', 'toString', 'constructor', '', null, 42]) { + expect(achievementDef(bad)).toBeNull(); + } + }); +}); + +describe('checkAchievements', () => { + it('unlocks nothing on a fresh state', () => { + const s = initialState(); + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toEqual([]); + expect(s.meta.achievements).toEqual({}); + }); + + it('unlocks on a met condition and stamps the unlock time', () => { + const s = initialState(); + s.meta.stats.singularities = 1; + const unlocked = checkAchievements(s, DEFAULT_CONFIG, NOW); + expect(unlocked).toContain('first_singularity'); + expect(s.meta.achievements.first_singularity).toBe(NOW); + }); + + it('never re-unlocks or re-stamps an already-held achievement', () => { + const s = initialState(); + s.meta.stats.singularities = 1; + checkAchievements(s, DEFAULT_CONFIG, NOW); + const second = checkAchievements(s, DEFAULT_CONFIG, NOW + 5000); + expect(second).not.toContain('first_singularity'); + expect(s.meta.achievements.first_singularity).toBe(NOW); // original stamp kept + }); + + it('pays nothing - no currency or xp moves when an achievement unlocks', () => { + const s = initialState(); + s.meta.stats.migrates = 1; + const before = { + wafers: s.meta.wafers, xp: s.meta.xp, level: s.meta.level, + credits: s.run.credits, tapes: s.meta.coldStorage.tapes, + }; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW).length).toBeGreaterThan(0); + expect(s.meta.wafers).toBe(before.wafers); + expect(s.meta.xp).toBe(before.xp); + expect(s.meta.level).toBe(before.level); + expect(s.run.credits).toBe(before.credits); + expect(s.meta.coldStorage.tapes).toBe(before.tapes); + }); + + it('unlocks the event-champion badge off the eventTopRungs counter', () => { + const s = initialState(); + s.meta.stats.eventTopRungs = 1; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toContain('event_champion'); + }); + + it('unlocks the streak badge off bestStreak', () => { + const s = initialState(); + s.meta.stats.bestStreak = 7; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toContain('streak_week'); + }); + + it('every condition survives a fresh state without throwing', () => { + const s = initialState(); + for (const d of ACHIEVEMENT_DEFS) { + expect(() => checkAchievements(s, DEFAULT_CONFIG, NOW)).not.toThrow(); + } + }); +}); + +describe('topBadges', () => { + it('returns at most three ids, gold first', () => { + const gold = ACHIEVEMENT_DEFS.filter((d) => d.tier === 'gold')[0]; + const bronze = ACHIEVEMENT_DEFS.filter((d) => d.tier === 'bronze').slice(0, 3); + const held = {}; + for (const d of [...bronze, gold]) held[d.id] = NOW; + const badges = topBadges(held); + expect(badges).toHaveLength(3); + expect(badges[0]).toBe(gold.id); + }); + it('ignores unknown ids in a hand-edited save', () => { + expect(topBadges({ nope: 1, __proto__: 1 })).toEqual([]); + }); + it('handles a missing or non-object bag', () => { + for (const bad of [null, undefined, [], 'x']) expect(topBadges(bad)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/achievements.test.js` → FAIL. + +- [ ] **Step 3: Implement `shared/achievements.js`** + +```js +// Achievements & badges (v1.5, spec §6.3 + design §4.4). +// +// Distinct from goals: NO PAYOUT, pure prestige. Unlocked automatically +// whenever conditions are met - never claimed - so there is no reducer +// action here and no reward field on any def. Displayed as the badge case in +// the Social tab and as mini-icons on leaderboard rows. +// +// Conditions are written against the SAME ctx object the goal system uses +// (goalCtx from shared/goals.js), so an achievement condition and a goal +// condition are interchangeable in style and neither can drift from the +// other's view of the world. + +import { goalCtx, GOAL_DEFS } from './goals.js'; + +const st = (ctx, key) => { + const v = ctx.meta.stats[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +}; + +// `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. +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: '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: '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]) }, +]; + +const TIER_ORDER = { gold: 0, silver: 1, bronze: 2 }; + +export function achievementDef(id) { + if (typeof id !== 'string' || id === '') return null; + return ACHIEVEMENT_DEFS.find((d) => d.id === id) || null; +} + +/** + * 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 are never re-stamped. Mutates `state.meta.achievements` in place - + * callers on 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 is run inside a try/catch and a throwing condition simply + * counts as unmet. + */ +export function checkAchievements(state, config, now) { + const ctx = goalCtx(state, config, now); + const held = state.meta.achievements; + const unlocked = []; + for (const def of ACHIEVEMENT_DEFS) { + if (Object.prototype.hasOwnProperty.call(held, def.id)) continue; + let met = false; + try { + met = !!def.condition(ctx); + } catch { + met = false; + } + if (met) { + held[def.id] = now; + unlocked.push(def.id); + } + } + return unlocked; +} + +/** At most `limit` unlocked ids, gold first, then ACHIEVEMENT_DEFS order. */ +export function topBadges(achievements, limit = 3) { + if (!achievements || typeof achievements !== 'object' || Array.isArray(achievements)) return []; + return ACHIEVEMENT_DEFS + .filter((d) => Object.prototype.hasOwnProperty.call(achievements, d.id)) + .sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]) + .slice(0, limit) + .map((d) => d.id); +} +``` + +Note `topBadges` relies on `Array.prototype.sort` being stable (guaranteed by spec since ES2019), which is what preserves `ACHIEVEMENT_DEFS` order within a tier. + +- [ ] **Step 4: Run to verify pass** — `npx vitest run tests/achievements.test.js` → PASS, then full `npm test`. + +- [ ] **Step 5: Commit** — `"Add achievement definitions and the automatic unlock sweep"`. + +--- + +### Task 6: Reducer — `claimContract`, `claimStreak`, and the achievement hook + +**Files:** +- Modify: `shared/reducer.js` +- Test: `tests/reducer.social.test.js` + +**Interfaces:** +- Consumes: Tasks 1–5 (`utcDateKey`, `contractsForState`, `contractProgress`, `canClaimStreak`, `nextStreakCount`, `streakReward`, `checkAchievements`). +- Produces: + - `{type:'claimContract', index}` → `{ok:true, reward:{wafers,tapes}, index}` | `invalid_target` | `not_met` + - `{type:'claimStreak'}` → `{ok:true, reward:{flops,wafers,tapes}, day}` | `invalid_target` + - `applyAction` results gain `unlockedAchievements: string[]` when non-empty. + - `claimEventRung` increments `stats.eventTopRungs` on a top-rung claim. + +- [ ] **Step 1: Write the failing test** — `tests/reducer.social.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { applyAction } from '../shared/reducer.js'; +import { rolloverContracts, contractsForState } from '../shared/contracts.js'; + +const NOW = Date.UTC(2026, 6, 31, 12, 0, 0); // 2026-07-31 +const TOMORROW = NOW + 24 * 3600 * 1000; + +// A state with today's board rolled over and contract slot 0 already met, by +// pushing that slot's own metric past its snapshotted target. +function stateWithMetContract() { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const [first] = contractsForState(s.meta); + s.meta.stats[first.def.metric] = s.meta.contracts.baseline[first.def.metric] + first.target; + return s; +} + +describe('claimContract', () => { + it('pays wafers and tapes, marks the slot claimed, and bumps the lifetime counter', () => { + const s = stateWithMetContract(); + const { state, result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.reward.wafers).toBeGreaterThan(0); + expect(result.reward.tapes).toBeGreaterThan(0); + expect(state.meta.wafers).toBe(result.reward.wafers); + expect(state.meta.stats.totalWafersEarned).toBe(result.reward.wafers); + expect(state.meta.coldStorage.tapes).toBe(result.reward.tapes); + expect(state.meta.stats.tapesEarnedLifetime).toBe(result.reward.tapes); + expect(state.meta.contracts.claimed[0]).toBe(true); + expect(state.meta.stats.contractsCompletedLifetime).toBe(1); + }); + + it('rejects a double claim', () => { + const s = stateWithMetContract(); + const a = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + const b = applyAction(a.state, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(b.result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('rejects an unmet contract with not_met', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + // c_minigames/c_wafers/c_blocks all have non-zero targets on a fresh + // state; find whichever slot is genuinely unmet and assert on it. + const unmet = contractsForState(s.meta).find((c) => c.target > 0); + const { result } = applyAction(s, { type: 'claimContract', index: unmet.index }, DEFAULT_CONFIG, NOW); + expect(result).toEqual({ ok: false, error: 'not_met' }); + }); + + it('rejects a claim against a stale board (dateKey is not today)', () => { + const s = stateWithMetContract(); + const { result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, TOMORROW); + expect(result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('rejects a claim before any rollover has happened', () => { + const { result } = applyAction(initialState(), { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it.each([['__proto__'], ['push'], ['length'], [-1], [1.5], [3], [999], [null], [undefined], [{}]])( + 'rejects malformed index %p as invalid_target without throwing', (index) => { + const s = stateWithMetContract(); + let out; + expect(() => { out = applyAction(s, { type: 'claimContract', index }, DEFAULT_CONFIG, NOW); }).not.toThrow(); + expect(out.result).toEqual({ ok: false, error: 'invalid_target' }); + }, + ); + + it('never mutates the input state', () => { + const s = stateWithMetContract(); + const before = structuredClone(s); + applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(s).toEqual(before); + }); +}); + +describe('claimStreak', () => { + it('starts a streak at day 1 and pays FLOPS to both credits and lifetimeRun', () => { + const s = initialState(); + s.run.tiers[0].owned = 50; // non-zero output so the day-1 FLOPS reward is > 0 + const { state, result } = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.day).toBe(1); + expect(result.reward.flops).toBeGreaterThan(0); + expect(state.run.credits).toBeCloseTo(s.run.credits + result.reward.flops); + expect(state.run.lifetimeRun).toBeCloseTo(s.run.lifetimeRun + result.reward.flops); + expect(state.meta.streak).toEqual({ count: 1, lastClaimDate: '2026-07-31' }); + expect(state.meta.stats.bestStreak).toBe(1); + }); + + it('rejects a second claim on the same UTC day', () => { + const a = applyAction(initialState(), { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + const b = applyAction(a.state, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + 3600 * 1000); + expect(b.result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('advances on a consecutive day and records the best streak', () => { + let s = initialState(); + for (let day = 0; day < 3; day++) { + s = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000).state; + } + expect(s.meta.streak.count).toBe(3); + expect(s.meta.stats.bestStreak).toBe(3); + }); + + it('resets to day 1 after a missed day but keeps bestStreak', () => { + let s = initialState(); + for (let day = 0; day < 4; day++) { + s = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000).state; + } + const after = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + 6 * 24 * 3600 * 1000).state; + expect(after.meta.streak.count).toBe(1); + expect(after.meta.stats.bestStreak).toBe(4); + }); + + it('pays tapes on the final day of the streak', () => { + let s = initialState(); + let last; + for (let day = 0; day < DEFAULT_CONFIG.social.streakMaxDay; day++) { + last = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000); + s = last.state; + } + expect(last.result.day).toBe(DEFAULT_CONFIG.social.streakMaxDay); + expect(last.result.reward.tapes).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + expect(s.meta.coldStorage.tapes).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + expect(s.meta.stats.tapesEarnedLifetime).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + }); + + it('never mutates the input state', () => { + const s = initialState(); + const before = structuredClone(s); + applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + expect(s).toEqual(before); + }); +}); + +describe('achievement sweep on the action path', () => { + it('reports newly-unlocked achievements on the action result', () => { + const s = initialState(); + s.meta.legacyCores = 100; // enough for a Singularity + const { state, result } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.unlockedAchievements).toContain('first_singularity'); + expect(state.meta.achievements.first_singularity).toBe(NOW); + }); + + it('omits the field entirely when nothing unlocked', () => { + const s = initialState(); + s.run.credits = 1e6; + const { result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 1 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.unlockedAchievements).toBeUndefined(); + }); + + it('does not sweep after a rejected action', () => { + const s = initialState(); + s.meta.stats.singularities = 1; // condition is met... + const { state, result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(false); // ...but the action failed + expect(state.meta.achievements).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/reducer.social.test.js` → FAIL. + +- [ ] **Step 3: Implement in `shared/reducer.js`** — add the imports, the two handlers, register them in `HANDLERS`, and hook the sweep into `applyAction`: + +```js +import { utcDateKey } from './daily.js'; +import { contractsForState, contractProgress } from './contracts.js'; +import { canClaimStreak, nextStreakCount, streakReward } from './streak.js'; +import { checkAchievements } from './achievements.js'; +``` + +```js +// v1.5 Social. Both handlers are gated on a CALENDAR-DAY key rather than a +// rolling window, so "already done today" is invalid_target (the existing +// string for a repeat claim, as in claimGoal/claimBlock) and never +// cooldown_active - no new error strings. + +function claimContract(s, action, config, now) { + const { index } = action; + if (!validIndex(index, 3)) return err('invalid_target'); + + // The board is rolled over on the server's load path + // (server/stateService.js) BEFORE any action is applied, so a dateKey that + // isn't today's here means this claim raced past a rollover - reject it + // rather than pay out against a stale target/baseline pair. + const today = utcDateKey(now); + if (today === null || s.meta.contracts.dateKey !== today) return err('invalid_target'); + + const resolved = contractsForState(s.meta); + const contract = resolved[index]; + if (!contract || !contract.def) return err('invalid_target'); + if (s.meta.contracts.claimed[index] === true) return err('invalid_target'); + + const baseline = s.meta.contracts.baseline[contract.def.metric]; + if (!contractProgress(contract.def, s.meta, baseline, contract.target).met) return err('not_met'); + + const scale = 1 + config.social.contractRewardLevelScalePct * (s.meta.level || 0); + const wafers = Math.round(config.social.contractRewardWafers * scale); + const tapes = Math.round(config.social.contractRewardTapes * scale); + + s.meta.wafers += wafers; + s.meta.stats.totalWafersEarned = (s.meta.stats.totalWafersEarned || 0) + wafers; + s.meta.coldStorage.tapes += tapes; + s.meta.stats.tapesEarnedLifetime = (s.meta.stats.tapesEarnedLifetime || 0) + tapes; + s.meta.contracts.claimed[index] = true; + s.meta.stats.contractsCompletedLifetime = (s.meta.stats.contractsCompletedLifetime || 0) + 1; + + return { ok: true, reward: { wafers, tapes }, index }; +} + +function claimStreak(s, action, config, now) { + const today = utcDateKey(now); + if (today === null) return err('invalid_target'); + if (!canClaimStreak(s.meta.streak, today)) return err('invalid_target'); + + const day = nextStreakCount(s.meta.streak, today, config); + const ctx = goalCtx(s, config, now); + const reward = streakReward(day, config, ctx); + + if (reward.flops > 0) { + s.run.credits += reward.flops; + s.run.lifetimeRun += reward.flops; + } + if (reward.wafers > 0) { + s.meta.wafers += reward.wafers; + s.meta.stats.totalWafersEarned = (s.meta.stats.totalWafersEarned || 0) + reward.wafers; + } + if (reward.tapes > 0) { + s.meta.coldStorage.tapes += reward.tapes; + s.meta.stats.tapesEarnedLifetime = (s.meta.stats.tapesEarnedLifetime || 0) + reward.tapes; + } + + s.meta.streak = { count: day, lastClaimDate: today }; + s.meta.stats.bestStreak = Math.max(s.meta.stats.bestStreak || 0, day); + return { ok: true, reward, day }; +} +``` + +Register both in `HANDLERS`: `claimContract, claimStreak,`. + +In `claimEventRung`, immediately after `ep.rungsClaimed.push(index);`: + +```js + // Feeds the 'event_champion' achievement through the ordinary + // condition-driven path (shared/achievements.js) rather than special-casing + // a badge grant here - spec §5.1's "top rung awards a badge". + if (index === ladder.length - 1) { + s.meta.stats.eventTopRungs = (s.meta.stats.eventTopRungs || 0) + 1; + } +``` + +And in `applyAction`, replace the body's tail: + +```js +export function applyAction(state, action, config, now, rng = Math.random) { + const handler = action && HANDLERS[action.type]; + if (!handler) { + return { state: structuredClone(state), result: err('unknown_action') }; + } + const s = structuredClone(state); + const result = handler(s, action, config, now, rng); + // Achievements unlock automatically, never by claim (spec §6.3). Sweeping + // here - after any SUCCESSFUL action - is what makes "unlocked in the + // reducer" true for everything a player does. A rejected action changed + // nothing, so there is nothing new to unlock and sweeping would only + // burn a goalCtx build on every bad request. The offline half (thresholds + // crossed by evaluate()'s accrual, which no action touches) is swept + // separately in server/stateService.js. + if (result && result.ok) { + const unlocked = checkAchievements(s, config, now); + if (unlocked.length > 0) result.unlockedAchievements = unlocked; + } + return { state: s, result }; +} +``` + +- [ ] **Step 4: Run to verify pass** — `npx vitest run tests/reducer.social.test.js` → PASS, then full `npm test`. Existing reducer suites must stay green; if any assert on exact result objects with `toEqual`, an added `unlockedAchievements` key would break them — fix by asserting the specific fields rather than by suppressing the sweep. + +- [ ] **Step 5: Commit** — `"Add claimContract and claimStreak actions with automatic achievement unlocks"`. + +--- + +### Task 7: `stateService` — rollover and the offline achievement sweep + +**Files:** +- Modify: `server/stateService.js` +- Test: `tests/stateService.social.test.js` + +**Interfaces:** +- Consumes: `rolloverContracts` (Task 3), `checkAchievements` (Task 5). +- Produces: `loadEvaluateAndSchedule` returns an added `unlockedAchievements: string[]`, and `loadAndEvaluate` passes it through for `GET /api/state`. + +- [ ] **Step 1: Write the failing test** — `tests/stateService.social.test.js`, following `tests/stateService.events.test.js`'s `DB_PATH=':memory:'`-before-dynamic-import pattern: + +```js +process.env.DB_PATH = ':memory:'; +process.env.JWT_SECRET = 'test-secret-social-state'; + +import { describe, it, expect } from 'vitest'; + +const { upsertUser, putSave, getSave } = await import('../server/db.js'); +const { ensureConfig } = await import('../server/configService.js'); +const { loadAndEvaluate } = await import('../server/stateService.js'); +const { initialState } = await import('../shared/state.js'); +const { utcDateKey } = await import('../shared/daily.js'); + +ensureConfig(); + +let seq = 0; +function makeUser() { + seq += 1; + return upsertUser({ provider: 'discord', providerId: `soc${seq}`, username: `socuser${seq}`, avatarUrl: null }); +} + +describe('contracts roll over on the load path', () => { + it("populates today's board for a user who has never had one", () => { + const u = makeUser(); + const now = Date.now(); + const { state } = loadAndEvaluate(u.id, now); + expect(state.meta.contracts.dateKey).toBe(utcDateKey(now)); + expect(state.meta.contracts.targets).toHaveLength(3); + }); + + it('persists the rolled-over board, so a reload sees the same targets', () => { + const u = makeUser(); + const now = Date.now(); + const first = loadAndEvaluate(u.id, now).state; + const second = loadAndEvaluate(u.id, now + 60_000).state; + expect(second.meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); + expect(second.meta.contracts.targets).toEqual(first.meta.contracts.targets); + expect(JSON.parse(getSave(u.id).data).meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); + }); + + it('rolls over and clears claims when the UTC day advances', () => { + const u = makeUser(); + const now = Date.now(); + const first = loadAndEvaluate(u.id, now).state; + first.meta.contracts.claimed = [true, true, true]; + putSave(u.id, first, now); + const next = loadAndEvaluate(u.id, now + 24 * 3600 * 1000).state; + expect(next.meta.contracts.dateKey).not.toBe(first.meta.contracts.dateKey); + expect(next.meta.contracts.claimed).toEqual([false, false, false]); + }); +}); + +describe('achievements unlock from offline accrual', () => { + it('sweeps after evaluate, so a threshold crossed while away unlocks on next load', () => { + const u = makeUser(); + const now = Date.now(); + const s = initialState(); + s.meta.stats.lifetimeFlopsAllTime = 1e9; // 'flops_g' condition met + putSave(u.id, s, now); + const { state, unlockedAchievements } = loadAndEvaluate(u.id, now + 1000); + expect(state.meta.achievements.flops_g).toBeDefined(); + expect(unlockedAchievements).toContain('flops_g'); + }); + + it('reports nothing on a subsequent load once already held', () => { + const u = makeUser(); + const now = Date.now(); + const s = initialState(); + s.meta.stats.singularities = 1; + putSave(u.id, s, now); + loadAndEvaluate(u.id, now + 1000); + const { unlockedAchievements } = loadAndEvaluate(u.id, now + 2000); + expect(unlockedAchievements).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/stateService.social.test.js` → FAIL. + +- [ ] **Step 3: Implement in `server/stateService.js`** — import `rolloverContracts` and `checkAchievements`, then in `loadEvaluateAndSchedule`, after the `resolvePlayerEvents` block and before the `return`: + +```js + // v1.5: roll the contracts board to today's UTC day. Runs AFTER evaluate() + // (so ctx.totalOutputPerSec reflects the gap just closed, and the + // rate-scaled FLOPS target is computed against the player's real current + // output) and AFTER joinEventIfEligible (so an active event's config + // overlay is already in force when targets are computed). Idempotent - a + // no-op on every load within the same UTC day. + rolloverContracts(state, config, now); + + // v1.5: the offline half of the achievement sweep. shared/reducer.js's + // applyAction sweeps after every successful ACTION, which covers everything + // a player does - but the lifetime-FLOPS tiers are crossed by evaluate()'s + // accrual during a gap, which no action touches. Without this, a player who + // crossed 1T FLOPS while asleep wouldn't unlock until their next successful + // action. Both call sites write to the same `meta.achievements` bag and + // checkAchievements never re-stamps a held id, so a double sweep is free. + const unlockedAchievements = checkAchievements(state, config, now); + + return { state, gained, config, activeEvent, unlockedAchievements }; +``` + +Then in `loadAndEvaluate`: + +```js +export function loadAndEvaluate(userId, now = Date.now()) { + const { state, gained, activeEvent, unlockedAchievements } = loadEvaluateAndSchedule(userId, now); + putSave(userId, state, now); + return { state, gained, activeEvent, unlockedAchievements }; +} +``` + +And in `applyActions`, merge the load-path unlocks so the client's toast isn't lost when a batch also crosses an offline threshold — destructure `unlockedAchievements: loadUnlocked` from `loadEvaluateAndSchedule` and return it as a top-level `unlockedAchievements` alongside `results`. + +- [ ] **Step 4: Wire the route** — in `server/routes/api.js`, `GET /api/state` adds `unlockedAchievements` to its JSON, and `POST /api/actions` adds the merged `unlockedAchievements` to its response. + +- [ ] **Step 5: Run to verify pass** — `npx vitest run tests/stateService.social.test.js` → PASS, then full `npm test`. + +- [ ] **Step 6: Commit** — `"Roll contracts over and sweep offline achievements on the load path"`. + +--- + +### Task 8: Leaderboard service and `GET /api/leaderboard` + +**Files:** +- Create: `server/leaderboardService.js` +- Modify: `server/db.js`, `server/routes/api.js` +- Test: `tests/api.social.test.js` + +**Interfaces:** +- Consumes: `topBadges` (Task 5), `getAllUsersWithSaves`/`listLeaderboard` (`server/db.js`). +- Produces: + - `server/db.js`: `getAllUsersWithSaves()` additionally selects `u.leaderboard_opt_out`; new `getLatestEventId() -> string|null` (the most recently-started non-draft event). + - `server/leaderboardService.js`: `getLeaderboards(now = Date.now()) -> { generatedAt, boards }`, `invalidateLeaderboards()` (exported for tests). + - Route: `GET /api/leaderboard` (auth: any signed-in user). + +Boards: `allTimeFlops`, `level`, `legacyCores`, `singularities`, `tapes`, `latestEventRung`. Each row: `{ userId, username, avatarUrl, value, badges }`. + +- [ ] **Step 1: Write the failing test** — `tests/api.social.test.js`: + +```js +process.env.JWT_SECRET = 'test-secret-for-supertest-social'; +process.env.DB_PATH = ':memory:'; + +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; + +const { buildApp } = await import('../server/app.js'); +const { ensureConfig } = await import('../server/configService.js'); +const { upsertUser, putSave, setLeaderboardOptOut } = await import('../server/db.js'); +const { invalidateLeaderboards } = await import('../server/leaderboardService.js'); +const { COOKIE_NAME } = await import('../server/auth.js'); +const { initialState } = await import('../shared/state.js'); + +ensureConfig(); +const app = buildApp(); + +let seq = 0; +function seedPlayer({ flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {} }) { + seq += 1; + const u = upsertUser({ provider: 'discord', providerId: `lb${seq}`, username: `lbuser${seq}`, avatarUrl: `https://x/${seq}.png` }); + const s = initialState(); + s.meta.stats.lifetimeFlopsAllTime = flops; + s.meta.level = level; + s.meta.legacyCores = cores; + s.meta.stats.singularities = singularities; + s.meta.coldStorage.tapes = tapes; + s.meta.achievements = achievements; + putSave(u.id, s, Date.now()); + return u; +} + +function cookieFor(user) { + const token = jwt.sign( + { sub: user.id, username: user.username, avatarUrl: user.avatar_url }, + process.env.JWT_SECRET, { expiresIn: '90d' }, + ); + return `${COOKIE_NAME}=${token}`; +} + +describe('GET /api/leaderboard', () => { + it('401s when unauthenticated', async () => { + expect((await request(app).get('/api/leaderboard')).status).toBe(401); + }); + + it('returns every board, ranked descending', async () => { + const low = seedPlayer({ flops: 100, level: 1 }); + const high = seedPlayer({ flops: 999999, level: 40 }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(low)); + expect(res.status).toBe(200); + expect(Object.keys(res.body.boards).sort()).toEqual( + ['allTimeFlops', 'latestEventRung', 'legacyCores', 'level', 'singularities', 'tapes'], + ); + const flopsIds = res.body.boards.allTimeFlops.map((r) => r.userId); + expect(flopsIds.indexOf(high.id)).toBeLessThan(flopsIds.indexOf(low.id)); + const row = res.body.boards.allTimeFlops.find((r) => r.userId === high.id); + expect(row.username).toBe(high.username); + expect(row.avatarUrl).toBe(high.avatar_url); + expect(row.value).toBe(999999); + expect(Array.isArray(row.badges)).toBe(true); + }); + + it('excludes opted-out players from every board', async () => { + const shy = seedPlayer({ flops: 1e12, level: 90 }); + const seen = seedPlayer({ flops: 5, level: 1 }); + setLeaderboardOptOut(shy.id, true); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(seen)); + for (const board of Object.values(res.body.boards)) { + expect(board.map((r) => r.userId)).not.toContain(shy.id); + } + }); + + it('surfaces up to three badges per row', async () => { + const decorated = seedPlayer({ + flops: 42, + achievements: { first_migrate: 1, first_singularity: 2, level_10: 3, jackpot: 4, level_50: 5 }, + }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(decorated)); + const row = res.body.boards.allTimeFlops.find((r) => r.userId === decorated.id); + expect(row.badges.length).toBeLessThanOrEqual(3); + expect(row.badges).toContain('level_50'); // gold sorts first + }); + + it('serves a cached payload within the TTL and rebuilds after invalidation', async () => { + const u = seedPlayer({ flops: 1 }); + invalidateLeaderboards(); + const first = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + const second = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(second.body.generatedAt).toBe(first.body.generatedAt); // same cached build + + seedPlayer({ flops: 1e15 }); + const stale = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(stale.body.generatedAt).toBe(first.body.generatedAt); // still cached + + invalidateLeaderboards(); + const fresh = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(fresh.body.generatedAt).toBeGreaterThanOrEqual(first.body.generatedAt); + expect(fresh.body.boards.allTimeFlops[0].value).toBe(1e15); + }); + + it('skips users with no save row without throwing', async () => { + const u = seedPlayer({ flops: 1 }); + upsertUser({ provider: 'discord', providerId: 'lb-nosave', username: 'nosave', avatarUrl: null }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(res.status).toBe(200); + expect(res.body.boards.allTimeFlops.map((r) => r.username)).not.toContain('nosave'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `npx vitest run tests/api.social.test.js` → FAIL. + +- [ ] **Step 3: Extend `server/db.js`** — add `u.leaderboard_opt_out` to `getAllUsersWithSaves()`'s SELECT list, and add: + +```js +/** + * The most recently-STARTED event that has actually run (any status except + * 'draft', which has no window). Backs the leaderboard's latest-event board. + * Returns null when no event has ever been scheduled. + */ +export function getLatestEventId() { + const row = db.prepare( + "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", + ).get(); + return row ? row.id : null; +} +``` + +- [ ] **Step 4: Implement `server/leaderboardService.js`** + +```js +// Global leaderboards (v1.5, spec §6.2 + design §8). +// +// Aggregated server-side from CANONICAL SAVES - there are no denormalized +// per-user counters to drift out of sync. That means a rebuild parses every +// user's save JSON, which is why the whole payload sits behind a +// time-based cache: for a friends-server (tens of users) at one rebuild per +// social.leaderboardCacheMs this is negligible, and it keeps saves as the +// single source of truth. If the user count ever made this hot, the fix is +// materialized columns on `users`, not a different cache. + +import { getAllUsersWithSaves, listLeaderboard, getLatestEventId } from './db.js'; +import { getConfig } from './configService.js'; +import { topBadges } from '../shared/achievements.js'; + +let cache = null; // { generatedAt, boards } + +/** Drops the cached payload. Exported for tests and for future admin tooling. */ +export function invalidateLeaderboards() { + cache = null; +} + +// Each board is (key, how to read its value off a parsed save's meta). +const BOARDS = [ + ['allTimeFlops', (meta) => (meta.stats && meta.stats.lifetimeFlopsAllTime) || 0], + ['level', (meta) => meta.level || 0], + ['legacyCores', (meta) => meta.legacyCores || 0], + ['singularities', (meta) => (meta.stats && meta.stats.singularities) || 0], + ['tapes', (meta) => (meta.coldStorage && meta.coldStorage.tapes) || 0], +]; + +function buildBoards(limit) { + const players = []; + for (const row of getAllUsersWithSaves()) { + // The LIVE opt-out column (users.leaderboard_opt_out), the one v1.4 + // shipped for exactly this - never event_participation.opted_out, which + // is a join-time snapshot that never updates. + if (row.leaderboard_opt_out) continue; + if (!row.data) continue; + let meta; + try { + meta = JSON.parse(row.data).meta; + } catch { + continue; // a corrupt save is skipped, never fatal to the whole board + } + if (!meta) continue; + players.push({ + userId: row.id, + username: row.username, + avatarUrl: row.avatar_url, + badges: topBadges(meta.achievements), + meta, + }); + } + + const boards = {}; + for (const [key, read] of BOARDS) { + boards[key] = players + .map((p) => ({ + userId: p.userId, username: p.username, avatarUrl: p.avatarUrl, + badges: p.badges, value: read(p.meta), + })) + .filter((r) => r.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, limit); + } + + // The latest event's board comes from event_participation rather than + // saves - listLeaderboard already applies the same live opt-out filter and + // the same ranking the Event tab uses, so the two can never disagree. + const eventId = getLatestEventId(); + const badgesByUser = new Map(players.map((p) => [p.userId, p.badges])); + const avatarByUser = new Map(players.map((p) => [p.userId, p.avatarUrl])); + boards.latestEventRung = eventId + ? listLeaderboard(eventId, limit).map((r) => ({ + userId: r.userId, + username: r.username, + avatarUrl: avatarByUser.get(r.userId) || null, + badges: badgesByUser.get(r.userId) || [], + value: r.rungsClaimed, + })) + : []; + + return boards; +} + +export function getLeaderboards(now = Date.now()) { + const { data: config } = getConfig(); + if (cache && now - cache.generatedAt < config.social.leaderboardCacheMs) return cache; + cache = { generatedAt: now, boards: buildBoards(config.social.leaderboardLimit) }; + return cache; +} +``` + +Note this reads `getConfig()` (the admin baseline), not `getEffectiveConfig()`: the cache TTL and row limit are operational knobs, and letting an event overlay quietly change how a shared cross-user cache behaves would be surprising. + +- [ ] **Step 5: Add the route** to `server/routes/api.js`, in a new "Leaderboards (v1.5)" section: + +```js +router.get('/api/leaderboard', requireAuth, (req, res) => { + const { generatedAt, boards } = getLeaderboards(Date.now()); + res.json({ generatedAt, boards }); +}); +``` + +- [ ] **Step 6: Run to verify pass** — `npx vitest run tests/api.social.test.js` → PASS, then full `npm test`. + +- [ ] **Step 7: Commit** — `"Add global leaderboards service and GET /api/leaderboard"`. + +--- + +### Task 9: Client — Social tab shell and the Contracts section + +**Files:** +- Modify: `client/src/game/api.js`, `client/src/game/constants.js`, `client/src/game/data/tabs.js`, `client/src/RackStack.jsx` +- Create: `client/src/game/data/achievementIcons.js`, `client/src/game/components/SocialPanel.jsx`, `client/src/game/components/social/ContractsSection.jsx` + +**Interfaces:** +- Consumes: Tasks 3, 5, 6 (`contractsForState`, `contractProgress`, `ACHIEVEMENT_DEFS`), `GET /api/leaderboard` (Task 8). +- Produces: + - `api.js`: `fetchLeaderboard()` built on the module's existing private `request(path)` helper (the same one `fetchEvent()` uses), following its `{status, error}`-on-failure convention; `'claimContract'` and `'claimStreak'` added to `IMMEDIATE`. + - `constants.js`: `LEADERBOARD_REFRESH_THROTTLE_MS = 30000`. + - `tabs.js`: `{ id: 'social', label: 'Social', Icon: Users }` inserted before the `event` entry. + - `achievementIcons.js`: `ACHIEVEMENT_ICONS` — a `{ [iconName]: LucideComponent }` map covering every distinct `icon` string in `ACHIEVEMENT_DEFS`, plus a fallback. + - `SocialPanel.jsx`: ``. + - `ContractsSection.jsx`: ``. + +The Social tab is **always visible and never locked** — contracts and the streak work from level 0. It is therefore added to `TABS` normally and needs no filtering in `RackStack.jsx` (contrast the `event` tab). + +- [ ] **Step 1: Add `fetchLeaderboard` to `client/src/game/api.js`**, next to `fetchEvent`: + +```js +// GET /api/leaderboard -> { generatedAt, boards: { : [row] } }. +// Rows are already ranked, opt-out-filtered and capped server-side +// (server/leaderboardService.js) - the client renders them in order and +// never re-sorts. The payload is a shared server-side cache, so hammering +// this endpoint costs no extra work beyond the request itself; it is still +// throttled client-side by LEADERBOARD_REFRESH_THROTTLE_MS. +export function fetchLeaderboard() { + return request('/api/leaderboard'); +} +``` + +- [ ] **Step 2: Add both actions to `IMMEDIATE`** in `client/src/game/api.js`: + +```js + // Social (v1.5): both are reward claims exactly like claimGoal/ + // claimEventRung above, and both are gated on a UTC calendar-day key - + // so an action that sits queued across midnight is the difference between + // claimable and rejected. They go in from the start. + 'claimContract', 'claimStreak', +``` + +- [ ] **Step 3: Add the tab** to `client/src/game/data/tabs.js` — import `Users` from lucide-react and insert `{ id: 'social', label: 'Social', Icon: Users },` immediately before the `event` entry, with a comment noting it is never locked. + +- [ ] **Step 4: Create `client/src/game/data/achievementIcons.js`** + +```js +import { + RefreshCw, Sparkles, Gift, Archive, Layers, ChevronsUp, Cpu, + Gamepad2, Trophy, Crown, Flame, ClipboardCheck, ListChecks, Award, +} from 'lucide-react'; + +// Maps the icon NAME strings carried by shared/achievements.js's +// ACHIEVEMENT_DEFS to real components. The names live in shared/ (which +// must not import from client/), so this map is the one place the two sides +// meet. `Award` is the fallback for an unmapped name, so adding a def with a +// new icon degrades to a generic badge rather than crashing the panel. +export const ACHIEVEMENT_ICONS = { + RefreshCw, Sparkles, Gift, Archive, Layers, ChevronsUp, Cpu, + Gamepad2, Trophy, Crown, Flame, ClipboardCheck, ListChecks, +}; + +export function achievementIcon(name) { + return ACHIEVEMENT_ICONS[name] || Award; +} +``` + +- [ ] **Step 5: Create `ContractsSection.jsx`** — renders `contractsForState(meta)`, each row showing `def.desc(target, fmt)`, a progress bar from `contractProgress(def, meta, meta.contracts.baseline[def.metric], target)`, and a Claim button enabled only when `met && !claimed`. Header shows a countdown to the next UTC midnight. Reuse `theme.js` tokens and the card patterns from `GoalsPanel.jsx`; no new visual conventions. + +- [ ] **Step 6: Create `SocialPanel.jsx`** — the shell: a three-way inner toggle (Contracts / Leaderboard / Achievements) copying `ProfileView.jsx`'s toggle markup exactly, rendering `ContractsSection` now and the two Task 10 sections once they exist. Calls `onRefreshLeaderboard()` the first time the Leaderboard section is opened. + +- [ ] **Step 7: Wire into `RackStack.jsx`** — leaderboard state + a throttled `refreshLeaderboard()` mirroring the existing `refreshEventData()` pattern, a `claimContract(index)` dispatch one-liner, and `{activeTab === 'social' && }` in the render body. + +- [ ] **Step 8: Verify** — `cd client && npm run build` clean, plus a real-browser check against a throwaway server: the Social tab appears, three contracts render with correct targets, a met contract's Claim button pays out and disables, an unmet one stays disabled. + +- [ ] **Step 9: Commit** — `"Add Social tab with the daily contracts board"`. + +--- + +### Task 10: Client — Leaderboard and Achievements sections + +**Files:** +- Create: `client/src/game/components/social/LeaderboardSection.jsx`, `client/src/game/components/social/AchievementsSection.jsx` +- Modify: `client/src/game/components/SocialPanel.jsx` + +**Interfaces:** +- Consumes: `fetchLeaderboard` (Task 9), `ACHIEVEMENT_DEFS`/`achievementDef` (Task 5), `achievementIcon` (Task 9), the existing `setLeaderboardOptOut` from `api.js`. +- Produces: + - `` + - `` + +- [ ] **Step 1: Implement `LeaderboardSection.jsx`** — a board selector (All-time FLOPS / Level / Legacy Cores / Singularities / Tapes / Latest Event) over `boards`, ranked rows showing rank, avatar, username, formatted value via `fmt`, and up to three badge mini-icons via `achievementIcon(achievementDef(id).icon)` with the def's `name` as the `title`. The caller's own row is highlighted. Below the list, the opt-out checkbox — reuse `EventPanel.jsx`'s existing "Hide me" control markup and wire it to the same `onToggleOptOut`, since `PUT /api/me/leaderboard-opt-out` is authoritative for both boards. An empty board renders the same empty-state treatment `EventPanel.jsx` already uses. + +- [ ] **Step 2: Implement `AchievementsSection.jsx`** — the badge case: every `ACHIEVEMENT_DEFS` entry as a tile, unlocked ones lit with their tier colour and unlock date, locked ones dimmed showing name + `desc`. A `n / total` counter at the top. + +- [ ] **Step 3: Render both from `SocialPanel.jsx`.** + +- [ ] **Step 4: Verify** — `cd client && npm run build` clean, plus a real-browser check with two seeded users: both appear on the boards, the caller's row is highlighted, badges render, toggling opt-out removes the caller from the list on the next refresh, and the badge case shows locked and unlocked states correctly. + +- [ ] **Step 5: Commit** — `"Add leaderboard and achievements sections to the Social tab"`. + +--- + +### Task 11: Client — streak banner and achievement toast + +**Files:** +- Create: `client/src/game/components/StreakBanner.jsx` +- Modify: `client/src/RackStack.jsx` + +**Interfaces:** +- Consumes: `canClaimStreak`/`nextStreakCount`/`streakReward` (Task 4), `utcDateKey` (Task 1), `achievementDef` (Task 5), the `unlockedAchievements` field on state/action responses (Tasks 6–7). +- Produces: ``. + +- [ ] **Step 1: Implement `StreakBanner.jsx`** — sits in the sticky header beside `EventBanner`, following the same surge-banner markup. Shows 🔥 `Day N` and, when `canClaimStreak(streak, utcDateKey(serverTime))`, a Claim button previewing the reward from `streakReward(nextStreakCount(...), config, ctx)`. When already claimed today, it shows the current day and a muted "back tomorrow" line. Renders nothing at all only if `config` hasn't loaded yet. + +**Use `serverTime` from the state response, never `Date.now()`**, for the day key the banner displays — the server is authoritative for the boundary, and a client with a skewed clock must not be shown a Claim button the server will reject. + +- [ ] **Step 2: Wire the claim** — a `claimStreak()` dispatch one-liner in `RackStack.jsx`, and render `` in the header. + +- [ ] **Step 3: Add the achievement toast** — `unlockedAchievements` arrives on both the `GET /api/state` response and the `POST /api/actions` response. On a non-empty array, show a toast per id (name + icon, resolved via `achievementDef`/`achievementIcon`), reusing `AnomalyToast.jsx`'s presentation and auto-dismiss timing. Queue them if several land at once rather than stacking overlapping toasts. + +- [ ] **Step 4: Verify** — `cd client && npm run build` clean, plus a real-browser check: the banner shows day 1 claimable on a fresh user, claiming pays out and flips it to the claimed state, a reload keeps it claimed, and an achievement unlock pops a toast. + +- [ ] **Step 5: Commit** — `"Add streak banner and achievement unlock toasts"`. + +--- + +### Task 12: Docs, e2e, release prep + +**Files:** +- Create: `tests/e2e/smoke-v15.mjs` +- Modify: `README.md`, `CHANGELOG.md`, `package.json`, `client/package.json`, `Dockerfile` + +**Coverage checklist** (following `tests/e2e/smoke-v14.mjs`'s exact structure — spawned server against a scratch SQLite file, users/saves seeded directly through `server/db.js`, JWT cookies minted via `server/auth.js`, the *built* client driven with Playwright/Chromium for anything exercising a real client-side wrapper, PASS/FAIL per check, non-zero exit, cleanup on exit/SIGINT/SIGTERM): + +- Two users seeded on the same day get the **same three contract types**, with **different numeric targets** scaled to their own progress. +- A contract's target does **not** move when the player's output changes mid-day (buy racks, re-fetch state, assert the target is unchanged). +- Claiming a met contract pays wafers + tapes exactly once through the **real Claim button**; a second click is rejected. +- The streak banner pays on day 1 and refuses a second claim the same UTC day. +- An achievement unlocks automatically with **no payout** (assert wafers/xp/level/credits/tapes are all unchanged across the unlock) and appears in the badge case. +- `GET /api/leaderboard` ranks two seeded users correctly, and **opting out via the real checkbox removes the user from every board**. +- A player with Cold Storage locked receives only base-lane contracts (no dead contract). + +- [ ] **Step 1: Write + run the e2e suite** — all PASS. +- [ ] **Step 2: Full verification** — `npm test` green, `cd client && npm run build` clean, container build succeeds. +- [ ] **Step 3: Docs** — README gains a v1.5 Social & Retention section (contracts board, leaderboards + opt-out, achievements, daily streak, and the `social.*` tunables now on the Balancing tab). CHANGELOG gains a `## v1.5.0` entry. +- [ ] **Step 4: Version bump** — both `package.json`s to `1.5.0` and the Dockerfile's `org.opencontainers.image.version` label. +- [ ] **Step 5: Commit** — `"v1.5.0: Social & Retention - contracts, leaderboards, achievements, streak"` — **do not push or tag**; the owner handles the release (merge the PR, then tag `main`, never the branch, and push the tag). + +--- + +## Self-Review (completed) + +- **Spec coverage:** design §4.1 `daily.js` → Task 1. §4.2 contracts (defs, determinism, locked-lane substitution, snapshotting, rollover placement) → Tasks 3, 7. §4.3 streak → Tasks 4, 6, 11. §4.4 achievements incl. the two sweep sites → Tasks 5, 6, 7. §5 `social` config + the `contractFlopsMin` floor → Task 1. §6 state additions + migration → Task 2. §7 reducer actions + the `claimEventRung` change → Task 6. §8 leaderboards → Task 8. §9 client (tab, three sections, streak banner, `IMMEDIATE`, toast, no admin change needed) → Tasks 9, 10, 11. §10 testing → every task's test step plus Task 12's e2e. §11 rollout → Task 12. No gaps. +- **Placeholder scan:** no TBD/TODO. The two items left to implementer judgment are both bounded by asserted requirements: the exact achievement roster (Task 5 ships 19 defs, with the test asserting ≥15, unique ids, valid tiers, and no reward field) and the visual detail of the four new client components (bounded by "reuse `theme.js` tokens and the established card patterns; no new visual conventions" plus a real-browser verification step). +- **Type consistency:** `utcDateKey(ms)` / `daysBetweenDateKeys(a, b)` are called identically in Tasks 3, 4, 6, 11. `contractsForState(meta)` returns `{def, target, claimed, index}` in Task 3 and is destructured that way in Tasks 6 and 9. `contractProgress(def, meta, baseline, target)` takes the same four positional args in Tasks 3, 6, 9. `checkAchievements(state, config, now) -> string[]` has one signature across Tasks 5, 6, 7. `topBadges(achievements, limit)` is defined in Task 5 and used in Task 8. `streakReward(day, config, ctx)` matches across Tasks 4, 6, 11. Leaderboard row shape `{userId, username, avatarUrl, value, badges}` is identical in Tasks 8 and 10. +- **Known risk called out for review:** the achievement sweep in `applyAction` (Task 6) adds a `goalCtx` build to every successful action. `goalCtx` is already built per-action by `claimGoal`/`claimRepeatable`/`claimBlock`/`claimAnomaly`, so the added cost is bounded and on a path that already does this work — but a batch of up to 100 actions now builds up to 100 contexts. If the whole-branch review finds this material, the fix is to sweep once after the batch in `applyActions` rather than per action, at the cost of losing per-action `unlockedAchievements` attribution. From 3f609d7382da8d3c2f054422e07bdf2a855e5557 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:23:14 -0400 Subject: [PATCH 03/11] Add UTC day-cycle helpers and the social config section Co-Authored-By: Claude Opus 5 --- shared/configSchema.js | 47 ++++++++++++++++++++++++++++++++++++++ shared/daily.js | 38 ++++++++++++++++++++++++++++++ tests/configSchema.test.js | 20 ++++++++++++++++ tests/daily.test.js | 44 +++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 shared/daily.js create mode 100644 tests/daily.test.js diff --git a/shared/configSchema.js b/shared/configSchema.js index d65d291..6c13c5a 100644 --- a/shared/configSchema.js +++ b/shared/configSchema.js @@ -32,6 +32,30 @@ export const DEFAULT_CONFIG = { jobDurationIndexMs: 28800000, // 8h jobDurationDeepMs: 86400000, // 24h }, + // v1.5 Social & Retention. Every leaf here is admin-tunable via the + // Balancing tab (which is TUNABLES-driven, so it needs no dashboard change) + // and overlayable by a live event's modifiers - a "double streak rewards" + // weekend is an authoring exercise, not a code change. + social: { + contractFlopsSeconds: 600, + contractFlopsMin: 500, + contractMinigamesTarget: 3, + contractBlocksTarget: 4, + contractTapesBase: 15, + contractTapesPerLevel: 1, + contractWafersBase: 5, + contractWafersGrowth: 1.15, + contractRewardWafers: 6, + contractRewardTapes: 4, + contractRewardLevelScalePct: 0.05, + streakMaxDay: 7, + streakFlopsSeconds: 300, + streakWaferBase: 4, + streakWaferPerDay: 2, + streakDay7Tapes: 25, + leaderboardCacheMs: 60000, + leaderboardLimit: 50, + }, }; export const TUNABLES = [ @@ -116,6 +140,29 @@ export const TUNABLES = [ { path: 'batchQueue.jobDurationDefragMs', label: 'Job duration: Defrag Run (ms)', min: 60000, max: 604800000, integer: true }, { path: 'batchQueue.jobDurationIndexMs', label: 'Job duration: Index Rebuild (ms)', min: 60000, max: 604800000, integer: true }, { path: 'batchQueue.jobDurationDeepMs', label: 'Job duration: Deep Archive Scrub (ms)', min: 60000, max: 604800000, integer: true }, + + { path: 'social.contractFlopsSeconds', label: 'Contract FLOPS target (seconds of output)', min: 1, max: 86400, integer: true }, + // A floor, never a cap: a player at zero output (a fresh save, or the + // instant after a Migrate) would otherwise get a target of 0, which is + // already met, and the contract would auto-complete for free. Set this to 0 + // to deliberately restore that behaviour. + { path: 'social.contractFlopsMin', label: 'Contract FLOPS target floor', min: 0, max: 1e12, integer: false }, + { path: 'social.contractMinigamesTarget', label: 'Contract target: minigames won', min: 1, max: 100, integer: true }, + { path: 'social.contractBlocksTarget', label: 'Contract target: blocks claimed', min: 1, max: 100, integer: true }, + { path: 'social.contractTapesBase', label: 'Contract target: tapes base', min: 1, max: 10000, integer: true }, + { path: 'social.contractTapesPerLevel', label: 'Contract target: tapes per level', min: 0, max: 1000, integer: false }, + { path: 'social.contractWafersBase', label: 'Contract target: wafers base', min: 1, max: 10000, integer: true }, + { path: 'social.contractWafersGrowth', label: 'Contract target: wafers growth per level', min: 1, max: 3, integer: false }, + { path: 'social.contractRewardWafers', label: 'Contract reward: wafers', min: 0, max: 10000, integer: true }, + { path: 'social.contractRewardTapes', label: 'Contract reward: tapes', min: 0, max: 10000, integer: true }, + { path: 'social.contractRewardLevelScalePct', label: 'Contract reward scaling per level', min: 0, max: 1, integer: false }, + { path: 'social.streakMaxDay', label: 'Streak length (days)', min: 1, max: 30, integer: true }, + { path: 'social.streakFlopsSeconds', label: 'Streak FLOPS reward (seconds of output)', min: 0, max: 86400, integer: true }, + { path: 'social.streakWaferBase', label: 'Streak wafer reward base', min: 0, max: 10000, integer: true }, + { path: 'social.streakWaferPerDay', label: 'Streak wafer reward per day', min: 0, max: 1000, integer: true }, + { path: 'social.streakDay7Tapes', label: 'Streak final-day tape reward', min: 0, max: 10000, integer: true }, + { path: 'social.leaderboardCacheMs', label: 'Leaderboard cache TTL (ms)', min: 0, max: 3600000, integer: true }, + { path: 'social.leaderboardLimit', label: 'Leaderboard rows per board', min: 1, max: 500, integer: true }, ]; export function getAtPath(obj, path) { diff --git a/shared/daily.js b/shared/daily.js new file mode 100644 index 0000000..be5fd3d --- /dev/null +++ b/shared/daily.js @@ -0,0 +1,38 @@ +// The UTC day boundary, owned in exactly one place so the contracts board +// (shared/contracts.js) and the daily streak (shared/streak.js) can never +// drift apart on when "tomorrow" starts. All arithmetic goes through +// Date.UTC, so local time and DST are structurally irrelevant. + +const DATE_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function utcDateKey(ms) { + if (typeof ms !== 'number' || !Number.isFinite(ms)) return null; + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// Parses a 'YYYY-MM-DD' key to a UTC epoch, rejecting anything that isn't +// exactly that shape AND that doesn't round-trip - so '2026-02-31' and +// '2026-13-01', which Date.UTC would silently roll over into a different real +// date, are rejected rather than quietly accepted as some other day. +function parseDateKey(key) { + if (typeof key !== 'string') return null; + const m = DATE_KEY_RE.exec(key); + if (!m) return null; + const [, y, mo, d] = m; + const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d)); + if (!Number.isFinite(ms)) return null; + return utcDateKey(ms) === key ? ms : null; +} + +const DAY_MS = 24 * 3600 * 1000; + +export function daysBetweenDateKeys(a, b) { + const from = parseDateKey(a); + const to = parseDateKey(b); + if (from === null || to === null) return null; + return Math.round((to - from) / DAY_MS); +} diff --git a/tests/configSchema.test.js b/tests/configSchema.test.js index 19a887c..6f7eea3 100644 --- a/tests/configSchema.test.js +++ b/tests/configSchema.test.js @@ -50,3 +50,23 @@ describe('configSchema', () => { expect(v.ok).toBe(true); }); }); + +describe('social config section', () => { + it('every social leaf has a matching TUNABLES entry', () => { + const paths = new Set(TUNABLES.map((t) => t.path)); + for (const key of Object.keys(DEFAULT_CONFIG.social)) { + expect(paths.has(`social.${key}`), `social.${key}`).toBe(true); + } + }); + it('DEFAULT_CONFIG still validates with the new section', () => { + expect(validateConfig(DEFAULT_CONFIG)).toEqual({ ok: true }); + }); + it('every social default sits inside its declared range', () => { + for (const t of TUNABLES.filter((row) => row.path.startsWith('social.'))) { + const v = DEFAULT_CONFIG.social[t.path.slice('social.'.length)]; + expect(v, t.path).toBeGreaterThanOrEqual(t.min); + expect(v, t.path).toBeLessThanOrEqual(t.max); + if (t.integer) expect(Number.isInteger(v), t.path).toBe(true); + } + }); +}); diff --git a/tests/daily.test.js b/tests/daily.test.js new file mode 100644 index 0000000..a2528ed --- /dev/null +++ b/tests/daily.test.js @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { utcDateKey, daysBetweenDateKeys } from '../shared/daily.js'; + +describe('utcDateKey', () => { + it('formats the UTC calendar day', () => { + expect(utcDateKey(Date.UTC(2026, 6, 31, 12, 0, 0))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 0, 1, 0, 0, 0))).toBe('2026-01-01'); + }); + it('zero-pads month and day', () => { + expect(utcDateKey(Date.UTC(2026, 8, 5, 23, 59, 59))).toBe('2026-09-05'); + }); + it('uses UTC, not local time, at both edges of the day', () => { + expect(utcDateKey(Date.UTC(2026, 6, 31, 0, 0, 0))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 6, 31, 23, 59, 59, 999))).toBe('2026-07-31'); + expect(utcDateKey(Date.UTC(2026, 7, 1, 0, 0, 0))).toBe('2026-08-01'); + }); + it('returns null for non-finite input', () => { + for (const bad of [NaN, Infinity, null, undefined, 'x']) { + expect(utcDateKey(bad)).toBeNull(); + } + }); +}); + +describe('daysBetweenDateKeys', () => { + it('counts whole days forward', () => { + expect(daysBetweenDateKeys('2026-07-30', '2026-07-31')).toBe(1); + expect(daysBetweenDateKeys('2026-07-31', '2026-07-31')).toBe(0); + expect(daysBetweenDateKeys('2026-07-25', '2026-07-31')).toBe(6); + }); + it('counts backwards as negative', () => { + expect(daysBetweenDateKeys('2026-07-31', '2026-07-30')).toBe(-1); + }); + it('crosses month and year boundaries', () => { + expect(daysBetweenDateKeys('2026-07-31', '2026-08-01')).toBe(1); + expect(daysBetweenDateKeys('2026-12-31', '2027-01-01')).toBe(1); + expect(daysBetweenDateKeys('2028-02-28', '2028-03-01')).toBe(2); // leap year + }); + it('returns null for malformed keys instead of NaN', () => { + for (const bad of ['', 'nope', '2026-13-01', '2026-7-1', null, undefined, 42, '__proto__']) { + expect(daysBetweenDateKeys(bad, '2026-07-31')).toBeNull(); + expect(daysBetweenDateKeys('2026-07-31', bad)).toBeNull(); + } + }); +}); From 25e49798eeeb07e04f54a3a4c362fe574d3b8a8b Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:24:02 -0400 Subject: [PATCH 04/11] Add contracts, achievements and streak to canonical state Co-Authored-By: Claude Opus 5 --- shared/state.js | 42 +++++++++++++++++++++++ tests/state.social.test.js | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/state.social.test.js diff --git a/shared/state.js b/shared/state.js index 900c639..afa01c6 100644 --- a/shared/state.js +++ b/shared/state.js @@ -32,7 +32,24 @@ 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, }, + // 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 + // dailyContractTypes(), which is what guarantees the client and server + // agree on them without a sync step. `targets` and `baseline` ARE + // stored, because both are snapshotted at rollover: recomputing a + // rate-scaled target on every read would move the goalposts every time + // the player bought a rack. + contracts: { + dateKey: null, + targets: [0, 0, 0], + baseline: {}, + claimed: [false, false, false], + }, + // Pure prestige - no payout, ever (spec §6.3). { [id]: unlockedAtMs }. + achievements: {}, + streak: { count: 0, lastClaimDate: null }, eventProgress: null, // Live Events (v1.4): personal windows that were force-ended early by // a NEW event going active (spec §5.2) but whose 48h claim grace @@ -123,6 +140,31 @@ export function migrateSave(raw) { upgrades: { ...base.meta.coldStorage.upgrades, ...(srcCS.upgrades || {}) }, }; + // v1.5: every field below is defaulted AND shape-checked. A corrupt or + // hand-edited save must never hand a non-array to claimContract's + // validIndex path, or a non-object to the baseline lookup - same reasoning + // as pendingEventClaims' explicit array pinning above. + const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + const srcContracts = isPlainObject(srcMeta.contracts) ? srcMeta.contracts : {}; + const padTo3 = (arr, fill) => { + const list = Array.isArray(arr) ? arr.slice(0, 3) : []; + while (list.length < 3) list.push(fill); + return list; + }; + meta.contracts = { + dateKey: typeof srcContracts.dateKey === 'string' ? srcContracts.dateKey : null, + targets: padTo3(srcContracts.targets, 0) + .map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : 0)), + baseline: isPlainObject(srcContracts.baseline) ? { ...srcContracts.baseline } : {}, + claimed: padTo3(srcContracts.claimed, false).map((b) => b === true), + }; + meta.achievements = isPlainObject(srcMeta.achievements) ? { ...srcMeta.achievements } : {}; + const srcStreak = isPlainObject(srcMeta.streak) ? srcMeta.streak : {}; + meta.streak = { + count: typeof srcStreak.count === 'number' && Number.isFinite(srcStreak.count) ? srcStreak.count : 0, + lastClaimDate: typeof srcStreak.lastClaimDate === 'string' ? srcStreak.lastClaimDate : null, + }; + const server = { ...base.server, ...srcServer, diff --git a/tests/state.social.test.js b/tests/state.social.test.js new file mode 100644 index 0000000..1b42eed --- /dev/null +++ b/tests/state.social.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { initialState, migrateSave } from '../shared/state.js'; + +describe('v1.5 state additions', () => { + it('initialState seeds contracts, achievements, streak and the new stats', () => { + const s = initialState(); + expect(s.meta.contracts).toEqual({ + dateKey: null, targets: [0, 0, 0], baseline: {}, claimed: [false, false, false], + }); + expect(s.meta.achievements).toEqual({}); + expect(s.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + expect(s.meta.stats.contractsCompletedLifetime).toBe(0); + expect(s.meta.stats.bestStreak).toBe(0); + expect(s.meta.stats.eventTopRungs).toBe(0); + }); + + it('migrateSave defaults the v1.5 fields on a pre-v1.5 save without losing data', () => { + const preV15 = { + run: { credits: 5 }, + meta: { wafers: 3, level: 7, coldStorage: { tapes: 99 }, eventProgress: null }, + }; + const s = migrateSave(preV15); + expect(s.meta.contracts.dateKey).toBeNull(); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + expect(s.meta.achievements).toEqual({}); + expect(s.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + expect(s.meta.stats.contractsCompletedLifetime).toBe(0); + // existing data preserved + expect(s.meta.wafers).toBe(3); + expect(s.meta.level).toBe(7); + expect(s.meta.coldStorage.tapes).toBe(99); + }); + + it('migrateSave preserves in-flight v1.5 progress', () => { + const s = initialState(); + s.meta.contracts = { + dateKey: '2026-07-31', targets: [100, 3, 4], + baseline: { lifetimeFlopsAllTime: 50 }, claimed: [true, false, false], + }; + s.meta.achievements = { first_migrate: 1234 }; + s.meta.streak = { count: 4, lastClaimDate: '2026-07-31' }; + const out = migrateSave(s); + expect(out.meta.contracts).toEqual(s.meta.contracts); + expect(out.meta.achievements).toEqual({ first_migrate: 1234 }); + expect(out.meta.streak).toEqual({ count: 4, lastClaimDate: '2026-07-31' }); + }); + + it('migrateSave repairs corrupt/hand-edited shapes rather than passing them through', () => { + const bad = migrateSave({ + meta: { + contracts: { dateKey: 5, targets: 'nope', baseline: null, claimed: [true] }, + achievements: [1, 2, 3], + streak: 'nope', + }, + }); + expect(bad.meta.contracts.dateKey).toBeNull(); // non-string key discarded + expect(bad.meta.contracts.targets).toEqual([0, 0, 0]); // non-array replaced + expect(bad.meta.contracts.baseline).toEqual({}); // null replaced + expect(bad.meta.contracts.claimed).toEqual([true, false, false]); // padded to 3 + expect(bad.meta.achievements).toEqual({}); // array replaced + expect(bad.meta.streak).toEqual({ count: 0, lastClaimDate: null }); + }); + + it('is idempotent', () => { + const once = migrateSave(initialState()); + expect(migrateSave(once)).toEqual(once); + }); +}); From 183fc33200a14d5c2b2ba88199d102fd54da4cb5 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:25:21 -0400 Subject: [PATCH 05/11] Add deterministic daily contracts board Co-Authored-By: Claude Opus 5 --- shared/contracts.js | 210 ++++++++++++++++++++++++++++++++++++++++ tests/contracts.test.js | 165 +++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 shared/contracts.js create mode 100644 tests/contracts.test.js diff --git a/shared/contracts.js b/shared/contracts.js new file mode 100644 index 0000000..86a139c --- /dev/null +++ b/shared/contracts.js @@ -0,0 +1,210 @@ +// Daily contracts board (v1.5, spec §6.1 + design §4.2). +// +// Three contracts a day, rotating at midnight UTC, generated +// DETERMINISTICALLY from the date: every player gets the same three contract +// TYPES on the same day, while the numeric targets scale to each player's own +// progress. That determinism is why the type ids aren't stored in the save - +// they're re-derived from `meta.contracts.dateKey`, so the client and the +// server can never disagree about what today's contracts are. + +import { utcDateKey } from './daily.js'; +import { goalCtx } from './goals.js'; + +// Every metric here is a MONOTONIC lifetime counter on meta.stats, so a delta +// against a snapshotted baseline is always well-defined and can never go +// backwards. `coldStorage.tapes` (a spendable balance) is deliberately NOT +// usable for this reason - `tapesEarnedLifetime` is its monotonic twin. +// +// `lane` gates availability: the three 'cold' defs are impossible for a player +// who hasn't unlocked Cold Storage (rack tier 5), so dailyContractTypes +// substitutes base-lane defs for them rather than handing such a player two +// dead contracts. There must always be at least three base-lane defs for that +// substitution to have enough to draw from - tests/contracts.test.js asserts +// exactly this. +export const CONTRACT_DEFS = [ + { + id: 'c_flops', metric: 'lifetimeFlopsAllTime', lane: 'base', + desc: (target, fmt) => `Earn ${fmt(target)} FLOPS`, + target: (ctx, config) => Math.max( + ctx.totalOutputPerSec * config.social.contractFlopsSeconds, + config.social.contractFlopsMin, + ), + }, + { + id: 'c_minigames', metric: 'minigamesWon', lane: 'base', + desc: (target) => `Win ${target} minigame${target === 1 ? '' : 's'}`, + target: (ctx, config) => config.social.contractMinigamesTarget, + }, + { + id: 'c_wafers', metric: 'totalWafersEarned', lane: 'base', + desc: (target, fmt) => `Earn ${fmt(target)} Wafers`, + target: (ctx, config) => Math.round( + config.social.contractWafersBase + * Math.pow(config.social.contractWafersGrowth, ctx.meta.level || 0), + ), + }, + { + id: 'c_blocks', metric: 'blocksClaimedLifetime', lane: 'cold', + desc: (target) => `Claim ${target} Cold Storage block${target === 1 ? '' : 's'}`, + target: (ctx, config) => config.social.contractBlocksTarget, + }, + { + id: 'c_tapes', metric: 'tapesEarnedLifetime', lane: 'cold', + desc: (target, fmt) => `Earn ${fmt(target)} Tapes`, + target: (ctx, config) => Math.round( + config.social.contractTapesBase + + config.social.contractTapesPerLevel * (ctx.meta.level || 0), + ), + }, + { + id: 'c_jobs', metric: 'jobsCompletedLifetime', lane: 'cold', + desc: () => 'Complete a Cold Storage job', + target: () => 1, + }, +]; + +/** `.find()` lookup - `id` may come from a save or a payload, so it is never used as a bare key. */ +export function contractDef(id) { + if (typeof id !== 'string' || id === '') return null; + return CONTRACT_DEFS.find((d) => d.id === id) || null; +} + +// FNV-1a over the date key, then mulberry32. Both are tiny, well-known and +// stable across engines - the requirement is only that every player's client +// and the server derive the SAME shuffle from the same date, not that the bits +// are cryptographically good. +function hashSeed(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The day's three contract type ids, a pure function of (dateKey, + * coldUnlocked). Fisher-Yates over CONTRACT_DEFS seeded by the date, take + * three. When Cold Storage is locked, each 'cold' pick is replaced by the next + * unused 'base' id from the SAME shuffled order - so two locked players still + * match each other exactly, and the result is stable across calls. Returns [] + * for a malformed key rather than throwing. + */ +export function dailyContractTypes(dateKey, coldUnlocked) { + if (typeof dateKey !== 'string' || !DATE_KEY_RE.test(dateKey)) return []; + + const rng = mulberry32(hashSeed(dateKey)); + const order = CONTRACT_DEFS.map((d) => d.id); + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + + const picked = []; + const used = new Set(); + const takeBase = () => order.find((id) => !used.has(id) && contractDef(id).lane === 'base'); + + for (const id of order) { + if (picked.length === 3) break; + if (used.has(id)) continue; + let chosen = id; + if (!coldUnlocked && contractDef(id).lane === 'cold') { + chosen = takeBase(); + if (!chosen) continue; // no base def left to substitute; skip this pick + } + used.add(chosen); + picked.push(chosen); + } + return picked; +} + +export function contractProgress(def, meta, baseline, target) { + const stats = (meta && meta.stats) || {}; + const raw = Object.prototype.hasOwnProperty.call(stats, def.metric) ? stats[def.metric] : 0; + const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; + const base = typeof baseline === 'number' && Number.isFinite(baseline) ? baseline : 0; + const current = Math.max(0, value - base); + return { current, target, met: current >= target }; +} + +/** Cold Storage unlocks with the Server Room (rack tier index 4) - the same gate RackStack.jsx uses. */ +function isColdUnlocked(state) { + const t = state.run && state.run.tiers && state.run.tiers[4]; + return !!(t && t.owned >= 1); +} + +/** + * Rolls the board over to `now`'s UTC day if it isn't already there. + * Idempotent - returns false and touches nothing when the stored dateKey is + * already today's. Snapshots BOTH the targets and the per-metric baselines + * (see design §4.2.1: a rate-scaled target recomputed on every read would + * recede as fast as the player approached it). + * + * Mutates `state` in place, matching the scheduleAnomaly/joinEventIfEligible + * convention on the server's load path. + */ +export function rolloverContracts(state, config, now) { + const today = utcDateKey(now); + if (today === null) return false; + if (state.meta.contracts && state.meta.contracts.dateKey === today) return false; + + const ctx = goalCtx(state, config, now); + const types = dailyContractTypes(today, isColdUnlocked(state)); + const baseline = {}; + const targets = []; + for (const id of types) { + const def = contractDef(id); + const raw = state.meta.stats[def.metric]; + baseline[def.metric] = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; + targets.push(def.target(ctx, config)); + } + + state.meta.contracts = { + dateKey: today, + targets, + baseline, + claimed: [false, false, false], + }; + return true; +} + +/** + * The three resolved contracts for the CURRENTLY STORED dateKey - the single + * place both the reducer and the client turn `meta.contracts` into something + * with defs attached, so the two can't disagree. Returns [] before the first + * rollover. + * + * `coldUnlocked` is re-derived from the stored BASELINE's metrics rather than + * from live run state, so the day's snapshot stays stable even if the player + * unlocks Cold Storage mid-day. (A board that drew three base-lane types + * anyway resolves identically either way, since substitution is a no-op when + * there's nothing cold to substitute.) + */ +export function contractsForState(meta) { + const c = meta && meta.contracts; + if (!c || typeof c.dateKey !== 'string') return []; + const baseline = c.baseline || {}; + const hadCold = CONTRACT_DEFS.some( + (d) => d.lane === 'cold' && Object.prototype.hasOwnProperty.call(baseline, d.metric), + ); + const types = dailyContractTypes(c.dateKey, hadCold); + return types.map((id, index) => ({ + def: contractDef(id), + target: c.targets[index], + claimed: c.claimed[index] === true, + index, + })); +} diff --git a/tests/contracts.test.js b/tests/contracts.test.js new file mode 100644 index 0000000..d58473f --- /dev/null +++ b/tests/contracts.test.js @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { + CONTRACT_DEFS, contractDef, dailyContractTypes, + contractProgress, rolloverContracts, contractsForState, +} from '../shared/contracts.js'; + +const NOW = Date.UTC(2026, 6, 31, 12, 0, 0); // 2026-07-31 + +describe('CONTRACT_DEFS', () => { + it('has six defs with unique ids and a valid lane', () => { + expect(CONTRACT_DEFS).toHaveLength(6); + const ids = CONTRACT_DEFS.map((d) => d.id); + expect(new Set(ids).size).toBe(6); + for (const d of CONTRACT_DEFS) { + expect(['base', 'cold']).toContain(d.lane); + expect(typeof d.metric).toBe('string'); + expect(typeof d.desc).toBe('function'); + expect(typeof d.target).toBe('function'); + } + }); + it('has exactly three base-lane defs, so substitution always has enough to draw from', () => { + expect(CONTRACT_DEFS.filter((d) => d.lane === 'base')).toHaveLength(3); + }); + it('contractDef resolves by id and fails closed on prototype keys', () => { + expect(contractDef('c_flops').id).toBe('c_flops'); + for (const bad of ['nope', '__proto__', 'toString', 'constructor', '', null, 42]) { + expect(contractDef(bad)).toBeNull(); + } + }); +}); + +describe('dailyContractTypes', () => { + it('is deterministic: the same date gives the same three types every call', () => { + const a = dailyContractTypes('2026-07-31', true); + for (let i = 0; i < 20; i++) expect(dailyContractTypes('2026-07-31', true)).toEqual(a); + }); + it('returns three distinct known ids', () => { + for (const key of ['2026-01-01', '2026-07-31', '2026-12-25', '2027-03-14']) { + const picked = dailyContractTypes(key, true); + expect(picked).toHaveLength(3); + expect(new Set(picked).size).toBe(3); + for (const id of picked) expect(contractDef(id)).not.toBeNull(); + } + }); + it('varies across dates', () => { + const seen = new Set(); + for (let d = 1; d <= 28; d++) { + seen.add(dailyContractTypes(`2026-02-${String(d).padStart(2, '0')}`, true).join(',')); + } + expect(seen.size).toBeGreaterThan(3); + }); + it('substitutes base-lane defs for locked cold-lane picks, deterministically', () => { + for (const key of ['2026-01-01', '2026-07-31', '2026-12-25']) { + const locked = dailyContractTypes(key, false); + expect(locked).toHaveLength(3); + expect(new Set(locked).size).toBe(3); + for (const id of locked) expect(contractDef(id).lane).toBe('base'); + expect(dailyContractTypes(key, false)).toEqual(locked); // stable + } + }); + it('returns [] for a malformed date key rather than throwing', () => { + for (const bad of ['', 'nope', null, undefined, 42, '__proto__']) { + expect(dailyContractTypes(bad, true)).toEqual([]); + } + }); +}); + +describe('contractProgress', () => { + const def = contractDef('c_minigames'); + const meta = { stats: { minigamesWon: 9 } }; + it('measures the delta since baseline, floored at zero', () => { + expect(contractProgress(def, meta, 4, 3)).toEqual({ current: 5, target: 3, met: true }); + expect(contractProgress(def, meta, 8, 3)).toEqual({ current: 1, target: 3, met: false }); + expect(contractProgress(def, meta, 20, 3)).toEqual({ current: 0, target: 3, met: false }); + }); + it('treats a missing baseline as zero', () => { + expect(contractProgress(def, meta, undefined, 3).current).toBe(9); + }); +}); + +describe('rolloverContracts', () => { + it('populates dateKey, targets, baselines and clears claimed on a fresh state', () => { + const s = initialState(); + s.meta.stats.minigamesWon = 11; + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW)).toBe(true); + expect(s.meta.contracts.dateKey).toBe('2026-07-31'); + expect(s.meta.contracts.targets).toHaveLength(3); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + // Baselines snapshot the CURRENT counters, so pre-existing progress never + // counts toward today's contracts. + const types = dailyContractTypes('2026-07-31', false); + for (const id of types) { + const def = contractDef(id); + expect(s.meta.contracts.baseline[def.metric]).toBe(s.meta.stats[def.metric] ?? 0); + } + }); + + it('is a no-op within the same UTC day, preserving snapshotted targets and claims', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [true, false, false]; + const before = structuredClone(s.meta.contracts); + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW + 3600 * 1000)).toBe(false); + expect(s.meta.contracts).toEqual(before); + }); + + it('rolls over on the next UTC day and resets claims', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [true, true, true]; + expect(rolloverContracts(s, DEFAULT_CONFIG, NOW + 24 * 3600 * 1000)).toBe(true); + expect(s.meta.contracts.dateKey).toBe('2026-08-01'); + expect(s.meta.contracts.claimed).toEqual([false, false, false]); + }); + + it('snapshots the FLOPS target so buying racks mid-day cannot move it', () => { + const s = initialState(); + s.run.tiers[0].owned = 10; + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const snapshot = [...s.meta.contracts.targets]; + s.run.tiers[0].owned = 10000; // output explodes + rolloverContracts(s, DEFAULT_CONFIG, NOW + 3600 * 1000); + expect(s.meta.contracts.targets).toEqual(snapshot); + }); + + it('never produces a zero FLOPS target for a zero-output player', () => { + const s = initialState(); // no racks owned -> totalOutputPerSec === 0 + rolloverContracts(s, DEFAULT_CONFIG, NOW); + for (const c of contractsForState(s.meta)) { + expect(c.target).toBeGreaterThan(0); + } + }); +}); + +describe('contractsForState', () => { + it('returns [] before the first rollover', () => { + expect(contractsForState(initialState().meta)).toEqual([]); + }); + it('resolves three defs with their snapshotted targets and claim flags', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + s.meta.contracts.claimed = [false, true, false]; + const resolved = contractsForState(s.meta); + expect(resolved).toHaveLength(3); + expect(resolved.map((c) => c.index)).toEqual([0, 1, 2]); + expect(resolved.map((c) => c.claimed)).toEqual([false, true, false]); + expect(resolved.map((c) => c.target)).toEqual(s.meta.contracts.targets); + }); + it('resolves the same three defs a Cold-Storage-unlocked player was given', () => { + const s = initialState(); + s.run.tiers[4].owned = 1; // Server Room -> Cold Storage unlocked + rolloverContracts(s, DEFAULT_CONFIG, NOW); + expect(contractsForState(s.meta).map((c) => c.def.id)) + .toEqual(dailyContractTypes('2026-07-31', true)); + }); + it('keeps the locked-player board stable even after Cold Storage unlocks mid-day', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const before = contractsForState(s.meta).map((c) => c.def.id); + s.run.tiers[4].owned = 1; // unlocks, but today's snapshot must not shift + expect(contractsForState(s.meta).map((c) => c.def.id)).toEqual(before); + }); +}); From afd1708dd9b6a245bd46479405a5c1eeca629623 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:26:59 -0400 Subject: [PATCH 06/11] Add daily streak rules and achievement definitions with the unlock sweep Co-Authored-By: Claude Opus 5 --- shared/achievements.js | 95 +++++++++++++++++++++++++++++ shared/streak.js | 49 +++++++++++++++ tests/achievements.test.js | 118 +++++++++++++++++++++++++++++++++++++ tests/streak.test.js | 71 ++++++++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 shared/achievements.js create mode 100644 shared/streak.js create mode 100644 tests/achievements.test.js create mode 100644 tests/streak.test.js diff --git a/shared/achievements.js b/shared/achievements.js new file mode 100644 index 0000000..a73d188 --- /dev/null +++ b/shared/achievements.js @@ -0,0 +1,95 @@ +// Achievements & badges (v1.5, spec §6.3 + design §4.4). +// +// Distinct from goals: NO PAYOUT, pure prestige. Unlocked automatically +// whenever conditions are met - never claimed - so there is no reducer action +// here and no reward field on any def. Displayed as the badge case in the +// Social tab and as mini-icons on leaderboard rows. +// +// Conditions are written against the SAME ctx object the goal system uses +// (goalCtx from shared/goals.js), so an achievement condition and a goal +// condition are interchangeable in style and neither can drift from the +// other's view of the world. + +import { goalCtx, GOAL_DEFS } from './goals.js'; + +const st = (ctx, key) => { + const v = ctx.meta.stats[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +}; + +// `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. +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: '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: '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]) }, +]; + +const TIER_ORDER = { gold: 0, silver: 1, bronze: 2 }; + +export function achievementDef(id) { + if (typeof id !== 'string' || id === '') return null; + return ACHIEVEMENT_DEFS.find((d) => d.id === id) || null; +} + +/** + * 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 + * are never re-stamped. Mutates `state.meta.achievements` in place - callers on + * 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. + */ +export function checkAchievements(state, config, now) { + const ctx = goalCtx(state, config, now); + const held = state.meta.achievements; + const unlocked = []; + for (const def of ACHIEVEMENT_DEFS) { + if (Object.prototype.hasOwnProperty.call(held, def.id)) continue; + let met = false; + try { + met = !!def.condition(ctx); + } catch { + met = false; + } + if (met) { + held[def.id] = now; + unlocked.push(def.id); + } + } + return unlocked; +} + +/** + * At most `limit` unlocked ids, gold first, then ACHIEVEMENT_DEFS order. + * Relies on Array.prototype.sort being stable (guaranteed since ES2019), + * which is what preserves definition order within a tier. + */ +export function topBadges(achievements, limit = 3) { + if (!achievements || typeof achievements !== 'object' || Array.isArray(achievements)) return []; + return ACHIEVEMENT_DEFS + .filter((d) => Object.prototype.hasOwnProperty.call(achievements, d.id)) + .sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]) + .slice(0, limit) + .map((d) => d.id); +} diff --git a/shared/streak.js b/shared/streak.js new file mode 100644 index 0000000..0378f85 --- /dev/null +++ b/shared/streak.js @@ -0,0 +1,49 @@ +// Daily streak (v1.5, spec §6.4 + design §4.3). +// +// A 7-day escalating claim that stays at the day-7 reward while unbroken. A +// fully missed UTC calendar day resets to day 1 - the same day boundary the +// contracts board uses (shared/daily.js), deliberately, so a player who shows +// up once a day satisfies both at once. Rewards are a bonus, never a content +// gate. + +import { daysBetweenDateKeys } from './daily.js'; + +export function canClaimStreak(streak, today) { + return !streak || streak.lastClaimDate !== today; +} + +export function nextStreakCount(streak, today, config) { + const max = config.social.streakMaxDay; + const last = streak && streak.lastClaimDate; + const gap = daysBetweenDateKeys(last, today); + // gap === null covers "never claimed" and a malformed stored key; any gap + // other than exactly 1 covers both a missed day and a stored date at or + // after today (clock skew / hand-edited save). + if (gap === null || gap !== 1) return 1; + const count = typeof streak.count === 'number' && Number.isFinite(streak.count) ? streak.count : 0; + return Math.min(count + 1, max); +} + +/** + * Reward for reaching `day`. Days 1..3 pay FLOPS scaled to the player's own + * output (so it stays meaningful across the whole progression curve), days + * 4..(max-1) pay wafers, and the final day pays tapes on top of the wafer + * amount. Every branch returns all three keys so callers never have to test + * for absence. + */ +export function streakReward(day, config, ctx) { + const s = config.social; + const out = { flops: 0, wafers: 0, tapes: 0 }; + const clamped = Math.max(1, Math.min(day, s.streakMaxDay)); + const waferAmount = s.streakWaferBase + s.streakWaferPerDay * clamped; + + if (clamped >= s.streakMaxDay) { + out.tapes = s.streakDay7Tapes; + out.wafers = waferAmount; + } else if (clamped > 3) { + out.wafers = waferAmount; + } else { + out.flops = Math.max(0, ctx.totalOutputPerSec * s.streakFlopsSeconds * clamped); + } + return out; +} diff --git a/tests/achievements.test.js b/tests/achievements.test.js new file mode 100644 index 0000000..2b465d9 --- /dev/null +++ b/tests/achievements.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { + ACHIEVEMENT_DEFS, achievementDef, checkAchievements, topBadges, +} from '../shared/achievements.js'; + +const NOW = 1_000_000; + +describe('ACHIEVEMENT_DEFS', () => { + it('has unique ids, a valid tier, and a complete shape', () => { + const ids = ACHIEVEMENT_DEFS.map((d) => d.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.length).toBeGreaterThanOrEqual(15); + for (const d of ACHIEVEMENT_DEFS) { + expect(typeof d.name).toBe('string'); + expect(d.name.length).toBeGreaterThan(0); + 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'); + } + }); + it('carries no reward field of any kind - achievements are pure prestige', () => { + for (const d of ACHIEVEMENT_DEFS) { + expect(d.reward).toBeUndefined(); + expect(d.wafers).toBeUndefined(); + expect(d.xp).toBeUndefined(); + } + }); + it('achievementDef fails closed on prototype keys', () => { + for (const bad of ['nope', '__proto__', 'toString', 'constructor', '', null, 42]) { + expect(achievementDef(bad)).toBeNull(); + } + }); +}); + +describe('checkAchievements', () => { + it('unlocks nothing on a fresh state', () => { + const s = initialState(); + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toEqual([]); + expect(s.meta.achievements).toEqual({}); + }); + + it('unlocks on a met condition and stamps the unlock time', () => { + const s = initialState(); + s.meta.stats.singularities = 1; + const unlocked = checkAchievements(s, DEFAULT_CONFIG, NOW); + expect(unlocked).toContain('first_singularity'); + expect(s.meta.achievements.first_singularity).toBe(NOW); + }); + + it('never re-unlocks or re-stamps an already-held achievement', () => { + const s = initialState(); + s.meta.stats.singularities = 1; + checkAchievements(s, DEFAULT_CONFIG, NOW); + const second = checkAchievements(s, DEFAULT_CONFIG, NOW + 5000); + expect(second).not.toContain('first_singularity'); + expect(s.meta.achievements.first_singularity).toBe(NOW); // original stamp kept + }); + + it('pays nothing - no currency or xp moves when an achievement unlocks', () => { + const s = initialState(); + s.meta.stats.migrates = 1; + const before = { + wafers: s.meta.wafers, xp: s.meta.xp, level: s.meta.level, + credits: s.run.credits, tapes: s.meta.coldStorage.tapes, + }; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW).length).toBeGreaterThan(0); + expect(s.meta.wafers).toBe(before.wafers); + expect(s.meta.xp).toBe(before.xp); + expect(s.meta.level).toBe(before.level); + expect(s.run.credits).toBe(before.credits); + expect(s.meta.coldStorage.tapes).toBe(before.tapes); + }); + + it('unlocks the event-champion badge off the eventTopRungs counter', () => { + const s = initialState(); + s.meta.stats.eventTopRungs = 1; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toContain('event_champion'); + }); + + it('unlocks the streak badge off bestStreak', () => { + const s = initialState(); + s.meta.stats.bestStreak = 7; + expect(checkAchievements(s, DEFAULT_CONFIG, NOW)).toContain('streak_week'); + }); + + it('every condition survives a fresh state without throwing', () => { + const s = initialState(); + expect(() => checkAchievements(s, DEFAULT_CONFIG, NOW)).not.toThrow(); + }); + + it('treats a condition that throws on a malformed save as simply unmet', () => { + const s = initialState(); + delete s.meta.coldStorage; // would throw inside the jackpot/tape conditions + expect(() => checkAchievements(s, DEFAULT_CONFIG, NOW)).not.toThrow(); + expect(s.meta.achievements.jackpot).toBeUndefined(); + }); +}); + +describe('topBadges', () => { + it('returns at most three ids, gold first', () => { + const gold = ACHIEVEMENT_DEFS.filter((d) => d.tier === 'gold')[0]; + const bronze = ACHIEVEMENT_DEFS.filter((d) => d.tier === 'bronze').slice(0, 3); + const held = {}; + for (const d of [...bronze, gold]) held[d.id] = NOW; + const badges = topBadges(held); + expect(badges).toHaveLength(3); + expect(badges[0]).toBe(gold.id); + }); + it('ignores unknown ids in a hand-edited save', () => { + expect(topBadges({ nope: 1 })).toEqual([]); + }); + it('handles a missing or non-object bag', () => { + for (const bad of [null, undefined, [], 'x']) expect(topBadges(bad)).toEqual([]); + }); +}); diff --git a/tests/streak.test.js b/tests/streak.test.js new file mode 100644 index 0000000..151cd1c --- /dev/null +++ b/tests/streak.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { nextStreakCount, streakReward, canClaimStreak } from '../shared/streak.js'; + +const cfg = DEFAULT_CONFIG; + +describe('nextStreakCount', () => { + it('starts at day 1 when there is no prior claim', () => { + expect(nextStreakCount({ count: 0, lastClaimDate: null }, '2026-07-31', cfg)).toBe(1); + }); + it('advances by one on a consecutive day', () => { + expect(nextStreakCount({ count: 3, lastClaimDate: '2026-07-30' }, '2026-07-31', cfg)).toBe(4); + }); + it('caps at streakMaxDay and stays there while unbroken', () => { + expect(nextStreakCount({ count: 7, lastClaimDate: '2026-07-30' }, '2026-07-31', cfg)).toBe(7); + }); + it('resets to 1 after a fully missed day', () => { + expect(nextStreakCount({ count: 6, lastClaimDate: '2026-07-29' }, '2026-07-31', cfg)).toBe(1); + expect(nextStreakCount({ count: 6, lastClaimDate: '2026-07-01' }, '2026-07-31', cfg)).toBe(1); + }); + it('resets to 1 on a malformed or future lastClaimDate', () => { + expect(nextStreakCount({ count: 5, lastClaimDate: 'nope' }, '2026-07-31', cfg)).toBe(1); + expect(nextStreakCount({ count: 5, lastClaimDate: '2026-08-05' }, '2026-07-31', cfg)).toBe(1); + }); +}); + +describe('canClaimStreak', () => { + it('is false only when already claimed today', () => { + expect(canClaimStreak({ lastClaimDate: '2026-07-31' }, '2026-07-31')).toBe(false); + expect(canClaimStreak({ lastClaimDate: '2026-07-30' }, '2026-07-31')).toBe(true); + expect(canClaimStreak({ lastClaimDate: null }, '2026-07-31')).toBe(true); + }); +}); + +describe('streakReward', () => { + const ctx = { totalOutputPerSec: 100, meta: { level: 0 } }; + it('pays FLOPS on days 1-3', () => { + for (const day of [1, 2, 3]) { + const r = streakReward(day, cfg, ctx); + expect(r.flops).toBeGreaterThan(0); + expect(r.wafers).toBe(0); + expect(r.tapes).toBe(0); + } + }); + it('escalates the FLOPS payout across days 1-3', () => { + expect(streakReward(2, cfg, ctx).flops).toBeGreaterThan(streakReward(1, cfg, ctx).flops); + expect(streakReward(3, cfg, ctx).flops).toBeGreaterThan(streakReward(2, cfg, ctx).flops); + }); + it('pays escalating wafers on days 4-6', () => { + for (const day of [4, 5, 6]) { + const r = streakReward(day, cfg, ctx); + expect(r.wafers).toBeGreaterThan(0); + expect(r.tapes).toBe(0); + } + expect(streakReward(6, cfg, ctx).wafers).toBeGreaterThan(streakReward(4, cfg, ctx).wafers); + }); + it('pays tapes plus wafers on the final day', () => { + const r = streakReward(7, cfg, ctx); + expect(r.tapes).toBe(cfg.social.streakDay7Tapes); + expect(r.wafers).toBeGreaterThan(0); + }); + it('never pays a negative or non-finite amount', () => { + for (let day = 1; day <= cfg.social.streakMaxDay; day++) { + const r = streakReward(day, cfg, { totalOutputPerSec: 0, meta: { level: 0 } }); + for (const v of Object.values(r)) { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + } + } + }); +}); From 1f8438577326983204461f0b07e2d590a3e76143 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:29:53 -0400 Subject: [PATCH 07/11] Add claimContract and claimStreak actions with automatic achievement unlocks Two pre-existing event tests needed updating: one asserted a successful result with toEqual (now also carries unlockedAchievements), and two built a bare runtime-fields-only config that the sweep's goalCtx call can't read. Co-Authored-By: Claude Opus 5 --- shared/reducer.js | 86 +++++++++++++++ tests/eventService.finalfix.test.js | 20 +++- tests/reducer.events.test.js | 6 +- tests/reducer.social.test.js | 163 ++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 tests/reducer.social.test.js diff --git a/shared/reducer.js b/shared/reducer.js index 6b96ea3..b2646f0 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -5,6 +5,10 @@ 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'; import { rungProgress } from './events.js'; +import { utcDateKey } from './daily.js'; +import { contractsForState, contractProgress } from './contracts.js'; +import { canClaimStreak, nextStreakCount, streakReward } from './streak.js'; +import { checkAchievements } from './achievements.js'; const LANE_DEFS = { tiers: TIER_DEFS, grid: GRID_DEFS, overclock: OVERCLOCK_DEFS }; @@ -503,6 +507,12 @@ function claimEventRung(s, action, config, now) { } ep.rungsClaimed.push(index); + // Feeds the 'event_champion' achievement through the ordinary + // condition-driven path (shared/achievements.js) rather than special-casing + // a badge grant here - spec §5.1's "top rung awards a badge". + if (index === ladder.length - 1) { + s.meta.stats.eventTopRungs = (s.meta.stats.eventTopRungs || 0) + 1; + } // `eventId` echoes back which event's ladder this rung came from - the // claim may have targeted a superseded (pendingEventClaims) window, so // callers that sync per-event bookkeeping (stateService.applyActions -> @@ -526,6 +536,70 @@ function progressRecordFor(meta, eventId) { return pending.find((p) => p && p.eventId === eventId) || null; } +// v1.5 Social. Both handlers below are gated on a CALENDAR-DAY key rather +// than a rolling window, so "already done today" is invalid_target (the +// existing string for a repeat claim, as in claimGoal/claimBlock) and never +// cooldown_active - no new error strings. + +function claimContract(s, action, config, now) { + const { index } = action; + if (!validIndex(index, 3)) return err('invalid_target'); + + // The board is rolled over on the server's load path + // (server/stateService.js) BEFORE any action is applied, so a dateKey that + // isn't today's here means this claim raced past a rollover - reject it + // rather than pay out against a stale target/baseline pair. + const today = utcDateKey(now); + if (today === null || s.meta.contracts.dateKey !== today) return err('invalid_target'); + + const contract = contractsForState(s.meta)[index]; + if (!contract || !contract.def) return err('invalid_target'); + if (s.meta.contracts.claimed[index] === true) return err('invalid_target'); + + const baseline = s.meta.contracts.baseline[contract.def.metric]; + if (!contractProgress(contract.def, s.meta, baseline, contract.target).met) return err('not_met'); + + const scale = 1 + config.social.contractRewardLevelScalePct * (s.meta.level || 0); + const wafers = Math.round(config.social.contractRewardWafers * scale); + const tapes = Math.round(config.social.contractRewardTapes * scale); + + s.meta.wafers += wafers; + s.meta.stats.totalWafersEarned = (s.meta.stats.totalWafersEarned || 0) + wafers; + s.meta.coldStorage.tapes += tapes; + s.meta.stats.tapesEarnedLifetime = (s.meta.stats.tapesEarnedLifetime || 0) + tapes; + s.meta.contracts.claimed[index] = true; + s.meta.stats.contractsCompletedLifetime = (s.meta.stats.contractsCompletedLifetime || 0) + 1; + + return { ok: true, reward: { wafers, tapes }, index }; +} + +function claimStreak(s, action, config, now) { + const today = utcDateKey(now); + if (today === null) return err('invalid_target'); + if (!canClaimStreak(s.meta.streak, today)) return err('invalid_target'); + + const day = nextStreakCount(s.meta.streak, today, config); + const ctx = goalCtx(s, config, now); + const reward = streakReward(day, config, ctx); + + if (reward.flops > 0) { + s.run.credits += reward.flops; + s.run.lifetimeRun += reward.flops; + } + if (reward.wafers > 0) { + s.meta.wafers += reward.wafers; + s.meta.stats.totalWafersEarned = (s.meta.stats.totalWafersEarned || 0) + reward.wafers; + } + if (reward.tapes > 0) { + s.meta.coldStorage.tapes += reward.tapes; + s.meta.stats.tapesEarnedLifetime = (s.meta.stats.tapesEarnedLifetime || 0) + reward.tapes; + } + + s.meta.streak = { count: day, lastClaimDate: today }; + s.meta.stats.bestStreak = Math.max(s.meta.stats.bestStreak || 0, day); + return { ok: true, reward, day }; +} + // Boolean-validated client display preference; the route layer (Task 6) // mirrors this to the `users` column. The reducer only records it in `meta`. function setLeaderboardOptOut(s, action) { @@ -553,6 +627,7 @@ const HANDLERS = Object.assign(Object.create(null), { claimGoal, claimRepeatable, claimAnomaly, hardReset, claimBlock, claimAllBlocks, resetTrack, startJob, cancelJob, claimJob, buyTapeUpgrade, claimEventRung, setLeaderboardOptOut, + claimContract, claimStreak, }); export function applyAction(state, action, config, now, rng = Math.random) { @@ -562,5 +637,16 @@ export function applyAction(state, action, config, now, rng = Math.random) { } const s = structuredClone(state); const result = handler(s, action, config, now, rng); + // Achievements unlock automatically, never by claim (spec §6.3). Sweeping + // here - after any SUCCESSFUL action - is what makes "unlocked in the + // reducer" true for everything a player does. A rejected action changed + // nothing, so there is nothing new to unlock and sweeping would only burn a + // goalCtx build on every bad request. The offline half (thresholds crossed + // by evaluate()'s accrual, which no action touches) is swept separately in + // server/stateService.js. + if (result && result.ok) { + const unlocked = checkAchievements(s, config, now); + if (unlocked.length > 0) result.unlockedAchievements = unlocked; + } return { state: s, result }; } diff --git a/tests/eventService.finalfix.test.js b/tests/eventService.finalfix.test.js index b1c5188..cd174e0 100644 --- a/tests/eventService.finalfix.test.js +++ b/tests/eventService.finalfix.test.js @@ -17,9 +17,21 @@ const { const { initialState } = await import('../shared/state.js'); const { applyAction, EVENT_CLAIM_GRACE_MS } = await import('../shared/reducer.js'); const { validateRecurrence } = await import('../shared/events.js'); +const { DEFAULT_CONFIG } = await import('../shared/configSchema.js'); ensureConfig(); +// applyAction needs a FULL config document, not just the __claimableEvent / +// __pendingClaimables runtime fields these tests care about: v1.5's automatic +// achievement sweep runs goalCtx() after every successful action, and that +// reads real tunables (config.offline.*, config.production.*, ...). The bare +// `{ __claimableEvent }` objects these tests used to hand-build threw on the +// first successful claim once that sweep landed. Same convention +// tests/reducer.events.test.js's activeConfig() helper already uses. +function withDefaults(runtimeFields) { + return { ...structuredClone(DEFAULT_CONFIG), ...runtimeFields }; +} + const DAY_MS = 24 * 60 * 60 * 1000; let seq = 0; @@ -229,14 +241,14 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { // Exactly what stateService attaches for this player. const { current, pending } = resolvePlayerEvents(state); - const config = { + const config = withDefaults({ __claimableEvent: { id: current.event.id, ladder: current.event.ladder, endsAt: current.event.ends_at, }, __pendingClaimables: pending.map(({ event }) => ({ id: event.id, ladder: event.ladder, endsAt: event.ends_at, })), - }; + }); // Pre-fix this returned not_met with eventProgress already replaced and // the 20 wafers gone for good. @@ -287,14 +299,14 @@ describe('joinEventIfEligible: superseding preserves the claim right', () => { state.meta.stats.lifetimeFlopsAllTime += 20000; const { current, pending } = resolvePlayerEvents(state); - const config = { + const config = withDefaults({ __claimableEvent: { id: current.event.id, ladder: current.event.ladder, endsAt: current.event.ends_at, }, __pendingClaimables: pending.map(({ event }) => ({ id: event.id, ladder: event.ladder, endsAt: event.ends_at, })), - }; + }); const late = applyAction( state, { type: 'claimEventRung', index: 1, eventId: eventA.id }, config, now, diff --git a/tests/reducer.events.test.js b/tests/reducer.events.test.js index 9e9b417..71a29c6 100644 --- a/tests/reducer.events.test.js +++ b/tests/reducer.events.test.js @@ -42,7 +42,11 @@ describe('reducer: claimEventRung', () => { // `eventId` echoes which event's ladder the rung came from - a claim may // target a superseded window from meta.pendingEventClaims, so callers // (stateService's participation sync) can't infer it from eventProgress. - expect(result).toEqual({ + // toMatchObject, not toEqual: v1.5's automatic achievement sweep also + // attaches `unlockedAchievements` to any successful result (here + // 'event_joined', since joinedState sets meta.eventProgress). The four + // fields below are what this test is actually pinning. + expect(result).toMatchObject({ ok: true, reward: { flops: 50 }, rungIndex: 0, eventId: 'ev1', }); expect(s2.run.credits).toBe(10 + 50); // initial 10 + flops reward diff --git a/tests/reducer.social.test.js b/tests/reducer.social.test.js new file mode 100644 index 0000000..1f4023a --- /dev/null +++ b/tests/reducer.social.test.js @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +import { applyAction } from '../shared/reducer.js'; +import { rolloverContracts, contractsForState } from '../shared/contracts.js'; + +const NOW = Date.UTC(2026, 6, 31, 12, 0, 0); // 2026-07-31 +const TOMORROW = NOW + 24 * 3600 * 1000; + +// A state with today's board rolled over and contract slot 0 already met, by +// pushing that slot's own metric past its snapshotted target. +function stateWithMetContract() { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const [first] = contractsForState(s.meta); + s.meta.stats[first.def.metric] = s.meta.contracts.baseline[first.def.metric] + first.target; + return s; +} + +describe('claimContract', () => { + it('pays wafers and tapes, marks the slot claimed, and bumps the lifetime counter', () => { + const s = stateWithMetContract(); + const { state, result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.reward.wafers).toBeGreaterThan(0); + expect(result.reward.tapes).toBeGreaterThan(0); + expect(state.meta.wafers).toBe(result.reward.wafers); + expect(state.meta.stats.totalWafersEarned).toBe(result.reward.wafers); + expect(state.meta.coldStorage.tapes).toBe(result.reward.tapes); + expect(state.meta.stats.tapesEarnedLifetime).toBe(result.reward.tapes); + expect(state.meta.contracts.claimed[0]).toBe(true); + expect(state.meta.stats.contractsCompletedLifetime).toBe(1); + }); + + it('rejects a double claim', () => { + const s = stateWithMetContract(); + const a = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + const b = applyAction(a.state, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(b.result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('rejects an unmet contract with not_met', () => { + const s = initialState(); + rolloverContracts(s, DEFAULT_CONFIG, NOW); + const unmet = contractsForState(s.meta).find((c) => c.target > 0); + const { result } = applyAction(s, { type: 'claimContract', index: unmet.index }, DEFAULT_CONFIG, NOW); + expect(result).toEqual({ ok: false, error: 'not_met' }); + }); + + it('rejects a claim against a stale board (dateKey is not today)', () => { + const s = stateWithMetContract(); + const { result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, TOMORROW); + expect(result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('rejects a claim before any rollover has happened', () => { + const { result } = applyAction(initialState(), { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it.each([['__proto__'], ['push'], ['length'], [-1], [1.5], [3], [999], [null], [undefined], [{}]])( + 'rejects malformed index %p as invalid_target without throwing', (index) => { + const s = stateWithMetContract(); + let out; + expect(() => { out = applyAction(s, { type: 'claimContract', index }, DEFAULT_CONFIG, NOW); }).not.toThrow(); + expect(out.result).toEqual({ ok: false, error: 'invalid_target' }); + }, + ); + + it('never mutates the input state', () => { + const s = stateWithMetContract(); + const before = structuredClone(s); + applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(s).toEqual(before); + }); +}); + +describe('claimStreak', () => { + it('starts a streak at day 1 and pays FLOPS to both credits and lifetimeRun', () => { + const s = initialState(); + s.run.tiers[0].owned = 50; // non-zero output so the day-1 FLOPS reward is > 0 + const { state, result } = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.day).toBe(1); + expect(result.reward.flops).toBeGreaterThan(0); + expect(state.run.credits).toBeCloseTo(s.run.credits + result.reward.flops); + expect(state.run.lifetimeRun).toBeCloseTo(s.run.lifetimeRun + result.reward.flops); + expect(state.meta.streak).toEqual({ count: 1, lastClaimDate: '2026-07-31' }); + expect(state.meta.stats.bestStreak).toBe(1); + }); + + it('rejects a second claim on the same UTC day', () => { + const a = applyAction(initialState(), { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + const b = applyAction(a.state, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + 3600 * 1000); + expect(b.result).toEqual({ ok: false, error: 'invalid_target' }); + }); + + it('advances on a consecutive day and records the best streak', () => { + let s = initialState(); + for (let day = 0; day < 3; day++) { + s = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000).state; + } + expect(s.meta.streak.count).toBe(3); + expect(s.meta.stats.bestStreak).toBe(3); + }); + + it('resets to day 1 after a missed day but keeps bestStreak', () => { + let s = initialState(); + for (let day = 0; day < 4; day++) { + s = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000).state; + } + const after = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + 6 * 24 * 3600 * 1000).state; + expect(after.meta.streak.count).toBe(1); + expect(after.meta.stats.bestStreak).toBe(4); + }); + + it('pays tapes on the final day of the streak', () => { + let s = initialState(); + let last; + for (let day = 0; day < DEFAULT_CONFIG.social.streakMaxDay; day++) { + last = applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW + day * 24 * 3600 * 1000); + s = last.state; + } + expect(last.result.day).toBe(DEFAULT_CONFIG.social.streakMaxDay); + expect(last.result.reward.tapes).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + expect(s.meta.coldStorage.tapes).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + expect(s.meta.stats.tapesEarnedLifetime).toBe(DEFAULT_CONFIG.social.streakDay7Tapes); + }); + + it('never mutates the input state', () => { + const s = initialState(); + const before = structuredClone(s); + applyAction(s, { type: 'claimStreak' }, DEFAULT_CONFIG, NOW); + expect(s).toEqual(before); + }); +}); + +describe('achievement sweep on the action path', () => { + it('reports newly-unlocked achievements on the action result', () => { + const s = initialState(); + s.meta.legacyCores = 100; // enough for a Singularity + const { state, result } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.unlockedAchievements).toContain('first_singularity'); + expect(state.meta.achievements.first_singularity).toBe(NOW); + }); + + it('omits the field entirely when nothing unlocked', () => { + const s = initialState(); + s.run.credits = 1e6; + const { result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 1 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(result.unlockedAchievements).toBeUndefined(); + }); + + it('does not sweep after a rejected action', () => { + const s = initialState(); + s.meta.stats.singularities = 1; // condition is met... + const { state, result } = applyAction(s, { type: 'claimContract', index: 0 }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(false); // ...but the action failed + expect(state.meta.achievements).toEqual({}); + }); +}); From b5eba78fa5052f75e6751c13f3fe84b079eafc5a Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:30:54 -0400 Subject: [PATCH 08/11] Roll contracts over and sweep offline achievements on the load path Co-Authored-By: Claude Opus 5 --- server/routes/api.js | 11 +++-- server/stateService.js | 42 ++++++++++++++--- tests/stateService.social.test.js | 75 +++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 tests/stateService.social.test.js diff --git a/server/routes/api.js b/server/routes/api.js index fc077d9..be37262 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -90,7 +90,7 @@ router.get('/api/me', requireAuth, (req, res) => { router.get('/api/state', requireAuth, (req, res) => { const now = Date.now(); - const { state, gained, activeEvent } = loadAndEvaluate(req.user.sub, now); + const { state, gained, activeEvent, unlockedAchievements } = loadAndEvaluate(req.user.sub, now); const { version } = getConfig(); const { current } = resolvePlayerEvents(state); const claimable = current && inClaimGrace(current.progress, now) ? current.event : null; @@ -134,6 +134,11 @@ router.get('/api/state', requireAuth, (req, res) => { endsAt: activeEvent.ends_at, } : null, eventProgress: state.meta.eventProgress, + // Social (v1.5): achievements the load-path sweep unlocked on THIS + // request - typically thresholds crossed by offline accrual since the + // player was last seen. The client toasts them; meta.achievements above + // is the durable record either way. + unlockedAchievements, }); }); @@ -143,8 +148,8 @@ router.post('/api/actions', requireAuth, (req, res) => { return res.status(400).json({ error: 'actions must be an array of at most 100 items' }); } const now = Date.now(); - const { state, results } = applyActions(req.user.sub, actions, now); - res.json({ state, results, serverTime: now }); + const { state, results, unlockedAchievements } = applyActions(req.user.sub, actions, now); + res.json({ state, results, serverTime: now, unlockedAchievements }); }); // --------------------------------------------------------------------------- diff --git a/server/stateService.js b/server/stateService.js index 568b8e8..4a5fbc0 100644 --- a/server/stateService.js +++ b/server/stateService.js @@ -5,6 +5,8 @@ import { } from './db.js'; import { getEffectiveConfig } from './configService.js'; import { joinEventIfEligible, resolvePlayerEvents } from './eventService.js'; +import { rolloverContracts } from '../shared/contracts.js'; +import { checkAchievements } from '../shared/achievements.js'; function safeParse(text, userId) { try { @@ -100,14 +102,31 @@ export function loadEvaluateAndSchedule(userId, now) { })); } - return { state, gained, config, activeEvent }; + // v1.5: roll the contracts board to today's UTC day. Runs AFTER evaluate() + // (so goalCtx's totalOutputPerSec reflects the gap just closed, and the + // rate-scaled FLOPS target is computed against the player's real current + // output) and AFTER joinEventIfEligible (so an active event's config + // overlay is already in force when targets are computed). Idempotent - a + // no-op on every load within the same UTC day. + rolloverContracts(state, config, now); + + // v1.5: the offline half of the achievement sweep. shared/reducer.js's + // applyAction sweeps after every successful ACTION, which covers everything + // a player does - but the lifetime-FLOPS tiers are crossed by evaluate()'s + // accrual during a gap, which no action touches. Without this, a player who + // crossed 1T FLOPS while asleep wouldn't unlock until their next successful + // action. Both call sites write to the same meta.achievements bag and + // checkAchievements never re-stamps a held id, so a double sweep is free. + const unlockedAchievements = checkAchievements(state, config, now); + + return { state, gained, config, activeEvent, unlockedAchievements }; } /** Loads, evaluates, persists, and returns { state, gained, activeEvent } for GET /api/state. */ export function loadAndEvaluate(userId, now = Date.now()) { - const { state, gained, activeEvent } = loadEvaluateAndSchedule(userId, now); + const { state, gained, activeEvent, unlockedAchievements } = loadEvaluateAndSchedule(userId, now); putSave(userId, state, now); - return { state, gained, activeEvent }; + return { state, gained, activeEvent, unlockedAchievements }; } /** @@ -122,7 +141,9 @@ export function loadAndEvaluate(userId, now = Date.now()) { * here instead used to silently clobber those actions' own id client-side. */ export function applyActions(userId, actions, now = Date.now()) { - const { state: loaded, config } = loadEvaluateAndSchedule(userId, now); + const { + state: loaded, config, unlockedAchievements: loadUnlocked, + } = loadEvaluateAndSchedule(userId, now); let state = loaded; const results = []; @@ -166,5 +187,16 @@ export function applyActions(userId, actions, now = Date.now()) { } } - return { state, results }; + // v1.5: everything unlocked during THIS request, from either sweep site - + // the load path (an offline threshold crossed since the last visit) and each + // successful action. Merged and de-duplicated so the client can toast the + // set once; a batch that both crosses an offline threshold and unlocks + // something by acting must not drop either half. `unlockedAchievements` is + // left on the individual results too, for callers that want attribution. + const unlockedAchievements = [...new Set([ + ...(loadUnlocked || []), + ...results.flatMap((r) => r.unlockedAchievements || []), + ])]; + + return { state, results, unlockedAchievements }; } diff --git a/tests/stateService.social.test.js b/tests/stateService.social.test.js new file mode 100644 index 0000000..da78716 --- /dev/null +++ b/tests/stateService.social.test.js @@ -0,0 +1,75 @@ +process.env.DB_PATH = ':memory:'; +process.env.JWT_SECRET = 'test-secret-social-state'; + +import { describe, it, expect } from 'vitest'; + +const { upsertUser, putSave, getSave } = await import('../server/db.js'); +const { ensureConfig } = await import('../server/configService.js'); +const { loadAndEvaluate } = await import('../server/stateService.js'); +const { initialState } = await import('../shared/state.js'); +const { utcDateKey } = await import('../shared/daily.js'); + +ensureConfig(); + +let seq = 0; +function makeUser() { + seq += 1; + return upsertUser({ + provider: 'discord', providerId: `soc${seq}`, username: `socuser${seq}`, avatarUrl: null, + }); +} + +describe('contracts roll over on the load path', () => { + it("populates today's board for a user who has never had one", () => { + const u = makeUser(); + const now = Date.now(); + const { state } = loadAndEvaluate(u.id, now); + expect(state.meta.contracts.dateKey).toBe(utcDateKey(now)); + expect(state.meta.contracts.targets).toHaveLength(3); + }); + + it('persists the rolled-over board, so a reload sees the same targets', () => { + const u = makeUser(); + const now = Date.now(); + const first = loadAndEvaluate(u.id, now).state; + const second = loadAndEvaluate(u.id, now + 60_000).state; + expect(second.meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); + expect(second.meta.contracts.targets).toEqual(first.meta.contracts.targets); + expect(JSON.parse(getSave(u.id).data).meta.contracts.dateKey).toBe(first.meta.contracts.dateKey); + }); + + it('rolls over and clears claims when the UTC day advances', () => { + const u = makeUser(); + const now = Date.now(); + const first = loadAndEvaluate(u.id, now).state; + first.meta.contracts.claimed = [true, true, true]; + putSave(u.id, first, now); + const next = loadAndEvaluate(u.id, now + 24 * 3600 * 1000).state; + expect(next.meta.contracts.dateKey).not.toBe(first.meta.contracts.dateKey); + expect(next.meta.contracts.claimed).toEqual([false, false, false]); + }); +}); + +describe('achievements unlock from offline accrual', () => { + it('sweeps after evaluate, so a threshold crossed while away unlocks on next load', () => { + const u = makeUser(); + const now = Date.now(); + const s = initialState(); + s.meta.stats.lifetimeFlopsAllTime = 1e9; // 'flops_g' condition met + putSave(u.id, s, now); + const { state, unlockedAchievements } = loadAndEvaluate(u.id, now + 1000); + expect(state.meta.achievements.flops_g).toBeDefined(); + expect(unlockedAchievements).toContain('flops_g'); + }); + + it('reports nothing on a subsequent load once already held', () => { + const u = makeUser(); + const now = Date.now(); + const s = initialState(); + s.meta.stats.singularities = 1; + putSave(u.id, s, now); + loadAndEvaluate(u.id, now + 1000); + const { unlockedAchievements } = loadAndEvaluate(u.id, now + 2000); + expect(unlockedAchievements).toEqual([]); + }); +}); From f0960339638263cc8eff03cb00f588929eec6d06 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:32:00 -0400 Subject: [PATCH 09/11] Add global leaderboards service and GET /api/leaderboard Co-Authored-By: Claude Opus 5 --- server/db.js | 13 ++++ server/leaderboardService.js | 101 +++++++++++++++++++++++++++++ server/routes/api.js | 15 +++++ tests/api.social.test.js | 121 +++++++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 server/leaderboardService.js create mode 100644 tests/api.social.test.js diff --git a/server/db.js b/server/db.js index 707fdd2..b0e391d 100644 --- a/server/db.js +++ b/server/db.js @@ -220,6 +220,7 @@ export function getUserById(id) { export function getAllUsersWithSaves() { return db.prepare(` SELECT u.id, u.provider, u.username, u.avatar_url, u.created_at, + u.leaderboard_opt_out, s.data, s.last_save FROM users u LEFT JOIN saves s ON s.user_id = u.id @@ -550,6 +551,18 @@ export function listLeaderboard(eventId, limit = 50) { `).all(eventId, limit); } +/** + * The most recently-STARTED event that has actually run (any status except + * 'draft', which by definition has no window). Backs the v1.5 leaderboard's + * latest-event board. Returns null when no event has ever been scheduled. + */ +export function getLatestEventId() { + const row = db.prepare( + "SELECT id FROM live_events WHERE status != 'draft' AND starts_at IS NOT NULL ORDER BY starts_at DESC LIMIT 1", + ).get(); + return row ? row.id : null; +} + const seedEventStmt = db.prepare(` INSERT OR IGNORE INTO live_events (id, name, description, theme, modifiers, ladder, status, starts_at, ends_at, recurrence, created_at, created_by) VALUES (@id, @name, @description, @theme, @modifiers, @ladder, 'draft', NULL, NULL, @recurrence, @created_at, NULL) diff --git a/server/leaderboardService.js b/server/leaderboardService.js new file mode 100644 index 0000000..2a1b83b --- /dev/null +++ b/server/leaderboardService.js @@ -0,0 +1,101 @@ +// Global leaderboards (v1.5, spec §6.2 + design §8). +// +// Aggregated server-side from CANONICAL SAVES - there are no denormalized +// per-user counters to drift out of sync. That means a rebuild parses every +// user's save JSON, which is why the whole payload sits behind a time-based +// cache: for a friends-server (tens of users) at one rebuild per +// social.leaderboardCacheMs this is negligible, and it keeps saves as the +// single source of truth. If the user count ever made this hot, the fix is +// materialized columns on `users`, not a different cache. + +import { getAllUsersWithSaves, listLeaderboard, getLatestEventId } from './db.js'; +import { getConfig } from './configService.js'; +import { topBadges } from '../shared/achievements.js'; + +let cache = null; // { generatedAt, boards } + +/** Drops the cached payload. Exported for tests and for future admin tooling. */ +export function invalidateLeaderboards() { + cache = null; +} + +// Each board is (key, how to read its value off a parsed save's meta). +const BOARDS = [ + ['allTimeFlops', (meta) => (meta.stats && meta.stats.lifetimeFlopsAllTime) || 0], + ['level', (meta) => meta.level || 0], + ['legacyCores', (meta) => meta.legacyCores || 0], + ['singularities', (meta) => (meta.stats && meta.stats.singularities) || 0], + ['tapes', (meta) => (meta.coldStorage && meta.coldStorage.tapes) || 0], +]; + +function buildBoards(limit) { + const players = []; + for (const row of getAllUsersWithSaves()) { + // The LIVE opt-out column (users.leaderboard_opt_out), the one v1.4 + // shipped for exactly this - never event_participation.opted_out, which is + // a join-time snapshot that never updates afterwards. + if (row.leaderboard_opt_out) continue; + if (!row.data) continue; + let meta; + try { + meta = JSON.parse(row.data).meta; + } catch { + continue; // a corrupt save is skipped, never fatal to the whole board + } + if (!meta) continue; + players.push({ + userId: row.id, + username: row.username, + avatarUrl: row.avatar_url, + badges: topBadges(meta.achievements), + meta, + }); + } + + const boards = {}; + for (const [key, read] of BOARDS) { + boards[key] = players + .map((p) => ({ + userId: p.userId, + username: p.username, + avatarUrl: p.avatarUrl, + badges: p.badges, + value: read(p.meta), + })) + .filter((r) => r.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, limit); + } + + // The latest event's board comes from event_participation rather than saves: + // listLeaderboard already applies the same live opt-out filter and the same + // ranking the Event tab uses, so the two can never disagree. + const eventId = getLatestEventId(); + const badgesByUser = new Map(players.map((p) => [p.userId, p.badges])); + const avatarByUser = new Map(players.map((p) => [p.userId, p.avatarUrl])); + boards.latestEventRung = eventId + ? listLeaderboard(eventId, limit).map((r) => ({ + userId: r.userId, + username: r.username, + avatarUrl: avatarByUser.get(r.userId) || null, + badges: badgesByUser.get(r.userId) || [], + value: r.rungsClaimed, + })) + : []; + + return boards; +} + +/** + * The cached leaderboard payload, rebuilt on the first call after the TTL + * lapses. Reads getConfig() (the admin baseline) rather than + * getEffectiveConfig(): the cache TTL and row limit are operational knobs, and + * letting a live event's overlay quietly change how a SHARED cross-user cache + * behaves would be surprising. + */ +export function getLeaderboards(now = Date.now()) { + const { data: config } = getConfig(); + if (cache && now - cache.generatedAt < config.social.leaderboardCacheMs) return cache; + cache = { generatedAt: now, boards: buildBoards(config.social.leaderboardLimit) }; + return cache; +} diff --git a/server/routes/api.js b/server/routes/api.js index be37262..0fcdf8f 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -21,6 +21,7 @@ import { activateEvent, endEvent, resolvePlayerEvents, inClaimGrace, } from '../eventService.js'; import { loadAndEvaluate, loadEvaluateAndSchedule, applyActions } from '../stateService.js'; +import { getLeaderboards } from '../leaderboardService.js'; import { minigameWafers } from '../../shared/gameRules.js'; import { USERNAME_RE } from '../../shared/validation.js'; import { @@ -639,6 +640,20 @@ router.get('/api/admin/events/:id/participation', requireAuth, requireRole('even res.json({ participation: listParticipation(event.id) }); }); +// --------------------------------------------------------------------------- +// Leaderboards (v1.5) +// --------------------------------------------------------------------------- + +// Every board at once, already ranked, opt-out-filtered and capped +// server-side. The payload is a single shared in-memory cache +// (server/leaderboardService.js), so this is cheap to poll - the client +// throttles it anyway. Respects users.leaderboard_opt_out, the same live +// column the per-event leaderboard filters on. +router.get('/api/leaderboard', requireAuth, (req, res) => { + const { generatedAt, boards } = getLeaderboards(Date.now()); + res.json({ generatedAt, boards }); +}); + // --------------------------------------------------------------------------- // Changelog // --------------------------------------------------------------------------- diff --git a/tests/api.social.test.js b/tests/api.social.test.js new file mode 100644 index 0000000..9792397 --- /dev/null +++ b/tests/api.social.test.js @@ -0,0 +1,121 @@ +process.env.JWT_SECRET = 'test-secret-for-supertest-social'; +process.env.DB_PATH = ':memory:'; + +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; + +const { buildApp } = await import('../server/app.js'); +const { ensureConfig } = await import('../server/configService.js'); +const { upsertUser, putSave, setLeaderboardOptOut } = await import('../server/db.js'); +const { invalidateLeaderboards } = await import('../server/leaderboardService.js'); +const { COOKIE_NAME } = await import('../server/auth.js'); +const { initialState } = await import('../shared/state.js'); + +ensureConfig(); +const app = buildApp(); + +let seq = 0; +function seedPlayer({ + flops = 0, level = 0, cores = 0, singularities = 0, tapes = 0, achievements = {}, +} = {}) { + seq += 1; + const u = upsertUser({ + provider: 'discord', providerId: `lb${seq}`, username: `lbuser${seq}`, + avatarUrl: `https://x/${seq}.png`, + }); + const s = initialState(); + s.meta.stats.lifetimeFlopsAllTime = flops; + s.meta.level = level; + s.meta.legacyCores = cores; + s.meta.stats.singularities = singularities; + s.meta.coldStorage.tapes = tapes; + s.meta.achievements = achievements; + putSave(u.id, s, Date.now()); + return u; +} + +function cookieFor(user) { + const token = jwt.sign( + { sub: user.id, username: user.username, avatarUrl: user.avatar_url }, + process.env.JWT_SECRET, { expiresIn: '90d' }, + ); + return `${COOKIE_NAME}=${token}`; +} + +describe('GET /api/leaderboard', () => { + it('401s when unauthenticated', async () => { + expect((await request(app).get('/api/leaderboard')).status).toBe(401); + }); + + it('returns every board, ranked descending', async () => { + const low = seedPlayer({ flops: 100, level: 1 }); + const high = seedPlayer({ flops: 999999, level: 40 }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(low)); + expect(res.status).toBe(200); + expect(Object.keys(res.body.boards).sort()).toEqual( + ['allTimeFlops', 'latestEventRung', 'legacyCores', 'level', 'singularities', 'tapes'], + ); + const flopsIds = res.body.boards.allTimeFlops.map((r) => r.userId); + expect(flopsIds.indexOf(high.id)).toBeLessThan(flopsIds.indexOf(low.id)); + const row = res.body.boards.allTimeFlops.find((r) => r.userId === high.id); + expect(row.username).toBe(high.username); + expect(row.avatarUrl).toBe(high.avatar_url); + expect(row.value).toBe(999999); + expect(Array.isArray(row.badges)).toBe(true); + }); + + it('excludes opted-out players from every board', async () => { + const shy = seedPlayer({ flops: 1e12, level: 90 }); + const seen = seedPlayer({ flops: 5, level: 1 }); + setLeaderboardOptOut(shy.id, true); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(seen)); + for (const board of Object.values(res.body.boards)) { + expect(board.map((r) => r.userId)).not.toContain(shy.id); + } + }); + + it('surfaces up to three badges per row, gold first', async () => { + const decorated = seedPlayer({ + flops: 42, + achievements: { + first_migrate: 1, first_singularity: 2, level_10: 3, jackpot: 4, level_50: 5, + }, + }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(decorated)); + const row = res.body.boards.allTimeFlops.find((r) => r.userId === decorated.id); + expect(row.badges.length).toBeLessThanOrEqual(3); + expect(row.badges).toContain('level_50'); // gold sorts first + }); + + it('serves a cached payload within the TTL and rebuilds after invalidation', async () => { + const u = seedPlayer({ flops: 1 }); + invalidateLeaderboards(); + const first = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + const second = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(second.body.generatedAt).toBe(first.body.generatedAt); // same cached build + + seedPlayer({ flops: 1e15 }); + const stale = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(stale.body.generatedAt).toBe(first.body.generatedAt); // still cached + + invalidateLeaderboards(); + const fresh = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(fresh.body.generatedAt).toBeGreaterThanOrEqual(first.body.generatedAt); + expect(fresh.body.boards.allTimeFlops[0].value).toBe(1e15); + }); + + it('skips users with no save row without throwing', async () => { + const u = seedPlayer({ flops: 1 }); + upsertUser({ + provider: 'discord', providerId: 'lb-nosave', username: 'nosave', avatarUrl: null, + }); + invalidateLeaderboards(); + const res = await request(app).get('/api/leaderboard').set('Cookie', cookieFor(u)); + expect(res.status).toBe(200); + expect(res.body.boards.allTimeFlops.map((r) => r.username)).not.toContain('nosave'); + }); +}); From 9f3ca00fba90feae77b4a21f1ae4aa948e5a3c99 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:36:20 -0400 Subject: [PATCH 10/11] Add Social tab, contracts board, leaderboards, badge case and streak banner Co-Authored-By: Claude Opus 5 --- client/client/package-lock.json | 6 + client/package-lock.json | 4 +- client/src/RackStack.jsx | 134 +++++++++++++++++- client/src/game/api.js | 29 +++- client/src/game/components/SocialPanel.jsx | 66 +++++++++ client/src/game/components/StreakBanner.jsx | 61 ++++++++ .../components/social/AchievementsSection.jsx | 55 +++++++ .../components/social/ContractsSection.jsx | 94 ++++++++++++ .../components/social/LeaderboardSection.jsx | 109 ++++++++++++++ client/src/game/constants.js | 6 + client/src/game/data/achievementIcons.js | 27 ++++ client/src/game/data/tabs.js | 7 +- 12 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 client/client/package-lock.json create mode 100644 client/src/game/components/SocialPanel.jsx create mode 100644 client/src/game/components/StreakBanner.jsx create mode 100644 client/src/game/components/social/AchievementsSection.jsx create mode 100644 client/src/game/components/social/ContractsSection.jsx create mode 100644 client/src/game/components/social/LeaderboardSection.jsx create mode 100644 client/src/game/data/achievementIcons.js diff --git a/client/client/package-lock.json b/client/client/package-lock.json new file mode 100644 index 0000000..ac6f59a --- /dev/null +++ b/client/client/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "client", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/client/package-lock.json b/client/package-lock.json index 74eba84..49a790a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "rackstack-client", - "version": "1.0.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rackstack-client", - "version": "1.0.0", + "version": "1.4.0", "dependencies": { "lucide-react": "^0.383.0", "react": "^18.3.1", diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index f7aa972..407c79e 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -2,17 +2,20 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { TICK_MS, ANOMALY_LABELS, EVENT_REFRESH_THROTTLE_MS, CONFIG_POLL_MS, + LEADERBOARD_REFRESH_THROTTLE_MS, } from './game/constants.js'; -import { cardBorder, textDim, teal, amber, danger, inset } from './game/theme.js'; +import { cardBorder, textDim, textMain, teal, amber, danger, inset } from './game/theme.js'; import { TABS } from './game/data/tabs.js'; import { fetchState, fetchConfig, makeActionQueue, startMinigame, finishMinigame, - fetchEvent, setLeaderboardOptOut, + fetchEvent, setLeaderboardOptOut, fetchLeaderboard, } from './game/api.js'; import { evaluate } from '@shared/state.js'; import { applyAction, EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js'; import { computeMults, migrateGain, xpForLevel } from '@shared/gameRules.js'; import { goalCtx } from '@shared/goals.js'; +import { achievementDef } from '@shared/achievements.js'; +import { achievementIcon, TIER_COLOR } from './game/data/achievementIcons.js'; import HeaderBar from './game/components/HeaderBar.jsx'; import StatsRow from './game/components/StatsRow.jsx'; @@ -28,6 +31,8 @@ import GoalsPanel from './game/components/GoalsPanel.jsx'; import GamesPanel from './game/components/GamesPanel.jsx'; import ColdStoragePanel from './game/components/ColdStoragePanel.jsx'; import EventPanel from './game/components/EventPanel.jsx'; +import SocialPanel from './game/components/SocialPanel.jsx'; +import StreakBanner from './game/components/StreakBanner.jsx'; import AnomalyToast from './game/components/AnomalyToast.jsx'; import RushOverlay from './game/components/minigames/RushOverlay.jsx'; import DebugOverlay from './game/components/minigames/DebugOverlay.jsx'; @@ -109,6 +114,17 @@ export default function RackStack({ user }) { // Unlike `activeEvent` these carry their own progress snapshot, because the // canonical meta.eventProgress has already moved on to the newer event. const [pendingClaims, setPendingClaims] = useState([]); + // Social (v1.5): the leaderboard payload (all six boards at once) from GET + // /api/leaderboard. Like the event leaderboard this isn't part of canonical + // state - contracts, streak and achievements all live in `meta` and arrive + // free on every reconcile, but cross-user boards can only come from the + // server. + const [leaderboards, setLeaderboards] = useState(null); + const [leaderboardLoading, setLeaderboardLoading] = useState(false); + // Achievement unlocks waiting to be shown, oldest first. Queued rather than + // rendered all at once so several landing together (common on the first + // load after a long absence) don't stack overlapping toasts. + const [achievementQueue, setAchievementQueue] = useState([]); // Local mirror of the display name so a successful username change (see // ProfileSettings) reflects in the header/profile immediately, without a // page reload or waiting on the next /api/me fetch (App.jsx's `user` @@ -128,6 +144,15 @@ export default function RackStack({ user }) { // heartbeat effect below. const configKeyRef = useRef(null); const lastConfigPollAtRef = useRef(0); + const lastLeaderboardFetchAtRef = useRef(0); + // Social (v1.5): serverTime-minus-Date.now(), refreshed from every state + // load and every reconcile. Only the UTC day boundary needs this: the + // streak and the contracts board are gated on a calendar DAY, a hard cliff + // the server owns, so a client whose clock is off by minutes near midnight + // would otherwise render a Claim button the server rejects (or hide one it + // would accept). Countdowns elsewhere in this file keep using Date.now() + // directly - a few seconds of skew just shifts a displayed number. + const serverClockOffsetRef = useRef(0); const stateRef = useRef(null); const queueRef = useRef(null); const lastTickAtRef = useRef(Date.now()); @@ -218,7 +243,10 @@ export default function RackStack({ user }) { } } - function handleReconcile(serverState, results) { + function handleReconcile(serverState, results, serverTime, unlocked) { + if (typeof serverTime === 'number' && Number.isFinite(serverTime)) { + serverClockOffsetRef.current = serverTime - Date.now(); + } const resultCids = new Set((results || []).map((r) => r._cid)); pendingActionsRef.current = pendingActionsRef.current.filter((a) => !resultCids.has(a._cid)); @@ -228,6 +256,8 @@ export default function RackStack({ user }) { if (result.ok && result.reward) openAnomalyRewardModal(result.reward); } + queueAchievements(unlocked); + let rungClaimed = false; for (const result of results || []) { if (!pendingRungClaimIdsRef.current.has(result._cid)) continue; @@ -312,6 +342,14 @@ export default function RackStack({ user }) { } }, [state, activeTab, pendingClaims]); + // Drains the achievement queue one badge at a time, ~3.2s each, so a batch + // that lands together reads as a sequence rather than a pile. + useEffect(() => { + if (achievementQueue.length === 0) return undefined; + const t = setTimeout(() => setAchievementQueue((q) => q.slice(1)), 3200); + return () => clearTimeout(t); + }, [achievementQueue]); + const REJECT_MESSAGES = { insufficient_credits: 'Not enough FLOPS', cooldown_active: 'On cooldown', @@ -405,6 +443,10 @@ export default function RackStack({ user }) { configRef.current = configRes.data; configKeyRef.current = configKeyOf(configRes); setConfig(configRes); + if (typeof stateRes.serverTime === 'number' && Number.isFinite(stateRes.serverTime)) { + serverClockOffsetRef.current = stateRes.serverTime - Date.now(); + } + queueAchievements(stateRes.unlockedAchievements); const initial = { run: stateRes.run, meta: stateRes.meta, server: stateRes.server }; stateRef.current = initial; setState(initial); @@ -572,6 +614,42 @@ export default function RackStack({ user }) { // the optimistic flip is reverted - the reducer action is purely for // display, so it must not keep claiming an opt-out that didn't actually // persist server-side. + // Social (v1.5). Achievements unlock server-side with no claim step, so the + // only client-side job is telling the player it happened. Ids are appended + // (de-duplicated against what's already waiting) and drained one at a time + // by the effect below. + function queueAchievements(ids) { + if (!Array.isArray(ids) || ids.length === 0) return; + setAchievementQueue((q) => { + const seen = new Set(q); + return [...q, ...ids.filter((id) => !seen.has(id))]; + }); + } + + // GET /api/leaderboard. Throttled because the Social tab's board section + // refetches on every open; the server serves one shared cached payload + // regardless, so this bounds client request volume rather than server work. + // `force` skips the throttle for the opt-out toggle, where the whole point + // is seeing yourself (dis)appear immediately. + async function refreshLeaderboard(force = false) { + const now = Date.now(); + if (!force && now - lastLeaderboardFetchAtRef.current < LEADERBOARD_REFRESH_THROTTLE_MS) return; + lastLeaderboardFetchAtRef.current = now; + setLeaderboardLoading(true); + const res = await fetchLeaderboard(); + setLeaderboardLoading(false); + if (!res || res.error) return; + setLeaderboards(res.boards || null); + } + + function claimContract(index) { + dispatchAction({ type: 'claimContract', index }); + } + + function claimStreak() { + dispatchAction({ type: 'claimStreak' }); + } + function toggleLeaderboardOptOut(next) { dispatchAction({ type: 'setLeaderboardOptOut', optOut: next }); (async () => { @@ -584,6 +662,9 @@ export default function RackStack({ user }) { // next unrelated reconcile to refresh it. lastEventFetchAtRef.current = Date.now(); refreshEventData(); + // The same column gates the v1.5 global boards, so refresh those too + // (forced past the throttle for the same reason). + refreshLeaderboard(true); } else { dispatchAction({ type: 'setLeaderboardOptOut', optOut: !next }); } @@ -910,6 +991,11 @@ export default function RackStack({ user }) { // (isEventLive/isEventTabVisible are the module-scope helpers above the // component, shared with the activeTab-reset effect so this can't drift // from that check). + // Social (v1.5): wall-clock corrected to the server's clock, for the two + // displays gated on a UTC calendar DAY (the streak banner, the contracts + // rollover countdown). Everything else in this render keeps using `now`. + const serverNow = now + serverClockOffsetRef.current; + const eventProgress = state.meta.eventProgress; const eventLive = isEventLive(eventProgress, now); const eventTabVisible = isEventTabVisible(eventProgress, pendingClaims, now); @@ -950,6 +1036,13 @@ export default function RackStack({ user }) { {eventLive && activeEvent && ( setActiveTab('event')} /> )} + setModal({ type: 'migrate' })} onCollectAll={collectAll} /> @@ -1028,6 +1121,20 @@ export default function RackStack({ user }) { /> )} + {activeTab === 'social' && ( + + )} + {minigame && minigame.type === 'rush' && } {minigame && minigame.type === 'debug' && } {minigame && minigame.type === 'match' && } @@ -1035,6 +1142,27 @@ export default function RackStack({ user }) { + {achievementQueue.length > 0 && (() => { + const def = achievementDef(achievementQueue[0]); + if (!def) return null; + const Icon = achievementIcon(def.icon); + const accent = TIER_COLOR[def.tier]; + return ( +
+
+ +
+
Achievement unlocked
+
{def.name}
+
+
+
+ ); + })()} + {rejectToast && (
diff --git a/client/src/game/api.js b/client/src/game/api.js index 416bdc4..fcbb472 100644 --- a/client/src/game/api.js +++ b/client/src/game/api.js @@ -168,6 +168,19 @@ export function setLeaderboardOptOut(optOut) { return postJSON('/api/me/leaderboard-opt-out', { optOut }, 'PUT'); } +// GET /api/leaderboard -> { generatedAt, boards: { : [row] } } +// where row is { userId, username, avatarUrl, value, badges: [achievementId] } +// +// Rows arrive already ranked, opt-out-filtered and capped server-side +// (server/leaderboardService.js) - the client renders them in order and never +// re-sorts. The payload is one shared in-memory server cache, so polling this +// costs nothing beyond the request itself; it's still throttled client-side by +// LEADERBOARD_REFRESH_THROTTLE_MS so a burst of reconciles can't turn into a +// request each. +export function fetchLeaderboard() { + return request('/api/leaderboard'); +} + // --------------------------------------------------------------------------- // Event coordinator admin (role-gated server-side; Task 8 builds the UI that // consumes these - included here since api.js is this task's one designated @@ -264,6 +277,11 @@ const IMMEDIATE = new Set([ // Storage analogs in this set and needed a follow-up fix round - see the // block above - so this one goes in from the start.) 'claimEventRung', + // Social (v1.5): both are reward claims exactly like claimGoal/ + // claimEventRung above, and both are gated on a UTC calendar-day key - so an + // action left sitting in the queue across midnight is the difference between + // claimable and rejected. They go in from the start. + 'claimContract', 'claimStreak', ]); // makeActionQueue({ onReconcile, onReject, onQueueError }) -> { dispatch, flush, pending } @@ -277,7 +295,7 @@ const IMMEDIATE = new Set([ // - flush(): POSTs the queued batch to /api/actions. Only one flush is ever // in flight at a time - if one is already running, this is a no-op (and // anything dispatched meanwhile just accumulates for the next flush). -// On success: calls onReconcile(state, results, serverTime), then +// On success: calls onReconcile(state, results, serverTime, unlockedAchievements), then // onReject(result) for each per-action result with `ok: false`. Also // resets the backoff below to its normal cadence. // On batch-level failure (network error, or a non-2xx from the batch @@ -366,8 +384,13 @@ export function makeActionQueue({ onReconcile, onReject, onQueueError, onBeaconF consecutiveFailures = 0; nextAllowedAttemptAt = 0; - const { state, results, serverTime } = res; - onReconcile(state, results, serverTime); + const { state, results, serverTime, unlockedAchievements } = res; + // `unlockedAchievements` (v1.5) is the merged set from BOTH server-side + // sweep sites for this request - the load path (an offline threshold + // crossed since the last visit) and each successful action - so passing + // it separately is not redundant with the per-result field of the same + // name, which only covers the action half. + onReconcile(state, results, serverTime, unlockedAchievements); for (const result of results || []) { if (!result.ok) onReject(result); } diff --git a/client/src/game/components/SocialPanel.jsx b/client/src/game/components/SocialPanel.jsx new file mode 100644 index 0000000..2e08df7 --- /dev/null +++ b/client/src/game/components/SocialPanel.jsx @@ -0,0 +1,66 @@ +import { useState, useEffect } from 'react'; +import { ClipboardCheck, Trophy, Award } from 'lucide-react'; +import { textDim, amber } from '../theme.js'; +import ContractsSection from './social/ContractsSection.jsx'; +import LeaderboardSection from './social/LeaderboardSection.jsx'; +import AchievementsSection from './social/AchievementsSection.jsx'; + +const SECTIONS = [ + { id: 'contracts', label: 'Contracts', Icon: ClipboardCheck }, + { id: 'board', label: 'Board', Icon: Trophy }, + { id: 'badges', label: 'Badges', Icon: Award }, +]; + +// Social tab shell (v1.5). Three sections behind an inner toggle, reusing +// ProfileView.jsx's segmented-control pattern rather than adding three more +// entries to an already nine-wide tab bar. +// +// The leaderboard is the only part that needs a network fetch (contracts and +// badges are both derived from canonical state, which arrives free on every +// reconcile), so it's fetched lazily the first time the Board section is +// opened - and refreshed on each subsequent open, throttled by RackStack.jsx. +export default function SocialPanel({ + meta, serverTime, userId, boards, leaderboardLoading, optOut, + onClaimContract, onToggleOptOut, onRefreshLeaderboard, +}) { + const [section, setSection] = useState('contracts'); + + useEffect(() => { + if (section === 'board') onRefreshLeaderboard(); + }, [section, onRefreshLeaderboard]); + + return ( +
+
+ {SECTIONS.map((s) => { + const active = section === s.id; + const SectionIcon = s.Icon; + return ( + + ); + })} +
+ + {section === 'contracts' && ( + + )} + {section === 'board' && ( + + )} + {section === 'badges' && } +
+ ); +} diff --git a/client/src/game/components/StreakBanner.jsx b/client/src/game/components/StreakBanner.jsx new file mode 100644 index 0000000..eedf89d --- /dev/null +++ b/client/src/game/components/StreakBanner.jsx @@ -0,0 +1,61 @@ +import { Flame } from 'lucide-react'; +import { amber, textMain, textDim } from '../theme.js'; +import { fmt } from '../helpers.js'; +import { utcDateKey } from '@shared/daily.js'; +import { canClaimStreak, nextStreakCount, streakReward } from '@shared/streak.js'; + +function rewardLabel(reward) { + const parts = []; + if (reward.flops > 0) parts.push(`${fmt(reward.flops)} FLOPS`); + if (reward.wafers > 0) parts.push(`${fmt(reward.wafers)} wafers`); + if (reward.tapes > 0) parts.push(`${fmt(reward.tapes)} tapes`); + return parts.join(' + '); +} + +// Sticky-header streak banner (v1.5) - same rounded-pill pattern as +// EventBanner/the surge banner in StatsRow.jsx. +// +// The day boundary comes from `serverTime`, never Date.now(): the server owns +// the UTC calendar day this claim is gated on, so a client with a skewed clock +// must not be shown a Claim button the server will reject with invalid_target +// (or, worse, be told it already claimed when it hasn't). +export default function StreakBanner({ streak, serverTime, config, ctx, onClaim }) { + if (!config || !config.social || !streak) return null; + + const today = utcDateKey(serverTime); + if (today === null) return null; + + const claimable = canClaimStreak(streak, today); + const day = claimable ? nextStreakCount(streak, today, config) : streak.count; + + if (!claimable) { + return ( +
+ + + Day {day} streak + + back tomorrow +
+ ); + } + + const reward = streakReward(day, config, ctx); + return ( + + ); +} diff --git a/client/src/game/components/social/AchievementsSection.jsx b/client/src/game/components/social/AchievementsSection.jsx new file mode 100644 index 0000000..87c6717 --- /dev/null +++ b/client/src/game/components/social/AchievementsSection.jsx @@ -0,0 +1,55 @@ +import { cardBg, cardBorder, textMain, textDim } from '../../theme.js'; +import { ACHIEVEMENT_DEFS } from '@shared/achievements.js'; +import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js'; + +function unlockedDate(ms) { + if (typeof ms !== 'number' || !Number.isFinite(ms)) return null; + return new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +// The badge case. Achievements are pure prestige (spec §6.3) - there is +// deliberately no Claim button anywhere here, because they unlock +// automatically in the reducer the moment their condition is met. +export default function AchievementsSection({ achievements }) { + const held = achievements && typeof achievements === 'object' ? achievements : {}; + const unlockedCount = ACHIEVEMENT_DEFS.filter( + (d) => Object.prototype.hasOwnProperty.call(held, d.id), + ).length; + + return ( +
+
+ {unlockedCount}/{ACHIEVEMENT_DEFS.length} unlocked +
+ +
+ {ACHIEVEMENT_DEFS.map((def) => { + const at = held[def.id]; + const unlocked = Object.prototype.hasOwnProperty.call(held, def.id); + const Icon = achievementIcon(def.icon); + const accent = TIER_COLOR[def.tier]; + return ( +
+ +
+ {def.name} +
+
{def.desc}
+ {unlocked && unlockedDate(at) && ( +
{unlockedDate(at)}
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/game/components/social/ContractsSection.jsx b/client/src/game/components/social/ContractsSection.jsx new file mode 100644 index 0000000..0b8560c --- /dev/null +++ b/client/src/game/components/social/ContractsSection.jsx @@ -0,0 +1,94 @@ +import { ClipboardCheck } from 'lucide-react'; +import { cardBg, cardBorder, inset, textMain, textDim, teal, amber } from '../../theme.js'; +import { fmt } from '../../helpers.js'; +import { contractsForState, contractProgress } from '@shared/contracts.js'; + +// Hh Mm until the next UTC midnight - the same boundary shared/daily.js uses +// for both the contracts board and the streak, so this countdown is exact +// rather than an approximation of "sometime tonight". +function fmtUntilRollover(now) { + const d = new Date(now); + const nextMidnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1); + const totalMin = Math.max(0, Math.ceil((nextMidnight - now) / 60000)); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return `${h}h ${m}m`; +} + +// The three daily contracts. Everything rendered here is derived from +// canonical state via contractsForState/contractProgress - the EXACT functions +// the server's own claimContract runs - so a Claim button can never appear for +// something the server would reject with not_met, and vice versa. +// +// `serverTime` (not Date.now()) drives the rollover countdown: the server owns +// the day boundary, and a client with a skewed clock must not be told the +// board resets at a different moment than it actually will. +export default function ContractsSection({ meta, serverTime, onClaim }) { + const contracts = contractsForState(meta); + + if (contracts.length === 0) { + return ( +
+ Today’s contracts haven’t been drawn yet. Reload in a moment. +
+ ); + } + + const claimedCount = contracts.filter((c) => c.claimed).length; + + return ( +
+
+ {claimedCount}/{contracts.length} claimed today + Resets in {fmtUntilRollover(serverTime)} +
+ + {contracts.map((c) => { + const baseline = meta.contracts.baseline[c.def.metric]; + const { current, target, met } = contractProgress(c.def, meta, baseline, c.target); + const pct = target > 0 ? Math.min(100, (current / target) * 100) : 0; + return ( +
+
+
+
+ {c.claimed ? '✓ ' : ''}{c.def.desc(target, fmt)} +
+
+ {c.claimed ? 'Claimed' : `${fmt(current)}/${fmt(target)}`} +
+
+ {!c.claimed && met && ( + + )} +
+ {!c.claimed && ( +
+
+
+ )} +
+ ); + })} + +
+ + Everyone gets the same three contracts each day — the numbers scale to your own progress. +
+
+ ); +} diff --git a/client/src/game/components/social/LeaderboardSection.jsx b/client/src/game/components/social/LeaderboardSection.jsx new file mode 100644 index 0000000..3b5cb62 --- /dev/null +++ b/client/src/game/components/social/LeaderboardSection.jsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { cardBg, cardBorder, inset, textMain, textDim, amber } from '../../theme.js'; +import { fmt } from '../../helpers.js'; +import { achievementDef } from '@shared/achievements.js'; +import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js'; + +// Board keys match server/leaderboardService.js's BOARDS list plus the +// event board it appends. `format` is how that board's `value` reads. +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: '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` }, +]; + +function Badges({ ids }) { + if (!Array.isArray(ids) || ids.length === 0) return null; + return ( + + {ids.map((id) => { + const def = achievementDef(id); + if (!def) return null; + const Icon = achievementIcon(def.icon); + return ; + })} + + ); +} + +// Rows arrive already ranked, opt-out-filtered and capped by the server - this +// component never re-sorts or re-filters them, so what a player sees is +// exactly what the server decided is public. +// +// The opt-out control writes through PUT /api/me/leaderboard-opt-out (the +// authoritative column these boards filter on), the same endpoint and the same +// handler the Event tab's "Hide me" checkbox uses - there is one preference, +// not one per board. +export default function LeaderboardSection({ boards, userId, optOut, loading, onToggleOptOut }) { + const [active, setActive] = useState('allTimeFlops'); + const board = (boards && boards[active]) || []; + const activeDef = BOARDS.find((b) => b.key === active); + + return ( +
+
+ {BOARDS.map((b) => ( + + ))} +
+ +
+ {loading && board.length === 0 ? ( +
Loading…
+ ) : board.length === 0 ? ( +
No entries yet.
+ ) : ( +
+ {board.map((row, i) => { + const mine = row.userId === userId; + return ( +
+ + #{i + 1} + {row.avatarUrl && ( + + )} + {row.username || 'Anonymous'}{mine ? ' (you)' : ''} + + + {activeDef.format(row.value)} +
+ ); + })} +
+ )} +
+ + +
+ ); +} diff --git a/client/src/game/constants.js b/client/src/game/constants.js index 3fbac90..057d71b 100644 --- a/client/src/game/constants.js +++ b/client/src/game/constants.js @@ -44,3 +44,9 @@ export const EVENT_REFRESH_THROTTLE_MS = 8000; // The response is a cached in-memory document server-side and the client // discards it unless (version, activeEventId) actually changed. export const CONFIG_POLL_MS = 10000; + +// Social (v1.5): how often the Social tab's leaderboard section is allowed to +// re-fetch GET /api/leaderboard. The server already serves one shared cached +// payload (social.leaderboardCacheMs, default 60s), so this throttle exists to +// bound client-side request volume, not server work. +export const LEADERBOARD_REFRESH_THROTTLE_MS = 30000; diff --git a/client/src/game/data/achievementIcons.js b/client/src/game/data/achievementIcons.js new file mode 100644 index 0000000..900b183 --- /dev/null +++ b/client/src/game/data/achievementIcons.js @@ -0,0 +1,27 @@ +import { + RefreshCw, Sparkles, Gift, Archive, Layers, ChevronsUp, Cpu, + Gamepad2, Trophy, Crown, Flame, ClipboardCheck, ListChecks, Award, +} from 'lucide-react'; + +// Maps the icon NAME strings carried by shared/achievements.js's +// ACHIEVEMENT_DEFS to real components. The names live in shared/ (which must +// not import from client/, and must stay dependency-free), so this map is the +// one place the two sides meet. +const ACHIEVEMENT_ICONS = { + RefreshCw, Sparkles, Gift, Archive, Layers, ChevronsUp, Cpu, + Gamepad2, Trophy, Crown, Flame, ClipboardCheck, ListChecks, +}; + +// Award is the fallback for an unmapped name, so adding a def with a new icon +// degrades to a generic badge rather than crashing the panel. +export function achievementIcon(name) { + return ACHIEVEMENT_ICONS[name] || Award; +} + +// Tier accents, reused by the badge case and the leaderboard's mini-icons so +// a gold badge reads the same everywhere it appears. +export const TIER_COLOR = { + gold: '#E8A33D', // amber + silver: '#9FB0C5', + bronze: '#B87A4B', +}; diff --git a/client/src/game/data/tabs.js b/client/src/game/data/tabs.js index a51b47b..9e31395 100644 --- a/client/src/game/data/tabs.js +++ b/client/src/game/data/tabs.js @@ -1,4 +1,4 @@ -import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy } from 'lucide-react'; +import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy, Users } from 'lucide-react'; export const TABS = [ { id: 'racks', label: 'Racks', Icon: Layers }, @@ -9,6 +9,11 @@ export const TABS = [ { id: 'goals', label: 'Goals', Icon: ListChecks }, { id: 'games', label: 'Games', Icon: Gamepad2 }, { id: 'coldstorage', label: 'Cold Storage', Icon: Archive }, + // Social (v1.5): contracts, leaderboards and the badge case. Unlike + // grid/overclock/singularity/coldstorage, this one is never locked - the + // daily contracts board and the streak both work from level 0, so there's + // no progression gate to render it disabled behind (see TabBar.jsx). + { id: 'social', label: 'Social', Icon: Users }, // Live Events (v1.4): unlike every other tab above (which is locked-but- // always-rendered until progression clears it, see TabBar.jsx), this one // is entirely absent from the bar outside its window - RackStack.jsx From 4c9fda44e808fc03f033d83c881cce2f9e5df088 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 1 Aug 2026 00:40:55 -0400 Subject: [PATCH 11/11] v1.5.0: Social & Retention - contracts, leaderboards, achievements, streak Adds the e2e smoke suite, README/CHANGELOG entries, and the version bump. The e2e suite caught a real bug before it shipped: the global leaderboards are served from a ~60s cache, so opting out left a player visible for up to a minute. The per-event board has always been immediate (v1.4's "hard requirement 1"), so the route now invalidates the cache on opt-out, with a unit regression test pinning it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 32 + Dockerfile | 2 +- README.md | 42 ++ client/package.json | 2 +- .../components/social/AchievementsSection.jsx | 2 +- .../components/social/ContractsSection.jsx | 1 + package.json | 2 +- server/routes/api.js | 11 +- tests/api.social.test.js | 28 + tests/e2e/smoke-v15.mjs | 552 ++++++++++++++++++ 10 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/smoke-v15.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7377e..0754d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## v1.5.0 + +Social & Retention: a daily contracts board, global leaderboards, +achievements, and a daily login streak - all bonuses layered on the existing +economy, none of them gating content. + +- Daily contracts: three contracts a day, rotating at midnight UTC and + generated deterministically from the date, so every player gets the same + three *types* while the numeric targets scale to their own progress. + Targets and baselines are snapshotted at rollover, so buying racks mid-day + never moves the goalposts. Claims pay wafers + tapes. +- A player who hasn't unlocked Cold Storage gets base-lane contracts + substituted for any Cold Storage ones, so nobody is ever handed a contract + they cannot possibly complete. +- Global leaderboards: all-time FLOPS, level, Legacy Cores, Singularities, + Tapes, and the most recent event's rungs - aggregated server-side from + canonical saves behind a ~60s cache, with avatars, usernames, and badge + mini-icons. The existing per-user leaderboard opt-out now covers these + boards too, and takes effect immediately. +- Achievements: 19 of them, pure prestige with no payout, unlocked + automatically the moment their condition is met - including from progress + accrued while offline. Shown as a badge case in the Social tab, and the + top three (gold first) ride along on leaderboard rows. +- Daily streak: a 7-day escalating claim (FLOPS, then wafers, then Tapes on + day 7) that stays at the day-7 reward while unbroken; a fully missed UTC + day resets it to day 1. Claimed from a banner in the sticky header. +- New Social tab holding contracts, the leaderboard, and the badge case. +- New `social.*` tunables on the Balancing tab covering contract targets and + rewards, streak rewards, and leaderboard cache/size - all overlayable by a + live event's modifiers, so e.g. a double-streak-rewards weekend needs no + code change. + ## v1.4.0 Live Events: config-overlay events with goal ladders, running one at a time diff --git a/Dockerfile b/Dockerfile index 3f6d2af..99d0ac6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ ENV DB_PATH=/app/data/rackstack.db LABEL org.opencontainers.image.source="https://github.com/NeverEndingCode/rackstack-server" LABEL org.opencontainers.image.description="RackStack self-hosted server" LABEL org.opencontainers.image.licenses="MIT" -LABEL org.opencontainers.image.version="1.4.0" +LABEL org.opencontainers.image.version="1.5.0" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/README.md b/README.md index 27d4566..77add71 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ automatically on their own annual windows via an hourly scheduler, and a coordinator can author, schedule, and run additional ones from the admin dashboard's Events tab. See [Live Events](#live-events) below for details. +As of v1.5, a Social tab adds a daily contracts board (three contracts a day, +the same three types for everyone, with targets scaled to your own progress), +global leaderboards, and a badge case of achievements that unlock on their own +as you play. A daily login streak sits in the header. See +[Social & Retention](#social--retention) below. + ## Architecture - `shared/` - a package used by both server and client (via a `@shared` Vite @@ -202,6 +208,42 @@ tapes/FLOPS as they clear metric targets. event while one is already active is rejected outright (409) - end the running one first. +## Social & Retention + +All four of these are bonuses - none of them gates content, and none +introduces a new currency. + +- **Daily contracts** (Social tab): three a day, rotating at midnight UTC. + Which three is derived deterministically from the date, so everyone on the + server gets the same set and can compare notes; the numeric targets scale + to each player's own output and level. Both the targets and the progress + baselines are snapshotted at rollover, so a contract can't recede as you + grow into it. Completing one pays wafers + tapes. A player who hasn't + unlocked Cold Storage gets base-lane substitutions rather than contracts + they can't act on. +- **Daily streak** (header banner): a 7-day escalating claim - FLOPS on days + 1-3, wafers on 4-6, Tapes on day 7 - which then stays at the day-7 reward + for as long as it's unbroken. Missing a full UTC day resets it to day 1. + The day boundary is the same one contracts roll over on, so showing up once + a day satisfies both. +- **Leaderboards** (Social tab): all-time FLOPS, level, Legacy Cores, + Singularities, Tapes, and the latest event's rungs. Aggregated server-side + from canonical saves behind a ~60s cache. The same per-user opt-out the + Event tab already had covers these too - tick "Hide me from all + leaderboards" and you disappear from every board immediately. +- **Achievements** (Social tab badge case): 19 of them, pure prestige - no + payout, ever. They unlock automatically the moment their condition is met, + including from progress that accrued while you were offline, and pop a + toast when they do. Your top three (gold first) show as mini-icons next to + your name on the leaderboards. +- **Tuning**: everything numeric above lives under `social.*` in the + Balancing tab, and - like every other tunable - can be overlaid by a live + event's modifiers. `social.contractFlopsMin` is worth knowing about: it's a + floor on the FLOPS contract target, there because a purely rate-scaled + target is zero for a player at zero output (a fresh save, or the instant + after a Migrate) and would auto-complete for free. Set it to 0 if you'd + rather have that. + ## Notes / things worth knowing - **SQLite, not Postgres**: chosen for zero-config, single-file persistence diff --git a/client/package.json b/client/package.json index 8d6f2a8..dddce67 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "rackstack-client", "private": true, - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/client/src/game/components/social/AchievementsSection.jsx b/client/src/game/components/social/AchievementsSection.jsx index 87c6717..6624c03 100644 --- a/client/src/game/components/social/AchievementsSection.jsx +++ b/client/src/game/components/social/AchievementsSection.jsx @@ -22,7 +22,7 @@ export default function AchievementsSection({ achievements }) { {unlockedCount}/{ACHIEVEMENT_DEFS.length} unlocked
-
+
{ACHIEVEMENT_DEFS.map((def) => { const at = held[def.id]; const unlocked = Object.prototype.hasOwnProperty.call(held, def.id); diff --git a/client/src/game/components/social/ContractsSection.jsx b/client/src/game/components/social/ContractsSection.jsx index 0b8560c..5609515 100644 --- a/client/src/game/components/social/ContractsSection.jsx +++ b/client/src/game/components/social/ContractsSection.jsx @@ -69,6 +69,7 @@ export default function ContractsSection({ meta, serverTime, onClaim }) { {!c.claimed && met && (