# 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/.json` and knowledge bodies to `content/server/topics/.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.