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:
@@ -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).
|
||||
Reference in New Issue
Block a user