The three replay guards shipped earlier lived on the in-memory SeanceState, which made them per-CONNECTION. I flagged that as an open residual at the time: drop the socket and reconnect — or just open a second one — and the client got a fresh empty guard and could be paid again for the same spirit. summon_limiter bounded the rate of that, never the total. `award_claims` is the durable form: one row per (seeker, presence, milestone), with a UNIQUE constraint doing the actual enforcement. The claim is a bare INSERT and losing the race raises IntegrityError, which is caught and read as "already paid" — a check-then-insert would let two sockets both read "unclaimed" and both pay. `crossing` is claimed by BOTH roads, so a spirit crosses once whichever road arrives first. Measured with the durable claim disabled: 5 reconnects paid 75 extra essence on the ritual, 60 on a verdict, 140 on the passage, and two simultaneous sockets paid 30 for one 15-essence ritual. The four tests that were failing were the tests, not the guard. They compared raw balances across reconnects, but re-opening a channel IS a summon, and SUMMON_ESSENCE_TRICKLE is paid per summon by design (inventory.py:42, bounded by summon_limiter rather than by any once-per-presence rule). The expected trickle is now stated explicitly so the assertion speaks about the milestone it is actually testing. Favor has no trickle, so it must not move at all — asserted separately. Anti-overshoot covered in both directions: a genuinely fresh presence still pays in full across a reconnect, a corrected verdict still pays on a second connection, and `test` stays freely repeatable since it never touches the ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
10 KiB
Python
235 lines
10 KiB
Python
import asyncio
|
|
import contextlib
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import text
|
|
|
|
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
|
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.conditions import router as conditions_router
|
|
from app.routes.device import router as device_router
|
|
from app.routes.inventory import router as inventory_router
|
|
from app.routes.messages import router as messages_router
|
|
from app.routes.profile import router as profile_router
|
|
from app.routes.seances import router as seances_router
|
|
from app.routes.seo import router as seo_router
|
|
from app.routes.shop import router as shop_router
|
|
from app.session_cleanup import delete_expired_sessions
|
|
from app.ws import AUDIO_DIR
|
|
from app.ws import router as ws_router
|
|
|
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
|
|
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
|
|
|
|
|
|
async def _session_cleanup_loop() -> None:
|
|
"""Periodically sweeps expired auth_sessions rows so the table doesn't
|
|
grow forever — get_current_user already rejects expired sessions on
|
|
read, this just deletes the rows themselves."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
|
|
try:
|
|
async with async_session_maker() as db:
|
|
await delete_expired_sessions(db)
|
|
except Exception:
|
|
# A transient DB hiccup shouldn't kill the sweep loop —
|
|
# just try again next interval.
|
|
pass
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
# No Alembic in this repo — `create_all` never alters existing
|
|
# tables, so columns added to live models need a manual, idempotent
|
|
# migration here. Safe to run on every startup.
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
|
))
|
|
# Workstream C (character-depth-ghost-log spec) — missing from C's
|
|
# own commit, added by the integrator after Workstream B's report
|
|
# flagged that User.essence had a live model column and application
|
|
# code (auth/me, inventory purchases, summon trickle) but no
|
|
# migration, which would have broken on the real production DB.
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
|
|
))
|
|
# Workstream B (character-depth-ghost-log spec).
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
|
|
))
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
|
))
|
|
# Any entity that predates the traits column above was left with
|
|
# `{}` — the ALTER defaults it and nothing backfills. Every judgment
|
|
# read then falls back to 0.5, which makes `trust` always correct and
|
|
# `cross_over` unreachable for that spirit: the minigame is silently
|
|
# solved for it. roll_traits() only ever runs at mint time, so such a
|
|
# row can never repair itself.
|
|
#
|
|
# Seeded from the entity's own signature so the values are stable and
|
|
# reproducible rather than random, matching how a freshly-minted
|
|
# spirit derives them. Guarded to empty-traits rows only, so it can
|
|
# never touch a spirit that already has a real hidden nature. This
|
|
# install currently has zero such rows; the backfill exists so the
|
|
# gap cannot bite a longer-lived deployment.
|
|
await conn.execute(text(
|
|
"""
|
|
UPDATE entities SET traits = jsonb_build_object(
|
|
'alignment', round((('x' || substr(md5(signature || 'alignment'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3),
|
|
'power', round((('x' || substr(md5(signature || 'power'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3),
|
|
'volatility', round((('x' || substr(md5(signature || 'volatility'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3),
|
|
'deceptiveness',round((('x' || substr(md5(signature || 'deceptiveness'),1, 8))::bit(32)::bigint % 1000) / 1000.0, 3)
|
|
)
|
|
WHERE traits = '{}'::jsonb OR traits IS NULL
|
|
"""
|
|
))
|
|
|
|
# Hunter profile columns. All nullable (or defaulted) so existing
|
|
# rows, guests included, stay valid without a backfill.
|
|
for column, ddl in (
|
|
("display_name", "VARCHAR(48)"),
|
|
("bio", "VARCHAR(280)"),
|
|
("gender", "VARCHAR(16)"),
|
|
("avatar_form", "VARCHAR(16)"),
|
|
("avatar_hue", "INTEGER"),
|
|
):
|
|
await conn.execute(
|
|
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
|
|
)
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
|
"profile_public BOOLEAN NOT NULL DEFAULT true"
|
|
))
|
|
|
|
# Defense-in-depth: purchase_unlock() already enforces one row per
|
|
# (user, unlock_key) via a row-locked check-then-insert, so this
|
|
# constraint should never actually find a conflict on a live DB.
|
|
# `ADD CONSTRAINT` has no IF NOT EXISTS form, so the guard is a
|
|
# catalog check instead — safe to run on every startup.
|
|
await conn.execute(text(
|
|
"DO $$ BEGIN "
|
|
"IF NOT EXISTS ("
|
|
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_unlocks_user_key'"
|
|
") THEN "
|
|
" ALTER TABLE unlocks ADD CONSTRAINT uq_unlocks_user_key UNIQUE (user_id, unlock_key); "
|
|
"END IF; "
|
|
"END $$;"
|
|
))
|
|
|
|
# The durable reward ledger (app/models/award_claim.py). `create_all`
|
|
# above already builds this table on a fresh database; these two
|
|
# statements are for an install that predates it — the CREATE is a
|
|
# no-op there, and the constraint is what actually enforces
|
|
# once-per-(seeker, presence, milestone), so it is asserted explicitly
|
|
# rather than left to whatever the table happened to be created with.
|
|
await conn.execute(text(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS award_claims (
|
|
id UUID PRIMARY KEY,
|
|
user_id UUID NOT NULL REFERENCES users(id),
|
|
entity_id UUID NOT NULL REFERENCES entities(id),
|
|
award_key VARCHAR(64) NOT NULL,
|
|
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
))
|
|
await conn.execute(text(
|
|
"CREATE INDEX IF NOT EXISTS ix_award_claims_user_id ON award_claims (user_id)"
|
|
))
|
|
await conn.execute(text(
|
|
"CREATE INDEX IF NOT EXISTS ix_award_claims_entity_id ON award_claims (entity_id)"
|
|
))
|
|
# Same catalog-check shape as uq_unlocks_user_key above: `ADD
|
|
# CONSTRAINT` has no IF NOT EXISTS form. Unlike that one this
|
|
# constraint is not defense-in-depth — it IS the guard: _claim_award
|
|
# relies on the IntegrityError it raises to resolve two concurrent
|
|
# connections claiming the same award down to a single payout.
|
|
await conn.execute(text(
|
|
"DO $$ BEGIN "
|
|
"IF NOT EXISTS ("
|
|
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_award_claims_user_entity_key'"
|
|
") THEN "
|
|
" ALTER TABLE award_claims ADD CONSTRAINT uq_award_claims_user_entity_key "
|
|
" UNIQUE (user_id, entity_id, award_key); "
|
|
"END IF; "
|
|
"END $$;"
|
|
))
|
|
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
|
try:
|
|
yield
|
|
finally:
|
|
cleanup_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await cleanup_task
|
|
|
|
|
|
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
|
app.include_router(auth_router)
|
|
app.include_router(codex_router)
|
|
app.include_router(conditions_router)
|
|
app.include_router(device_router)
|
|
app.include_router(inventory_router)
|
|
app.include_router(messages_router)
|
|
app.include_router(profile_router)
|
|
app.include_router(seances_router)
|
|
# Registered before the SPA catch-all below, or /robots.txt and
|
|
# /sitemap.xml would be served index.html instead.
|
|
app.include_router(seo_router)
|
|
app.include_router(shop_router)
|
|
app.include_router(ws_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.mount(
|
|
"/assets",
|
|
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
|
|
name="frontend-assets",
|
|
)
|
|
app.mount(
|
|
"/audio",
|
|
StaticFiles(directory=AUDIO_DIR, check_dir=False),
|
|
name="spirit-audio",
|
|
)
|
|
|
|
|
|
@app.get("/{full_path:path}")
|
|
async def serve_spa(full_path: str):
|
|
index_file = FRONTEND_DIST / "index.html"
|
|
if not index_file.exists():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
|
|
)
|
|
|
|
# Vite emits root-level static files (favicon.ico, favicon.svg,
|
|
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
|
|
# dist/assets/ — the only mounted static dir. Without this, requests for
|
|
# them fall through to the SPA fallback below and get index.html back
|
|
# instead of the actual file (browsers silently ignore it; social-media
|
|
# link-preview crawlers fetching og:image get an HTML page).
|
|
if full_path:
|
|
dist_root = FRONTEND_DIST.resolve()
|
|
candidate = (dist_root / full_path).resolve()
|
|
if candidate.is_file() and dist_root in candidate.parents:
|
|
return FileResponse(candidate)
|
|
|
|
return FileResponse(index_file)
|