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
|
||||
@@ -12,6 +12,7 @@ from app.config import settings
|
||||
from app.db import Base, async_session_maker, engine
|
||||
from app.routes.auth import router as auth_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.session_cleanup import delete_expired_sessions
|
||||
from app.ws import AUDIO_DIR
|
||||
@@ -57,6 +58,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(codex_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(shop_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ 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.sigil import Sigil
|
||||
from app.models.unlock import UnlockRecord
|
||||
from app.models.user import User
|
||||
from app.models.waitlist_entry import WaitlistEntry
|
||||
|
||||
@@ -13,5 +16,8 @@ __all__ = [
|
||||
"Entity",
|
||||
"EntitySighting",
|
||||
"Event",
|
||||
"InventoryItem",
|
||||
"Sigil",
|
||||
"UnlockRecord",
|
||||
"WaitlistEntry",
|
||||
]
|
||||
|
||||
24
backend/app/models/inventory_item.py
Normal file
24
backend/app/models/inventory_item.py
Normal 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)
|
||||
)
|
||||
25
backend/app/models/sigil.py
Normal file
25
backend/app/models/sigil.py
Normal 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)
|
||||
)
|
||||
21
backend/app/models/unlock.py
Normal file
21
backend/app/models/unlock.py
Normal 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)
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
@@ -14,6 +14,13 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
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(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db import get_db
|
||||
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.unlock import UnlockRecord
|
||||
from app.models.user import User
|
||||
from app.schemas import LoginRequest, RegisterRequest, UserOut
|
||||
from app.security import hash_password, verify_password
|
||||
@@ -92,5 +93,13 @@ async def logout(
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
async def me(
|
||||
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
|
||||
)
|
||||
|
||||
119
backend/app/routes/inventory.py
Normal file
119
backend/app/routes/inventory.py
Normal 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,
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -12,6 +13,8 @@ class RegisterRequest(BaseModel):
|
||||
class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
essence: int = 0
|
||||
unlocks: list[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -19,3 +22,40 @@ class UserOut(BaseModel):
|
||||
class LoginRequest(BaseModel):
|
||||
username: 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)
|
||||
|
||||
@@ -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