feedBack/lib/routers/shop.py
Byron Gamatos 5f58af4faa
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>
2026-07-10 23:42:02 +02:00

63 lines
2.6 KiB
Python

"""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)}