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_layer: str = passage.FIRST_LAYER
|
||||||
passage_lied: bool = False
|
passage_lied: bool = False
|
||||||
passage_crossed: 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
|
# Per-session RNG for `tell` frames — unseeded (a session's tells should
|
||||||
# vary run to run), but persistent across calls so the draw sequence
|
# vary run to run), but persistent across calls so the draw sequence
|
||||||
# isn't restarted on every single message.
|
# isn't restarted on every single message.
|
||||||
@@ -572,7 +583,7 @@ async def _summon_locked(state: SeanceState) -> None:
|
|||||||
state.ritual_steps = 0
|
state.ritual_steps = 0
|
||||||
state.ritual_completed = False
|
state.ritual_completed = False
|
||||||
state.ritual_success = False
|
state.ritual_success = False
|
||||||
_reset_passage(state)
|
_reset_passage(state, new_entity=True)
|
||||||
await state.send_queue.put(
|
await state.send_queue.put(
|
||||||
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
|
{"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
|
return
|
||||||
|
|
||||||
traits = state.entity.get("traits", {})
|
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(
|
outcome = judgment.judge_verdict(
|
||||||
verdict,
|
verdict,
|
||||||
traits,
|
traits,
|
||||||
@@ -941,7 +957,7 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
|||||||
credit_essence(user, outcome.essence_delta)
|
credit_essence(user, outcome.essence_delta)
|
||||||
|
|
||||||
if outcome.consequence == "crossed_over":
|
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:
|
if entity_row is not None:
|
||||||
entity_row.at_peace = True
|
entity_row.at_peace = True
|
||||||
|
|
||||||
@@ -958,6 +974,15 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
|||||||
)
|
)
|
||||||
await db.commit()
|
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(
|
await state.send_queue.put(
|
||||||
{
|
{
|
||||||
"type": "judgment_result",
|
"type": "judgment_result",
|
||||||
@@ -982,13 +1007,25 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
|||||||
# uses), the `at_peace` write, and the frames.
|
# 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_layer = passage.FIRST_LAYER
|
||||||
state.passage_lied = False
|
state.passage_lied = False
|
||||||
state.passage_crossed = 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
|
reveal = outcome.reveal
|
||||||
return {
|
return {
|
||||||
"type": "passage_result",
|
"type": "passage_result",
|
||||||
@@ -1001,7 +1038,7 @@ def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
|
|||||||
if reveal is not None
|
if reveal is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"essence": outcome.essence,
|
"essence": essence,
|
||||||
"at_peace": outcome.at_peace,
|
"at_peace": outcome.at_peace,
|
||||||
"next_layer": outcome.next_layer,
|
"next_layer": outcome.next_layer,
|
||||||
# Deliberately NOT `outcome.lied` — that's this layer's own lie, and
|
# 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
|
return
|
||||||
|
|
||||||
traits = state.entity.get("traits", {})
|
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
|
layer = state.passage_layer
|
||||||
# Both twists draw from the room's real physical noise, with distinct
|
# Both twists draw from the room's real physical noise, with distinct
|
||||||
# contexts so a volatility collapse and a deceptive reveal can never be
|
# 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:
|
if outcome.at_peace:
|
||||||
state.passage_crossed = True
|
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
|
item = None
|
||||||
if outcome.essence or outcome.at_peace:
|
if award or outcome.at_peace:
|
||||||
async with session_maker() as db:
|
async with session_maker() as db:
|
||||||
# Locked — same read-modify-write race as every other essence
|
# Locked — same read-modify-write race as every other essence
|
||||||
# credit in this file (see _reward_summon).
|
# 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()
|
select(User).where(User.id == state.user_id).with_for_update()
|
||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if outcome.essence:
|
if award:
|
||||||
credit_essence(user, outcome.essence)
|
credit_essence(user, award)
|
||||||
if outcome.at_peace:
|
if outcome.at_peace:
|
||||||
# Completing the Passage is the most compassionate act
|
# Completing the Passage is the most compassionate act
|
||||||
# in the game, exactly as a correct cross_over verdict
|
# 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
|
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
|
||||||
)
|
)
|
||||||
if outcome.at_peace:
|
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:
|
if entity_row is not None:
|
||||||
entity_row.at_peace = True
|
entity_row.at_peace = True
|
||||||
item = roll_item_drop("judgment")
|
item = roll_item_drop("judgment")
|
||||||
@@ -1108,7 +1158,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
|
|||||||
)
|
)
|
||||||
await db.commit()
|
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:
|
if item is not None:
|
||||||
await state.send_queue.put({"type": "item_drop", "item": item})
|
await state.send_queue.put({"type": "item_drop", "item": item})
|
||||||
|
|
||||||
|
|||||||
294
backend/tests/test_ws_passage.py
Normal file
294
backend/tests/test_ws_passage.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""WS integration tests for the Passage handlers in app.ws.
|
||||||
|
|
||||||
|
Why this file exists: `tests/test_passage.py` covers app/passage.py, which is
|
||||||
|
pure — it decides outcomes and returns them. It cannot see the handler that
|
||||||
|
banks the essence those outcomes describe, and that handler is where an
|
||||||
|
unbounded essence faucet lived undetected through a 400-test suite: nothing
|
||||||
|
exercised `passage_start` / `passage_layer` over a real connection.
|
||||||
|
|
||||||
|
The faucet: `passage_start` rewinds the rite to `listen` at any time, and the
|
||||||
|
UI offers exactly that button after a resisted release (PassagePanel.tsx's
|
||||||
|
"walk it again"). Every replayed layer re-credited its essence, so one summon
|
||||||
|
funded an endless loop. The rate limiter caps how FAST that runs, never how
|
||||||
|
much it totals.
|
||||||
|
|
||||||
|
These tests are written against the exploit path, not against the
|
||||||
|
implementation: they drive the same frames a browser sends and then read the
|
||||||
|
ledger straight out of the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.ws
|
||||||
|
from app.entities import fallback_profile
|
||||||
|
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
||||||
|
from app.models.user import User
|
||||||
|
from app.passage import LAYERS, LAYER_ESSENCE
|
||||||
|
from app.rate_limit import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSpiritService:
|
||||||
|
async def mint_profile(
|
||||||
|
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||||
|
):
|
||||||
|
return fallback_profile(signature)
|
||||||
|
|
||||||
|
async def fragment(self, source, anomaly, language="en"):
|
||||||
|
return "listen"
|
||||||
|
|
||||||
|
async def wire_whisper(self, telemetry, language="en"):
|
||||||
|
return "the wire hums"
|
||||||
|
|
||||||
|
def chat_stream(self, entity, question, history, language="en"):
|
||||||
|
async def gen():
|
||||||
|
yield "here."
|
||||||
|
|
||||||
|
return gen()
|
||||||
|
|
||||||
|
def ambient_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _fake_synth(text, voice, profile, instability=0.0):
|
||||||
|
return b"RIFFfake wav bytes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fake_spirits(monkeypatch):
|
||||||
|
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
|
||||||
|
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
|
||||||
|
# Module-level limiters are shared singletons that accumulate real hit
|
||||||
|
# counts across the whole session (precedent: test_ws_ritual_judgment.py).
|
||||||
|
# The passage limiter matters most here — the exploit these tests replay
|
||||||
|
# is deliberately high-volume, and the real 20/60s cap would mask it
|
||||||
|
# behind a rate limit rather than letting us observe the actual total.
|
||||||
|
for name in (
|
||||||
|
"summon_limiter",
|
||||||
|
"summon_ip_limiter",
|
||||||
|
"question_limiter",
|
||||||
|
"question_ip_limiter",
|
||||||
|
"fragment_limiter",
|
||||||
|
"fragment_ip_limiter",
|
||||||
|
"ritual_limiter",
|
||||||
|
"ritual_ip_limiter",
|
||||||
|
"judgment_limiter",
|
||||||
|
"judgment_ip_limiter",
|
||||||
|
"passage_limiter",
|
||||||
|
"passage_ip_limiter",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(app.ws, name, RateLimiter(max_requests=10_000, window_seconds=60))
|
||||||
|
|
||||||
|
|
||||||
|
def _no_twists(monkeypatch):
|
||||||
|
"""Pin both Passage draws to "no twist", leaving every other draw real.
|
||||||
|
|
||||||
|
A PassageDraw of 1.0 never fires, since both twists trigger on
|
||||||
|
`draw < chance`. Only the `passage:` contexts are pinned — blanketing
|
||||||
|
`veil_float` would also steer the summon and manifest draws, and these
|
||||||
|
tests are about the ledger, not about the room's noise.
|
||||||
|
"""
|
||||||
|
real = app.ws.veil_float
|
||||||
|
|
||||||
|
def selective(entropy, context, *args, **kwargs):
|
||||||
|
if context.startswith("passage:"):
|
||||||
|
return 1.0
|
||||||
|
return real(entropy, context, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(app.ws, "veil_float", selective)
|
||||||
|
|
||||||
|
|
||||||
|
# The exploit never reaches `release`. Crossing latches `passage_crossed`,
|
||||||
|
# which correctly blocks replay — the faucet ran on the four beats BEFORE
|
||||||
|
# release, rewound by the "walk it again" button after a resisted release.
|
||||||
|
BEATS_BEFORE_RELEASE = len(LAYERS) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _read_until(ws, msg_type, max_frames=80, **match):
|
||||||
|
for _ in range(max_frames):
|
||||||
|
frame = ws.receive_json()
|
||||||
|
if frame.get("type") != msg_type:
|
||||||
|
continue
|
||||||
|
if all(frame.get(key) == value for key, value in match.items()):
|
||||||
|
return frame
|
||||||
|
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _login(sync_client, username):
|
||||||
|
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
|
||||||
|
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
|
||||||
|
return sync_client.cookies.get("qm_session")
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_connect(sync_client, token):
|
||||||
|
return sync_client.websocket_connect(
|
||||||
|
"/ws/session", headers={"cookie": f"qm_session={token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summon(ws):
|
||||||
|
ws.send_json({"type": "summon"})
|
||||||
|
frame = _read_until(ws, "entity")
|
||||||
|
_read_until(ws, "utterance", kind="greeting")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(ws):
|
||||||
|
"""Force a full round trip so any post-frame DB write has landed.
|
||||||
|
|
||||||
|
The essence credit runs before the frame is queued, but the sender task
|
||||||
|
drains that queue concurrently with the handler's remaining awaits. The
|
||||||
|
connection's message loop is sequential, so a pong is a hard guarantee
|
||||||
|
that the previous handler returned — not a poll-and-hope.
|
||||||
|
"""
|
||||||
|
ws.send_json({"type": "ping"})
|
||||||
|
_read_until(ws, "pong")
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(ws, beats):
|
||||||
|
"""Send `beats` passage_layer frames, returning the result frames."""
|
||||||
|
frames = []
|
||||||
|
for _ in range(beats):
|
||||||
|
ws.send_json({"type": "passage_layer"})
|
||||||
|
frames.append(_read_until(ws, "passage_result"))
|
||||||
|
_settle(ws)
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
async def _essence(db_session, user_id):
|
||||||
|
db_session.expire_all()
|
||||||
|
user = await db_session.get(User, user_id)
|
||||||
|
return user.essence
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(sync_client, token):
|
||||||
|
return uuid.UUID(
|
||||||
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the faucet ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_replaying_the_rite_never_mints_essence_twice(
|
||||||
|
sync_client, db_session, monkeypatch
|
||||||
|
):
|
||||||
|
"""The exploit, executed literally.
|
||||||
|
|
||||||
|
Walk every layer, hit `passage_start` to rewind, walk them all again —
|
||||||
|
ten times over. The seeker must finish with exactly one rite's worth of
|
||||||
|
essence, because there was only ever one spirit.
|
||||||
|
|
||||||
|
Before the fix this credited the full ladder on every pass; with the
|
||||||
|
ladder summing to 53, ten passes paid 530 instead of 53.
|
||||||
|
"""
|
||||||
|
# No collapses and no crossing: this test is about the ledger, and a
|
||||||
|
# random rewind or an early `crossed` would make the totals depend on
|
||||||
|
# the draw rather than on the replay guard.
|
||||||
|
_no_twists(monkeypatch)
|
||||||
|
|
||||||
|
token = _login(sync_client, "faucet-walker")
|
||||||
|
user_id = _user_id(sync_client, token)
|
||||||
|
|
||||||
|
with _ws_connect(sync_client, token) as ws:
|
||||||
|
_read_until(ws, "session")
|
||||||
|
_summon(ws)
|
||||||
|
baseline = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
after_one_rite = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
|
||||||
|
after_ten_replays = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
earned = after_one_rite - baseline
|
||||||
|
assert earned > 0, "the rite paid nothing at all — the test proves nothing"
|
||||||
|
assert earned <= sum(LAYER_ESSENCE.values())
|
||||||
|
assert after_ten_replays == after_one_rite, (
|
||||||
|
f"replaying the rite minted {after_ten_replays - after_one_rite} extra essence; "
|
||||||
|
"the passage is an unbounded faucet again"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_reported_essence_matches_what_was_actually_credited(
|
||||||
|
sync_client, db_session, monkeypatch
|
||||||
|
):
|
||||||
|
"""The UI sums the frame's `essence` field into its running total.
|
||||||
|
|
||||||
|
If a replayed layer reports the layer's face value while the ledger
|
||||||
|
credits nothing, the seeker watches a total climb that their account
|
||||||
|
never receives — the most corrosive kind of bug in a game about trust.
|
||||||
|
"""
|
||||||
|
_no_twists(monkeypatch)
|
||||||
|
|
||||||
|
token = _login(sync_client, "honest-ledger")
|
||||||
|
user_id = _user_id(sync_client, token)
|
||||||
|
|
||||||
|
with _ws_connect(sync_client, token) as ws:
|
||||||
|
_read_until(ws, "session")
|
||||||
|
_summon(ws)
|
||||||
|
baseline = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
first = _walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
second = _walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
|
||||||
|
final = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
assert sum(f["essence"] for f in first) == final - baseline
|
||||||
|
assert all(f["essence"] == 0 for f in second), (
|
||||||
|
"a replayed layer advertised essence it did not pay"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_genuinely_new_presence_reopens_the_purse(
|
||||||
|
sync_client, db_session, monkeypatch
|
||||||
|
):
|
||||||
|
"""The guard must not overshoot: each spirit is worth its own rite.
|
||||||
|
|
||||||
|
A `passage_paid` set that survived a summon would silently make every
|
||||||
|
spirit after the first worthless, which is a worse bug than the faucet.
|
||||||
|
"""
|
||||||
|
_no_twists(monkeypatch)
|
||||||
|
|
||||||
|
token = _login(sync_client, "second-spirit")
|
||||||
|
user_id = _user_id(sync_client, token)
|
||||||
|
|
||||||
|
with _ws_connect(sync_client, token) as ws:
|
||||||
|
_read_until(ws, "session")
|
||||||
|
_summon(ws)
|
||||||
|
baseline = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
after_first = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
_summon(ws)
|
||||||
|
ws.send_json({"type": "passage_start"})
|
||||||
|
_settle(ws)
|
||||||
|
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||||
|
after_second = await _essence(db_session, user_id)
|
||||||
|
|
||||||
|
first_rite = after_first - baseline
|
||||||
|
# The second summon also pays its own trickle, so compare the rite only.
|
||||||
|
second_rite = after_second - after_first - SUMMON_ESSENCE_TRICKLE
|
||||||
|
assert first_rite > 0
|
||||||
|
assert second_rite == first_rite, (
|
||||||
|
"a fresh presence did not re-open the purse — every spirit after the "
|
||||||
|
"first is worth nothing"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user