mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
4 routes (overview/add-paths/onboarding/events, spec 010) + their EXCLUSIVE helpers (_goal_ui_progress, _progression_overview 112L) + the _PROGRESSION_EVENT_TYPES whitelist. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields. The two SHARED server accessors read through the seam: get_progression_content (added for shop #851) and builtin_diagnostic_filename (new slot — a trivial const-returning fn shared with the stats router's api_record_stats). Both are injected via the second appstate.configure() after their defs (the import-top configure runs before them). The cache + fns stay in server.py, so test_progression_api's server._progression_content patch is untouched — 0 retarget. server.py: 7,798 -> 7,594. Verified: pyflakes clean; route table IDENTICAL (143); both seam accessors wired; pytest 2401 passed (63 in test_progression_api); eslint 0. Boot smoke: GET /api/progression 200 (drives _progression_overview + both accessors), events 400 on bad body. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
9.6 KiB
Python
231 lines
9.6 KiB
Python
"""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}
|