diff --git a/docs/superpowers/specs/2026-07-10-bounded-npc-conversation-design.md b/docs/superpowers/specs/2026-07-10-bounded-npc-conversation-design.md new file mode 100644 index 0000000..e32149b --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-bounded-npc-conversation-design.md @@ -0,0 +1,281 @@ +# 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).