Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-12-npc-addressing-design.md
Phillip Tarrant a3506f7f02 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
2026-07-12 01:58:30 -05:00

7.1 KiB

NPC Addressing — Design


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_ids (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.