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:
@@ -2,6 +2,7 @@
|
|||||||
.claude
|
.claude
|
||||||
client
|
client
|
||||||
content
|
content
|
||||||
|
!content
|
||||||
docs/*
|
docs/*
|
||||||
!docs/schemas
|
!docs/schemas
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY api/app ./app
|
COPY api/app ./app
|
||||||
COPY api/prompts ./prompts
|
COPY api/prompts ./prompts
|
||||||
COPY docs/schemas ./schemas
|
COPY docs/schemas ./schemas
|
||||||
|
COPY content ./content
|
||||||
|
|
||||||
# Run as non-root.
|
# Run as non-root.
|
||||||
RUN useradd --create-home --uid 1000 appuser
|
RUN useradd --create-home --uid 1000 appuser
|
||||||
|
|||||||
@@ -18,5 +18,9 @@ def narrator_model() -> str:
|
|||||||
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
|
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:
|
def call_log_path() -> str | None:
|
||||||
return os.environ.get("CALL_LOG_PATH") or None
|
return os.environ.get("CALL_LOG_PATH") or None
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ scenes into play.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
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"]:
|
if npc_id not in world["npcs"]:
|
||||||
missing.append(f"npc:{npc_id}")
|
missing.append(f"npc:{npc_id}")
|
||||||
return missing
|
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)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from .canon_log import validate_canon_log
|
from .canon_log import validate_canon_log
|
||||||
from .narrate import run as narrate_run
|
from .narrate import run as narrate_run
|
||||||
|
from .npc import UnknownNpc, run as npc_run
|
||||||
from .ollama_client import ModelError
|
from .ollama_client import ModelError
|
||||||
|
|
||||||
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
||||||
@@ -42,6 +43,22 @@ def valid_turn(req: TurnRequest) -> TurnRequest:
|
|||||||
return req
|
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")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
"""Liveness probe for compose / fly.io."""
|
"""Liveness probe for compose / fly.io."""
|
||||||
@@ -74,8 +91,18 @@ def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/npc/speak")
|
@app.post("/npc/speak")
|
||||||
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
|
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
|
||||||
return {"detail": "not implemented"}
|
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")
|
@app.post("/party/banter")
|
||||||
|
|||||||
59
api/app/npc.py
Normal file
59
api/app/npc.py
Normal 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
|
||||||
@@ -55,3 +55,57 @@ def render_digest(canon_log: dict) -> str:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Describe the scene as it is now.")
|
lines.append("Describe the scene as it is now.")
|
||||||
return "\n".join(lines)
|
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)
|
||||||
|
|||||||
@@ -22,4 +22,10 @@ def for_role(role: str) -> RoleConfig:
|
|||||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
|
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
|
||||||
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
|
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}")
|
raise KeyError(f"no routing for role: {role}")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
41
api/tests/test_content_npc.py
Normal file
41
api/tests/test_content_npc.py
Normal 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
|
||||||
@@ -31,7 +31,15 @@ def test_health_ok():
|
|||||||
|
|
||||||
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
||||||
for path in ROLE_ENDPOINTS:
|
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}"
|
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 = json.loads(json.dumps(VALID_LOG))
|
||||||
bad["player"]["luck"] = 5 # §7 leak
|
bad["player"]["luck"] = 5 # §7 leak
|
||||||
for path in ROLE_ENDPOINTS:
|
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 r.status_code == 422, f"{path} accepted an invalid log"
|
||||||
assert "canon_log_errors" in r.json()["detail"]
|
assert "canon_log_errors" in r.json()["detail"]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import app.narrate as narrate
|
import app.narrate as narrate
|
||||||
|
import app.npc as npc
|
||||||
|
|
||||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
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)
|
text = narrate.run(VALID)
|
||||||
assert isinstance(text, str)
|
assert isinstance(text, str)
|
||||||
assert len(text.strip()) > 40 # real prose, not a blip
|
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
55
api/tests/test_npc.py
Normal 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
|
||||||
59
api/tests/test_npc_endpoint.py
Normal file
59
api/tests/test_npc_endpoint.py
Normal 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
|
||||||
@@ -40,3 +40,26 @@ def test_narrator_body_is_authored():
|
|||||||
assert "TBD" not in body
|
assert "TBD" not in body
|
||||||
assert "[FACT:" in body # the tag instruction is present
|
assert "[FACT:" in body # the tag instruction is present
|
||||||
assert "second person" in body # the core task 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
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ def test_env_overrides_model(monkeypatch):
|
|||||||
def test_unknown_role_raises():
|
def test_unknown_role_raises():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
for_role("wizard")
|
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
|
||||||
|
|||||||
5
client/content/fallback/npc.json
Normal file
5
client/content/fallback/npc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"npc": [
|
||||||
|
"The figure looks you over, says nothing you can use, and turns back to their work."
|
||||||
|
]
|
||||||
|
}
|
||||||
10
client/scenes/npc_harness.tscn
Normal file
10
client/scenes/npc_harness.tscn
Normal 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")
|
||||||
@@ -58,6 +58,20 @@ func adjust_disposition(id: String, delta: int) -> bool:
|
|||||||
return true
|
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:
|
func set_quest_status(id: String, status: String) -> bool:
|
||||||
for q in active_quests:
|
for q in active_quests:
|
||||||
if q.id == id:
|
if q.id == id:
|
||||||
|
|||||||
94
client/scripts/harness/npc_harness.gd
Normal file
94
client/scripts/harness/npc_harness.gd
Normal 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")
|
||||||
1
client/scripts/harness/npc_harness.gd.uid
Normal file
1
client/scripts/harness/npc_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cbmr6caohwsxb
|
||||||
@@ -1,37 +1,42 @@
|
|||||||
class_name FallbackLibrary
|
class_name FallbackLibrary
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Authored degraded-DM fallback text (charter §13). The client shows one of
|
## Authored degraded-DM fallback text (charter §13). On ANY failure the client
|
||||||
## these lines on ANY failure, in the Narrator's voice — never an error. If the
|
## shows one of these lines in-voice — never an error. Missing/malformed content
|
||||||
## content file is missing or malformed the library returns a hardcoded
|
## yields a hardcoded constant, so the fallback itself cannot fail.
|
||||||
## constant, so the fallback itself cannot fail.
|
|
||||||
|
|
||||||
const PATH := "res://content/fallback/narrator.json"
|
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 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 _lines: Array = []
|
||||||
|
var _npc_lines: Array = []
|
||||||
|
|
||||||
|
|
||||||
func _init(path := PATH) -> void:
|
func _init(path := PATH, npc_path := NPC_PATH) -> void:
|
||||||
_lines = _load(path)
|
_lines = _load(path, "narrator")
|
||||||
|
_npc_lines = _load(npc_path, "npc")
|
||||||
|
|
||||||
|
|
||||||
func narrator_line() -> String:
|
func narrator_line() -> String:
|
||||||
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
|
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):
|
if not FileAccess.file_exists(path):
|
||||||
return []
|
return []
|
||||||
var text := FileAccess.get_file_as_string(path)
|
var text := FileAccess.get_file_as_string(path)
|
||||||
# Use the instance API, not JSON.parse_string(): the static convenience
|
# Instance parse() (not JSON.parse_string) so malformed input returns an
|
||||||
# method pushes an engine-level error on malformed input, which is noisy
|
# Error code instead of pushing an engine error the test config promotes.
|
||||||
# and (in this project's test config) turns a handled failure into a
|
|
||||||
# reported test error. The instance parse() just returns an Error code.
|
|
||||||
var json := JSON.new()
|
var json := JSON.new()
|
||||||
if json.parse(text) != OK:
|
if json.parse(text) != OK:
|
||||||
return []
|
return []
|
||||||
var data = json.get_data()
|
var data = json.get_data()
|
||||||
if typeof(data) != TYPE_DICTIONARY:
|
if typeof(data) != TYPE_DICTIONARY:
|
||||||
return []
|
return []
|
||||||
var lines = data.get("narrator", [])
|
var lines = data.get(key, [])
|
||||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||||
|
|||||||
27
client/scripts/net/npc_result.gd
Normal file
27
client/scripts/net/npc_result.gd
Normal 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)
|
||||||
1
client/scripts/net/npc_result.gd.uid
Normal file
1
client/scripts/net/npc_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://chpx73tbklp51
|
||||||
48
client/scripts/net/npc_service.gd
Normal file
48
client/scripts/net/npc_service.gd
Normal 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)
|
||||||
1
client/scripts/net/npc_service.gd.uid
Normal file
1
client/scripts/net/npc_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b5perr1c1a61o
|
||||||
37
client/scripts/npc/move_applier.gd
Normal file
37
client/scripts/npc/move_applier.gd
Normal 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
|
||||||
1
client/scripts/npc/move_applier.gd.uid
Normal file
1
client/scripts/npc/move_applier.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dhd5r0ugcx5bs
|
||||||
29
client/scripts/npc/move_validator.gd
Normal file
29
client/scripts/npc/move_validator.gd
Normal 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}
|
||||||
1
client/scripts/npc/move_validator.gd.uid
Normal file
1
client/scripts/npc/move_validator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cc7n3rqt1pvdv
|
||||||
32
client/scripts/npc/npc_content.gd
Normal file
32
client/scripts/npc/npc_content.gd
Normal 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
|
||||||
1
client/scripts/npc/npc_content.gd.uid
Normal file
1
client/scripts/npc/npc_content.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c64k81yn5arnn
|
||||||
@@ -9,6 +9,8 @@ var luck_base: int = 0
|
|||||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||||
var inventory: Dictionary = {} # item_id -> qty
|
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:
|
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:
|
func add_item(item_id: String, qty: int) -> void:
|
||||||
inventory[item_id] = int(inventory.get(item_id, 0)) + qty
|
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)
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ extends RefCounted
|
|||||||
|
|
||||||
static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]")
|
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 _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 _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:
|
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})
|
moves.append({"name": m.get_string(1), "args": args})
|
||||||
|
|
||||||
var clean := _any_re.sub(prose, "", true)
|
var clean := _any_re.sub(prose, "", true)
|
||||||
|
clean = _bare_move_re.sub(clean, "", true)
|
||||||
# Collapse whitespace left where inline tags were removed.
|
# Collapse whitespace left where inline tags were removed.
|
||||||
while clean.contains(" "):
|
while clean.contains(" "):
|
||||||
clean = clean.replace(" ", " ")
|
clean = clean.replace(" ", " ")
|
||||||
|
|||||||
@@ -46,3 +46,15 @@ func test_set_quest_status_rejects_bad_enum():
|
|||||||
|
|
||||||
func test_unsupported_schema_version_returns_null():
|
func test_unsupported_schema_version_returns_null():
|
||||||
assert_null(CanonLog.from_dict({"schema_version": 2}))
|
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)
|
||||||
|
|||||||
@@ -31,3 +31,15 @@ func test_empty_list_uses_last_resort():
|
|||||||
f.close()
|
f.close()
|
||||||
var lib = FallbackLibrary.new(path)
|
var lib = FallbackLibrary.new(path)
|
||||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
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(), "")
|
||||||
|
|||||||
@@ -28,3 +28,26 @@ func test_luck_descriptor_delegates():
|
|||||||
var s = GameState.new()
|
var s = GameState.new()
|
||||||
s.luck = 4
|
s.luck = 4
|
||||||
assert_eq(s.luck_descriptor(), "Fortune spits on you")
|
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"))
|
||||||
|
|||||||
55
client/tests/unit/test_move_applier.gd
Normal file
55
client/tests/unit/test_move_applier.gd
Normal 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)
|
||||||
1
client/tests/unit/test_move_applier.gd.uid
Normal file
1
client/tests/unit/test_move_applier.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cysf45uyit1hv
|
||||||
38
client/tests/unit/test_move_validator.gd
Normal file
38
client/tests/unit/test_move_validator.gd
Normal 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)
|
||||||
1
client/tests/unit/test_move_validator.gd.uid
Normal file
1
client/tests/unit/test_move_validator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bubsx2qbuesxi
|
||||||
@@ -2,6 +2,7 @@ extends "res://addons/gut/test.gd"
|
|||||||
|
|
||||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||||
const NarrateResult = preload("res://scripts/net/narrate_result.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 DmTransport = preload("res://scripts/net/dm_transport.gd")
|
||||||
const ProxyConfig = preload("res://scripts/net/proxy_config.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")
|
ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000")
|
||||||
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
|
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
|
||||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
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)
|
||||||
|
|||||||
44
client/tests/unit/test_npc_content.gd
Normal file
44
client/tests/unit/test_npc_content.gd
Normal 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)
|
||||||
1
client/tests/unit/test_npc_content.gd.uid
Normal file
1
client/tests/unit/test_npc_content.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bxbq6nm43vlva
|
||||||
92
client/tests/unit/test_npc_service.gd
Normal file
92
client/tests/unit/test_npc_service.gd
Normal 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)
|
||||||
1
client/tests/unit/test_npc_service.gd.uid
Normal file
1
client/tests/unit/test_npc_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dt8idmag53m7u
|
||||||
@@ -38,3 +38,45 @@ func test_no_tags_is_clean_passthrough():
|
|||||||
assert_eq(r["dispositions"], [])
|
assert_eq(r["dispositions"], [])
|
||||||
assert_eq(r["moves"], [])
|
assert_eq(r["moves"], [])
|
||||||
assert_eq(r["clean_text"], "no tags here")
|
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)]"))
|
||||||
|
|||||||
17
content/world/npcs/fenn.json
Normal file
17
content/world/npcs/fenn.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,5 +26,6 @@ services:
|
|||||||
- ./api/app:/app/app:ro
|
- ./api/app:/app/app:ro
|
||||||
- ./api/prompts:/app/prompts:ro
|
- ./api/prompts:/app/prompts:ro
|
||||||
- ./docs/schemas:/app/schemas:ro
|
- ./docs/schemas:/app/schemas:ro
|
||||||
|
- ./content:/app/content:ro
|
||||||
command: >
|
command: >
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|||||||
@@ -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)*
|
### 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.*
|
- ✅ **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.*
|
- ✅ **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.*
|
- ▶ **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)*
|
### 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.*
|
- ○ **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.*
|
||||||
|
|||||||
Reference in New Issue
Block a user