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
7.1 KiB
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.mdand the locked schema2026-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-fennand a hypotheticalperson.mera-fennboth slug tomera-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 "theidfield 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)
api/app/content.py::load_npc(npc_id)— build an id-indexed map of NPC records (persona fromcontent/server/npcs/, joined with the world-skeletonknows/start_dispositionwhere needed), and returnby_id.get(npc_id). Cache the index like_content_root()callers do. Resolve the requested id whether it isnpc.mera-fenn(built) orfenn(legacy).- Knowledge assembly — add a resolver that turns a list of
fact_ids (the client's reveal set) into their bodies fromcontent/server/topics/(indexed by id). Feed that intoprompts.render_npc_digest(... knowledge=…)inapi/app/npc.py, replacing today's flatnpc.get("knowledge", []). - Wire contract — the
/npc/speakrequest 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). - Client
ContentDB— no change to keying (already id-keyed); confirm the NPC-selection path hands the full id to the NPC service. - 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/, excludeserver/) — 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.
capabilitiesblock — the oldfenn.jsoncarried anofferable_quests/giveable_items/revealable_topicscapabilities block that the built skeleton does not reproduce. Decide during the cycle whether the runtime derives capabilities from the skeleton'sknows+ related quest/item content, or whether the schema needs a capabilities field. Tracked, not decided here.