mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 12:47:10 +00:00
feat(achievements): local engine + tabbed Profile shell (epic PR1) (#587)
Adds the Achievements & Feats of Power local engine, fully offline. Core (static/v3/profile.js): the Profile screen becomes tabbed exactly like v3 Settings (.fb-tabbar/.fb-tab/.fb-tabpanel, active tab persisted in localStorage 'v3-profile-tab'). A Profile (main) tab carries the existing cards + a Feats trophy-shelf mount (#v3-profile-feats-slot, earned-only), and an Achievements tab carries a plugin mount (#v3-profile-achievements-mount) + empty-state note. A new `v3:profile-rendered` event fires after every render so the plugin re-injects (mirrors v3:settings-rendered). New bundled plugin (plugins/achievements/): SQLite engine (unlocks/counters/comp_ledger/sync_queue) with pure threshold/criterion math in the testable sibling engine.py (P-V); routes activity/ report-unlock/report-criterion/catalog/earned/feats/remove-me. Feats read activity counters only (batched song:ended POST; notes only when notedetect present — graceful degradation); competency Achievements evaluate from progression events only — the integration law, never crossed. Catalogue is always shown (locked=greyed), grouped by the real progression paths (Global/Guitar/Bass/Drums/Keys, auto-extending) with per-category earned badges. Versioned window.feedBack.achievements registration API with the __feedBackAchievementsPending load-order queue + achievements:ready event. Verified natively (uvicorn) end-to-end + Playwright (tabbar, earned-only Feats shelf, greyed catalogue, registration API, zero console errors); 24 plugin tests pass incl. the integration-law assertion. Opt-in/privacy/data-min gate (PR2) and the hosted wall (PR3) follow. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
873ee3d5f2
commit
05dd3d227a
@@ -21,6 +21,9 @@ plugins/*/
|
|||||||
# treats them identically to user-installed ones) but are bundled with
|
# treats them identically to user-installed ones) but are bundled with
|
||||||
# the default container image and marked `"bundled": true` in their
|
# the default container image and marked `"bundled": true` in their
|
||||||
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
||||||
|
!plugins/achievements/
|
||||||
|
!plugins/achievements/**
|
||||||
|
plugins/achievements/__pycache__/
|
||||||
!plugins/highway_3d/
|
!plugins/highway_3d/
|
||||||
!plugins/highway_3d/**
|
!plugins/highway_3d/**
|
||||||
plugins/highway_3d/__pycache__/
|
plugins/highway_3d/__pycache__/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
|
||||||
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
|
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
|
||||||
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
|
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
|
||||||
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
|
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"global": [
|
||||||
|
{
|
||||||
|
"id": "first_steps",
|
||||||
|
"title": "First Steps",
|
||||||
|
"description": "Reach Mastery Rank 1 — finish onboarding.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "mastery_rank", "tiers": [1] },
|
||||||
|
"tiers": [1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ascendant",
|
||||||
|
"title": "Ascendant",
|
||||||
|
"description": "Climb the Mastery Rank ladder.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "mastery_rank", "tiers": [10, 25, 50] },
|
||||||
|
"tiers": [10, 25, 50],
|
||||||
|
"tier_titles": ["Ascendant I", "Ascendant II", "Ascendant III"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "steady_hands",
|
||||||
|
"title": "Steady Hands",
|
||||||
|
"description": "Make a real advance on many separate days.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "growth_streak_days", "tiers": [7, 30, 100] },
|
||||||
|
"tiers": [7, 30, 100],
|
||||||
|
"tier_titles": ["Steady Hands (7)", "Steady Hands (30)", "Steady Hands (100)"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "renaissance",
|
||||||
|
"title": "Renaissance",
|
||||||
|
"description": "Reach Level 10 across several distinct instrument paths.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "paths_at_level", "level": 10, "tiers": [2, 3, 5] },
|
||||||
|
"tiers": [2, 3, 5],
|
||||||
|
"tier_titles": ["Renaissance (2)", "Renaissance (3)", "Renaissance (5)"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"per_instrument": [
|
||||||
|
{
|
||||||
|
"id": "path_rank",
|
||||||
|
"title": "{Inst} Mastery",
|
||||||
|
"description": "Climb the {Inst} path: Apprentice, Journeyman, Master.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "path_level", "tiers": [10, 25, "max"] },
|
||||||
|
"tier_titles": ["{Inst} Apprentice", "{Inst} Journeyman", "{Inst} Master"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "personal_best",
|
||||||
|
"title": "Personal Best ({Inst})",
|
||||||
|
"description": "Beat your own best accuracy on any {Inst} chart.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "personal_best" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "challenger",
|
||||||
|
"title": "Challenger ({Inst})",
|
||||||
|
"description": "Clear a full level-up challenge set on the {Inst} path.",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"criterion": { "type": "challenge_set_cleared" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/* Achievements & Feats of Power — plugin styles (plain CSS, no Tailwind build;
|
||||||
|
* P-II: plugins ship their own stylesheet for non-core classes). Mirrors the v3
|
||||||
|
* dark surface tokens used across the Profile page. */
|
||||||
|
|
||||||
|
/* Secondary category pill row inside the Achievements tab (lighter .fb-tab). */
|
||||||
|
.fb-ach-pillrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.fb-ach-pill {
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(30, 41, 59, .5);
|
||||||
|
border: 1px solid rgba(51, 65, 85, .6);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.fb-ach-pill:hover { color: #e2e8f0; border-color: rgba(14, 165, 233, .4); }
|
||||||
|
.fb-ach-pill.active { color: #f8fafc; background: rgba(14, 165, 233, .15); border-color: #0ea5e9; }
|
||||||
|
.fb-ach-pill-badge {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: .15rem;
|
||||||
|
}
|
||||||
|
.fb-ach-pill.active .fb-ach-pill-badge { color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* Catalogue list. */
|
||||||
|
.fb-ach-list { display: flex; flex-direction: column; gap: .5rem; }
|
||||||
|
.fb-ach-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .85rem;
|
||||||
|
padding: .75rem .9rem;
|
||||||
|
border-radius: .6rem;
|
||||||
|
border: 1px solid rgba(51, 65, 85, .5);
|
||||||
|
background: rgba(30, 41, 59, .35);
|
||||||
|
}
|
||||||
|
.fb-ach-item.locked { opacity: .45; filter: grayscale(.6); }
|
||||||
|
.fb-ach-item.earned { border-color: rgba(14, 165, 233, .35); background: rgba(14, 165, 233, .06); }
|
||||||
|
.fb-ach-item-icon { font-size: 1.4rem; line-height: 1.6rem; flex: 0 0 auto; }
|
||||||
|
.fb-ach-item-body { min-width: 0; }
|
||||||
|
.fb-ach-item-title {
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.fb-ach-tier {
|
||||||
|
font-size: .65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7dd3fc;
|
||||||
|
border: 1px solid rgba(125, 211, 252, .35);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
}
|
||||||
|
.fb-ach-item-desc { font-size: .78rem; color: #94a3b8; margin-top: .15rem; }
|
||||||
|
|
||||||
|
/* Feats of Power trophy shelf (profile main page). */
|
||||||
|
.fb-feat-shelf {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.fb-feat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem .75rem;
|
||||||
|
border-radius: .7rem;
|
||||||
|
border: 1px solid rgba(234, 179, 8, .35);
|
||||||
|
background: linear-gradient(180deg, rgba(234, 179, 8, .1), rgba(30, 41, 59, .3));
|
||||||
|
}
|
||||||
|
.fb-feat-icon { font-size: 1.9rem; }
|
||||||
|
.fb-feat-title { font-size: .85rem; font-weight: 800; color: #fde68a; margin-top: .3rem; }
|
||||||
|
.fb-feat-desc { font-size: .72rem; color: #cbd5e1; margin-top: .25rem; }
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Achievements & Feats of Power — pure evaluation helpers.
|
||||||
|
|
||||||
|
This module holds the side-effect-free core of the engine so it is unit
|
||||||
|
testable (constitution P-V): no IO, no SQLite, no clock. `routes.py` owns the
|
||||||
|
storage/HTTP shell and calls into these functions.
|
||||||
|
|
||||||
|
**Integration law (structural):** Feats are evaluated from *activity counters*
|
||||||
|
only (`evaluate_feats` / `apply_activity`); competency Achievements are recorded
|
||||||
|
from *competency events* the source reports (`report-unlock`). Nothing here ever
|
||||||
|
converts an activity count into a competency unlock or vice versa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Counter keys the activity model owns. `*_max` keys take the running maximum;
|
||||||
|
# everything else is a cumulative running total. Kept here (not in routes) so a
|
||||||
|
# test can assert the contract without standing up a DB.
|
||||||
|
MAX_COUNTERS = frozenset({"notes_session_max", "streak_insong_max", "chart_encore_max"})
|
||||||
|
|
||||||
|
|
||||||
|
def tier_index_for(tiers, value):
|
||||||
|
"""Highest 0-based tier index whose threshold is met by ``value``.
|
||||||
|
|
||||||
|
Returns -1 when no tier is reached. Tiers are assumed ascending; we scan
|
||||||
|
all of them rather than short-circuit so an out-of-order catalogue still
|
||||||
|
resolves to the largest satisfied tier.
|
||||||
|
"""
|
||||||
|
idx = -1
|
||||||
|
for i, threshold in enumerate(tiers or []):
|
||||||
|
try:
|
||||||
|
if value >= threshold:
|
||||||
|
idx = i
|
||||||
|
except TypeError:
|
||||||
|
continue
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def feat_counter_value(feat, counters):
|
||||||
|
"""Activity-counter value backing a Feat definition (0 when absent)."""
|
||||||
|
key = feat.get("counter")
|
||||||
|
if not key:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(counters.get(key, 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_feats(feat_defs, counters):
|
||||||
|
"""Map ``feat_id -> highest reached tier index`` for all satisfied Feats.
|
||||||
|
|
||||||
|
A Feat with no tiers, or whose counter hasn't reached tier 0, is omitted.
|
||||||
|
Pure: takes the current counters snapshot, returns a plain dict.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for feat in feat_defs or []:
|
||||||
|
fid = feat.get("id")
|
||||||
|
if not fid:
|
||||||
|
continue
|
||||||
|
tiers = feat.get("tiers") or []
|
||||||
|
if not tiers:
|
||||||
|
continue
|
||||||
|
ti = tier_index_for(tiers, feat_counter_value(feat, counters))
|
||||||
|
if ti >= 0:
|
||||||
|
out[fid] = ti
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_activity(counters, delta):
|
||||||
|
"""Return a NEW counters dict after folding in one activity ``delta``.
|
||||||
|
|
||||||
|
Cumulative keys add; ``*_max`` keys keep the running maximum. The caller
|
||||||
|
(routes.py) is responsible for the only stateful bit — the per-chart play
|
||||||
|
count — and passes the post-increment value as ``delta['chart_play_count']``
|
||||||
|
so this function stays pure.
|
||||||
|
|
||||||
|
Recognised delta fields (all optional, default 0):
|
||||||
|
notes -> notes_total (+=)
|
||||||
|
song_done -> songs_done (+=)
|
||||||
|
seconds -> time_total_seconds (+=)
|
||||||
|
session_notes -> notes_session_max (max)
|
||||||
|
in_song_streak -> streak_insong_max (max)
|
||||||
|
chart_play_count -> chart_encore_max (max)
|
||||||
|
"""
|
||||||
|
out = dict(counters or {})
|
||||||
|
|
||||||
|
def _cur(key):
|
||||||
|
try:
|
||||||
|
return int(out.get(key, 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _int(v):
|
||||||
|
try:
|
||||||
|
return int(v or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
out["notes_total"] = _cur("notes_total") + _int(delta.get("notes"))
|
||||||
|
out["songs_done"] = _cur("songs_done") + _int(delta.get("song_done"))
|
||||||
|
out["time_total_seconds"] = _cur("time_total_seconds") + _int(delta.get("seconds"))
|
||||||
|
out["notes_session_max"] = max(_cur("notes_session_max"), _int(delta.get("session_notes")))
|
||||||
|
out["streak_insong_max"] = max(_cur("streak_insong_max"), _int(delta.get("in_song_streak")))
|
||||||
|
if delta.get("chart_play_count") is not None:
|
||||||
|
out["chart_encore_max"] = max(_cur("chart_encore_max"), _int(delta.get("chart_play_count")))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def consecutive_run_length(dates):
|
||||||
|
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||||||
|
|
||||||
|
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
||||||
|
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||||||
|
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
parsed = []
|
||||||
|
for d in dates or []:
|
||||||
|
try:
|
||||||
|
y, m, dd = (int(x) for x in str(d).split("-"))
|
||||||
|
parsed.append(date(y, m, dd))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not parsed:
|
||||||
|
return 0
|
||||||
|
parsed = sorted(set(parsed))
|
||||||
|
best = run = 1
|
||||||
|
for prev, cur in zip(parsed, parsed[1:]):
|
||||||
|
if (cur - prev).days == 1:
|
||||||
|
run += 1
|
||||||
|
best = max(best, run)
|
||||||
|
else:
|
||||||
|
run = 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def diff_unlocks(prev_tiers, new_tiers):
|
||||||
|
"""Feat ids whose tier advanced (incl. first unlock).
|
||||||
|
|
||||||
|
``prev_tiers`` / ``new_tiers`` are ``feat_id -> tier_index`` maps as
|
||||||
|
returned by :func:`evaluate_feats`. Returns the ids that are newly present
|
||||||
|
or moved to a higher tier — i.e. the Feats to record + announce this round.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for fid, tier in (new_tiers or {}).items():
|
||||||
|
if tier > prev_tiers.get(fid, -1):
|
||||||
|
out.append(fid)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"feats": [
|
||||||
|
{
|
||||||
|
"id": "notes_total",
|
||||||
|
"title": "Note Hunter",
|
||||||
|
"description": "Hit a colossal number of notes across all your practice.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "notes_total",
|
||||||
|
"tiers": [100000, 1000000, 10000000],
|
||||||
|
"tier_titles": ["Note Hunter", "Million-Note Maestro", "Ten-Million-Note Titan"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notes_session",
|
||||||
|
"title": "Marathon",
|
||||||
|
"description": "Hit 25,000 notes in a single sitting.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "notes_session_max",
|
||||||
|
"tiers": [25000],
|
||||||
|
"tier_titles": ["Marathon"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "streak_insong",
|
||||||
|
"title": "Untouchable",
|
||||||
|
"description": "Land a huge run of consecutive in-song hits with no miss.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": true,
|
||||||
|
"counter": "streak_insong_max",
|
||||||
|
"tiers": [1000, 5000],
|
||||||
|
"tier_titles": ["Untouchable", "Truly Untouchable"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "songs_done",
|
||||||
|
"title": "Road Warrior",
|
||||||
|
"description": "Finish a mountain of songs.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "songs_done",
|
||||||
|
"tiers": [1000, 5000],
|
||||||
|
"tier_titles": ["Road Warrior", "Road Legend"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "time_total",
|
||||||
|
"title": "Time Served",
|
||||||
|
"description": "Pour hundreds of hours into practice.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "time_total_seconds",
|
||||||
|
"tiers": [1800000, 7200000],
|
||||||
|
"tier_titles": ["Time Served (500h)", "Time Served (2,000h)"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chart_encore",
|
||||||
|
"title": "Encore",
|
||||||
|
"description": "Play the same chart again and again and again.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": false,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "chart_encore_max",
|
||||||
|
"tiers": [100, 500],
|
||||||
|
"tier_titles": ["Encore", "Standing Ovation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secret_witching",
|
||||||
|
"title": "The Witching Hour",
|
||||||
|
"description": "Practise in the dead of night, seven nights running.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": true,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": "witching_nights_run",
|
||||||
|
"tiers": [7],
|
||||||
|
"tier_titles": ["The Witching Hour"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secret_combo",
|
||||||
|
"title": "Hidden Track",
|
||||||
|
"description": "You found the hidden track.",
|
||||||
|
"category": "global",
|
||||||
|
"sourceId": "achievements",
|
||||||
|
"secret": true,
|
||||||
|
"needs_notedetect": false,
|
||||||
|
"counter": null,
|
||||||
|
"tiers": [],
|
||||||
|
"tier_titles": ["Hidden Track"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "achievements",
|
||||||
|
"name": "Achievements",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bundled": true,
|
||||||
|
"private": false,
|
||||||
|
"description": "Achievements & Feats of Power — skill milestones on your Profile, plus rare activity Feats.",
|
||||||
|
"script": "screen.js",
|
||||||
|
"styles": "assets/achievements.css",
|
||||||
|
"settings": {
|
||||||
|
"html": "settings.html",
|
||||||
|
"category": "system",
|
||||||
|
"server_files": [
|
||||||
|
"achievements/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"routes": "routes.py"
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""Achievements & Feats of Power — local engine (offline).
|
||||||
|
|
||||||
|
State lives under ``<config_dir>/achievements/``:
|
||||||
|
- ``achievements.db`` SQLite — unlocks, activity counters, derived ledger,
|
||||||
|
and the (PR3) wall sync queue.
|
||||||
|
|
||||||
|
Two surfaces, one engine, kept structurally apart (the **integration law**):
|
||||||
|
* **Feats of Power** — activity/volume. The engine OWNS raw activity counters
|
||||||
|
(`counters`), evaluates Feat thresholds, and records Feat unlocks. Feats are
|
||||||
|
the only thing that ever syncs to the public wall.
|
||||||
|
* **Achievements** — demonstrated competency. The engine RECORDS unlocks the
|
||||||
|
source reports (`report-unlock`); it never re-derives them from activity.
|
||||||
|
A baseline catalogue ships here and is driven by the built-in progression
|
||||||
|
system; richer items are contributed by source plugins at runtime.
|
||||||
|
|
||||||
|
Endpoints (all under /api/plugins/achievements/):
|
||||||
|
POST /activity bump activity counters, eval Feats, return newly-unlocked
|
||||||
|
POST /report-unlock idempotent upsert of a competency/feat unlock
|
||||||
|
POST /report-criterion record a (criterion_id, token) pair → distinct count
|
||||||
|
GET /catalog baseline competency defs + earned state
|
||||||
|
GET /earned all earned items (id, cls, category, tier, at)
|
||||||
|
GET /feats earned Feats (for the profile trophy shelf)
|
||||||
|
POST /remove-me wipe synced state (full wall-removal lands in PR2/PR3)
|
||||||
|
|
||||||
|
Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
|
||||||
|
this module is the SQLite + HTTP shell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_state = {
|
||||||
|
"db_path": None,
|
||||||
|
"dir": None, # plugin directory (for catalog JSON)
|
||||||
|
"log": logging.getLogger("feedBack.plugin.achievements"),
|
||||||
|
"engine": None, # sibling engine.py module (pure helpers)
|
||||||
|
"feat_defs": [], # parsed feats.json -> list of feat defs
|
||||||
|
"baseline": {}, # parsed achievements.json
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQLite ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
conn = sqlite3.connect(_state["db_path"], timeout=5)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _init_db():
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS unlocks (
|
||||||
|
achievement_id TEXT PRIMARY KEY,
|
||||||
|
cls TEXT NOT NULL, -- 'competency' | 'feat'
|
||||||
|
disp_category TEXT, -- global/guitar/bass/...
|
||||||
|
source_id TEXT,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unlocked_at TEXT,
|
||||||
|
synced INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS counters (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS comp_ledger (
|
||||||
|
criterion_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (criterion_id, token)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL, -- 'unlock' | 'remove'
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending' -- 'pending' | 'dead_letter'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def _read_counters(conn):
|
||||||
|
return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_counters(conn, counters):
|
||||||
|
for key, value in counters.items():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(key, int(value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_counter(conn, key, delta):
|
||||||
|
"""Increment a counter and return the new value (used for per-chart plays)."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=value+excluded.value",
|
||||||
|
(key, int(delta)),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT value FROM counters WHERE key=?", (key,)).fetchone()
|
||||||
|
return int(row["value"]) if row else int(delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _earned_feat_tiers(conn):
|
||||||
|
return {
|
||||||
|
row["achievement_id"]: int(row["tier"])
|
||||||
|
for row in conn.execute("SELECT achievement_id, tier FROM unlocks WHERE cls='feat'")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_unlock(conn, ach_id, cls, disp_category, source_id, tier, at):
|
||||||
|
"""Idempotent upsert; only advances the tier upward. Returns True if changed."""
|
||||||
|
row = conn.execute("SELECT tier FROM unlocks WHERE achievement_id=?", (ach_id,)).fetchone()
|
||||||
|
if row is not None and int(row["tier"]) >= int(tier):
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO unlocks(achievement_id, cls, disp_category, source_id, tier, unlocked_at, synced)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(achievement_id) DO UPDATE SET
|
||||||
|
tier=excluded.tier,
|
||||||
|
cls=excluded.cls,
|
||||||
|
disp_category=COALESCE(excluded.disp_category, unlocks.disp_category),
|
||||||
|
source_id=COALESCE(excluded.source_id, unlocks.source_id)
|
||||||
|
""",
|
||||||
|
(ach_id, cls, disp_category, source_id, int(tier), at or _now_iso()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Catalog loading ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _feat_by_id(fid):
|
||||||
|
for f in _state["feat_defs"]:
|
||||||
|
if f.get("id") == fid:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalogs():
|
||||||
|
base = Path(_state["dir"])
|
||||||
|
try:
|
||||||
|
feats = json.loads((base / "feats.json").read_text(encoding="utf-8"))
|
||||||
|
_state["feat_defs"] = feats.get("feats", []) if isinstance(feats, dict) else []
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_state["log"].warning("achievements: could not load feats.json: %s", e)
|
||||||
|
_state["feat_defs"] = []
|
||||||
|
try:
|
||||||
|
_state["baseline"] = json.loads((base / "achievements.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_state["log"].warning("achievements: could not load achievements.json: %s", e)
|
||||||
|
_state["baseline"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ActivityIn(BaseModel):
|
||||||
|
notes: int = Field(ge=0, default=0)
|
||||||
|
session_notes: int = Field(ge=0, default=0)
|
||||||
|
in_song_streak: int = Field(ge=0, default=0)
|
||||||
|
song_done: int = Field(ge=0, default=0)
|
||||||
|
seconds: int = Field(ge=0, default=0)
|
||||||
|
chart: str | None = None
|
||||||
|
night_session: bool = False
|
||||||
|
night_date: str | None = None # 'YYYY-MM-DD', frontend supplies (no server clock)
|
||||||
|
|
||||||
|
|
||||||
|
class UnlockIn(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: str = "achievement" # 'achievement' | 'feat'
|
||||||
|
category: str | None = None # display category (global/guitar/...)
|
||||||
|
sourceId: str | None = None
|
||||||
|
tier: int = Field(ge=0, default=0)
|
||||||
|
at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CriterionIn(BaseModel):
|
||||||
|
criterion_id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI wiring ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def setup(app, context):
|
||||||
|
config_dir = context["config_dir"]
|
||||||
|
base = Path(config_dir) / "achievements"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
_state["db_path"] = str(base / "achievements.db")
|
||||||
|
_state["dir"] = str(Path(__file__).resolve().parent)
|
||||||
|
_state["log"] = context.get("log") or _state["log"]
|
||||||
|
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
|
||||||
|
# plain-import fallback for pytest / standalone use.
|
||||||
|
load_sibling = context.get("load_sibling")
|
||||||
|
try:
|
||||||
|
_state["engine"] = load_sibling("engine") if load_sibling else __import__("engine")
|
||||||
|
except Exception: # noqa: BLE001 — last-ditch, keep the plugin alive
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"achievements_engine", str(Path(__file__).resolve().parent / "engine.py"))
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_state["engine"] = mod
|
||||||
|
_init_db()
|
||||||
|
_load_catalogs()
|
||||||
|
log = _state["log"]
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/activity")
|
||||||
|
def post_activity(body: ActivityIn):
|
||||||
|
engine = _state["engine"]
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
# Per-chart play count is the only stateful bit; bump it first so
|
||||||
|
# apply_activity() stays pure (it just takes the new max).
|
||||||
|
chart_play_count = None
|
||||||
|
if body.song_done and body.chart:
|
||||||
|
chart_key = "chart_plays:" + str(abs(hash(body.chart)))
|
||||||
|
chart_play_count = _bump_counter(conn, chart_key, 1)
|
||||||
|
# Night-window ledger → consecutive-night run feeds witching feat.
|
||||||
|
if body.night_session and body.night_date:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)",
|
||||||
|
(body.night_date,),
|
||||||
|
)
|
||||||
|
nights = [r["token"] for r in conn.execute(
|
||||||
|
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
|
||||||
|
run = engine.consecutive_run_length(nights)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO counters(key, value) VALUES ('witching_nights_run', ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (run,))
|
||||||
|
|
||||||
|
counters = _read_counters(conn)
|
||||||
|
prev_tiers = _state["engine"].evaluate_feats(_state["feat_defs"], counters)
|
||||||
|
new_counters = engine.apply_activity(counters, {
|
||||||
|
"notes": body.notes,
|
||||||
|
"session_notes": body.session_notes,
|
||||||
|
"in_song_streak": body.in_song_streak,
|
||||||
|
"song_done": body.song_done,
|
||||||
|
"seconds": body.seconds,
|
||||||
|
"chart_play_count": chart_play_count,
|
||||||
|
})
|
||||||
|
_write_counters(conn, new_counters)
|
||||||
|
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
|
||||||
|
fresh = engine.diff_unlocks(prev_tiers, new_tiers)
|
||||||
|
unlocked = []
|
||||||
|
for fid in fresh:
|
||||||
|
f = _feat_by_id(fid) or {}
|
||||||
|
tier = new_tiers[fid]
|
||||||
|
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, _now_iso()):
|
||||||
|
unlocked.append(_feat_payload(fid, f, tier))
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/report-unlock")
|
||||||
|
def post_report_unlock(body: UnlockIn):
|
||||||
|
cls = "feat" if body.kind == "feat" else "competency"
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
changed = _record_unlock(
|
||||||
|
conn, body.id, cls, body.category, body.sourceId, body.tier, body.at)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/report-criterion")
|
||||||
|
def post_report_criterion(body: CriterionIn):
|
||||||
|
"""Record a distinct (criterion_id, token); return the distinct count.
|
||||||
|
|
||||||
|
Lets a baseline subscriber aggregate multi-event criteria (e.g. the set
|
||||||
|
of distinct days with a real advance → `steady_hands`) without us
|
||||||
|
re-deriving competency from activity. Bookkeeping over events only.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES (?, ?)",
|
||||||
|
(body.criterion_id, body.token),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM comp_ledger WHERE criterion_id=?",
|
||||||
|
(body.criterion_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "count": int(row["n"]) if row else 0}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/catalog")
|
||||||
|
def get_catalog():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
earned = _earned_map(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"baseline": _state["baseline"], "earned": earned}
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/earned")
|
||||||
|
def get_earned():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
return {"earned": list(_earned_map(conn).values())}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/plugins/achievements/feats")
|
||||||
|
def get_feats():
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT achievement_id, tier, unlocked_at FROM unlocks WHERE cls='feat'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
fid = row["achievement_id"]
|
||||||
|
f = _feat_by_id(fid) or {}
|
||||||
|
payload = _feat_payload(fid, f, int(row["tier"]))
|
||||||
|
payload["unlocked_at"] = row["unlocked_at"]
|
||||||
|
out.append(payload)
|
||||||
|
return {"feats": out}
|
||||||
|
|
||||||
|
@app.post("/api/plugins/achievements/remove-me")
|
||||||
|
def post_remove_me():
|
||||||
|
# Local removal works offline: drop the synced flag so nothing re-syncs,
|
||||||
|
# and enqueue a wall removal (drained in PR3). The wall identity
|
||||||
|
# (player_hash) is resolved server-side at drain time, not stored here.
|
||||||
|
with _lock:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', '{}', 'pending')")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info("achievements engine ready (%d feats, baseline v%s)",
|
||||||
|
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
|
||||||
|
|
||||||
|
|
||||||
|
def _feat_payload(fid, feat, tier):
|
||||||
|
titles = feat.get("tier_titles") or []
|
||||||
|
title = titles[tier] if 0 <= tier < len(titles) else feat.get("title", fid)
|
||||||
|
return {
|
||||||
|
"id": fid,
|
||||||
|
"cls": "feat",
|
||||||
|
"tier": tier,
|
||||||
|
"title": title,
|
||||||
|
"description": feat.get("description", ""),
|
||||||
|
"category": feat.get("category", "global"),
|
||||||
|
"secret": bool(feat.get("secret", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _earned_map(conn):
|
||||||
|
out = {}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT achievement_id, cls, disp_category, tier, unlocked_at FROM unlocks"
|
||||||
|
):
|
||||||
|
out[row["achievement_id"]] = {
|
||||||
|
"id": row["achievement_id"],
|
||||||
|
"cls": row["cls"],
|
||||||
|
"category": row["disp_category"],
|
||||||
|
"tier": int(row["tier"]),
|
||||||
|
"unlocked_at": row["unlocked_at"],
|
||||||
|
}
|
||||||
|
return out
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/*
|
||||||
|
* Achievements & Feats of Power — frontend engine (vanilla, constitution P-II).
|
||||||
|
*
|
||||||
|
* Renders into the two core Profile mount points (achievements epic):
|
||||||
|
* #v3-profile-feats-slot → earned Feats trophy shelf (hidden-until-earned)
|
||||||
|
* #v3-profile-achievements-mount → full competency catalogue (locked = greyed),
|
||||||
|
* grouped by instrument via a secondary pill row.
|
||||||
|
* Re-injects on every `v3:profile-rendered` (core wipes the mounts each render).
|
||||||
|
*
|
||||||
|
* Also exposes the cross-plugin registration API `window.feedBack.achievements`
|
||||||
|
* (v1) so source plugins (Virtuoso, notedetect, …) contribute competency defs +
|
||||||
|
* report unlocks without us hardcoding their vocabulary. Load-order safe via the
|
||||||
|
* `window.__feedBackAchievementsPending` queue + `achievements:ready` event.
|
||||||
|
*
|
||||||
|
* INTEGRATION LAW: Feats read activity counters only (we POST batched activity on
|
||||||
|
* song:ended); competency Achievements are evaluated from progression EVENTS only.
|
||||||
|
* The two paths never cross.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '/api/plugins/achievements';
|
||||||
|
var bus = window.feedBack;
|
||||||
|
if (!bus) return; // bus must exist (capabilities.js); nothing to attach to.
|
||||||
|
|
||||||
|
var esc = function (s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────
|
||||||
|
var registered = {}; // id -> def (contributed + expanded baseline defs)
|
||||||
|
var earned = {}; // id -> { tier, cls, category }
|
||||||
|
var baseline = null; // /catalog baseline blob
|
||||||
|
var CAT_KEY = 'v3-profile-ach-cat';
|
||||||
|
var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys'];
|
||||||
|
|
||||||
|
function progState() {
|
||||||
|
return (window.v3Progression && window.v3Progression.get()) || null;
|
||||||
|
}
|
||||||
|
function notedetectPresent() {
|
||||||
|
return typeof window.createNoteDetector === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend I/O ──────────────────────────────────────────────────────────
|
||||||
|
function fetchJSON(path, opts) {
|
||||||
|
return fetch(API + path, opts).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
function postUnlock(def, tier) {
|
||||||
|
return fetch(API + '/report-unlock', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: def.id, kind: def.kind || 'achievement',
|
||||||
|
category: def.category || 'global', sourceId: def.sourceId || 'achievements',
|
||||||
|
tier: tier || 0,
|
||||||
|
}),
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
function refreshEarned() {
|
||||||
|
return fetchJSON('/earned').then(function (data) {
|
||||||
|
earned = {};
|
||||||
|
((data && data.earned) || []).forEach(function (e) { earned[e.id] = e; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tier math (mirrors engine.tier_index_for) ────────────────────────────
|
||||||
|
function tierIndexFor(tiers, value) {
|
||||||
|
var idx = -1;
|
||||||
|
(tiers || []).forEach(function (t, i) { if (value >= t) idx = i; });
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
function alreadyEarnedAtLeast(id, tier) {
|
||||||
|
var e = earned[id];
|
||||||
|
return e && e.tier >= tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration API (v1) ────────────────────────────────────────────────
|
||||||
|
function register(def) {
|
||||||
|
if (!def || !def.id) return;
|
||||||
|
registered[def.id] = {
|
||||||
|
id: def.id, kind: def.kind || 'achievement', category: def.category || 'global',
|
||||||
|
title: def.title || def.id, description: def.description || '',
|
||||||
|
secret: !!def.secret, sourceId: def.sourceId || 'unknown',
|
||||||
|
};
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
function registerAll(defs) { (defs || []).forEach(register); }
|
||||||
|
function unlock(id, opts) {
|
||||||
|
var def = registered[id] || { id: id, kind: 'achievement', category: 'global', sourceId: 'unknown' };
|
||||||
|
var tier = (opts && opts.tier) || 0;
|
||||||
|
if (alreadyEarnedAtLeast(id, tier)) return Promise.resolve();
|
||||||
|
return postUnlock(def, tier).then(function () {
|
||||||
|
return refreshEarned().then(function () {
|
||||||
|
// A contributed Feat unlock would enqueue a wall sync here when
|
||||||
|
// opted-in (PR2/PR3); competency never syncs (integration law).
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function progress() { /* accepted, optional — display is greyed/earned, not bars */ }
|
||||||
|
|
||||||
|
var api = { version: 1, register: register, registerAll: registerAll, unlock: unlock, progress: progress };
|
||||||
|
bus.achievements = api;
|
||||||
|
// Drain sources that loaded before us (minigames pending-queue pattern).
|
||||||
|
try { (window.__feedBackAchievementsPending || []).forEach(function (fn) {
|
||||||
|
try { typeof fn === 'function' ? fn(api) : register(fn); } catch (_) { /* noop */ }
|
||||||
|
}); } catch (_) { /* noop */ }
|
||||||
|
window.__feedBackAchievementsPending = null;
|
||||||
|
try { bus.emit && bus.emit('achievements:ready', { version: 1 }); } catch (_) { /* noop */ }
|
||||||
|
|
||||||
|
// ── Baseline competency: evaluate from progression EVENTS only ────────────
|
||||||
|
function expandBaseline() {
|
||||||
|
// Register baseline defs (always present — built-in progression is always
|
||||||
|
// present) so they render greyed even before they're earned. Per-instrument
|
||||||
|
// templates expand across the REAL paths that exist (auto-extends).
|
||||||
|
if (!baseline) return;
|
||||||
|
(baseline.global || []).forEach(function (d) {
|
||||||
|
register({ id: d.id, kind: 'achievement', category: 'global', title: d.title,
|
||||||
|
description: d.description, sourceId: 'achievements' });
|
||||||
|
});
|
||||||
|
var paths = (progState() && progState().paths) || [];
|
||||||
|
var pathIds = paths.length ? paths.map(function (p) { return { id: p.id, name: p.name }; })
|
||||||
|
: INSTRUMENTS.map(function (i) { return { id: i, name: i.charAt(0).toUpperCase() + i.slice(1) }; });
|
||||||
|
(baseline.per_instrument || []).forEach(function (tpl) {
|
||||||
|
pathIds.forEach(function (pi) {
|
||||||
|
var inst = pi.name;
|
||||||
|
register({
|
||||||
|
id: tpl.id + ':' + pi.id, kind: 'achievement', category: pi.id,
|
||||||
|
title: (tpl.title || '').replace(/\{Inst\}/g, inst),
|
||||||
|
description: (tpl.description || '').replace(/\{Inst\}/g, inst),
|
||||||
|
sourceId: 'achievements',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateBaseline() {
|
||||||
|
var prog = progState();
|
||||||
|
if (!prog || !baseline) return;
|
||||||
|
var defById = {};
|
||||||
|
(baseline.global || []).forEach(function (d) { defById[d.id] = d; });
|
||||||
|
|
||||||
|
// mastery_rank → first_steps / ascendant
|
||||||
|
(baseline.global || []).forEach(function (d) {
|
||||||
|
var crit = d.criterion || {};
|
||||||
|
if (crit.type === 'mastery_rank') {
|
||||||
|
var ti = tierIndexFor(crit.tiers || d.tiers, prog.mastery_rank || 0);
|
||||||
|
if (ti >= 0) baselineUnlock(d.id, 'global', ti);
|
||||||
|
} else if (crit.type === 'paths_at_level') {
|
||||||
|
var n = ((prog.paths) || []).filter(function (p) { return (p.level || 0) >= (crit.level || 10); }).length;
|
||||||
|
var ti2 = tierIndexFor(crit.tiers || d.tiers, n);
|
||||||
|
if (ti2 >= 0) baselineUnlock(d.id, 'global', ti2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// per-instrument path_rank → reach Lv 10/25/max in that path
|
||||||
|
var tpl = (baseline.per_instrument || []).filter(function (t) { return t.id === 'path_rank'; })[0];
|
||||||
|
if (tpl) {
|
||||||
|
((prog.paths) || []).forEach(function (p) {
|
||||||
|
var thresholds = (tpl.criterion && tpl.criterion.tiers) || [10, 25, 'max'];
|
||||||
|
var resolved = thresholds.map(function (t) { return t === 'max' ? (p.max_level || 9999) : t; });
|
||||||
|
var ti = tierIndexFor(resolved, p.level || 0);
|
||||||
|
if (ti >= 0) baselineUnlock('path_rank:' + p.id, p.id, ti);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineUnlock(id, category, tier) {
|
||||||
|
if (alreadyEarnedAtLeast(id, tier)) return;
|
||||||
|
var def = registered[id] || { id: id, kind: 'achievement', category: category, sourceId: 'achievements' };
|
||||||
|
postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// growth-streak + challenger record on real competency events (date ledger /
|
||||||
|
// distinct challenge sets) — kept as bookkeeping over EVENTS, never activity.
|
||||||
|
function recordGrowthDay() {
|
||||||
|
var date = new Date();
|
||||||
|
var iso = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
|
||||||
|
fetchJSON('/report-criterion', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }),
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res) return;
|
||||||
|
var d = ((baseline && baseline.global) || []).filter(function (x) { return x.id === 'steady_hands'; })[0];
|
||||||
|
var ti = tierIndexFor((d && d.tiers) || [7, 30, 100], res.count || 0);
|
||||||
|
if (ti >= 0) baselineUnlock('steady_hands', 'global', ti);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity (Feats): in-memory session counters, flushed on song:ended ───
|
||||||
|
var session = { notesTotal: 0 }; // cumulative across this sitting
|
||||||
|
var song = { hits: 0, streak: 0, maxStreak: 0, chart: null };
|
||||||
|
function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null }; }
|
||||||
|
|
||||||
|
function flushActivity(seconds) {
|
||||||
|
// No notedetect → song.hits stays 0; notes-based Feats simply don't move
|
||||||
|
// (graceful degradation). song_done / seconds / chart still flow so the
|
||||||
|
// notedetect-free Feats (Road Warrior, Time Served, Encore) progress.
|
||||||
|
var hour = new Date().getHours();
|
||||||
|
var isNight = hour >= 2 && hour < 5;
|
||||||
|
var d = new Date();
|
||||||
|
var iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
var body = {
|
||||||
|
notes: song.hits,
|
||||||
|
session_notes: session.notesTotal,
|
||||||
|
in_song_streak: song.maxStreak,
|
||||||
|
song_done: 1,
|
||||||
|
seconds: Math.max(0, Math.round(seconds || 0)),
|
||||||
|
chart: song.chart,
|
||||||
|
night_session: isNight,
|
||||||
|
night_date: isNight ? iso : null,
|
||||||
|
};
|
||||||
|
fetchJSON('/activity', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res && res.unlocked && res.unlocked.length) {
|
||||||
|
// A Feat just unlocked — refresh the shelf + toast via the bus.
|
||||||
|
fetchFeatsAndRender();
|
||||||
|
res.unlocked.forEach(function (f) {
|
||||||
|
try { bus.emit && bus.emit('achievements:feat-unlocked', f); } catch (_) { /* noop */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────────────
|
||||||
|
var _renderQueued = false;
|
||||||
|
function scheduleRender() {
|
||||||
|
if (_renderQueued) return;
|
||||||
|
_renderQueued = true;
|
||||||
|
(window.requestAnimationFrame || window.setTimeout)(function () { _renderQueued = false; renderAll(); }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderCatalog();
|
||||||
|
fetchFeatsAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoriesForDisplay() {
|
||||||
|
var cats = [{ id: 'global', name: 'Global' }];
|
||||||
|
var paths = (progState() && progState().paths) || [];
|
||||||
|
if (paths.length) {
|
||||||
|
paths.forEach(function (p) { cats.push({ id: p.id, name: p.name }); });
|
||||||
|
} else {
|
||||||
|
// Fallback before progression loads: show the known instrument cats
|
||||||
|
// that actually have registered items.
|
||||||
|
INSTRUMENTS.forEach(function (i) {
|
||||||
|
if (Object.keys(registered).some(function (id) { return registered[id].category === i; })) {
|
||||||
|
cats.push({ id: i, name: i.charAt(0).toUpperCase() + i.slice(1) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsForCategory(catId) {
|
||||||
|
return Object.keys(registered).map(function (id) { return registered[id]; })
|
||||||
|
.filter(function (d) { return (d.category || 'global') === catId; })
|
||||||
|
// Hide un-earned secret items (revealed only once earned).
|
||||||
|
.filter(function (d) { return !d.secret || earned[d.id]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCatalog() {
|
||||||
|
var mount = document.getElementById('v3-profile-achievements-mount');
|
||||||
|
if (!mount) return;
|
||||||
|
// Hide the core empty-state note now that we own this mount.
|
||||||
|
var emptyNote = document.querySelector('[data-empty-for="v3-profile-achievements-mount"]');
|
||||||
|
if (emptyNote) emptyNote.style.display = 'none';
|
||||||
|
|
||||||
|
var cats = categoriesForDisplay();
|
||||||
|
var saved = null;
|
||||||
|
try { saved = localStorage.getItem(CAT_KEY); } catch (_) { /* noop */ }
|
||||||
|
// Default to the player's primary path (first path), fallback Global.
|
||||||
|
var primary = (progState() && progState().paths && progState().paths[0] && progState().paths[0].id) || 'global';
|
||||||
|
var active = cats.some(function (c) { return c.id === saved; }) ? saved
|
||||||
|
: (cats.some(function (c) { return c.id === primary; }) ? primary : 'global');
|
||||||
|
|
||||||
|
var pills = cats.map(function (c) {
|
||||||
|
var items = itemsForCategory(c.id);
|
||||||
|
var got = items.filter(function (d) { return earned[d.id]; }).length;
|
||||||
|
return '<button type="button" class="fb-ach-pill' + (c.id === active ? ' active' : '') +
|
||||||
|
'" data-cat="' + esc(c.id) + '">' + esc(c.name) +
|
||||||
|
' <span class="fb-ach-pill-badge">' + got + '/' + items.length + '</span></button>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var items = itemsForCategory(active);
|
||||||
|
var list = items.length ? items.map(function (d) {
|
||||||
|
var got = !!earned[d.id];
|
||||||
|
var tier = got ? (earned[d.id].tier || 0) : -1;
|
||||||
|
return '<div class="fb-ach-item' + (got ? ' earned' : ' locked') + '">' +
|
||||||
|
'<div class="fb-ach-item-icon">' + (got ? '🏅' : '🔒') + '</div>' +
|
||||||
|
'<div class="fb-ach-item-body">' +
|
||||||
|
'<div class="fb-ach-item-title">' + esc(d.title) +
|
||||||
|
(got && tier > 0 ? ' <span class="fb-ach-tier">tier ' + (tier + 1) + '</span>' : '') + '</div>' +
|
||||||
|
'<div class="fb-ach-item-desc">' + esc(d.description) + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('') : '<p class="fb-tabpanel-empty">No achievements in this category yet.</p>';
|
||||||
|
|
||||||
|
mount.innerHTML =
|
||||||
|
'<div class="fb-ach-pillrow">' + pills + '</div>' +
|
||||||
|
'<div class="fb-ach-list">' + list + '</div>';
|
||||||
|
mount.querySelectorAll('[data-cat]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
try { localStorage.setItem(CAT_KEY, b.dataset.cat); } catch (_) { /* noop */ }
|
||||||
|
renderCatalog();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFeatsAndRender() {
|
||||||
|
return fetchJSON('/feats').then(function (data) { renderFeats((data && data.feats) || []); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeats(feats) {
|
||||||
|
var slot = document.getElementById('v3-profile-feats-slot');
|
||||||
|
if (!slot) return;
|
||||||
|
if (!feats.length) { slot.innerHTML = ''; return; } // hidden-until-earned
|
||||||
|
var cards = feats.map(function (f) {
|
||||||
|
return '<div class="fb-feat-card" title="' + esc(f.description) + '">' +
|
||||||
|
'<div class="fb-feat-icon">🏆</div>' +
|
||||||
|
'<div class="fb-feat-title">' + esc(f.title) + '</div>' +
|
||||||
|
'<div class="fb-feat-desc">' + esc(f.description) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
// Opt-out users get a subtle wall hint linking to Settings (PR2 wires it).
|
||||||
|
var hint = '';
|
||||||
|
slot.innerHTML =
|
||||||
|
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||||
|
'<h3 class="text-lg font-bold text-fb-text mb-3">Feats of Power</h3>' +
|
||||||
|
'<div class="fb-feat-shelf">' + cards + '</div>' + hint +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
fetchJSON('/catalog').then(function (data) {
|
||||||
|
baseline = (data && data.baseline) || {};
|
||||||
|
((data && data.earned) && (earned = {}, Object.keys(data.earned).forEach(function (id) { earned[id] = data.earned[id]; })));
|
||||||
|
expandBaseline();
|
||||||
|
evaluateBaseline();
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
// Re-inject on every profile entry (core wipes the mounts each render).
|
||||||
|
document.addEventListener('v3:profile-rendered', function () { renderAll(); });
|
||||||
|
|
||||||
|
// Competency events → re-expand (paths may have appeared) + re-evaluate.
|
||||||
|
['progression:updated', 'progression:rank-changed', 'progression:path-level-up'].forEach(function (ev) {
|
||||||
|
bus.on && bus.on(ev, function () { expandBaseline(); evaluateBaseline(); });
|
||||||
|
});
|
||||||
|
// A real competency advance ticks the growth-streak day ledger.
|
||||||
|
['progression:rank-changed', 'progression:path-level-up', 'progression:challenge-completed'].forEach(function (ev) {
|
||||||
|
bus.on && bus.on(ev, function () { recordGrowthDay(); });
|
||||||
|
});
|
||||||
|
// challenger:<inst> — clearing a full level-up set for a path.
|
||||||
|
bus.on && bus.on('progression:path-level-up', function (e) {
|
||||||
|
var pid = e && e.detail && (e.detail.path_id || e.detail.id);
|
||||||
|
if (pid) baselineUnlock('challenger:' + pid, pid, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activity (Feats) — in-memory counters, flushed once per song.
|
||||||
|
bus.on && bus.on('song:loading', function (e) {
|
||||||
|
resetSong(e && e.detail && e.detail.filename);
|
||||||
|
});
|
||||||
|
bus.on && bus.on('note:hit', function () {
|
||||||
|
song.hits++; song.streak++; session.notesTotal++;
|
||||||
|
if (song.streak > song.maxStreak) song.maxStreak = song.streak;
|
||||||
|
});
|
||||||
|
bus.on && bus.on('note:miss', function () { song.streak = 0; });
|
||||||
|
bus.on && bus.on('song:ended', function (e) {
|
||||||
|
flushActivity(e && e.detail && (e.detail.time || e.detail.audioT));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Achievements plugin settings panel. PR1 ships an informational stub; the
|
||||||
|
Privacy opt-in toggle + "Remove me from the wall" button land in PR2. -->
|
||||||
|
<div class="text-sm text-gray-300 space-y-2">
|
||||||
|
<p>Your <strong>Achievements</strong> (skill milestones) and <strong>Feats of Power</strong>
|
||||||
|
(rare activity trophies) live on your <em>Profile</em> page. Everything here is
|
||||||
|
local and private.</p>
|
||||||
|
<p class="text-gray-400">Sharing Feats on the public wall is opt-in and arrives in a later update.</p>
|
||||||
|
</div>
|
||||||
+84
-10
@@ -96,6 +96,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Profile screen (#v3-profile) ────────────────────────────────────────-
|
// ── Profile screen (#v3-profile) ────────────────────────────────────────-
|
||||||
|
// The screen is tabbed exactly like the v3 Settings page (.fb-tabbar /
|
||||||
|
// .fb-tab[data-tab] / .fb-tabpanel[data-tab]; achievements epic): a
|
||||||
|
// **Profile** (main) tab and an **Achievements** tab. Core only ships the
|
||||||
|
// shell + two mount points; the bundled achievements plugin renders the
|
||||||
|
// Feats trophy shelf (#v3-profile-feats-slot, earned-only) and the full
|
||||||
|
// competency catalogue (#v3-profile-achievements-mount) into them on the
|
||||||
|
// `v3:profile-rendered` event below. Active tab persists in localStorage,
|
||||||
|
// mirroring settings.js.
|
||||||
|
var PROFILE_TAB_KEY = 'v3-profile-tab';
|
||||||
|
var PROFILE_DEFAULT_TAB = 'profile';
|
||||||
|
|
||||||
|
function activateProfileTab(tab) {
|
||||||
|
var root = document.getElementById('v3-profile');
|
||||||
|
if (!root) return;
|
||||||
|
var bar = root.querySelector('#v3-profile-tabbar');
|
||||||
|
var tabs = [];
|
||||||
|
if (bar) bar.querySelectorAll('.fb-tab').forEach(function (b) { if (b.dataset.tab) tabs.push(b.dataset.tab); });
|
||||||
|
if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : PROFILE_DEFAULT_TAB;
|
||||||
|
root.querySelectorAll('#v3-profile-tabbar .fb-tab').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.fb-tabpanel').forEach(function (p) {
|
||||||
|
p.classList.toggle('active', p.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
try { localStorage.setItem(PROFILE_TAB_KEY, tab); } catch (_) { /* private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireProfileTabs() {
|
||||||
|
var bar = document.getElementById('v3-profile-tabbar');
|
||||||
|
if (bar && bar.dataset.wired !== '1') {
|
||||||
|
bar.dataset.wired = '1';
|
||||||
|
bar.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
|
||||||
|
if (btn && btn.dataset.tab) activateProfileTab(btn.dataset.tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var saved = PROFILE_DEFAULT_TAB;
|
||||||
|
try { saved = localStorage.getItem(PROFILE_TAB_KEY) || PROFILE_DEFAULT_TAB; } catch (_) { /* noop */ }
|
||||||
|
activateProfileTab(saved);
|
||||||
|
}
|
||||||
|
|
||||||
function renderProfileScreen() {
|
function renderProfileScreen() {
|
||||||
const root = document.getElementById('v3-profile');
|
const root = document.getElementById('v3-profile');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -110,9 +151,8 @@
|
|||||||
const pathChips = ((prog && prog.paths) || []).map((path) =>
|
const pathChips = ((prog && prog.paths) || []).map((path) =>
|
||||||
'<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
|
'<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
|
||||||
esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
|
esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
|
||||||
root.innerHTML =
|
// Header card (Profile main tab).
|
||||||
'<div class="max-w-4xl mx-auto p-6 md:p-8 space-y-6">' +
|
const headerCard =
|
||||||
// Header card
|
|
||||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
|
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
|
||||||
'<span data-v3-avatar-frame class="inline-block rounded-full">' +
|
'<span data-v3-avatar-frame class="inline-block rounded-full">' +
|
||||||
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
|
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
|
||||||
@@ -128,17 +168,39 @@
|
|||||||
'<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
|
'<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
|
||||||
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name & avatar</button>' +
|
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name & avatar</button>' +
|
||||||
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges & quests →</button>' +
|
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges & quests →</button>' +
|
||||||
'</div></div></div>' +
|
'</div></div></div>';
|
||||||
// Per-song bests — top scored songs from /api/stats/top, filled by
|
// Per-song bests — top scored songs from /api/stats/top, filled by
|
||||||
// renderBests() after innerHTML is set. The placeholder text shows
|
// renderBests() after innerHTML is set. The placeholder text shows
|
||||||
// during load and when nothing's been scored yet.
|
// during load and when nothing's been scored yet.
|
||||||
|
const bestsCard =
|
||||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||||
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
|
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
|
||||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||||
|
'</div>';
|
||||||
|
const playerIdFooter = (_profile && _profile.player_hash
|
||||||
|
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||||
|
: '');
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
|
||||||
|
'<div class="fb-tabbar" id="v3-profile-tabbar">' +
|
||||||
|
'<button type="button" class="fb-tab" data-tab="profile">Profile</button>' +
|
||||||
|
'<button type="button" class="fb-tab" data-tab="achievements">Achievements</button>' +
|
||||||
|
'</div>' +
|
||||||
|
// ── Profile (main) panel ──────────────────────────────────────────
|
||||||
|
'<div class="fb-tabpanel" data-tab="profile">' +
|
||||||
|
'<div class="space-y-6">' +
|
||||||
|
headerCard +
|
||||||
|
bestsCard +
|
||||||
|
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||||
|
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||||
|
'<div id="v3-profile-feats-slot"></div>' +
|
||||||
|
playerIdFooter +
|
||||||
|
'</div></div>' +
|
||||||
|
// ── Achievements panel ────────────────────────────────────────────
|
||||||
|
'<div class="fb-tabpanel" data-tab="achievements">' +
|
||||||
|
'<div id="v3-profile-achievements-mount"></div>' +
|
||||||
|
'<p class="fb-tabpanel-empty" data-empty-for="v3-profile-achievements-mount">Install the Achievements plugin to track your skill milestones.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(_profile && _profile.player_hash
|
|
||||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
|
||||||
: '') +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
const edit = root.querySelector('[data-v3-edit-profile]');
|
const edit = root.querySelector('[data-v3-edit-profile]');
|
||||||
if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
|
if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
|
||||||
@@ -147,7 +209,13 @@
|
|||||||
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
||||||
window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
|
window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
|
||||||
}
|
}
|
||||||
|
wireProfileTabs();
|
||||||
renderBests();
|
renderBests();
|
||||||
|
// Tell the achievements plugin (or any profile consumer) the shell +
|
||||||
|
// mount points exist now, so it can (re)inject on every profile entry —
|
||||||
|
// innerHTML above wipes prior injected content. Mirrors
|
||||||
|
// `v3:settings-rendered`. Harmless if no listener is attached.
|
||||||
|
try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill the "Your best scores" panel from /api/stats/top (top scored songs,
|
// Fill the "Your best scores" panel from /api/stats/top (top scored songs,
|
||||||
@@ -646,6 +714,12 @@
|
|||||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||||
window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
|
window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
|
||||||
window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
|
window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
|
||||||
|
// Re-render on every Profile entry so the Feats shelf + Achievements
|
||||||
|
// catalogue refresh (and `v3:profile-rendered` re-fires for the
|
||||||
|
// plugin) — the plugin may have loaded after the initial boot render.
|
||||||
|
window.feedBack.on('screen:changed', (e) => {
|
||||||
|
if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'achievements'))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import routes as ach_routes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
app = FastAPI()
|
||||||
|
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||||
|
return TestClient(app)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Pure-helper unit tests for the achievements engine (no IO, P-V)."""
|
||||||
|
|
||||||
|
import engine
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierIndexFor:
|
||||||
|
def test_below_first_tier(self):
|
||||||
|
assert engine.tier_index_for([100000, 1000000], 50000) == -1
|
||||||
|
|
||||||
|
def test_exact_threshold(self):
|
||||||
|
assert engine.tier_index_for([100000, 1000000], 100000) == 0
|
||||||
|
|
||||||
|
def test_highest_reached(self):
|
||||||
|
assert engine.tier_index_for([100000, 1000000, 10000000], 2000000) == 1
|
||||||
|
|
||||||
|
def test_all_tiers(self):
|
||||||
|
assert engine.tier_index_for([100, 500], 9999) == 1
|
||||||
|
|
||||||
|
def test_empty_tiers(self):
|
||||||
|
assert engine.tier_index_for([], 10) == -1
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyActivity:
|
||||||
|
def test_cumulative_adds(self):
|
||||||
|
c = engine.apply_activity({}, {"notes": 10, "song_done": 1, "seconds": 30})
|
||||||
|
assert c["notes_total"] == 10
|
||||||
|
assert c["songs_done"] == 1
|
||||||
|
assert c["time_total_seconds"] == 30
|
||||||
|
c = engine.apply_activity(c, {"notes": 5, "song_done": 1, "seconds": 20})
|
||||||
|
assert c["notes_total"] == 15
|
||||||
|
assert c["songs_done"] == 2
|
||||||
|
assert c["time_total_seconds"] == 50
|
||||||
|
|
||||||
|
def test_max_counters_take_maximum(self):
|
||||||
|
c = engine.apply_activity({}, {"session_notes": 100, "in_song_streak": 40})
|
||||||
|
c = engine.apply_activity(c, {"session_notes": 60, "in_song_streak": 90})
|
||||||
|
assert c["notes_session_max"] == 100
|
||||||
|
assert c["streak_insong_max"] == 90
|
||||||
|
|
||||||
|
def test_chart_encore_only_when_present(self):
|
||||||
|
c = engine.apply_activity({}, {"notes": 1})
|
||||||
|
assert "chart_encore_max" not in c
|
||||||
|
c = engine.apply_activity(c, {"chart_play_count": 7})
|
||||||
|
assert c["chart_encore_max"] == 7
|
||||||
|
|
||||||
|
def test_is_pure(self):
|
||||||
|
before = {"notes_total": 5}
|
||||||
|
engine.apply_activity(before, {"notes": 100})
|
||||||
|
assert before == {"notes_total": 5} # input unmutated
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluateFeats:
|
||||||
|
FEATS = [
|
||||||
|
{"id": "notes_total", "counter": "notes_total", "tiers": [100000, 1000000]},
|
||||||
|
{"id": "songs_done", "counter": "songs_done", "tiers": [1000, 5000]},
|
||||||
|
{"id": "secret_combo", "counter": None, "tiers": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_unmet_omitted(self):
|
||||||
|
assert engine.evaluate_feats(self.FEATS, {"notes_total": 50000}) == {}
|
||||||
|
|
||||||
|
def test_met_tier(self):
|
||||||
|
out = engine.evaluate_feats(self.FEATS, {"notes_total": 2000000, "songs_done": 1200})
|
||||||
|
assert out == {"notes_total": 1, "songs_done": 0}
|
||||||
|
|
||||||
|
def test_no_counter_feat_never_auto_unlocks(self):
|
||||||
|
out = engine.evaluate_feats(self.FEATS, {"notes_total": 99999999})
|
||||||
|
assert "secret_combo" not in out
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiffUnlocks:
|
||||||
|
def test_first_unlock(self):
|
||||||
|
assert engine.diff_unlocks({}, {"a": 0}) == ["a"]
|
||||||
|
|
||||||
|
def test_tier_advance(self):
|
||||||
|
assert engine.diff_unlocks({"a": 0}, {"a": 1}) == ["a"]
|
||||||
|
|
||||||
|
def test_no_change(self):
|
||||||
|
assert engine.diff_unlocks({"a": 1}, {"a": 1}) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsecutiveRun:
|
||||||
|
def test_seven_consecutive(self):
|
||||||
|
dates = ["2026-06-0%d" % d for d in range(1, 8)]
|
||||||
|
assert engine.consecutive_run_length(dates) == 7
|
||||||
|
|
||||||
|
def test_break_resets(self):
|
||||||
|
assert engine.consecutive_run_length(["2026-06-01", "2026-06-02", "2026-06-05"]) == 2
|
||||||
|
|
||||||
|
def test_dedup_and_unsorted(self):
|
||||||
|
assert engine.consecutive_run_length(["2026-06-03", "2026-06-01", "2026-06-02", "2026-06-02"]) == 3
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""HTTP-level tests for the achievements engine, incl. the integration law."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_ships_baseline(client):
|
||||||
|
data = client.get("/api/plugins/achievements/catalog").json()
|
||||||
|
assert "baseline" in data
|
||||||
|
ids = [d["id"] for d in data["baseline"].get("global", [])]
|
||||||
|
assert "first_steps" in ids and "ascendant" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_unlocks_feat_and_appears_on_shelf(client):
|
||||||
|
# 100k notes in one shot crosses notes_total tier 0 (Note Hunter).
|
||||||
|
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
|
||||||
|
assert res["ok"] is True
|
||||||
|
unlocked_ids = [u["id"] for u in res["unlocked"]]
|
||||||
|
assert "notes_total" in unlocked_ids
|
||||||
|
# And it shows on the Feats shelf.
|
||||||
|
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
|
||||||
|
assert any(f["id"] == "notes_total" for f in feats)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_below_threshold_unlocks_nothing(client):
|
||||||
|
res = client.post("/api/plugins/achievements/activity", json={"notes": 50000}).json()
|
||||||
|
assert res["unlocked"] == []
|
||||||
|
assert client.get("/api/plugins/achievements/feats").json()["feats"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_law_competency_never_on_feat_shelf(client):
|
||||||
|
# A competency unlock reported by a source must NEVER appear among Feats.
|
||||||
|
client.post("/api/plugins/achievements/report-unlock", json={
|
||||||
|
"id": "tempo_push", "kind": "achievement", "category": "guitar", "sourceId": "virtuoso"})
|
||||||
|
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
|
||||||
|
assert all(f["id"] != "tempo_push" for f in feats)
|
||||||
|
# But it is earned (competency class).
|
||||||
|
earned = client.get("/api/plugins/achievements/earned").json()["earned"]
|
||||||
|
rec = [e for e in earned if e["id"] == "tempo_push"]
|
||||||
|
assert rec and rec[0]["cls"] == "competency"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_unlock_is_idempotent_and_tier_monotonic(client):
|
||||||
|
body = {"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1}
|
||||||
|
first = client.post("/api/plugins/achievements/report-unlock", json=body).json()
|
||||||
|
assert first["changed"] is True
|
||||||
|
# Same tier again → no change.
|
||||||
|
again = client.post("/api/plugins/achievements/report-unlock", json=body).json()
|
||||||
|
assert again["changed"] is False
|
||||||
|
# Lower tier → still no change (monotonic).
|
||||||
|
lower = client.post("/api/plugins/achievements/report-unlock",
|
||||||
|
json={**body, "tier": 0}).json()
|
||||||
|
assert lower["changed"] is False
|
||||||
|
# Higher tier → advances.
|
||||||
|
higher = client.post("/api/plugins/achievements/report-unlock",
|
||||||
|
json={**body, "tier": 2}).json()
|
||||||
|
assert higher["changed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_criterion_counts_distinct(client):
|
||||||
|
url = "/api/plugins/achievements/report-criterion"
|
||||||
|
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1
|
||||||
|
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1 # dup
|
||||||
|
assert client.post(url, json={"criterion_id": "x", "token": "b"}).json()["count"] == 2
|
||||||
Reference in New Issue
Block a user