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:
2026-07-12 01:58:30 -05:00
parent bbaa59fad4
commit a3506f7f02

View 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.