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:
219
backend/app/inventory.py
Normal file
219
backend/app/inventory.py
Normal 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
|
||||
Reference in New Issue
Block a user