docs(spec): NPC addressing design (runtime cycle)
Lock the NPC identifier: full namespaced id (npc.mera-fenn) is canonical everywhere — stored data, client dict key, and the /npc/speak wire. The API load_npc changes from a filename lookup to an id-indexed load (matching the client's ContentDB), reads persona from content/server, and resolves the client's reveal-set fact_ids to topic bodies. Legacy Fenn keeps working; no migration required. First task of the runtime-consumption cycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
This commit is contained in:
148
docs/superpowers/specs/2026-07-12-npc-addressing-design.md
Normal file
148
docs/superpowers/specs/2026-07-12-npc-addressing-design.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# NPC Addressing — Design
|
||||
|
||||
- **Date:** 2026-07-12
|
||||
- **Status:** Design approved (in conversation); not yet planned/implemented. The
|
||||
first task of the **runtime-consumption cycle**.
|
||||
- **Consumes:** the content build tool + role split from
|
||||
[`2026-07-11-content-canon-build-tool-design.md`](2026-07-11-content-canon-build-tool-design.md)
|
||||
and the locked schema
|
||||
[`2026-07-11-content-canon-schema-design.md`](2026-07-11-content-canon-schema-design.md).
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The build tool now emits namespaced NPC ids (`npc.mera-fenn`) but the API still
|
||||
addresses NPCs by **filename**. Lock how an NPC is identified across the client,
|
||||
the wire, and the server so the runtime can voice the new built content. This is
|
||||
a **decision record + concrete change list**, not a full plan.
|
||||
|
||||
**Charter alignment:** §2 (code owns state — this is all identifier plumbing, no
|
||||
AI involvement), §4 (the API reads server-only personas), §6 (bounded NPC
|
||||
dialogue), §16 (`/content` layout).
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
One NPC currently answers to **three** identifiers, and they only coincide for
|
||||
legacy content:
|
||||
|
||||
| Identifier | Value | Used by |
|
||||
|---|---|---|
|
||||
| Canonical id | `npc.mera-fenn` | the `id` field; the client `ContentDB` dict key; every `related` / `knows` / origin cross-reference |
|
||||
| Slug / filename | `mera-fenn` | the file path; the API `load_npc(npc_id)` filename lookup (`{npc_id}.json`) |
|
||||
| Legacy bare id | `fenn` | old hand-authored content, where id == slug == filename collapse to one |
|
||||
|
||||
It works **today** only because legacy Fenn's three names are identical. It breaks
|
||||
the moment the runtime talks to built content: the client holds `npc.mera-fenn`,
|
||||
sends it to `/npc/speak`, and `load_npc` looks for `content/…/npcs/npc.mera-fenn.json`
|
||||
— which does not exist (the file is `mera-fenn.json`).
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### D1 — The full namespaced `id` is the one true identifier
|
||||
|
||||
`npc.mera-fenn` is canonical **everywhere**: in stored data, as the client dict
|
||||
key, and **on the wire** to `/npc/speak`. The slug is demoted to an incidental
|
||||
filename. Everything cross-references by the full id already (`related`, `knows`,
|
||||
origin `disposition_overrides`); the wire and the API are brought into line with
|
||||
that, not the reverse.
|
||||
|
||||
**Rejected alternatives:**
|
||||
|
||||
- **Address by bare slug at runtime** (strip the namespace) — resurrects the
|
||||
slug-collision hazard (`npc.mera-fenn` and a hypothetical `person.mera-fenn`
|
||||
both slug to `mera-fenn`) and destroys the cross-type disambiguation the
|
||||
namespace exists to provide. The namespace is load-bearing; keep it.
|
||||
- **Rename files to the full id** (`npcs/npc.mera-fenn.json`, so filename == id
|
||||
and no index is needed) — stutters the type in both the directory and the
|
||||
prefix, puts dots in every filename, and its "no index" saving is illusory
|
||||
because both sides load-all anyway. Readable slug filenames plus "the `id`
|
||||
field is truth, the filename is incidental" is the more robust principle:
|
||||
files can be reorganized without breaking a single reference.
|
||||
|
||||
### D2 — The API resolves NPCs by internal `id`, matching the client
|
||||
|
||||
`api/app/content.py::load_npc` changes from a **filename** lookup to an
|
||||
**id-indexed** load — exactly what the client `ContentDB` already does
|
||||
(`{data["id"]: data}`, keyed by the `id` field, never the filename). This makes
|
||||
the API consistent with the client's established pattern rather than inventing
|
||||
new machinery; the API already load-alls in `load_world`.
|
||||
|
||||
### D3 — Persona + knowledge come from `content/server/`, not the world tree
|
||||
|
||||
The build moved NPC personas to `content/server/npcs/<slug>.json` and knowledge
|
||||
bodies to `content/server/topics/<slug>.json`. `load_npc` reads persona from the
|
||||
server tree; the knowledge list fed to the `/npc/speak` prompt is assembled from
|
||||
the topic **bodies** the NPC may currently reveal (see D4). The old crammed
|
||||
`content/world/npcs/fenn.json` (persona + knowledge + capabilities in one file)
|
||||
is superseded — see Legacy below.
|
||||
|
||||
### D4 — The client sends the reveal set; the API resolves ids → bodies
|
||||
|
||||
The client already computes `available_moves` (the `reveal(fact_id)` moves legal
|
||||
at the current disposition — §6 gate logic over the npc skeleton's
|
||||
`knows: [{fact_id, gate}]`). The wire request carries the NPC id + those fact
|
||||
ids; the API resolves each `fact_id` to its body from `content/server/topics/`
|
||||
(by id, per D2) and feeds those bodies to the prompt as the knowledge list. The
|
||||
API never decides what an NPC may reveal — the client's gate computation does;
|
||||
the API only voices the resolved bodies. (Code owns state; AI owns text.)
|
||||
|
||||
---
|
||||
|
||||
## Concrete changes (the runtime cycle's work)
|
||||
|
||||
1. **`api/app/content.py::load_npc(npc_id)`** — build an id-indexed map of NPC
|
||||
records (persona from `content/server/npcs/`, joined with the world-skeleton
|
||||
`knows`/`start_disposition` where needed), and return `by_id.get(npc_id)`.
|
||||
Cache the index like `_content_root()` callers do. Resolve the requested id
|
||||
whether it is `npc.mera-fenn` (built) or `fenn` (legacy).
|
||||
2. **Knowledge assembly** — add a resolver that turns a list of `fact_id`s (the
|
||||
client's reveal set) into their bodies from `content/server/topics/` (indexed
|
||||
by id). Feed that into `prompts.render_npc_digest(... knowledge=…)` in
|
||||
`api/app/npc.py`, replacing today's flat `npc.get("knowledge", [])`.
|
||||
3. **Wire contract** — the `/npc/speak` request identifies the NPC by full
|
||||
namespaced id and carries the reveal-set fact ids. Update the client NPC
|
||||
service to send the full id (it sends a bare id today).
|
||||
4. **Client `ContentDB`** — no change to keying (already id-keyed); confirm the
|
||||
NPC-selection path hands the full id to the NPC service.
|
||||
5. **Mirror in the client** — the client already keys by full id; ensure any
|
||||
origin `disposition_overrides` / references use the namespaced id for built
|
||||
NPCs.
|
||||
|
||||
---
|
||||
|
||||
## Legacy Fenn
|
||||
|
||||
`content/world/npcs/fenn.json` (bare id `fenn`, old crammed shape) keeps working
|
||||
through the transition — `load_npc("fenn")` resolves it by its own id. When Fenn
|
||||
is eventually authored as a bible he becomes `npc.fenn`, and the old file is
|
||||
retired by the build — a one-line content change once addressing is id-based, not
|
||||
a migration. This spec does not require migrating Fenn.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The rest of runtime consumption — the client's dialogue UI, applying validated
|
||||
moves, the disposition-band gate wiring end to end (this spec is only the
|
||||
**identifier + persona/knowledge resolution** substrate they sit on).
|
||||
- Godot export packaging (include `world/`, exclude `server/`) — still deferred.
|
||||
- Any change to the build tool or the locked schema.
|
||||
|
||||
---
|
||||
|
||||
## Open
|
||||
|
||||
- **Knowledge-assembly caching** — the topic-body index can be built once at API
|
||||
startup (personas + topics are static authored content); pick the cache seam at
|
||||
implementation, consistent with the existing content-root resolution.
|
||||
- **`capabilities` block** — the old `fenn.json` carried an
|
||||
`offerable_quests`/`giveable_items`/`revealable_topics` capabilities block that
|
||||
the built skeleton does not reproduce. Decide during the cycle whether the
|
||||
runtime derives capabilities from the skeleton's `knows` + related quest/item
|
||||
content, or whether the schema needs a capabilities field. Tracked, not decided
|
||||
here.
|
||||
Reference in New Issue
Block a user