feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)

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) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-24 17:00:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 05dd3d227a
commit 287c23a532
9 changed files with 318 additions and 25 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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 `<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). - **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.
+20
View File
@@ -135,6 +135,26 @@ def consecutive_run_length(dates):
return best 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): def diff_unlocks(prev_tiers, new_tiers):
"""Feat ids whose tier advanced (incl. first unlock). """Feat ids whose tier advanced (incl. first unlock).
+65 -2
View File
@@ -40,6 +40,8 @@ _lock = threading.Lock()
_state = { _state = {
"db_path": None, "db_path": None,
"dir": None, # plugin directory (for catalog JSON) "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"), "log": logging.getLogger("feedBack.plugin.achievements"),
"engine": None, # sibling engine.py module (pure helpers) "engine": None, # sibling engine.py module (pure helpers)
"feat_defs": [], # parsed feats.json -> list of feat defs "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()) 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): def _read_counters(conn):
return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")} 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) base.mkdir(parents=True, exist_ok=True)
_state["db_path"] = str(base / "achievements.db") _state["db_path"] = str(base / "achievements.db")
_state["dir"] = str(Path(__file__).resolve().parent) _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"] _state["log"] = context.get("log") or _state["log"]
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a # Pure helpers via the per-plugin sibling loader (constitution P-III), with a
# plain-import fallback for pytest / standalone use. # plain-import fallback for pytest / standalone use.
@@ -277,7 +333,9 @@ def setup(app, context):
for fid in fresh: for fid in fresh:
f = _feat_by_id(fid) or {} f = _feat_by_id(fid) or {}
tier = new_tiers[fid] 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)) unlocked.append(_feat_payload(fid, f, tier))
conn.commit() conn.commit()
return {"ok": True, "unlocked": unlocked, "counters": new_counters} return {"ok": True, "unlocked": unlocked, "counters": new_counters}
@@ -287,11 +345,16 @@ def setup(app, context):
@app.post("/api/plugins/achievements/report-unlock") @app.post("/api/plugins/achievements/report-unlock")
def post_report_unlock(body: UnlockIn): def post_report_unlock(body: UnlockIn):
cls = "feat" if body.kind == "feat" else "competency" cls = "feat" if body.kind == "feat" else "competency"
at = body.at or _now_iso()
with _lock: with _lock:
conn = _conn() conn = _conn()
try: try:
changed = _record_unlock( 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() conn.commit()
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier} return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
finally: finally:
+67 -7
View File
@@ -1,8 +1,68 @@
<!-- Achievements plugin settings panel. PR1 ships an informational stub; the <!-- Achievements plugin — Privacy panel (mounts under the Settings "System" tab
Privacy opt-in toggle + "Remove me from the wall" button land in PR2. --> via settings.category). Owns the wall opt-in toggle (bound to the core
<div class="text-sm text-gray-300 space-y-2"> `achievements_enabled` setting) + the self-serve "Remove me from the wall"
<p>Your <strong>Achievements</strong> (skill milestones) and <strong>Feats of Power</strong> action. Default OFF — nothing publishes until the user opts in. -->
(rare activity trophies) live on your <em>Profile</em> page. Everything here is <div class="text-sm text-gray-300 space-y-4" data-ach-privacy>
local and private.</p> <div>
<p class="text-gray-400">Sharing Feats on the public wall is opt-in and arrives in a later update.</p> <p>Your <strong>Achievements</strong> and <strong>Feats of Power</strong> live on your
<em>Profile</em> page and are local &amp; private by default.</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="setting-achievements-enabled"
class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500">
<span>
<span class="font-medium text-gray-200">Share my Feats of Power on the public wall</span>
<span class="block text-gray-400">Publishes only your display name and the rare
<strong>Feats</strong> you earn (activity milestones) — never songs, skills, or scores.
You can turn this off and remove yourself at any time.</span>
</span>
</label>
<div>
<button type="button" id="ach-remove-me"
class="px-3 py-1.5 rounded-md text-sm border border-red-500/40 text-red-300 hover:bg-red-500/10 transition">
Remove me from the wall
</button>
<span id="ach-remove-status" class="ml-2 text-xs text-gray-400"></span>
</div>
</div> </div>
<script>
(function () {
var root = document.currentScript && document.currentScript.previousElementSibling;
// The panel re-injects on each Settings entry; bind once per element.
var toggle = document.getElementById('setting-achievements-enabled');
if (!toggle || toggle.dataset.wired === '1') return;
toggle.dataset.wired = '1';
// Hydrate from the authoritative server setting.
fetch('/api/settings').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
toggle.checked = d && d.achievements_enabled === true;
}).catch(function () { /* offline — leave unchecked */ });
toggle.addEventListener('change', function () {
var on = !!toggle.checked;
try { localStorage.setItem('achievementsEnabled', on ? '1' : '0'); } catch (_) {}
fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ achievements_enabled: on }),
}).catch(function () { /* best-effort; revert on failure */ });
});
var removeBtn = document.getElementById('ach-remove-me');
var status = document.getElementById('ach-remove-status');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
removeBtn.disabled = true;
if (status) status.textContent = 'Removing…';
fetch('/api/plugins/achievements/remove-me', { method: 'POST' })
.then(function (r) {
if (status) status.textContent = r.ok ? 'Removed. Your Feats stay on your Profile.' : 'Could not reach the server — try again.';
})
.catch(function () { if (status) status.textContent = 'Offline — queued; it will sync when you reconnect.'; })
.finally(function () { removeBtn.disabled = false; });
});
}
})();
</script>
+12
View File
@@ -5388,6 +5388,11 @@ def _default_settings():
"countdown_before_song": False, "countdown_before_song": False,
"miss_penalty": "none", "miss_penalty": "none",
"fail_behavior": "continue", "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): if not isinstance(raw, bool):
return {"error": "countdown_before_song must be a boolean"} return {"error": "countdown_before_song must be a boolean"}
updates["countdown_before_song"] = raw 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: if "miss_penalty" in data:
raw = data["miss_penalty"] raw = data["miss_penalty"]
if raw is not None: if raw is not None:
@@ -5614,6 +5625,7 @@ _RESETTABLE_SETTINGS_KEYS = frozenset({
"default_arrangement", "demucs_server_url", "master_difficulty", "default_arrangement", "demucs_server_url", "master_difficulty",
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior",
"reference_pitch", "instrument", "string_count", "tuning", "reference_pitch", "instrument", "string_count", "tuning",
"achievements_enabled",
}) })
+4
View File
@@ -3275,6 +3275,10 @@ async function loadSettings() {
try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ } try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ }
const countdownEl = document.getElementById('setting-countdown-before-song'); const countdownEl = document.getElementById('setting-countdown-before-song');
if (countdownEl) countdownEl.checked = countdownOn; 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'); const missEl = document.getElementById('setting-miss-penalty');
if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none'; if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none';
const failEl = document.getElementById('setting-fail-behavior'); const failEl = document.getElementById('setting-fail-behavior');
+48 -16
View File
@@ -332,7 +332,7 @@
const stepDots = editing ? '' : const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' + '<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') + [1, 2, 3, 4, 5].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>'; '</div>';
const overlay = document.createElement('div'); 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">' + '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">' +
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' + '<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
'</div></div>' + '</div></div>' +
// Step 3 — instrument paths (first-run only; tiles filled on entry). // Step 3 — Achievements wall opt-in (first-run only; default OFF).
'<div id="v3-ob-step3" class="hidden">' + '<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
'<p class="text-sm text-fb-textDim mb-3">As you practise youll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
'<span class="block text-xs text-fb-textDim mt-1">Publishes only your display name and the Feats you earn — never songs, skills, or scores. You can change this any time in Settings, and remove yourself with one click.</span></span>' +
'</label>' +
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked to keep everything private. This is off by default.</p></div>' +
// Step 4 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step4" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' + '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' + '<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' + '<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 4 — calibration offer (first-run only). // Step 5 — calibration offer (first-run only).
'<div id="v3-ob-step4" class="hidden">' + '<div id="v3-ob-step5" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' + '<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' + '<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' + '<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@@ -417,6 +427,9 @@
// for now" is available for users who'll set it later. // for now" is available for users who'll set it later.
submit.disabled = !songDir.trim(); submit.disabled = !songDir.trim();
} else if (step === 3) { } else if (step === 3) {
// Achievements opt-in — either choice is valid; always enabled.
submit.disabled = false;
} else if (step === 4) {
// ≥1 path required — unless none could be offered (offline / // ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding. // empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1; submit.disabled = pathsAvailable && selectedPaths.length < 1;
@@ -428,7 +441,7 @@
function setStep(n) { function setStep(n) {
step = n; step = n;
errEl.classList.add('hidden'); errEl.classList.add('hidden');
for (let i = 1; i <= 4; i++) { for (let i = 1; i <= 5; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n); overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
} }
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => { overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@@ -439,13 +452,14 @@
if (subtitle) { if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile' subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Point us at your songs' : n === 2 ? 'Point us at your songs'
: n === 3 ? 'Choose your instrument paths' : n === 3 ? 'Feats of Power (optional)'
: n === 4 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup'; : 'One last thing — calibrate your setup';
} }
submit.textContent = n === 4 ? 'Play it now' : 'Next'; submit.textContent = n === 5 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and // Skip is offered on the song-directory step (configure later) and
// the calibration challenge. // the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4)); skipBtn.classList.toggle('hidden', !(n === 2 || n === 5));
refreshSubmit(); refreshSubmit();
} }
@@ -630,23 +644,42 @@
} }
if (step === 2) { if (step === 2) {
// Save the song directory + kick a library scan, then continue // Save the song directory + kick a library scan, then continue
// to instrument paths. "Skip for now" leaves it unconfigured. // to the achievements opt-in. "Skip for now" leaves it unconfigured.
submit.disabled = true; submit.disabled = true;
try { try {
await saveSongDir(); await saveSongDir();
setStep(3); setStep(3);
loadPathTiles();
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); } } catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
return; return;
} }
if (step === 3) { if (step === 3) {
// Persist the wall opt-in choice (default OFF) then continue to
// instrument paths. Best-effort — a failed write must not block
// onboarding; the user can still set it later in Settings.
submit.disabled = true;
try {
const optEl = overlay.querySelector('#v3-ob-optin');
const optedIn = !!(optEl && optEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ achievements_enabled: optedIn }),
});
try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ }
} catch (e) { /* best-effort — settable later */ }
setStep(4);
loadPathTiles();
} finally { refreshSubmit(); }
return;
}
if (step === 4) {
// Create the profile (onboarded=1) BEFORE the calibration choice // Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at the challenge can never lose the profile. // so closing the overlay at the challenge can never lose the profile.
submit.disabled = true; submit.disabled = true;
try { try {
_profile = await postProfile(); _profile = await postProfile();
if (selectedPaths.length) { if (selectedPaths.length) {
// A failed path save must NOT advance — step 3's skip // A failed path save must NOT advance — step 4's skip
// requires ≥1 selected path (spec invariant) and would // requires ≥1 selected path (spec invariant) and would
// otherwise leave a pathless rank-1 profile. // otherwise leave a pathless rank-1 profile.
const res = await fetch('/api/progression/paths', { const res = await fetch('/api/progression/paths', {
@@ -662,11 +695,11 @@
// New step: input-device selection + calibration, between // New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge. // path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths); await runInputSetup(selectedPaths);
setStep(4); setStep(5);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); } } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return; return;
} }
// Step 4 — "Play it now": leave calibration pending (it completes // Step 5 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic. // through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename; const target = diagnosticFilename;
await finish({ launchingSong: !!target }); await finish({ launchingSong: !!target });
@@ -675,13 +708,12 @@
skipBtn.addEventListener('click', async () => { skipBtn.addEventListener('click', async () => {
// Step 2 — skip the song directory (the user can set it later in // Step 2 — skip the song directory (the user can set it later in
// Settings). Proceed straight to instrument paths. // Settings). Proceed to the achievements opt-in.
if (step === 2) { if (step === 2) {
setStep(3); setStep(3);
loadPathTiles();
return; return;
} }
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays // Step 5 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen. // replayable from the Progress screen.
skipBtn.disabled = true; skipBtn.disabled = true;
try { try {
@@ -0,0 +1,85 @@
"""Data-minimization contract + opt-in gating (binding).
The outbound wall payload must be EXACTLY {display_name, player_hash,
achievement_id, unlocked_at}; competency unlocks must never enqueue; and nothing
enqueues unless the user opted in AND has a profile identity.
"""
import json
import sqlite3
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import engine
import routes as ach_routes
class _FakeMetaDB:
def __init__(self, name="Ada", phash="deadbeefcafe"):
self._p = {"display_name": name, "player_hash": phash}
def get_profile(self):
return dict(self._p)
def _make_client(tmp_path, *, opted_in, meta_db=None):
if opted_in:
(tmp_path / "config.json").write_text(json.dumps({"achievements_enabled": True}))
app = FastAPI()
ach_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
return TestClient(app)
def _queue_rows(tmp_path):
db = sqlite3.connect(str(tmp_path / "achievements" / "achievements.db"))
try:
return [
{"kind": k, "payload": p, "state": s}
for (k, p, s) in db.execute("SELECT kind, payload, state FROM sync_queue")
]
finally:
db.close()
# ── The pure serializer is the only gate ─────────────────────────────────────
def test_serializer_keyset_is_exactly_four():
payload = engine.build_wall_payload("Ada", "hash", "notes_total", "2026-06-24T00:00:00Z")
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
assert len(payload) == 4 # go red if a fifth field is ever added
# ── End-to-end enqueue gating ────────────────────────────────────────────────
def test_opted_out_never_enqueues(tmp_path):
client = _make_client(tmp_path, opted_in=False, meta_db=_FakeMetaDB())
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
assert "notes_total" in [u["id"] for u in res["unlocked"]] # feat did unlock
assert _queue_rows(tmp_path) == [] # but nothing queued
def test_opted_in_enqueues_exactly_one_four_field_payload(tmp_path):
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
rows = [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"]
assert len(rows) == 1
payload = json.loads(rows[0]["payload"])
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
assert payload["achievement_id"] == "notes_total"
assert payload["display_name"] == "Ada"
def test_opted_in_without_identity_does_not_enqueue(tmp_path):
client = _make_client(tmp_path, opted_in=True, meta_db=None)
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
def test_competency_never_enqueues_even_opted_in(tmp_path):
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
client.post("/api/plugins/achievements/report-unlock", json={
"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1})
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
+16
View File
@@ -582,6 +582,22 @@ def test_api_post_settings_null_string_is_noop_via_testclient(api_client, tmp_pa
assert _read_cfg(tmp_path)["master_difficulty"] == 50 assert _read_cfg(tmp_path)["master_difficulty"] == 50
def test_achievements_enabled_persists_and_validates(api_client, tmp_path):
"""The achievements-epic opt-in flag round-trips as a boolean and rejects
non-bools at the route level (mirrors countdown_before_song)."""
tc, _server = api_client
r = tc.post("/api/settings", json={"achievements_enabled": True})
assert r.status_code == 200
assert _read_cfg(tmp_path)["achievements_enabled"] is True
bad = tc.post("/api/settings", json={"achievements_enabled": "yes"})
assert bad.status_code == 200 and "error" in bad.json()
def test_achievements_enabled_is_resettable(server_module):
"""The flag is in the resettable allow-list so a Reset clears it to default."""
assert "achievements_enabled" in server_module._RESETTABLE_SETTINGS_KEYS
def test_skip_startup_tasks_drives_startup_to_complete(api_client): def test_skip_startup_tasks_drives_startup_to_complete(api_client):
"""With FEEDBACK_SKIP_STARTUP_TASKS set, the startup hook must: """With FEEDBACK_SKIP_STARTUP_TASKS set, the startup hook must:
* skip plugin loading and the background scan, * skip plugin loading and the background scan,