mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
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:
co-authored by
Claude Opus 4.8
parent
05dd3d227a
commit
287c23a532
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 you’ll 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 you’ll 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 you’ll 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"] == []
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user