diff --git a/.dockerignore b/.dockerignore index 81987b7..fcecea2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ .claude client content +!content docs/* !docs/schemas **/__pycache__ diff --git a/api/Dockerfile b/api/Dockerfile index 3be8795..0cf9093 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 diff --git a/api/app/config.py b/api/app/config.py index 4981f57..4d07e1e 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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 diff --git a/api/app/content.py b/api/app/content.py index f95efd0..69f135d 100644 --- a/api/app/content.py +++ b/api/app/content.py @@ -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) diff --git a/api/app/main.py b/api/app/main.py index 5ac3ffa..9503394 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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") diff --git a/api/app/npc.py b/api/app/npc.py new file mode 100644 index 0000000..7f2499d --- /dev/null +++ b/api/app/npc.py @@ -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 diff --git a/api/app/prompts.py b/api/app/prompts.py index f334ea5..094f3ab 100644 --- a/api/app/prompts.py +++ b/api/app/prompts.py @@ -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) diff --git a/api/app/routing.py b/api/app/routing.py index 98d9dba..8cf1669 100644 --- a/api/app/routing.py +++ b/api/app/routing.py @@ -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}") diff --git a/api/prompts/npc.md b/api/prompts/npc.md index 96afcea..241863b 100644 --- a/api/prompts/npc.md +++ b/api/prompts/npc.md @@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops --- - +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. diff --git a/api/tests/test_content_npc.py b/api/tests/test_content_npc.py new file mode 100644 index 0000000..ba8aedd --- /dev/null +++ b/api/tests/test_content_npc.py @@ -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 diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py index b3f6d70..0fa84a0 100644 --- a/api/tests/test_endpoints.py +++ b/api/tests/test_endpoints.py @@ -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"] diff --git a/api/tests/test_live_smoke.py b/api/tests/test_live_smoke.py index c76066c..887741b 100644 --- a/api/tests/test_live_smoke.py +++ b/api/tests/test_live_smoke.py @@ -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() diff --git a/api/tests/test_npc.py b/api/tests/test_npc.py new file mode 100644 index 0000000..c4d4893 --- /dev/null +++ b/api/tests/test_npc.py @@ -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 diff --git a/api/tests/test_npc_endpoint.py b/api/tests/test_npc_endpoint.py new file mode 100644 index 0000000..791988d --- /dev/null +++ b/api/tests/test_npc_endpoint.py @@ -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 diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py index 940fbeb..d1fceb1 100644 --- a/api/tests/test_prompts.py +++ b/api/tests/test_prompts.py @@ -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 diff --git a/api/tests/test_routing.py b/api/tests/test_routing.py index d455598..fe1eef4 100644 --- a/api/tests/test_routing.py +++ b/api/tests/test_routing.py @@ -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 diff --git a/client/content/fallback/npc.json b/client/content/fallback/npc.json new file mode 100644 index 0000000..0520bed --- /dev/null +++ b/client/content/fallback/npc.json @@ -0,0 +1,5 @@ +{ + "npc": [ + "The figure looks you over, says nothing you can use, and turns back to their work." + ] +} diff --git a/client/scenes/npc_harness.tscn b/client/scenes/npc_harness.tscn new file mode 100644 index 0000000..200b353 --- /dev/null +++ b/client/scenes/npc_harness.tscn @@ -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") diff --git a/client/scripts/canon_log/canon_log.gd b/client/scripts/canon_log/canon_log.gd index 7fe7c9d..01ca212 100644 --- a/client/scripts/canon_log/canon_log.gd +++ b/client/scripts/canon_log/canon_log.gd @@ -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: diff --git a/client/scripts/harness/npc_harness.gd b/client/scripts/harness/npc_harness.gd new file mode 100644 index 0000000..a9de9d6 --- /dev/null +++ b/client/scripts/harness/npc_harness.gd @@ -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") diff --git a/client/scripts/harness/npc_harness.gd.uid b/client/scripts/harness/npc_harness.gd.uid new file mode 100644 index 0000000..e85093d --- /dev/null +++ b/client/scripts/harness/npc_harness.gd.uid @@ -0,0 +1 @@ +uid://cbmr6caohwsxb diff --git a/client/scripts/net/fallback_library.gd b/client/scripts/net/fallback_library.gd index 408a7be..73a15b5 100644 --- a/client/scripts/net/fallback_library.gd +++ b/client/scripts/net/fallback_library.gd @@ -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 [] diff --git a/client/scripts/net/npc_result.gd b/client/scripts/net/npc_result.gd new file mode 100644 index 0000000..3ef58b8 --- /dev/null +++ b/client/scripts/net/npc_result.gd @@ -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) diff --git a/client/scripts/net/npc_result.gd.uid b/client/scripts/net/npc_result.gd.uid new file mode 100644 index 0000000..d29d4bf --- /dev/null +++ b/client/scripts/net/npc_result.gd.uid @@ -0,0 +1 @@ +uid://chpx73tbklp51 diff --git a/client/scripts/net/npc_service.gd b/client/scripts/net/npc_service.gd new file mode 100644 index 0000000..0feea5b --- /dev/null +++ b/client/scripts/net/npc_service.gd @@ -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) diff --git a/client/scripts/net/npc_service.gd.uid b/client/scripts/net/npc_service.gd.uid new file mode 100644 index 0000000..bfd9318 --- /dev/null +++ b/client/scripts/net/npc_service.gd.uid @@ -0,0 +1 @@ +uid://b5perr1c1a61o diff --git a/client/scripts/npc/move_applier.gd b/client/scripts/npc/move_applier.gd new file mode 100644 index 0000000..1aeaa0c --- /dev/null +++ b/client/scripts/npc/move_applier.gd @@ -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 diff --git a/client/scripts/npc/move_applier.gd.uid b/client/scripts/npc/move_applier.gd.uid new file mode 100644 index 0000000..6a8b53f --- /dev/null +++ b/client/scripts/npc/move_applier.gd.uid @@ -0,0 +1 @@ +uid://dhd5r0ugcx5bs diff --git a/client/scripts/npc/move_validator.gd b/client/scripts/npc/move_validator.gd new file mode 100644 index 0000000..45afc92 --- /dev/null +++ b/client/scripts/npc/move_validator.gd @@ -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} diff --git a/client/scripts/npc/move_validator.gd.uid b/client/scripts/npc/move_validator.gd.uid new file mode 100644 index 0000000..c76e502 --- /dev/null +++ b/client/scripts/npc/move_validator.gd.uid @@ -0,0 +1 @@ +uid://cc7n3rqt1pvdv diff --git a/client/scripts/npc/npc_content.gd b/client/scripts/npc/npc_content.gd new file mode 100644 index 0000000..6c5bc4a --- /dev/null +++ b/client/scripts/npc/npc_content.gd @@ -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 diff --git a/client/scripts/npc/npc_content.gd.uid b/client/scripts/npc/npc_content.gd.uid new file mode 100644 index 0000000..3027908 --- /dev/null +++ b/client/scripts/npc/npc_content.gd.uid @@ -0,0 +1 @@ +uid://c64k81yn5arnn diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index 2ee7b06..fcfbb7a 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -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) diff --git a/client/scripts/text/tag_extractor.gd b/client/scripts/text/tag_extractor.gd index 1f710ce..0efb4e8 100644 --- a/client/scripts/text/tag_extractor.gd +++ b/client/scripts/text/tag_extractor.gd @@ -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(" ", " ") diff --git a/client/tests/unit/test_canon_log.gd b/client/tests/unit/test_canon_log.gd index aa7235c..bd0c414 100644 --- a/client/tests/unit/test_canon_log.gd +++ b/client/tests/unit/test_canon_log.gd @@ -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) diff --git a/client/tests/unit/test_fallback_library.gd b/client/tests/unit/test_fallback_library.gd index e517993..5b3d4f6 100644 --- a/client/tests/unit/test_fallback_library.gd +++ b/client/tests/unit/test_fallback_library.gd @@ -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(), "") diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd index e83917b..c56e7bb 100644 --- a/client/tests/unit/test_game_state.gd +++ b/client/tests/unit/test_game_state.gd @@ -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")) diff --git a/client/tests/unit/test_move_applier.gd b/client/tests/unit/test_move_applier.gd new file mode 100644 index 0000000..2a653c9 --- /dev/null +++ b/client/tests/unit/test_move_applier.gd @@ -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) diff --git a/client/tests/unit/test_move_applier.gd.uid b/client/tests/unit/test_move_applier.gd.uid new file mode 100644 index 0000000..a3d46c8 --- /dev/null +++ b/client/tests/unit/test_move_applier.gd.uid @@ -0,0 +1 @@ +uid://cysf45uyit1hv diff --git a/client/tests/unit/test_move_validator.gd b/client/tests/unit/test_move_validator.gd new file mode 100644 index 0000000..869eff5 --- /dev/null +++ b/client/tests/unit/test_move_validator.gd @@ -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) diff --git a/client/tests/unit/test_move_validator.gd.uid b/client/tests/unit/test_move_validator.gd.uid new file mode 100644 index 0000000..7762c83 --- /dev/null +++ b/client/tests/unit/test_move_validator.gd.uid @@ -0,0 +1 @@ +uid://bubsx2qbuesxi diff --git a/client/tests/unit/test_net_primitives.gd b/client/tests/unit/test_net_primitives.gd index 3c74103..9a6a2ca 100644 --- a/client/tests/unit/test_net_primitives.gd +++ b/client/tests/unit/test_net_primitives.gd @@ -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) diff --git a/client/tests/unit/test_npc_content.gd b/client/tests/unit/test_npc_content.gd new file mode 100644 index 0000000..e4f7bf1 --- /dev/null +++ b/client/tests/unit/test_npc_content.gd @@ -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) diff --git a/client/tests/unit/test_npc_content.gd.uid b/client/tests/unit/test_npc_content.gd.uid new file mode 100644 index 0000000..a7b48ad --- /dev/null +++ b/client/tests/unit/test_npc_content.gd.uid @@ -0,0 +1 @@ +uid://bxbq6nm43vlva diff --git a/client/tests/unit/test_npc_service.gd b/client/tests/unit/test_npc_service.gd new file mode 100644 index 0000000..e47578b --- /dev/null +++ b/client/tests/unit/test_npc_service.gd @@ -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) diff --git a/client/tests/unit/test_npc_service.gd.uid b/client/tests/unit/test_npc_service.gd.uid new file mode 100644 index 0000000..4a2d5c7 --- /dev/null +++ b/client/tests/unit/test_npc_service.gd.uid @@ -0,0 +1 @@ +uid://dt8idmag53m7u diff --git a/client/tests/unit/test_tag_extractor.gd b/client/tests/unit/test_tag_extractor.gd index f8da1a5..fafd71e 100644 --- a/client/tests/unit/test_tag_extractor.gd +++ b/client/tests/unit/test_tag_extractor.gd @@ -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)]")) diff --git a/content/world/npcs/fenn.json b/content/world/npcs/fenn.json new file mode 100644 index 0000000..7fac017 --- /dev/null +++ b/content/world/npcs/fenn.json @@ -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"] + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 37b241c..f805a1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 29f32a0..aed7a07 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.*