feat: ritual + judgment + favor + cross-over (Workstream B)

Implements Workstream B of the character-depth-ghost-log spec:
ritual_start/ritual_step/judgment WS handlers, the pure judgment.py
logic module, and the User.favor / Entity.at_peace columns + migration.

- app/judgment.py: pure ritual success roll (base 65%, floored at 30%,
  driven by an entity's power+deceptiveness difficulty), the "stuck
  spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20%
  of entities), judgment correctness/favor-delta/essence-delta/
  consequence resolution for all four verdicts, favor clamping, the
  favor-to-trait-roll bias applied at mint time, and tell-line
  generation (opaque behavioral flavor text, never a raw stat).
- app/ws.py: wires ritual_start/ritual_step/judgment frames, emits
  ritual_complete/tell/judgment_result/item_drop per the spec's
  Contract; traits are added to serialize_entity for internal
  server-side use but stripped from the outbound `entity` frame via a
  new _public_entity helper so hidden ground truth never reaches the
  client outside ritual_complete; _summon excludes at-peace entities
  from signature re-contact and mints a fresh (salted-signature) entity
  instead; new entities' traits are nudged by the discovering user's
  favor before being persisted.
- models/user.py, models/entity.py, main.py: User.favor and
  Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT
  EXISTS migration lines in lifespan, alongside the existing ones.
- tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new
  tests covering the ritual/judgment correctness matrix, favor
  clamping/bias, essence crediting, at_peace persistence + re-contact,
  and the entity-frame trait leak guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-24 21:27:30 +00:00
parent f023b591b5
commit 36c4a9e6e4
7 changed files with 1541 additions and 19 deletions

315
backend/app/judgment.py Normal file
View File

@@ -0,0 +1,315 @@
"""Ritual + judgment: pure logic for Workstream B of the Character Depth /
Ghost Log spec
(docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
No I/O, no DB, no network — every function here takes plain values (and an
optional injectable `random.Random`) and returns plain values, so the whole
module is trivially unit-testable in isolation. `backend/app/ws.py`'s
`ritual_start`/`ritual_step`/`judgment` WS handlers are the only place these
outputs get persisted or sent over the wire.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from app.inventory import CORRECT_JUDGMENT_ESSENCE, CROSS_OVER_ESSENCE
# --- favor -------------------------------------------------------------
FAVOR_MIN = -1.0
FAVOR_MAX = 1.0
def clamp_favor(value: float) -> float:
"""Clamps a `User.favor` value to [-1.0, 1.0] — call this at every write
site, per the spec's Contract section."""
return max(FAVOR_MIN, min(FAVOR_MAX, value))
# --- ritual --------------------------------------------------------------
# Rituals should feel doable most of the time — this is a short rite in
# front of judgment, not a grindy gate. A highly resistant entity (high
# `power` *and* high `deceptiveness` — strong and cagey) drags the odds
# down, floored so even the worst-case entity stays beatable on a retry
# rather than a hard wall.
RITUAL_BASE_SUCCESS_CHANCE = 0.65
RITUAL_MIN_SUCCESS_CHANCE = 0.30
RITUAL_DIFFICULTY_SWING = RITUAL_BASE_SUCCESS_CHANCE - RITUAL_MIN_SUCCESS_CHANCE
def roll_ritual_success(traits: dict, rng: random.Random | None = None) -> bool:
"""One ritual attempt's pass/fail roll.
`traits` is the entity's hidden truth dict (`alignment`/`power`/
`volatility`/`deceptiveness`). Only `power` and `deceptiveness` affect
the odds — a strong, cagey spirit is the hardest to get an accurate
read on. `volatility` is deliberately left out: it's about how *noisy*
the entity's ambient tells are, a separate axis from whether a focused
ritual can pin it down. `alignment` is also left out — letting it
influence pass/fail would telegraph ground truth (easier ritual =
probably benevolent) before `ritual_complete` is supposed to reveal
anything, undermining the "judge blind or judge informed" choice the
contract is built around.
`rng` defaults to a fresh, unseeded `random.Random()` — deliberately
*not* signature-seeded like `entities.roll_traits`: a seeker can retry
the same entity's ritual repeatedly (`RitualPanel`'s "attempt again"),
and each attempt needs genuine variance, not a fixed pass/fail baked
into the entity's identity.
"""
rng = rng if rng is not None else random.Random()
power = float(traits.get("power", 0.5))
deceptiveness = float(traits.get("deceptiveness", 0.5))
difficulty = (power + deceptiveness) / 2.0 # 0..1
chance = RITUAL_BASE_SUCCESS_CHANCE - RITUAL_DIFFICULTY_SWING * difficulty
chance = max(RITUAL_MIN_SUCCESS_CHANCE, min(RITUAL_BASE_SUCCESS_CHANCE, chance))
return rng.random() < chance
# --- "stuck spirit" rule for cross_over -----------------------------------
# "Genuinely benevolent" mirrors the correct-trust threshold used below.
# "Reads as stuck" is modeled as above-average volatility: the existing
# fallback personas already lean on "died with something unfinished" themes
# (see entities.py's _PERSONA_TEMPLATES), and an unresolved spirit is
# exactly the kind whose behavioral tells would plausibly be noisy/
# inconsistent rather than settled, rather than tying "stuck" to a whole
# separate hidden dimension. 0.6 (vs. the 0.5 midpoint) keeps "stuck" a
# meaningfully above-average band rather than a coinflip: with both
# `alignment` and `volatility` rolled uniform(0, 1) independently,
# P(alignment >= 0.5) * P(volatility > 0.6) = 0.5 * 0.4 = 20% of all
# entities qualify — special enough to justify the single largest reward
# in the game (see CROSS_OVER_ESSENCE / FAVOR_CORRECT_CROSS_OVER) without
# being so rare it never comes up.
BENEVOLENT_ALIGNMENT_THRESHOLD = 0.5
STUCK_VOLATILITY_THRESHOLD = 0.6
def is_stuck_spirit(traits: dict) -> bool:
alignment = float(traits.get("alignment", 0.5))
volatility = float(traits.get("volatility", 0.5))
return (
alignment >= BENEVOLENT_ALIGNMENT_THRESHOLD
and volatility > STUCK_VOLATILITY_THRESHOLD
)
# --- judgment --------------------------------------------------------------
# Favor deltas: small, single-digit-percent nudges of the [-1, 1] range (a
# 2.0-wide band) — the caller always re-clamps via `clamp_favor`.
#
# Wrong-trust is punished harder than wrong-banish: trusting something that
# turns out malevolent is the reckless failure mode with real in-fiction
# consequences (the haunting escalates), while a wrong banish is merely
# over-cautious — you turned away something harmless, nothing lashes back.
# Correct cross_over pays the best favor *and* essence of any outcome,
# matching the contract's "largest essence reward of any outcome" language
# with an equivalently generous favor nudge: it's the hardest-to-spot,
# most compassionate correct call a seeker can make.
FAVOR_CORRECT_TRUST = 0.05
FAVOR_CORRECT_BANISH = 0.05
FAVOR_WRONG_TRUST = -0.10
FAVOR_WRONG_BANISH = -0.05
FAVOR_CORRECT_CROSS_OVER = 0.08
FAVOR_CROSS_OVER_FAIL = 0.0 # a naive read, not a reckless one — no penalty
FAVOR_TEST = 0.0 # a diagnostic pulse only — nothing risked, nothing gained
VERDICTS = ("trust", "banish", "test", "cross_over")
@dataclass(frozen=True)
class JudgmentOutcome:
correct: bool
favor_delta: float
essence_delta: int
at_peace: bool
consequence: str # "reward" | "escalation" | "withdrawal" | "crossed_over" | "resisted" | "neutral"
def judge_verdict(
verdict: str,
traits: dict,
*,
ritual_completed: bool = False,
ritual_success: bool = False,
) -> JudgmentOutcome:
"""Resolves one `judgment` frame against the entity's hidden truth.
`correct` per the contract: trust called on real alignment >= 0.5, or
banish called on alignment < 0.5, or cross_over called on a genuinely
"stuck" spirit (see `is_stuck_spirit`). `cross_over` on anything else
(a demon, or a benevolent-but-not-stuck spirit that simply isn't ready
to move on) resists — "resisted" covers both: neither is a reckless
misread the way a wrong trust/banish is, so neither costs favor.
`test` never touches favor/essence; its `correct` reflects whether a
completed-this-session ritual actually surfaced true information (no
completed ritual, or a failed one, means the diagnostic has nothing
real to go on).
"""
if verdict not in VERDICTS:
raise ValueError(f"unknown verdict: {verdict!r}")
alignment = float(traits.get("alignment", 0.5))
benevolent = alignment >= BENEVOLENT_ALIGNMENT_THRESHOLD
if verdict == "trust":
if benevolent:
return JudgmentOutcome(
True, FAVOR_CORRECT_TRUST, CORRECT_JUDGMENT_ESSENCE, False, "reward"
)
return JudgmentOutcome(False, FAVOR_WRONG_TRUST, 0, False, "escalation")
if verdict == "banish":
if not benevolent:
return JudgmentOutcome(
True, FAVOR_CORRECT_BANISH, CORRECT_JUDGMENT_ESSENCE, False, "reward"
)
return JudgmentOutcome(False, FAVOR_WRONG_BANISH, 0, False, "withdrawal")
if verdict == "cross_over":
if is_stuck_spirit(traits):
return JudgmentOutcome(
True, FAVOR_CORRECT_CROSS_OVER, CROSS_OVER_ESSENCE, True, "crossed_over"
)
return JudgmentOutcome(False, FAVOR_CROSS_OVER_FAIL, 0, False, "resisted")
# verdict == "test"
correct = bool(ritual_completed and ritual_success)
return JudgmentOutcome(correct, FAVOR_TEST, 0, False, "neutral")
# --- favor read-back bias on trait rolls ------------------------------------
# `entities.roll_traits` stays a pure, signature-seeded function with no
# session/user awareness (Workstream A's design — see the "roll_traits
# doesn't take a bias/rng parameter" check in ws.py's minting path). This is
# applied instead as a post-processing nudge, called from `app.ws._summon`
# right after a *new* entity's profile is minted, using the discovering
# user's current favor.
#
# It's applied once, at mint time, not per-viewing-session: the nudged
# traits become that entity's permanent, shared ground truth in the Codex
# (the same spirit reads the same way to every future seeker who contacts
# it). That matches the contract's "read back to mildly bias future summon
# trait rolls" framing — favor shapes what a seeker tends to *conjure*,
# not a private lens they view existing spirits through.
#
# Effect size is deliberately small and verifiable: at most a 0.12 shift at
# |favor| == 1.0, linear in between, applied only to volatility/
# deceptiveness (the two "how legible is this entity" axes) — alignment and
# power are left untouched so favor nudges readability, not who a spirit
# fundamentally is.
FAVOR_TRAIT_BIAS_MAX = 0.12
_BIASED_TRAIT_KEYS = ("volatility", "deceptiveness")
def apply_favor_bias(traits: dict, favor: float) -> dict:
"""Higher favor nudges volatility/deceptiveness down (more legible
entities); lower favor nudges them up. Returns a new dict; clamps each
nudged value back into [0.0, 1.0]."""
favor = clamp_favor(favor)
shift = -FAVOR_TRAIT_BIAS_MAX * favor
biased = dict(traits)
for key in _BIASED_TRAIT_KEYS:
if key in biased:
biased[key] = max(0.0, min(1.0, float(biased[key]) + shift))
return biased
# --- tells -----------------------------------------------------------------
# Flavor text describing *behavior*, never a stat number (per the contract
# and frontend/src/lib/evilMeter.ts's comments on how tells are consumed) —
# each line hints at one trait dimension without naming it. Picking one
# trait per call (rather than always the most extreme) keeps tells varied
# session to session even for the same entity; the high/low/neutral banding
# means a middling trait still produces a plausible, non-committal line
# instead of always screaming its most extreme dimension.
_TELL_LINES: dict[str, dict[str, tuple[str, ...]]] = {
"alignment": {
"high": (
"a warmth threads through the static, unmistakably kind.",
"it seems to want nothing more than to be heard.",
"the presence feels gentle, almost grateful for the company.",
),
"low": (
"something cold coils under the words.",
"you feel watched, not accompanied.",
"the air around the signal turns unfriendly.",
),
"neutral": (
"hard to say if it means well.",
"the intent behind it stays unreadable.",
),
},
"power": {
"high": (
"the signal surges, straining the line.",
"it pushes back against the questions, strong-willed.",
),
"low": (
"the presence feels thin, easily startled.",
"it flickers at the edge of hearing.",
),
"neutral": (
"steady, unremarkable strength.",
"neither weak nor overwhelming.",
),
},
"volatility": {
"high": (
"the tone lurches without warning.",
"it contradicts itself within the same breath.",
"the signal keeps slipping out from under itself.",
),
"low": (
"calm, consistent, almost rehearsed.",
"every answer lands the same measured way.",
),
"neutral": (
"mostly steady, with the odd hitch.",
"a little uneven, nothing alarming.",
),
},
"deceptiveness": {
"high": (
"the entity avoided a direct question.",
"an answer arrives that doesn't quite fit what was asked.",
"something about the reply feels rehearsed, not remembered.",
),
"low": (
"it answers plainly, almost bluntly.",
"nothing about the reply feels rehearsed.",
),
"neutral": (
"the reply seems straightforward enough.",
"no obvious dodge, no obvious tell.",
),
},
}
_TELL_TRAIT_KEYS = tuple(_TELL_LINES.keys())
TELL_HIGH_THRESHOLD = 0.6
TELL_LOW_THRESHOLD = 0.4
def generate_tell(traits: dict, rng: random.Random) -> str:
"""Picks one trait dimension at random and returns a short, opaque
flavor line hinting at it (never the raw number). `rng` should be a
per-session `random.Random` so a given session's tell sequence is
varied but the choice of *which* call sites emit a tell stays the
caller's (ws.py's) responsibility."""
trait_key = rng.choice(_TELL_TRAIT_KEYS)
value = float(traits.get(trait_key, 0.5))
bank = _TELL_LINES[trait_key]
if value >= TELL_HIGH_THRESHOLD:
pool = bank["high"]
elif value <= TELL_LOW_THRESHOLD:
pool = bank["low"]
else:
pool = bank["neutral"]
return rng.choice(pool)

View File

@@ -54,6 +54,13 @@ async def lifespan(app: FastAPI):
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
))
# Workstream B (character-depth-ghost-log spec).
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
))
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
))
cleanup_task = asyncio.create_task(_session_cleanup_loop())
try:
yield

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -27,6 +27,12 @@ class Entity(Base):
traits: Mapped[dict] = mapped_column(JSONB, default=dict)
sample_quotes: Mapped[list] = mapped_column(JSONB, default=list)
contact_count: Mapped[int] = mapped_column(Integer, default=0)
# Workstream B (character-depth-ghost-log spec): set true when a seeker
# correctly helps a genuinely benevolent, "stuck" spirit cross over. The
# row is never deleted (memorialized in the Codex permanently) but
# `signature_from_anomalies` re-contact in app.ws._summon skips it and
# mints a fresh entity instead.
at_peace: Mapped[bool] = mapped_column(Boolean, default=False)
discovered_by: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String
from sqlalchemy import DateTime, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
@@ -14,13 +14,12 @@ class User(Base):
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# NOTE: owned by Workstream B in the character-depth-ghost-log spec
# (docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
# Added here so Workstream C (unlocks/items/sigils/drops) can build and
# test against it in isolation; the merge controller reconciles this
# against Workstream B's own edit to this file (which will also add
# `favor: float` per the spec's Contract section).
essence: Mapped[int] = mapped_column(Integer, default=0)
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's
# written (see app.judgment.clamp_favor). Read back as a small bias on
# new entities' trait rolls at mint time (app.judgment.apply_favor_bias).
favor: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -8,6 +8,10 @@ Protocol (client → server):
{"type": "anomaly", "source": SRC, ...} → {"type": "utterance", ...}
{"type": "question", "text": "..."} → reply_start / reply_token* / reply_end
{"type": "passive", "enabled": bool} → ambient wire loop on/off
{"type": "ritual_start"} → (begins a ritual attempt)
{"type": "ritual_step", "step": <int>} → (on the final step) ritual_complete
{"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"}
→ judgment_result
All server → client frames flow through a single sender task so concurrent
producers (ambient loop, reply streaming, TTS callbacks) never interleave on
@@ -26,11 +30,18 @@ from pathlib import Path
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app import judgment
from app.config import settings
from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME
from app.entities import fallback_signature, signature_from_anomalies
from app.inventory import SUMMON_ESSENCE_TRICKLE, credit_essence, roll_item_drop, summon_drop_trigger
from app.inventory import (
RITUAL_SUCCESS_ESSENCE,
SUMMON_ESSENCE_TRICKLE,
credit_essence,
roll_item_drop,
summon_drop_trigger,
)
from app.llm.service import SpiritBusyError, spirit_service
from app.models.auth_session import AuthSession, hash_token
from app.models.contact_session import ContactSession
@@ -89,6 +100,16 @@ class SeanceState:
ambient_task: asyncio.Task | None = None
wire_jitter_history: list[float] = field(default_factory=list)
last_wire_anomaly_at: float = 0.0
# Workstream B (character-depth-ghost-log spec): ritual progress for the
# *current* entity — reset whenever a fresh presence is summoned (see
# _handle_summon) or a new ritual_start arrives.
ritual_steps: int = 0
ritual_completed: bool = False
ritual_success: 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.
tell_rng: random.Random = field(default_factory=random.Random)
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
@@ -133,9 +154,24 @@ def serialize_entity(entity: Entity) -> dict:
"quotes": entity.sample_quotes,
"contact_count": entity.contact_count,
"discovered_at": entity.discovered_at.isoformat(),
# Workstream B: hidden ground truth, kept on the server-side
# SeanceState.entity dict for the ritual/judgment/tell handlers to
# read (state.entity["traits"]) — see `_public_entity` below for why
# this never reaches the wire directly.
"traits": entity.traits,
}
def _public_entity(entity: dict) -> dict:
"""The entity payload actually sent to the client in the `entity`
frame — everything `serialize_entity` produces *except* `traits`.
Hidden traits must never leak outside `ritual_complete` on success (the
contract's decoupling requirement, echoed in
frontend/src/lib/evilMeter.ts's comments); `frontend/src/lib/types.ts`'s
`SpiritEntity` type correspondingly has no `traits` field."""
return {key: value for key, value in entity.items() if key != "traits"}
def _client_ip(websocket: WebSocket) -> str:
host = websocket.client.host if websocket.client else None
return resolve_client_ip(websocket.headers, host)
@@ -237,28 +273,50 @@ async def _unique_entity_name(db, base_name: str) -> str:
async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
"""Match this session's signature against the Codex, or mint a new entity."""
"""Match this session's signature against the Codex, or mint a new entity.
An at-peace entity (Workstream B: a spirit correctly helped to cross
over) is excluded from the match — it stays in the Codex forever but
can't be re-contacted. If its signature is what this session's anomaly
pattern hashes to, a *new* entity is minted instead. `Entity.signature`
is unique, so the new entity can't reuse the exact same string while the
retired row still holds it — it gets a salted variant of the same base
signature instead.
"""
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
str(state.session_id)
)
async with session_maker() as db:
entity = await db.scalar(select(Entity).where(Entity.signature == signature))
entity = await db.scalar(
select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False))
)
is_new = entity is None
if is_new:
mint_signature = signature
retired = await db.scalar(select(Entity).where(Entity.signature == signature))
if retired is not None:
mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}"
profile = await spirit_service.mint_profile(
signature, channel, state.anomalies, state.language
mint_signature, channel, state.anomalies, state.language
)
discoverer = await db.get(User, state.user_id)
favor = discoverer.favor if discoverer is not None else 0.0
entity = Entity(
name=await _unique_entity_name(db, profile["name"]),
epithet=profile["epithet"],
persona=profile["persona"],
rarity_tier=profile["rarity"],
signature=signature,
signature=mint_signature,
voice_profile=profile["voice"],
visual_profile=profile["visual"],
sample_quotes=profile["quotes"],
# Workstream B: signature-seeded traits, nudged by the
# discovering user's favor (app.judgment.apply_favor_bias) —
# never derived from/fed into the persona above.
traits=judgment.apply_favor_bias(profile["traits"], favor),
discovered_by=state.user_id,
contact_count=1,
)
@@ -286,11 +344,11 @@ async def _reward_summon(state: SeanceState) -> None:
summon (any mode), and — only when the summoned entity is high-rarity —
a roll for an item drop. The other two contract trigger points ("after a
correct judgment, a successful ritual") belong to Workstream B's
ritual/judgment WS handlers, which don't exist in this codebase yet;
`app.inventory` exposes the same `roll_item_drop`/`credit_essence`
helpers (plus the milestone essence constants) for those handlers to
call once they land, so the drop table and essence economy stay in one
place instead of being duplicated."""
ritual/judgment WS handlers (`_reward_ritual_success` / `_handle_judgment`
below), which call the same `app.inventory` `roll_item_drop`/
`credit_essence` helpers and milestone essence constants so the drop
table and essence economy stay in one place instead of being
duplicated."""
assert state.entity is not None
rarity = state.entity.get("rarity", "common")
@@ -338,14 +396,39 @@ async def _handle_summon(state: SeanceState) -> None:
await state.send_queue.put({"type": "status", "state": "summoning"})
entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
state.entity = serialize_entity(entity)
# A fresh presence invalidates any in-progress/completed ritual from
# whatever was previously in this slot (mirrors the frontend reducer's
# 'entity' case in state/seance.tsx, which resets its own ritual/
# judgment UI state the same way).
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
await state.send_queue.put(
{"type": "entity", "entity": state.entity, "is_new": is_new}
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
await _reward_summon(state)
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
await _speak(state, "greeting", greeting)
# Workstream B: `tell` frames piggyback on the existing anomaly/reply
# handling rather than running their own timer — a fragment (ambient,
# frequent) rolls a lower chance than a direct reply (deliberate, a seeker
# just asked something), so tells feel like they're punctuating engagement
# rather than firing on a fixed clock.
TELL_CHANCE_ON_FRAGMENT = 0.2
TELL_CHANCE_ON_REPLY = 0.35
async def _maybe_tell(state: SeanceState, chance: float) -> None:
if state.entity is None:
return
if state.tell_rng.random() >= chance:
return
text = judgment.generate_tell(state.entity.get("traits", {}), state.tell_rng)
await state.send_queue.put({"type": "tell", "text": text})
async def _handle_anomaly(state: SeanceState, message: dict) -> None:
anomaly = {
"source": str(message.get("source", "unknown"))[:16],
@@ -378,6 +461,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None:
except SpiritBusyError:
return
await _speak(state, "fragment", fragment)
await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT)
async def _handle_question(state: SeanceState, text: str) -> None:
@@ -434,6 +518,7 @@ async def _handle_question(state: SeanceState, text: str) -> None:
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
if reply:
await _speak(state, "reply", reply, instability=1 - stability)
await _maybe_tell(state, TELL_CHANCE_ON_REPLY)
async def _ambient_loop(state: SeanceState) -> None:
@@ -485,6 +570,133 @@ async def _handle_passive(state: SeanceState, enabled: bool) -> None:
await state.send_queue.put({"type": "passive", "enabled": False})
# --- Workstream B: ritual + judgment (character-depth-ghost-log spec) ------
# How many `ritual_step` frames complete one attempt — matches
# frontend/src/lib/ritual.ts's RITUAL_TOTAL_STEPS (the 4-rune "align /
# breathe / trace / lock" sequence). The frontend owns the exact step count
# per the spec ("implementer's call"); this just has to agree with it.
RITUAL_STEPS_REQUIRED = 4
async def _handle_ritual_start(state: SeanceState) -> None:
if state.entity is None:
return # no presence to focus on — frontend already gates the button
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
async def _reward_ritual_success(state: SeanceState) -> None:
"""Mirrors `_reward_summon`'s essence-credit + item-drop pattern for the
ritual milestone trigger point."""
item = None
async with session_maker() as db:
user = await db.get(User, state.user_id)
if user is None:
return
credit_essence(user, RITUAL_SUCCESS_ESSENCE)
item = roll_item_drop("ritual")
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()
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
if state.entity is None or state.ritual_completed:
return
if not isinstance(message.get("step"), int):
return
state.ritual_steps += 1
if state.ritual_steps < RITUAL_STEPS_REQUIRED:
return
traits = state.entity.get("traits", {})
success = judgment.roll_ritual_success(traits)
state.ritual_completed = True
state.ritual_success = success
revealed = dict(traits) if success else None
await state.send_queue.put(
{"type": "ritual_complete", "success": success, "revealed": revealed}
)
if success:
await _reward_ritual_success(state)
async def _handle_judgment(state: SeanceState, message: dict) -> None:
if state.entity is None:
return
verdict = message.get("verdict")
if verdict not in judgment.VERDICTS:
return
traits = state.entity.get("traits", {})
outcome = judgment.judge_verdict(
verdict,
traits,
ritual_completed=state.ritual_completed,
ritual_success=state.ritual_success,
)
item = None
# Skip the DB round-trip entirely when there's nothing to persist (e.g.
# `test` without a completed ritual, or a resisted cross_over) — the
# contract's "no crash, just no effect" for those cases.
if outcome.favor_delta or outcome.essence_delta or outcome.consequence in (
"reward",
"crossed_over",
):
async with session_maker() as db:
user = await db.get(User, state.user_id)
if user is not None:
if outcome.favor_delta:
user.favor = judgment.clamp_favor(user.favor + outcome.favor_delta)
if outcome.essence_delta:
credit_essence(user, outcome.essence_delta)
if outcome.consequence == "crossed_over":
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
if entity_row is not None:
entity_row.at_peace = True
if outcome.consequence in ("reward", "crossed_over"):
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(
{
"type": "judgment_result",
"correct": outcome.correct,
"favor_delta": outcome.favor_delta,
"essence_delta": outcome.essence_delta,
"at_peace": outcome.at_peace,
"consequence": outcome.consequence,
}
)
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
@router.websocket("/ws/session")
async def session_socket(websocket: WebSocket) -> None:
user_id = await _authenticate(websocket)
@@ -539,6 +751,12 @@ async def session_socket(websocket: WebSocket) -> None:
await _handle_question(state, message["text"])
elif msg_type == "passive":
await _handle_passive(state, bool(message.get("enabled")))
elif msg_type == "ritual_start":
await _handle_ritual_start(state)
elif msg_type == "ritual_step":
await _handle_ritual_step(state, message)
elif msg_type == "judgment":
await _handle_judgment(state, message)
except WebSocketDisconnect:
pass
finally: