Merge feature/npc-conversation into dev

Bounded NPC conversation (/npc/speak), M2 core experiment. Server role is a
thin drop-in on the M1 pipeline (persona+knowledge server-side); client adds a
pure MoveValidator + MoveApplier over the proven DmService loop — validates
moves against live state, drops invalid, keeps prose (§6). Live-proven vs
qwen3.5 (Fenn in-voice, grounded in knowledge, no Luck leak). Live smoke caught
+ fixed two TagExtractor tag-format drifts. 18 tasks, all reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:23:00 -05:00
50 changed files with 1158 additions and 20 deletions

View File

@@ -2,6 +2,7 @@
.claude
client
content
!content
docs/*
!docs/schemas
**/__pycache__

View File

@@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY api/app ./app
COPY api/prompts ./prompts
COPY docs/schemas ./schemas
COPY content ./content
# Run as non-root.
RUN useradd --create-home --uid 1000 appuser

View File

@@ -18,5 +18,9 @@ def narrator_model() -> str:
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
def npc_model() -> str:
return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")
def call_log_path() -> str | None:
return os.environ.get("CALL_LOG_PATH") or None

View File

@@ -8,6 +8,7 @@ scenes into play.
"""
import json
import os
from pathlib import Path
@@ -48,3 +49,30 @@ def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
if npc_id not in world["npcs"]:
missing.append(f"npc:{npc_id}")
return missing
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)

View File

@@ -15,6 +15,7 @@ from pydantic import BaseModel
from .canon_log import validate_canon_log
from .narrate import run as narrate_run
from .npc import UnknownNpc, run as npc_run
from .ollama_client import ModelError
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
@@ -42,6 +43,22 @@ def valid_turn(req: TurnRequest) -> TurnRequest:
return req
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
@app.get("/health")
def health() -> dict:
"""Liveness probe for compose / fly.io."""
@@ -74,8 +91,18 @@ def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
@app.post("/npc/speak")
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
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)})
@app.post("/party/banter")

59
api/app/npc.py Normal file
View File

@@ -0,0 +1,59 @@
"""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

View File

@@ -55,3 +55,57 @@ def render_digest(canon_log: dict) -> str:
lines.append("")
lines.append("Describe the scene as it is now.")
return "\n".join(lines)
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)

View File

@@ -22,4 +22,10 @@ def for_role(role: str) -> RoleConfig:
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
)
if role == "npc":
return RoleConfig(
model=config.npc_model(),
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 320},
think=False,
)
raise KeyError(f"no routing for role: {role}")

View File

@@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
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.

View File

@@ -0,0 +1,41 @@
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
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

View File

@@ -31,7 +31,15 @@ def test_health_ok():
def test_every_role_endpoint_accepts_a_valid_canon_log():
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": VALID_LOG})
if path == "/npc/speak":
# /npc/speak has additional required fields
body = {
"canon_log": VALID_LOG, "npc_id": "brannoc_thane",
"disposition": 0, "available_moves": [], "utterance": "test"
}
else:
body = {"canon_log": VALID_LOG}
r = client.post(path, json=body)
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
@@ -39,7 +47,15 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log():
bad = json.loads(json.dumps(VALID_LOG))
bad["player"]["luck"] = 5 # §7 leak
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": bad})
if path == "/npc/speak":
# /npc/speak has additional required fields
body = {
"canon_log": bad, "npc_id": "brannoc_thane",
"disposition": 0, "available_moves": [], "utterance": "test"
}
else:
body = {"canon_log": bad}
r = client.post(path, json=body)
assert r.status_code == 422, f"{path} accepted an invalid log"
assert "canon_log_errors" in r.json()["detail"]

View File

@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
import app.narrate as narrate
import app.npc as npc
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
@@ -17,3 +18,39 @@ def test_narrator_returns_real_prose():
text = narrate.run(VALID)
assert isinstance(text, str)
assert len(text.strip()) > 40 # real prose, not a blip
@pytest.mark.live
def test_live_npc_speak_fenn():
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the /npc/speak wire
end-to-end against the authored "fenn" NPC: persona + knowledge digest →
model → non-empty in-voice prose. Not asserted: tag content
(nondeterministic). Run with: pytest --run-live
"""
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)
assert len(prose.strip()) > 40 # real prose, not a blip
# No Luck numbers/mechanics leaked into NPC dialogue (§7).
assert "luck" not in prose.lower()

55
api/tests/test_npc.py Normal file
View File

@@ -0,0 +1,55 @@
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

View File

@@ -0,0 +1,59 @@
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

View File

@@ -40,3 +40,26 @@ def test_narrator_body_is_authored():
assert "TBD" not in body
assert "[FACT:" in body # the tag instruction is present
assert "second person" in body # the core task is present
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

View File

@@ -19,3 +19,11 @@ def test_env_overrides_model(monkeypatch):
def test_unknown_role_raises():
with pytest.raises(KeyError):
for_role("wizard")
def test_npc_role_routes_to_npc_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
cfg = for_role("npc")
assert cfg.model == "some-npc-model"
assert cfg.think is False
assert "num_predict" in cfg.options

View File

@@ -0,0 +1,5 @@
{
"npc": [
"The figure looks you over, says nothing you can use, and turns back to their work."
]
}

View File

@@ -0,0 +1,10 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/harness/npc_harness.gd" id="1_harness"]
[node name="NpcHarness" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_harness")

View File

@@ -58,6 +58,20 @@ func adjust_disposition(id: String, delta: int) -> bool:
return true
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
func set_quest_status(id: String, status: String) -> bool:
for q in active_quests:
if q.id == id:

View File

@@ -0,0 +1,94 @@
extends Control
## Throwaway harness — the bounded-NPC loop on screen, not shipped UI. Type an
## utterance to Fenn, see his voiced prose, watch moves apply and disposition
## change, until he ends the conversation. Mirrors narrate_harness. Run this
## scene directly (F6) with the proxy + Ollama up.
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"
var _service: NpcService
var _content: ContentDB
var _game_state: GameState
var _canon_log: CanonLog
var _output: RichTextLabel
var _entry: LineEdit
var _speak_btn: Button
func _ready() -> void:
var 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"
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
# can offer_quest it during the conversation.
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 12)
add_child(vbox)
_output = RichTextLabel.new()
_output.bbcode_enabled = true
_output.fit_content = true
_output.custom_minimum_size = Vector2(600, 400)
vbox.add_child(_output)
_entry = LineEdit.new()
_entry.placeholder_text = "Say something to Fenn…"
vbox.add_child(_entry)
_speak_btn = Button.new()
_speak_btn.text = "Speak"
_speak_btn.pressed.connect(_on_speak)
vbox.add_child(_speak_btn)
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 disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0))
var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log)
var r: NpcResult = await _service.speak(
NPC_ID, utterance, _canon_log, disposition, available)
# Apply state explicitly (§2) — the service applied nothing.
MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID)
for f in r.facts:
_canon_log.add_fact(f)
_append("[b]Fenn:[/b] %s" % r.display_text)
_append("[i]moves: %d applied, %d 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")

View File

@@ -0,0 +1 @@
uid://cbmr6caohwsxb

View File

@@ -1,37 +1,42 @@
class_name FallbackLibrary
extends RefCounted
## Authored degraded-DM fallback text (charter §13). The client shows one of
## these lines on ANY failure, in the Narrator's voice — never an error. If the
## content file is missing or malformed the library returns a hardcoded
## constant, so the fallback itself cannot fail.
## 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) -> void:
_lines = _load(path)
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
static func _load(path: String) -> Array:
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)
# Use the instance API, not JSON.parse_string(): the static convenience
# method pushes an engine-level error on malformed input, which is noisy
# and (in this project's test config) turns a handled failure into a
# reported test error. The instance parse() just returns an Error code.
# 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("narrator", [])
var lines = data.get(key, [])
return lines if typeof(lines) == TYPE_ARRAY else []

View File

@@ -0,0 +1,27 @@
# 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)

View File

@@ -0,0 +1 @@
uid://chpx73tbklp51

View File

@@ -0,0 +1,48 @@
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)

View File

@@ -0,0 +1 @@
uid://b5perr1c1a61o

View File

@@ -0,0 +1,37 @@
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

View File

@@ -0,0 +1 @@
uid://dhd5r0ugcx5bs

View File

@@ -0,0 +1,29 @@
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}

View File

@@ -0,0 +1 @@
uid://cc7n3rqt1pvdv

View File

@@ -0,0 +1,32 @@
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

View File

@@ -0,0 +1 @@
uid://c64k81yn5arnn

View File

@@ -9,6 +9,8 @@ var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty
var revealed_topics: Dictionary = {} # topic_id -> true
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
func set_luck(v: int) -> void:
@@ -29,3 +31,27 @@ func set_npc_disposition(id: String, v: int) -> void:
func add_item(item_id: String, qty: int) -> void:
inventory[item_id] = int(inventory.get(item_id, 0)) + qty
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)

View File

@@ -7,8 +7,9 @@ extends RefCounted
static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]")
static var _dispo_re: RegEx = RegEx.create_from_string("\\[ADJUST_DISPOSITION:\\s*([+-]?\\d+)\\]")
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)\\(([^)]*)\\)\\]")
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)(?:\\(([^)]*)\\))?\\]")
static var _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]")
static var _bare_move_re: RegEx = RegEx.create_from_string("\\[[a-z_]+\\([^\\]]*\\)\\]")
static func extract(prose: String) -> Dictionary:
@@ -30,6 +31,7 @@ static func extract(prose: String) -> Dictionary:
moves.append({"name": m.get_string(1), "args": args})
var clean := _any_re.sub(prose, "", true)
clean = _bare_move_re.sub(clean, "", true)
# Collapse whitespace left where inline tags were removed.
while clean.contains(" "):
clean = clean.replace(" ", " ")

View File

@@ -46,3 +46,15 @@ func test_set_quest_status_rejects_bad_enum():
func test_unsupported_schema_version_returns_null():
assert_null(CanonLog.from_dict({"schema_version": 2}))
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)

View File

@@ -31,3 +31,15 @@ func test_empty_list_uses_last_resort():
f.close()
var lib = FallbackLibrary.new(path)
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
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(), "")

View File

@@ -28,3 +28,26 @@ func test_luck_descriptor_delegates():
var s = GameState.new()
s.luck = 4
assert_eq(s.luck_descriptor(), "Fortune spits on you")
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"))

View File

@@ -0,0 +1,55 @@
# 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)

View File

@@ -0,0 +1 @@
uid://cysf45uyit1hv

View File

@@ -0,0 +1,38 @@
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)

View File

@@ -0,0 +1 @@
uid://bubsx2qbuesxi

View File

@@ -2,6 +2,7 @@ extends "res://addons/gut/test.gd"
const DmResponse = preload("res://scripts/net/dm_response.gd")
const NarrateResult = preload("res://scripts/net/narrate_result.gd")
const NpcResult = preload("res://scripts/net/npc_result.gd")
const DmTransport = preload("res://scripts/net/dm_transport.gd")
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
@@ -52,3 +53,12 @@ func test_proxy_config_override():
ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000")
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
func test_npc_result_fallback_is_degraded_with_no_moves():
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)

View File

@@ -0,0 +1,44 @@
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)

View File

@@ -0,0 +1 @@
uid://bxbq6nm43vlva

View File

@@ -0,0 +1,92 @@
# 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)

View File

@@ -0,0 +1 @@
uid://dt8idmag53m7u

View File

@@ -38,3 +38,45 @@ func test_no_tags_is_clean_passthrough():
assert_eq(r["dispositions"], [])
assert_eq(r["moves"], [])
assert_eq(r["clean_text"], "no tags here")
# LF1 — a live model emitted [MOVE: refuse] with no parens at all. The move
# must still be recognized so the legal move isn't silently dropped.
func test_move_without_parens_is_parsed():
var r := TagExtractor.extract("[MOVE: refuse]")
assert_eq(r["moves"][0]["name"], "refuse")
assert_eq(r["moves"][0]["args"], [])
# LF1 regression guard — explicit empty parens must still work as before.
func test_move_with_explicit_empty_parens_still_parsed():
var r := TagExtractor.extract("[MOVE: end_conversation()]")
assert_eq(r["moves"][0]["name"], "end_conversation")
assert_eq(r["moves"][0]["args"], [])
# LF1 regression guard — well-formed args must still parse unchanged.
func test_move_with_args_still_parsed():
var r := TagExtractor.extract("[MOVE: reveal(varrell_twins)]")
assert_eq(r["moves"][0]["name"], "reveal")
assert_eq(r["moves"][0]["args"], ["varrell_twins"])
# LF2 — a live model emitted [adjust_disposition(-5)] without the MOVE:
# prefix. It must not be parsed/applied (untrusted, malformed), but it must
# also not leak into player-visible prose as literal tag text.
func test_bare_lowercase_move_shape_is_scrubbed_not_parsed():
var r := TagExtractor.extract("He glares. [adjust_disposition(-5)] Fine.")
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))
assert_eq(r["moves"], [])
assert_eq(r["dispositions"], [])
# LF2 regression guard — a well-formed MOVE tag alongside a bare lowercase
# shape in the same prose must still be parsed and stripped correctly.
func test_wellformed_move_still_parsed_alongside_bare_lowercase_shape():
var r := TagExtractor.extract("[MOVE: reveal(x)] He glares. [adjust_disposition(-5)] Fine.")
assert_eq(r["moves"][0]["name"], "reveal")
assert_eq(r["moves"][0]["args"], ["x"])
assert_false(r["clean_text"].contains("[MOVE:"))
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))

View File

@@ -0,0 +1,17 @@
{
"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"]
}
}

View File

@@ -26,5 +26,6 @@ services:
- ./api/app:/app/app:ro
- ./api/prompts:/app/prompts:ro
- ./docs/schemas:/app/schemas:ro
- ./content:/app/content:ro
command: >
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -48,8 +48,8 @@ The milestones are ordered to reach the POC's one question as early as possible,
### M2 — Prove aliveness ◀ *(the POC's one question)*
-**Client HTTP loop** — client posts its canon log to `/dm/narrate`, displays the prose, harvests `[FACT]` via its `TagExtractor` into its own log, and shows the degraded-DM authored fallback (§13) on any non-200. First end-to-end vertical slice: **state → HTTP → model → prose → screen → fact-harvest.** Pure `DmService` core behind an injectable transport seam; throwaway harness scene for the first on-screen prose. **Live-proven** against qwen3.5 through Docker (grounded scene prose, no numbers per §7). 67/67 client tests; merged to `dev` (`b949cd2`). Surfaced + fixed two Docker-dev gaps in M1 (prompts not packaged, no container→host-Ollama route) — M1's live proof had been venv-on-host only. *§2: state (client owns the loop, consumes text) · goal: put the Narrator on screen.*
- **Bounded NPC conversation**`/npc/speak` with persona + `knowledge[]` + `available_moves[]`; client validates and applies moves, silently drops the invalid, keeps the prose (§6). Drops onto the *proven* client loop — adds move-tag validation on top of a display+harvest pipeline that already works. **The core experiment: this is the surface §17 is really asking about.** *§2: both (text = the NPC's voice · state = the validated moves) · depends on the client loop · goal: answer the one question.*
- **Streaming** (§14) — server streams Ollama → client; latency reads as *the DM is speaking* rather than *the game is frozen*, hiding the ~2s call. **Gated on aliveness:** landed only after the dialogue experiment above says the prose is worth polishing. Additive to `ollama_client` (shaped for it) + an SSE/chunked response + the client stream consumer. *§2: text · depends on the client loop · goal: polish, not proof.*
- **Bounded NPC conversation**`/npc/speak` with server-owned persona + `knowledge[]` (spoilers stay off the client) and client-computed `available_moves[]`; the client validates each emitted move against live state, applies the valid, silently drops the invalid, and always keeps the prose (§6). Server role is a thin drop-in on the M1 pipeline (`npc.run` mirrors `narrate.py`); the client added a pure `MoveValidator` (membership is the whole test) + a `MoveApplier` that writes town-NPC disposition to `GameState.npc_dispositions` (not the party log), plus `NpcContent.available_moves` as the single legality home. Free text goes straight to the NPC prompt (no Adjudicator needed — the moves are the NPC's). **Live-proven** against qwen3.5: Fenn answered in-voice, grounded strictly in his authored knowledge, no Luck leak (§7), with `reveal`/`offer_quest` moves landing. The live smoke also caught two real tag-format drifts a unit test can't — paren-less zero-arg moves and a dropped `MOVE:` prefix leaking into prose — both fixed by making `TagExtractor` tolerant/scrub-safe (backward-compatible; narrator path unaffected). Built on `feature/npc-conversation`; awaiting the human's `--no-ff` merge to `dev` (§18). *§2: both (text = the NPC's voice · state = the validated moves) · goal: answer the one question.*
- **Streaming** (§14) — server streams Ollama → client; latency reads as *the DM is speaking* rather than *the game is frozen*, hiding the ~2s call. **Gated on aliveness:** landed only after the dialogue experiment above says the prose is worth polishing. Additive to `ollama_client` (shaped for it) + an SSE/chunked response + the client stream consumer. *§2: text · depends on the client loop · goal: polish, not proof.*
### M3 — Make it a game *(the playable ~30-min slice — dungeon first, per the sequencing call)*
-**Adjudicator**`/dm/adjudicate`: free text → a legal action or an in-world rejection, **strict JSON** (§12), small/fast model, the one-retry-with-error-appended path. Unlocks the hybrid input model (§15) the playable slice needs — the "what do you do?" boundary. *§2: both (text = the in-world reason · state = the chosen action) · depends on the pipeline · goal: free-text world actions.*