diff --git a/backend/app/passage.py b/backend/app/passage.py new file mode 100644 index 0000000..b5d3b0e --- /dev/null +++ b/backend/app/passage.py @@ -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, "")` — 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, + ) diff --git a/backend/app/ws.py b/backend/app/ws.py index 929ad0e..4eda54c 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -12,6 +12,9 @@ Protocol (client → server): {"type": "ritual_step", "step": } → (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": ""} → {"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: diff --git a/backend/tests/test_passage.py b/backend/tests/test_passage.py new file mode 100644 index 0000000..bbf8fc8 --- /dev/null +++ b/backend/tests/test_passage.py @@ -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 diff --git a/frontend/src/components/PassagePanel.css b/frontend/src/components/PassagePanel.css new file mode 100644 index 0000000..92f916c --- /dev/null +++ b/frontend/src/components/PassagePanel.css @@ -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; + } +} diff --git a/frontend/src/components/PassagePanel.tsx b/frontend/src/components/PassagePanel.tsx new file mode 100644 index 0000000..2a0f081 --- /dev/null +++ b/frontend/src/components/PassagePanel.tsx @@ -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 + +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 = { + sealed: '◇', + active: '◈', + open: '◆', + failed: '✕', +} + +export function PassagePanel() { + const { state, startPassage, sendPassageLayer } = useSeance() + const { t } = useTranslation() + + if (!state.entity) { + return ( +
+
+

+ {t('seance.passage.none', { defaultValue: 'no presence to pass' })} +

+

+ {t('seance.passage.noneHint', { + defaultValue: 'summon a spirit before opening the way', + })} +

+
+
+ ) + } + + const p = state.passage + const finished = p.crossed || p.resisted + + return ( +
+
+

+ {t('seance.passage.title', { defaultValue: 'the passage' })} +

+ + {!p.started && ( + <> +

+ {t('seance.passage.intro', { + defaultValue: + 'crossing over is not one act. it is five, and each one asks something back.', + })} +

+ + + )} + + {p.started && ( +
    + {p.layers.map((entry) => { + const isCurrent = entry.layer === p.current && !finished + return ( +
  1. + + {STATUS_GLYPH[entry.status]} + +
    + {layerTitle(entry.layer, t)} + {entry.status === 'active' && ( + + {layerPrompt(entry.layer, t)} + + )} + {entry.reveal && ( + + {revealText(entry.reveal, t)} + + )} + {entry.unreliable && ( + + {t('seance.passage.lieNote', { + defaultValue: 'in hindsight: it was lying to you here.', + })} + + )} + {entry.essence > 0 && ( + +{entry.essence} + )} +
    +
  2. + ) + })} +
+ )} + + {p.started && p.collapses > 0 && !finished && ( +

+ {t('seance.passage.collapsed', { + defaultValue: + 'the rite came apart. begin again from listening — what you earned is still yours.', + })} +

+ )} + + {p.started && !finished && ( + + )} + + {p.crossed && ( +

+ {t('seance.passage.crossedTitle', { + defaultValue: 'the way stays open behind it. it is at peace.', + })} +

+ )} + + {p.resisted && ( +

+ {t('seance.passage.resistedTitle', { + defaultValue: 'it will not cross. whatever this is, it is not ready — or not willing.', + })} +

+ )} + + {p.started && ( +

+ {t('seance.passage.earned', { defaultValue: 'essence drawn from the rite' })}:{' '} + {p.earned} +

+ )} + + {p.resisted && ( + + )} +
+
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a96c6f0..61bbaf1 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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": { diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index ceaa590..c1499a4 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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": { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3c0d001..a43109e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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' diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index aa61bd5..f4fc2d5 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -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 }) { +
diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index 072a712..ab14082 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -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, ], )