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

View File

@@ -12,6 +12,7 @@ from app.config import settings
from app.db import Base, async_session_maker, engine from app.db import Base, async_session_maker, engine
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router from app.routes.codex import router as codex_router
from app.routes.inventory import router as inventory_router
from app.routes.shop import router as shop_router from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions from app.session_cleanup import delete_expired_sessions
from app.ws import AUDIO_DIR from app.ws import AUDIO_DIR
@@ -57,6 +58,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Quantumancy", lifespan=lifespan) app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(codex_router) app.include_router(codex_router)
app.include_router(inventory_router)
app.include_router(shop_router) app.include_router(shop_router)
app.include_router(ws_router) app.include_router(ws_router)

View File

@@ -3,6 +3,9 @@ from app.models.contact_session import ContactSession
from app.models.entity import Entity from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting from app.models.entity_sighting import EntitySighting
from app.models.event import Event from app.models.event import Event
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.models.user import User
from app.models.waitlist_entry import WaitlistEntry from app.models.waitlist_entry import WaitlistEntry
@@ -13,5 +16,8 @@ __all__ = [
"Entity", "Entity",
"EntitySighting", "EntitySighting",
"Event", "Event",
"InventoryItem",
"Sigil",
"UnlockRecord",
"WaitlistEntry", "WaitlistEntry",
] ]

View File

@@ -0,0 +1,24 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class InventoryItem(Base):
"""An item a seeker has been dropped (see `app.inventory.roll_item_drop`)
— a Reliquary trinket, not a purchased unlock."""
__tablename__ = "inventory_items"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
item_type: Mapped[str] = mapped_column(String(32), index=True)
item_key: Mapped[str] = mapped_column(String(64))
payload: Mapped[dict] = mapped_column(JSONB, default=dict)
obtained_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Sigil(Base):
"""A seeker-designed sigil (Workstream F's constrained geometric
builder). `design` is placeholder-shaped `{"points": [[x,y],...],
"rune": str}` pending the frontend sigil-designer workstream landing —
see `app.inventory.validate_sigil_design`."""
__tablename__ = "sigils"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String(64))
design: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,21 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class UnlockRecord(Base):
"""A permanent unlock a seeker has purchased with essence (e.g. the
listening tool). One row per (user, unlock_key)."""
__tablename__ = "unlocks"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
unlock_key: Mapped[str] = mapped_column(String(64), index=True)
unlocked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base from app.db import Base
@@ -14,6 +14,13 @@ class User(Base):
username: Mapped[str] = mapped_column(String(32), unique=True, index=True) username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255), nullable=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# NOTE: owned by Workstream B in the character-depth-ghost-log spec
# (docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
# Added here so Workstream C (unlocks/items/sigils/drops) can build and
# test against it in isolation; the merge controller reconciles this
# against Workstream B's own edit to this file (which will also add
# `favor: float` per the spec's Contract section).
essence: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )

View File

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db from app.db import get_db
from app.deps import SESSION_COOKIE_NAME, get_current_user 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.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.models.user import User
from app.schemas import LoginRequest, RegisterRequest, UserOut from app.schemas import LoginRequest, RegisterRequest, UserOut
from app.security import hash_password, verify_password from app.security import hash_password, verify_password
@@ -92,5 +93,13 @@ async def logout(
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)): async def me(
return user 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,
)

View File

@@ -1,4 +1,5 @@
import uuid import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -12,6 +13,8 @@ class RegisterRequest(BaseModel):
class UserOut(BaseModel): class UserOut(BaseModel):
id: uuid.UUID id: uuid.UUID
username: str username: str
essence: int = 0
unlocks: list[str] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -19,3 +22,40 @@ class UserOut(BaseModel):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
class UnlockOut(BaseModel):
unlock_key: str
unlocked_at: datetime
model_config = ConfigDict(from_attributes=True)
class PurchaseOut(BaseModel):
unlock_key: str
unlocked_at: datetime
essence: int
class InventoryItemOut(BaseModel):
id: uuid.UUID
item_type: str
item_key: str
payload: dict
obtained_at: datetime
model_config = ConfigDict(from_attributes=True)
class SigilIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
design: dict
class SigilOut(BaseModel):
id: uuid.UUID
name: str
design: dict
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -30,12 +30,15 @@ from app.config import settings
from app.db import async_session_maker as _default_session_maker from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME from app.deps import SESSION_COOKIE_NAME
from app.entities import fallback_signature, signature_from_anomalies 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.llm.service import SpiritBusyError, spirit_service
from app.models.auth_session import AuthSession, hash_token from app.models.auth_session import AuthSession, hash_token
from app.models.contact_session import ContactSession from app.models.contact_session import ContactSession
from app.models.entity import Entity from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting from app.models.entity_sighting import EntitySighting
from app.models.event import Event 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.possession import compute_stability
from app.rate_limit import RateLimiter, resolve_client_ip from app.rate_limit import RateLimiter, resolve_client_ip
from app.telemetry import detect_wire_spike, sample_network 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 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: async def _handle_summon(state: SeanceState) -> None:
# Short-circuits: an account already over its own cap never gets far # Short-circuits: an account already over its own cap never gets far
# enough to spend from the IP budget too. # enough to spend from the IP budget too.
@@ -269,6 +311,7 @@ async def _handle_summon(state: SeanceState) -> None:
await state.send_queue.put( await state.send_queue.put(
{"type": "entity", "entity": state.entity, "is_new": is_new} {"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." greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
await _speak(state, "greeting", greeting) await _speak(state, "greeting", greeting)

View File

@@ -0,0 +1,352 @@
import random
import uuid
import pytest
from sqlalchemy import select
from app.inventory import (
DROP_CHANCES,
HIGH_RARITY_TIERS,
ITEM_POOLS,
SIGIL_MAX_POINTS,
UNLOCK_PRICES,
InsufficientEssenceError,
UnknownUnlockError,
credit_essence,
purchase_unlock,
roll_item_drop,
summon_drop_trigger,
validate_sigil_design,
)
from app.models.unlock import UnlockRecord
from app.models.user import User
# ---------------------------------------------------------------------------
# Drop-roll probability sanity — seeded RNG, so this is a deterministic
# statistical check, not a flaky one.
# ---------------------------------------------------------------------------
def test_roll_item_drop_statistical_sanity_summon_mythic():
rng = random.Random(1234)
trials = 20_000
hits = sum(1 for _ in range(trials) if roll_item_drop("summon_mythic", rng) is not None)
empirical = hits / trials
expected = DROP_CHANCES["summon_mythic"]
assert abs(empirical - expected) < 0.02
def test_roll_item_drop_statistical_sanity_judgment():
rng = random.Random(5678)
trials = 20_000
hits = sum(1 for _ in range(trials) if roll_item_drop("judgment", rng) is not None)
empirical = hits / trials
expected = DROP_CHANCES["judgment"]
assert abs(empirical - expected) < 0.02
def test_roll_item_drop_is_deterministic_given_same_seed():
results_a = [roll_item_drop("ritual", random.Random(42)) for _ in range(50)]
results_b = [roll_item_drop("ritual", random.Random(42)) for _ in range(50)]
assert results_a == results_b
def test_roll_item_drop_unknown_trigger_returns_none():
assert roll_item_drop("not_a_real_trigger", random.Random(1)) is None
def test_roll_item_drop_shape_matches_item_pool():
# A high roll chance with a fixed seed known to hit, to check the shape.
rng = random.Random(1234)
item = None
for _ in range(200):
item = roll_item_drop("ritual", rng)
if item is not None:
break
assert item is not None
item_type, keys = ITEM_POOLS["ritual"]
assert item["item_type"] == item_type
assert item["item_key"] in keys
assert item["payload"] == {"trigger": "ritual"}
def test_summon_drop_trigger_only_fires_for_high_rarity():
assert summon_drop_trigger("common") is None
assert summon_drop_trigger("uncommon") is None
assert summon_drop_trigger("rare") == "summon_rare"
assert summon_drop_trigger("mythic") == "summon_mythic"
assert HIGH_RARITY_TIERS == {"rare", "mythic"}
# ---------------------------------------------------------------------------
# credit_essence
# ---------------------------------------------------------------------------
def test_credit_essence_adds_and_floors_at_zero():
user = User(username="x", password_hash="x")
user.essence = 5
assert credit_essence(user, 3) == 8
assert credit_essence(user, -100) == 0
# ---------------------------------------------------------------------------
# Sigil design validation
# ---------------------------------------------------------------------------
def test_validate_sigil_design_accepts_valid_shape():
design = {"points": [[0, 0], [1.5, 2.5], [3, -3]], "rune": "eld"}
normalized = validate_sigil_design(design)
assert normalized == {"points": [[0.0, 0.0], [1.5, 2.5], [3.0, -3.0]], "rune": "eld"}
def test_validate_sigil_design_rejects_too_many_points():
design = {"points": [[i, i] for i in range(SIGIL_MAX_POINTS + 1)], "rune": "eld"}
assert validate_sigil_design(design) is None
def test_validate_sigil_design_accepts_max_points():
design = {"points": [[i, i] for i in range(SIGIL_MAX_POINTS)], "rune": "eld"}
assert validate_sigil_design(design) is not None
def test_validate_sigil_design_rejects_empty_points():
assert validate_sigil_design({"points": [], "rune": "eld"}) is None
def test_validate_sigil_design_rejects_bad_point_shape():
assert validate_sigil_design({"points": [[1, 2, 3]], "rune": "eld"}) is None
assert validate_sigil_design({"points": [["a", "b"]], "rune": "eld"}) is None
assert validate_sigil_design({"points": "not-a-list", "rune": "eld"}) is None
def test_validate_sigil_design_rejects_missing_or_bad_rune():
assert validate_sigil_design({"points": [[0, 0]]}) is None
assert validate_sigil_design({"points": [[0, 0]], "rune": ""}) is None
assert validate_sigil_design({"points": [[0, 0]], "rune": 5}) is None
assert validate_sigil_design({"points": [[0, 0]], "rune": "x" * 33}) is None
def test_validate_sigil_design_rejects_non_dict():
assert validate_sigil_design("nope") is None
assert validate_sigil_design(None) is None
# ---------------------------------------------------------------------------
# purchase_unlock — success, insufficient funds, unknown unlock, idempotency,
# race safety.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_purchase_unlock_success_deducts_essence_and_records(db_session):
user = User(username="buyer", password_hash="x", essence=100)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
price = UNLOCK_PRICES["listening_tool"]
record = await purchase_unlock(db_session, user.id, "listening_tool")
assert record.unlock_key == "listening_tool"
await db_session.refresh(user)
assert user.essence == 100 - price
rows = (
await db_session.execute(
select(UnlockRecord).where(UnlockRecord.user_id == user.id)
)
).scalars().all()
assert len(rows) == 1
assert rows[0].unlock_key == "listening_tool"
@pytest.mark.asyncio
async def test_purchase_unlock_insufficient_funds_raises_and_does_not_charge(db_session):
user = User(username="pauper", password_hash="x", essence=5)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
with pytest.raises(InsufficientEssenceError):
await purchase_unlock(db_session, user.id, "listening_tool")
await db_session.refresh(user)
assert user.essence == 5
rows = (
await db_session.execute(
select(UnlockRecord).where(UnlockRecord.user_id == user.id)
)
).scalars().all()
assert rows == []
@pytest.mark.asyncio
async def test_purchase_unlock_unknown_key_raises(db_session):
user = User(username="curious", password_hash="x", essence=1000)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
with pytest.raises(UnknownUnlockError):
await purchase_unlock(db_session, user.id, "does_not_exist")
@pytest.mark.asyncio
async def test_purchase_unlock_is_idempotent_does_not_double_charge(db_session):
user = User(username="rebuyer", password_hash="x", essence=100)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
price = UNLOCK_PRICES["listening_tool"]
await purchase_unlock(db_session, user.id, "listening_tool")
await db_session.refresh(user)
assert user.essence == 100 - price
# Buying again must not charge a second time.
await purchase_unlock(db_session, user.id, "listening_tool")
await db_session.refresh(user)
assert user.essence == 100 - price
rows = (
await db_session.execute(
select(UnlockRecord).where(UnlockRecord.user_id == user.id)
)
).scalars().all()
assert len(rows) == 1
# ---------------------------------------------------------------------------
# HTTP layer: purchase endpoint, list endpoints, sigil save, /auth/me shape.
# ---------------------------------------------------------------------------
async def _register_and_login(client, username="seeker"):
await client.post(
"/auth/register", json={"username": username, "password": "spookyspooky"}
)
resp = await client.post(
"/auth/login", json={"username": username, "password": "spookyspooky"}
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_buy_unlock_success_over_http(client, db_session):
user_id = await _register_and_login(client, "affluent")
user = await db_session.get(User, uuid.UUID(user_id))
user.essence = 100
await db_session.commit()
resp = await client.post("/api/inventory/unlocks/listening_tool")
assert resp.status_code == 200
body = resp.json()
assert body["unlock_key"] == "listening_tool"
assert body["essence"] == 100 - UNLOCK_PRICES["listening_tool"]
me = await client.get("/auth/me")
assert me.json()["unlocks"] == ["listening_tool"]
assert me.json()["essence"] == 100 - UNLOCK_PRICES["listening_tool"]
@pytest.mark.asyncio
async def test_buy_unlock_insufficient_funds_over_http(client, db_session):
await _register_and_login(client, "broke")
# A fresh user starts with 0 essence.
resp = await client.post("/api/inventory/unlocks/listening_tool")
assert resp.status_code == 402
me = await client.get("/auth/me")
assert me.json()["unlocks"] == []
assert me.json()["essence"] == 0
@pytest.mark.asyncio
async def test_buy_unlock_unknown_key_over_http(client):
await _register_and_login(client, "explorer")
resp = await client.post("/api/inventory/unlocks/nonexistent_thing")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_buy_unlock_requires_auth(client):
resp = await client.post("/api/inventory/unlocks/listening_tool")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_inventory_list_endpoints_require_auth(client):
assert (await client.get("/api/inventory/unlocks")).status_code == 401
assert (await client.get("/api/inventory/items")).status_code == 401
assert (await client.get("/api/inventory/sigils")).status_code == 401
@pytest.mark.asyncio
async def test_list_unlocks_and_items_empty_then_populated(client, db_session):
user_id = await _register_and_login(client, "collector")
empty = await client.get("/api/inventory/unlocks")
assert empty.status_code == 200
assert empty.json() == []
user = await db_session.get(User, uuid.UUID(user_id))
user.essence = 100
await db_session.commit()
await client.post("/api/inventory/unlocks/listening_tool")
populated = await client.get("/api/inventory/unlocks")
assert len(populated.json()) == 1
assert populated.json()[0]["unlock_key"] == "listening_tool"
assert "unlocked_at" in populated.json()[0]
@pytest.mark.asyncio
async def test_save_and_list_sigil(client):
await _register_and_login(client, "sigilsmith")
resp = await client.post(
"/api/inventory/sigils",
json={"name": "ward-of-quiet", "design": {"points": [[0, 0], [1, 1]], "rune": "eld"}},
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "ward-of-quiet"
assert body["design"]["rune"] == "eld"
listed = await client.get("/api/inventory/sigils")
assert len(listed.json()) == 1
assert listed.json()[0]["name"] == "ward-of-quiet"
@pytest.mark.asyncio
async def test_save_sigil_rejects_too_many_points(client):
await _register_and_login(client, "greedy-sigilsmith")
resp = await client.post(
"/api/inventory/sigils",
json={
"name": "overreach",
"design": {"points": [[i, i] for i in range(13)], "rune": "eld"},
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_save_sigil_rejects_missing_rune(client):
await _register_and_login(client, "runeless")
resp = await client.post(
"/api/inventory/sigils",
json={"name": "blank", "design": {"points": [[0, 0]]}},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_auth_me_includes_essence_and_unlocks_default(client):
await _register_and_login(client, "freshuser")
resp = await client.get("/auth/me")
body = resp.json()
assert body["essence"] == 0
assert body["unlocks"] == []

View File

@@ -1,12 +1,16 @@
import asyncio import asyncio
import uuid
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
import app.ws import app.ws
from app.entities import fallback_profile from app.entities import fallback_profile
from app.inventory import SUMMON_ESSENCE_TRICKLE
from app.models.contact_session import ContactSession from app.models.contact_session import ContactSession
from app.models.event import Event from app.models.event import Event
from app.models.inventory_item import InventoryItem
from app.models.user import User
from app.rate_limit import RateLimiter from app.rate_limit import RateLimiter
@@ -266,3 +270,168 @@ async def test_summon_per_ip_bucket_follows_cf_connecting_ip_not_socket_peer(
# A different visitor IP behind the same tunnel — must not be # A different visitor IP behind the same tunnel — must not be
# rejected by visitor A's already-spent bucket. # rejected by visitor A's already-spent bucket.
_read_until(ws, "entity") _read_until(ws, "entity")
@pytest.mark.asyncio
async def test_summon_credits_essence_trickle(sync_client, db_session, monkeypatch):
# The module-level summon_ip_limiter/summon_limiter are shared singletons
# that persist real hit counts across every test in this file (they're
# only overridden where a test explicitly monkeypatches them, as earlier
# tests above do) — fresh, generous instances here keep this test from
# depending on how many summons ran before it.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
token = _login(sync_client, "trickle-earner")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
_read_until(ws, "utterance", kind="greeting")
for _ in range(40):
db_session.expire_all()
user = await db_session.get(User, user_id)
if user.essence == SUMMON_ESSENCE_TRICKLE:
break
await asyncio.sleep(0.05)
assert user.essence == SUMMON_ESSENCE_TRICKLE
@pytest.mark.asyncio
async def test_summon_high_rarity_item_drop_is_persisted_and_sent(
sync_client, db_session, monkeypatch
):
# Force a hit so the wiring is exercised deterministically rather than
# relying on the real roll odds (those are covered statistically in
# test_inventory.py).
forced_item = {
"item_type": "curio",
"item_key": "veil_thread",
"payload": {"trigger": "summon_mythic"},
}
monkeypatch.setattr(app.ws, "roll_item_drop", lambda trigger: forced_item)
# See test_summon_credits_essence_trickle above re: why this is reset.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
class MythicSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"):
profile = fallback_profile(signature)
profile["rarity"] = "mythic"
return profile
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
yield "hi"
return gen()
def ambient_ready(self):
return False
monkeypatch.setattr(app.ws, "spirit_service", MythicSpiritService())
token = _login(sync_client, "jackpot-seeker")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["entity"]["rarity"] == "mythic"
drop_frame = _read_until(ws, "item_drop")
assert drop_frame["item"] == forced_item
for _ in range(40):
db_session.expire_all()
items = (
await db_session.execute(
select(InventoryItem).where(InventoryItem.user_id == user_id)
)
).scalars().all()
if items:
break
await asyncio.sleep(0.05)
assert len(items) == 1
assert items[0].item_key == "veil_thread"
assert items[0].item_type == "curio"
@pytest.mark.asyncio
async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session, monkeypatch):
# See test_summon_credits_essence_trickle above re: why this is reset.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
# Common is the overwhelmingly likely fallback rarity (55% weight) but
# not guaranteed — force it explicitly so this test can't flake.
def _common_only(signature: str) -> dict:
profile = fallback_profile(signature)
profile["rarity"] = "common"
return profile
class CommonSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"):
return _common_only(signature)
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
yield "hi"
return gen()
def ambient_ready(self):
return False
monkeypatch.setattr(app.ws, "spirit_service", CommonSpiritService())
# If a drop were (incorrectly) rolled for a common-rarity summon, this
# would make it always hit — proving the rarity gate, not just the odds,
# is what's preventing a drop here.
monkeypatch.setattr(
app.ws, "roll_item_drop", lambda trigger: {"item_type": "x", "item_key": "y", "payload": {}}
)
token = _login(sync_client, "commoner")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["entity"]["rarity"] == "common"
greeting = _read_until(ws, "utterance", kind="greeting")
assert greeting["kind"] == "greeting"
# Give the (already-awaited, so this should be immediate) reward path a
# moment to land, then assert no item was ever persisted for this user —
# the rarity gate, not just the roll odds, is what prevents a drop here.
for _ in range(20):
db_session.expire_all()
items = (
await db_session.execute(
select(InventoryItem).where(InventoryItem.user_id == user_id)
)
).scalars().all()
await asyncio.sleep(0.02)
assert items == []