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
+29
View File
@@ -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