feat(achievements): local engine + tabbed Profile shell (epic PR1) (#587)

Adds the Achievements & Feats of Power local engine, fully offline.

Core (static/v3/profile.js): the Profile screen becomes tabbed exactly
like v3 Settings (.fb-tabbar/.fb-tab/.fb-tabpanel, active tab persisted in
localStorage 'v3-profile-tab'). A Profile (main) tab carries the existing
cards + a Feats trophy-shelf mount (#v3-profile-feats-slot, earned-only),
and an Achievements tab carries a plugin mount
(#v3-profile-achievements-mount) + empty-state note. A new
`v3:profile-rendered` event fires after every render so the plugin
re-injects (mirrors v3:settings-rendered).

New bundled plugin (plugins/achievements/): SQLite engine
(unlocks/counters/comp_ledger/sync_queue) with pure threshold/criterion
math in the testable sibling engine.py (P-V); routes activity/
report-unlock/report-criterion/catalog/earned/feats/remove-me. Feats read
activity counters only (batched song:ended POST; notes only when notedetect
present — graceful degradation); competency Achievements evaluate from
progression events only — the integration law, never crossed. Catalogue is
always shown (locked=greyed), grouped by the real progression paths
(Global/Guitar/Bass/Drums/Keys, auto-extending) with per-category earned
badges. Versioned window.feedBack.achievements registration API with the
__feedBackAchievementsPending load-order queue + achievements:ready event.

Verified natively (uvicorn) end-to-end + Playwright (tabbar, earned-only
Feats shelf, greyed catalogue, registration API, zero console errors);
24 plugin tests pass incl. the integration-law assertion.

Opt-in/privacy/data-min gate (PR2) and the hosted wall (PR3) follow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-24 16:57:37 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 873ee3d5f2
commit 05dd3d227a
15 changed files with 1474 additions and 10 deletions
+61
View File
@@ -0,0 +1,61 @@
"""HTTP-level tests for the achievements engine, incl. the integration law."""
def test_catalog_ships_baseline(client):
data = client.get("/api/plugins/achievements/catalog").json()
assert "baseline" in data
ids = [d["id"] for d in data["baseline"].get("global", [])]
assert "first_steps" in ids and "ascendant" in ids
def test_activity_unlocks_feat_and_appears_on_shelf(client):
# 100k notes in one shot crosses notes_total tier 0 (Note Hunter).
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
assert res["ok"] is True
unlocked_ids = [u["id"] for u in res["unlocked"]]
assert "notes_total" in unlocked_ids
# And it shows on the Feats shelf.
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
assert any(f["id"] == "notes_total" for f in feats)
def test_activity_below_threshold_unlocks_nothing(client):
res = client.post("/api/plugins/achievements/activity", json={"notes": 50000}).json()
assert res["unlocked"] == []
assert client.get("/api/plugins/achievements/feats").json()["feats"] == []
def test_integration_law_competency_never_on_feat_shelf(client):
# A competency unlock reported by a source must NEVER appear among Feats.
client.post("/api/plugins/achievements/report-unlock", json={
"id": "tempo_push", "kind": "achievement", "category": "guitar", "sourceId": "virtuoso"})
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
assert all(f["id"] != "tempo_push" for f in feats)
# But it is earned (competency class).
earned = client.get("/api/plugins/achievements/earned").json()["earned"]
rec = [e for e in earned if e["id"] == "tempo_push"]
assert rec and rec[0]["cls"] == "competency"
def test_report_unlock_is_idempotent_and_tier_monotonic(client):
body = {"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1}
first = client.post("/api/plugins/achievements/report-unlock", json=body).json()
assert first["changed"] is True
# Same tier again → no change.
again = client.post("/api/plugins/achievements/report-unlock", json=body).json()
assert again["changed"] is False
# Lower tier → still no change (monotonic).
lower = client.post("/api/plugins/achievements/report-unlock",
json={**body, "tier": 0}).json()
assert lower["changed"] is False
# Higher tier → advances.
higher = client.post("/api/plugins/achievements/report-unlock",
json={**body, "tier": 2}).json()
assert higher["changed"] is True
def test_report_criterion_counts_distinct(client):
url = "/api/plugins/achievements/report-criterion"
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1 # dup
assert client.post(url, json={"criterion_id": "x", "token": "b"}).json()["count"] == 2