"""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