fix: reward guards now survive a reconnect
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>
This commit is contained in:
@@ -129,6 +129,45 @@ async def lifespan(app: FastAPI):
|
||||
"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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.award_claim import AwardClaim
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.device import Device
|
||||
from app.models.entity import Entity
|
||||
@@ -14,6 +15,7 @@ from app.models.waitlist_entry import WaitlistEntry
|
||||
__all__ = [
|
||||
"User",
|
||||
"AuthSession",
|
||||
"AwardClaim",
|
||||
"ContactSession",
|
||||
"Device",
|
||||
"Entity",
|
||||
|
||||
47
backend/app/models/award_claim.py
Normal file
47
backend/app/models/award_claim.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AwardClaim(Base):
|
||||
"""One row per milestone a seeker has ACTUALLY been paid for a presence.
|
||||
|
||||
The three reward faucets in `app/ws.py` (the ritual milestone, a verdict,
|
||||
a Passage beat) each used to be guarded by a set held on the in-memory
|
||||
`SeanceState`. That guard is per-CONNECTION: a client that disconnects and
|
||||
reconnects — or simply opens a second websocket — got a fresh, empty guard
|
||||
and could be paid all over again for the same spirit. `summon_limiter`
|
||||
bounded the rate of that, never the total.
|
||||
|
||||
This table is the durable form of those guards. The UNIQUE constraint on
|
||||
(user_id, entity_id, award_key) is the actual enforcement, not a
|
||||
belt-and-braces afterthought: `app.ws._claim_award` claims a milestone by
|
||||
INSERTing here and treating an IntegrityError as "somebody already paid
|
||||
this", which is what makes two connections racing the same award resolve
|
||||
to exactly one payout instead of two.
|
||||
|
||||
`award_key` is the milestone within a presence, not a currency:
|
||||
`ritual`, `judgment:<verdict>`, `passage:<layer>`, and `crossing` (claimed
|
||||
by BOTH roads to a crossing — the Passage's `release` beat and the
|
||||
`cross_over` verdict — so one spirit crosses once whichever road got there
|
||||
first).
|
||||
"""
|
||||
|
||||
__tablename__ = "award_claims"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "entity_id", "award_key", name="uq_award_claims_user_entity_key"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entities.id"), index=True)
|
||||
award_key: Mapped[str] = mapped_column(String(64))
|
||||
claimed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -55,6 +55,7 @@ from app.inventory import (
|
||||
)
|
||||
from app.llm.service import SpiritBusyError, spirit_service
|
||||
from app.models.auth_session import AuthSession, hash_token
|
||||
from app.models.award_claim import AwardClaim
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
@@ -153,31 +154,24 @@ class SeanceState:
|
||||
ritual_steps: int = 0
|
||||
ritual_completed: bool = False
|
||||
ritual_success: bool = False
|
||||
# Whether the ritual milestone has ALREADY paid out for the current
|
||||
# presence. Exactly the same faucet `passage_paid` closes, on the other
|
||||
# rite: `ritual_start` rewinds `ritual_completed` to False at any time
|
||||
# (and the UI offers precisely that button — RitualPanel's "attempt
|
||||
# again", which honest play needs after a FAILED roll), so without this
|
||||
# a client could walk ritual_start + 4x ritual_step for +15 essence and
|
||||
# an item roll, restart, and repeat forever off a single summon. The
|
||||
# limiter caps the cadence, not the total. So the milestone pays the
|
||||
# FIRST time it succeeds for a given presence and never again;
|
||||
# re-attempting still rolls, still reveals the traits on success, but
|
||||
# mints no new essence and rolls no new item. Cleared only on a fresh
|
||||
# summon, never by `ritual_start` — that is the whole point.
|
||||
ritual_paid: bool = False
|
||||
# Which verdicts have ALREADY been applied to the ledger for the current
|
||||
# presence. Same class of hole: `judgment` had no once-per-presence
|
||||
# guard at all except for `cross_over`, so replaying {"type":
|
||||
# "judgment", "verdict": "trust"} against a benevolent spirit credited
|
||||
# CORRECT_JUDGMENT_ESSENCE *and* +0.05 favor *and* rolled an item on
|
||||
# every single frame — an unbounded faucet for both currencies (favor
|
||||
# pins to +1.0, which then biases every future mint) that the 10/60s
|
||||
# limiter only slowed down. Keyed by verdict rather than a single latch
|
||||
# so an honest correction (a wrong `trust`, then the right `banish` on
|
||||
# the same spirit) still resolves normally — what can never repeat is
|
||||
# the SAME verdict on the SAME presence. Cleared only on a fresh summon.
|
||||
judged_verdicts: set[str] = field(default_factory=set)
|
||||
# Awards this connection has already OBSERVED to be claimed, as
|
||||
# (entity_id, award_key) pairs — a pure read-through cache of rows in
|
||||
# `award_claims` (see `_claim_award`), never the guard itself.
|
||||
#
|
||||
# It exists only to spare the DB a doomed INSERT on the common replay
|
||||
# path. It is safe precisely because it caches one direction: an entry
|
||||
# means "definitely already paid" (a fact that can never become false —
|
||||
# rows are never deleted), and its ABSENCE means nothing at all, so a
|
||||
# miss always falls through to the database. It is deliberately never
|
||||
# cleared on a summon; a stale entry would only ever be correct.
|
||||
#
|
||||
# What used to live here instead — `ritual_paid: bool`,
|
||||
# `judged_verdicts: set[str]`, `passage_paid: set[str]` — were the guards
|
||||
# themselves, and that was the hole: they are per-CONNECTION. Every one
|
||||
# of the faucets they close (see the comments on the handlers below)
|
||||
# reopened in full the moment a client dropped the socket and
|
||||
# reconnected, or simply opened a second one alongside the first.
|
||||
awards_claimed: set[tuple[uuid.UUID, str]] = field(default_factory=set)
|
||||
# Workstream L (passage-doctrine spec): where the layered crossing rite
|
||||
# stands for the *current* entity. `passage_layer` is the next beat to
|
||||
# attempt, `passage_lied` remembers whether the layer just completed was
|
||||
@@ -188,17 +182,6 @@ class SeanceState:
|
||||
passage_layer: str = passage.FIRST_LAYER
|
||||
passage_lied: bool = False
|
||||
passage_crossed: bool = False
|
||||
# Which beats have ALREADY paid out for the current entity. Without
|
||||
# this, essence is unbounded: `passage_start` rewinds the rite to
|
||||
# `listen` at any time (and the UI offers exactly that button after a
|
||||
# resisted release), so a client could walk listen/name/unbind/open for
|
||||
# +28 essence, restart, and repeat forever off a single summon — the
|
||||
# limiter caps the rate, not the total. A volatility collapse rewinds it
|
||||
# the same way. So each layer pays the FIRST time it opens for a given
|
||||
# presence and never again; re-walking it still reveals, still costs
|
||||
# nothing already banked, but mints no new essence. Cleared only on a
|
||||
# fresh summon, never by `passage_start` — that is the whole point.
|
||||
passage_paid: set[str] = field(default_factory=set)
|
||||
# Per-session RNG for `tell` frames — unseeded (a session's tells should
|
||||
# vary run to run), but persistent across calls so the draw sequence
|
||||
# isn't restarted on every single message.
|
||||
@@ -513,6 +496,46 @@ async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
|
||||
raise last_error
|
||||
|
||||
|
||||
async def _claim_award(state: SeanceState, entity_id: str, award_key: str) -> bool:
|
||||
"""Claim a milestone for (this seeker, this presence). True exactly once.
|
||||
|
||||
This is the durable replacement for the three per-connection guards that
|
||||
used to live on `SeanceState`. Every reward path in this file that is
|
||||
meant to pay once per presence asks here first, and pays only on True.
|
||||
|
||||
The `award_claims` UNIQUE constraint is the real guard, not the SELECT
|
||||
that never happens: the claim is a bare INSERT, and losing the race is an
|
||||
IntegrityError, which we swallow and report as "already paid". That is
|
||||
the only shape that holds when two websockets for the same account claim
|
||||
the same award at the same instant — a check-then-insert would let both
|
||||
read "unclaimed" and both pay. Same handling as `_summon`'s signature/name
|
||||
collision above: catch, roll back, and take the winner's outcome as the
|
||||
answer, rather than letting a 500 kill the séance.
|
||||
|
||||
Rows are never deleted, so a claim is permanent for that spirit — which is
|
||||
the whole point. A genuinely NEW presence is a different `entity_id` and
|
||||
therefore a fresh, unclaimed set of milestones; see the note in
|
||||
`_summon_locked`.
|
||||
"""
|
||||
key = (uuid.UUID(entity_id), award_key)
|
||||
if key in state.awards_claimed:
|
||||
return False # fast path only; a miss still asks the database
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add(
|
||||
AwardClaim(user_id=state.user_id, entity_id=key[0], award_key=award_key)
|
||||
)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
state.awards_claimed.add(key)
|
||||
return False
|
||||
|
||||
state.awards_claimed.add(key)
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
@@ -608,11 +631,18 @@ async def _summon_locked(state: SeanceState) -> None:
|
||||
state.ritual_steps = 0
|
||||
state.ritual_completed = False
|
||||
state.ritual_success = False
|
||||
# Only a genuinely fresh summon re-opens the purse for the other two
|
||||
# rites, exactly as `_reset_passage(new_entity=True)` does below.
|
||||
state.ritual_paid = False
|
||||
state.judged_verdicts = set()
|
||||
_reset_passage(state, new_entity=True)
|
||||
# Nothing here re-opens the purse any more, and that is the fix.
|
||||
#
|
||||
# A genuinely new presence still gets its own full purse — automatically,
|
||||
# because the purse is now keyed on `entity.id` in `award_claims` and a
|
||||
# new presence is a new row. What no longer re-opens it is a summon that
|
||||
# lands back on the SAME spirit, and that distinction is the entire hole:
|
||||
# `RETURN_CHANCE` means re-calling a known channel usually returns the
|
||||
# familiar presence, so the old unconditional reset here let a client
|
||||
# re-summon its way to an unlimited number of payouts for one spirit,
|
||||
# bounded only by `summon_limiter`. Reconnecting did the same thing for
|
||||
# free. Each spirit is worth its own rite — once.
|
||||
_reset_passage(state)
|
||||
await state.send_queue.put(
|
||||
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
|
||||
)
|
||||
@@ -918,6 +948,11 @@ async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
|
||||
return
|
||||
|
||||
traits = state.entity.get("traits", {})
|
||||
# Pinned before any await, for the same reason as in the judgment and
|
||||
# Passage handlers below: the HTTP device-telemetry path drives the same
|
||||
# SeanceState and can replace `state.entity` mid-handler, and the claim
|
||||
# must land against the spirit whose rite this actually was.
|
||||
entity_id = state.entity["id"]
|
||||
success = judgment.roll_ritual_success(traits)
|
||||
state.ritual_completed = True
|
||||
state.ritual_success = success
|
||||
@@ -925,11 +960,12 @@ async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
|
||||
await state.send_queue.put(
|
||||
{"type": "ritual_complete", "success": success, "revealed": revealed}
|
||||
)
|
||||
# The milestone pays once per presence. A re-attempt after a rewind
|
||||
# (`ritual_start`) still rolls and still reveals on success — it just
|
||||
# doesn't mint a second payout. See `SeanceState.ritual_paid`.
|
||||
if success and not state.ritual_paid:
|
||||
state.ritual_paid = True
|
||||
# The milestone pays once per presence, for good — across rewinds
|
||||
# (`ritual_start`, the UI's "attempt again"), across reconnects, and
|
||||
# across two sockets racing each other. A re-attempt still rolls and
|
||||
# still reveals on success; it just doesn't mint a second payout. See
|
||||
# `_claim_award`.
|
||||
if success and await _claim_award(state, entity_id, "ritual"):
|
||||
await _reward_ritual_success(state)
|
||||
|
||||
|
||||
@@ -971,14 +1007,28 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
||||
ritual_success=state.ritual_success,
|
||||
)
|
||||
|
||||
# A verdict lands on a presence once. Replaying the same one — the UI
|
||||
# re-arms the panel after any result — re-reports the same reading for
|
||||
# free rather than paying it out again. See `judged_verdicts`; `test`
|
||||
# never touches the ledger so it is deliberately exempt and stays
|
||||
# freely repeatable.
|
||||
already_judged = verdict != "test" and verdict in state.judged_verdicts
|
||||
if verdict != "test":
|
||||
state.judged_verdicts.add(verdict)
|
||||
# A verdict lands on a presence once, durably. Replaying the same one —
|
||||
# the UI re-arms the panel after any result, and a scripted client can
|
||||
# simply reconnect or open a second socket — re-reports the same reading
|
||||
# for free rather than paying it out again.
|
||||
#
|
||||
# Claimed PER VERDICT rather than as a single latch, so an honest
|
||||
# correction (a wrong `trust`, then the right `banish` on the same
|
||||
# spirit) still resolves normally; what can never repeat is the SAME
|
||||
# verdict on the SAME presence. `test` never touches the ledger, so it is
|
||||
# deliberately exempt and stays freely repeatable.
|
||||
if verdict == "test":
|
||||
already_judged = False
|
||||
elif outcome.consequence == "crossed_over":
|
||||
# One spirit, one crossing, whichever road got there first: the
|
||||
# Passage's `release` beat claims this same key. Without the shared
|
||||
# key, a seeker on two sockets could cross the one spirit down both
|
||||
# roads and be paid for it twice.
|
||||
already_judged = not await _claim_award(state, entity_id, "crossing")
|
||||
else:
|
||||
already_judged = not await _claim_award(
|
||||
state, entity_id, f"judgment:{verdict}"
|
||||
)
|
||||
|
||||
# What will ACTUALLY reach the ledger. The frame below reports these,
|
||||
# not `outcome`'s face values: the client sums `essence_delta`/
|
||||
@@ -1060,15 +1110,13 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
||||
# uses), the `at_peace` write, and the frames.
|
||||
|
||||
|
||||
def _reset_passage(state: SeanceState, *, new_entity: bool = False) -> None:
|
||||
def _reset_passage(state: SeanceState) -> None:
|
||||
"""Rewind where the rite STANDS. It no longer touches what has been paid —
|
||||
that lives in `award_claims` now and is keyed on the entity, so there is
|
||||
nothing here a `passage_start` rewind could reopen."""
|
||||
state.passage_layer = passage.FIRST_LAYER
|
||||
state.passage_lied = False
|
||||
state.passage_crossed = False
|
||||
if new_entity:
|
||||
# Only a genuinely new presence re-opens the purse. A `passage_start`
|
||||
# rewind must not, or the rite becomes an essence faucet (see
|
||||
# `passage_paid` on SeanceState).
|
||||
state.passage_paid = set()
|
||||
|
||||
|
||||
def _passage_frame(
|
||||
@@ -1170,12 +1218,23 @@ async def _handle_passage_layer(state: SeanceState) -> None:
|
||||
if outcome.at_peace:
|
||||
state.passage_crossed = True
|
||||
|
||||
# A layer pays once per presence. Replaying it — via `passage_start`, via
|
||||
# a collapse rewind, or via a hand-rolled client spamming the frame —
|
||||
# reveals the same thing again for free rather than minting essence again.
|
||||
award = outcome.essence if layer not in state.passage_paid else 0
|
||||
if outcome.result in ("opened", "crossed"):
|
||||
state.passage_paid.add(layer)
|
||||
# A layer pays once per presence, durably. Replaying it — via
|
||||
# `passage_start`, via a collapse rewind, via a reconnect, via a second
|
||||
# socket, or via a hand-rolled client spamming the frame — reveals the
|
||||
# same thing again for free rather than minting essence again.
|
||||
award = 0
|
||||
if outcome.result in ("opened", "crossed") and await _claim_award(
|
||||
state, entity_id, f"passage:{layer}"
|
||||
):
|
||||
award = outcome.essence
|
||||
|
||||
# The crossing itself (favor, `at_peace`, the item roll) is claimed
|
||||
# separately and under a key the `cross_over` VERDICT claims too, so one
|
||||
# spirit is paid for crossing exactly once no matter which road reached
|
||||
# it — including two roads walked concurrently on two sockets.
|
||||
crossing_paid = bool(
|
||||
outcome.at_peace and await _claim_award(state, entity_id, "crossing")
|
||||
)
|
||||
|
||||
item = None
|
||||
if award or outcome.at_peace:
|
||||
@@ -1188,7 +1247,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
|
||||
if user is not None:
|
||||
if award:
|
||||
credit_essence(user, award)
|
||||
if outcome.at_peace:
|
||||
if crossing_paid:
|
||||
# Completing the Passage is the most compassionate act
|
||||
# in the game, exactly as a correct cross_over verdict
|
||||
# is — so it moves favor by the same amount.
|
||||
@@ -1196,9 +1255,14 @@ async def _handle_passage_layer(state: SeanceState) -> None:
|
||||
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
|
||||
)
|
||||
if outcome.at_peace:
|
||||
# Not gated on `crossing_paid`: the spirit really did cross,
|
||||
# so the flag is set either way. It is only the PAYOUT that
|
||||
# happens once. (When the claim was lost, the row is already
|
||||
# at_peace and this write is a no-op.)
|
||||
entity_row = await db.get(Entity, uuid.UUID(entity_id))
|
||||
if entity_row is not None:
|
||||
entity_row.at_peace = True
|
||||
if crossing_paid:
|
||||
item = roll_item_drop("judgment")
|
||||
if item is not None:
|
||||
db.add(
|
||||
|
||||
Reference in New Issue
Block a user