✨ Add achievement engine: registry, evaluation, unlock-bubble backend (G2) - #184
Merged
Merged
Conversation
… (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>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-gamificationmemory 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.XpEntry. A trophy isn't taken back because a later action changes the underlying state.XpService.award()gains an optionalamountOverride, the one exception to "amount always comes from the barème registry" —XP_RULES.ACHIEVEMENT_UNLOCKEDnow has no fixed amount, closing a gap flagged since G1. The XP↔achievement link issourceId = <the UserAchievement row's own id>; no duplicatedxpAwardedcolumn — the amount is already onXpEntry.amount.EPISODE_WATCHED, and bothMOVIE_WATCHEDaward sites —upsertEntryandupdateEntry), 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. TheupdateEntrysite was missed in the first implementation pass and caught in review — a regression test now covers it.Notificationrow 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) —displayedAtonUserAchievement,GET /achievements/pending,PATCH /achievements/:id/displayed.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 greenpnpm --filter @loomkeep/api exec tsc --noEmit— cleanGAMIFICATION_ENABLED=true: watch an episode →first_episodeunlocks + XP credited; complete 10/50/200 movies → eachcinephiletier unlocks in turn;GET /achievements/pendingreturns them oldest-first;PATCH .../displayedmarks one shown and 404s on another user's achievement🤖 Generated with Claude Code