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.
This commit is contained in:
Indiana
2026-07-23 10:41:58 +00:00
parent 58c30b7273
commit 291d75c70c

View File

@@ -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": <int>}` — 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.