Skip to content

Achievements engine — unlockable badges (Racing · Speed · Collection)#23

Merged
burkben merged 3 commits into
mainfrom
burkben/achievements
Jun 20, 2026
Merged

Achievements engine — unlockable badges (Racing · Speed · Collection)#23
burkben merged 3 commits into
mainfrom
burkben/achievements

Conversation

@burkben

@burkben burkben commented Jun 19, 2026

Copy link
Copy Markdown
Owner

What this adds

A new Achievements feature (Phase 5 polish): a data-driven catalog of 13 unlockable badges across Racing, Speed, and Collection, with durable unlocks and live progress. Builds entirely on the shipped Phase 3 persistence seam.

🏆 New Achievements button on Home → a grouped screen showing unlocked badges (with the date earned) and locked ones (with a progress bar toward the goal) plus an X/Y count chip.

The 13 achievements

Category Examples
Racing First Finish · Regular Racer (10) · Century of Laps (100) · Marathon (20-lap race) · Sub-3 Lap
Speed Getting Going (100) · Quick (200) · Into the Red (240) · Redline Hero (290)
Collection New Wheels (1) · Small Garage (5) · Collector (10) · Hoarder (25)

Speed thresholds are sized to the gauge (max 300, redline 240) and the mock portal's pass magnitudes, so they're meaningful in demo mode too. No Mattel/Hot Wheels imagery or naming.

How it works (mirrors the Race/Garage/History pattern)

  • Pure engine (src/achievements/): catalog.ts (data-driven defs), stats.ts (flat AchievementStats snapshot + combineStats), engine.ts (evaluate → progress + unlock state; supports both reach ≥ X and get under ≤ X goals for lap times), format.ts (label/percent/date helpers). Framework-free, fully Node-tested.
  • Store (store/achievementsStore.ts): holds the unlocked set + latest stats, stamps newly-crossed goals with an unlock time, and forwards each to storage via a write-through hook — same persistence-seam pattern as raceStore/garageStore.
  • Persistence: achievementsRepository.ts (interface + InMemory) + sqliteAchievementsRepository.ts (INSERT OR IGNORE, so the first-unlock time wins). RaceRepository gains aggregate() for lifetime totals (the leaderboard is capped at 20, so achievements read a one-row roll-up of the durable race_results table instead).
  • Bootstrap (initPersistence.ts): hydrates unlocked badges, recomputes stats from race.aggregate() + the live garage, and refreshes on every banked race (after the durable write, so it's counted) and every garage change. Retroactive unlocks work — earlier races/cars count the first time you open the build.

Schema

Migration v5 appends an achievements (id TEXT PK, unlocked_at INTEGER) table to the shared redlineid.db. Append-only ladder, CREATE TABLE IF NOT EXISTSno native rebuild required; it activates on a JS reload, just like Garage/History/Settings. (SQLite-less builds stay graceful: everything reads locked at 0%.)

Scope notes / deferred

  • No live unlock toast in v1 — the store already detects newly-unlocked ids (applyStats returns them), so a celebratory toast is a tidy follow-up PR. Unlocks are recorded immediately and shown on the screen.
  • Entry point is a Home button (consistent with 🏁/🏎️/🕘/⚙️); a nav hub can come later if the list grows.

Tests / gates

  • 146 vitest pass (+41 this PR: 19 engine/catalog/stats, 6 store, 5 repo, 6 format, +2 race-aggregate, +3 bootstrap integration covering retroactive unlock, garage-driven unlock, and race-sink unlock).
  • tsc --noEmit clean.
  • Production iOS bundle exports clean (npx expo export, exit 0) — the new route + screen bundle in Metro.

Validation (USER)

Device-validate on the SQLite dev client: open Achievements, confirm retroactive unlocks from prior races/cars, run a race / detect a new car and watch progress advance + new badges unlock, then quit→relaunch to confirm they persist.

🤖 Generated with GitHub Copilot CLI

burkben and others added 3 commits June 19, 2026 16:35
Data-driven catalog of 13 goals across Racing / Speed / Collection,
evaluated against a flat AchievementStats snapshot. Supports both
reach-at-least (gte) and get-under (lte, for lap times) goals, reports
0..1 progress, and detects newly-crossed thresholds. Pure + Node-tested
(19 vitest cases); no framework/storage/time deps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- achievementsStore: unlocked-set + latest stats, stamps new unlocks,
  write-through persistence hook (mirrors race/garage stores).
- achievementsRepository (interface + InMemory) + SQLite adapter
  (INSERT OR IGNORE so first-unlock time wins); migration v5 adds the
  achievements table to the shared redlineid.db.
- RaceRepository gains aggregate() (lifetime totals, not the capped
  leaderboard) — one roll-up query on SQLite, pure helper for InMemory.
- initPersistence hydrates unlocked, recomputes stats from
  race.aggregate() + the live garage, and refreshes on every banked race
  (after the durable write) and garage change. Retroactive unlocks work.
- 140 vitest pass (+35); tsc clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- app/achievements.tsx: grouped badges (Racing / Speed / Collection) read
  live from useAchievementsStore via the pure evaluate() engine — unlocked
  show their date, locked show a progress bar toward the goal. X/Y count chip.
- achievements/format.ts: pure label/percent/date helpers (+6 vitest).
- Route registered in _layout.tsx; 🏆 Achievements button on Home.
- 146 vitest pass; tsc clean; production iOS export clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@burkben burkben merged commit e28f637 into main Jun 20, 2026
1 check passed
burkben added a commit that referenced this pull request Jun 20, 2026
Adds Phase 5 "share results": a Share button on the finished-race view and
on a History session detail, each handing a clean text recap to the OS share
sheet (Messages, Notes, AirDrop, etc.).

- New pure apps/mobile/src/share/summary.ts (+13 tests): raceShareText (total
  time, best/avg/worst splits, lap-by-lap with the fastest lap flagged) and
  sessionShareText (date, pass count, duration, best, fastest passes). Speeds
  run through the same speed/format converter as every readout, so shared
  numbers honor the user's unit + calibration; lap times mirror race.tsx.
- race.tsx Results: "Share result" button (RN Share API), resolving the car's
  garage nickname; honors the haptics setting.
- history/[id].tsx: header "Share" action (shown once a session has passes),
  passing the current speed display + car nickname map.
- Uses React Native's built-in Share — no new native module.

Also refreshed stale ROADMAP Phase 5 checkboxes (Achievements #23, speed
units #25, this as #26).

178 vitest pass; tsc --noEmit clean; iOS export clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant