mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
* feat(achievements): wall sync drain worker (epic PR3, client side) Background dead-letter worker that POSTs queued Feat unlocks/removals to the hosted feedback-achievements wall. Idle unless FEEDBACK_ACHIEVEMENTS_WALL_URL is set; uses requests + the client-token header (mirrors lyrics_transcribe). Dead-letter, never drop (pure engine.drain_decision): network err / 429 / 5xx -> keep pending (retry) other 4xx -> dead_letter (diagnosable, replayable) 2xx -> delete on server ack remove-me enqueues a wall removal keyed by the reused player_hash. Verified by an end-to-end staging round-trip (earn a Feat -> drains onto the wall with name + short hash -> remove-me -> wall empties) with no IP in tables or access logs. 42 plugin tests pass (test_sync.py adds the decision table + ack/retry/dead-letter retention + four-field on-the-wire payload). The hosted service lives in the new feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(achievements): address local review findings (epic) Bugs caught in the pre-merge review loop: - secret_witching Feat was DEAD: post_activity wrote witching_nights_run to the DB before snapshotting prev_tiers, so diff_unlocks never saw the fresh unlock. Fold the run into the activity delta instead (same asymmetry chart_encore uses) so the 7th-night unlock is detected. +regression tests. - chart_encore broke across restarts: per-chart counter keyed on abs(hash(str)), which Python salts per-process (PYTHONHASHSEED). Use a stable sha1 digest so the same chart accumulates across sessions. +regression test. - Bounded the per-activity counter read: _read_counters no longer pulls the unbounded chart_plays:* rows (they're bumped/read individually). - screen.js: gate note:hit/miss on an active-song flag so tuner/calibration note events can't inflate Feats or flush a phantom chart:null session. - screen.js: P-III — prefix the plugin localStorage key (achievements:profile-cat). - screen.js: extract the duplicated local-ISO-date helper. 45 plugin tests pass (3 new). Wall-side review fixes are in the feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(achievements): default the drain worker to the hosted wall Point FEEDBACK_ACHIEVEMENTS_WALL_URL's default at the live got-feedback wall (https://feedback-achievements.onrender.com) so the drain worker targets it out of the box; still env-overridable for self-hosting/staging. Nothing publishes unless the user opted in AND has a profile identity, so a default URL alone sends nothing. Tests disable the default (autouse fixture) so no test ever POSTs to production; drain logic is covered via _drain_once() with an injected poster. 45 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
586 lines
23 KiB
Python
586 lines
23 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 hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
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
|
|
|
|
|
|
# ── Wall sync — background drain worker (dead-letter, never drop) ─────────────
|
|
# Idle unless a wall URL is configured. POSTs pending rows to the hosted
|
|
# feedback-achievements service; the decision state machine
|
|
# (engine.drain_decision) is pure + tested. A row leaves the queue only on a
|
|
# server ack (or a user opt-out wiping it) — never silently dropped.
|
|
|
|
# Canonical hosted Feats wall (the got-feedback service). Used by default so the
|
|
# drain worker targets it out of the box; override via env for self-hosting or a
|
|
# staging wall. Nothing is ever sent unless the user opted in AND has an identity
|
|
# (see _enqueue_feat_sync), so a default URL does not publish anything on its own.
|
|
_DEFAULT_WALL_URL = "https://feedback-achievements.onrender.com"
|
|
_WALL_URL = (os.environ.get("FEEDBACK_ACHIEVEMENTS_WALL_URL")
|
|
or os.environ.get("SLOPSMITH_ACHIEVEMENTS_WALL_URL")
|
|
or _DEFAULT_WALL_URL).rstrip("/")
|
|
_WALL_TOKEN = os.environ.get("FEEDBACK_ACHIEVEMENTS_CLIENT_TOKEN", "fb-wall-v1")
|
|
_DRAIN_INTERVAL_S = int(os.environ.get("FEEDBACK_ACHIEVEMENTS_DRAIN_INTERVAL", "30"))
|
|
_drain_started = False
|
|
|
|
|
|
def _post_to_wall(kind, payload):
|
|
"""POST one queued item; return the HTTP status code, or None on a network
|
|
error. Mirrors the lib/lyrics_transcribe outbound pattern (explicit timeout,
|
|
no raise on non-2xx — the caller's state machine decides)."""
|
|
import requests # local import: only needed when a wall is configured
|
|
|
|
path = "/api/unlock" if kind == "unlock" else "/api/remove"
|
|
try:
|
|
resp = requests.post(
|
|
_WALL_URL + path, json=payload,
|
|
headers={"X-Client-Token": _WALL_TOKEN, "Content-Type": "application/json"},
|
|
timeout=10,
|
|
)
|
|
return resp.status_code
|
|
except requests.RequestException:
|
|
return None
|
|
|
|
|
|
def _drain_once(post_fn=None):
|
|
"""Process all pending queue rows once. ``post_fn(kind, payload) -> status``
|
|
is injectable for tests; defaults to the real wall POST."""
|
|
post_fn = post_fn or _post_to_wall
|
|
engine = _state["engine"]
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, kind, payload FROM sync_queue WHERE state='pending'").fetchall()
|
|
finally:
|
|
conn.close()
|
|
for row in rows:
|
|
try:
|
|
payload = json.loads(row["payload"]) if row["payload"] else {}
|
|
except ValueError:
|
|
payload = {}
|
|
status = post_fn(row["kind"], payload)
|
|
action = engine.drain_decision(status)
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
if action == "ack":
|
|
conn.execute("DELETE FROM sync_queue WHERE id=?", (row["id"],))
|
|
elif action == "dead":
|
|
conn.execute("UPDATE sync_queue SET state='dead_letter' WHERE id=?", (row["id"],))
|
|
# 'retry' → leave it pending for the next pass
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _drain_loop():
|
|
while True:
|
|
try:
|
|
_drain_once()
|
|
except Exception as e: # noqa: BLE001 — a worker crash must not kill the thread
|
|
_state["log"].warning("achievements wall drain error: %s", e)
|
|
time.sleep(_DRAIN_INTERVAL_S)
|
|
|
|
|
|
def _maybe_start_drain():
|
|
global _drain_started
|
|
if _drain_started or not _WALL_URL:
|
|
return
|
|
_drain_started = True
|
|
threading.Thread(target=_drain_loop, name="ach-wall-drain", daemon=True).start()
|
|
_state["log"].info("achievements wall drain worker started → %s", _WALL_URL)
|
|
|
|
|
|
def _chart_key(chart):
|
|
"""Stable per-chart counter key — a sha1 digest of the chart id. NOT the
|
|
builtin hash(), whose str hashing is salted per process (PYTHONHASHSEED), so
|
|
the same chart would land on a different counter after every restart and the
|
|
Encore Feat could never accumulate across sessions."""
|
|
return "chart_plays:" + hashlib.sha1(str(chart).encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _read_counters(conn):
|
|
# Excludes the per-chart `chart_plays:*` rows: they are bumped + read
|
|
# individually via _bump_counter and would otherwise make this aggregate
|
|
# round-trip O(distinct charts played) on every activity POST.
|
|
return {
|
|
row["key"]: int(row["value"])
|
|
for row in conn.execute("SELECT key, value FROM counters WHERE key NOT LIKE 'chart_plays:%'")
|
|
}
|
|
|
|
|
|
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 directly-stateful bit; bump it
|
|
# first (stable key) so apply_activity() just takes the new max.
|
|
chart_play_count = None
|
|
if body.song_done and body.chart:
|
|
chart_play_count = _bump_counter(conn, _chart_key(body.chart), 1)
|
|
# Night-window ledger → consecutive-night run. Computed here but
|
|
# NOT written before the prev snapshot: it is folded into the delta
|
|
# below so prev_tiers reflects the OLD run and new_tiers the new one
|
|
# (the same asymmetry chart_encore relies on). Pre-writing it would
|
|
# make prev already satisfy the Feat, so diff_unlocks would never
|
|
# see the freshly-earned witching unlock.
|
|
witching_run = None
|
|
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'")]
|
|
witching_run = engine.consecutive_run_length(nights)
|
|
|
|
counters = _read_counters(conn)
|
|
prev_tiers = 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,
|
|
})
|
|
if witching_run is not None:
|
|
new_counters["witching_nights_run"] = max(
|
|
int(new_counters.get("witching_nights_run", 0) or 0), witching_run)
|
|
_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.
|
|
_, player_hash = _identity()
|
|
with _lock:
|
|
conn = _conn()
|
|
try:
|
|
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
|
|
# Enqueue a wall removal only when we have an identity to key it
|
|
# by; the drain worker (below) POSTs it. Idempotent server-side.
|
|
if player_hash:
|
|
conn.execute(
|
|
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', ?, 'pending')",
|
|
(json.dumps({"player_hash": player_hash}),),
|
|
)
|
|
conn.commit()
|
|
return {"ok": True}
|
|
finally:
|
|
conn.close()
|
|
|
|
_maybe_start_drain()
|
|
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
|