mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 03:34:29 +00:00
Three spots used British "practise" while the rest of the app uses American "practice" (Keep practicing, Virtuoso - Practice, etc.): - static/v3/profile.js — first-run onboarding "Feats of Power" step - plugins/achievements/feats.json — The Witching Hour feat description - plugins/achievements/engine.py — code comment Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: topkoa <topkoa@gmail.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 (practice 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
|