# 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/.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/.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: ```json { "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` ```json { "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": ""}` — same shape as narrate; the client extracts. - `502` → `{"detail": {"model_error": ""}}`. - `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_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 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.gd` — `NpcService.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.gd` — **pure**: `(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 `prose` → `NpcResult` 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).