diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 43b800e..28258db 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(7,798 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and nine `routers/` modules) · +(7,594 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and ten `routers/` modules) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/appstate.py b/lib/appstate.py index d4b1234..06af167 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -86,12 +86,13 @@ audio_cache_dir = None # through the seam. get_progression_content wraps a lazy content cache that stays # in server.py (its `setattr(server, "_progression_content")` test is untouched). get_progression_content = None +builtin_diagnostic_filename = None _SLOTS = frozenset({ "meta_db", "audio_effect_mappings", "config_dir", "dlc_dir", "dlc_dir_env", "static_dir", "sloppak_cache_dir", "audio_cache_dir", - "get_progression_content", + "get_progression_content", "builtin_diagnostic_filename", }) diff --git a/lib/routers/progression.py b/lib/routers/progression.py new file mode 100644 index 0000000..c2e7db0 --- /dev/null +++ b/lib/routers/progression.py @@ -0,0 +1,230 @@ +"""Progression (spec 010) — mastery rank, challenges, quests, onboarding paths. + +Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``, +``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``, and the +two shared server accessors read through the seam: +``_get_progression_content()`` -> ``appstate.get_progression_content()`` and +``_builtin_diagnostic_filename()`` -> ``appstate.builtin_diagnostic_filename()``. +The exclusive helpers (_goal_ui_progress, _progression_overview) + the +_PROGRESSION_EVENT_TYPES whitelist move with it. +""" + +import math + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +import appstate +from reqfields import _clean_str + +router = APIRouter() + + +def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple: + """(count, target) for a challenge/quest progress bar. Count goals show + n/target; threshold goals show how far the live stat is along the line.""" + import progression as progression_mod + gtype = goal.get("type") + if gtype in progression_mod.COUNT_GOAL_TYPES: + target = int(goal.get("target") or 1) + count = target if state.get("completed") else min(int(state.get("count") or 0), target) + return count, target + if gtype == "streak_reached": + target = int(goal.get("days") or 1) + return (target if state.get("completed") else min(streak, target)), target + if gtype == "db_earned": + target = int(goal.get("amount") or 1) + return (target if state.get("completed") else min(xp_total, target)), target + return 0, 1 + + +def _progression_overview() -> dict: + """The full GET /api/progression payload (also the capability `inspect` + result): rank, onboarding, per-path challenge checklists, quests, wallet.""" + import progression as progression_mod + from datetime import datetime as _dt + content = appstate.get_progression_content() + now = _dt.now() + appstate.meta_db.ensure_quest_period(content, now) + + state = appstate.meta_db.get_progression_state() + player_paths = appstate.meta_db.get_player_paths() + challenge_state = appstate.meta_db.get_challenge_state() + wallet = appstate.meta_db.get_wallet() + streak_progress = appstate.meta_db.get_progress() + streak = int(streak_progress.get("current_streak") or 0) + xp_total = wallet["lifetime_db"] + keys = progression_mod.period_keys(now) + + def _path_order(pid): + pdef = content["paths"].get(pid) or {} + return (pdef.get("order") or 0, pid) + + paths_payload = [] + for pid in sorted(player_paths, key=_path_order): + pdef = content["paths"].get(pid) + level = player_paths[pid] + if not pdef: + # Path selected under older content that no longer ships: keep its + # rank contribution visible rather than silently dropping it. + paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level, + "max_level": level, "next": None}) + continue + next_block = None + active = progression_mod.active_challenges(content, pid, level) + if active: + level_def = next(e for e in pdef["levels"] if e["level"] == level + 1) + challenges = [] + completed_count = 0 + for ch in active: + st = challenge_state.get(ch["id"]) or {} + count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total) + if st.get("completed"): + completed_count += 1 + challenges.append({ + "id": ch["id"], + "title": ch["title"], + "description": ch["description"], + "count": count, + "target": target, + "completed": bool(st.get("completed")), + "completed_at": st.get("completed_at"), + }) + next_block = { + "level": level + 1, + "required": level_def["required"], + "completed": completed_count, + "challenges": challenges, + } + paths_payload.append({ + "id": pid, + "name": pdef["name"], + "icon": pdef["icon"], + "level": level, + "max_level": progression_mod.path_max_level(content, pid), + "next": next_block, + }) + + available = [ + {"id": pid, "name": pdef["name"], "icon": pdef["icon"]} + for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0])) + if pid not in player_paths + ] + + quest_rows = appstate.meta_db.get_quest_rows(keys) + quests_payload = {} + for period_type in ("daily", "weekly"): + pool = content["quests"][period_type]["pool"] + quests = [] + for row in quest_rows: + if row["period_type"] != period_type: + continue + qdef = pool.get(row["quest_id"]) + if not qdef: + continue # removed from the pool mid-period: hide, keep the row + count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total) + quests.append({ + "id": row["quest_id"], + "title": qdef["title"], + "description": qdef["description"], + "reward_db": row["reward_db"], + "count": count, + "target": target, + "completed": row["completed"], + "completed_at": row["completed_at"], + }) + quests_payload[period_type] = { + "period_key": keys[period_type], + "resets_at": progression_mod.period_resets_at(period_type, now).isoformat(), + "quests": quests, + } + + return { + "mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths), + "onboarding": { + "calibration_status": state["calibration_status"], + "calibration_completed_at": state["calibration_completed_at"], + "diagnostic_filename": appstate.builtin_diagnostic_filename(), + }, + "paths": paths_payload, + "available_paths": available, + "quests": quests_payload, + "wallet": wallet, + } + + +@router.get("/api/progression") +def api_progression(): + return _progression_overview() + + +@router.post("/api/progression/paths") +def api_progression_add_paths(data: dict): + """Select instrument paths. Body: {add: [path_id, ...]}. Idempotent; + removal is unsupported (Mastery Rank never decreases).""" + add = data.get("add") + if not isinstance(add, list) or not add: + return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400) + content = appstate.get_progression_content() + for pid in add: + if not isinstance(pid, str) or pid not in content["paths"]: + return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400) + appstate.meta_db.add_player_paths(add) + return _progression_overview() + + +@router.post("/api/progression/onboarding") +def api_progression_onboarding(data: dict): + """Onboarding calibration choice. Body: {action: "skip"} — completing the + calibration needs no endpoint, it flows through the normal /api/stats path.""" + if _clean_str(data.get("action")) != "skip": + return JSONResponse({"error": "action must be 'skip'"}, status_code=400) + # Spec invariant: onboarding requires picking at least one instrument path + # before finishing, so skipping straight to rank 1 with no paths would + # leave a rank that can never grow. Only enforced when the content bundle + # actually defines paths — broken/empty content must never brick onboarding. + if appstate.get_progression_content()["paths"] and not appstate.meta_db.get_player_paths(): + return JSONResponse( + {"error": "select at least one instrument path before skipping calibration"}, + status_code=400, + ) + appstate.meta_db.skip_calibration() + return _progression_overview() + + +# Externally postable progression events. song_completed is deliberately NOT +# here: it is server-derived inside /api/stats so the scored-session authority +# stays in one place. +_PROGRESSION_EVENT_TYPES = {"minigame_run"} + + +@router.post("/api/progression/events") +def api_progression_events(data: dict): + """Generic progression-event intake for plugins (capability `record-event`). + Body: {type, payload}. Whitelisted types, scalar payload values only.""" + etype = _clean_str(data.get("type")) + if etype not in _PROGRESSION_EVENT_TYPES: + return JSONResponse( + {"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"}, + status_code=400, + ) + payload = data.get("payload") + if payload is None: + payload = {} + if not isinstance(payload, dict) or len(payload) > 16: + return JSONResponse({"error": "payload must be a small object"}, status_code=400) + clean = {} + for key, value in payload.items(): + if not isinstance(key, str) or len(key) > 64: + return JSONResponse({"error": "payload keys must be short strings"}, status_code=400) + if value is None: + continue + if isinstance(value, bool) or ( + not isinstance(value, (int, float, str)) + ) or (isinstance(value, float) and not math.isfinite(value)) or ( + isinstance(value, str) and len(value) > 256 + ): + return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400) + clean[key] = value + summary = appstate.meta_db.record_progression_event(etype, clean, appstate.get_progression_content()) + return {"ok": True, "progression": summary} diff --git a/server.py b/server.py index 3551858..2bdcc79 100644 --- a/server.py +++ b/server.py @@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path # Lives in lib/ because that is the one core dir every packaging path copies. import appstate # Extracted route modules. They import `appstate`, never `server` — one-way graph. -from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop +from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -1168,7 +1168,10 @@ def _get_progression_content() -> dict: # (the main configure() at import-top runs before this def). The cache global + # lock stay in server.py, so the `setattr(server, "_progression_content")` test # path is unchanged; routers call `appstate.get_progression_content()`. -appstate.configure(get_progression_content=_get_progression_content) +appstate.configure( + get_progression_content=_get_progression_content, + builtin_diagnostic_filename=_builtin_diagnostic_filename, +) def _copy_builtin_packs( @@ -4800,216 +4803,9 @@ def api_award_xp(data: dict): return meta_db.get_progress() -# ── Progression (spec 010): mastery rank, challenges, quests, shop ─────────── - -def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple: - """(count, target) for a challenge/quest progress bar. Count goals show - n/target; threshold goals show how far the live stat is along the line.""" - import progression as progression_mod - gtype = goal.get("type") - if gtype in progression_mod.COUNT_GOAL_TYPES: - target = int(goal.get("target") or 1) - count = target if state.get("completed") else min(int(state.get("count") or 0), target) - return count, target - if gtype == "streak_reached": - target = int(goal.get("days") or 1) - return (target if state.get("completed") else min(streak, target)), target - if gtype == "db_earned": - target = int(goal.get("amount") or 1) - return (target if state.get("completed") else min(xp_total, target)), target - return 0, 1 - - -def _progression_overview() -> dict: - """The full GET /api/progression payload (also the capability `inspect` - result): rank, onboarding, per-path challenge checklists, quests, wallet.""" - import progression as progression_mod - from datetime import datetime as _dt - content = _get_progression_content() - now = _dt.now() - meta_db.ensure_quest_period(content, now) - - state = meta_db.get_progression_state() - player_paths = meta_db.get_player_paths() - challenge_state = meta_db.get_challenge_state() - wallet = meta_db.get_wallet() - streak_progress = meta_db.get_progress() - streak = int(streak_progress.get("current_streak") or 0) - xp_total = wallet["lifetime_db"] - keys = progression_mod.period_keys(now) - - def _path_order(pid): - pdef = content["paths"].get(pid) or {} - return (pdef.get("order") or 0, pid) - - paths_payload = [] - for pid in sorted(player_paths, key=_path_order): - pdef = content["paths"].get(pid) - level = player_paths[pid] - if not pdef: - # Path selected under older content that no longer ships: keep its - # rank contribution visible rather than silently dropping it. - paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level, - "max_level": level, "next": None}) - continue - next_block = None - active = progression_mod.active_challenges(content, pid, level) - if active: - level_def = next(e for e in pdef["levels"] if e["level"] == level + 1) - challenges = [] - completed_count = 0 - for ch in active: - st = challenge_state.get(ch["id"]) or {} - count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total) - if st.get("completed"): - completed_count += 1 - challenges.append({ - "id": ch["id"], - "title": ch["title"], - "description": ch["description"], - "count": count, - "target": target, - "completed": bool(st.get("completed")), - "completed_at": st.get("completed_at"), - }) - next_block = { - "level": level + 1, - "required": level_def["required"], - "completed": completed_count, - "challenges": challenges, - } - paths_payload.append({ - "id": pid, - "name": pdef["name"], - "icon": pdef["icon"], - "level": level, - "max_level": progression_mod.path_max_level(content, pid), - "next": next_block, - }) - - available = [ - {"id": pid, "name": pdef["name"], "icon": pdef["icon"]} - for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0])) - if pid not in player_paths - ] - - quest_rows = meta_db.get_quest_rows(keys) - quests_payload = {} - for period_type in ("daily", "weekly"): - pool = content["quests"][period_type]["pool"] - quests = [] - for row in quest_rows: - if row["period_type"] != period_type: - continue - qdef = pool.get(row["quest_id"]) - if not qdef: - continue # removed from the pool mid-period: hide, keep the row - count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total) - quests.append({ - "id": row["quest_id"], - "title": qdef["title"], - "description": qdef["description"], - "reward_db": row["reward_db"], - "count": count, - "target": target, - "completed": row["completed"], - "completed_at": row["completed_at"], - }) - quests_payload[period_type] = { - "period_key": keys[period_type], - "resets_at": progression_mod.period_resets_at(period_type, now).isoformat(), - "quests": quests, - } - - return { - "mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths), - "onboarding": { - "calibration_status": state["calibration_status"], - "calibration_completed_at": state["calibration_completed_at"], - "diagnostic_filename": _builtin_diagnostic_filename(), - }, - "paths": paths_payload, - "available_paths": available, - "quests": quests_payload, - "wallet": wallet, - } - - -@app.get("/api/progression") -def api_progression(): - return _progression_overview() - - -@app.post("/api/progression/paths") -def api_progression_add_paths(data: dict): - """Select instrument paths. Body: {add: [path_id, ...]}. Idempotent; - removal is unsupported (Mastery Rank never decreases).""" - add = data.get("add") - if not isinstance(add, list) or not add: - return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400) - content = _get_progression_content() - for pid in add: - if not isinstance(pid, str) or pid not in content["paths"]: - return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400) - meta_db.add_player_paths(add) - return _progression_overview() - - -@app.post("/api/progression/onboarding") -def api_progression_onboarding(data: dict): - """Onboarding calibration choice. Body: {action: "skip"} — completing the - calibration needs no endpoint, it flows through the normal /api/stats path.""" - if _clean_str(data.get("action")) != "skip": - return JSONResponse({"error": "action must be 'skip'"}, status_code=400) - # Spec invariant: onboarding requires picking at least one instrument path - # before finishing, so skipping straight to rank 1 with no paths would - # leave a rank that can never grow. Only enforced when the content bundle - # actually defines paths — broken/empty content must never brick onboarding. - if _get_progression_content()["paths"] and not meta_db.get_player_paths(): - return JSONResponse( - {"error": "select at least one instrument path before skipping calibration"}, - status_code=400, - ) - meta_db.skip_calibration() - return _progression_overview() - - -# Externally postable progression events. song_completed is deliberately NOT -# here: it is server-derived inside /api/stats so the scored-session authority -# stays in one place. -_PROGRESSION_EVENT_TYPES = {"minigame_run"} - - -@app.post("/api/progression/events") -def api_progression_events(data: dict): - """Generic progression-event intake for plugins (capability `record-event`). - Body: {type, payload}. Whitelisted types, scalar payload values only.""" - etype = _clean_str(data.get("type")) - if etype not in _PROGRESSION_EVENT_TYPES: - return JSONResponse( - {"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"}, - status_code=400, - ) - payload = data.get("payload") - if payload is None: - payload = {} - if not isinstance(payload, dict) or len(payload) > 16: - return JSONResponse({"error": "payload must be a small object"}, status_code=400) - clean = {} - for key, value in payload.items(): - if not isinstance(key, str) or len(key) > 64: - return JSONResponse({"error": "payload keys must be short strings"}, status_code=400) - if value is None: - continue - if isinstance(value, bool) or ( - not isinstance(value, (int, float, str)) - ) or (isinstance(value, float) and not math.isfinite(value)) or ( - isinstance(value, str) and len(value) > 256 - ): - return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400) - clean[key] = value - summary = meta_db.record_progression_event(etype, clean, _get_progression_content()) - return {"ok": True, "progression": summary} +# ── Progression (spec 010) ─────────────────────────────────────────────────── +# Mounted here (registration order). Implementation in lib/routers/progression.py. +app.include_router(progression.router) # ── Cosmetics shop (spec 010) ────────────────────────────────────────────────