mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* feat(achievements): wall sync drain worker (epic PR3, client side) Background dead-letter worker that POSTs queued Feat unlocks/removals to the hosted feedback-achievements wall. Idle unless FEEDBACK_ACHIEVEMENTS_WALL_URL is set; uses requests + the client-token header (mirrors lyrics_transcribe). Dead-letter, never drop (pure engine.drain_decision): network err / 429 / 5xx -> keep pending (retry) other 4xx -> dead_letter (diagnosable, replayable) 2xx -> delete on server ack remove-me enqueues a wall removal keyed by the reused player_hash. Verified by an end-to-end staging round-trip (earn a Feat -> drains onto the wall with name + short hash -> remove-me -> wall empties) with no IP in tables or access logs. 42 plugin tests pass (test_sync.py adds the decision table + ack/retry/dead-letter retention + four-field on-the-wire payload). The hosted service lives in the new feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(achievements): address local review findings (epic) Bugs caught in the pre-merge review loop: - secret_witching Feat was DEAD: post_activity wrote witching_nights_run to the DB before snapshotting prev_tiers, so diff_unlocks never saw the fresh unlock. Fold the run into the activity delta instead (same asymmetry chart_encore uses) so the 7th-night unlock is detected. +regression tests. - chart_encore broke across restarts: per-chart counter keyed on abs(hash(str)), which Python salts per-process (PYTHONHASHSEED). Use a stable sha1 digest so the same chart accumulates across sessions. +regression test. - Bounded the per-activity counter read: _read_counters no longer pulls the unbounded chart_plays:* rows (they're bumped/read individually). - screen.js: gate note:hit/miss on an active-song flag so tuner/calibration note events can't inflate Feats or flush a phantom chart:null session. - screen.js: P-III — prefix the plugin localStorage key (achievements:profile-cat). - screen.js: extract the duplicated local-ISO-date helper. 45 plugin tests pass (3 new). Wall-side review fixes are in the feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(achievements): default the drain worker to the hosted wall Point FEEDBACK_ACHIEVEMENTS_WALL_URL's default at the live got-feedback wall (https://feedback-achievements.onrender.com) so the drain worker targets it out of the box; still env-overridable for self-hosting/staging. Nothing publishes unless the user opted in AND has a profile identity, so a default URL alone sends nothing. Tests disable the default (autouse fixture) so no test ever POSTs to production; drain logic is covered via _drain_once() with an injected poster. 45 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
6.9 KiB
Python
192 lines
6.9 KiB
Python
"""Achievements & Feats of Power — pure evaluation helpers.
|
||
|
||
This module holds the side-effect-free core of the engine so it is unit
|
||
testable (constitution P-V): no IO, no SQLite, no clock. `routes.py` owns the
|
||
storage/HTTP shell and calls into these functions.
|
||
|
||
**Integration law (structural):** Feats are evaluated from *activity counters*
|
||
only (`evaluate_feats` / `apply_activity`); competency Achievements are recorded
|
||
from *competency events* the source reports (`report-unlock`). Nothing here ever
|
||
converts an activity count into a competency unlock or vice versa.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
# Counter keys the activity model owns. `*_max` keys take the running maximum;
|
||
# everything else is a cumulative running total. Kept here (not in routes) so a
|
||
# test can assert the contract without standing up a DB.
|
||
MAX_COUNTERS = frozenset({"notes_session_max", "streak_insong_max", "chart_encore_max"})
|
||
|
||
|
||
def tier_index_for(tiers, value):
|
||
"""Highest 0-based tier index whose threshold is met by ``value``.
|
||
|
||
Returns -1 when no tier is reached. Tiers are assumed ascending; we scan
|
||
all of them rather than short-circuit so an out-of-order catalogue still
|
||
resolves to the largest satisfied tier.
|
||
"""
|
||
idx = -1
|
||
for i, threshold in enumerate(tiers or []):
|
||
try:
|
||
if value >= threshold:
|
||
idx = i
|
||
except TypeError:
|
||
continue
|
||
return idx
|
||
|
||
|
||
def feat_counter_value(feat, counters):
|
||
"""Activity-counter value backing a Feat definition (0 when absent)."""
|
||
key = feat.get("counter")
|
||
if not key:
|
||
return 0
|
||
try:
|
||
return int(counters.get(key, 0) or 0)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def evaluate_feats(feat_defs, counters):
|
||
"""Map ``feat_id -> highest reached tier index`` for all satisfied Feats.
|
||
|
||
A Feat with no tiers, or whose counter hasn't reached tier 0, is omitted.
|
||
Pure: takes the current counters snapshot, returns a plain dict.
|
||
"""
|
||
out = {}
|
||
for feat in feat_defs or []:
|
||
fid = feat.get("id")
|
||
if not fid:
|
||
continue
|
||
tiers = feat.get("tiers") or []
|
||
if not tiers:
|
||
continue
|
||
ti = tier_index_for(tiers, feat_counter_value(feat, counters))
|
||
if ti >= 0:
|
||
out[fid] = ti
|
||
return out
|
||
|
||
|
||
def apply_activity(counters, delta):
|
||
"""Return a NEW counters dict after folding in one activity ``delta``.
|
||
|
||
Cumulative keys add; ``*_max`` keys keep the running maximum. The caller
|
||
(routes.py) is responsible for the only stateful bit — the per-chart play
|
||
count — and passes the post-increment value as ``delta['chart_play_count']``
|
||
so this function stays pure.
|
||
|
||
Recognised delta fields (all optional, default 0):
|
||
notes -> notes_total (+=)
|
||
song_done -> songs_done (+=)
|
||
seconds -> time_total_seconds (+=)
|
||
session_notes -> notes_session_max (max)
|
||
in_song_streak -> streak_insong_max (max)
|
||
chart_play_count -> chart_encore_max (max)
|
||
"""
|
||
out = dict(counters or {})
|
||
|
||
def _cur(key):
|
||
try:
|
||
return int(out.get(key, 0) or 0)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
def _int(v):
|
||
try:
|
||
return int(v or 0)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
out["notes_total"] = _cur("notes_total") + _int(delta.get("notes"))
|
||
out["songs_done"] = _cur("songs_done") + _int(delta.get("song_done"))
|
||
out["time_total_seconds"] = _cur("time_total_seconds") + _int(delta.get("seconds"))
|
||
out["notes_session_max"] = max(_cur("notes_session_max"), _int(delta.get("session_notes")))
|
||
out["streak_insong_max"] = max(_cur("streak_insong_max"), _int(delta.get("in_song_streak")))
|
||
if delta.get("chart_play_count") is not None:
|
||
out["chart_encore_max"] = max(_cur("chart_encore_max"), _int(delta.get("chart_play_count")))
|
||
return out
|
||
|
||
|
||
def consecutive_run_length(dates):
|
||
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||
|
||
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
||
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||
"""
|
||
from datetime import date
|
||
|
||
parsed = []
|
||
for d in dates or []:
|
||
try:
|
||
y, m, dd = (int(x) for x in str(d).split("-"))
|
||
parsed.append(date(y, m, dd))
|
||
except (ValueError, TypeError):
|
||
continue
|
||
if not parsed:
|
||
return 0
|
||
parsed = sorted(set(parsed))
|
||
best = run = 1
|
||
for prev, cur in zip(parsed, parsed[1:]):
|
||
if (cur - prev).days == 1:
|
||
run += 1
|
||
best = max(best, run)
|
||
else:
|
||
run = 1
|
||
return best
|
||
|
||
|
||
# ── Data-minimization contract (binding, code-enforced) ──────────────────────
|
||
# The wall payload key-set is frozen here and asserted by a unit test. The
|
||
# serializer below is the ONLY way outbound data is built — never dict(row) or
|
||
# **model — so a stray field cannot leak. Adding a key makes the test go red.
|
||
WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at")
|
||
|
||
|
||
def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at):
|
||
"""Build the EXACT four-field wall payload. ``achievement_id`` must always be
|
||
a Feat id (the caller only ever invokes this for Feat unlocks — competency
|
||
never syncs). Explicit literal dict on purpose; do not refactor into a
|
||
row/model splat."""
|
||
return {
|
||
"display_name": display_name,
|
||
"player_hash": player_hash,
|
||
"achievement_id": achievement_id,
|
||
"unlocked_at": 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).
|
||
|
||
``prev_tiers`` / ``new_tiers`` are ``feat_id -> tier_index`` maps as
|
||
returned by :func:`evaluate_feats`. Returns the ids that are newly present
|
||
or moved to a higher tier — i.e. the Feats to record + announce this round.
|
||
"""
|
||
out = []
|
||
for fid, tier in (new_tiers or {}).items():
|
||
if tier > prev_tiers.get(fid, -1):
|
||
out.append(fid)
|
||
return out
|