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

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

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

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

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

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

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

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

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

View File

@@ -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,
],
)