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:
@@ -30,12 +30,15 @@ from app.config import settings
|
||||
from app.db import async_session_maker as _default_session_maker
|
||||
from app.deps import SESSION_COOKIE_NAME
|
||||
from app.entities import fallback_signature, signature_from_anomalies
|
||||
from app.inventory import SUMMON_ESSENCE_TRICKLE, credit_essence, roll_item_drop, summon_drop_trigger
|
||||
from app.llm.service import SpiritBusyError, spirit_service
|
||||
from app.models.auth_session import AuthSession, hash_token
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.event import Event
|
||||
from app.models.inventory_item import InventoryItem
|
||||
from app.models.user import User
|
||||
from app.possession import compute_stability
|
||||
from app.rate_limit import RateLimiter, resolve_client_ip
|
||||
from app.telemetry import detect_wire_spike, sample_network
|
||||
@@ -247,6 +250,45 @@ async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
|
||||
return entity, is_new
|
||||
|
||||
|
||||
async def _reward_summon(state: SeanceState) -> None:
|
||||
"""Workstream C's essence-trickle + item-drop trigger points that exist
|
||||
in this handler today: a small essence trickle for every successful
|
||||
summon (any mode), and — only when the summoned entity is high-rarity —
|
||||
a roll for an item drop. The other two contract trigger points ("after a
|
||||
correct judgment, a successful ritual") belong to Workstream B's
|
||||
ritual/judgment WS handlers, which don't exist in this codebase yet;
|
||||
`app.inventory` exposes the same `roll_item_drop`/`credit_essence`
|
||||
helpers (plus the milestone essence constants) for those handlers to
|
||||
call once they land, so the drop table and essence economy stay in one
|
||||
place instead of being duplicated."""
|
||||
assert state.entity is not None
|
||||
rarity = state.entity.get("rarity", "common")
|
||||
|
||||
async with session_maker() as db:
|
||||
user = await db.get(User, state.user_id)
|
||||
if user is None:
|
||||
return
|
||||
credit_essence(user, SUMMON_ESSENCE_TRICKLE)
|
||||
|
||||
item = None
|
||||
trigger = summon_drop_trigger(rarity)
|
||||
if trigger is not None:
|
||||
item = roll_item_drop(trigger)
|
||||
if item is not None:
|
||||
db.add(
|
||||
InventoryItem(
|
||||
user_id=state.user_id,
|
||||
item_type=item["item_type"],
|
||||
item_key=item["item_key"],
|
||||
payload=item["payload"],
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if item is not None:
|
||||
await state.send_queue.put({"type": "item_drop", "item": item})
|
||||
|
||||
|
||||
async def _handle_summon(state: SeanceState) -> None:
|
||||
# Short-circuits: an account already over its own cap never gets far
|
||||
# enough to spend from the IP budget too.
|
||||
@@ -269,6 +311,7 @@ async def _handle_summon(state: SeanceState) -> None:
|
||||
await state.send_queue.put(
|
||||
{"type": "entity", "entity": state.entity, "is_new": is_new}
|
||||
)
|
||||
await _reward_summon(state)
|
||||
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
|
||||
await _speak(state, "greeting", greeting)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user