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

219
backend/app/inventory.py Normal file
View File

@@ -0,0 +1,219 @@
"""Unlocks, inventory items, sigils, and the essence economy — Workstream C
of docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md.
Essence amounts (the implementer's call per the spec's "exact amounts are
the implementer's call; keep summon income small (single digits) and
milestone income larger (tens)" guidance):
- SUMMON_ESSENCE_TRICKLE: every successful summon, any mode/rarity — kept
tiny so it accrues constantly without needing any gating logic. This is
the only trigger point that exists in `backend/app/ws.py` today (see
module docstring there); it's wired directly in `_handle_summon`.
- RITUAL_SUCCESS_ESSENCE / CORRECT_JUDGMENT_ESSENCE / CROSS_OVER_ESSENCE:
milestone constants for Workstream B's ritual/judgment WS handlers to
draw from once they land (those handlers — `ritual_start`,
`ritual_step`, `judgment` — don't exist in this worktree yet; Workstream
B owns `backend/app/judgment.py` and the WS wiring per the spec). They're
defined here, alongside the drop table, so both workstreams price things
out of one shared module instead of duplicating numbers. Cross-over pays
the most, matching the contract's "the largest essence reward of any
outcome".
Item drop odds (also the implementer's call): ritual success and correct
judgments are deliberate, effortful player actions, so they roll a bit more
generously than a bare summon (which happens constantly and is often
passive/ambient). High-rarity summons scale with how rare the entity itself
already is — a mythic summon is already a jackpot, so the drop on top of it
pays off further. None of these odds are large enough to make items feel
guaranteed/grindy, matching the same "reachable within a handful of
sessions, not grindy" spirit as the essence guidance.
"""
import random
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.unlock import UnlockRecord
from app.models.user import User
# --- Essence economy --------------------------------------------------
SUMMON_ESSENCE_TRICKLE = 2 # every successful summon, any mode
RITUAL_SUCCESS_ESSENCE = 15 # a completed ritual
CORRECT_JUDGMENT_ESSENCE = 12 # correct trust/banish call
CROSS_OVER_ESSENCE = 25 # correct cross_over — largest reward of any outcome
# --- Unlocks ------------------------------------------------------------
UNLOCK_PRICES: dict[str, int] = {
"listening_tool": 40,
}
# --- Item drops -----------------------------------------------------------
HIGH_RARITY_TIERS = {"rare", "mythic"}
# (trigger key) -> drop chance. Triggers correspond to the contract's
# `item_drop` emission points: "after a correct judgment, a successful
# ritual, or a high-rarity summon" — the last one is split by tier since a
# mythic summon should feel more rewarded than a merely-rare one.
DROP_CHANCES: dict[str, float] = {
"judgment": 0.20,
"ritual": 0.25,
"summon_rare": 0.15,
"summon_mythic": 0.35,
}
# (trigger key) -> (item_type, item_key pool)
ITEM_POOLS: dict[str, tuple[str, list[str]]] = {
"judgment": (
"trinket",
["static_shard", "cracked_locket", "cold_coin", "grave_dust"],
),
"ritual": (
"relic",
["obsidian_mirror", "bone_dial", "silver_tuning_fork", "warded_chalk"],
),
"summon_rare": (
"curio",
["moth_wing", "ectoplasm_vial", "tarnished_key"],
),
"summon_mythic": (
"curio",
["black_candle_stub", "veil_thread", "gilded_grave_dust"],
),
}
def summon_drop_trigger(rarity: str) -> str | None:
"""Maps an entity's rarity tier to its drop-trigger key, or None if the
summon isn't high-rarity enough to qualify for a drop roll at all."""
if rarity == "rare":
return "summon_rare"
if rarity == "mythic":
return "summon_mythic"
return None
def roll_item_drop(trigger: str, rng: random.Random | None = None) -> dict | None:
"""Rolls for an item drop at one of the contract's trigger points
("judgment", "ritual", "summon_rare", "summon_mythic"). Returns the
`item_drop` frame's `item` payload shape
(`{"item_type", "item_key", "payload"}`), or None on a miss / unknown
trigger."""
rng = rng if rng is not None else random.Random()
chance = DROP_CHANCES.get(trigger)
pool = ITEM_POOLS.get(trigger)
if chance is None or pool is None:
return None
if rng.random() >= chance:
return None
item_type, keys = pool
return {
"item_type": item_type,
"item_key": rng.choice(keys),
"payload": {"trigger": trigger},
}
def credit_essence(user: User, amount: int) -> int:
"""Adjusts `user.essence` in place, floored at 0, and returns the new
balance. Caller is responsible for committing/flushing."""
user.essence = max(0, user.essence + amount)
return user.essence
# --- Sigil design validation ---------------------------------------------
SIGIL_MAX_POINTS = 12
_RUNE_MAX_LEN = 32
def validate_sigil_design(design: object) -> dict | None:
"""Validates the placeholder sigil design shape
`{"points": [[x, y], ...], "rune": str}` (a stand-in until Workstream F's
sigil designer defines the real shape — see the spec's Workstream C
section). `points` is capped at `SIGIL_MAX_POINTS` to bound payload
size. Returns a normalized dict on success, or None if the shape is
invalid."""
if not isinstance(design, dict):
return None
points = design.get("points")
if not isinstance(points, list) or not (1 <= len(points) <= SIGIL_MAX_POINTS):
return None
normalized_points: list[list[float]] = []
for point in points:
if (
not isinstance(point, (list, tuple))
or len(point) != 2
or not all(
isinstance(coord, (int, float)) and not isinstance(coord, bool)
for coord in point
)
):
return None
normalized_points.append([float(point[0]), float(point[1])])
rune = design.get("rune")
if not isinstance(rune, str) or not (1 <= len(rune) <= _RUNE_MAX_LEN):
return None
return {"points": normalized_points, "rune": rune}
# --- Purchases --------------------------------------------------------
class InsufficientEssenceError(Exception):
"""Raised when a user tries to buy an unlock they can't afford."""
class UnknownUnlockError(Exception):
"""Raised when `unlock_key` isn't in `UNLOCK_PRICES`."""
async def purchase_unlock(
db: AsyncSession, user_id: uuid.UUID, unlock_key: str
) -> UnlockRecord:
"""Atomically spends essence for `unlock_key` and records the unlock.
Locks the user's row (`SELECT ... FOR UPDATE`) for the duration of the
balance check + deduction, so two concurrent purchase requests can't
both read the same starting balance and both succeed (double-spend).
The second request blocks on the row lock until the first commits, then
re-reads the now-decremented balance. Idempotent: re-buying an unlock
already owned returns the existing record without charging again.
"""
if unlock_key not in UNLOCK_PRICES:
raise UnknownUnlockError(unlock_key)
price = UNLOCK_PRICES[unlock_key]
locked_user = await db.scalar(
select(User).where(User.id == user_id).with_for_update()
)
if locked_user is None:
raise UnknownUnlockError(unlock_key) # user vanished mid-request
existing = await db.scalar(
select(UnlockRecord).where(
UnlockRecord.user_id == user_id, UnlockRecord.unlock_key == unlock_key
)
)
if existing is not None:
return existing
if locked_user.essence < price:
raise InsufficientEssenceError(
f"not enough essence for {unlock_key!r}: "
f"have {locked_user.essence}, need {price}"
)
locked_user.essence -= price
record = UnlockRecord(user_id=user_id, unlock_key=unlock_key)
db.add(record)
await db.commit()
await db.refresh(record)
return record