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>
220 lines
7.9 KiB
Python
220 lines
7.9 KiB
Python
"""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
|