Renaming: "Armory" read too militaristic for a séance app. Landed on "The Reliquary" (not "The Threshold" — that name was already taken by the landing-page back-link). Spec addendum: added a visible essence currency (earned per summon, spent on unlocks — distinct from the hidden favor score) and a fourth judgment verdict, cross_over, for compassionately helping a genuinely benevolent "stuck" spirit move on rather than just trusting or banishing it. Updates workstreams B/C/E/F accordingly before any of them are dispatched.
255 lines
14 KiB
Markdown
255 lines
14 KiB
Markdown
# 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 users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
|
||
))
|
||
await conn.execute(text(
|
||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
||
))
|
||
await conn.execute(text(
|
||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
||
))
|
||
```
|
||
|
||
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.
|
||
|
||
**`User.essence: int`** (new column, default `0`) — a visible, spendable
|
||
currency, distinct from the hidden `favor` score. Earned in small amounts
|
||
just for summoning (every successful summon, any mode) and larger amounts
|
||
for meaningful actions (ritual success, a correct judgment, crossing a
|
||
spirit over — see below). This is the currency Workstream C's unlocks/items
|
||
are priced in — earning it should feel constant and easy ("the more you
|
||
summon"), spending it on unlocks/tools is the sink. Exact amounts are the
|
||
implementer's call; keep summon income small (single digits) and
|
||
milestone income larger (tens), so unlocks feel reachable within a handful
|
||
of sessions, not grindy.
|
||
|
||
**`Entity.at_peace: bool`** (new column, default `false`) — set when a
|
||
seeker successfully helps a genuinely benevolent, "stuck" spirit cross
|
||
over (see the `cross_over` verdict below). An at-peace entity stays in the
|
||
Codex permanently (memorialized, never deleted) but can no longer be
|
||
re-contacted — `signature_from_anomalies` matching it should mint a new
|
||
entity instead of re-summoning the retired one. The Codex API/UI marking
|
||
it distinctly is a later concern (Workstream D can add a simple "at peace"
|
||
badge if time allows, but it's not required for this spec to be complete).
|
||
|
||
**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" |
|
||
"cross_over"}` — always allowed (a seeker can judge blind without
|
||
completing a ritual — the ritual doesn't gate eligibility, only whether
|
||
accurate info was shown first). `cross_over` is the compassionate
|
||
resolution: helping a spirit move on rather than simply continuing
|
||
contact (`trust`) or expelling it (`banish`). It's the *correct* call
|
||
specifically for a genuinely benevolent entity that reads as "stuck"
|
||
(implementer's call on the exact signal — a reasonable rule: alignment
|
||
>= 0.5 and volatility above some threshold, since the existing fallback
|
||
personas already lean on "died with something unfinished" themes). Used
|
||
on a demon, it fails outright — demons resist crossing over — with no
|
||
reward and no `at_peace` change, distinct from a wrong `trust`/`banish`
|
||
call (it's a naive read, not a reckless one, so no favor penalty either).
|
||
|
||
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,
|
||
"essence_delta": int, "at_peace": bool,
|
||
"consequence": "reward" | "escalation" | "withdrawal" | "crossed_over" |
|
||
"resisted" | "neutral"}` — `correct` = trust called on real alignment
|
||
>= 0.5, or banish called on alignment < 0.5, or cross_over called
|
||
correctly per the rule above. `test` verdict always returns
|
||
`consequence: "neutral"`, both deltas `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). `cross_over` on a demon returns
|
||
`consequence: "resisted"`, both deltas `0`. `cross_over` correctly called
|
||
returns `consequence: "crossed_over"`, `at_peace: true`, and the largest
|
||
essence reward of any outcome. 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 favor deltas
|
||
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) and an `essence: int` field (current spendable balance)
|
||
so the frontend knows what's enabled and affordable.
|
||
|
||
**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 + cross-over
|
||
|
||
New `backend/app/judgment.py` (pure functions: ritual success roll,
|
||
judgment-correctness + favor-delta + essence-delta + consequence selection
|
||
for all four verdicts including `cross_over`'s "stuck spirit" rule and its
|
||
demon-resists-crossing-over failure case) plus the WS handlers in
|
||
`backend/app/ws.py` for `ritual_start`/`ritual_step`/`judgment`, and the
|
||
`User.favor`/`User.essence`/`Entity.at_peace` columns + migration lines.
|
||
Reads `state.entity["traits"]` (entity dict shape — check how
|
||
`serialize_entity` exposes it and add `traits` there). On correct
|
||
`cross_over`, set `Entity.at_peace = true` and ensure
|
||
`signature_from_anomalies` re-contact no longer matches an at-peace entity
|
||
(mint a fresh one instead — check `entities.py`'s matching path). 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/cross_over × real
|
||
spirit/demon/stuck spirit), favor clamping, essence crediting per outcome,
|
||
at_peace persistence + re-contact behavior, favor read-back bias on trait
|
||
rolls (small, verifiable effect size).
|
||
|
||
## Workstream C (backend) — unlocks, items, sigils, drops, essence
|
||
|
||
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, 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), and a new `POST /api/inventory/unlocks/{unlock_key}`
|
||
that spends `essence` to buy a named unlock (define a small fixed price
|
||
table, e.g. `{"listening_tool": 40}` — reject with 402/insufficient-funds
|
||
style error if the user's `essence` is below the price, otherwise deduct
|
||
and insert into `unlocks`). Extend `GET /auth/me` with `unlocks: list[str]`
|
||
and `essence: int`. Wire `item_drop` emission and `essence` crediting
|
||
(summon-time trickle + judgment/ritual/cross-over milestones) into
|
||
`backend/app/ws.py` per the contract's trigger points and amounts. Tests:
|
||
drop-roll probability sanity, inventory/sigil CRUD, essence purchase
|
||
success/insufficient-funds paths, `/auth/me` unlock list + essence 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/Cross Over/Test buttons — four verdicts per the contract)
|
||
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). On `consequence: "crossed_over"`, a distinct
|
||
calm/peaceful visual beat (your call — e.g. the entity's glyph fading out
|
||
gently) rather than reusing the trust "reward" treatment, since it's a
|
||
farewell, not a continuation. Tests: ritual step sequencing, judgment
|
||
button → frame shape (all four verdicts), 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, show current `essence` balance,
|
||
and a "buy" button per locked unlock that calls Workstream C's purchase
|
||
endpoint — disable/gray out unlocks the user can't yet afford) 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,
|
||
purchase button afford/can't-afford states.
|
||
|
||
## 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.
|