From 291d75c70c195ced614057a30625495c7b28b261 Mon Sep 17 00:00:00 2001 From: Indiana Date: Thu, 23 Jul 2026 10:41:58 +0000 Subject: [PATCH] Add Character Depth, Ghost Log, Ritual, Judgment, Unlocks design spec Second sub-project of the "make contact feel real" arc. Defines the shared contract (entity traits, favor, WS frames, new tables) that 6 parallel workstreams build against: entity traits + migration, ritual/judgment/favor, unlocks/items/sigils, Ghost Log HUD, ritual+judgment UI, inventory/sigil designer + listening tool. --- ...-07-23-character-depth-ghost-log-design.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md diff --git a/docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md b/docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md new file mode 100644 index 0000000..18af9c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md @@ -0,0 +1,193 @@ +# 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.