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