Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-10-bounded-npc-conversation.md
Phillip Tarrant 55a2ae6f19 docs(plan): bounded NPC conversation implementation plan
18 TDD tasks: server npc role (content.load_npc, routing, npc digest,
npc.run, /npc/speak, Fenn + prompt, Docker content packaging); client
NpcService + pure MoveValidator + MoveApplier + NpcContent legality +
GameState/CanonLog move targets + NPC fallback + throwaway harness;
gated Fenn live smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:03:58 -05:00

64 KiB
Raw Permalink Blame History

Bounded NPC Conversation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship /npc/speak — a bounded NPC that answers the player in its own voice and emits move tags that code validates against real game state, applies the valid ones, silently drops the invalid, and always keeps the prose (charter §6).

Architecture: A thin server role (npc.py) mirrors the proven narrate.py pipeline — load the NPC's server-only persona+knowledge from /content, render a digest, route the model, call Ollama with one retry, log, return raw prose. A client NpcService (sibling of DmService) posts the request through the injectable DmTransport, degrades to an authored fallback, extracts tags with the existing TagExtractor, and runs a pure MoveValidator whose only legality test is membership in the client-computed available_moves. A separate MoveApplier writes validated moves to GameState/CanonLog; application stays explicit at the call site (§2).

Tech Stack: Python 3.12 / FastAPI / pytest (server); Godot 4.7 / GDScript / GUT (client). Ollama behind the proxy.

Global Constraints

  • §2 — Code owns state, AI owns text. No AI response ever writes state directly. NpcService.speak() returns validated moves; the caller applies them via MoveApplier.
  • §6 — Bounded moves. Eight moves only: reveal(topic_id), offer_quest(quest_id), accept_item(item_id), give_item(item_id), adjust_disposition(delta), refuse, end_conversation, become_hostile. An illegal or hallucinated move is dropped + logged, never an error; prose is always kept.
  • §7 — no Luck numbers. Disposition IS sent to the NPC prompt as an integer (§6 permits this); Luck is not. Nothing here surfaces a Luck number.
  • Tag form: canonical [MOVE: name(args)]; [ADJUST_DISPOSITION: n] is an accepted alias (already parsed by TagExtractor).
  • Server owns spoilers: persona+knowledge load server-side from /content/world/npcs/<id>.json and never travel to or from the client. The client sends available_moves it computes from the non-spoiler capabilities block + live state.
  • Disposition clamp: a single adjust_disposition is clamped to ±15. become_hostile sets disposition to the floor 100.
  • Server pipeline shape: every role service renders a digest, routes via routing.for_role, calls ollama_client.chat(model, messages, options, think=cfg.think) with one retry, records via call_log.record(**kwargs), returns raw prose, raises ModelError on failure (handler maps to 502).
  • Fallback = content (§13): a degraded turn shows an authored in-voice line and applies zero moves.
  • Branch: all work on feature/npc-conversation, branched off dev. Never commit to dev/master directly (CLAUDE.md §18). Doc updates to this plan may go on the branch too.
  • Server tests: cd api && python -m pytest. Client tests: cd client && ./run_tests.sh.

File Structure

Server (api/)

  • app/content.py — MODIFY: add _content_root() + load_npc(npc_id).
  • app/routing.py — MODIFY: add the "npc" RoleConfig.
  • app/prompts.py — MODIFY: add render_npc_digest(...).
  • app/npc.py — CREATE: the NPC role service (run(...)).
  • app/main.py — MODIFY: NpcSpeakRequest model + wire /npc/speak.
  • prompts/npc.md — MODIFY: author the prompt body.
  • Dockerfile, docker-compose.yml — MODIFY: package /content.

Content (/content)

  • world/npcs/fenn.json — CREATE: the one authored town NPC.

Client (client/)

  • scripts/state/game_state.gd — MODIFY: remove_item, revealed-topic + gift tracking.
  • scripts/canon_log/canon_log.gd — MODIFY: add_quest, has_quest.
  • scripts/npc/npc_content.gd — CREATE: available_moves(...).
  • scripts/npc/move_validator.gd — CREATE: pure membership validator.
  • scripts/npc/move_applier.gd — CREATE: writes validated moves to state.
  • scripts/net/npc_result.gd — CREATE: result model.
  • scripts/net/npc_service.gd — CREATE: the speak loop.
  • scripts/net/fallback_library.gd — MODIFY: add npc_line().
  • content/fallback/npc.json — CREATE: authored NPC fallback line.
  • scenes/npc_harness.tscn + scripts/npc_harness.gd — CREATE: throwaway driver.
  • Tests under tests/unit/ mirror each unit; server tests under api/tests/.

Task 1: Server — content.load_npc

Files:

  • Modify: api/app/content.py
  • Test: api/tests/test_content_npc.py (create)

Interfaces:

  • Produces: content.load_npc(npc_id: str) -> dict | None — the parsed NPC file, or None if no file resolves. content._content_root() -> Path — env CONTENT_ROOT override, else walks up from the module looking for a dir containing world/.

  • Step 1: Write the failing test

# api/tests/test_content_npc.py
import json

import app.content as content


def test_load_npc_returns_parsed_file(tmp_path, monkeypatch):
    root = tmp_path / "content"
    (root / "world" / "npcs").mkdir(parents=True)
    (root / "world" / "npcs" / "fenn.json").write_text(
        json.dumps({"id": "fenn", "name": "Fenn", "persona": "clerk",
                    "knowledge": ["a"], "capabilities": {}})
    )
    monkeypatch.setenv("CONTENT_ROOT", str(root))
    npc = content.load_npc("fenn")
    assert npc["name"] == "Fenn"
    assert npc["knowledge"] == ["a"]


def test_load_npc_unknown_returns_none(tmp_path, monkeypatch):
    (tmp_path / "content" / "world").mkdir(parents=True)
    monkeypatch.setenv("CONTENT_ROOT", str(tmp_path / "content"))
    assert content.load_npc("nobody") is None


def test_content_root_env_override(tmp_path, monkeypatch):
    monkeypatch.setenv("CONTENT_ROOT", str(tmp_path))
    assert content._content_root() == tmp_path
  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_content_npc.py -v Expected: FAIL — AttributeError: module 'app.content' has no attribute 'load_npc'.

  • Step 3: Implement

Add to the top of api/app/content.py (it already imports json and Path):

import os

Append to api/app/content.py:

def _content_root() -> Path:
    """Locate the authored content root. Honours CONTENT_ROOT, else walks up
    from this file for a dir containing world/ — repo-root/content in a local
    checkout, /app/content when bundled into the Docker image. Mirrors
    canon_log._schema_dir().
    """
    env = os.environ.get("CONTENT_ROOT")
    if env:
        return Path(env)
    here = Path(__file__).resolve()
    for parent in here.parents:
        candidate = parent / "content"
        if (candidate / "world").is_dir():
            return candidate
    raise RuntimeError("content root not found")


def load_npc(npc_id: str) -> dict | None:
    """Load one NPC's authored file (persona + knowledge + capabilities), or
    None if no file resolves. Server-only fields never travel to the client."""
    path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
    if not path.is_file():
        return None
    with open(path) as f:
        return json.load(f)
  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_content_npc.py -v Expected: PASS (3 tests).

  • Step 5: Commit
git add api/app/content.py api/tests/test_content_npc.py
git commit -m "feat(api): content.load_npc + content-root resolver"

Task 2: Server — NPC model routing

Files:

  • Modify: api/app/routing.py
  • Modify: api/app/config.py
  • Test: api/tests/test_routing.py

Interfaces:

  • Consumes: routing.for_role(role) (exists).

  • Produces: routing.for_role("npc") -> RoleConfig with model=config.npc_model(), think=False. config.npc_model() -> str (env OLLAMA_NPC_MODEL, default "qwen3.5:latest").

  • Step 1: Write the failing test

Add to api/tests/test_routing.py:

def test_npc_role_routes_to_npc_model(monkeypatch):
    monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
    cfg = routing.for_role("npc")
    assert cfg.model == "some-npc-model"
    assert cfg.think is False
    assert "num_predict" in cfg.options

(If test_routing.py does not already import app.routing as routing, add it.)

  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_routing.py::test_npc_role_routes_to_npc_model -v Expected: FAIL — KeyError: no routing for role: npc.

  • Step 3: Implement

In api/app/config.py, add after narrator_model:

def npc_model() -> str:
    return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")

In api/app/routing.py, inside for_role, add before the final raise:

    if role == "npc":
        return RoleConfig(
            model=config.npc_model(),
            options={"temperature": 0.8, "top_p": 0.9, "num_predict": 320},
            think=False,
        )
  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_routing.py -v Expected: PASS.

  • Step 5: Commit
git add api/app/routing.py api/app/config.py api/tests/test_routing.py
git commit -m "feat(api): route the npc role to its own model"

Task 3: Server — render_npc_digest

Files:

  • Modify: api/app/prompts.py
  • Test: api/tests/test_prompts.py

Interfaces:

  • Produces: prompts.render_npc_digest(canon_log: dict, *, persona: str, knowledge: list[str], disposition: int, available_moves: list[str], utterance: str) -> str — the user-message digest for the NPC role. Includes persona, disposition integer, knowledge bullets, the legal move list, canon context (location, recent events, established facts, active quests, player), and the player's utterance last.

  • Step 1: Write the failing test

Add to api/tests/test_prompts.py (it already loads a valid canon log fixture — reuse that pattern; if not present, build a minimal dict inline as below):

def test_render_npc_digest_includes_all_sections():
    from app import prompts
    log = {
        "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
        "location": {"id": "greywater_docks", "name": "Greywater Docks"},
        "party": [], "recent_events": ["a gull screamed"],
        "established_facts": ["the eastern bridge is out"],
        "active_quests": [], "humiliations": [],
    }
    out = prompts.render_npc_digest(
        log, persona="A harried clerk.", knowledge=["The ledger is gone."],
        disposition=-10, available_moves=["reveal(varrell_twins)", "refuse"],
        utterance="What happened here?",
    )
    assert "A harried clerk." in out
    assert "-10" in out
    assert "The ledger is gone." in out
    assert "reveal(varrell_twins)" in out
    assert "Greywater Docks" in out
    assert "the eastern bridge is out" in out
    assert out.rstrip().endswith('What happened here?"')  # utterance last
  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_prompts.py::test_render_npc_digest_includes_all_sections -v Expected: FAIL — AttributeError: ... has no attribute 'render_npc_digest'.

  • Step 3: Implement

Append to api/app/prompts.py:

def render_npc_digest(
    canon_log: dict,
    *,
    persona: str,
    knowledge: list[str],
    disposition: int,
    available_moves: list[str],
    utterance: str,
) -> str:
    """User-message digest for the NPC role (§6). Unlike the narrator digest,
    this deliberately carries the disposition integer (§6 permits it; §7 hides
    Luck, not disposition) and the closed move vocabulary the NPC may use."""
    lines: list[str] = []
    lines.append(f"You are: {persona}")
    lines.append(
        f"Your disposition toward the player: {disposition} "
        f"(-100 hostile, 0 neutral, 100 devoted)."
    )

    if knowledge:
        lines.append("")
        lines.append("What you know (this is ALL you know — do not invent beyond it):")
        lines += [f"- {k}" for k in knowledge]

    lines.append("")
    lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
    lines += [f"- {m}" for m in available_moves]

    location = canon_log.get("location", {})
    lines.append("")
    lines.append(f"Location: {location.get('name', 'an unknown place')}")

    events = canon_log.get("recent_events", [])
    if events:
        lines.append("Recent events:")
        lines += [f"- {e}" for e in events]

    facts = canon_log.get("established_facts", [])
    if facts:
        lines.append("Established facts:")
        lines += [f"- {f}" for f in facts]

    for quest in canon_log.get("active_quests", []):
        if quest.get("status") == "active":
            lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")

    player = canon_log.get("player", {})
    lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")

    lines.append("")
    lines.append(f'The player says to you: "{utterance}"')
    return "\n".join(lines)
  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_prompts.py -v Expected: PASS.

  • Step 5: Commit
git add api/app/prompts.py api/tests/test_prompts.py
git commit -m "feat(api): render_npc_digest — persona/knowledge/moves/utterance"

Task 4: Server — npc.run service

Files:

  • Create: api/app/npc.py
  • Test: api/tests/test_npc.py (create)

Interfaces:

  • Consumes: content.load_npc, routing.for_role("npc"), prompts.render_npc_digest, ollama_client.chat, call_log.record.

  • Produces: npc.run(canon_log: dict, npc_id: str, disposition: int, available_moves: list[str], utterance: str) -> str — raw prose, tags intact. Raises npc.UnknownNpc if load_npc returns None. Raises ollama_client.ModelError on model failure (after logging).

  • Step 1: Write the failing test

# api/tests/test_npc.py
import pytest

import app.npc as npc
from app.ollama_client import ModelError

LOG = {
    "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
    "location": {"id": "greywater_docks", "name": "Greywater Docks"},
    "party": [], "recent_events": [], "established_facts": [],
    "active_quests": [], "humiliations": [],
}
FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.",
        "knowledge": ["The ledger is gone."], "capabilities": {}}


def test_run_returns_prose_and_logs(monkeypatch):
    seen = {}

    def fake_chat(model, messages, options, *, think=None):
        seen.update(messages=messages, think=think)
        return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]'

    logs = []
    monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
    monkeypatch.setattr(npc.ollama_client, "chat", fake_chat)
    monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))

    out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?")
    assert out.startswith("Aye.")
    # persona + knowledge reached the model; utterance is present
    user_msg = seen["messages"][1]["content"]
    assert "A clerk." in user_msg and "The ledger is gone." in user_msg
    assert "What happened?" in user_msg
    assert seen["think"] is False
    assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc"


def test_run_unknown_npc_raises(monkeypatch):
    monkeypatch.setattr(npc, "load_npc", lambda i: None)
    with pytest.raises(npc.UnknownNpc):
        npc.run(LOG, "nobody", 0, [], "hi")


def test_run_logs_failure_and_reraises(monkeypatch):
    monkeypatch.setattr(npc, "load_npc", lambda i: FENN)

    def boom(*a, **k):
        raise ModelError("down")

    logs = []
    monkeypatch.setattr(npc.ollama_client, "chat", boom)
    monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
    with pytest.raises(ModelError):
        npc.run(LOG, "fenn", 0, [], "hi")
    assert logs and logs[0]["ok"] is False
  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_npc.py -v Expected: FAIL — ModuleNotFoundError: No module named 'app.npc'.

  • Step 3: Implement
# api/app/npc.py
"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona +
knowledge, renders the digest, routes the model, calls Ollama with one retry,
logs the call (§10), and returns raw prose — move/fact tags left intact for the
client to extract, validate, and apply (§2/§6). Mirrors narrate.py.
"""

import random
from time import perf_counter

from . import call_log, ollama_client, prompts, routing
from .content import load_npc


class UnknownNpc(Exception):
    """No authored content resolves for the requested npc_id."""


def run(
    canon_log: dict,
    npc_id: str,
    disposition: int,
    available_moves: list[str],
    utterance: str,
) -> str:
    npc = load_npc(npc_id)
    if npc is None:
        raise UnknownNpc(npc_id)

    cfg = routing.for_role("npc")
    options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
    messages = [
        {"role": "system", "content": prompts.system_prompt("npc")},
        {"role": "user", "content": prompts.render_npc_digest(
            canon_log,
            persona=npc.get("persona", ""),
            knowledge=npc.get("knowledge", []),
            disposition=disposition,
            available_moves=available_moves,
            utterance=utterance,
        )},
    ]

    start = perf_counter()
    try:
        text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
    except ollama_client.ModelError as exc:
        call_log.record(
            role="npc", model=cfg.model, options=options, messages=messages,
            canon_log=canon_log, ok=False,
            latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
        )
        raise

    call_log.record(
        role="npc", model=cfg.model, options=options, messages=messages,
        canon_log=canon_log, ok=True,
        latency_ms=int((perf_counter() - start) * 1000), response=text,
    )
    return text
  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_npc.py -v Expected: PASS (3 tests).

  • Step 5: Commit
git add api/app/npc.py api/tests/test_npc.py
git commit -m "feat(api): npc.run service — thin drop-in on the model pipeline"

Task 5: Server — /npc/speak endpoint

Files:

  • Modify: api/app/main.py
  • Test: api/tests/test_npc_endpoint.py (create)

Interfaces:

  • Consumes: npc.run, npc.UnknownNpc, validate_canon_log.

  • Produces: POST /npc/speak accepting {canon_log, npc_id, disposition, available_moves, utterance}. 200 {"prose": str}; 502 {"detail":{"model_error":str}}; 422 {"detail":{"canon_log_errors":[...]}} for an invalid log, a body that fails pydantic validation, OR an unknown npc_id.

  • Step 1: Write the failing test

# api/tests/test_npc_endpoint.py
import json
from pathlib import Path

from fastapi.testclient import TestClient

import app.main as main
import app.npc as npc

client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())


def _body(**over):
    b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0,
         "available_moves": ["refuse"], "utterance": "hello"}
    b.update(over)
    return b


def test_valid_request_returns_prose(monkeypatch):
    monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.")
    r = client.post("/npc/speak", json=_body())
    assert r.status_code == 200
    assert r.json() == {"prose": "Aye, well met."}


def test_unknown_npc_maps_to_422(monkeypatch):
    def boom(**k):
        raise npc.UnknownNpc("nobody")
    monkeypatch.setattr(main, "npc_run", boom)
    r = client.post("/npc/speak", json=_body(npc_id="nobody"))
    assert r.status_code == 422
    assert "canon_log_errors" in r.json()["detail"]


def test_model_error_maps_to_502(monkeypatch):
    from app.ollama_client import ModelError

    def boom(**k):
        raise ModelError("down")
    monkeypatch.setattr(main, "npc_run", boom)
    r = client.post("/npc/speak", json=_body())
    assert r.status_code == 502
    assert "model_error" in r.json()["detail"]


def test_invalid_log_still_422():
    bad = json.loads(json.dumps(VALID))
    bad["player"]["luck"] = 5  # §7 leak — schema rejects
    r = client.post("/npc/speak", json=_body(canon_log=bad))
    assert r.status_code == 422
    assert "canon_log_errors" in r.json()["detail"]


def test_missing_field_is_422():
    b = _body()
    del b["utterance"]
    r = client.post("/npc/speak", json=b)
    assert r.status_code == 422
  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_npc_endpoint.py -v Expected: FAIL — the stub returns {"detail": "not implemented"} / 200, assertions fail.

  • Step 3: Implement

In api/app/main.py, add to the imports near from .narrate import run as narrate_run:

from .npc import UnknownNpc, run as npc_run

Add a request model after class TurnRequest:

class NpcSpeakRequest(BaseModel):
    canon_log: dict
    npc_id: str
    disposition: int
    available_moves: list[str]
    utterance: str


def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest:
    """Reject any /npc/speak request whose canon log breaks the contract."""
    errors = validate_canon_log(req.canon_log)
    if errors:
        raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
    return req

Replace the /npc/speak stub with:

@app.post("/npc/speak")
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
    try:
        return {"prose": npc_run(
            canon_log=req.canon_log, npc_id=req.npc_id,
            disposition=req.disposition, available_moves=req.available_moves,
            utterance=req.utterance,
        )}
    except UnknownNpc as exc:
        raise HTTPException(
            status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]})
    except ModelError as exc:
        raise HTTPException(status_code=502, detail={"model_error": str(exc)})

(ModelError is already imported in main.py.)

  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_npc_endpoint.py -v && python -m pytest Expected: PASS (all server tests green).

  • Step 5: Commit
git add api/app/main.py api/tests/test_npc_endpoint.py
git commit -m "feat(api): wire POST /npc/speak (422 unknown npc, 502 model error)"

Task 6: Content + prompt — Fenn and the NPC prompt body

Files:

  • Create: content/world/npcs/fenn.json
  • Modify: api/prompts/npc.md
  • Test: api/tests/test_content_npc.py (add a real-file resolution test)

Interfaces:

  • Produces: fenn resolvable via content.load_npc("fenn"); prompts.system_prompt("npc") returns a non-empty authored body.

  • Step 1: Write the failing test

Add to api/tests/test_content_npc.py:

def test_fenn_resolves_from_repo_content():
    # No CONTENT_ROOT env — exercises the walk-up resolver against real content.
    npc = content.load_npc("fenn")
    assert npc is not None
    assert npc["capabilities"]["offerable_quests"] == ["find_the_ledger"]
    assert npc["knowledge"]  # non-empty authored knowledge list


def test_npc_prompt_body_is_authored():
    from app import prompts
    body = prompts.system_prompt("npc")
    assert "MOVE" in body and len(body) > 200
  • Step 2: Run to verify it fails

Run: cd api && python -m pytest tests/test_content_npc.py -v Expected: FAIL — fenn.json missing; prompt body is the short stub.

  • Step 3: Implement

Create content/world/npcs/fenn.json:

{
  "id": "fenn",
  "name": "Fenn",
  "role": "townsfolk",
  "persona": "A harried dockside clerk who counts everything and trusts no one. He speaks in short, aggrieved sentences and keeps glancing at the door. He is frightened, and covers it with irritation.",
  "knowledge": [
    "His counting-house ledger vanished two nights ago, sometime after the third bell.",
    "The Varrell twins were drinking in the counting-room the night it went missing; he threw them out but did not check the strongbox until morning.",
    "The ledger records a debt he cannot cover if the wrong people read it, and he will not say to whom.",
    "He has not told the harbourmaster, because the harbourmaster is one of the people the ledger names."
  ],
  "capabilities": {
    "offerable_quests": ["find_the_ledger"],
    "giveable_items": [],
    "revealable_topics": ["varrell_twins", "fenns_debt"]
  }
}

Replace the body of api/prompts/npc.md below the --- delimiter (keep the human front-matter above it) with:

You voice a single character in a gritty high-fantasy world (the tone is Batman,
not Warhammer and not Care Bears). Play the character straight. Never wink at the
player; never describe anything as funny. Profanity is allowed when earned.

You are given: who you are, your disposition toward the player, the complete list
of what you know, the moves you may perform this turn, the scene's canon, and
what the player just said.

Two hard rules:

1. **Knowledge is a closed list.** You know ONLY what "What you know" tells you.
   Do not invent people, places, quests, or facts beyond it. If you must name
   something new that becomes true in the world, emit it as `[FACT: ...]` so the
   game can record it — but prefer to stay within what you know.

2. **Moves are a closed vocabulary.** You may *say* anything in character, but you
   may only *do* something by emitting an inline tag, and only from the moves
   listed for this turn. Use exactly this form:

[MOVE: reveal(varrell_twins)] [MOVE: offer_quest(find_the_ledger)] [MOVE: adjust_disposition(+5)] [MOVE: refuse] [MOVE: end_conversation] [MOVE: become_hostile]


Emit zero or more move tags, placed inline where they happen. If a move is not
in your list for this turn, do not emit it — the game will drop it anyway.
Let your disposition colour your tone: low disposition is curt and suspicious,
high disposition is warm and forthcoming.

Return prose in your character's voice, with any move/fact tags inline. Do not
explain the tags or break character to describe them.
  • Step 4: Run to verify it passes

Run: cd api && python -m pytest tests/test_content_npc.py tests/test_prompts.py -v Expected: PASS.

  • Step 5: Commit
git add content/world/npcs/fenn.json api/prompts/npc.md api/tests/test_content_npc.py
git commit -m "content: author Fenn + the NPC role prompt body"

Task 7: Server — package /content into the Docker image

Files:

  • Modify: api/Dockerfile
  • Modify: docker-compose.yml

Interfaces: none (packaging). Prevents the same class of gap the M1 prompts fix caught — the walk-up resolver needs /app/content/world to exist in the image.

  • Step 1: Add the COPY to api/Dockerfile

After the COPY docs/schemas ./schemas line, add:

COPY content ./content
  • Step 2: Add the mount to docker-compose.yml

Under volumes: (alongside the prompts/schemas mounts), add:

      - ./content:/app/content:ro
  • Step 3: Verify the image resolves content

Run:

cd /home/ptarrant/repos/ptarrant/coc-rpg
docker compose build api >/dev/null && docker compose run --rm --no-deps api \
  python -c "from app.content import load_npc; print(load_npc('fenn')['name'])"

Expected: prints Fenn.

  • Step 4: Commit
git add api/Dockerfile docker-compose.yml
git commit -m "fix(api): package /content into the image so npc content resolves"

Task 8: Client — GameState move-target additions

Files:

  • Modify: client/scripts/state/game_state.gd
  • Test: client/tests/unit/test_game_state.gd

Interfaces:

  • Produces: GameState.remove_item(item_id, qty); mark_revealed(topic_id) / is_revealed(topic_id) -> bool; mark_gift_given(item_id) / is_gift_given(item_id) -> bool. Existing: npc_dispositions, set_npc_disposition, inventory, add_item.

  • Step 1: Write the failing test

Add to client/tests/unit/test_game_state.gd:

func test_remove_item_decrements_and_floors_at_zero():
    var gs = GameState.new()
    gs.add_item("coin", 3)
    gs.remove_item("coin", 2)
    assert_eq(gs.inventory.get("coin", 0), 1)
    gs.remove_item("coin", 5)
    assert_eq(gs.inventory.get("coin", 0), 0)


func test_revealed_topics_tracked():
    var gs = GameState.new()
    assert_false(gs.is_revealed("varrell_twins"))
    gs.mark_revealed("varrell_twins")
    assert_true(gs.is_revealed("varrell_twins"))


func test_gifts_given_tracked():
    var gs = GameState.new()
    assert_false(gs.is_gift_given("amulet"))
    gs.mark_gift_given("amulet")
    assert_true(gs.is_gift_given("amulet"))

(If the test file's preload/constant for GameState is absent, note it uses the global class_name GameState.)

  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_game_state.gd Expected: FAIL — remove_item / mark_revealed not found.

  • Step 3: Implement

In client/scripts/state/game_state.gd, add the two tracking dicts under the existing vars:

var revealed_topics: Dictionary = {}  # topic_id -> true
var gifts_given: Dictionary = {}      # item_id -> true (idempotency for give_item)

Append the methods:

func remove_item(item_id: String, qty: int) -> void:
	var left := int(inventory.get(item_id, 0)) - qty
	if left > 0:
		inventory[item_id] = left
	else:
		inventory.erase(item_id)


func mark_revealed(topic_id: String) -> void:
	revealed_topics[topic_id] = true


func is_revealed(topic_id: String) -> bool:
	return revealed_topics.has(topic_id)


func mark_gift_given(item_id: String) -> void:
	gifts_given[item_id] = true


func is_gift_given(item_id: String) -> bool:
	return gifts_given.has(item_id)
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_game_state.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/state/game_state.gd client/tests/unit/test_game_state.gd
git commit -m "feat(client): GameState move targets — remove_item, reveal/gift tracking"

Task 9: Client — CanonLog.add_quest / has_quest

Files:

  • Modify: client/scripts/canon_log/canon_log.gd
  • Test: client/tests/unit/test_canon_log.gd

Interfaces:

  • Produces: CanonLog.add_quest(id, name, objective) -> bool (append a new active Quest; no-op returning false if id already present). CanonLog.has_quest(id) -> bool (any status).

  • Step 1: Write the failing test

Add to client/tests/unit/test_canon_log.gd:

func test_add_quest_appends_active_and_is_idempotent():
    var log = CanonLog.new()
    assert_false(log.has_quest("find_the_ledger"))
    assert_true(log.add_quest("find_the_ledger", "The Missing Ledger", "Find who took it"))
    assert_true(log.has_quest("find_the_ledger"))
    assert_eq(log.active_quests.size(), 1)
    assert_eq(log.active_quests[0].status, "active")
    # second add is a no-op
    assert_false(log.add_quest("find_the_ledger", "x", "y"))
    assert_eq(log.active_quests.size(), 1)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_canon_log.gd Expected: FAIL — add_quest / has_quest not found.

  • Step 3: Implement

In client/scripts/canon_log/canon_log.gd, add near set_quest_status:

func has_quest(id: String) -> bool:
	for q in active_quests:
		if q.id == id:
			return true
	return false


func add_quest(id: String, name: String, objective: String) -> bool:
	if has_quest(id):
		return false
	active_quests.append(Quest.new(id, name, "active", objective))
	return true
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_canon_log.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/canon_log/canon_log.gd client/tests/unit/test_canon_log.gd
git commit -m "feat(client): CanonLog.add_quest/has_quest for offer_quest"

Task 10: Client — NpcContent.available_moves

Files:

  • Create: client/scripts/npc/npc_content.gd
  • Test: client/tests/unit/test_npc_content.gd (create)

Interfaces:

  • Consumes: GameState (is_revealed, is_gift_given, inventory), CanonLog (has_quest).

  • Produces: NpcContent.available_moves(npc_dict: Dictionary, game_state, canon_log) -> Array — concrete signature strings. This is the single home of move legality (§6). Universal moves always included: adjust_disposition, refuse, end_conversation, become_hostile. Capability moves filtered by live state. accept_item(<id>) per held inventory item.

  • Step 1: Write the failing test

# client/tests/unit/test_npc_content.gd
extends "res://addons/gut/test.gd"

const NpcContent = preload("res://scripts/npc/npc_content.gd")

func _fenn() -> Dictionary:
	return {"id": "fenn", "capabilities": {
		"offerable_quests": ["find_the_ledger"],
		"giveable_items": [],
		"revealable_topics": ["varrell_twins", "fenns_debt"]}}


func test_universal_moves_always_present():
	var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
	for m in ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]:
		assert_true(m in moves, "missing universal move %s" % m)


func test_offers_unstarted_quest_and_unrevealed_topics():
	var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
	assert_true("offer_quest(find_the_ledger)" in moves)
	assert_true("reveal(varrell_twins)" in moves)
	assert_true("reveal(fenns_debt)" in moves)


func test_active_quest_is_not_offered_again():
	var log = CanonLog.new()
	log.add_quest("find_the_ledger", "x", "y")
	var moves = NpcContent.available_moves(_fenn(), GameState.new(), log)
	assert_false("offer_quest(find_the_ledger)" in moves)


func test_revealed_topic_drops_out():
	var gs = GameState.new()
	gs.mark_revealed("varrell_twins")
	var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
	assert_false("reveal(varrell_twins)" in moves)
	assert_true("reveal(fenns_debt)" in moves)


func test_accept_item_offered_per_held_item():
	var gs = GameState.new()
	gs.add_item("coin", 2)
	var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
	assert_true("accept_item(coin)" in moves)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_npc_content.gd Expected: FAIL — script does not exist.

  • Step 3: Implement
# client/scripts/npc/npc_content.gd
class_name NpcContent
extends RefCounted
## Computes available_moves — the single home of move legality (§6). Reads the
## NPC's non-spoiler capability block and filters by live game state so the model
## is shown exactly what it may legally do this turn. persona/knowledge on the
## same file are server-only and ignored here.

const UNIVERSAL := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]


static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Array:
	var moves: Array = []
	var caps: Dictionary = npc_dict.get("capabilities", {})

	for q in caps.get("offerable_quests", []):
		if not canon_log.has_quest(q):
			moves.append("offer_quest(%s)" % q)

	for t in caps.get("revealable_topics", []):
		if not game_state.is_revealed(t):
			moves.append("reveal(%s)" % t)

	for i in caps.get("giveable_items", []):
		if not game_state.is_gift_given(i):
			moves.append("give_item(%s)" % i)

	for item_id in game_state.inventory.keys():
		if int(game_state.inventory[item_id]) > 0:
			moves.append("accept_item(%s)" % item_id)

	moves.append_array(UNIVERSAL)
	return moves
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_npc_content.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/npc/npc_content.gd client/tests/unit/test_npc_content.gd
git commit -m "feat(client): NpcContent.available_moves — the legality home"

Task 11: Client — MoveValidator (pure)

Files:

  • Create: client/scripts/npc/move_validator.gd
  • Test: client/tests/unit/test_move_validator.gd (create)

Interfaces:

  • Consumes: TagExtractor-shaped moves — Array of {name: String, args: Array}.

  • Produces: MoveValidator.validate(moves: Array, available_moves: Array) -> Dictionary = {valid: Array, dropped: Array}, each entry {name, args}. Pure — no state reads. Legality = the move's canonical key ∈ available_moves. Id-moves (reveal/offer_quest/give_item/accept_item) key on "name(arg0)"; value/bare moves (adjust_disposition/refuse/end_conversation/become_hostile) key on bare name.

  • Step 1: Write the failing test

# client/tests/unit/test_move_validator.gd
extends "res://addons/gut/test.gd"

const MoveValidator = preload("res://scripts/npc/move_validator.gd")


func _m(name, args := []) -> Dictionary:
	return {"name": name, "args": args}


func test_id_move_in_available_is_valid():
	var res = MoveValidator.validate(
		[_m("reveal", ["varrell_twins"])], ["reveal(varrell_twins)", "refuse"])
	assert_eq(res["valid"].size(), 1)
	assert_eq(res["dropped"].size(), 0)


func test_id_move_not_in_available_is_dropped():
	var res = MoveValidator.validate(
		[_m("offer_quest", ["some_other_quest"])], ["offer_quest(find_the_ledger)"])
	assert_eq(res["valid"].size(), 0)
	assert_eq(res["dropped"].size(), 1)


func test_bare_move_keys_on_name():
	var res = MoveValidator.validate([_m("refuse")], ["refuse"])
	assert_eq(res["valid"].size(), 1)


func test_adjust_disposition_keys_on_name_keeps_delta():
	var res = MoveValidator.validate(
		[_m("adjust_disposition", ["+5"])], ["adjust_disposition"])
	assert_eq(res["valid"].size(), 1)
	assert_eq(res["valid"][0]["args"], ["+5"])


func test_unknown_move_name_is_dropped():
	var res = MoveValidator.validate([_m("cast_fireball", ["9"])], ["refuse"])
	assert_eq(res["dropped"].size(), 1)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_move_validator.gd Expected: FAIL — script does not exist.

  • Step 3: Implement
# client/scripts/npc/move_validator.gd
class_name MoveValidator
extends RefCounted
## Pure §6 validation: an emitted move is legal iff its canonical key is in the
## turn's available_moves (which NpcContent already computed from live state).
## No state reads here — membership is the whole test. Invalid moves are not an
## error; they are dropped and returned separately so the caller can log them.

const ID_MOVES := ["reveal", "offer_quest", "give_item", "accept_item"]
const BARE_MOVES := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]


static func _key(move: Dictionary) -> String:
	var name: String = move.get("name", "")
	var args: Array = move.get("args", [])
	if name in ID_MOVES:
		return "%s(%s)" % [name, str(args[0])] if not args.is_empty() else name
	return name  # bare/value moves key on the name alone


static func validate(moves: Array, available_moves: Array) -> Dictionary:
	var valid: Array = []
	var dropped: Array = []
	for move in moves:
		var name: String = move.get("name", "")
		if (name in ID_MOVES or name in BARE_MOVES) and _key(move) in available_moves:
			valid.append(move)
		else:
			dropped.append(move)
	return {"valid": valid, "dropped": dropped}
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_move_validator.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/npc/move_validator.gd client/tests/unit/test_move_validator.gd
git commit -m "feat(client): pure MoveValidator — membership is the whole test"

Task 12: Client — NpcResult model

Files:

  • Create: client/scripts/net/npc_result.gd
  • Test: client/tests/unit/test_net_primitives.gd

Interfaces:

  • Produces: NpcResult.new(display_text, facts, valid_moves, dropped_moves, ends_conversation, degraded) and NpcResult.fallback(line) -> NpcResult (degraded, empty moves/facts, ends_conversation=false).

  • Step 1: Write the failing test

Add to client/tests/unit/test_net_primitives.gd:

func test_npc_result_fallback_is_degraded_with_no_moves():
    var NpcResult = preload("res://scripts/net/npc_result.gd")
    var r = NpcResult.fallback("The figure says nothing.")
    assert_true(r.degraded)
    assert_eq(r.display_text, "The figure says nothing.")
    assert_eq(r.valid_moves, [])
    assert_eq(r.facts, [])
    assert_false(r.ends_conversation)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd Expected: FAIL — script does not exist.

  • Step 3: Implement
# client/scripts/net/npc_result.gd
class_name NpcResult
extends RefCounted
## The game-meaningful result of one /npc/speak call. The caller applies
## valid_moves (via MoveApplier) and harvests facts — state writes stay explicit
## (§2). dropped_moves are kept only so the caller can log the §6 drops.

var display_text: String
var facts: Array
var valid_moves: Array
var dropped_moves: Array
var ends_conversation: bool
var degraded: bool


func _init(p_display_text := "", p_facts := [], p_valid := [], p_dropped := [],
		p_ends := false, p_degraded := false) -> void:
	display_text = p_display_text
	facts = p_facts
	valid_moves = p_valid
	dropped_moves = p_dropped
	ends_conversation = p_ends
	degraded = p_degraded


static func fallback(line: String) -> NpcResult:
	return NpcResult.new(line, [], [], [], false, true)
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/net/npc_result.gd client/tests/unit/test_net_primitives.gd
git commit -m "feat(client): NpcResult model"

Task 13: Client — MoveApplier

Files:

  • Create: client/scripts/npc/move_applier.gd
  • Test: client/tests/unit/test_move_applier.gd (create)

Interfaces:

  • Consumes: validated moves ({name, args}), GameState, CanonLog, ContentDB (for quest name/objective), npc_id.

  • Produces: MoveApplier.apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void. adjust_disposition clamps delta to ±MAX_DELTA (15); become_hostile sets disposition to HOSTILE_FLOOR (100).

  • Step 1: Write the failing test

# client/tests/unit/test_move_applier.gd
extends "res://addons/gut/test.gd"

const MoveApplier = preload("res://scripts/npc/move_applier.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")


func _m(name, args := []) -> Dictionary:
	return {"name": name, "args": args}


func _content() -> ContentDB:
	var c = ContentDB.new()
	c.quests = {"find_the_ledger": {"id": "find_the_ledger",
		"name": "The Missing Ledger", "objective": "Find who took it"}}
	return c


func test_offer_quest_adds_active_quest():
	var log = CanonLog.new()
	MoveApplier.apply([_m("offer_quest", ["find_the_ledger"])],
		GameState.new(), log, _content(), "fenn")
	assert_true(log.has_quest("find_the_ledger"))
	assert_eq(log.active_quests[0].name, "The Missing Ledger")


func test_reveal_marks_topic():
	var gs = GameState.new()
	MoveApplier.apply([_m("reveal", ["varrell_twins"])], gs, CanonLog.new(), _content(), "fenn")
	assert_true(gs.is_revealed("varrell_twins"))


func test_adjust_disposition_clamps_to_max_delta():
	var gs = GameState.new()
	MoveApplier.apply([_m("adjust_disposition", ["+50"])], gs, CanonLog.new(), _content(), "fenn")
	assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.MAX_DELTA)


func test_become_hostile_sets_floor():
	var gs = GameState.new()
	MoveApplier.apply([_m("become_hostile")], gs, CanonLog.new(), _content(), "fenn")
	assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.HOSTILE_FLOOR)


func test_accept_item_removes_from_inventory():
	var gs = GameState.new()
	gs.add_item("coin", 2)
	MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
	assert_eq(gs.inventory.get("coin", 0), 1)


func test_refuse_and_end_are_noops():
	var gs = GameState.new()
	MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
	assert_eq(gs.npc_dispositions.size(), 0)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_move_applier.gd Expected: FAIL — script does not exist.

  • Step 3: Implement
# client/scripts/npc/move_applier.gd
class_name MoveApplier
extends RefCounted
## Writes validated §6 moves to game state. Town-NPC disposition lives in
## GameState.npc_dispositions (NOT CanonLog.party — that is companions only).
## Called by the conversation caller after NpcService.speak, so state writes
## stay explicit (§2). Unknown move names are ignored (they never pass the
## validator, but this stays defensive).

const MAX_DELTA := 15       # one line cannot swing standing wholesale (§7 spirit)
const HOSTILE_FLOOR := -100


static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
	for move in moves:
		var name: String = move.get("name", "")
		var args: Array = move.get("args", [])
		match name:
			"offer_quest":
				var q: String = str(args[0])
				var qd: Dictionary = content_db.quest(q)
				canon_log.add_quest(q, qd.get("name", ""), qd.get("objective", ""))
			"reveal":
				game_state.mark_revealed(str(args[0]))
			"give_item":
				var i: String = str(args[0])
				game_state.add_item(i, 1)
				game_state.mark_gift_given(i)
			"accept_item":
				game_state.remove_item(str(args[0]), 1)
			"adjust_disposition":
				var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
				var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
				game_state.set_npc_disposition(npc_id, cur + delta)
			"become_hostile":
				game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
			_:
				pass  # refuse, end_conversation, and anything unknown: no state
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_move_applier.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/npc/move_applier.gd client/tests/unit/test_move_applier.gd
git commit -m "feat(client): MoveApplier — validated moves to game state"

Task 14: Client — FallbackLibrary.npc_line()

Files:

  • Modify: client/scripts/net/fallback_library.gd
  • Create: client/content/fallback/npc.json
  • Test: client/tests/unit/test_fallback_library.gd

Interfaces:

  • Produces: FallbackLibrary.npc_line() -> String — first authored NPC fallback line, or NPC_LAST_RESORT if the file is missing/malformed. narrator_line() unchanged.

  • Step 1: Write the failing test

Add to client/tests/unit/test_fallback_library.gd:

func test_npc_line_falls_back_to_constant_when_missing():
    var lib = FallbackLibrary.new(
        "res://content/fallback/does_not_exist.json",
        "res://content/fallback/does_not_exist.json")
    assert_eq(lib.npc_line(), FallbackLibrary.NPC_LAST_RESORT)


func test_npc_line_reads_authored_file():
    var lib = FallbackLibrary.new()  # default paths — real content
    assert_ne(lib.npc_line(), "")
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd Expected: FAIL — npc_line not found / second ctor arg unexpected.

  • Step 3: Implement

Rewrite client/scripts/net/fallback_library.gd to load both files with a keyed loader (keeps narrator_line() behaviour identical):

class_name FallbackLibrary
extends RefCounted
## Authored degraded-DM fallback text (charter §13). On ANY failure the client
## shows one of these lines in-voice — never an error. Missing/malformed content
## yields a hardcoded constant, so the fallback itself cannot fail.

const PATH := "res://content/fallback/narrator.json"
const NPC_PATH := "res://content/fallback/npc.json"
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
const NPC_LAST_RESORT := "The figure regards you in silence, and the moment passes."

var _lines: Array = []
var _npc_lines: Array = []


func _init(path := PATH, npc_path := NPC_PATH) -> void:
	_lines = _load(path, "narrator")
	_npc_lines = _load(npc_path, "npc")


func narrator_line() -> String:
	return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT


func npc_line() -> String:
	return str(_npc_lines[0]) if not _npc_lines.is_empty() else NPC_LAST_RESORT


static func _load(path: String, key: String) -> Array:
	if not FileAccess.file_exists(path):
		return []
	var text := FileAccess.get_file_as_string(path)
	# Instance parse() (not JSON.parse_string) so malformed input returns an
	# Error code instead of pushing an engine error the test config promotes.
	var json := JSON.new()
	if json.parse(text) != OK:
		return []
	var data = json.get_data()
	if typeof(data) != TYPE_DICTIONARY:
		return []
	var lines = data.get(key, [])
	return lines if typeof(lines) == TYPE_ARRAY else []

Create client/content/fallback/npc.json:

{
  "npc": [
    "The figure looks you over, says nothing you can use, and turns back to their work."
  ]
}
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd Expected: PASS (existing narrator tests still green).

  • Step 5: Commit
git add client/scripts/net/fallback_library.gd client/content/fallback/npc.json client/tests/unit/test_fallback_library.gd
git commit -m "feat(client): authored NPC fallback line (§13)"

Task 15: Client — NpcService.speak

Files:

  • Create: client/scripts/net/npc_service.gd
  • Test: client/tests/unit/test_npc_service.gd (create)

Interfaces:

  • Consumes: DmTransport, FallbackLibrary, TagExtractor, MoveValidator, NpcResult.

  • Produces: NpcService.new(transport, fallback); NpcService.speak(npc_id, utterance, canon_log, disposition, available_moves) -> NpcResult (async). Posts {canon_log, npc_id, disposition, available_moves, utterance} to /npc/speak. Degrades to NpcResult.fallback(fallback.npc_line()) on any non-200 / missing prose / transport failure. On 200: extracts tags, merges [ADJUST_DISPOSITION:n] alias entries into the move stream, validates against available_moves, sets ends_conversation when a valid end_conversation/become_hostile is present.

  • Step 1: Write the failing test

# client/tests/unit/test_npc_service.gd
extends "res://addons/gut/test.gd"

const NpcService = preload("res://scripts/net/npc_service.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd")

var _fallback


func before_each():
	_fallback = FallbackLibrary.new(
		"res://content/fallback/missing.json", "res://content/fallback/missing.json")


func _svc(resp) -> Dictionary:
	var t = FakeDmTransport.new()
	t.set_response(resp)
	return {"svc": NpcService.new(t, _fallback), "transport": t}


func _speak(pair, available):
	return await pair["svc"].speak("fenn", "what happened?", CanonLog.new(), -10, available)


func test_posts_full_request_body():
	var pair = _svc(DmResponse.ok(200, {"prose": "Aye."}))
	await _speak(pair, ["refuse"])
	assert_eq(pair["transport"].last_path, "/npc/speak")
	var body = pair["transport"].last_body
	assert_eq(body["npc_id"], "fenn")
	assert_eq(body["disposition"], -10)
	assert_eq(body["utterance"], "what happened?")
	assert_true(body.has("available_moves"))
	assert_true(body.has("canon_log"))


func test_valid_move_kept_prose_cleaned():
	var prose = "Aye, I knew them. [MOVE: reveal(varrell_twins)]"
	var pair = _svc(DmResponse.ok(200, {"prose": prose}))
	var r = await _speak(pair, ["reveal(varrell_twins)", "refuse"])
	assert_false(r.degraded)
	assert_eq(r.display_text, "Aye, I knew them.")
	assert_eq(r.valid_moves.size(), 1)
	assert_eq(r.dropped_moves.size(), 0)


func test_illegal_move_dropped_prose_kept():
	var prose = "No. [MOVE: offer_quest(some_other)]"
	var pair = _svc(DmResponse.ok(200, {"prose": prose}))
	var r = await _speak(pair, ["offer_quest(find_the_ledger)", "refuse"])
	assert_eq(r.display_text, "No.")
	assert_eq(r.valid_moves.size(), 0)
	assert_eq(r.dropped_moves.size(), 1)


func test_adjust_disposition_alias_is_validated():
	var prose = "Fine. [ADJUST_DISPOSITION: +5]"
	var pair = _svc(DmResponse.ok(200, {"prose": prose}))
	var r = await _speak(pair, ["adjust_disposition"])
	assert_eq(r.valid_moves.size(), 1)
	assert_eq(r.valid_moves[0]["name"], "adjust_disposition")
	assert_eq(r.valid_moves[0]["args"], ["5"])


func test_end_conversation_sets_flag():
	var prose = "We're done. [MOVE: end_conversation]"
	var pair = _svc(DmResponse.ok(200, {"prose": prose}))
	var r = await _speak(pair, ["end_conversation"])
	assert_true(r.ends_conversation)


func test_facts_harvested():
	var prose = "The bridge fell. [FACT: the eastern bridge is out]"
	var pair = _svc(DmResponse.ok(200, {"prose": prose}))
	var r = await _speak(pair, ["refuse"])
	assert_eq(r.facts, ["the eastern bridge is out"])


func test_502_degrades_no_moves():
	var pair = _svc(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
	var r = await _speak(pair, ["refuse"])
	assert_true(r.degraded)
	assert_eq(r.display_text, FallbackLibrary.NPC_LAST_RESORT)
	assert_eq(r.valid_moves, [])


func test_transport_failure_degrades():
	var pair = _svc(DmResponse.failed("refused"))
	var r = await _speak(pair, ["refuse"])
	assert_true(r.degraded)
  • Step 2: Run to verify it fails

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_npc_service.gd Expected: FAIL — script does not exist.

  • Step 3: Implement
# client/scripts/net/npc_service.gd
class_name NpcService
extends RefCounted
## The client /npc/speak loop (§6). Posts persona-free request (npc_id +
## disposition + client-computed available_moves + utterance) through an injected
## transport, degrades to an authored NPC fallback on ANY failure, extracts tags
## with TagExtractor, validates moves against available_moves (MoveValidator),
## and returns an NpcResult. It applies NO state — the caller runs MoveApplier,
## keeping state writes explicit (§2).

const ENDING_MOVES := ["end_conversation", "become_hostile"]

var _transport: DmTransport
var _fallback: FallbackLibrary


func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
	_transport = transport
	_fallback = fallback


func speak(npc_id: String, utterance: String, canon_log: CanonLog,
		disposition: int, available_moves: Array) -> NpcResult:
	var resp: DmResponse = await _transport.post_json("/npc/speak", {
		"canon_log": canon_log.to_dict(),
		"npc_id": npc_id,
		"disposition": disposition,
		"available_moves": available_moves,
		"utterance": utterance,
	})
	if not resp.transport_ok or resp.status != 200 \
			or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
		return NpcResult.fallback(_fallback.npc_line())

	var extracted := TagExtractor.extract(str(resp.body["prose"]))
	var moves: Array = extracted["moves"].duplicate()
	# Fold the [ADJUST_DISPOSITION:n] alias into the move stream so the validator
	# sees one uniform shape (§ tag convention).
	for delta in extracted["dispositions"]:
		moves.append({"name": "adjust_disposition", "args": [str(delta)]})

	var checked := MoveValidator.validate(moves, available_moves)
	var ends := false
	for m in checked["valid"]:
		if m["name"] in ENDING_MOVES:
			ends = true
	return NpcResult.new(
		extracted["clean_text"], extracted["facts"],
		checked["valid"], checked["dropped"], ends, false)
  • Step 4: Run to verify it passes

Run: cd client && ./run_tests.sh -gtest=res://tests/unit/test_npc_service.gd Expected: PASS.

  • Step 5: Commit
git add client/scripts/net/npc_service.gd client/tests/unit/test_npc_service.gd
git commit -m "feat(client): NpcService.speak — post, extract, validate moves (§6)"

Task 16: Client — throwaway NPC harness scene

Files:

  • Create: client/scenes/npc_harness.tscn
  • Create: client/scripts/npc_harness.gd
  • (No unit test — this is a disposable driver, verified by running it, mirroring narrate_harness.)

Interfaces:

  • Consumes: NpcService, NpcContent, MoveApplier, ContentDB, GameState, CanonLog, HttpTransport, FallbackLibrary, an HTTPRequest node.

  • Step 1: Read the existing harness for the exact node wiring

Run: sed -n '1,80p' client/scripts/narrate_harness.gd (or the harness script path). Copy its HTTPRequest-in-tree + HttpTransport + FallbackLibrary setup verbatim; only the service call and the result handling differ.

  • Step 2: Create the harness script
# client/scripts/npc_harness.gd
extends Control
## Throwaway driver for /npc/speak (like narrate_harness). Type an utterance to
## Fenn, see his prose, watch moves apply and disposition change, until he ends
## the conversation. NOT shipped UI — the real dialogue screen is a later
## wireframe. Do not gold-plate.

const NpcService = preload("res://scripts/net/npc_service.gd")
const NpcContent = preload("res://scripts/npc/npc_content.gd")
const MoveApplier = preload("res://scripts/npc/move_applier.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")

const NPC_ID := "fenn"

@onready var _output: RichTextLabel = $VBox/Output
@onready var _entry: LineEdit = $VBox/Entry
@onready var _speak_btn: Button = $VBox/Speak

var _http: HTTPRequest
var _service: NpcService
var _content: ContentDB
var _game_state: GameState
var _canon_log: CanonLog


func _ready() -> void:
	_http = HTTPRequest.new()
	add_child(_http)
	_service = NpcService.new(HttpTransport.new(_http), FallbackLibrary.new())

	_content = ContentDB.new()
	_content.load_from(ContentDB.default_content_root())

	_game_state = GameState.new()
	_canon_log = CanonLog.new()
	_canon_log.set_location("greywater_docks", "Greywater Docks")
	_canon_log.player.name = "Aldric"
	_canon_log.player.class_id = "sellsword"

	_speak_btn.pressed.connect(_on_speak)


func _on_speak() -> void:
	var utterance := _entry.text
	if utterance.strip_edges() == "":
		return
	_entry.text = ""
	_speak_btn.disabled = true
	_append("[b]You:[/b] %s" % utterance)

	var npc_dict := _content.npc(NPC_ID)
	var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0))
	var available := NpcContent.available_moves(npc_dict, _game_state, _canon_log)

	var r: NpcResult = await _service.speak(
		NPC_ID, utterance, _canon_log, disposition, available)

	# Apply state explicitly (§2) — the service never wrote anything.
	MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID)
	for fact in r.facts:
		_canon_log.add_fact(fact)

	_append("[b]Fenn:[/b] %s" % r.display_text)
	_append("[i]moves: %s applied, %s dropped · disposition %d%s[/i]" % [
		r.valid_moves.size(), r.dropped_moves.size(),
		int(_game_state.npc_dispositions.get(NPC_ID, 0)),
		"  (degraded)" if r.degraded else ""])

	if r.ends_conversation:
		_append("[i]— Fenn ends the conversation. —[/i]")
	else:
		_speak_btn.disabled = false


func _append(bbcode: String) -> void:
	_output.append_text(bbcode + "\n\n")
  • Step 3: Create the scene

Build client/scenes/npc_harness.tscn in the editor (or hand-author the .tscn) with this tree, matching the node paths the script @onreadys expect:

Control
└─ VBox (VBoxContainer, full-rect anchors)
   ├─ Output (RichTextLabel, bbcode_enabled=true, fit_content=true, custom_minimum_size ~ (600, 400))
   ├─ Entry  (LineEdit, placeholder "Say something to Fenn…")
   └─ Speak  (Button, text "Speak")
  • Step 4: Verify it runs

Ensure the proxy is up (docker compose up with Ollama reachable). In the Godot editor, open npc_harness.tscn, run it, type “I heard you lost something.” Confirm: Fenn answers in voice; at least one legal move applies (e.g. offer_quest adds the ledger quest / reveal toggles a topic out of available_moves next turn); disposition line updates; killing the proxy shows the degraded NPC line with zero moves.

Run the full client suite to confirm nothing regressed: Run: cd client && ./run_tests.sh Expected: all unit tests PASS.

  • Step 5: Commit
git add client/scenes/npc_harness.tscn client/scripts/npc_harness.gd
git commit -m "feat(client): throwaway NPC harness — drive /npc/speak on screen"

Task 17: Server — gated live smoke against Fenn

Files:

  • Modify: api/tests/test_live_smoke.py

Interfaces: none — a gated integration check, skipped unless the live flag/env is set (mirror the existing narrate live smoke's gating exactly).

  • Step 1: Read the existing gate

Run: sed -n '1,60p' api/tests/test_live_smoke.py — copy its skip guard (env var / --run-live marker) and Ollama-reachability check verbatim.

  • Step 2: Add the NPC live smoke, using the same gate
def test_live_npc_speak_fenn(<same gate/fixtures as the narrate live test>):
    from app import npc
    log = {
        "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
        "location": {"id": "greywater_docks", "name": "Greywater Docks"},
        "party": [], "recent_events": [], "established_facts": [],
        "active_quests": [], "humiliations": [],
    }
    available = ["offer_quest(find_the_ledger)", "reveal(varrell_twins)",
                 "reveal(fenns_debt)", "adjust_disposition", "refuse",
                 "end_conversation", "become_hostile"]
    prose = npc.run(log, "fenn", 0, available, "I heard you lost something. What happened?")
    assert isinstance(prose, str) and prose.strip()
    # No Luck numbers leaked (§7); model stayed in prose.
    assert "luck" not in prose.lower()
  • Step 3: Run it live (manual, gated)

Run (matching the narrate live invocation, e.g.): cd api && RUN_LIVE=1 python -m pytest tests/test_live_smoke.py::test_live_npc_speak_fenn -v -s Expected: PASS; inspect the printed prose — Fenn is grounded in his knowledge, any emitted [MOVE:]/[FACT:] tags are well-formed, no spoilers beyond his knowledge list.

  • Step 4: Confirm the gate keeps it skipped by default

Run: cd api && python -m pytest -q Expected: the live NPC test is SKIPPED; all other tests PASS.

  • Step 5: Commit
git add api/tests/test_live_smoke.py
git commit -m "test(api): gated live smoke — Fenn answers /npc/speak in voice"

Task 18: Docs — roadmap + memory update (branch)

Files:

  • Modify: docs/roadmap.md

  • Step 1: Flip the roadmap item

In docs/roadmap.md M2, change the Bounded NPC conversation bullet from to , note it merged, and move to Streaming. Keep the one-line summary factual (mechanism + Fenn live-proven).

  • Step 2: Commit
git add docs/roadmap.md
git commit -m "docs(roadmap): M2 bounded NPC conversation done"

(The human pushes dev after the human-confirmed --no-ff merge, per §18. The project-memory canon-log-state note is updated at merge time, not here.)


Self-Review

Spec coverage — every spec section maps to a task:

  • Decisions 12 (server owns spoilers / trio split) → Tasks 1, 4, 5, 6, 10.
  • Decision 3 (disposition integer) → Tasks 3, 4, 5, 15.
  • Decision 4 (free text, no Adjudicator) → Task 15 (utterance posted straight through) + Task 16.
  • Decision 5 (canonical tag form + alias) → Tasks 11, 15 (alias fold).
  • Decision 6 (sibling service + pure validator + applier) → Tasks 11, 13, 15.
  • Decision 7 (mechanism + one NPC) → Task 6 (Fenn) + Task 17 (live).
  • Decision 8 (throwaway harness) → Task 16.
  • Move → state map (spec table) → Tasks 8, 9, 10, 13.
  • Request/response contract → Tasks 5, 15.
  • Error handling (degrade, zero moves) → Tasks 12, 15.
  • Testing surface → each task's tests + Task 17.
  • Docker/content packaging → Task 7.

Placeholder scan — Task 16 (scene .tscn) and Tasks 16/17 Step 1 intentionally say "read the existing harness/live-smoke and copy its wiring" rather than reproduce a generated .tscn / an unseen gate; these are copy-an-existing-pattern steps, not unspecified logic. All code steps contain complete code.

Type consistency — checked across tasks: available_moves is Array[String] of signatures everywhere (Tasks 10, 11, 15); moves are {name, args} from TagExtractor through MoveValidatorMoveApplier (Tasks 11, 13, 15); NpcResult fields match between Tasks 12 and 15; npc.run(...) keyword params match between Tasks 4 and 5; content.load_npc / _content_root names match between Tasks 1 and 4.