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
|
||||
client
|
||||
content
|
||||
!content
|
||||
docs/*
|
||||
!docs/schemas
|
||||
**/__pycache__
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
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("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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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():
|
||||
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"]
|
||||
|
||||
|
||||
@@ -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
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 "[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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
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
|
||||
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 []
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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(" ", " ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(), "")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
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 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)
|
||||
|
||||
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["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)]"))
|
||||
|
||||
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/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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Reference in New Issue
Block a user