Files
qtalker---/backend/app/main.py
Indiana ff68379772 feat: unlocks, inventory items, sigils, drops, and essence (Workstream C)
Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:

- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
  Sigil (sigils) — brand-new tables, picked up by main.py's existing
  create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
  essence economy constants, sigil design validation, and an atomic
  (row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
  (validates the placeholder {points, rune} shape, points capped at 12),
  POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
  key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
  point that exists in this worktree today (_handle_summon, covering
  every successful summon plus high-rarity summons); the other two
  contract trigger points (correct judgment, successful ritual) belong
  to Workstream B's not-yet-landed ritual/judgment WS handlers, which
  should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
  added here per orchestrator instruction so this workstream is
  independently testable — merge controller reconciles the duplicate
  edit).

Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.

Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 03:02:20 +00:00

105 lines
3.4 KiB
Python

import asyncio
import contextlib
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import app.models # noqa: F401 — registers models on Base.metadata before create_all
from app.config import settings
from app.db import Base, async_session_maker, engine
from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router
from app.routes.inventory import router as inventory_router
from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions
from app.ws import AUDIO_DIR
from app.ws import router as ws_router
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
async def _session_cleanup_loop() -> None:
"""Periodically sweeps expired auth_sessions rows so the table doesn't
grow forever — get_current_user already rejects expired sessions on
read, this just deletes the rows themselves."""
try:
while True:
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
try:
async with async_session_maker() as db:
await delete_expired_sessions(db)
except Exception:
# A transient DB hiccup shouldn't kill the sweep loop —
# just try again next interval.
pass
except asyncio.CancelledError:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
cleanup_task = asyncio.create_task(_session_cleanup_loop())
try:
yield
finally:
cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await cleanup_task
app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(codex_router)
app.include_router(inventory_router)
app.include_router(shop_router)
app.include_router(ws_router)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
app.mount(
"/assets",
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
name="frontend-assets",
)
app.mount(
"/audio",
StaticFiles(directory=AUDIO_DIR, check_dir=False),
name="spirit-audio",
)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
index_file = FRONTEND_DIST / "index.html"
if not index_file.exists():
raise HTTPException(
status_code=503,
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
)
# Vite emits root-level static files (favicon.ico, favicon.svg,
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
# dist/assets/ — the only mounted static dir. Without this, requests for
# them fall through to the SPA fallback below and get index.html back
# instead of the actual file (browsers silently ignore it; social-media
# link-preview crawlers fetching og:image get an HTML page).
if full_path:
dist_root = FRONTEND_DIST.resolve()
candidate = (dist_root / full_path).resolve()
if candidate.is_file() and dist_root in candidate.parents:
return FileResponse(candidate)
return FileResponse(index_file)