From 287c23a532309637a047b229c6e25230890e4f95 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Wed, 24 Jun 2026 17:00:30 +0200 Subject: [PATCH] feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing earned Feats on the (forthcoming) public wall is strictly opt-in, default OFF, with a binding data-minimization contract. - Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard) after song-directory / before paths — publishes only display name + earned Feats, never songs/skills/scores; off by default. - Settings (plugins/achievements/settings.html, System tab via settings.category): the same toggle + a "Remove me from the wall" button (POST remove-me — wipes local synced state offline + enqueues removal). - Core (server.py): achievements_enabled (bool, default false) in _default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS; mirrored to localStorage in app.js loadSettings(). - Data-minimization gate: engine.build_wall_payload is the single explicit-dict serializer; key-set is EXACTLY {display_name, player_hash, achievement_id, unlocked_at}, achievement_id always a Feat id. Enqueue is gated on opted-in AND profile identity (reused player_hash); competency never enqueues (integration law). Verified natively: settings round-trip + validation + remove-me; opted-in activity enqueues exactly one 4-field Feat payload; Playwright confirms the 5-step wizard + opt-in card (default unchecked), zero console errors. 29 plugin tests + new settings tests pass. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + plugins/achievements/engine.py | 20 +++++ plugins/achievements/routes.py | 67 ++++++++++++++++- plugins/achievements/settings.html | 74 +++++++++++++++++-- server.py | 12 +++ static/app.js | 4 + static/v3/profile.js | 64 ++++++++++++---- tests/plugins/achievements/test_datamin.py | 85 ++++++++++++++++++++++ tests/test_settings_api.py | 16 ++++ 9 files changed, 318 insertions(+), 25 deletions(-) create mode 100644 tests/plugins/achievements/test_datamin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d72efe3..69d3fcc 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 wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). - **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. diff --git a/plugins/achievements/engine.py b/plugins/achievements/engine.py index 72fa4c6..956369a 100644 --- a/plugins/achievements/engine.py +++ b/plugins/achievements/engine.py @@ -135,6 +135,26 @@ def consecutive_run_length(dates): return best +# ── Data-minimization contract (binding, code-enforced) ────────────────────── +# The wall payload key-set is frozen here and asserted by a unit test. The +# serializer below is the ONLY way outbound data is built — never dict(row) or +# **model — so a stray field cannot leak. Adding a key makes the test go red. +WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at") + + +def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at): + """Build the EXACT four-field wall payload. ``achievement_id`` must always be + a Feat id (the caller only ever invokes this for Feat unlocks — competency + never syncs). Explicit literal dict on purpose; do not refactor into a + row/model splat.""" + return { + "display_name": display_name, + "player_hash": player_hash, + "achievement_id": achievement_id, + "unlocked_at": unlocked_at, + } + + def diff_unlocks(prev_tiers, new_tiers): """Feat ids whose tier advanced (incl. first unlock). diff --git a/plugins/achievements/routes.py b/plugins/achievements/routes.py index d68f04d..7939460 100644 --- a/plugins/achievements/routes.py +++ b/plugins/achievements/routes.py @@ -40,6 +40,8 @@ _lock = threading.Lock() _state = { "db_path": None, "dir": None, # plugin directory (for catalog JSON) + "config_dir": None, # CONFIG_DIR (for reading the opt-in setting) + "meta_db": None, # MetadataDB (for the profile identity: name + hash) "log": logging.getLogger("feedBack.plugin.achievements"), "engine": None, # sibling engine.py module (pure helpers) "feat_defs": [], # parsed feats.json -> list of feat defs @@ -108,6 +110,58 @@ def _now_iso(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) +def _opted_in(): + """True only when the user has opted in (core setting ``achievements_enabled``). + + Read straight from CONFIG_DIR/config.json — the single source of truth the + /api/settings endpoint persists. Default OFF on any read failure: nothing + leaves the device unless explicitly enabled. + """ + try: + cfg_path = Path(_state["config_dir"]) / "config.json" + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + return bool(cfg.get("achievements_enabled") is True) + except (OSError, ValueError, TypeError): + return False + + +def _identity(): + """(display_name, player_hash) from the profile, or (None, None). + + Reused as the wall identity (server.py's documented player_hash). Sync is + skipped entirely when either is missing. + """ + db = _state["meta_db"] + if db is None or not hasattr(db, "get_profile"): + return None, None + try: + prof = db.get_profile() or {} + return (prof.get("display_name") or None), (prof.get("player_hash") or None) + except Exception: # noqa: BLE001 — identity is best-effort; never break a request + return None, None + + +def _enqueue_feat_sync(conn, feat_id, unlocked_at): + """Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated. + + Builds the outbound payload through the SINGLE code-gated serializer + (engine.build_wall_payload, exactly four fields). Competency unlocks never + reach this path (integration law + data-minimization contract). The drain + worker (PR3) POSTs the queued rows; here we only persist intent. + """ + if not _opted_in(): + return False + display_name, player_hash = _identity() + if not display_name or not player_hash: + return False + payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at) + conn.execute( + "INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')", + (json.dumps(payload),), + ) + return True + + def _read_counters(conn): return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")} @@ -218,6 +272,8 @@ def setup(app, context): base.mkdir(parents=True, exist_ok=True) _state["db_path"] = str(base / "achievements.db") _state["dir"] = str(Path(__file__).resolve().parent) + _state["config_dir"] = str(config_dir) + _state["meta_db"] = context.get("meta_db") _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. @@ -277,7 +333,9 @@ def setup(app, context): 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()): + at = _now_iso() + if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at): + _enqueue_feat_sync(conn, fid, at) unlocked.append(_feat_payload(fid, f, tier)) conn.commit() return {"ok": True, "unlocked": unlocked, "counters": new_counters} @@ -287,11 +345,16 @@ def setup(app, context): @app.post("/api/plugins/achievements/report-unlock") def post_report_unlock(body: UnlockIn): cls = "feat" if body.kind == "feat" else "competency" + at = body.at or _now_iso() with _lock: conn = _conn() try: changed = _record_unlock( - conn, body.id, cls, body.category, body.sourceId, body.tier, body.at) + conn, body.id, cls, body.category, body.sourceId, body.tier, at) + # Only Feats sync; competency never enqueues (integration law + + # data-minimization contract). + if changed and cls == "feat": + _enqueue_feat_sync(conn, body.id, at) conn.commit() return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier} finally: diff --git a/plugins/achievements/settings.html b/plugins/achievements/settings.html index 4bb6b97..054cb60 100644 --- a/plugins/achievements/settings.html +++ b/plugins/achievements/settings.html @@ -1,8 +1,68 @@ - -
-

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.

+ +
+
+

Your Achievements and Feats of Power live on your + Profile page and are local & private by default.

+
+ + + +
+ + +
+ + diff --git a/server.py b/server.py index 03033cf..d2c2d2c 100644 --- a/server.py +++ b/server.py @@ -5388,6 +5388,11 @@ def _default_settings(): "countdown_before_song": False, "miss_penalty": "none", "fail_behavior": "continue", + # Achievements epic: opt-in to publishing earned Feats (name + Feat id + # only) to the hosted wall. Default OFF — nothing leaves the device + # until the user opts in. Read by the bundled achievements plugin to + # gate its wall-sync enqueue. + "achievements_enabled": False, } @@ -5524,6 +5529,12 @@ def save_settings(data: dict): if not isinstance(raw, bool): return {"error": "countdown_before_song must be a boolean"} updates["countdown_before_song"] = raw + if "achievements_enabled" in data: + raw = data["achievements_enabled"] + if raw is not None: + if not isinstance(raw, bool): + return {"error": "achievements_enabled must be a boolean"} + updates["achievements_enabled"] = raw if "miss_penalty" in data: raw = data["miss_penalty"] if raw is not None: @@ -5614,6 +5625,7 @@ _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "reference_pitch", "instrument", "string_count", "tuning", + "achievements_enabled", }) diff --git a/static/app.js b/static/app.js index a95259e..1360887 100644 --- a/static/app.js +++ b/static/app.js @@ -3275,6 +3275,10 @@ async function loadSettings() { try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ } const countdownEl = document.getElementById('setting-countdown-before-song'); if (countdownEl) countdownEl.checked = countdownOn; + // Achievements epic: mirror the opt-in flag to localStorage so the + // onboarding card + the bundled achievements plugin can read the current + // state app-wide (the plugin's own settings panel still owns the toggle). + try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ } const missEl = document.getElementById('setting-miss-penalty'); if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none'; const failEl = document.getElementById('setting-fail-behavior'); diff --git a/static/v3/profile.js b/static/v3/profile.js index adeb511..60ba4b3 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -332,7 +332,7 @@ const stepDots = editing ? '' : '
' + - [1, 2, 3, 4].map((n) => '').join('') + + [1, 2, 3, 4, 5].map((n) => '').join('') + '
'; const overlay = document.createElement('div'); @@ -365,13 +365,23 @@ 'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' + '' + '
' + - // Step 3 — instrument paths (first-run only; tiles filled on entry). + // Step 3 — Achievements wall opt-in (first-run only; default OFF). '' + + // Step 4 — instrument paths (first-run only; tiles filled on entry). + '' + - // Step 4 — calibration offer (first-run only). - '