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>
This commit is contained in:
2026-07-10 11:36:44 -05:00
parent 7db1eb8f26
commit 097ac618f4

View File

@@ -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/<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:
```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": "<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_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).