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:
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user