refactor(server): extract the shop routes + inject get_progression_content into the seam (R3) (#851)

The progression-content substrate: `_get_progression_content` (a lazy,
double-checked-locking content cache) is now published into the appstate seam as
a CALLABLE. The cache global + lock + the function stay in server.py (startup
uses it, and test_progression_api patches `server._progression_content`
directly), so ZERO test retargeting — routers just call
`appstate.get_progression_content()`.

Because the accessor is defined at server.py:1152 but the import-top configure()
runs at :346, a second `appstate.configure(get_progression_content=...)` publishes
it right after the def (configure is idempotent/additive).

First consumer: routers/shop.py (3 routes: buy/equip/list). Bodies verbatim;
@app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields,
_get_progression_content() -> appstate.get_progression_content(). This unblocks
stats/progression/profile next (all share the accessor).

server.py: 7,880 -> 7,845.

Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed
(test_progression_api's server._progression_content patch still works via the
kept cache); packaging guard; eslint 0. Boot smoke: GET /api/shop 200 (drives
appstate.get_progression_content), buy 400 on bad body.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-10 23:42:02 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ea8834862d
commit 5f58af4faa
4 changed files with 81 additions and 48 deletions
+11 -46
View File
@@ -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) ───────────────────────────────