# 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**

```python
# 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`):

```python
import os
```

Append to `api/app/content.py`:

```python
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**

```bash
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`:

```python
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`:

```python
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`:

```python
    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**

```bash
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):

```python
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`:

```python
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**

```bash
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**

```python
# 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**

```python
# 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**

```bash
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**

```python
# 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`:

```python
from .npc import UnknownNpc, run as npc_run
```

Add a request model after `class TurnRequest`:

```python
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:

```python
@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**

```bash
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`:

```python
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`:

```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:

```markdown
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**

```bash
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:

```dockerfile
COPY content ./content
```

- [ ] **Step 2: Add the mount to `docker-compose.yml`**

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

```yaml
      - ./content:/app/content:ro
```

- [ ] **Step 3: Verify the image resolves content**

Run:
```bash
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**

```bash
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`:

```gdscript
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:

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

Append the methods:

```gdscript
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**

```bash
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`:

```gdscript
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`:

```gdscript
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**

```bash
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**

```gdscript
# 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**

```gdscript
# 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**

```bash
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**

```gdscript
# 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**

```gdscript
# 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**

```bash
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`:

```gdscript
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**

```gdscript
# 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**

```bash
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**

```gdscript
# 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**

```gdscript
# 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**

```bash
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`:

```gdscript
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):

```gdscript
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`:

```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**

```bash
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**

```gdscript
# 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**

```gdscript
# 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**

```bash
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**

```gdscript
# 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 `@onready`s 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**

```bash
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**

```python
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**

```bash
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**

```bash
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 1–2 (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 `MoveValidator` → `MoveApplier` (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.
