mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 05:37:42 +00:00
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:
co-authored by
Claude Opus 4.8
parent
287c23a532
commit
d2569cc2a8
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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", "?")))
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user