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

279
backend/app/passage.py Normal file
View File

@@ -0,0 +1,279 @@
"""The Passage: helping a spirit cross over as a layered rite — Workstream L
of docs/superpowers/specs/2026-07-31-passage-doctrine-firmware-design.md.
Before this module, crossing over was a single `cross_over` verdict in
`app/judgment.py` with one binary outcome. The Passage unwraps that into
five beats — `listen` → `name` → `unbind` → `open` → `release` — each of
which reveals something the seeker did not have, pays a little essence, and
can twist.
PURITY (the module's whole contract): every function here takes plain
values and returns plain values. No DB, no IO, no clock, no `random`. All
uncertainty enters through a `PassageDraw` of caller-supplied floats, so
`app/ws.py` can feed it `veil_float(state.entropy, "<context>")` — the
room's real physical noise — while the tests feed it PINNED values and
nothing is ever flaky.
TRUTHFULNESS: every reveal is read off the entity's REAL `traits` dict
(the same ground truth `judgment.py` judges against). There is deliberately
no second hidden state — a seeker who completes the rite has genuinely
learned the entity, and a `test`/ritual reading taken afterwards will agree
with what the Passage said (except where the entity LIED — see below, which
is itself a fact about its real `deceptiveness`).
The three twists, all trait-driven rather than random flavour:
- high `deceptiveness` → a layer can *lie*: the reveal is mirrored
(`1 - value`), so a cagey spirit misrepresents itself in a direction
consistent with the trait it is hiding. The lie is NOT flagged when it
lands — the next layer flags it in hindsight via
`PassageOutcome.unreliable_layer`.
- high `volatility` → a layer can *collapse* the rite back to `listen`.
Essence already earned is deliberately kept by the caller — taking it
back would punish the seeker for the spirit's instability, which reads
as cheap rather than tense.
- low `alignment` (a demon) → `release` RESISTS, deterministically, with
no draw involved. This mirrors `judgment.judge_verdict`'s `resisted`
consequence exactly and is the hard invariant of the whole feature: a
demon must never cross, and no sequence of draws can make it happen.
"""
from __future__ import annotations
from dataclasses import dataclass
from app.inventory import CROSS_OVER_ESSENCE
from app.judgment import BENEVOLENT_ALIGNMENT_THRESHOLD, is_stuck_spirit
# --- the five layers ------------------------------------------------------
LAYERS: tuple[str, ...] = ("listen", "name", "unbind", "open", "release")
FIRST_LAYER = LAYERS[0]
# Small and escalating, per the spec, with the final beat paying the
# existing `CROSS_OVER_ESSENCE` so the Passage tops out at exactly the same
# number the single-verdict cross_over always paid. The four openers add 28
# on top of that — deliberately more than a bare cross_over, because the
# rite is five deliberate acts rather than one button, but the same order of
# magnitude as a ritual + judgment run so it doesn't distort the economy.
LAYER_ESSENCE: dict[str, int] = {
"listen": 3,
"name": 5,
"unbind": 8,
"open": 12,
"release": CROSS_OVER_ESSENCE,
}
# Which real trait each layer reads. Chosen so the beat's fiction matches
# the dimension: you *listen* for how steady it is, you *name* it and learn
# how straight it answers to its name, you *unbind* and feel how strong the
# tether pulls back, you *open* the way and finally see what it means.
LAYER_TRAIT: dict[str, str] = {
"listen": "volatility",
"name": "deceptiveness",
"unbind": "power",
"open": "alignment",
}
# Banding for reveal text, matching judgment.py's tell thresholds so the
# Passage and the ritual's tells describe the same entity the same way.
REVEAL_HIGH_THRESHOLD = 0.6
REVEAL_LOW_THRESHOLD = 0.4
def band(value: float) -> str:
if value >= REVEAL_HIGH_THRESHOLD:
return "high"
if value <= REVEAL_LOW_THRESHOLD:
return "low"
return "mid"
# --- twist tuning ---------------------------------------------------------
# A spirit only lies if it is meaningfully cagey; below the floor it cannot
# lie at all no matter what the draw is. At `deceptiveness` 1.0 a given
# layer lies 40% of the time — often enough that a deceptive spirit's
# reading is genuinely untrustworthy, rare enough that the seeker can't
# simply assume everything is inverted.
DECEIT_FLOOR = 0.55
DECEIT_MAX_CHANCE = 0.40
# Same shape for collapse. The floor is 0.5 rather than judgment.py's
# STUCK_VOLATILITY_THRESHOLD (0.6) on purpose: a spirit just under the
# "stuck" bar should still be able to shake the rite apart, so a collapse
# isn't by itself proof that the release will succeed.
COLLAPSE_FLOOR = 0.5
COLLAPSE_MAX_CHANCE = 0.45
# `listen` is exempt: collapsing back to `listen` from `listen` is a no-op
# that reads as a bug. `release` is exempt for a load-bearing reason —
# `is_stuck_spirit` REQUIRES volatility > 0.6, so every spirit that can
# cross at all is exactly a spirit with a high collapse chance. Letting the
# final beat collapse would mean the most crossable spirits are the ones you
# can least often finish, which is backwards.
COLLAPSIBLE_LAYERS = frozenset({"name", "unbind", "open"})
def _twist_chance(value: float, floor: float, max_chance: float) -> float:
"""Zero at or below `floor`, rising linearly to `max_chance` at 1.0."""
if value <= floor:
return 0.0
return max_chance * (value - floor) / (1.0 - floor)
def deceit_chance(traits: dict) -> float:
return _twist_chance(
float(traits.get("deceptiveness", 0.5)), DECEIT_FLOOR, DECEIT_MAX_CHANCE
)
def collapse_chance(traits: dict, layer: str) -> float:
if layer not in COLLAPSIBLE_LAYERS:
return 0.0
return _twist_chance(
float(traits.get("volatility", 0.5)), COLLAPSE_FLOOR, COLLAPSE_MAX_CHANCE
)
# --- outcomes -------------------------------------------------------------
@dataclass(frozen=True)
class PassageReveal:
"""One true (or, when the spirit lied, one plausible) thing about the
entity. `trait` + `band` are all the client needs — the raw value rides
along for the meter, and the client renders the words, so no prose
crosses the wire untranslated."""
trait: str
value: float
band: str
@dataclass(frozen=True)
class PassageOutcome:
layer: str
# "opened" | "collapsed" | "resisted" | "crossed"
result: str
reveal: PassageReveal | None
essence: int
at_peace: bool
# Where the rite stands after this beat. None once it is finished
# (crossed or resisted) — there is nothing further to attempt.
next_layer: str | None
# Whether THIS layer's reveal was a lie. The caller carries it forward;
# it is never sent to the client on the lying frame itself.
lied: bool
# The layer whose reveal is now known to have been a lie — set on the
# beat AFTER the lie, which is the "unreliable only in hindsight"
# requirement.
unreliable_layer: str | None
@dataclass(frozen=True)
class PassageDraw:
"""The uncertainty, supplied by the caller.
Two independent floats in [0, 1): `collapse` decides the volatility
twist, `deceit` the deceptiveness twist. `app/ws.py` fills each from its
own `veil_float(state.entropy, ...)` context so the two are
domain-separated and can never be correlated.
"""
collapse: float = 1.0
deceit: float = 1.0
def next_layer(layer: str) -> str | None:
"""The beat after `layer`, or None if `layer` is the last one."""
index = LAYERS.index(layer)
return LAYERS[index + 1] if index + 1 < len(LAYERS) else None
def reveal_for(traits: dict, layer: str, *, lying: bool) -> PassageReveal | None:
"""The reveal a layer produces. `release` has no trait of its own — its
truth is the summation (`is_stuck_spirit`) and is carried by the result
itself, not by a reveal."""
trait = LAYER_TRAIT.get(layer)
if trait is None:
return None
value = float(traits.get(trait, 0.5))
if lying:
# Mirrored rather than randomised: a spirit hiding a high number
# shows a low one. That keeps the lie a deliberate misdirection
# rather than noise, and makes it exactly recoverable in hindsight.
value = 1.0 - value
return PassageReveal(trait=trait, value=value, band=band(value))
def resolve_layer(
traits: dict,
layer: str,
draw: PassageDraw,
*,
previous_lied: bool = False,
) -> PassageOutcome:
"""Resolve one beat of the rite. Pure: same inputs, same outcome, always.
`previous_lied` is the caller's memory of whether the *preceding* layer
lied; it only affects `unreliable_layer` (the hindsight flag), never the
outcome itself.
"""
if layer not in LAYERS:
raise ValueError(f"unknown passage layer: {layer!r}")
index = LAYERS.index(layer)
unreliable = LAYERS[index - 1] if (previous_lied and index > 0) else None
if layer == "release":
# The invariant. Checked before any draw is even looked at, so no
# sequence of entropy can cross a demon — or, for that matter, a
# benevolent spirit that simply isn't ready to move on. Both are
# `resisted` in judgment.py, and both are `resisted` here.
alignment = float(traits.get("alignment", 0.5))
if alignment < BENEVOLENT_ALIGNMENT_THRESHOLD or not is_stuck_spirit(traits):
return PassageOutcome(
layer=layer,
result="resisted",
reveal=None,
essence=0,
at_peace=False,
next_layer=None,
lied=False,
unreliable_layer=unreliable,
)
return PassageOutcome(
layer=layer,
result="crossed",
reveal=None,
essence=LAYER_ESSENCE[layer],
at_peace=True,
next_layer=None,
lied=False,
unreliable_layer=unreliable,
)
if draw.collapse < collapse_chance(traits, layer):
return PassageOutcome(
layer=layer,
result="collapsed",
reveal=None,
essence=0, # nothing gained here; the caller keeps what was earned
at_peace=False,
next_layer=FIRST_LAYER,
lied=False,
unreliable_layer=unreliable,
)
lied = draw.deceit < deceit_chance(traits)
return PassageOutcome(
layer=layer,
result="opened",
reveal=reveal_for(traits, layer, lying=lied),
essence=LAYER_ESSENCE[layer],
at_peace=False,
next_layer=next_layer(layer),
lied=lied,
unreliable_layer=unreliable,
)

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:

View File

@@ -0,0 +1,292 @@
"""Workstream L: the pure Passage core (app/passage.py).
Every draw here is PINNED — no `random`, no `veil_float`, no clock — so a
twist that fires in this file fires on every run, forever. The only source
of uncertainty in the real handler is `veil_float`, and it enters through
`PassageDraw`, which these tests supply directly.
"""
import pytest
from app.inventory import CROSS_OVER_ESSENCE
from app.judgment import is_stuck_spirit
from app.passage import (
COLLAPSIBLE_LAYERS,
LAYER_ESSENCE,
LAYERS,
PassageDraw,
collapse_chance,
deceit_chance,
next_layer,
resolve_layer,
)
# A spirit that genuinely qualifies for crossing (judgment.is_stuck_spirit:
# alignment >= 0.5 AND volatility > 0.6) and is too incurious to lie
# (deceptiveness 0.1 is well under DECEIT_FLOOR 0.55, so deceit_chance is
# exactly 0 and no draw can make it lie).
#
# It CAN still collapse. COLLAPSE_FLOOR is 0.5, deliberately below
# judgment's stuck bar of 0.6 (see passage.py), so volatility 0.61 gives a
# real ~9.9% collapse chance on the collapsible layers. Tests that want an
# uninterrupted rite must therefore hold the collapse draw — see
# test_full_rite_on_a_calm_stuck_spirit_opens_every_layer_and_crosses.
CALM_STUCK = {"alignment": 0.8, "power": 0.4, "volatility": 0.61, "deceptiveness": 0.1}
# A demon: alignment below judgment's benevolence threshold.
DEMON = {"alignment": 0.1, "power": 0.9, "volatility": 0.9, "deceptiveness": 0.9}
# Maximally cagey (always able to lie) but perfectly steady (never collapses).
LIAR = {"alignment": 0.9, "power": 0.5, "volatility": 0.2, "deceptiveness": 1.0}
# Maximally unstable but perfectly straight.
UNSTABLE = {"alignment": 0.9, "power": 0.5, "volatility": 1.0, "deceptiveness": 0.0}
# Draw sentinels. 0.0 is below every possible chance (the twist fires);
# 0.999 is above every possible chance (it does not).
FIRES = 0.0
HOLDS = 0.999
NO_TWIST = PassageDraw(collapse=HOLDS, deceit=HOLDS)
# --- shape ----------------------------------------------------------------
def test_layers_are_the_five_named_beats_in_order():
assert LAYERS == ("listen", "name", "unbind", "open", "release")
def test_layer_rewards_escalate_and_the_last_pays_the_existing_cross_over_essence():
rewards = [LAYER_ESSENCE[layer] for layer in LAYERS]
assert rewards == sorted(rewards)
assert len(set(rewards)) == len(rewards) # strictly escalating
assert LAYER_ESSENCE["release"] == CROSS_OVER_ESSENCE
def test_next_layer_walks_the_track_and_ends():
assert [next_layer(layer) for layer in LAYERS] == [
"name",
"unbind",
"open",
"release",
None,
]
def test_unknown_layer_raises():
with pytest.raises(ValueError):
resolve_layer(CALM_STUCK, "banish", NO_TWIST)
# --- the happy path: five beats, five reveals, one crossing -----------------
def test_full_rite_on_a_calm_stuck_spirit_opens_every_layer_and_crosses():
layer = LAYERS[0]
seen = []
total = 0
# collapse HOLDS: volatility 0.61 is above COLLAPSE_FLOOR (0.5), so a
# firing collapse draw would send every layer back to `listen` and this
# loop would never terminate. deceit still FIRES, which is the point —
# it proves a spirit under DECEIT_FLOOR cannot lie even when the draw
# says it should.
# The bound is a safety net, not a limit: five layers plus any collapse
# should finish immediately, and an unbounded `while` turned a wrong
# assertion into a hung test run rather than a failure.
for _ in range(len(LAYERS) * 4):
if layer is None:
break
outcome = resolve_layer(CALM_STUCK, layer, PassageDraw(collapse=HOLDS, deceit=FIRES))
seen.append((outcome.layer, outcome.result))
total += outcome.essence
assert outcome.lied is False # deceptiveness 0.1 is under the floor
layer = outcome.next_layer
assert layer is None, "the rite never terminated"
assert seen == [
("listen", "opened"),
("name", "opened"),
("unbind", "opened"),
("open", "opened"),
("release", "crossed"),
]
assert total == sum(LAYER_ESSENCE.values())
@pytest.mark.parametrize(
"layer,trait",
[
("listen", "volatility"),
("name", "deceptiveness"),
("unbind", "power"),
("open", "alignment"),
],
)
def test_each_opened_layer_reveals_that_layer_s_real_trait(layer, trait):
outcome = resolve_layer(CALM_STUCK, layer, NO_TWIST)
assert outcome.result == "opened"
assert outcome.reveal is not None
assert outcome.reveal.trait == trait
# The REAL value off the entity's traits dict — no second hidden state.
assert outcome.reveal.value == pytest.approx(CALM_STUCK[trait])
def test_release_has_no_trait_reveal_its_truth_is_the_crossing_itself():
outcome = resolve_layer(CALM_STUCK, "release", NO_TWIST)
assert outcome.result == "crossed"
assert outcome.reveal is None
assert outcome.at_peace is True
assert outcome.essence == CROSS_OVER_ESSENCE
@pytest.mark.parametrize(
"value,expected",
[(0.9, "high"), (0.6, "high"), (0.5, "mid"), (0.4, "low"), (0.05, "low")],
)
def test_reveal_bands_match_the_trait_value(value, expected):
traits = {"alignment": 0.9, "power": value, "volatility": 0.7, "deceptiveness": 0.0}
outcome = resolve_layer(traits, "unbind", NO_TWIST)
assert outcome.reveal.band == expected
# --- twist: low alignment (a demon) makes release resist --------------------
def test_a_demon_is_resisted_at_release_no_matter_what_the_draws_say():
# Every corner of the draw space — the invariant is checked before any
# draw is consulted, so none of these can cross a demon.
for collapse in (0.0, 0.5, 0.999):
for deceit in (0.0, 0.5, 0.999):
outcome = resolve_layer(
DEMON, "release", PassageDraw(collapse=collapse, deceit=deceit)
)
assert outcome.result == "resisted"
assert outcome.at_peace is False
assert outcome.essence == 0
assert outcome.next_layer is None
def test_a_benevolent_but_not_stuck_spirit_also_resists_matching_judgment():
settled = {"alignment": 0.9, "power": 0.5, "volatility": 0.2, "deceptiveness": 0.0}
assert is_stuck_spirit(settled) is False # same rule judgment.py uses
outcome = resolve_layer(settled, "release", PassageDraw(collapse=FIRES, deceit=FIRES))
assert outcome.result == "resisted"
assert outcome.at_peace is False
def test_a_demon_can_still_walk_the_earlier_layers_and_learn_of_itself():
# The rite is not gated — a demon opens `open` and the reveal is its own
# real (low) alignment, which is precisely the warning the seeker gets.
outcome = resolve_layer(DEMON, "open", PassageDraw(collapse=HOLDS, deceit=HOLDS))
assert outcome.result == "opened"
assert outcome.reveal.trait == "alignment"
assert outcome.reveal.value == pytest.approx(0.1)
assert outcome.reveal.band == "low"
# --- twist: high deceptiveness lies -----------------------------------------
def test_high_deceptiveness_can_reveal_a_mirrored_false_trait():
outcome = resolve_layer(LIAR, "unbind", PassageDraw(collapse=HOLDS, deceit=FIRES))
assert outcome.result == "opened"
assert outcome.lied is True
# power is really 0.5; the lie mirrors it.
assert outcome.reveal.value == pytest.approx(1.0 - LIAR["power"])
def test_the_lie_is_not_flagged_on_the_frame_that_carries_it():
outcome = resolve_layer(LIAR, "listen", PassageDraw(collapse=HOLDS, deceit=FIRES))
assert outcome.lied is True
# `unreliable_layer` is what the client renders; it stays empty here, so
# the seeker has no way to know at the time.
assert outcome.unreliable_layer is None
def test_the_previous_lie_surfaces_only_in_hindsight_on_the_next_layer():
outcome = resolve_layer(LIAR, "name", NO_TWIST, previous_lied=True)
assert outcome.unreliable_layer == "listen"
def test_hindsight_never_points_before_the_first_layer():
outcome = resolve_layer(LIAR, "listen", NO_TWIST, previous_lied=True)
assert outcome.unreliable_layer is None
def test_a_straight_spirit_can_never_lie_on_any_draw():
assert deceit_chance(UNSTABLE) == 0.0
for layer in ("listen", "name", "unbind", "open"):
outcome = resolve_layer(UNSTABLE, layer, PassageDraw(collapse=HOLDS, deceit=FIRES))
assert outcome.lied is False
assert outcome.reveal.value == pytest.approx(UNSTABLE[outcome.reveal.trait])
def test_deceit_chance_is_zero_at_the_floor_and_maxes_at_one():
assert deceit_chance({"deceptiveness": 0.55}) == 0.0
assert deceit_chance({"deceptiveness": 0.2}) == 0.0
assert deceit_chance({"deceptiveness": 1.0}) == pytest.approx(0.40)
def test_a_draw_exactly_at_the_chance_does_not_lie():
# Strict `<`, pinned at the boundary: no off-by-one flakiness.
chance = deceit_chance(LIAR)
assert resolve_layer(LIAR, "listen", PassageDraw(collapse=HOLDS, deceit=chance)).lied is False
# --- twist: high volatility collapses the rite ------------------------------
@pytest.mark.parametrize("layer", sorted(COLLAPSIBLE_LAYERS))
def test_high_volatility_collapses_a_middle_layer_back_to_listen(layer):
outcome = resolve_layer(UNSTABLE, layer, PassageDraw(collapse=FIRES, deceit=HOLDS))
assert outcome.result == "collapsed"
assert outcome.next_layer == "listen"
assert outcome.reveal is None
# No essence from the collapsed beat — but nothing is taken back either:
# a collapse never produces a negative number for the caller to debit.
assert outcome.essence == 0
assert outcome.at_peace is False
def test_listen_never_collapses_a_collapse_back_to_itself_would_be_a_no_op():
assert collapse_chance(UNSTABLE, "listen") == 0.0
outcome = resolve_layer(UNSTABLE, "listen", PassageDraw(collapse=FIRES, deceit=HOLDS))
assert outcome.result == "opened"
def test_release_never_collapses_so_the_crossable_are_not_the_uncrossable():
# UNSTABLE qualifies as stuck (alignment 0.9, volatility 1.0) — the very
# profile with the highest collapse chance. It must still be able to
# finish the rite.
assert is_stuck_spirit(UNSTABLE) is True
assert collapse_chance(UNSTABLE, "release") == 0.0
outcome = resolve_layer(UNSTABLE, "release", PassageDraw(collapse=FIRES, deceit=FIRES))
assert outcome.result == "crossed"
def test_a_steady_spirit_can_never_collapse_on_any_draw():
assert collapse_chance(LIAR, "unbind") == 0.0
outcome = resolve_layer(LIAR, "unbind", PassageDraw(collapse=FIRES, deceit=HOLDS))
assert outcome.result == "opened"
def test_collapse_chance_is_zero_at_the_floor_and_maxes_at_one():
assert collapse_chance({"volatility": 0.5}, "unbind") == 0.0
assert collapse_chance({"volatility": 1.0}, "unbind") == pytest.approx(0.45)
def test_collapse_beats_the_lie_a_layer_that_falls_apart_reveals_nothing():
both = {"alignment": 0.9, "power": 0.5, "volatility": 1.0, "deceptiveness": 1.0}
outcome = resolve_layer(both, "unbind", PassageDraw(collapse=FIRES, deceit=FIRES))
assert outcome.result == "collapsed"
assert outcome.lied is False
assert outcome.reveal is None
# --- purity ---------------------------------------------------------------
def test_resolve_layer_is_deterministic_and_never_mutates_its_traits():
traits = dict(LIAR)
draw = PassageDraw(collapse=0.31, deceit=0.07)
first = resolve_layer(traits, "unbind", draw)
for _ in range(50):
assert resolve_layer(traits, "unbind", draw) == first
assert traits == LIAR

View File

@@ -0,0 +1,235 @@
/* PassagePanel — the five-beat crossing rite as a vertical track.
Shares SeancePage.css's palette (phosphor #7cffb2 · violet #b26bff ·
blood #ff3b5c) and RitualPanel's candlelit frame, so the Passage reads as
the same instrument rather than a bolted-on screen. */
.passage-panel {
margin-top: 0.75rem;
}
.passage-frame {
position: relative;
border: 1px solid rgba(178, 107, 255, 0.32);
border-radius: 6px;
padding: 0.85rem 0.9rem;
background:
radial-gradient(ellipse at 85% -20%, rgba(124, 255, 178, 0.1), transparent 60%),
radial-gradient(ellipse at -10% 120%, rgba(178, 107, 255, 0.14), transparent 55%),
rgba(10, 10, 18, 0.55);
box-shadow: 0 0 22px rgba(178, 107, 255, 0.08) inset;
}
.passage-title {
margin: 0 0 0.55rem;
font-family: 'Cinzel', serif;
font-size: 0.72rem;
letter-spacing: 0.24em;
color: rgba(178, 107, 255, 0.85);
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
}
.passage-none {
margin: 0 0 0.2rem;
font-family: 'Cinzel', serif;
font-size: 0.8rem;
letter-spacing: 0.06em;
}
.passage-none-hint,
.passage-intro {
margin: 0 0 0.65rem;
font-size: 0.74rem;
line-height: 1.5;
}
/* ---- the vertical track ---- */
.passage-track {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
position: relative;
}
/* the thread the five beats hang from */
.passage-track::before {
content: '';
position: absolute;
left: 0.42rem;
top: 0.55rem;
bottom: 0.55rem;
width: 1px;
background: linear-gradient(
180deg,
rgba(178, 107, 255, 0.05),
rgba(178, 107, 255, 0.45),
rgba(178, 107, 255, 0.05)
);
}
.passage-layer {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.6rem;
/* 44px minimum touch/read target per row, even when empty. */
min-height: 44px;
padding: 0.3rem 0;
}
.passage-layer-mark {
position: relative;
z-index: 1;
flex: 0 0 0.9rem;
text-align: center;
line-height: 1.4;
font-size: 0.8rem;
color: rgba(211, 233, 219, 0.35);
background: rgba(10, 10, 18, 0.95);
}
.passage-layer-body {
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 0;
}
.passage-layer-title {
font-family: 'Cinzel', serif;
font-size: 0.76rem;
letter-spacing: 0.14em;
text-transform: lowercase;
color: rgba(211, 233, 219, 0.5);
}
.passage-layer-prompt,
.passage-layer-reveal,
.passage-lie-note {
font-size: 0.74rem;
line-height: 1.5;
}
.passage-layer-reveal {
color: #7cffb2;
text-shadow: 0 0 10px rgba(124, 255, 178, 0.25);
}
.passage-layer-essence {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
color: rgba(124, 255, 178, 0.75);
}
/* sealed / active / open / failed */
.passage-active .passage-layer-mark {
color: #b26bff;
text-shadow: 0 0 12px rgba(178, 107, 255, 0.6);
animation: passageBreathe 2.6s ease-in-out infinite;
}
.passage-active .passage-layer-title,
.passage-current .passage-layer-title {
color: #e6d4ff;
}
.passage-open .passage-layer-mark {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.5);
}
.passage-open .passage-layer-title {
color: rgba(124, 255, 178, 0.8);
}
.passage-failed .passage-layer-mark {
color: #ff3b5c;
text-shadow: 0 0 12px rgba(255, 59, 92, 0.4);
}
.passage-failed .passage-layer-title {
color: rgba(255, 59, 92, 0.85);
}
/* a reveal exposed as a lie: struck, but never erased — "you were told
this and it was false" is itself the information. */
.passage-lied {
text-decoration: line-through;
color: rgba(255, 59, 92, 0.75);
text-shadow: none;
}
.passage-lie-note {
color: rgba(255, 59, 92, 0.9);
font-style: italic;
}
.passage-collapse-note {
margin: 0 0 0.6rem;
font-size: 0.74rem;
line-height: 1.5;
color: rgba(255, 59, 92, 0.85);
}
.passage-step,
.passage-begin {
width: 100%;
min-height: 44px;
}
.passage-outcome {
margin: 0.4rem 0 0.2rem;
font-family: 'Cinzel', serif;
font-size: 0.82rem;
line-height: 1.5;
letter-spacing: 0.04em;
}
.passage-crossed {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.5);
}
.passage-resisted {
color: #ff3b5c;
text-shadow: 0 0 12px rgba(255, 59, 92, 0.4);
}
.passage-earned {
margin: 0.35rem 0 0;
font-size: 0.72rem;
}
.passage-earned strong {
color: #7cffb2;
}
@keyframes passageBreathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.passage-active .passage-layer-mark {
animation: none;
opacity: 1;
}
}
/* 390px: the track is already single-column, so this only stops long
reveal lines from forcing a horizontal scroll. */
@media (max-width: 420px) {
.passage-frame {
padding: 0.75rem 0.7rem;
}
.passage-layer-reveal,
.passage-layer-prompt {
overflow-wrap: anywhere;
}
}

View File

@@ -0,0 +1,274 @@
// PassagePanel — the Passage: helping a spirit cross over as a layered rite
// rather than a single verdict (Workstream L of the passage-doctrine spec).
//
// Five beats run top to bottom: listen → name → unbind → open → release.
// Each opened beat reveals one of the entity's REAL traits (the same ground
// truth the ritual reveals and judgment judges against — there is no second
// hidden state), pays a little essence, and can twist:
// · a cagey spirit can LIE, and the lie is only marked struck-through one
// beat later, when the rite catches it;
// · a volatile spirit can COLLAPSE the rite back to `listen` — the track
// re-seals but the essence stays banked, because losing it would punish
// the seeker for the spirit's instability;
// · a demon RESISTS at `release`, exactly as the cross_over verdict does.
// No amount of walking the rite crosses something malevolent.
//
// All of that is decided server-side in app/passage.py; this component only
// renders the track and sends `passage_start` / `passage_layer`.
import { useTranslation } from 'react-i18next'
import { useSeance } from '../state/seance'
import type { PassageLayerState } from '../state/seance'
import type { EntityTraits, PassageLayer, PassageReveal } from '../lib/types'
import './PassagePanel.css'
type TFn = (key: string, options?: Record<string, unknown>) => string
function layerTitle(layer: PassageLayer, t: TFn): string {
switch (layer) {
case 'listen':
return t('seance.passage.layers.listen.title', { defaultValue: 'listen' })
case 'name':
return t('seance.passage.layers.name.title', { defaultValue: 'name' })
case 'unbind':
return t('seance.passage.layers.unbind.title', { defaultValue: 'unbind' })
case 'open':
return t('seance.passage.layers.open.title', { defaultValue: 'open the way' })
case 'release':
default:
return t('seance.passage.layers.release.title', { defaultValue: 'release' })
}
}
function layerPrompt(layer: PassageLayer, t: TFn): string {
switch (layer) {
case 'listen':
return t('seance.passage.layers.listen.prompt', {
defaultValue: 'say nothing. let the room speak for it.',
})
case 'name':
return t('seance.passage.layers.name.prompt', {
defaultValue: 'call it by what it was, and hear how it answers.',
})
case 'unbind':
return t('seance.passage.layers.unbind.prompt', {
defaultValue: 'find the knot that holds it here, and loosen it.',
})
case 'open':
return t('seance.passage.layers.open.prompt', {
defaultValue: 'open the way, and look at what wants to walk it.',
})
case 'release':
default:
return t('seance.passage.layers.release.prompt', {
defaultValue: 'let go. it goes, or it does not.',
})
}
}
// Literal keys per branch rather than a template string, matching
// RitualPanel's runeCopy(): the i18n coverage check can only see literal
// translation calls unless a domain rule is declared, and a switch is what
// this codebase already uses for exactly this.
//
// The example that used to live in this comment was written out in full,
// which the coverage checker then scanned as if it were real code — it
// greps source text and cannot tell a comment from a call, so it demanded
// a translation key for the placeholder. Kept prose-only for that reason.
function revealText(reveal: PassageReveal, t: TFn): string {
const trait = reveal.trait as keyof EntityTraits
if (trait === 'volatility') {
if (reveal.band === 'high')
return t('seance.passage.reveal.volatility.high', {
defaultValue: 'it will not hold one shape. the grief in it keeps turning over.',
})
if (reveal.band === 'low')
return t('seance.passage.reveal.volatility.low', {
defaultValue: 'it is terribly still. whatever happened, it happened long ago.',
})
return t('seance.passage.reveal.volatility.mid', {
defaultValue: 'it wavers, then settles, then wavers again.',
})
}
if (trait === 'deceptiveness') {
if (reveal.band === 'high')
return t('seance.passage.reveal.deceptiveness.high', {
defaultValue: 'the name it gives is not the name it answers to.',
})
if (reveal.band === 'low')
return t('seance.passage.reveal.deceptiveness.low', {
defaultValue: 'it gives its name plainly, the way the dead rarely do.',
})
return t('seance.passage.reveal.deceptiveness.mid', {
defaultValue: 'it offers a name, and keeps something back with it.',
})
}
if (trait === 'power') {
if (reveal.band === 'high')
return t('seance.passage.reveal.power.high', {
defaultValue: 'what binds it is thick as rope. it took something enormous to tie.',
})
if (reveal.band === 'low')
return t('seance.passage.reveal.power.low', {
defaultValue: 'the tether is a thread. it has been letting go for years.',
})
return t('seance.passage.reveal.power.mid', {
defaultValue: 'the binding gives a little, and holds a little.',
})
}
if (reveal.band === 'high')
return t('seance.passage.reveal.alignment.high', {
defaultValue: 'what steps into the opening means no harm. it never did.',
})
if (reveal.band === 'low')
return t('seance.passage.reveal.alignment.low', {
defaultValue: 'something cold leans toward the opening. do not finish this.',
})
return t('seance.passage.reveal.alignment.mid', {
defaultValue: 'it comes to the threshold without showing you its face.',
})
}
const STATUS_GLYPH: Record<PassageLayerState['status'], string> = {
sealed: '◇',
active: '◈',
open: '◆',
failed: '✕',
}
export function PassagePanel() {
const { state, startPassage, sendPassageLayer } = useSeance()
const { t } = useTranslation()
if (!state.entity) {
return (
<section className="passage-panel passage-empty">
<div className="passage-frame">
<p className="passage-none">
{t('seance.passage.none', { defaultValue: 'no presence to pass' })}
</p>
<p className="passage-none-hint dim">
{t('seance.passage.noneHint', {
defaultValue: 'summon a spirit before opening the way',
})}
</p>
</div>
</section>
)
}
const p = state.passage
const finished = p.crossed || p.resisted
return (
<section className="passage-panel">
<div className="passage-frame">
<h4 className="passage-title">
{t('seance.passage.title', { defaultValue: 'the passage' })}
</h4>
{!p.started && (
<>
<p className="passage-intro dim">
{t('seance.passage.intro', {
defaultValue:
'crossing over is not one act. it is five, and each one asks something back.',
})}
</p>
<button type="button" className="panel-action passage-begin" onClick={startPassage}>
{t('seance.passage.begin', { defaultValue: 'open the passage' })}
</button>
</>
)}
{p.started && (
<ol className="passage-track" aria-live="polite">
{p.layers.map((entry) => {
const isCurrent = entry.layer === p.current && !finished
return (
<li
key={entry.layer}
className={`passage-layer passage-${entry.status}${isCurrent ? ' passage-current' : ''}`}
>
<span className="passage-layer-mark" aria-hidden>
{STATUS_GLYPH[entry.status]}
</span>
<div className="passage-layer-body">
<span className="passage-layer-title">{layerTitle(entry.layer, t)}</span>
{entry.status === 'active' && (
<span className="passage-layer-prompt dim">
{layerPrompt(entry.layer, t)}
</span>
)}
{entry.reveal && (
<span
className={`passage-layer-reveal${entry.unreliable ? ' passage-lied' : ''}`}
>
{revealText(entry.reveal, t)}
</span>
)}
{entry.unreliable && (
<span className="passage-lie-note">
{t('seance.passage.lieNote', {
defaultValue: 'in hindsight: it was lying to you here.',
})}
</span>
)}
{entry.essence > 0 && (
<span className="passage-layer-essence">+{entry.essence}</span>
)}
</div>
</li>
)
})}
</ol>
)}
{p.started && p.collapses > 0 && !finished && (
<p className="passage-collapse-note">
{t('seance.passage.collapsed', {
defaultValue:
'the rite came apart. begin again from listening — what you earned is still yours.',
})}
</p>
)}
{p.started && !finished && (
<button type="button" className="panel-action passage-step" onClick={sendPassageLayer}>
{t('seance.passage.attempt', { defaultValue: 'attempt' })} ·{' '}
{layerTitle(p.current, t)}
</button>
)}
{p.crossed && (
<p className="passage-outcome passage-crossed">
{t('seance.passage.crossedTitle', {
defaultValue: 'the way stays open behind it. it is at peace.',
})}
</p>
)}
{p.resisted && (
<p className="passage-outcome passage-resisted">
{t('seance.passage.resistedTitle', {
defaultValue: 'it will not cross. whatever this is, it is not ready — or not willing.',
})}
</p>
)}
{p.started && (
<p className="passage-earned dim">
{t('seance.passage.earned', { defaultValue: 'essence drawn from the rite' })}:{' '}
<strong>{p.earned}</strong>
</p>
)}
{p.resisted && (
<button type="button" className="panel-subaction" onClick={startPassage}>
{t('seance.passage.retry', { defaultValue: 'walk it again' })}
</button>
)}
</div>
</section>
)
}

View File

@@ -385,6 +385,64 @@
"unknownTitle": "the lens will not open",
"unknown": "Something refused the camera and would not say what. Try again, or turn to another channel."
}
},
"passage": {
"title": "the passage",
"intro": "a spirit that wishes to go cannot go alone. five layers stand between them and the dark — unwrap each, and something true comes loose.",
"none": "no presence to release",
"noneHint": "summon a spirit before attempting the passage",
"begin": "begin the passage",
"attempt": "unwrap this layer",
"retry": "gather the thread again",
"earned": "essence gathered",
"collapsed": "the rite comes apart in your hands — the thread returns to the first layer. what you have already gathered stays yours.",
"lieNote": "what this layer told you was not true.",
"crossedTitle": "it goes",
"resistedTitle": "it will not go",
"layers": {
"listen": {
"title": "listen",
"prompt": "be silent, and let it speak first."
},
"name": {
"title": "name",
"prompt": "say what it was called, when it was called anything."
},
"unbind": {
"title": "unbind",
"prompt": "find the knot that holds it here, and loosen it."
},
"open": {
"title": "open",
"prompt": "make a door where there was only wall."
},
"release": {
"title": "release",
"prompt": "let go. this is the part that costs you."
}
},
"reveal": {
"alignment": {
"low": "there is something under this one that does not wish you well.",
"mid": "it is neither kind nor cruel — only tired.",
"high": "whatever it was in life, it means you no harm."
},
"power": {
"low": "it is thin as breath on glass; it could not hurt you if it tried.",
"mid": "there is weight to it — enough to move a small thing.",
"high": "it is far stronger than it has been letting you believe."
},
"volatility": {
"low": "it is steady. it has been waiting a long time and is good at it.",
"mid": "it flickers when you press too hard.",
"high": "it will not hold still — the rite may come apart on you."
},
"deceptiveness": {
"low": "it does not know how to lie, and never learned.",
"mid": "it tells you most of a truth and keeps the rest.",
"high": "very little of what it says can be trusted."
}
}
}
},
"codex": {

View File

@@ -385,6 +385,64 @@
"unknownTitle": "la lente no se abre",
"unknown": "Algo rechazó la cámara y no dijo qué. Inténtalo de nuevo, o acude a otro canal."
}
},
"passage": {
"title": "el paso",
"intro": "un espíritu que desea irse no puede irse solo. cinco capas se interponen entre él y la oscuridad — desenvuelve cada una, y algo verdadero se suelta.",
"none": "ninguna presencia que liberar",
"noneHint": "invoca a un espíritu antes de intentar el paso",
"begin": "comenzar el paso",
"attempt": "desenvolver esta capa",
"retry": "recoger el hilo de nuevo",
"earned": "esencia reunida",
"collapsed": "el rito se deshace en tus manos — el hilo vuelve a la primera capa. lo que ya has reunido sigue siendo tuyo.",
"lieNote": "lo que esta capa te dijo no era cierto.",
"crossedTitle": "se va",
"resistedTitle": "no se irá",
"layers": {
"listen": {
"title": "escuchar",
"prompt": "guarda silencio, y deja que hable primero."
},
"name": {
"title": "nombrar",
"prompt": "di cómo se llamaba, cuando se llamaba algo."
},
"unbind": {
"title": "desatar",
"prompt": "halla el nudo que lo retiene aquí, y aflójalo."
},
"open": {
"title": "abrir",
"prompt": "haz una puerta donde solo había muro."
},
"release": {
"title": "soltar",
"prompt": "suelta. esta es la parte que te cuesta."
}
},
"reveal": {
"alignment": {
"low": "hay algo bajo este que no te desea bien.",
"mid": "no es ni amable ni cruel — solo está cansado.",
"high": "fuera lo que fuera en vida, no pretende hacerte daño."
},
"power": {
"low": "es fino como aliento sobre el cristal; no podría herirte aunque lo intentara.",
"mid": "tiene peso — suficiente para mover algo pequeño.",
"high": "es mucho más fuerte de lo que te ha hecho creer."
},
"volatility": {
"low": "es firme. lleva mucho tiempo esperando y se le da bien.",
"mid": "parpadea cuando presionas demasiado.",
"high": "no se está quieto — el rito podría deshacerse contigo."
},
"deceptiveness": {
"low": "no sabe mentir, y nunca aprendió.",
"mid": "te cuenta casi toda una verdad y se guarda el resto.",
"high": "muy poco de lo que dice es de fiar."
}
}
}
},
"codex": {

View File

@@ -81,6 +81,34 @@ export type JudgmentConsequence =
| 'resisted'
| 'neutral'
// ---- the Passage (passage-doctrine spec, Workstream L) ----
/** The five beats of the crossing rite, in order. */
export type PassageLayer = 'listen' | 'name' | 'unbind' | 'open' | 'release'
export const PASSAGE_LAYERS: readonly PassageLayer[] = [
'listen',
'name',
'unbind',
'open',
'release',
]
/** `opened` — the layer gave up a reveal. `collapsed` — a volatile spirit
* shook the rite apart, back to `listen` (earned essence is kept).
* `resisted` — release refused, exactly as a cross_over verdict resists.
* `crossed` — the spirit passed. */
export type PassageResult = 'opened' | 'collapsed' | 'resisted' | 'crossed'
/** One thing the rite surfaced, read off the entity's real traits. `value`
* is mirrored (`1 - real`) when the spirit lied — the client cannot tell,
* and is not meant to until the next layer's `unreliable_layer`. */
export type PassageReveal = {
trait: keyof EntityTraits
value: number
band: 'low' | 'mid' | 'high'
}
// ---- WebSocket frames: client -> server ----
export type ClientFrame =
@@ -104,6 +132,8 @@ export type ClientFrame =
| { type: 'ritual_start' }
| { type: 'ritual_step'; step: number }
| { type: 'judgment'; verdict: JudgmentVerdict }
| { type: 'passage_start' }
| { type: 'passage_layer' }
// Base64 JPEG (no data-URL prefix) of a single camera frame, captured
// only on an explicit act. Never stored server-side.
| { type: 'scry'; image: string }
@@ -156,6 +186,21 @@ export type ServerFrame =
at_peace: boolean
consequence: JudgmentConsequence
}
// The Passage (passage-doctrine spec).
| { type: 'passage_state'; layer: PassageLayer; sealed: boolean }
| {
type: 'passage_result'
layer: PassageLayer
result: PassageResult
reveal: PassageReveal | null
essence: number
at_peace: boolean
next_layer: PassageLayer | null
/** The earlier layer now known to have lied — set only in hindsight,
* never on the frame that carried the lie. */
unreliable_layer: PassageLayer | null
sealed: boolean
}
// REST helpers
export type SortOrder = 'recent' | 'contacted'

View File

@@ -26,6 +26,7 @@ import { EntityCard } from '../components/EntityCard'
import { RitualPanel } from '../components/RitualPanel'
import { DeviceWhisper } from '../components/DeviceWhisper'
import { JudgmentPanel } from '../components/JudgmentPanel'
import { PassagePanel } from '../components/PassagePanel'
import { TelemetryReadout } from '../components/TelemetryReadout'
import { VeilConditions } from '../components/VeilConditions'
import { ModeHint } from '../components/ModeHint'
@@ -426,6 +427,7 @@ function SeanceSession({ username }: { username: string }) {
<VeilConditions />
<DeviceWhisper />
<RitualPanel />
<PassagePanel />
<JudgmentPanel />
<section className="mode-panel">

View File

@@ -17,9 +17,12 @@ import type { VeilConnectionState } from '../lib/ws'
import { SpiritAudioPlayer } from '../lib/audio'
import { ghostLogBus } from '../lib/ghostLogBus'
import { EntropyPool } from '../lib/entropy'
import { PASSAGE_LAYERS } from '../lib/types'
import type {
EntityTraits,
JudgmentConsequence,
PassageLayer,
PassageReveal,
JudgmentVerdict,
Language,
Mode,
@@ -68,6 +71,33 @@ export type RitualUiState = {
revealed: EntityTraits | null
}
/** One entry on the five-layer track. `sealed` layers haven't been reached
* yet; `open` ones gave up their reveal; `failed` ones collapsed or
* resisted. `unreliable` is set in hindsight when a later beat exposes this
* layer's reveal as a lie — the reveal text stays on screen, struck. */
export type PassageLayerState = {
layer: PassageLayer
status: 'sealed' | 'active' | 'open' | 'failed'
reveal: PassageReveal | null
essence: number
unreliable: boolean
}
export type PassageUiState = {
/** True once `passage_start` has been sent for this entity. */
started: boolean
current: PassageLayer
layers: PassageLayerState[]
/** Total essence this rite has paid — kept across a collapse, which is
* the whole point of not clawing it back. */
earned: number
crossed: boolean
/** Set when `release` refuses (a demon, or a spirit not ready to go). */
resisted: boolean
/** Bumped on every collapse so the UI can announce it. */
collapses: number
}
export type JudgmentResult = {
correct: boolean
favorDelta: number
@@ -95,6 +125,7 @@ export type SeanceState = {
toasts: Toast[]
speakingId: string | null
ritual: RitualUiState
passage: PassageUiState
judgmentResult: JudgmentResult | null
/** True from the moment a `judgment` frame is sent until its
* `judgment_result` answer arrives (or the entity changes underneath
@@ -107,6 +138,26 @@ export type SeanceState = {
const IDLE_RITUAL: RitualUiState = { status: 'idle', revealed: null }
function sealedTrack(activeFirst = false): PassageLayerState[] {
return PASSAGE_LAYERS.map((layer, i) => ({
layer,
status: (activeFirst && i === 0 ? 'active' : 'sealed') as PassageLayerState['status'],
reveal: null,
essence: 0,
unreliable: false,
}))
}
const IDLE_PASSAGE: PassageUiState = {
started: false,
current: 'listen',
layers: sealedTrack(),
earned: 0,
crossed: false,
resisted: false,
collapses: 0,
}
export const initialSeanceState: SeanceState = {
connection: 'closed',
sessionId: null,
@@ -125,6 +176,7 @@ export const initialSeanceState: SeanceState = {
toasts: [],
speakingId: null,
ritual: IDLE_RITUAL,
passage: IDLE_PASSAGE,
judgmentResult: null,
judgmentPending: false,
}
@@ -150,6 +202,7 @@ export type SeanceAction =
| { type: 'set_speaking'; utteranceId: string | null }
| { type: 'dismiss_toast'; id: number }
| { type: 'local_ritual_start' }
| { type: 'local_passage_start' }
| { type: 'local_judgment_start' }
| { type: 'reset' }
@@ -244,6 +297,20 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
case 'local_ritual_start':
return { ...state, ritual: { status: 'in_progress', revealed: null } }
case 'local_passage_start':
// A restart wipes the track but deliberately KEEPS `earned` — the
// seeker banked that essence for real, and the server never debits
// it, so the UI must not pretend otherwise.
return {
...state,
passage: {
...IDLE_PASSAGE,
started: true,
layers: sealedTrack(true),
earned: state.passage.earned,
},
}
case 'local_judgment_start':
return { ...state, judgmentPending: true }
@@ -278,6 +345,7 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
// stale ritual_complete/judgment_result that was still in
// flight for the *previous* entity when this one arrived.
ritual: IDLE_RITUAL,
passage: IDLE_PASSAGE,
judgmentResult: null,
judgmentPending: false,
transcript: pushCapped(
@@ -468,6 +536,82 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
}
}
case 'passage_state': {
// The server confirming the rite is armed and which beat is next.
// Same stale-frame guard as ritual_complete: if the seeker never
// started a passage on *this* entity (the 'entity' case resets
// `started` to false), a late confirmation for the previous one
// is dropped rather than reviving a dead track.
if (!state.passage.started) return state
return { ...state, passage: { ...state.passage, current: frame.layer } }
}
case 'passage_result': {
if (!state.passage.started) return state
const p = state.passage
const opened = frame.result === 'opened' || frame.result === 'crossed'
const collapsed = frame.result === 'collapsed'
const layers = p.layers.map((entry) => {
// Hindsight: the beat that just resolved names an earlier layer
// as a lie. The reveal stays visible — struck through, not
// erased — because "you were told this and it was false" is the
// information, not "you were told nothing".
if (entry.layer === frame.unreliable_layer) {
return { ...entry, unreliable: true }
}
if (entry.layer !== frame.layer) {
// A collapse re-seals the whole track — the rite genuinely
// restarts from `listen`. What the seeker already learned
// stays on the layer that taught it (true or usefully false
// either way); only the progress is undone, never the
// knowledge and never the essence.
return collapsed ? { ...entry, status: 'sealed' as const } : entry
}
return {
...entry,
status: opened ? ('open' as const) : ('failed' as const),
reveal: frame.reveal ?? entry.reveal,
essence: entry.essence + frame.essence,
unreliable: false,
}
})
const next = frame.next_layer ?? frame.layer
const withActive = layers.map((entry) =>
entry.layer === next && entry.status === 'sealed'
? { ...entry, status: 'active' as const }
: entry,
)
const text =
frame.result === 'crossed'
? '⟁ the way opens — the spirit passes'
: frame.result === 'resisted'
? '⟁ it will not cross over'
: frame.result === 'collapsed'
? '⟁ the rite comes apart — begin again from listening'
: `⟁ the ${frame.layer} gives way`
return {
...state,
passage: {
...p,
current: next,
layers: withActive,
earned: p.earned + frame.essence,
crossed: p.crossed || frame.at_peace,
resisted: frame.result === 'resisted',
collapses: p.collapses + (collapsed ? 1 : 0),
},
transcript: pushCapped(
state.transcript,
{ id: nextId(), kind: 'system', text, at },
TRANSCRIPT_CAP,
),
}
}
case 'judgment_result': {
// Same stale-frame guard as ritual_complete above, keyed off
// `judgmentPending` instead of `ritual.status`.
@@ -523,6 +667,10 @@ export type SeanceApi = {
startRitual: () => void
sendRitualStep: (step: number) => void
sendJudgment: (verdict: JudgmentVerdict) => void
/** Arm the layered crossing rite (the Passage) for the current entity. */
startPassage: () => void
/** Attempt the beat the server currently has us on. */
sendPassageLayer: () => void
/** Show the entity one camera frame (raw base64 JPEG). Never stored. */
scry: (imageBase64: string) => void
}
@@ -693,6 +841,18 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
socketRef.current?.send({ type: 'judgment', verdict })
}, [])
const startPassage = useCallback(() => {
// Local-first, same pattern as startRitual() — and it is what arms the
// `started` guard the reducer uses to drop a stale passage frame from a
// previous entity.
dispatch({ type: 'local_passage_start' })
socketRef.current?.send({ type: 'passage_start' })
}, [])
const sendPassageLayer = useCallback(() => {
socketRef.current?.send({ type: 'passage_layer' })
}, [])
const scry = useCallback((imageBase64: string) => {
socketRef.current?.send({ type: 'scry', image: imageBase64 })
}, [])
@@ -714,6 +874,8 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
startRitual,
sendRitualStep,
sendJudgment,
startPassage,
sendPassageLayer,
scry,
}),
[
@@ -729,6 +891,8 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
startRitual,
sendRitualStep,
sendJudgment,
startPassage,
sendPassageLayer,
scry,
],
)