Skip to content

✨ Add achievement engine: registry, evaluation, unlock-bubble backend (G2) - #184

Merged
Logan2234 merged 2 commits into
mainfrom
feat/gamification-g2-achievement-engine
Sep 2, 2026
Merged

✨ Add achievement engine: registry, evaluation, unlock-bubble backend (G2)#184
Logan2234 merged 2 commits into
mainfrom
feat/gamification-g2-achievement-engine

Conversation

@Logan2234

Copy link
Copy Markdown
Owner

Summary

Follows G1/G1b/G4, all merged. Adds the machinery to unlock achievements — not the final catalogue (that's [G3]), just the engine plus two test achievements exercising its two shapes (a unique unlock and a bronze/silver/gold tier).

Design was negotiated aspect-by-aspect with Logan before implementation (permanence, storage, backfill strategy, notification mechanism, XP-link) — see the loomkeep-gamification memory for the full record.

  • UserAchievement { userId, key, unlockedAt, displayedAt }, unique per (userId, key). Definitions live in code (registry.ts), never seeded in the database — same principle as the XP barème.
  • Achievements are permanent once unlocked — never revoked, unlike XpEntry. A trophy isn't taken back because a later action changes the underlying state.
  • XpService.award() gains an optional amountOverride, the one exception to "amount always comes from the barème registry" — XP_RULES.ACHIEVEMENT_UNLOCKED now has no fixed amount, closing a gap flagged since G1. The XP↔achievement link is sourceId = <the UserAchievement row's own id>; no duplicated xpAwarded column — the amount is already on XpEntry.amount.
  • Live-wired at every site that credits a relevant XP reason (EPISODE_WATCHED, and both MOVIE_WATCHED award sites — upsertEntry and updateEntry), not just one witness. Unlike G1's single-witness precedent, wiring a second call site here cost nothing extra, so there was no reason to leave one to the nightly sweep's delay. The updateEntry site was missed in the first implementation pass and caught in review — a regression test now covers it.
  • No classic Notification row for unlocks. This ships only the backend half of a different mechanism: a sliding unlock bubble (top of screen, one per unlock, sequenced — [G6], not built yet) — displayedAt on UserAchievement, GET /achievements/pending, PATCH /achievements/:id/displayed.
  • Nightly sweep (full registry × every user, deliberately naive at this catalogue size — flagged to revisit once G3 lands ~60 entries) is both the safety net for anything not wired live and the backfill mechanism for existing accounts' history — check() never cares about a row's provenance (live action vs. import vs. pre-dating the feature), so no separate backfill script is needed. A retroactive unlock still pays its full XP reward.

Also fixes a pre-existing, unrelated broken test in import-job.service.spec.ts (a constructor call missing its 5th arg, XpService, introduced when that dependency was added by an earlier PR).

Quackback: post_01m1f96j6dexyrvzkm935vajxp

Test plan

  • pnpm --filter @loomkeep/api exec vitest run — full API suite, 952/952 green
  • pnpm --filter @loomkeep/api exec tsc --noEmit — clean
  • Manual smoke test on dev with GAMIFICATION_ENABLED=true: watch an episode → first_episode unlocks + XP credited; complete 10/50/200 movies → each cinephile tier unlocks in turn; GET /achievements/pending returns them oldest-first; PATCH .../displayed marks one shown and 404s on another user's achievement

🤖 Generated with Claude Code

… (G2)

Follows G1/G1b/G4. Adds the machinery to unlock achievements — not the
final catalogue (that's G3), just the engine plus two test achievements
that exercise its two shapes:

- UserAchievement { userId, key, unlockedAt, displayedAt }, unique per
  (userId, key). Definitions live in code (registry.ts), never seeded
  in the database, same principle as the XP barème.
- Achievements are permanent once unlocked — never revoked, unlike
  XpEntry. A trophy isn't taken back because a later action changes
  the underlying state.
- XpService.award() gains an optional amountOverride, the one
  exception to "amount always comes from the barème registry" —
  XP_RULES.ACHIEVEMENT_UNLOCKED now has no fixed amount, closing a
  gap flagged since G1. The XP↔achievement link is sourceId =
  the UserAchievement row's own id; no duplicated xpAwarded column.
- Live-wired at every site that credits a relevant XP reason
  (EPISODE_WATCHED, and both MOVIE_WATCHED award sites), not just one
  witness — unlike G1's single-witness precedent, wiring a second
  call site here cost nothing extra, so there was no reason to leave
  it to the nightly sweep's delay.
- No classic Notification row for unlocks. This ships only the
  backend for a different mechanism: a sliding unlock bubble (G6,
  not built yet) — displayedAt on UserAchievement, GET
  /achievements/pending, PATCH /achievements/:id/displayed.
- Nightly sweep (full registry × every user, deliberately naive at
  this catalogue size) is both the safety net for anything not wired
  live and the backfill mechanism for existing accounts' history —
  check() never cares about a row's provenance, so no separate
  backfill script is needed.

Also fixes a pre-existing, unrelated broken test in
import-job.service.spec.ts (a constructor call missing its 5th arg,
XpService, introduced when that dependency was added).

Quackback: post_01m1f96j6dexyrvzkm935vajxp

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation docker Dockerfiles, docker-compose, self-hosting stack api apps/api (NestJS) web apps/web (SvelteKit) shared packages/shared labels Sep 2, 2026
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievements.controller.ts Fixed
Comment thread apps/api/src/gamification/achievements/registry.ts Fixed
Comment thread apps/api/src/gamification/achievements/registry.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
Comment thread apps/api/src/gamification/achievements/achievement.service.ts Fixed
@Logan2234
Logan2234 merged commit 96aa4e3 into main Sep 2, 2026
19 of 20 checks passed
@Logan2234
Logan2234 deleted the feat/gamification-g2-achievement-engine branch September 2, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api apps/api (NestJS) docker Dockerfiles, docker-compose, self-hosting stack documentation Improvements or additions to documentation shared packages/shared web apps/web (SvelteKit)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants