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