# Character Depth, Ghost Log, Ritual, Judgment, Unlocks — design Second sub-project of the "make contact feel real" arc (possession presentation layer shipped first). This is a big one, built as several parallel workstreams against the shared contract below — read your assigned section, but the contract section is binding for everyone since other workstreams build against these exact names without seeing your code. ## No migration framework — read this before touching models This repo has no Alembic; `backend/app/main.py`'s `lifespan` only runs `Base.metadata.create_all`, which creates missing tables but never alters existing ones. This is a **live production app with real user rows already in Postgres** — any new column on an existing table (`users`, `entities`) needs a manual, idempotent migration. Add it in `lifespan`, after `create_all`, as raw SQL using Postgres's `ADD COLUMN IF NOT EXISTS` (safe to run on every startup): ```python async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) 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 traits JSONB NOT NULL DEFAULT '{}'::jsonb" )) ``` New tables (`unlocks`, `inventory_items`, `sigils`) don't need this — `create_all` handles brand-new tables fine. ## Contract (binding field/frame names for all workstreams) **Entity hidden traits** — `Entity.traits: dict` (new JSONB column, default `{}`), four floats each 0.0–1.0: - `alignment` (0=malevolent/demon, 1=benevolent spirit) - `power` (how strong/how hard misjudging it hits) - `volatility` (how noisy/unreliable its behavioral tells are) - `deceptiveness` (how well it fakes being the opposite of what it is) Rolled once at mint time, seeded from the entity's `signature` (same `random.Random(f"traits:{signature}")` determinism convention already used elsewhere in `entities.py`) — **never derived from or sent into the LLM persona prompt**. Persona text must stay decoupled from truth: a high-deceptiveness demon can wear any persona convincingly. Do not add these fields to `mint_prompt()`'s inputs. **`User.favor: float`** (new column, default `0.0`, clamp to `[-1.0, 1.0]` everywhere it's written) — nudged by judgment correctness, read back to mildly bias future summon trait rolls (higher favor → entities trend more legible/less volatile; lower favor → more volatile/deceptive). The read-back bias is a small effect — a `favor`-scaled adjustment to the volatility and deceptiveness rolls in the trait-rolling function, not a hard gate. **New WS frames on `/ws/session`** (extends the existing protocol in `backend/app/ws.py`): Client → server: - `{"type": "ritual_start"}` — begins a ritual attempt for the current entity. - `{"type": "ritual_step", "step": }` — one completed interactive step. - `{"type": "judgment", "verdict": "trust" | "banish" | "test"}` — always allowed (a seeker can judge blind without completing a ritual — the ritual doesn't gate eligibility, only whether accurate info was shown first). Server → client: - `{"type": "ritual_complete", "success": bool, "revealed": {"alignment": float, "power": float, "volatility": float, "deceptiveness": float} | null}` — `revealed` is the entity's true `traits` dict on success, `null` on failure. - `{"type": "tell", "text": str}` — a short auto-generated hint line, emitted periodically during chat (piggyback on existing anomaly/reply handling), derived from `traits` + a per-session RNG draw. Content is flavor text describing *behavior*, never a stat number directly (e.g. "the entity avoided a direct question" for high deceptiveness), so the evil-meter's gradual narrowing (frontend responsibility) has something to work with without the backend leaking ground truth outside of `ritual_complete`. - `{"type": "judgment_result", "correct": bool, "favor_delta": float, "consequence": "reward" | "escalation" | "withdrawal" | "neutral"}` — `correct` = trust called on real alignment >= 0.5, or banish called on alignment < 0.5. `test` verdict always returns `consequence: "neutral"`, `favor_delta: 0.0`, and requires a completed ritual this session to do anything (a `judgment_result` with `correct: false` and `consequence: "neutral"` if attempted without one — no crash, just no effect). Wrong-trust favor penalty should be larger in magnitude than wrong-banish penalty (recklessness costs more than caution) — exact numbers are the implementer's call, keep them small (single-digit percent of the `[-1,1]` range per event). - `{"type": "item_drop", "item": {"item_type": str, "item_key": str, "payload": dict}}` — emitted opportunistically (implementer's call on exact odds) after a correct judgment, a successful ritual, or a high-rarity summon. **`GET /auth/me`** gains an `unlocks: list[str]` field (unlock keys the user has earned) so the frontend knows what's enabled — e.g. `"listening_tool"`. **New tables** (SQLAlchemy models, follow the existing `Entity`/`User` style in `backend/app/models/`): - `unlocks`: `id`, `user_id` FK, `unlock_key: str`, `unlocked_at: datetime`. - `inventory_items`: `id`, `user_id` FK, `item_type: str`, `item_key: str`, `payload: JSONB`, `obtained_at: datetime`. - `sigils`: `id`, `user_id` FK, `name: str`, `design: JSONB` (structured — see Workstream F), `created_at: datetime`. ## Workstream A (backend) — entity traits + migration `backend/app/entities.py`, `backend/app/models/entity.py`, the `lifespan` migration in `backend/app/main.py`. Roll the four `traits` in both `normalize_profile` and `fallback_profile`, signature-seeded, decoupled from persona/LLM input as specified above. Tests: trait ranges, signature determinism, persona/trait independence (same persona template can pair with any alignment). ## Workstream B (backend) — ritual + judgment + favor New `backend/app/judgment.py` (pure functions: ritual success roll, judgment-correctness + favor-delta + consequence selection) plus the WS handlers in `backend/app/ws.py` for `ritual_start`/`ritual_step`/`judgment`, and the `User.favor` column + migration line. Reads `state.entity["traits"]` (entity dict shape — check how `serialize_entity` exposes it and add `traits` there). On wrong-trust, call into the existing haunting-escalation hook if one exists server-side, or note in your report if escalation is frontend-only (check `frontend/src/lib/haunting.ts` first — if escalation state lives purely client-side, your job is just to emit `consequence: "escalation"` correctly and leave the actual effect to Workstream D). Tests: ritual success/fail paths, judgment correctness matrix (trust/banish × real spirit/demon), favor clamping, favor read-back bias on trait rolls (small, verifiable effect size). ## Workstream C (backend) — unlocks, items, sigils, drops New models (`unlocks`, `inventory_items`, `sigils` per the contract), a new `backend/app/routes/inventory.py` (or extend an existing routes file if more consistent with this codebase's conventions — check `backend/app/routes/`) exposing REST endpoints to list a user's unlocks/items/sigils and to save a sigil design (`POST`, validates the `design` JSON shape against whatever Workstream F defines — coordinate via your report if you build before seeing their output; a reasonable placeholder shape is `{"points": [[x,y],...], "rune": str}`, cap `points` length e.g. at 12 to bound payload size). Extend `GET /auth/me` with `unlocks: list[str]`. Wire `item_drop` emission into `backend/app/ws.py` per the contract's trigger points. Tests: drop-roll probability sanity, inventory/sigil CRUD, `/auth/me` unlock list shape. ## Workstream D (frontend) — Ghost Log HUD + evil meter + tells New `frontend/src/components/GhostLog.tsx`, mounted at the app-shell level (check `frontend/src/App.tsx` or wherever the top-level layout lives) so it's visible on every screen, not just the séance page. Hacker-terminal styling consistent with `Transcript.tsx`'s existing visual vocabulary. Idle state (no active entity) shows ambient status; once `state.entity` exists, streams `tell` frames as log lines. Build the evil-meter as a pure function of the accumulated `tell` history (narrows/shifts with more tells, resets/snaps to certainty on `ritual_complete` success) — keep the narrowing math in a testable pure function, not inline in the component. Tests: meter narrowing behavior over a sequence of tells, ritual-complete override, idle-state rendering. ## Workstream E (frontend) — ritual mini-game + judgment UI + consequences New `frontend/src/components/RitualPanel.tsx` — a short interactive sequence (your call on exact interaction, e.g. 3-5 click/hold steps) that sends `ritual_step` frames and handles `ritual_complete`. Judgment UI (Trust/Banish/Test buttons) sending the `judgment` frame and handling `judgment_result` — on `consequence: "escalation"`, read `frontend/src/lib/haunting.ts` first and hook into its existing idle-escalation state if there's a sensible extension point; if not, add a clearly-scoped "forced escalation" mode to it (report which you did). Tests: ritual step sequencing, judgment button → frame shape, escalation wiring (mock haunting.ts's public surface, don't test its internals). ## Workstream F (frontend) — unlocks/inventory UI + sigil designer + listening tool New `frontend/src/components/InventoryPanel.tsx` (list unlocks/items from `/auth/me` and the Workstream C endpoints) and `frontend/src/components/SigilDesigner.tsx` — a **constrained geometric builder**, not freeform drawing: seeker places points around a fixed circle (cap at 12, matching Workstream C's payload limit), the tool connects them and overlays a rune choice from a fixed small set. Saves via Workstream C's `POST` endpoint. Wire the `"listening_tool"` unlock (from `/auth/me`'s `unlocks` list) into `frontend/src/lib/evp.ts`'s detection threshold — when present, lower the anomaly threshold so fainter signals register (a real gameplay effect, not cosmetic). Tests: sigil point-cap enforcement, rune selection, listening-tool threshold change when unlock present vs. absent. ## Explicitly out of scope here Session recording/export, progression-driven GUI evolution, named-target summoning, resource-dedication instrumentation panel, sacred-geometry visual theme pass, onboarding gender question — each a separate later spec.