diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 0810824..43b800e 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,833 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and eight `routers/` modules) · +(7,798 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and nine `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 2b599db..d4b1234 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -82,10 +82,16 @@ static_dir = None sloppak_cache_dir = None audio_cache_dir = None +# Injected callables (not values): server owns the impl + its state, routers call +# 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 + _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", }) diff --git a/lib/routers/shop.py b/lib/routers/shop.py new file mode 100644 index 0000000..584678e --- /dev/null +++ b/lib/routers/shop.py @@ -0,0 +1,62 @@ +"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency. + +Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``, +``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` -> +``appstate.get_progression_content()`` (the accessor is injected into the seam; +its lazy content cache stays in server.py). +""" + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +import appstate +from reqfields import _clean_str + +router = APIRouter() + + +@router.get("/api/shop") +def api_shop(): + content = appstate.get_progression_content() + owned = appstate.meta_db.get_owned_items() + equipped = appstate.meta_db.get_equipped() + items = [ + {**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid} + for iid, item in sorted(content["shop"].items()) + ] + return {"items": items, "wallet": appstate.meta_db.get_wallet()} + + +@router.post("/api/shop/buy") +def api_shop_buy(data: dict): + """Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership + in one transaction. Decibels are earned by playing only — never purchasable.""" + item_id = _clean_str(data.get("item_id")) + item = appstate.get_progression_content()["shop"].get(item_id) + if not item: + return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400) + status, wallet = appstate.meta_db.buy_shop_item(item) + if status == "owned": + return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409) + if status == "insufficient": + return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402) + return {"ok": True, "item_id": item_id, "wallet": wallet} + + +@router.post("/api/shop/equip") +def api_shop_equip(data: dict): + """Equip an owned cosmetic into its slot. Body: {slot, item_id|null} + (null unequips, restoring the default look).""" + import progression as progression_mod + slot = _clean_str(data.get("slot")) + if slot not in progression_mod.SHOP_SLOTS: + return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400) + item_id = data.get("item_id") + if item_id is not None: + item_id = _clean_str(item_id) + item = appstate.get_progression_content()["shop"].get(item_id) + if not item or item["slot"] != slot: + return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400) + if item_id not in appstate.meta_db.get_owned_items(): + return JSONResponse({"error": "item not owned"}, status_code=403) + return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)} diff --git a/server.py b/server.py index d190ba8..3551858 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 +from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -1164,6 +1164,13 @@ def _get_progression_content() -> dict: return _progression_content +# Publish the progression-content accessor into the seam now that it's defined +# (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) + + def _copy_builtin_packs( root: Path, dest_dir: Path, @@ -5005,51 +5012,9 @@ def api_progression_events(data: dict): return {"ok": True, "progression": summary} -@app.get("/api/shop") -def api_shop(): - content = _get_progression_content() - owned = meta_db.get_owned_items() - equipped = meta_db.get_equipped() - items = [ - {**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid} - for iid, item in sorted(content["shop"].items()) - ] - return {"items": items, "wallet": meta_db.get_wallet()} - - -@app.post("/api/shop/buy") -def api_shop_buy(data: dict): - """Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership - in one transaction. Decibels are earned by playing only — never purchasable.""" - item_id = _clean_str(data.get("item_id")) - item = _get_progression_content()["shop"].get(item_id) - if not item: - return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400) - status, wallet = meta_db.buy_shop_item(item) - if status == "owned": - return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409) - if status == "insufficient": - return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402) - return {"ok": True, "item_id": item_id, "wallet": wallet} - - -@app.post("/api/shop/equip") -def api_shop_equip(data: dict): - """Equip an owned cosmetic into its slot. Body: {slot, item_id|null} - (null unequips, restoring the default look).""" - import progression as progression_mod - slot = _clean_str(data.get("slot")) - if slot not in progression_mod.SHOP_SLOTS: - return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400) - item_id = data.get("item_id") - if item_id is not None: - item_id = _clean_str(item_id) - item = _get_progression_content()["shop"].get(item_id) - if not item or item["slot"] != slot: - return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400) - if item_id not in meta_db.get_owned_items(): - return JSONResponse({"error": "item not owned"}, status_code=403) - return {"ok": True, "equipped": meta_db.equip_item(slot, item_id)} +# ── Cosmetics shop (spec 010) ──────────────────────────────────────────────── +# Mounted here (registration order). Implementation in lib/routers/shop.py. +app.include_router(shop.router) # ── Per-song practice stats (fee[dB]ack v0.3.0) ───────────────────────────────