Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-10-bounded-npc-conversation-design.md
Phillip Tarrant 097ac618f4 docs(spec): bounded NPC conversation (/npc/speak) design
M2 core experiment. Server owns persona+knowledge (spoilers/IP, no DB);
client owns live state and computes available_moves. Free text straight
to the NPC prompt (no Adjudicator). Sibling NpcService + pure MoveValidator;
moves validated against state, invalid dropped, prose always kept (§6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:36:44 -05:00

14 KiB
Raw Blame History

Bounded NPC conversation (M2) — design

Date: 2026-07-10 Milestone: M2 — prove aliveness (roadmap). This is the second M2 item, and the surface §17 is really asking about. Charter side (§2): both — the NPC's voice is text (AI owns it); the moves it performs are state (code validates and applies them). Two systems sharing one HTTP call.

Goal

Put a bounded NPC on screen. The player types free text to a specific NPC; the NPC answers in its own voice and may emit zero or more inline move tags. Code extracts the moves, validates each against real game state, applies the valid ones, silently drops the invalid ones, and always keeps the prose (§6).

utterance + canon_log + npc_id + disposition + available_moves
   → HTTP → /npc/speak → prose(+move tags) → screen
   → extract → validate → apply valid moves → drop invalid → harvest [FACT]

This is the core POC experiment: does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume? Everything here exists to make that test possible with one real NPC.

What already exists (and is proven)

  • Server pipeline (M1, merged): narrate.py is the template — render a digest, route the model (routing.for_role), call Ollama with one retry, log the call (§10), return raw prose with tags intact. /dm/narrate returns {"prose": …} / 502 {"detail":{"model_error":…}} / 422 {"detail":{"canon_log_errors":…}}. content.py already reads /content/world/npcs/*.json (ids only, today). prompts.system_prompt(role) loads api/prompts/<role>.md below its --- front-matter delimiter.
  • Client loop (M2, merged): DmService posts the canon log through an injectable DmTransport, parses status, degrades to an authored fallback, and returns a result the caller applies (state writes stay explicit, §2). TagExtractor.extract(prose) already returns {clean_text, facts, dispositions, moves}moves are already parsed; the new work is the validate-and-apply layer, not the parse.
  • Client state homes: GameState (client/scripts/state/game_state.gd) is the sole home of npc_dispositions (world-NPC id → int) and inventory (item_id → qty). Town NPCs are not canon-log party rows — companions are; town NPCs live only in GameState. CanonLog owns active_quests and its mutators (set_quest_status, add_fact).
  • Content: /content/world/npcs/{cadwyn_vell,brannoc_thane}.json (companions, empty knowledge). /content/world/quests/find_the_ledger.json exists. The POC town NPC Fenn — who owns that quest — does not exist yet.

Decisions

Settled during brainstorming; each is load-bearing.

  1. Server owns the secret text; client owns all live state. The proxy stays stateless for game state — no DB. It loads static authored material (prompts, persona, knowledge lists) into memory: zero per-player cost, and the spoilers never ship in the client binary. Every piece of dynamic state (saves, inventory, disposition, luck, quest progress) lives on the client (§4). The only DB this project ever grows is auth + a credits ledger, later, and it is not game state. This matches the intended pay-per-AI-credit model and the self-host-your-own-Ollama option: a self-hoster runs the client and points the proxy at their model — no server state to run.

  2. Trio split by sensitivity (fork 1). The §6 trio is assembled across the boundary:

    • Server-side (spoilers/IP): persona + knowledge[], loaded from /content/world/npcs/<id>.json. The client never sends or sees these.
    • Client-side (live legality): available_moves[] is computed by the client from the NPC's non-spoiler capability block plus live game state, and sent in the request. Legality depends on state the client owns (§2), so the client must compute it.
  3. Disposition is sent as an integer. The request carries this NPC's current disposition (GameState.npc_dispositions[npc_id], default 0) as an int. §6 explicitly gives the NPC prompt "disposition (integer, 100..100)". This does not violate §7 — §7 hides Luck, not disposition; the narrate digest's number-hiding is a Narrator concern, not an NPC one. /npc/speak therefore has its own request contract, not the bare {canon_log} of narrate.

  4. Free text goes straight to the NPC prompt — no Adjudicator. The moves are the NPC's, not the player's, so nothing needs to be mapped onto a legal player action. The player's raw utterance is passed to the prompt as context. This is what lets the aliveness experiment run now, before M3's Adjudicator exists — and a menu would blunt the exact free-form quality under test (§15's "free text → Adjudicator" is about player game-actions).

  5. One canonical tag form. All eight moves use [MOVE: name(args)]. [ADJUST_DISPOSITION: n] stays an accepted alias (already wired in TagExtractor). §6's bare [REFUSE] shorthand is dropped — the extractor never parsed it. The prompt instructs the model to use [MOVE: …] only.

  6. Client architecture: sibling service + pure validator. A new NpcService parallels DmService, reusing DmTransport, FallbackLibrary, and TagExtractor. Move logic lives in a pure MoveValidator ((extracted_moves, available_moves, state_view) → {valid, dropped}) with no side effects, plus a small MoveApplier the caller invokes to write state. Application stays explicit at the call site, mirroring how narrate() returns facts for the caller to apply (§2). Two small pure units beat one branchy service.

  7. Authoring scope: mechanism + one live NPC. Build the full machinery and author exactly one town NPC end-to-end — Fenn, who already owns find_the_ledger. The second town NPC's knowledge list is authoring work (§6: "real authoring work") that follows once the mechanism is proven. One live NPC answers the aliveness question.

  8. Throwaway NPC harness scene. Like the narrate harness: a RichTextLabel, a LineEdit for the utterance, a "Speak" button, and a small readout of applied/dropped moves + current disposition. Enough to drive a multi-turn conversation until end_conversation. Real dialogue UI is a later wireframe; do not gold-plate.

The NPC content file

/content/world/npcs/fenn.json — one file, three sections split by who reads them:

{
  "id": "fenn",
  "name": "Fenn",
  "role": "townsfolk",
  "persona": "Harried dockside clerk; talks in ledgers and grievances. Server-only.",
  "knowledge": [
    "His ledger went missing two nights ago.",
    "He last saw it when the Varrell twins were drinking in the counting-room.",
    "He owes money he cannot cover if the ledger surfaces in the wrong hands."
  ],
  "capabilities": {
    "offerable_quests": ["find_the_ledger"],
    "giveable_items": [],
    "revealable_topics": ["varrell_twins", "fenns_debt"]
  }
}
  • Server reads persona + knowledge. These are the spoilers; they are sent to the model, never to or from the client.
  • Client reads capabilities to compute available_moves. Non-spoiler handles only — ids, not content.
  • Deferred hardening (not this piece): for the shipped binary, physically split persona/knowledge into a server-only file so they cannot be datamined from the client bundle. Today the client simply ignores those keys. Captured as a deferred minor; §4 already puts us server-authoritative for the prose, so this is a bundling nicety, not a correctness gap.

Contracts

Request — POST /npc/speak

{
  "canon_log":       { "...schema-v1 canon log..." },
  "npc_id":          "fenn",
  "disposition":     0,
  "available_moves": ["offer_quest(find_the_ledger)", "reveal(varrell_twins)",
                      "reveal(fenns_debt)", "refuse", "end_conversation",
                      "become_hostile", "adjust_disposition"],
  "utterance":       "I heard you lost something."
}

available_moves entries are concrete signatures where the move takes an id (offer_quest(find_the_ledger)), and bare names for the universal moves (refuse, end_conversation, become_hostile, adjust_disposition, accept_item). The server injects this list verbatim into the prompt so the model knows exactly what it may do; the client re-validates every emitted move against it anyway (belt and suspenders — the model can still hallucinate).

Responses

  • 200{"prose": "<raw prose with [MOVE:]/[FACT:] tags>"} — same shape as narrate; the client extracts.
  • 502{"detail": {"model_error": "<str>"}}.
  • 422{"detail": {"canon_log_errors": [ … ]}} — invalid canon log, or an npc_id that resolves to no content file, or a malformed request body (reuses the unified 422 envelope).

Client: available_moves computation and the move → state map

available_moves starts from the NPC's capabilities and is filtered by live state:

Move Offered when Applied to
offer_quest(q) q ∈ offerable_quests and q not already active/complete in canon_log.active_quests append active quest to canon_log.active_quests
reveal(t) t ∈ revealable_topics and not already revealed mark revealed (GameState) + CanonLog.add_fact
give_item(i) i ∈ giveable_items and not already given GameState.add_item(i, 1)
accept_item(i) player holds i in GameState.inventory decrement inventory
adjust_disposition(delta) always GameState.set_npc_disposition, delta clamped to ±15 so one line can't swing standing wholesale
refuse always no state change (prose-only signal)
end_conversation always flow control — harness ends the loop
become_hostile always disposition → hostile floor + end conversation

Validation for an emitted move = name ∈ available_moves and the per-move precondition still holds against live state. Fail either → drop it, log it, keep the prose. adjust_disposition, end_conversation, refuse, and become_hostile carry no authored capability and no state precondition, so they are always in available_moves. accept_item also carries no authored capability, but is offered only while the player's inventory is non-empty (the NPC can only take what the player has).

Disposition target is GameState.npc_dispositionsnot CanonLog's party-only adjust_disposition, which only finds companion rows. This is the subtle correctness point: town NPCs have no canon-log row.

Components

Server (mirrors narrate.py):

  • api/app/npc.pyrun(req) -> str: load persona+knowledge via content.py, render an NPC digest (canon-log context + persona + disposition + knowledge[] + available_moves[] + the player's utterance), route for_role("npc"), call Ollama with one retry, log (§10), return raw prose.
  • api/app/content.py — add load_npc(npc_id) -> dict | None returning persona/knowledge; None when unknown.
  • api/app/main.py — replace the /npc/speak stub: a new NpcSpeakRequest pydantic model (still runs the shared canon-log validator), 422 on unknown npc_id, 502 on ModelError, else {"prose": …}.
  • api/prompts/npc.md — author the prompt body (voice bounded by knowledge only; [MOVE: …] vocabulary; use only moves from available_moves; disposition colours tone; never invent proper nouns without [FACT: …]).

Client (mirrors DmService):

  • client/scripts/net/npc_service.gdNpcService.speak(npc_id, utterance, canon_log, state_view) -> NpcResult. Builds the request, posts through DmTransport, degrades to the NPC fallback on any failure, extracts tags, runs MoveValidator, returns the result. Applies no state itself.
  • client/scripts/npc/move_validator.gdpure: (extracted_moves, available_moves, state_view) -> {valid, dropped}.
  • client/scripts/npc/move_applier.gd — writes validated moves to GameState/CanonLog; invoked by the caller (harness) after speak.
  • client/scripts/npc/npc_content.gd — loads a capabilities block and computes available_moves from it + live state.
  • client/scripts/net/npc_result.gd{display_text, facts, valid_moves, dropped_moves, degraded}.
  • client/content/fallback/npc.json — one authored in-voice degraded line; no moves applied on degrade (§13).
  • client/scenes/npc_harness.tscn (+ script) — throwaway driver.

Error handling

  • Any non-200, transport failure, or missing proseNpcResult degraded with the authored fallback line and zero moves (a degraded turn never mutates state).
  • Model failure server-side → one retry (pipeline default), then 502; client degrades.
  • The whole point of §6 is that a hallucinated or illegal move is not an error: it is dropped and logged, prose kept.

Testing

  • MoveValidator (pure, GUT): name-not-in-available dropped; precondition failures (quest already active, item not held, unknown id) dropped; delta clamp; valid moves pass. The densest test surface — the heart of §6.
  • NpcService with FakeDmTransport: 200 with mixed valid/invalid move tags → correct split + prose kept; 502/422/transport-fail → degraded, no moves; facts harvested.
  • NpcContent available_moves computation: capability + state → expected list.
  • Server npc.py with a fake Ollama (mirror the narrate tests): digest includes persona/knowledge/disposition/available_moves/utterance; prose returned raw; unknown npc_id → 422.
  • Gated live smoke: real model as Fenn — grounded answer, a legal move landing, an illegal move (if emitted) dropped, no leaked spoilers beyond knowledge.

Scope

In: /npc/speak server role; content.load_npc; npc.md prompt; Fenn's content file; client NpcService + MoveValidator + MoveApplier + NpcContent + NpcResult; NPC fallback content; throwaway harness; full test set + gated live smoke.

Out: the second town NPC's knowledge list (mechanism first); real dialogue UI (later wireframe); Adjudicator / free-text player actions (M3); streaming (next M2 item, gated on aliveness); combat/hostility resolution beyond setting the flag; physically splitting server-only content out of the client bundle (deferred hardening).