feat(achievements): wall sync drain worker + review fixes (epic PR3) (#592)

* 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>
This commit is contained in:
Byron Gamatos
2026-06-24 17:01:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 287c23a532
commit d2569cc2a8
7 changed files with 297 additions and 22 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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 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`.
+22
View File
@@ -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): def diff_unlocks(prev_tiers, new_tiers):
"""Feat ids whose tier advanced (incl. first unlock). """Feat ids whose tier advanced (incl. first unlock).
+128 -14
View File
@@ -26,14 +26,15 @@ Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
this module is the SQLite + HTTP shell. this module is the SQLite + HTTP shell.
""" """
import hashlib
import json import json
import logging import logging
import os
import sqlite3 import sqlite3
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
_lock = threading.Lock() _lock = threading.Lock()
@@ -162,8 +163,109 @@ def _enqueue_feat_sync(conn, feat_id, unlocked_at):
return True 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): 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): def _write_counters(conn, counters):
@@ -297,13 +399,18 @@ def setup(app, context):
with _lock: with _lock:
conn = _conn() conn = _conn()
try: try:
# Per-chart play count is the only stateful bit; bump it first so # Per-chart play count is the only directly-stateful bit; bump it
# apply_activity() stays pure (it just takes the new max). # first (stable key) so apply_activity() just takes the new max.
chart_play_count = None chart_play_count = None
if body.song_done and body.chart: if body.song_done and body.chart:
chart_key = "chart_plays:" + str(abs(hash(body.chart))) chart_play_count = _bump_counter(conn, _chart_key(body.chart), 1)
chart_play_count = _bump_counter(conn, chart_key, 1) # Night-window ledger → consecutive-night run. Computed here but
# Night-window ledger → consecutive-night run feeds witching feat. # 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: if body.night_session and body.night_date:
conn.execute( conn.execute(
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)", "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( nights = [r["token"] for r in conn.execute(
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")] "SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
run = engine.consecutive_run_length(nights) 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) 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, { new_counters = engine.apply_activity(counters, {
"notes": body.notes, "notes": body.notes,
"session_notes": body.session_notes, "session_notes": body.session_notes,
@@ -326,6 +430,9 @@ def setup(app, context):
"seconds": body.seconds, "seconds": body.seconds,
"chart_play_count": chart_play_count, "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) _write_counters(conn, new_counters)
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters) new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
fresh = engine.diff_unlocks(prev_tiers, new_tiers) 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, # Local removal works offline: drop the synced flag so nothing re-syncs,
# and enqueue a wall removal (drained in PR3). The wall identity # 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) is resolved server-side at drain time, not stored here.
_, player_hash = _identity()
with _lock: with _lock:
conn = _conn() conn = _conn()
try: try:
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'") conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
conn.execute( # Enqueue a wall removal only when we have an identity to key it
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', '{}', 'pending')") # 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() conn.commit()
return {"ok": True} return {"ok": True}
finally: finally:
conn.close() conn.close()
_maybe_start_drain()
log.info("achievements engine ready (%d feats, baseline v%s)", log.info("achievements engine ready (%d feats, baseline v%s)",
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?"))) len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
+22 -8
View File
@@ -33,7 +33,7 @@
var registered = {}; // id -> def (contributed + expanded baseline defs) var registered = {}; // id -> def (contributed + expanded baseline defs)
var earned = {}; // id -> { tier, cls, category } var earned = {}; // id -> { tier, cls, category }
var baseline = null; // /catalog baseline blob 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']; var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys'];
function progState() { function progState() {
@@ -172,11 +172,17 @@
postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); }); 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 / // growth-streak + challenger record on real competency events (date ledger /
// distinct challenge sets) — kept as bookkeeping over EVENTS, never activity. // distinct challenge sets) — kept as bookkeeping over EVENTS, never activity.
function recordGrowthDay() { function recordGrowthDay() {
var date = new Date(); var iso = localISODate();
var iso = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
fetchJSON('/report-criterion', { fetchJSON('/report-criterion', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }), body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }),
@@ -190,17 +196,21 @@
// ── Activity (Feats): in-memory session counters, flushed on song:ended ─── // ── Activity (Feats): in-memory session counters, flushed on song:ended ───
var session = { notesTotal: 0 }; // cumulative across this sitting var session = { notesTotal: 0 }; // cumulative across this sitting
var song = { hits: 0, streak: 0, maxStreak: 0, chart: null }; // `active` gates note counting to an actual song in progress — without it,
function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null }; } // 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) { 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 // No notedetect → song.hits stays 0; notes-based Feats simply don't move
// (graceful degradation). song_done / seconds / chart still flow so the // (graceful degradation). song_done / seconds / chart still flow so the
// notedetect-free Feats (Road Warrior, Time Served, Encore) progress. // notedetect-free Feats (Road Warrior, Time Served, Encore) progress.
var hour = new Date().getHours(); var hour = new Date().getHours();
var isNight = hour >= 2 && hour < 5; var isNight = hour >= 2 && hour < 5;
var d = new Date(); var iso = localISODate();
var iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
var body = { var body = {
notes: song.hits, notes: song.hits,
session_notes: session.notesTotal, session_notes: session.notesTotal,
@@ -364,13 +374,17 @@
resetSong(e && e.detail && e.detail.filename); resetSong(e && e.detail && e.detail.filename);
}); });
bus.on && bus.on('note:hit', function () { bus.on && bus.on('note:hit', function () {
if (!song.active) return; // ignore tuner/calibration note events
song.hits++; song.streak++; session.notesTotal++; song.hits++; song.streak++; session.notesTotal++;
if (song.streak > song.maxStreak) song.maxStreak = song.streak; 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) { bus.on && bus.on('song:ended', function (e) {
flushActivity(e && e.detail && (e.detail.time || e.detail.audioT)); 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') { if (document.readyState === 'loading') {
+11
View File
@@ -10,6 +10,17 @@ from fastapi.testclient import TestClient
import routes as ach_routes 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 @pytest.fixture
def client(tmp_path): def client(tmp_path):
app = FastAPI() app = FastAPI()
+29
View File
@@ -54,6 +54,35 @@ def test_report_unlock_is_idempotent_and_tier_monotonic(client):
assert higher["changed"] is True 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): def test_report_criterion_counts_distinct(client):
url = "/api/plugins/achievements/report-criterion" 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
+84
View File
@@ -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)