mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 20:57:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
ea8834862d
commit
5f58af4faa
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
|||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## 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`
|
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`
|
(7,798 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
extractions and eight `routers/` modules) ·
|
extractions and nine `routers/` modules) ·
|
||||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
`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
|
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`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
|
|||||||
@@ -82,10 +82,16 @@ static_dir = None
|
|||||||
sloppak_cache_dir = None
|
sloppak_cache_dir = None
|
||||||
audio_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({
|
_SLOTS = frozenset({
|
||||||
"meta_db", "audio_effect_mappings",
|
"meta_db", "audio_effect_mappings",
|
||||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||||
|
"get_progression_content",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -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.
|
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||||
import appstate
|
import appstate
|
||||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
# 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 sloppak as sloppak_mod
|
||||||
import loosefolder as loosefolder_mod
|
import loosefolder as loosefolder_mod
|
||||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||||||
@@ -1164,6 +1164,13 @@ def _get_progression_content() -> dict:
|
|||||||
return _progression_content
|
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(
|
def _copy_builtin_packs(
|
||||||
root: Path,
|
root: Path,
|
||||||
dest_dir: Path,
|
dest_dir: Path,
|
||||||
@@ -5005,51 +5012,9 @@ def api_progression_events(data: dict):
|
|||||||
return {"ok": True, "progression": summary}
|
return {"ok": True, "progression": summary}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/shop")
|
# ── Cosmetics shop (spec 010) ────────────────────────────────────────────────
|
||||||
def api_shop():
|
# Mounted here (registration order). Implementation in lib/routers/shop.py.
|
||||||
content = _get_progression_content()
|
app.include_router(shop.router)
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Per-song practice stats (fee[dB]ack v0.3.0) ───────────────────────────────
|
# ── Per-song practice stats (fee[dB]ack v0.3.0) ───────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user