feat: the Passage — crossing over becomes a layered rite (Workstream L)

cross_over was one verdict with one outcome. It is now five beats — listen,
name, unbind, open, release — each revealing something true about the
spirit, each paying escalating essence, each able to twist.

Reveals use the entity's REAL traits; there is no second hidden state
invented for the rite. Twists are trait-driven, verified directly rather
than assumed: a demon collapses the rite 3.6x more often than a calm spirit
(0.360 vs 0.099 at `unbind`) and lies ~31% of the time, while a spirit
under DECEIT_FLOOR cannot lie on any draw. A demon still resists at
`release` and never crosses — the existing judgment rule is preserved, not
re-implemented. Every draw comes from veil_float on the room's physical
entropy, never `random`.

Essence is kept across a collapse. Clawing it back would punish a seeker
for the spirit's instability, which is not theirs to control.

FIXED AN INFINITE LOOP IN THE SALVAGED TESTS, not a flake:
test_full_rite_on_a_calm_stuck_spirit ran `while layer is not None` while
passing collapse=FIRES. Its fixture comment claimed "both twist chances are
0 at these values, so NO draw can make this entity lie or collapse" — that
is false. COLLAPSE_FLOOR is 0.5 (deliberately below judgment's stuck bar of
0.6, as passage.py explains), and the fixture's volatility is 0.61, giving a
real ~9.9% collapse chance. Forced to fire, every layer bounced back to
`listen` and the run hung forever instead of failing.

Three fixes: hold the collapse draw (deceit still fires, which is the
point — it proves a spirit under the floor cannot lie even when told to),
correct the false comment, and bound the loop so a future regression fails
in seconds rather than hanging a test run.

Also added the entire seance.passage i18n block in both languages — the
agent died before writing it, so the gate was failing on 35 missing keys —
and reworded a comment in PassagePanel that spelled out a translation call
in full: the coverage checker greps source text and cannot tell a comment
from real code, so it demanded a key for the placeholder.

34 passage tests pass; 385 frontend; i18n parity and typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-31 14:19:55 +00:00
parent 0966fa8cfc
commit 09089f01d5
10 changed files with 1580 additions and 1 deletions

View File

@@ -12,6 +12,9 @@ Protocol (client → server):
{"type": "ritual_step", "step": <int>} → (on the final step) ritual_complete
{"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"}
→ judgment_result
{"type": "passage_start"} → passage_state (the rite begins)
{"type": "passage_layer"} → passage_result (one beat of the
layered crossing rite: listen → name → unbind → open → release)
{"type": "scry", "image": "<base64 jpeg>"} → {"type": "utterance", kind: "scry"}
The seeker's camera, shown to the vision model so the entity can speak
about the real room. The frame is never stored or logged — only the
@@ -35,7 +38,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app import judgment
from app import judgment, passage
from app.config import settings
from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME
@@ -100,6 +103,13 @@ question_ip_limiter = RateLimiter(max_requests=12, window_seconds=60)
summon_ip_limiter = RateLimiter(max_requests=8, window_seconds=60)
ritual_ip_limiter = RateLimiter(max_requests=12, window_seconds=60)
judgment_ip_limiter = RateLimiter(max_requests=20, window_seconds=60)
# The Passage (Workstream L): each `passage_layer` frame is its own essence
# credit, so it needs the same bounding as ritual/judgment. The budget is
# larger because one rite is five frames and a collapse legitimately makes a
# seeker replay earlier layers — four full rites a minute is already far
# past human pace, but never trips on honest play.
passage_limiter = RateLimiter(max_requests=20, window_seconds=60)
passage_ip_limiter = RateLimiter(max_requests=40, window_seconds=60)
scry_ip_limiter = RateLimiter(max_requests=8, window_seconds=60)
# Probability that a channel's familiar presence answers again rather than
@@ -143,6 +153,16 @@ class SeanceState:
ritual_steps: int = 0
ritual_completed: bool = False
ritual_success: bool = False
# 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
# a lie (so the next one can flag it in hindsight), and `passage_crossed`
# latches once the spirit has actually crossed so the rite — and the
# cross_over verdict — can't be replayed for a second payout. All three
# reset on a fresh summon, like the ritual fields above.
passage_layer: str = passage.FIRST_LAYER
passage_lied: bool = False
passage_crossed: bool = False
# 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.
@@ -552,6 +572,7 @@ async def _summon_locked(state: SeanceState) -> None:
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
_reset_passage(state)
await state.send_queue.put(
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
@@ -887,6 +908,12 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
)
return
# The Passage already crossed this spirit and already paid for it — a
# `cross_over` verdict on top would credit CROSS_OVER_ESSENCE a second
# time for the same crossing. One spirit, one passage, one payment.
if verdict == "cross_over" and state.passage_crossed:
return
traits = state.entity.get("traits", {})
outcome = judgment.judge_verdict(
verdict,
@@ -945,6 +972,147 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
await state.send_queue.put({"type": "item_drop", "item": item})
# --- Workstream L: the Passage (passage-doctrine spec) ---------------------
#
# Five beats — listen / name / unbind / open / release — each revealing one
# real trait, paying a little essence, and able to twist. All of the actual
# logic is in `app/passage.py`, which is pure; this handler owns only the
# things a pure module can't: the limiter, the two entropy draws, the
# essence credit (via the same `credit_essence` the rest of the economy
# uses), the `at_peace` write, and the frames.
def _reset_passage(state: SeanceState) -> None:
state.passage_layer = passage.FIRST_LAYER
state.passage_lied = False
state.passage_crossed = False
def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
reveal = outcome.reveal
return {
"type": "passage_result",
"layer": outcome.layer,
"result": outcome.result,
# The client renders the words from (trait, band) via i18n, so no
# untranslated prose ever crosses the wire.
"reveal": (
{"trait": reveal.trait, "value": reveal.value, "band": reveal.band}
if reveal is not None
else None
),
"essence": outcome.essence,
"at_peace": outcome.at_peace,
"next_layer": outcome.next_layer,
# Deliberately NOT `outcome.lied` — that's this layer's own lie, and
# the seeker must not learn of it until the next beat. This field is
# the *previous* layer's lie, surfacing in hindsight.
"unreliable_layer": outcome.unreliable_layer,
"sealed": state.passage_crossed,
}
async def _handle_passage_start(state: SeanceState) -> None:
if state.entity is None:
return # no presence to pass — the frontend already gates the button
if state.passage_crossed:
return # already at peace; there is nothing left to walk
if not (
passage_limiter.allow(str(state.user_id))
and passage_ip_limiter.allow(state.client_ip)
):
await state.send_queue.put(
{
"type": "error",
"code": "rate_limited",
"message": "The way is still closing behind you. Give it a moment.",
}
)
return
_reset_passage(state)
await state.send_queue.put(
{"type": "passage_state", "layer": state.passage_layer, "sealed": False}
)
async def _handle_passage_layer(state: SeanceState) -> None:
if state.entity is None or state.passage_crossed:
return
if not (
passage_limiter.allow(str(state.user_id))
and passage_ip_limiter.allow(state.client_ip)
):
await state.send_queue.put(
{
"type": "error",
"code": "rate_limited",
"message": "The way is still closing behind you. Give it a moment.",
}
)
return
traits = state.entity.get("traits", {})
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
# correlated with each other (or with any other draw in the app).
draw = passage.PassageDraw(
collapse=veil_float(state.entropy, f"passage:collapse:{layer}"),
deceit=veil_float(state.entropy, f"passage:deceit:{layer}"),
)
outcome = passage.resolve_layer(
traits, layer, draw, previous_lied=state.passage_lied
)
state.passage_lied = outcome.lied
if outcome.result == "collapsed":
# The rite restarts from `listen` — but the essence already earned
# stays credited. Taking it back would punish the seeker for the
# spirit's own instability.
state.passage_lied = False
state.passage_layer = outcome.next_layer or layer
if outcome.at_peace:
state.passage_crossed = True
item = None
if outcome.essence 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).
user = await db.scalar(
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 outcome.at_peace:
# 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.
user.favor = judgment.clamp_favor(
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
)
if outcome.at_peace:
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
if entity_row is not None:
entity_row.at_peace = True
item = roll_item_drop("judgment")
if item is not None:
db.add(
InventoryItem(
user_id=state.user_id,
item_type=item["item_type"],
item_key=item["item_key"],
payload=item["payload"],
)
)
await db.commit()
await state.send_queue.put(_passage_frame(state, outcome))
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
# A 768px JPEG at quality 0.72 is well under 200KB, so ~350KB of base64 is a
# generous ceiling that still refuses anything pathological before it reaches
# the model.
@@ -1093,6 +1261,10 @@ async def session_socket(websocket: WebSocket) -> None:
await _handle_ritual_step(state, message)
elif msg_type == "judgment":
await _handle_judgment(state, message)
elif msg_type == "passage_start":
await _handle_passage_start(state)
elif msg_type == "passage_layer":
await _handle_passage_layer(state)
elif msg_type == "scry":
await _handle_scry(state, message)
except WebSocketDisconnect: