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.
This commit is contained in:
Indiana
2026-07-23 11:06:07 +00:00
parent 291d75c70c
commit 6d8c6f2496
8 changed files with 137 additions and 76 deletions

View File

@@ -22,9 +22,15 @@ async with engine.begin() as conn:
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 —
@@ -53,6 +59,26 @@ 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`):
@@ -60,10 +86,19 @@ 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).
- `{"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":
@@ -79,23 +114,29 @@ Server → client:
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).
"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) so the frontend knows what's enabled — e.g. `"listening_tool"`.
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/`):
@@ -114,36 +155,49 @@ 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
## Workstream B (backend) — ritual + judgment + favor + cross-over
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).
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
## 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 and to save a
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). 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.
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
@@ -164,18 +218,24 @@ override, idle-state rendering.
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).
(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) and
`/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
@@ -184,7 +244,8 @@ and overlays a rune choice from a fixed small set. Saves via Workstream C'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.
selection, listening-tool threshold change when unlock present vs. absent,
purchase button afford/can't-afford states.
## Explicitly out of scope here