diff --git a/.gitignore b/.gitignore index c36ed39..c191124 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ plugins/*/ # treats them identically to user-installed ones) but are bundled with # the default container image and marked `"bundled": true` in their # manifest. Add new core plugins as `!plugins//` exceptions. +!plugins/achievements/ +!plugins/achievements/** +plugins/achievements/__pycache__/ !plugins/highway_3d/ !plugins/highway_3d/** plugins/highway_3d/__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4660265..d72efe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 `/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 `
` panel into `#plugin-settings-`, 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. - **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. diff --git a/plugins/achievements/achievements.json b/plugins/achievements/achievements.json new file mode 100644 index 0000000..274cbc1 --- /dev/null +++ b/plugins/achievements/achievements.json @@ -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" } + } + ] +} diff --git a/plugins/achievements/assets/achievements.css b/plugins/achievements/assets/achievements.css new file mode 100644 index 0000000..959af93 --- /dev/null +++ b/plugins/achievements/assets/achievements.css @@ -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; } diff --git a/plugins/achievements/engine.py b/plugins/achievements/engine.py new file mode 100644 index 0000000..72fa4c6 --- /dev/null +++ b/plugins/achievements/engine.py @@ -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 diff --git a/plugins/achievements/feats.json b/plugins/achievements/feats.json new file mode 100644 index 0000000..5643ca9 --- /dev/null +++ b/plugins/achievements/feats.json @@ -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"] + } + ] +} diff --git a/plugins/achievements/plugin.json b/plugins/achievements/plugin.json new file mode 100644 index 0000000..c684c13 --- /dev/null +++ b/plugins/achievements/plugin.json @@ -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" +} diff --git a/plugins/achievements/routes.py b/plugins/achievements/routes.py new file mode 100644 index 0000000..d68f04d --- /dev/null +++ b/plugins/achievements/routes.py @@ -0,0 +1,408 @@ +"""Achievements & Feats of Power β€” local engine (offline). + +State lives under ``/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 diff --git a/plugins/achievements/screen.js b/plugins/achievements/screen.js new file mode 100644 index 0000000..68937a8 --- /dev/null +++ b/plugins/achievements/screen.js @@ -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 ''; + }).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 '
' + + '
' + (got ? 'πŸ…' : 'πŸ”’') + '
' + + '
' + + '
' + esc(d.title) + + (got && tier > 0 ? ' tier ' + (tier + 1) + '' : '') + '
' + + '
' + esc(d.description) + '
' + + '
'; + }).join('') : '

No achievements in this category yet.

'; + + mount.innerHTML = + '
' + pills + '
' + + '
' + list + '
'; + 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 '
' + + '
πŸ†
' + + '
' + esc(f.title) + '
' + + '
' + esc(f.description) + '
' + + '
'; + }).join(''); + // Opt-out users get a subtle wall hint linking to Settings (PR2 wires it). + var hint = ''; + slot.innerHTML = + '
' + + '

Feats of Power

' + + '
' + cards + '
' + hint + + '
'; + } + + // ── 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: β€” 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(); + } +})(); diff --git a/plugins/achievements/settings.html b/plugins/achievements/settings.html new file mode 100644 index 0000000..4bb6b97 --- /dev/null +++ b/plugins/achievements/settings.html @@ -0,0 +1,8 @@ + +
+

Your Achievements (skill milestones) and Feats of Power + (rare activity trophies) live on your Profile page. Everything here is + local and private.

+

Sharing Feats on the public wall is opt-in and arrives in a later update.

+
diff --git a/static/v3/profile.js b/static/v3/profile.js index b46440c..adeb511 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -96,6 +96,47 @@ } // ── 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() { const root = document.getElementById('v3-profile'); if (!root) return; @@ -110,9 +151,8 @@ const pathChips = ((prog && prog.paths) || []).map((path) => '' + esc(path.name) + ' Lv ' + path.level + '').join(' '); - root.innerHTML = - '
' + - // Header card + // Header card (Profile main tab). + const headerCard = '
' + '' + avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '' + @@ -128,17 +168,39 @@ '
' + '' + '' + - '
' + - // Per-song bests β€” top scored songs from /api/stats/top, filled by - // renderBests() after innerHTML is set. The placeholder text shows - // during load and when nothing's been scored yet. + ''; + // Per-song bests β€” top scored songs from /api/stats/top, filled by + // renderBests() after innerHTML is set. The placeholder text shows + // during load and when nothing's been scored yet. + const bestsCard = '
' + '

Your best scores

' + '
Play a song to start tracking your accuracy and best scores.
' + + '
'; + const playerIdFooter = (_profile && _profile.player_hash + ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' + : ''); + root.innerHTML = + '
' + + '
' + + '' + + '' + + '
' + + // ── Profile (main) panel ────────────────────────────────────────── + '
' + + '
' + + headerCard + + bestsCard + + // Feats of Power trophy shelf β€” rendered by the achievements plugin + // (earned Feats only; hidden-until-earned, so empty when none). + '
' + + playerIdFooter + + '
' + + // ── Achievements panel ──────────────────────────────────────────── + '
' + + '
' + + '

Install the Achievements plugin to track your skill milestones.

' + '
' + - (_profile && _profile.player_hash - ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' - : '') + '
'; const edit = root.querySelector('[data-v3-edit-profile]'); if (edit) edit.addEventListener('click', () => show(_profile, { editing: true })); @@ -147,7 +209,13 @@ if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } + wireProfileTabs(); 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, @@ -646,6 +714,12 @@ if (window.feedBack && typeof window.feedBack.on === 'function') { window.feedBack.on('progression:updated', () => { 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') { diff --git a/tests/plugins/achievements/__init__.py b/tests/plugins/achievements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/achievements/conftest.py b/tests/plugins/achievements/conftest.py new file mode 100644 index 0000000..7dc6d96 --- /dev/null +++ b/tests/plugins/achievements/conftest.py @@ -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) diff --git a/tests/plugins/achievements/test_engine.py b/tests/plugins/achievements/test_engine.py new file mode 100644 index 0000000..4b45258 --- /dev/null +++ b/tests/plugins/achievements/test_engine.py @@ -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 diff --git a/tests/plugins/achievements/test_routes.py b/tests/plugins/achievements/test_routes.py new file mode 100644 index 0000000..a14c5cc --- /dev/null +++ b/tests/plugins/achievements/test_routes.py @@ -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