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>
14 KiB
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.pyis 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/narratereturns{"prose": …}/502 {"detail":{"model_error":…}}/422 {"detail":{"canon_log_errors":…}}.content.pyalready reads/content/world/npcs/*.json(ids only, today).prompts.system_prompt(role)loadsapi/prompts/<role>.mdbelow its---front-matter delimiter. - Client loop (M2, merged):
DmServiceposts the canon log through an injectableDmTransport, 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 ofnpc_dispositions(world-NPC id → int) andinventory(item_id → qty). Town NPCs are not canon-logpartyrows — companions are; town NPCs live only inGameState.CanonLogownsactive_questsand 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.jsonexists. The POC town NPC Fenn — who owns that quest — does not exist yet.
Decisions
Settled during brainstorming; each is load-bearing.
-
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.
-
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.
- Server-side (spoilers/IP):
-
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/speaktherefore has its own request contract, not the bare{canon_log}of narrate. -
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).
-
One canonical tag form. All eight moves use
[MOVE: name(args)].[ADJUST_DISPOSITION: n]stays an accepted alias (already wired inTagExtractor). §6's bare[REFUSE]shorthand is dropped — the extractor never parsed it. The prompt instructs the model to use[MOVE: …]only. -
Client architecture: sibling service + pure validator. A new
NpcServiceparallelsDmService, reusingDmTransport,FallbackLibrary, andTagExtractor. Move logic lives in a pureMoveValidator((extracted_moves, available_moves, state_view) → {valid, dropped}) with no side effects, plus a smallMoveApplierthe caller invokes to write state. Application stays explicit at the call site, mirroring hownarrate()returns facts for the caller to apply (§2). Two small pure units beat one branchy service. -
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. -
Throwaway NPC harness scene. Like the narrate harness: a
RichTextLabel, aLineEditfor the utterance, a "Speak" button, and a small readout of applied/dropped moves + current disposition. Enough to drive a multi-turn conversation untilend_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
capabilitiesto computeavailable_moves. Non-spoiler handles only — ids, not content. - Deferred hardening (not this piece): for the shipped binary, physically
split
persona/knowledgeinto 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 annpc_idthat 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_dispositions — not 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.py—run(req) -> str: load persona+knowledge viacontent.py, render an NPC digest (canon-log context + persona + disposition +knowledge[]+available_moves[]+ the player's utterance), routefor_role("npc"), call Ollama with one retry, log (§10), return raw prose.api/app/content.py— addload_npc(npc_id) -> dict | Nonereturningpersona/knowledge;Nonewhen unknown.api/app/main.py— replace the/npc/speakstub: a newNpcSpeakRequestpydantic model (still runs the shared canon-log validator), 422 on unknownnpc_id,502onModelError, else{"prose": …}.api/prompts/npc.md— author the prompt body (voice bounded byknowledgeonly;[MOVE: …]vocabulary; use only moves fromavailable_moves; disposition colours tone; never invent proper nouns without[FACT: …]).
Client (mirrors DmService):
client/scripts/net/npc_service.gd—NpcService.speak(npc_id, utterance, canon_log, state_view) -> NpcResult. Builds the request, posts throughDmTransport, degrades to the NPC fallback on any failure, extracts tags, runsMoveValidator, returns the result. Applies no state itself.client/scripts/npc/move_validator.gd— pure:(extracted_moves, available_moves, state_view) -> {valid, dropped}.client/scripts/npc/move_applier.gd— writes validated moves toGameState/CanonLog; invoked by the caller (harness) afterspeak.client/scripts/npc/npc_content.gd— loads acapabilitiesblock and computesavailable_movesfrom 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
prose→NpcResultdegraded 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.NpcServicewithFakeDmTransport: 200 with mixed valid/invalid move tags → correct split + prose kept; 502/422/transport-fail → degraded, no moves; facts harvested.NpcContentavailable_moves computation: capability + state → expected list.- Server
npc.pywith a fake Ollama (mirror the narrate tests): digest includes persona/knowledge/disposition/available_moves/utterance; prose returned raw; unknownnpc_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).