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>
This commit is contained in:
Indiana
2026-07-24 03:02:20 +00:00
parent 6d8c6f2496
commit ff68379772
13 changed files with 1039 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.deps import SESSION_COOKIE_NAME, get_current_user
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
from app.models.unlock import UnlockRecord
from app.models.user import User
from app.schemas import LoginRequest, RegisterRequest, UserOut
from app.security import hash_password, verify_password
@@ -92,5 +93,13 @@ async def logout(
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user
async def me(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(UnlockRecord.unlock_key).where(UnlockRecord.user_id == user.id)
)
unlock_keys = [row[0] for row in result.all()]
return UserOut(
id=user.id, username=user.username, essence=user.essence, unlocks=unlock_keys
)

View File

@@ -0,0 +1,119 @@
"""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,
)