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>
120 lines
3.8 KiB
Python
120 lines
3.8 KiB
Python
"""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,
|
|
)
|