fix: the Passage was an unbounded essence faucet

`passage_start` rewinds the rite to `listen` at any time, and PassagePanel
offers exactly that button after a resisted release. Every replayed layer
re-credited its essence, so one summon funded an endless loop — the 20/60s
limiter caps the rate, never the total. Measured at ~110 essence/minute,
indefinitely.

Each layer now pays the first time it opens for a presence and never again,
cleared only by a genuine summon. Re-walking still reveals; it just doesn't
mint. The frame reports what was ACTUALLY credited, so the UI's running
total can't drift from the ledger.

Three further fixes in the same handlers:
- judgment -> passage double-paid a crossing. The passage -> cross_over
  direction was already guarded; the reverse ran free, favor included.
- both handlers read `state.entity["id"]` AFTER their DB round-trip. The
  HTTP telemetry path drives the same SeanceState and can summon
  concurrently, so a crossing could mark the presence that just arrived.
  Pinned before the awaits.

tests/test_ws_passage.py is new, and covers the gap that let all of this
hide: test_passage.py tests the pure module, and nothing exercised these
handlers over a real connection. Efficacy proven by reverting the fix —
the replay test then reports "minted 280 extra essence".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 01:44:47 +00:00
parent 09089f01d5
commit a727858a62
2 changed files with 354 additions and 10 deletions

View File

@@ -163,6 +163,17 @@ 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.
@@ -572,7 +583,7 @@ async def _summon_locked(state: SeanceState) -> None:
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
_reset_passage(state)
_reset_passage(state, new_entity=True)
await state.send_queue.put(
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
@@ -915,6 +926,11 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
return
traits = state.entity.get("traits", {})
# Pinned before any await, for the same reason as in the Passage handler
# below: the device-telemetry path can re-summon on this SeanceState
# mid-handler, and re-reading `state.entity` after the DB round-trip would
# mark whichever entity arrived last as at_peace instead of the judged one.
entity_id = state.entity["id"]
outcome = judgment.judge_verdict(
verdict,
traits,
@@ -941,7 +957,7 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
credit_essence(user, outcome.essence_delta)
if outcome.consequence == "crossed_over":
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
entity_row = await db.get(Entity, uuid.UUID(entity_id))
if entity_row is not None:
entity_row.at_peace = True
@@ -958,6 +974,15 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
)
await db.commit()
if outcome.consequence == "crossed_over":
# Latch the same flag the Passage sets. The guard above already stops
# passage → cross_over double-paying; without this the REVERSE ran
# free: judge cross_over (+essence, +favor, entity at_peace), then walk
# the Passage to `release` on the same spirit and be paid the crossing
# a second time, favor included. One spirit, one crossing, whichever
# road got there first.
state.passage_crossed = True
await state.send_queue.put(
{
"type": "judgment_result",
@@ -982,13 +1007,25 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
# uses), the `at_peace` write, and the frames.
def _reset_passage(state: SeanceState) -> None:
def _reset_passage(state: SeanceState, *, new_entity: bool = False) -> None:
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(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
def _passage_frame(
state: SeanceState, outcome: passage.PassageOutcome, essence: int
) -> dict:
# `essence` is what was ACTUALLY credited, which is the layer's value only
# the first time it opens for this presence (see `passage_paid`). The
# client sums this field into its running total, so sending
# `outcome.essence` on a replay would show the seeker essence they did not
# receive.
reveal = outcome.reveal
return {
"type": "passage_result",
@@ -1001,7 +1038,7 @@ def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
if reveal is not None
else None
),
"essence": outcome.essence,
"essence": essence,
"at_peace": outcome.at_peace,
"next_layer": outcome.next_layer,
# Deliberately NOT `outcome.lied` — that's this layer's own lie, and
@@ -1052,6 +1089,12 @@ async def _handle_passage_layer(state: SeanceState) -> None:
return
traits = state.entity.get("traits", {})
# Pinned HERE, before any await. `state.entity` can be replaced underneath
# this coroutine mid-handler: the HTTP device-telemetry path drives the
# same SeanceState and can summon (see `summon_lock`). Reading the id
# after the DB round-trip below would mark the WRONG entity at peace —
# the one that just arrived, not the one that actually crossed.
entity_id = state.entity["id"]
layer = state.passage_layer
# Both twists draw from the room's real physical noise, with distinct
# contexts so a volatility collapse and a deceptive reveal can never be
@@ -1074,8 +1117,15 @@ 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)
item = None
if outcome.essence or outcome.at_peace:
if award or outcome.at_peace:
async with session_maker() as db:
# Locked — same read-modify-write race as every other essence
# credit in this file (see _reward_summon).
@@ -1083,8 +1133,8 @@ async def _handle_passage_layer(state: SeanceState) -> None:
select(User).where(User.id == state.user_id).with_for_update()
)
if user is not None:
if outcome.essence:
credit_essence(user, outcome.essence)
if award:
credit_essence(user, award)
if outcome.at_peace:
# Completing the Passage is the most compassionate act
# in the game, exactly as a correct cross_over verdict
@@ -1093,7 +1143,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
)
if outcome.at_peace:
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
entity_row = await db.get(Entity, uuid.UUID(entity_id))
if entity_row is not None:
entity_row.at_peace = True
item = roll_item_drop("judgment")
@@ -1108,7 +1158,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
)
await db.commit()
await state.send_queue.put(_passage_frame(state, outcome))
await state.send_queue.put(_passage_frame(state, outcome, award))
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})