From a3506f7f024fbe59a214707450d0b4bafab6ee31 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 01:58:30 -0500 Subject: [PATCH] docs(spec): NPC addressing design (runtime cycle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- .../specs/2026-07-12-npc-addressing-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-npc-addressing-design.md diff --git a/docs/superpowers/specs/2026-07-12-npc-addressing-design.md b/docs/superpowers/specs/2026-07-12-npc-addressing-design.md new file mode 100644 index 0000000..b551cfd --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-npc-addressing-design.md @@ -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/.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.