"""The Reliquary: a seeker's unlocks, dropped items, and saved sigils — Workstream C of docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md.""" from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_db from app.deps import get_current_user from app.inventory import ( UNLOCK_PRICES, InsufficientEssenceError, UnknownUnlockError, purchase_unlock, validate_sigil_design, ) from app.models.inventory_item import InventoryItem from app.models.sigil import Sigil from app.models.unlock import UnlockRecord from app.models.user import User from app.rate_limit import RateLimiter from app.schemas import ( InventoryItemOut, PurchaseOut, SigilIn, SigilOut, UnlockOut, ) router = APIRouter(prefix="/api/inventory", tags=["inventory"]) # A seeker mashing the buy button shouldn't be able to spam the DB — the # essence balance check itself is race-safe (see app.inventory.purchase_unlock) # but there's no reason to let unlimited attempts through either. purchase_limiter = RateLimiter(max_requests=20, window_seconds=60) @router.get("/unlocks", response_model=list[UnlockOut]) async def list_unlocks( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): result = await db.execute( select(UnlockRecord) .where(UnlockRecord.user_id == user.id) .order_by(UnlockRecord.unlocked_at) ) return result.scalars().all() @router.get("/items", response_model=list[InventoryItemOut]) async def list_items( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): result = await db.execute( select(InventoryItem) .where(InventoryItem.user_id == user.id) .order_by(InventoryItem.obtained_at) ) return result.scalars().all() @router.get("/sigils", response_model=list[SigilOut]) async def list_sigils( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): result = await db.execute( select(Sigil).where(Sigil.user_id == user.id).order_by(Sigil.created_at) ) return result.scalars().all() @router.post("/sigils", response_model=SigilOut, status_code=status.HTTP_201_CREATED) async def save_sigil( payload: SigilIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): normalized = validate_sigil_design(payload.design) if normalized is None: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "sigil design must be {\"points\": [[x, y], ...] (1-12 points), " "\"rune\": str}", ) sigil = Sigil(user_id=user.id, name=payload.name, design=normalized) db.add(sigil) await db.commit() await db.refresh(sigil) return sigil @router.post("/unlocks/{unlock_key}", response_model=PurchaseOut) async def buy_unlock( unlock_key: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if unlock_key not in UNLOCK_PRICES: raise HTTPException(status.HTTP_404_NOT_FOUND, "no such unlock") if not purchase_limiter.allow(str(user.id)): raise HTTPException( status.HTTP_429_TOO_MANY_REQUESTS, "too many purchase attempts — slow down" ) try: record = await purchase_unlock(db, user.id, unlock_key) except InsufficientEssenceError as exc: raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, str(exc)) from exc except UnknownUnlockError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, "no such unlock") from exc await db.refresh(user) return PurchaseOut( unlock_key=record.unlock_key, unlocked_at=record.unlocked_at, essence=user.essence, )