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:
279
backend/app/passage.py
Normal file
279
backend/app/passage.py
Normal 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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
292
backend/tests/test_passage.py
Normal file
292
backend/tests/test_passage.py
Normal 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
|
||||
235
frontend/src/components/PassagePanel.css
Normal file
235
frontend/src/components/PassagePanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
274
frontend/src/components/PassagePanel.tsx
Normal file
274
frontend/src/components/PassagePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user