diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d3fcc..e1b8720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now 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. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo. - **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 `/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 `
` panel into `#plugin-settings-`, 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`. diff --git a/plugins/achievements/engine.py b/plugins/achievements/engine.py index 956369a..4cff0a5 100644 --- a/plugins/achievements/engine.py +++ b/plugins/achievements/engine.py @@ -155,6 +155,28 @@ def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at): } +def drain_decision(status): + """Dead-letter state machine for one wall-sync attempt (pure). + + ``status`` is the HTTP status code, or ``None`` for a network error. + Returns one of: + 'ack' → server accepted; delete the row. + 'retry' → keep it pending (network error, 429 backoff, or 5xx). + 'dead' → any other 4xx; move to dead_letter (diagnosable/replayable). + A row is NEVER silently dropped — it leaves the queue only on 'ack' (or a + user opt-out wiping it). + """ + if status is None: + return "retry" + if 200 <= status < 300: + return "ack" + if status == 429: + return "retry" + if 400 <= status < 500: + return "dead" + return "retry" # 5xx — transient server-side, try again later + + def diff_unlocks(prev_tiers, new_tiers): """Feat ids whose tier advanced (incl. first unlock). diff --git a/plugins/achievements/routes.py b/plugins/achievements/routes.py index 7939460..69565bf 100644 --- a/plugins/achievements/routes.py +++ b/plugins/achievements/routes.py @@ -26,14 +26,15 @@ 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 fastapi import HTTPException from pydantic import BaseModel, Field _lock = threading.Lock() @@ -162,8 +163,109 @@ def _enqueue_feat_sync(conn, feat_id, unlocked_at): 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): - return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")} + # 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): @@ -297,13 +399,18 @@ def setup(app, context): 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). + # 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_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. + 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', ?)", @@ -311,13 +418,10 @@ def setup(app, context): ) 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,)) + witching_run = engine.consecutive_run_length(nights) counters = _read_counters(conn) - prev_tiers = _state["engine"].evaluate_feats(_state["feat_defs"], counters) + prev_tiers = engine.evaluate_feats(_state["feat_defs"], counters) new_counters = engine.apply_activity(counters, { "notes": body.notes, "session_notes": body.session_notes, @@ -326,6 +430,9 @@ def setup(app, context): "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) @@ -427,17 +534,24 @@ def setup(app, context): # 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'") - conn.execute( - "INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', '{}', 'pending')") + # 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", "?"))) diff --git a/plugins/achievements/screen.js b/plugins/achievements/screen.js index 68937a8..cc07ca8 100644 --- a/plugins/achievements/screen.js +++ b/plugins/achievements/screen.js @@ -33,7 +33,7 @@ var registered = {}; // id -> def (contributed + expanded baseline defs) var earned = {}; // id -> { tier, cls, category } var baseline = null; // /catalog baseline blob - var CAT_KEY = 'v3-profile-ach-cat'; + var CAT_KEY = 'achievements:profile-cat'; // P-III: plugin localStorage keys prefixed with plugin id var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys']; function progState() { @@ -172,11 +172,17 @@ postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); }); } + // Local calendar date 'YYYY-MM-DD' (one source of truth for both the + // steady-hands day ledger and the witching-night date below). + function localISODate(d) { + d = d || new Date(); + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + } + // growth-streak + challenger record on real competency events (date ledger / // distinct challenge sets) — kept as bookkeeping over EVENTS, never activity. function recordGrowthDay() { - var date = new Date(); - var iso = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0'); + var iso = localISODate(); fetchJSON('/report-criterion', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }), @@ -190,17 +196,21 @@ // ── Activity (Feats): in-memory session counters, flushed on song:ended ─── var session = { notesTotal: 0 }; // cumulative across this sitting - var song = { hits: 0, streak: 0, maxStreak: 0, chart: null }; - function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null }; } + // `active` gates note counting to an actual song in progress — without it, + // note:hit/miss from the tuner or input-calibration would inflate Feats from + // non-song input and flush a phantom streak with chart:null. + var song = { hits: 0, streak: 0, maxStreak: 0, chart: null, active: false }; + function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null, active: true }; } function flushActivity(seconds) { + if (!song.active) return; // no active song → nothing to flush (ignore stray events) + song.active = false; // No notedetect → song.hits stays 0; notes-based Feats simply don't move // (graceful degradation). song_done / seconds / chart still flow so the // notedetect-free Feats (Road Warrior, Time Served, Encore) progress. var hour = new Date().getHours(); var isNight = hour >= 2 && hour < 5; - var d = new Date(); - var iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + var iso = localISODate(); var body = { notes: song.hits, session_notes: session.notesTotal, @@ -364,13 +374,17 @@ resetSong(e && e.detail && e.detail.filename); }); bus.on && bus.on('note:hit', function () { + if (!song.active) return; // ignore tuner/calibration note events song.hits++; song.streak++; session.notesTotal++; if (song.streak > song.maxStreak) song.maxStreak = song.streak; }); - bus.on && bus.on('note:miss', function () { song.streak = 0; }); + bus.on && bus.on('note:miss', function () { if (song.active) song.streak = 0; }); bus.on && bus.on('song:ended', function (e) { flushActivity(e && e.detail && (e.detail.time || e.detail.audioT)); }); + // Song stopped/abandoned without a natural end → mark inactive so stray + // note events after it don't accrue against a phantom (chart:null) song. + bus.on && bus.on('song:stop', function () { song.active = false; }); } if (document.readyState === 'loading') { diff --git a/tests/plugins/achievements/conftest.py b/tests/plugins/achievements/conftest.py index 7dc6d96..9bbbccd 100644 --- a/tests/plugins/achievements/conftest.py +++ b/tests/plugins/achievements/conftest.py @@ -10,6 +10,17 @@ from fastapi.testclient import TestClient import routes as ach_routes +@pytest.fixture(autouse=True) +def _no_live_drain(): + # routes now defaults the wall URL to the live onrender service, so setup() + # would auto-start the drain thread. Disable it for every test so no test + # ever POSTs to production; the drain logic is exercised via _drain_once() + # with an injected poster instead. + ach_routes._WALL_URL = "" + ach_routes._drain_started = False + yield + + @pytest.fixture def client(tmp_path): app = FastAPI() diff --git a/tests/plugins/achievements/test_routes.py b/tests/plugins/achievements/test_routes.py index a14c5cc..dbdf69f 100644 --- a/tests/plugins/achievements/test_routes.py +++ b/tests/plugins/achievements/test_routes.py @@ -54,6 +54,35 @@ def test_report_unlock_is_idempotent_and_tier_monotonic(client): assert higher["changed"] is True +def test_witching_feat_unlocks_on_seventh_consecutive_night(client): + # Regression: the derived witching_nights_run counter must NOT be pre-written + # before the prev snapshot, or diff_unlocks never sees the fresh unlock. + unlocked_ever = [] + for day in range(1, 8): + res = client.post("/api/plugins/achievements/activity", + json={"night_session": True, "night_date": "2026-06-%02d" % day}).json() + unlocked_ever += [u["id"] for u in res["unlocked"]] + assert "secret_witching" in unlocked_ever, "witching feat never reported as unlocked" + feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]] + assert "secret_witching" in feats + + +def test_witching_not_unlocked_before_seven(client): + for day in range(1, 7): # only 6 nights + client.post("/api/plugins/achievements/activity", + json={"night_session": True, "night_date": "2026-06-%02d" % day}) + feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]] + assert "secret_witching" not in feats + + +def test_chart_key_is_stable_not_builtin_hash(client): + import hashlib + import routes + # Deterministic across processes (sha1-based), unlike the salted builtin hash(). + assert routes._chart_key("song.sloppak") == "chart_plays:" + hashlib.sha1(b"song.sloppak").hexdigest()[:16] + assert routes._chart_key("a") != routes._chart_key("b") + + 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 diff --git a/tests/plugins/achievements/test_sync.py b/tests/plugins/achievements/test_sync.py new file mode 100644 index 0000000..55ec7ab --- /dev/null +++ b/tests/plugins/achievements/test_sync.py @@ -0,0 +1,84 @@ +"""Wall-sync drain worker — dead-letter state machine (never drop).""" + +import json +import sqlite3 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import engine +import routes as ach_routes + + +# ── Pure decision table ────────────────────────────────────────────────────── + +@pytest.mark.parametrize("status,expected", [ + (200, "ack"), (201, "ack"), + (None, "retry"), # network error + (429, "retry"), # backoff + (500, "retry"), (503, "retry"), # transient server-side + (400, "dead"), (404, "dead"), (422, "dead"), # client 4xx → dead-letter +]) +def test_drain_decision(status, expected): + assert engine.drain_decision(status) == expected + + +# ── Worker over a seeded queue ─────────────────────────────────────────────── + +class _FakeMetaDB: + def get_profile(self): + return {"display_name": "Ada", "player_hash": "deadbeefcafe"} + + +@pytest.fixture +def opted_in_client(tmp_path): + (tmp_path / "config.json").write_text(json.dumps({"achievements_enabled": True})) + app = FastAPI() + ach_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": _FakeMetaDB()}) + c = TestClient(app) + c._tmp = tmp_path + return c + + +def _queue(tmp_path): + db = sqlite3.connect(str(tmp_path / "achievements" / "achievements.db")) + try: + return [(i, k, s) for (i, k, s) in db.execute("SELECT id, kind, state FROM sync_queue")] + finally: + db.close() + + +def test_ack_deletes_row(opted_in_client): + opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000}) + assert len(_queue(opted_in_client._tmp)) == 1 + ach_routes._drain_once(post_fn=lambda kind, payload: 200) + assert _queue(opted_in_client._tmp) == [] # acked → gone + + +def test_network_error_keeps_pending(opted_in_client): + opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000}) + ach_routes._drain_once(post_fn=lambda kind, payload: None) + rows = _queue(opted_in_client._tmp) + assert len(rows) == 1 and rows[0][2] == "pending" # retained for retry + + +def test_4xx_dead_letters_but_retains(opted_in_client): + opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000}) + ach_routes._drain_once(post_fn=lambda kind, payload: 400) + rows = _queue(opted_in_client._tmp) + assert len(rows) == 1 and rows[0][2] == "dead_letter" # diagnosable, not dropped + + +def test_drain_sends_exact_four_field_payload(opted_in_client): + opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000}) + captured = {} + + def fake_post(kind, payload): + captured["kind"] = kind + captured["payload"] = payload + return 200 + + ach_routes._drain_once(post_fn=fake_post) + assert captured["kind"] == "unlock" + assert set(captured["payload"].keys()) == set(engine.WALL_PAYLOAD_KEYS)