mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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>
472 lines
18 KiB
Python
472 lines
18 KiB
Python
"""Achievements & Feats of Power — local engine (offline).
|
|
|
|
State lives under ``<config_dir>/achievements/``:
|
|
- ``achievements.db`` SQLite — unlocks, activity counters, derived ledger,
|
|
and the (PR3) wall sync queue.
|
|
|
|
Two surfaces, one engine, kept structurally apart (the **integration law**):
|
|
* **Feats of Power** — activity/volume. The engine OWNS raw activity counters
|
|
(`counters`), evaluates Feat thresholds, and records Feat unlocks. Feats are
|
|
the only thing that ever syncs to the public wall.
|
|
* **Achievements** — demonstrated competency. The engine RECORDS unlocks the
|
|
source reports (`report-unlock`); it never re-derives them from activity.
|
|
A baseline catalogue ships here and is driven by the built-in progression
|
|
system; richer items are contributed by source plugins at runtime.
|
|
|
|
Endpoints (all under /api/plugins/achievements/):
|
|
POST /activity bump activity counters, eval Feats, return newly-unlocked
|
|
POST /report-unlock idempotent upsert of a competency/feat unlock
|
|
POST /report-criterion record a (criterion_id, token) pair → distinct count
|
|
GET /catalog baseline competency defs + earned state
|
|
GET /earned all earned items (id, cls, category, tier, at)
|
|
GET /feats earned Feats (for the profile trophy shelf)
|
|
POST /remove-me wipe synced state (full wall-removal lands in PR2/PR3)
|
|
|
|
Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
|
|
this module is the SQLite + HTTP shell.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
_lock = threading.Lock()
|
|
_state = {
|
|
"db_path": None,
|
|
"dir": None, # plugin directory (for catalog JSON)
|
|
"config_dir": None, # CONFIG_DIR (for reading the opt-in setting)
|
|
"meta_db": None, # MetadataDB (for the profile identity: name + hash)
|
|
"log": logging.getLogger("feedBack.plugin.achievements"),
|
|
"engine": None, # sibling engine.py module (pure helpers)
|
|
"feat_defs": [], # parsed feats.json -> list of feat defs
|
|
"baseline": {}, # parsed achievements.json
|
|
}
|
|
|
|
|
|
# ── SQLite ──────────────────────────────────────────────────────────────────
|
|
|
|
def _conn():
|
|
conn = sqlite3.connect(_state["db_path"], timeout=5)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
return conn
|
|
|
|
|
|
def _init_db():
|
|
conn = _conn()
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS unlocks (
|
|
achievement_id TEXT PRIMARY KEY,
|
|
cls TEXT NOT NULL, -- 'competency' | 'feat'
|
|
disp_category TEXT, -- global/guitar/bass/...
|
|
source_id TEXT,
|
|
tier INTEGER NOT NULL DEFAULT 0,
|
|
unlocked_at TEXT,
|
|
synced INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS counters (
|
|
key TEXT PRIMARY KEY,
|
|
value INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS comp_ledger (
|
|
criterion_id TEXT NOT NULL,
|
|
token TEXT NOT NULL,
|
|
PRIMARY KEY (criterion_id, token)
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL, -- 'unlock' | 'remove'
|
|
payload TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'pending' -- 'pending' | 'dead_letter'
|
|
)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _now_iso():
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
|
|
|
|
def _opted_in():
|
|
"""True only when the user has opted in (core setting ``achievements_enabled``).
|
|
|
|
Read straight from CONFIG_DIR/config.json — the single source of truth the
|
|
/api/settings endpoint persists. Default OFF on any read failure: nothing
|
|
leaves the device unless explicitly enabled.
|
|
"""
|
|
try:
|
|
cfg_path = Path(_state["config_dir"]) / "config.json"
|
|
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
return bool(cfg.get("achievements_enabled") is True)
|
|
except (OSError, ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def _identity():
|
|
"""(display_name, player_hash) from the profile, or (None, None).
|
|
|
|
Reused as the wall identity (server.py's documented player_hash). Sync is
|
|
skipped entirely when either is missing.
|
|
"""
|
|
db = _state["meta_db"]
|
|
if db is None or not hasattr(db, "get_profile"):
|
|
return None, None
|
|
try:
|
|
prof = db.get_profile() or {}
|
|
return (prof.get("display_name") or None), (prof.get("player_hash") or None)
|
|
except Exception: # noqa: BLE001 — identity is best-effort; never break a request
|
|
return None, None
|
|
|
|
|
|
def _enqueue_feat_sync(conn, feat_id, unlocked_at):
|
|
"""Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated.
|
|
|
|
Builds the outbound payload through the SINGLE code-gated serializer
|
|
(engine.build_wall_payload, exactly four fields). Competency unlocks never
|
|
reach this path (integration law + data-minimization contract). The drain
|
|
worker (PR3) POSTs the queued rows; here we only persist intent.
|
|
"""
|
|
if not _opted_in():
|
|
return False
|
|
display_name, player_hash = _identity()
|
|
if not display_name or not player_hash:
|
|
return False
|
|
payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at)
|
|
conn.execute(
|
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')",
|
|
(json.dumps(payload),),
|
|
)
|
|
return True
|
|
|
|
|
|
def _read_counters(conn):
|
|
return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")}
|
|
|
|
|
|
def _write_counters(conn, counters):
|
|
for key, value in counters.items():
|
|
conn.execute(
|
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
(key, int(value)),
|
|
)
|
|
|
|
|
|
def _bump_counter(conn, key, delta):
|
|
"""Increment a counter and return the new value (used for per-chart plays)."""
|
|
conn.execute(
|
|
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value=value+excluded.value",
|
|
(key, int(delta)),
|
|
)
|
|
row = conn.execute("SELECT value FROM counters WHERE key=?", (key,)).fetchone()
|
|
return int(row["value"]) if row else int(delta)
|
|
|
|
|
|
def _earned_feat_tiers(conn):
|
|
return {
|
|
row["achievement_id"]: int(row["tier"])
|
|
for row in conn.execute("SELECT achievement_id, tier FROM unlocks WHERE cls='feat'")
|
|
}
|
|
|
|
|
|
def _record_unlock(conn, ach_id, cls, disp_category, source_id, tier, at):
|
|
"""Idempotent upsert; only advances the tier upward. Returns True if changed."""
|
|
row = conn.execute("SELECT tier FROM unlocks WHERE achievement_id=?", (ach_id,)).fetchone()
|
|
if row is not None and int(row["tier"]) >= int(tier):
|
|
return False
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO unlocks(achievement_id, cls, disp_category, source_id, tier, unlocked_at, synced)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT(achievement_id) DO UPDATE SET
|
|
tier=excluded.tier,
|
|
cls=excluded.cls,
|
|
disp_category=COALESCE(excluded.disp_category, unlocks.disp_category),
|
|
source_id=COALESCE(excluded.source_id, unlocks.source_id)
|
|
""",
|
|
(ach_id, cls, disp_category, source_id, int(tier), at or _now_iso()),
|
|
)
|
|
return True
|
|
|
|
|
|
# ── Catalog loading ─────────────────────────────────────────────────────────
|
|
|
|
def _feat_by_id(fid):
|
|
for f in _state["feat_defs"]:
|
|
if f.get("id") == fid:
|
|
return f
|
|
return None
|
|
|
|
|
|
def _load_catalogs():
|
|
base = Path(_state["dir"])
|
|
try:
|
|
feats = json.loads((base / "feats.json").read_text(encoding="utf-8"))
|
|
_state["feat_defs"] = feats.get("feats", []) if isinstance(feats, dict) else []
|
|
except (OSError, ValueError) as e:
|
|
_state["log"].warning("achievements: could not load feats.json: %s", e)
|
|
_state["feat_defs"] = []
|
|
try:
|
|
_state["baseline"] = json.loads((base / "achievements.json").read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
_state["log"].warning("achievements: could not load achievements.json: %s", e)
|
|
_state["baseline"] = {}
|
|
|
|
|
|
# ── Request models ──────────────────────────────────────────────────────────
|
|
|
|
class ActivityIn(BaseModel):
|
|
notes: int = Field(ge=0, default=0)
|
|
session_notes: int = Field(ge=0, default=0)
|
|
in_song_streak: int = Field(ge=0, default=0)
|
|
song_done: int = Field(ge=0, default=0)
|
|
seconds: int = Field(ge=0, default=0)
|
|
chart: str | None = None
|
|
night_session: bool = False
|
|
night_date: str | None = None # 'YYYY-MM-DD', frontend supplies (no server clock)
|
|
|
|
|
|
class UnlockIn(BaseModel):
|
|
id: str
|
|
kind: str = "achievement" # 'achievement' | 'feat'
|
|
category: str | None = None # display category (global/guitar/...)
|
|
sourceId: str | None = None
|
|
tier: int = Field(ge=0, default=0)
|
|
at: str | None = None
|
|
|
|
|
|
class CriterionIn(BaseModel):
|
|
criterion_id: str
|
|
token: str
|
|
|
|
|
|
# ── FastAPI wiring ──────────────────────────────────────────────────────────
|
|
|
|
def setup(app, context):
|
|
config_dir = context["config_dir"]
|
|
base = Path(config_dir) / "achievements"
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
_state["db_path"] = str(base / "achievements.db")
|
|
_state["dir"] = str(Path(__file__).resolve().parent)
|
|
_state["config_dir"] = str(config_dir)
|
|
_state["meta_db"] = context.get("meta_db")
|
|
_state["log"] = context.get("log") or _state["log"]
|
|
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
|
|
# plain-import fallback for pytest / standalone use.
|
|
load_sibling = context.get("load_sibling")
|
|
try:
|
|
_state["engine"] = load_sibling("engine") if load_sibling else __import__("engine")
|
|
except Exception: # noqa: BLE001 — last-ditch, keep the plugin alive
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location(
|
|
"achievements_engine", str(Path(__file__).resolve().parent / "engine.py"))
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
_state["engine"] = mod
|
|
_init_db()
|
|
_load_catalogs()
|
|
log = _state["log"]
|
|
|
|
@app.post("/api/plugins/achievements/activity")
|
|
def post_activity(body: ActivityIn):
|
|
engine = _state["engine"]
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
# Per-chart play count is the only stateful bit; bump it first so
|
|
# apply_activity() stays pure (it just takes the new max).
|
|
chart_play_count = None
|
|
if body.song_done and body.chart:
|
|
chart_key = "chart_plays:" + str(abs(hash(body.chart)))
|
|
chart_play_count = _bump_counter(conn, chart_key, 1)
|
|
# Night-window ledger → consecutive-night run feeds witching feat.
|
|
if body.night_session and body.night_date:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)",
|
|
(body.night_date,),
|
|
)
|
|
nights = [r["token"] for r in conn.execute(
|
|
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
|
|
run = engine.consecutive_run_length(nights)
|
|
conn.execute(
|
|
"INSERT INTO counters(key, value) VALUES ('witching_nights_run', ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (run,))
|
|
|
|
counters = _read_counters(conn)
|
|
prev_tiers = _state["engine"].evaluate_feats(_state["feat_defs"], counters)
|
|
new_counters = engine.apply_activity(counters, {
|
|
"notes": body.notes,
|
|
"session_notes": body.session_notes,
|
|
"in_song_streak": body.in_song_streak,
|
|
"song_done": body.song_done,
|
|
"seconds": body.seconds,
|
|
"chart_play_count": chart_play_count,
|
|
})
|
|
_write_counters(conn, new_counters)
|
|
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
|
|
fresh = engine.diff_unlocks(prev_tiers, new_tiers)
|
|
unlocked = []
|
|
for fid in fresh:
|
|
f = _feat_by_id(fid) or {}
|
|
tier = new_tiers[fid]
|
|
at = _now_iso()
|
|
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at):
|
|
_enqueue_feat_sync(conn, fid, at)
|
|
unlocked.append(_feat_payload(fid, f, tier))
|
|
conn.commit()
|
|
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.post("/api/plugins/achievements/report-unlock")
|
|
def post_report_unlock(body: UnlockIn):
|
|
cls = "feat" if body.kind == "feat" else "competency"
|
|
at = body.at or _now_iso()
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
changed = _record_unlock(
|
|
conn, body.id, cls, body.category, body.sourceId, body.tier, at)
|
|
# Only Feats sync; competency never enqueues (integration law +
|
|
# data-minimization contract).
|
|
if changed and cls == "feat":
|
|
_enqueue_feat_sync(conn, body.id, at)
|
|
conn.commit()
|
|
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.post("/api/plugins/achievements/report-criterion")
|
|
def post_report_criterion(body: CriterionIn):
|
|
"""Record a distinct (criterion_id, token); return the distinct count.
|
|
|
|
Lets a baseline subscriber aggregate multi-event criteria (e.g. the set
|
|
of distinct days with a real advance → `steady_hands`) without us
|
|
re-deriving competency from activity. Bookkeeping over events only.
|
|
"""
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES (?, ?)",
|
|
(body.criterion_id, body.token),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM comp_ledger WHERE criterion_id=?",
|
|
(body.criterion_id,),
|
|
).fetchone()
|
|
conn.commit()
|
|
return {"ok": True, "count": int(row["n"]) if row else 0}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.get("/api/plugins/achievements/catalog")
|
|
def get_catalog():
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
earned = _earned_map(conn)
|
|
finally:
|
|
conn.close()
|
|
return {"baseline": _state["baseline"], "earned": earned}
|
|
|
|
@app.get("/api/plugins/achievements/earned")
|
|
def get_earned():
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
return {"earned": list(_earned_map(conn).values())}
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.get("/api/plugins/achievements/feats")
|
|
def get_feats():
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT achievement_id, tier, unlocked_at FROM unlocks WHERE cls='feat'"
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
out = []
|
|
for row in rows:
|
|
fid = row["achievement_id"]
|
|
f = _feat_by_id(fid) or {}
|
|
payload = _feat_payload(fid, f, int(row["tier"]))
|
|
payload["unlocked_at"] = row["unlocked_at"]
|
|
out.append(payload)
|
|
return {"feats": out}
|
|
|
|
@app.post("/api/plugins/achievements/remove-me")
|
|
def post_remove_me():
|
|
# Local removal works offline: drop the synced flag so nothing re-syncs,
|
|
# and enqueue a wall removal (drained in PR3). The wall identity
|
|
# (player_hash) is resolved server-side at drain time, not stored here.
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
|
|
conn.execute(
|
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', '{}', 'pending')")
|
|
conn.commit()
|
|
return {"ok": True}
|
|
finally:
|
|
conn.close()
|
|
|
|
log.info("achievements engine ready (%d feats, baseline v%s)",
|
|
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
|
|
|
|
|
|
def _feat_payload(fid, feat, tier):
|
|
titles = feat.get("tier_titles") or []
|
|
title = titles[tier] if 0 <= tier < len(titles) else feat.get("title", fid)
|
|
return {
|
|
"id": fid,
|
|
"cls": "feat",
|
|
"tier": tier,
|
|
"title": title,
|
|
"description": feat.get("description", ""),
|
|
"category": feat.get("category", "global"),
|
|
"secret": bool(feat.get("secret", False)),
|
|
}
|
|
|
|
|
|
def _earned_map(conn):
|
|
out = {}
|
|
for row in conn.execute(
|
|
"SELECT achievement_id, cls, disp_category, tier, unlocked_at FROM unlocks"
|
|
):
|
|
out[row["achievement_id"]] = {
|
|
"id": row["achievement_id"],
|
|
"cls": row["cls"],
|
|
"category": row["disp_category"],
|
|
"tier": int(row["tier"]),
|
|
"unlocked_at": row["unlocked_at"],
|
|
}
|
|
return out
|