Files
qtalker---/docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md
Indiana 6d8c6f2496 rename: The Armory -> The Reliquary; extend spec with essence + cross-over
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.
2026-07-23 11:06:07 +00:00

14 KiB
Raw Permalink Blame History

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):

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.