Compare commits

...

70 Commits

Author SHA1 Message Date
29474e5751 docs(charter+roadmap): pivot to the tactical CRPG (the POC is answered)
The aliveness POC (M0-M2) is proven, so redefine the project as the tactical
CRPG the /mockups describe. Charter: §1 reframed (Code of Conquest / The
Margreave), §10 combat = tactical + action-economy but GRIDLESS (tokens are
flavor, no positional rules), §15 = button-driven actions + free-text NPC
dialogue (Adjudicator reshaped to the optional free-text escape hatch), §16
adopts /mockups as the UI/systems bible, §17 rewritten to the game's scope.
Two conflicts flagged not silently decided (§18): damage determinism (§7 vs
mock ranges → M5) and class names (§8 vs mock callings → M4).

Roadmap: M0-M2 kept as the built engine; M3-M9 re-sequenced around the mock
screens (Theme+shell → creation → combat → dialogue → inventory/sheet → world
systems → framing/AI-flavor/persistence). Streaming stays shelved; v2 NPC
ideas preserved, re-homed to M6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:12:48 -05:00
49bc6b22c0 docs(mockups): add the UI/systems bible (11 screens + README)
High-fidelity HTML/CSS/JS design references for the tactical-CRPG direction —
title, character creation, character sheet, inventory, world map, quest log,
shop, pause/settings, dialogue, combat HUD, main exploration window. README is
the handoff spec (visual system, per-screen state models, Godot translation
notes). Not production code — the source of truth for the game's look + systems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:06:23 -05:00
b639cab0c2 docs(roadmap): advance milestone marker M2 -> M3
M2 (prove aliveness) build-complete + live-proven; feel confirmed in playtest.
Active build focus is now M3 (make it a game), starting with the Adjudicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:47:01 -05:00
57acfb75d9 docs(roadmap): considering-state done; shelve streaming for the POC
Flip considering-state to done+merged+live-confirmed. Strike the streaming
item — the considering-state reframes the wait and is tested/live; real
streaming's transport rework + mid-stream failure surface isn't worth it for
the POC. Streaming spec retained as a record (banner SHELVED). Includes the
human's folded-in further-down roadmap ideas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:44:15 -05:00
099714d4f6 Merge feature/considering-state into dev
Considering-state + transport timeout (M2 anti-freeze; streaming parked).
While a model call is in flight the client rotates authored in-character
'the Master considers…' lines (§13); HttpTransport now sets a request timeout
(35s, rejects <=0) so a hung call degrades to the fallback instead of spinning
forever. Human-confirmed working live, including the degrade path (API down →
fallback). 5 tasks, whole-branch review MERGE AS-IS. Rotation tuned to 3.0s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:33:58 -05:00
6d7eeaab27 tune(client): considering rotation 2.0s -> 3.0s (phrases linger longer)
By-hand cold-start test showed 3 phrases before the response; 1.5x the
cadence so each line stays on screen a touch longer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:31:59 -05:00
c5644d381e feat(client): harnesses show the considering-state around the call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:09:45 -05:00
82b2de19bb feat(client): ConsideringIndicator — rotates think-lines on a Timer 2026-07-10 15:06:31 -05:00
6bd5e4f84a fix(client): HttpTransport sets request timeout (no more infinite hang) 2026-07-10 15:03:29 -05:00
377adb10b2 feat(client): ProxyConfig.request_timeout_seconds (default 35s, reject <=0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:00:40 -05:00
a171d40781 feat(client): ConsideringPhrases — authored rotating think-lines (§13) 2026-07-10 14:57:38 -05:00
2c32bba320 docs(plan): considering-state + transport timeout implementation plan
5 TDD tasks: ConsideringPhrases (pure) + authored considering.json;
ProxyConfig.request_timeout_seconds (default 35s, reject <=0); HttpTransport
applies it (closes the infinite-hang gap); ConsideringIndicator node shim
(Timer rotates a Label); wire both harnesses around the await.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:55:00 -05:00
96432bee87 docs(spec): considering-state + transport timeout; park streaming
Pivot M2 anti-freeze from streaming to a lighter considering-state: a rotating
in-character 'the Master considers…' indicator (authored content, §13 spirit)
on the proven non-streaming loop, plus the HttpTransport request-timeout fix so
a hung call yields to the fallback instead of spinning forever. Meets §14's
anti-freeze goal by reframing the wait; near-zero new failure surface. Streaming
design PARKED (banner + roadmap ○), revisit if playtest shows the wait hurts.
Conscious §14 deviation recorded per §18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:37:20 -05:00
1ed863d3fe docs(spec): Narrator streaming (M2) design
Real token streaming to hide the ~2s call (§14). Proxy emits its own minimal
NDJSON (delta/done/error); /dm/narrate becomes a 200 stream (422 stays sync,
model errors become an in-stream frame). Client reworks to an HTTPClient
poll-loop StreamTransport behind a testable seam; hold-back tag filter;
keep-partial mid-stream degrade; folds in the HttpTransport timeout fix.
Narrator first; NPC fast-follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:23:32 -05:00
1a6e8e8616 Merge fix/npc-harness-luck-descriptor into dev
NPC harness built a canon log with an empty player.luck_descriptor, which the
schema rejects (minLength 1) → /npc/speak 422. Build the player through
LogPlayer with a non-empty §7 fortune line. Human-confirmed working live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:38:17 -05:00
81db962a9d fix(client): npc harness must set player.luck_descriptor (schema requires non-empty)
The throwaway harness hand-built a minimal canon log and left luck_descriptor
as the default "", which fails the canon-log schema (minLength 1) → /npc/speak
422s and the harness silently degrades to fallback. Build the player through
LogPlayer with a non-empty §7 fortune line. Verified: request now 200s and Fenn
answers in-voice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:30:45 -05:00
d788225754 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>
2026-07-10 13:23:00 -05:00
af1a3aa726 docs(roadmap): M2 Bounded NPC conversation done + live-proven
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:17:18 -05:00
2b1c11b1e6 fix(client): TagExtractor tolerates paren-less moves + scrubs misformatted move tags (live-review LF1/LF2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:15:18 -05:00
86d1352b19 test(api): gated live smoke — Fenn answers /npc/speak in voice
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:07:24 -05:00
ed2311b62c chore(client): commit Godot .uid sidecars for new npc scripts (Tasks 10-15)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:03:04 -05:00
87f337bffd feat(client): throwaway NPC harness — drive /npc/speak on screen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:02:13 -05:00
c347da6cec fix(api): npc.md — zero-arg move examples need parens for TagExtractor 2026-07-10 12:59:14 -05:00
832cdc65ca feat(client): NpcService.speak — post, extract, validate moves (§6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:55:52 -05:00
5181c3e74a feat(client): authored NPC fallback line (§13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:51:13 -05:00
c0dcc8df82 feat(client): MoveApplier — validated moves to game state 2026-07-10 12:47:51 -05:00
a2100f802f feat(client): NpcResult model 2026-07-10 12:45:23 -05:00
4c0b4e41c3 feat(client): pure MoveValidator — membership is the whole test 2026-07-10 12:41:45 -05:00
1b67b3984b feat(client): NpcContent.available_moves — the legality home 2026-07-10 12:39:10 -05:00
3b71fcdcc0 feat(client): CanonLog.add_quest/has_quest for offer_quest 2026-07-10 12:36:37 -05:00
1d1ae37281 feat(client): GameState move targets — remove_item, reveal/gift tracking
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:34:33 -05:00
9d2f93bbf2 fix(api): package /content into the image so npc content resolves 2026-07-10 12:30:36 -05:00
a2c654c047 content: author Fenn + the NPC role prompt body 2026-07-10 12:26:04 -05:00
0c3d64d068 feat(api): wire POST /npc/speak (422 unknown npc, 502 model error) 2026-07-10 12:22:07 -05:00
004cc21724 feat(api): npc.run service — thin drop-in on the model pipeline 2026-07-10 12:18:01 -05:00
76c04b45d2 feat(api): render_npc_digest — persona/knowledge/moves/utterance 2026-07-10 12:14:34 -05:00
6cd5003930 feat(api): route the npc role to its own model 2026-07-10 12:11:54 -05:00
69729f7673 feat(api): content.load_npc + content-root resolver 2026-07-10 12:08:50 -05:00
55a2ae6f19 docs(plan): bounded NPC conversation implementation plan
18 TDD tasks: server npc role (content.load_npc, routing, npc digest,
npc.run, /npc/speak, Fenn + prompt, Docker content packaging); client
NpcService + pure MoveValidator + MoveApplier + NpcContent legality +
GameState/CanonLog move targets + NPC fallback + throwaway harness;
gated Fenn live smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:03:58 -05:00
097ac618f4 docs(spec): bounded NPC conversation (/npc/speak) design
M2 core experiment. Server owns persona+knowledge (spoilers/IP, no DB);
client owns live state and computes available_moves. Free text straight
to the NPC prompt (no Adjudicator). Sibling NpcService + pure MoveValidator;
moves validated against state, invalid dropped, prose always kept (§6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:36:44 -05:00
7db1eb8f26 docs(roadmap): M2 Client HTTP loop done + live-proven; NPC conversation next
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:15:38 -05:00
b949cd2164 Merge feature/client-http-loop into dev
M2 client HTTP loop: the client posts its canon log to /dm/narrate, shows
the prose, harvests [FACT] via TagExtractor, and degrades to an authored
fallback (§13) on any failure. First end-to-end vertical slice, live-proven
against qwen3.5 through Docker. 67/67 client tests, whole-branch review
MERGE AS-IS.
2026-07-10 11:13:38 -05:00
5df67dcd8a Merge fix/docker-prompts-packaging into dev
Package api/prompts into the Docker image and give the Dockerized proxy a
route to a host-run Ollama. M1's narrator pipeline was proven via venv-on-
host; these were the two Docker-path gaps the client HTTP loop surfaced.
2026-07-10 11:13:38 -05:00
004bd53a39 fix(api): let the Dockerized proxy reach a host-run Ollama
After the prompts fix, /dm/narrate reached the model call and failed with
ConnectError: Connection refused — the container's localhost is not the
host, so the default OLLAMA_BASE_URL=http://localhost:11434 hit nothing.
Ollama runs on the host (M1's venv smoke reached it because uvicorn ran on
the host too; the container never had a route).

Add extra_hosts host.docker.internal:host-gateway so the container can
reach the host, and document using http://host.docker.internal:11434 in
.env.example. Verified a container reaches host Ollama's /api/tags (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:07:44 -05:00
f18d306059 fix(api): package api/prompts into the Docker image
The narrator pipeline (M1) reads role prompts from /app/prompts, but the
Dockerfile copied only api/app and docs/schemas — never api/prompts — so
/dm/narrate 500'd with FileNotFoundError on narrator.md in any Docker run.
The M1 live smoke passed only because it ran uvicorn from the local venv,
where ../prompts resolves on the host; the container path was never
exercised until the client HTTP loop drove a real request through compose.

Dockerfile now COPYs api/prompts; compose mounts it :ro so prompt edits
reload live like the app code (prompts are source code, §16). Verified by
building the image and confirming /app/prompts/narrator.md is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:29:08 -05:00
23ccc799aa fix(client): parse HttpTransport body via JSON.new() not parse_string
Whole-branch review Minor #1: JSON.parse_string pushes an engine-level
error on a non-JSON body (e.g. a gateway HTML 502), which the project's
gutconfig promotes to a false failure — the same reason FallbackLibrary
already avoids it. Consistency fix; a non-JSON body still degrades.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:20:26 -05:00
89afd85bb2 feat(client): throwaway narrate harness scene + live-smoke doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:12:21 -05:00
c208d3f1c4 feat(client): HttpTransport — real HTTPRequest transport (untested shim)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:08:20 -05:00
513225df3f feat(client): DmService narrate loop + FakeDmTransport (all branches tested)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:33 -05:00
32fa372124 feat(client): FallbackLibrary + authored narrator fallback (§13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:59:54 -05:00
18a18820fe feat(client): net primitives — DmResponse, NarrateResult, DmTransport, ProxyConfig
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:54:03 -05:00
b4d22f5c5f docs(plan): Client HTTP loop (M2) implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:51:49 -05:00
3a85d11439 docs(spec): Client HTTP loop (M2) design
First M2 vertical slice: CanonLog -> HTTP -> /dm/narrate -> prose ->
screen -> fact-harvest, with a §13 degraded-DM fallback on any failure.
Pure DmService core behind an injectable DmTransport seam (headless-
testable); throwaway harness scene; one authored fallback line under
client/content (renderer owns the file's home).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:33:44 -05:00
4ae9cdbe63 docs(roadmap): flesh out sequencing into milestones (M0–M4)
Regroup the flat status list into milestones with a 'what this proves' goal, and
tag every item with its §2 side (state/text/both), dependencies, and milestone
goal. Sequencing calls locked: M3 leads with the playable dungeon
(Adjudicator→Combat→Luck) before the comedic roles (Improviser/Banter); streaming
deferred until the M2 dialogue experiment proves aliveness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:10:16 -05:00
1189d2e24d docs: mark Narrator/Ollama pipeline complete (plan steps + roadmap)
Plan status → COMPLETE, all 8 tasks/44 steps checked. Roadmap: Narrator pipeline
, Client HTTP loop promoted to ▶ next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:00:34 -05:00
ddc00d42da Merge feature/narrator-ollama-pipeline into dev
Narrator/Ollama pipeline (server): shared model-call pipeline (config, role→model
routing, prompt loader + curated canon-log digest, Ollama /api/chat client with one
retry, §10 JSON-lines call logging) wired through the Narrator role — /dm/narrate is
now real (200 prose | 502 model_error | 422 invalid log). 49 passed/1 skipped; live
wire proven against qwen3.5. Final-review fixes: best-effort logging, non-JSON 200 →
ModelError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:52:34 -05:00
f7c51b79da fix(api): best-effort call logging + non-JSON 200 → ModelError (final-review)
- call_log._default_write: swallow any exception from the default file/
  stdout sink and report to stderr, so a log-write failure (disk full,
  bad permissions, misconfigured CALL_LOG_PATH) never turns a successful
  narration into a 500 (charter §13). Injected write= sinks (used by
  tests) are left to surface their own errors.
- ollama_client.chat: catch ValueError alongside httpx.HTTPError in the
  retry loop so a 200 response with a non-JSON body (JSONDecodeError is
  a ValueError subclass) counts as a failed attempt and falls through to
  ModelError after the one retry, instead of escaping chat() uncaught
  (charter §12 — one retry, then ModelError, nothing else escapes).
- main.py: update stale docstrings — /dm/narrate is now fully wired
  (routing + model call + logging); the other four roles remain stubs.

Regression tests added for both fixes (TDD: watched RED, then GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:45:39 -05:00
470ccb2935 test(api): gated live Ollama smoke (--run-live) + env docs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:36:56 -05:00
45d5f0360b test(api): keep test_endpoints hermetic after /dm/narrate wiring
/dm/narrate now makes a real Ollama call (Task 7). The role-endpoint
loop tests in test_endpoints.py had no mock, so every default pytest
run was silently hitting a live Ollama instance on localhost:11434 —
breaking the hermetic-suite constraint. Add an autouse fixture that
stubs narrate.ollama_client.chat and narrate.call_log.record so all
five endpoints stay hermetic; assertions (200 on valid, 422 on
invalid) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:31:42 -05:00
5a35802075 feat(api): wire /dm/narrate — 200 prose | 502 model_error | 422 invalid log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:31:38 -05:00
2027e7a9b9 feat(api): narrate service — render→route→call→log, returns raw prose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:27:36 -05:00
2558e2a2a0 feat(api): author narrator system prompt (voice, [FACT] rule, no-numbers, no player agency) 2026-07-10 08:23:31 -05:00
15995c57ee feat(api): §10 JSON-lines call logging (full prompt + seed, injectable sink)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:20:00 -05:00
be133c4f3a feat(api): Ollama /api/chat client with one retry + typed ModelError
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:15:45 -05:00
4d8a19353e feat(api): prompt loader (header split) + curated canon-log digest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:11:57 -05:00
42b3720721 feat(api): config + role→model routing (qwen3.5 narrator default, think off)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:08:56 -05:00
ea2cfafd34 docs(plan): Narrator/Ollama pipeline implementation plan
Phase 1 (config, routing, prompts+digest, ollama_client, call_log) + Phase 2
(author narrator.md, narrate service, wire /dm/narrate, gated live smoke).
Mock boundary via httpx.MockTransport; qwen3.5 default with think:false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:32:08 -05:00
ec01ce5c9b docs(spec): qwen3.5 narrator default + think:false; add model probe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:25:17 -05:00
959abc79eb docs(spec): Narrator/Ollama pipeline (server) design
Shared server-side model-call pipeline (Ollama client, role->model routing,
prompt loader + canon-log digest, §10 call logging, retry->typed 502) wired
through /dm/narrate as the first role. Non-streaming; server returns raw
prose, client extracts tags. Two phases: pipeline foundation, Narrator online.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:58:03 -05:00
b15e81e242 docs(roadmap): fill in sequencing — Narrator/Ollama pipeline next
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:56:42 -05:00
113 changed files with 12636 additions and 70 deletions

View File

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

View File

@@ -8,13 +8,13 @@ Read this before writing code. When a request conflicts with something written h
## 1. What this is
A single-player, party-based fantasy RPG. Combat is deterministic JRPG-style. The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
**Code of Conquest** — a single-player, party-based **tactical CRPG** in a gritty high-fantasy world ("The Margreave"). Turn-based, D&D-flavored combat is deterministic (§7/§10). The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
**Not multiplayer.** Not now, not later. Companions are code-controlled NPCs. Do not introduce networking, lockstep, or authority abstractions "in case."
**Target:** Desktop first (Windows/Linux). Mobile is a later consideration and should not constrain POC decisions.
**Target:** Desktop first (Windows/Linux), **1920×1080 (16:9), Godot 4.7.** Mobile is a later consideration and should not constrain design.
**Current phase:** POC. The question the POC answers is *does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?* Everything else is scaffolding for that experiment.
**Phase.** The original POC question — *does bounded AI dialogue feel alive, or a chatbot in a costume?* — is **answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0M2). The project is now **building the game the mockups describe** (§16, `/mockups`): the tactical CRPG across the 11 screens of that UI bible. The AI's job is unchanged — narrate, voice NPCs, improvise — and it never owns state.
---
@@ -348,15 +348,16 @@ Single integer, 100..100, per companion. Adjusted by player choices weighed a
## 10. Combat
**Positionless, turn-based.** No grid. No flanking. No terrain.
**Tactical, turn-based, gridless.** Turn order by initiative; an **action economy** (Action · Bonus · Move) per turn; abilities picked from a bar; enemies targeted by click. AC, HP, and initiative in the D&D-flavored idiom of the Combat HUD mockup (§16).
Combat is not the risk surface of this project. Ship the boring one; prove the interesting one.
**No real movement grid.** The isometric board and tokens are *visual flavor* — there are no positional rules, no flanking, no terrain, no reachable-tile movement. Positioning was the risk surface the old charter warned against; it stays out. The tactics live in the **action economy and resources**, not the map.
- **Damage is deterministic.** No variance rolls on damage.
- **The player controls only their own character.** The DM resolves the AI companions' and enemies' turns.
- **Damage is deterministic (§7). OPEN:** the Combat HUD mock rolls damage from a range, but §7 forbids damage variance so Luck can *select* outcomes, not fudge numbers. Reconcile at the combat brainstorm — either fixed damage with Luck governing crit thresholds + borderline status checks, or a conscious, written change to §7. Do **not** silently adopt damage ranges.
- **Initiative is rolled once at encounter start** and is relatively random. DEX modifies.
- **The interesting decision is resource management** — MP, cooldowns, consumables. Not matchup-reading. Pick one axis for the POC.
- **Resources are the interesting axis** — the action economy, cooldowns, consumables. Not matchup-reading.
- **Luck influences crit thresholds and borderline status checks.** Nothing else. See §7.
- **Combat flavor text is async and optional.** The numbers land immediately. The prose catches up. If the AI call fails, the fight continues with authored text.
- **Combat flavor text is async and optional.** The numbers land immediately; the prose (a Narrator/DM call) catches up. If the AI call fails, the fight continues with authored text.
### Determinism and seeding
@@ -366,6 +367,8 @@ This costs nothing now and is nearly impossible to retrofit. It is the differenc
Log the seed *and* the full prompt with every AI call, for the same reason.
The Combat HUD mock's `Component` state machine (initiative order, action economy, ability resolution, victory/defeat) is the reference spec for the GDScript turn manager.
---
## 11. The canon log
@@ -454,14 +457,14 @@ Roughly 13k tokens per call, dozens of calls per session.
## 15. Input model
**Hybrid.**
**Button-driven actions; free-text dialogue.**
- **Menus:** combat, navigation, inventory.
- **Free text:** dialogue, and explicit "what do you do?" moments.
- **Buttons / menus:** combat abilities, world actions, navigation, inventory, shop. The player picks from **legal options code presents** — no input can produce an illegal or absurd action ("I triple-backflip off the wall and roundhouse him" never reaches the rules). Bulletproof by construction.
- **Free text:** **NPC dialogue** — the aliveness surface, kept free-text (bounded by the move vocabulary §6, so wild input is harmless). Plus an optional **"describe your own action"** line on the main window (2a mock) for the tabletop feel.
**The boundary must be visible.** The player should always know which mode they are in. Ambiguity here reads as broken.
**The boundary must be visible.** The player should always know which mode they are in.
Free text goes to the Adjudicator, which maps it to a legal action or rejects it with an in-world reason.
**The Adjudicator (§5) is reshaped.** Its primary job is no longer parsing free text into a world action — code presents legal actions as buttons. It survives only as the interpreter for the *optional* free-text "describe your own action" escape hatch: free text → a legal action or an in-world rejection. Most turns never touch it.
---
@@ -470,7 +473,7 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
- **Client:** Godot 4, **GDScript.** Prefer readability. Drop to C# only if GDScript genuinely cannot do the thing — and say why before you do.
- **Proxy:** Python, **FastAPI.**
- **Models:** Ollama (self-hosted, dev/staging) → Replicate (prod). Routed per-role, server-side.
- **Repo:** greenfield. No existing code.
- **Repo:** the engine (M0M2) is built — canon-log contract + client engine, the server model-call pipeline, `/dm/narrate` + `/npc/speak`, bounded moves, considering-state. Build the game on top; follow the existing patterns.
### Layout
@@ -481,9 +484,23 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
/docs API-specific docs
/prompts Role prompts (versioned, reviewed like code)
/content Authored: quests, NPC knowledge lists, fallback text
/mockups The UI/systems bible — 11 hi-fi HTML/CSS/JS screen refs + README
/docs Cross-cutting docs — roadmap, ADRs, anything affecting both sides
```
**`/mockups` is the UI + systems bible.** Eleven high-fidelity HTML/CSS/JS screen
references (title, character creation, character sheet, inventory, world map,
quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
plus a `README.md` handoff. **Not production code** — each screen is recreated as
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
(the README's palette, three fonts, and reusable styleboxes keep the look one
system). Each `.dc.html`'s `Component` class is the **behavior spec** — the
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
highest-value logic to port; the seed-data maps (items, quests, locations, units,
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
art. When the mock and this charter disagree, the disagreement is a decision to
make, not a default to follow — see §17's reconcile list.
`/api` is still the proxy described in §4 — auth, metering, prompt-hiding, model
routing. It is named `api` for the client's convenience, not because it is a
generic pass-through. Docs are split: side-specific docs live under
@@ -494,33 +511,39 @@ Prompts are source code. They get reviewed. They get versioned. Changing a promp
---
## 17. POC scope
## 17. Scope — the game (the POC is done)
**~30 minutes of play.**
The aliveness POC is **answered** (M0M2, live-proven). The target is now the
tactical CRPG in the mockups (§16): the 11 screens and the systems they imply —
character creation, the exploration shell + DM narration, tactical (gridless)
combat, free-text NPC dialogue, inventory/equipment, character sheet, world-map
travel, a shop economy, a quest log, title/pause.
- One town, **two NPCs** with authored knowledge lists
- One dungeon: **three fights, one boss**
- **Two companions:** Cadwyn, Brannoc
- **Three playable classes:** Sellsword, Assassin, Priest
- Luck fully wired: generation, drift, one shrine, one cursed item
- Full proxy, full role split, full canon log
- Authored fallback text for every AI surface
Build it **screen by screen** against the mockups' shared visual system, on the
proven engine (canon log, DM/NPC pipeline, bounded moves, Luck, character-creation
model — all already aligned with the mocks). Each screen/system is its own
**spec → plan → implementation** cycle; the roadmap sequences them toward a
playable vertical slice first, then breadth.
### The POC's job
**Unchanged and load-bearing:** §2 (code owns state, AI owns text) — a tactical
CRPG has *far* more state, all code-owned; the AI stays narrator/DM/NPC-voice.
Also §3 (tone), §7 (Luck), §9 (companions), the canon log (§11), the output
contracts (§12), and Failure UX (§13).
Answer one question: **does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
### To reconcile as we build (flagged, not silently decided)
Test that before building anything else. Every other system exists to make that test possible.
- **Damage determinism** — §7/§10's deterministic damage vs the Combat HUD mock's
damage *ranges* (settle at the combat brainstorm).
- **Class names** — §8 uses Sellsword/Assassin/Priest/Scholar/Oathbound/Tracker/
Berserker; the Character-Creation mock uses callings Sellsword/Cutpurse/
Hedge-Mage/Bonesetter/Trapper (same archetypes + primary stats, different
names). Settle at the character-creation brainstorm.
### Explicitly out of scope
### Still out of scope (never / later)
- AI-generated story skeletons (v2 — the seeded story is authored for now)
- Multiplayer (never)
- Mobile
- Credits, payments, auth
- Companions leaving the party
- Positional combat
- More than three classes
- Multiplayer (**never**) · mobile (later) · AI-generated story skeletons (v2 —
the seeded story is authored for now) · credits/payments/auth (retrofit onto
the proxy later, §4).
---

View File

@@ -2,6 +2,10 @@
# any of these — keys live only in the proxy (charter §4).
# Where the proxy sends model calls in dev (Ollama on the homelab).
# Running the proxy IN DOCKER with Ollama on THIS host? localhost points at the
# container, not the host — use http://host.docker.internal:11434 instead
# (docker-compose.yml maps that name via host-gateway). Bare-metal/venv: keep
# localhost. Homelab Ollama: use its address.
OLLAMA_BASE_URL=http://localhost:11434
# Prod model provider (charter §4). Leave blank in dev.
@@ -9,3 +13,10 @@ REPLICATE_API_TOKEN=
# Port the proxy binds. compose / fly.io override this.
PORT=8000
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
# Per-attempt model-call timeout (seconds).
OLLAMA_TIMEOUT_SECONDS=30
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
CALL_LOG_PATH=

View File

@@ -12,9 +12,12 @@ WORKDIR /app
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App code + the schema contract (single source of truth in docs/schemas).
# App code, the role prompts (§16 — prompts are source code), and the schema
# contract (single source of truth in docs/schemas).
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

64
api/app/call_log.py Normal file
View File

@@ -0,0 +1,64 @@
"""Structured JSON-lines logging of every model call (charter §10 — the seed and
the full prompt logged with every call; §4 — free eval/replay infrastructure).
One JSON object per line: everything needed to replay the call (canon_log +
messages + model + seed) against a candidate model. No secrets to redact — the
client never sends keys, the canon log carries no PII, and the prompt is exactly
what we want on record.
"""
import json
import sys
from datetime import datetime, timezone
from typing import Callable
from . import config
def _default_write(line: str) -> None:
"""Best-effort (charter §13): a good model call must never become a 500
because the log write failed (disk full, bad permissions, a misconfigured
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
never stdout, since stdout may itself be the default sink and must stay
clean JSON-lines for downstream tooling.
"""
try:
path = config.call_log_path()
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
else:
sys.stdout.write(line + "\n")
except Exception as exc: # noqa: BLE001 - logging must never break a request
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
def record(
*,
role: str,
model: str,
options: dict,
messages: list[dict],
canon_log: dict,
ok: bool,
latency_ms: int,
response: str | None = None,
error: str | None = None,
write: Callable[[str], None] = _default_write,
) -> dict:
rec: dict = {
"ts": datetime.now(timezone.utc).isoformat(),
"role": role,
"model": model,
"options": options,
"messages": messages,
"canon_log": canon_log,
"ok": ok,
"latency_ms": latency_ms,
}
if ok:
rec["response"] = response
else:
rec["error"] = error
write(json.dumps(rec, ensure_ascii=False))
return rec

26
api/app/config.py Normal file
View File

@@ -0,0 +1,26 @@
"""Server config, read from the environment at call time so tests can override
without reimport. Charter §4 — all of this lives server-side; the client never
sees it.
"""
import os
def ollama_base_url() -> str:
return os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
def ollama_timeout_seconds() -> float:
return float(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "30"))
def narrator_model() -> str:
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
def npc_model() -> str:
return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")
def call_log_path() -> str | None:
return os.environ.get("CALL_LOG_PATH") or None

View File

@@ -8,6 +8,7 @@ scenes into play.
"""
import json
import os
from pathlib import Path
@@ -48,3 +49,30 @@ def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
if npc_id not in world["npcs"]:
missing.append(f"npc:{npc_id}")
return missing
def _content_root() -> Path:
"""Locate the authored content root. Honours CONTENT_ROOT, else walks up
from this file for a dir containing world/ — repo-root/content in a local
checkout, /app/content when bundled into the Docker image. Mirrors
canon_log._schema_dir().
"""
env = os.environ.get("CONTENT_ROOT")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "content"
if (candidate / "world").is_dir():
return candidate
raise RuntimeError("content root not found")
def load_npc(npc_id: str) -> dict | None:
"""Load one NPC's authored file (persona + knowledge + capabilities), or
None if no file resolves. Server-only fields never travel to the client."""
path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
if not path.is_file():
return None
with open(path) as f:
return json.load(f)

View File

@@ -3,7 +3,9 @@
Skeleton: a health check plus the five role endpoints. Each role endpoint now
validates the posted canon log against the contract (charter §11) before doing
anything else — an invalid log is rejected with 422 and never reaches a prompt.
Prompt routing, model selection, and logging land later.
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
call-log logging. The other four roles remain stubs until they get the same
treatment.
"""
from fastapi import Depends, FastAPI, HTTPException
@@ -12,6 +14,9 @@ from fastapi.responses import JSONResponse
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")
@@ -38,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."""
@@ -46,13 +67,17 @@ def health() -> dict:
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Bodies are validated against the canon log contract; the AI half is a
# stub until prompt routing lands.
# them. Bodies are validated against the canon log contract. /dm/narrate is
# fully wired (routing + model call + logging); the other four roles remain
# stubs until prompt routing, model selection, and logging land for them too.
@app.post("/dm/narrate")
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
try:
return {"prose": narrate_run(req.canon_log)}
except ModelError as exc:
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
@app.post("/dm/adjudicate")
@@ -66,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")

37
api/app/narrate.py Normal file
View File

@@ -0,0 +1,37 @@
"""The Narrator role service (charter §5). Renders the digest, routes the model,
calls Ollama with one retry, logs the call (§10), and returns raw prose — tags
left intact for the client's TagExtractor to harvest (§2/§6). Raises ModelError
on failure for the handler to map to a 502.
"""
import random
from time import perf_counter
from . import call_log, ollama_client, prompts, routing
def run(canon_log: dict) -> str:
cfg = routing.for_role("narrator")
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
messages = [
{"role": "system", "content": prompts.system_prompt("narrator")},
{"role": "user", "content": prompts.render_digest(canon_log)},
]
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="narrator", 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="narrator", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=True,
latency_ms=int((perf_counter() - start) * 1000), response=text,
)
return text

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

@@ -0,0 +1,59 @@
"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona +
knowledge, renders the digest, routes the model, calls Ollama with one retry,
logs the call (§10), and returns raw prose — move/fact tags left intact for the
client to extract, validate, and apply (§2/§6). Mirrors narrate.py.
"""
import random
from time import perf_counter
from . import call_log, ollama_client, prompts, routing
from .content import load_npc
class UnknownNpc(Exception):
"""No authored content resolves for the requested npc_id."""
def run(
canon_log: dict,
npc_id: str,
disposition: int,
available_moves: list[str],
utterance: str,
) -> str:
npc = load_npc(npc_id)
if npc is None:
raise UnknownNpc(npc_id)
cfg = routing.for_role("npc")
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
messages = [
{"role": "system", "content": prompts.system_prompt("npc")},
{"role": "user", "content": prompts.render_npc_digest(
canon_log,
persona=npc.get("persona", ""),
knowledge=npc.get("knowledge", []),
disposition=disposition,
available_moves=available_moves,
utterance=utterance,
)},
]
start = perf_counter()
try:
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
except ollama_client.ModelError as exc:
call_log.record(
role="npc", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=False,
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
)
raise
call_log.record(
role="npc", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=True,
latency_ms=int((perf_counter() - start) * 1000), response=text,
)
return text

50
api/app/ollama_client.py Normal file
View File

@@ -0,0 +1,50 @@
"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see
success or ModelError (charter §12/§13 — one retry, then fail; the client
degrades on any non-200). Built so a streaming mode is an additive path later.
"""
import httpx
from . import config
class ModelError(Exception):
"""The model call failed after one retry (transport / timeout / non-2xx / empty)."""
def chat(
model: str,
messages: list[dict],
options: dict,
*,
think: bool | None = None,
client: httpx.Client | None = None,
) -> str:
payload: dict = {"model": model, "messages": messages, "stream": False, "options": options}
if think is not None:
payload["think"] = think
owns = client is None
http = client or httpx.Client(
base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds()
)
try:
last = "no attempt made"
for _ in range(2): # one try + one retry (§12: never more than once)
try:
resp = http.post("/api/chat", json=payload)
resp.raise_for_status()
content = resp.json().get("message", {}).get("content", "")
if content.strip():
return content
last = "empty response from model"
except (httpx.HTTPError, ValueError) as exc:
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
last = f"{type(exc).__name__}: {exc}"
raise ModelError(last)
finally:
if owns:
http.close()

111
api/app/prompts.py Normal file
View File

@@ -0,0 +1,111 @@
"""Load a role's system prompt (§16 — prompts are source code) and render the
canon log into a curated digest for the user message (charter §11).
The digest is a projection, not a dump: it surfaces narrative context and
deliberately omits raw integers — companion dispositions render as names only,
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
"""
from functools import lru_cache
from pathlib import Path
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
@lru_cache(maxsize=None)
def system_prompt(role: str) -> str:
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
# Everything below the first '---' line is the model-facing prompt; the
# header above it is human/dev front-matter.
parts = text.split("\n---\n", 1)
body = parts[1] if len(parts) == 2 else text
return body.strip()
def render_digest(canon_log: dict) -> str:
lines: list[str] = []
location = canon_log.get("location", {})
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]
party = canon_log.get("party", [])
if party:
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
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", {})
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
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)

31
api/app/routing.py Normal file
View File

@@ -0,0 +1,31 @@
"""Role → model routing (charter §4/§5). Model choice is config, not law — a
deploy, not a client patch. Read at call time so an env override needs no reimport.
The provider abstraction (Ollama → Replicate) slots in here later (YAGNI now).
"""
from dataclasses import dataclass
from . import config
@dataclass(frozen=True)
class RoleConfig:
model: str
options: dict
think: bool | None = None # Ollama top-level `think`; None omits it
def for_role(role: str) -> RoleConfig:
if role == "narrator":
return RoleConfig(
model=config.narrator_model(),
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}")

21
api/conftest.py Normal file
View File

@@ -0,0 +1,21 @@
"""Gate `@pytest.mark.live` tests (they hit a real Ollama) behind --run-live so
the default suite stays hermetic. Run the live layer with: pytest --run-live
"""
import pytest
def pytest_addoption(parser):
parser.addoption(
"--run-live", action="store_true", default=False,
help="run tests marked @pytest.mark.live (require a real Ollama)",
)
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-live"):
return
skip_live = pytest.mark.skip(reason="needs --run-live (real Ollama)")
for item in items:
if "live" in item.keywords:
item.add_marker(skip_live)

View File

@@ -2,11 +2,24 @@
**Role:** Describe scenes, outcomes, transitions. **Quality matters most — use the good model** (charter §5).
**Input:** scene state + canon log (§11).
**Input:** scene state + canon log (§11), rendered as a labeled digest in the user message.
**Output:** prose with tags. Never invent a proper noun without emitting `[FACT: ...]` so code can harvest it into `established_facts` (§11).
**Voice:** dry, gritty not grim, plays the world straight. Never winks. Nothing is "hilariously" anything (§3).
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
You are the Narrator of a gritty high-fantasy RPG. You describe the world; you never control the player.
Given the current scene state below, describe the scene as it is right now, in the second person ("you"), present tense. Cover what the player sees, hears, and smells, and what the people present are doing. Three to five sentences, then stop.
Voice:
- Dry and economical. Gritty, not grim. Play the world straight — never wink, never point out that a thing is funny. Nothing is "hilariously" or "comically" anything.
- Profanity and ugliness are allowed when the moment earns them, never as decoration.
Hard rules:
- Do not introduce a proper noun — a person's name, a place name, a named object — unless it already appears below. If you must introduce one, append a tag on its own: `[FACT: <the new fact as a short clause>]`, one per new proper noun, so the game can record it.
- Never contradict anything under "Established facts".
- Never state a number, a statistic, a hit-point total, or a Luck value. "Fortune" is a feeling, never a figure.
- Never decide, narrate, or assume the player's choices, words, or actions. Describe the world and the other characters; leave the player's next move to the player.
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.

View File

@@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
You voice a single character in a gritty high-fantasy world (the tone is Batman,
not Warhammer and not Care Bears). Play the character straight. Never wink at the
player; never describe anything as funny. Profanity is allowed when earned.
You are given: who you are, your disposition toward the player, the complete list
of what you know, the moves you may perform this turn, the scene's canon, and
what the player just said.
Two hard rules:
1. **Knowledge is a closed list.** You know ONLY what "What you know" tells you.
Do not invent people, places, quests, or facts beyond it. If you must name
something new that becomes true in the world, emit it as `[FACT: ...]` so the
game can record it — but prefer to stay within what you know.
2. **Moves are a closed vocabulary.** You may *say* anything in character, but you
may only *do* something by emitting an inline tag, and only from the moves
listed for this turn. Use exactly this form:
```
[MOVE: reveal(varrell_twins)]
[MOVE: offer_quest(find_the_ledger)]
[MOVE: adjust_disposition(+5)]
[MOVE: refuse()]
[MOVE: end_conversation()]
[MOVE: become_hostile()]
```
Emit zero or more move tags, placed inline where they happen. If a move is not
in your list for this turn, do not emit it — the game will drop it anyway.
Let your disposition colour your tone: low disposition is curt and suspicious,
high disposition is warm and forthcoming.
Return prose in your character's voice, with any move/fact tags inline. Do not
explain the tags or break character to describe them.

View File

@@ -3,3 +3,5 @@ pythonpath = .
testpaths = tests
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated
markers =
live: exercises a real Ollama; skipped unless --run-live

View File

@@ -0,0 +1,56 @@
import json
from app.call_log import record
def test_success_record_shape():
captured = []
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose", write=captured.append,
)
assert len(captured) == 1
parsed = json.loads(captured[0])
assert parsed["ok"] is True
assert parsed["response"] == "prose"
assert parsed["options"]["seed"] == 7
assert parsed["role"] == "narrator"
assert parsed["canon_log"] == {"a": 1}
assert "error" not in parsed
assert "ts" in parsed
assert rec["response"] == "prose"
def test_failure_record_shape():
captured = []
record(
role="narrator", model="m", options={}, messages=[], canon_log={},
ok=False, latency_ms=5, error="ModelError: down", write=captured.append,
)
parsed = json.loads(captured[0])
assert parsed["ok"] is False
assert parsed["error"] == "ModelError: down"
assert "response" not in parsed
def test_default_write_failure_does_not_propagate(monkeypatch, capsys):
"""§13 — a good narration must never become a 500 because the log write
failed. Point CALL_LOG_PATH at a path whose parent directory does not
exist, so the default sink's `open()` raises. record() must swallow it,
report to stderr (never stdout — stdout may be the log sink itself), and
still return the record.
"""
monkeypatch.setenv(
"CALL_LOG_PATH", "/nonexistent-dir-for-test/does/not/exist.log"
)
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose",
)
assert rec["response"] == "prose"
assert rec["ok"] is True
out, err = capsys.readouterr()
assert out == ""
assert err != ""

View File

@@ -0,0 +1,41 @@
import json
import app.content as content
def test_load_npc_returns_parsed_file(tmp_path, monkeypatch):
root = tmp_path / "content"
(root / "world" / "npcs").mkdir(parents=True)
(root / "world" / "npcs" / "fenn.json").write_text(
json.dumps({"id": "fenn", "name": "Fenn", "persona": "clerk",
"knowledge": ["a"], "capabilities": {}})
)
monkeypatch.setenv("CONTENT_ROOT", str(root))
npc = content.load_npc("fenn")
assert npc["name"] == "Fenn"
assert npc["knowledge"] == ["a"]
def test_load_npc_unknown_returns_none(tmp_path, monkeypatch):
(tmp_path / "content" / "world").mkdir(parents=True)
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path / "content"))
assert content.load_npc("nobody") is None
def test_content_root_env_override(tmp_path, monkeypatch):
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path))
assert content._content_root() == tmp_path
def test_fenn_resolves_from_repo_content():
# No CONTENT_ROOT env — exercises the walk-up resolver against real content.
npc = content.load_npc("fenn")
assert npc is not None
assert npc["capabilities"]["offerable_quests"] == ["find_the_ledger"]
assert npc["knowledge"] # non-empty authored knowledge list
def test_npc_prompt_body_is_authored():
from app import prompts
body = prompts.system_prompt("npc")
assert "MOVE" in body and len(body) > 200

View File

@@ -1,8 +1,10 @@
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import app.narrate as narrate
from app.main import app
client = TestClient(app)
@@ -14,13 +16,30 @@ ROLE_ENDPOINTS = [
]
@pytest.fixture(autouse=True)
def _stub_narrate_model_call(monkeypatch):
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
# canon-log validation across ALL role endpoints, not model behavior, so
# stub the model boundary — keeps the default suite hermetic (no network).
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
def test_health_ok():
assert client.get("/health").json() == {"status": "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}"
@@ -28,7 +47,15 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log():
bad = json.loads(json.dumps(VALID_LOG))
bad["player"]["luck"] = 5 # §7 leak
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": bad})
if path == "/npc/speak":
# /npc/speak has additional required fields
body = {
"canon_log": bad, "npc_id": "brannoc_thane",
"disposition": 0, "available_moves": [], "utterance": "test"
}
else:
body = {"canon_log": bad}
r = client.post(path, json=body)
assert r.status_code == 422, f"{path} accepted an invalid log"
assert "canon_log_errors" in r.json()["detail"]

View File

@@ -0,0 +1,56 @@
import json
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())
@pytest.mark.live
def test_narrator_returns_real_prose():
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the wire end-to-end:
prompt assembly → model → non-empty prose. Not asserted: tag content
(nondeterministic). Run with: pytest --run-live
"""
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()

43
api/tests/test_narrate.py Normal file
View File

@@ -0,0 +1,43 @@
import json
from pathlib import Path
import pytest
import app.narrate as narrate
from app.ollama_client import ModelError
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_run_returns_prose_and_logs_success(monkeypatch):
seen = {}
def fake_chat(model, messages, options, *, think=None):
seen.update(model=model, messages=messages, options=options, think=think)
return "You stand on the wharf. [FACT: the gulls have gone quiet]"
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", fake_chat)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
out = narrate.run(VALID)
assert out.startswith("You stand on the wharf.")
assert seen["model"] == "qwen3.5:latest"
assert seen["think"] is False
assert seen["messages"][0]["role"] == "system"
assert seen["messages"][1]["role"] == "user"
assert "seed" in seen["options"]
assert logs and logs[0]["ok"] is True and logs[0]["response"] == out
def test_run_logs_failure_and_reraises(monkeypatch):
def boom(*args, **kwargs):
raise ModelError("upstream down")
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
with pytest.raises(ModelError):
narrate.run(VALID)
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]

View File

@@ -0,0 +1,39 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
import app.main as main
import app.narrate as narrate
from app.ollama_client import ModelError
client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_valid_log_returns_prose(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "You stand on the wharf.")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None) # keep test output pristine
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 200
assert r.json() == {"prose": "You stand on the wharf."}
def test_model_error_maps_to_502(monkeypatch):
def boom(*a, **k):
raise ModelError("upstream down")
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 502
assert "model_error" in r.json()["detail"]
def test_invalid_log_still_422(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "x")
bad = json.loads(json.dumps(VALID))
bad["player"]["luck"] = 5 # §7 leak — schema rejects
r = client.post("/dm/narrate", json={"canon_log": bad})
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]

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

@@ -0,0 +1,55 @@
import pytest
import app.npc as npc
from app.ollama_client import ModelError
LOG = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": [], "established_facts": [],
"active_quests": [], "humiliations": [],
}
FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.",
"knowledge": ["The ledger is gone."], "capabilities": {}}
def test_run_returns_prose_and_logs(monkeypatch):
seen = {}
def fake_chat(model, messages, options, *, think=None):
seen.update(messages=messages, think=think)
return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]'
logs = []
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
monkeypatch.setattr(npc.ollama_client, "chat", fake_chat)
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?")
assert out.startswith("Aye.")
# persona + knowledge reached the model; utterance is present
user_msg = seen["messages"][1]["content"]
assert "A clerk." in user_msg and "The ledger is gone." in user_msg
assert "What happened?" in user_msg
assert seen["think"] is False
assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc"
def test_run_unknown_npc_raises(monkeypatch):
monkeypatch.setattr(npc, "load_npc", lambda i: None)
with pytest.raises(npc.UnknownNpc):
npc.run(LOG, "nobody", 0, [], "hi")
def test_run_logs_failure_and_reraises(monkeypatch):
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
def boom(*a, **k):
raise ModelError("down")
logs = []
monkeypatch.setattr(npc.ollama_client, "chat", boom)
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
with pytest.raises(ModelError):
npc.run(LOG, "fenn", 0, [], "hi")
assert logs and logs[0]["ok"] is False

View File

@@ -0,0 +1,59 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
import app.main as main
import app.npc as npc
client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def _body(**over):
b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0,
"available_moves": ["refuse"], "utterance": "hello"}
b.update(over)
return b
def test_valid_request_returns_prose(monkeypatch):
monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.")
r = client.post("/npc/speak", json=_body())
assert r.status_code == 200
assert r.json() == {"prose": "Aye, well met."}
def test_unknown_npc_maps_to_422(monkeypatch):
def boom(**k):
raise npc.UnknownNpc("nobody")
monkeypatch.setattr(main, "npc_run", boom)
r = client.post("/npc/speak", json=_body(npc_id="nobody"))
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]
def test_model_error_maps_to_502(monkeypatch):
from app.ollama_client import ModelError
def boom(**k):
raise ModelError("down")
monkeypatch.setattr(main, "npc_run", boom)
r = client.post("/npc/speak", json=_body())
assert r.status_code == 502
assert "model_error" in r.json()["detail"]
def test_invalid_log_still_422():
bad = json.loads(json.dumps(VALID))
bad["player"]["luck"] = 5 # §7 leak — schema rejects
r = client.post("/npc/speak", json=_body(canon_log=bad))
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]
def test_missing_field_is_422():
b = _body()
del b["utterance"]
r = client.post("/npc/speak", json=b)
assert r.status_code == 422

View File

@@ -0,0 +1,95 @@
import json
import httpx
import pytest
from app.ollama_client import ModelError, chat
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://ollama")
def test_success_returns_content_and_sends_payload():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "You stand on the wharf."}})
out = chat("qwen3.5:latest", [{"role": "user", "content": "hi"}], {"seed": 7},
think=False, client=_client(handler))
assert out == "You stand on the wharf."
assert seen["body"]["model"] == "qwen3.5:latest"
assert seen["body"]["stream"] is False
assert seen["body"]["options"]["seed"] == 7
assert seen["body"]["think"] is False
def test_think_omitted_when_none():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "ok"}})
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert "think" not in seen["body"]
def test_retries_once_then_succeeds():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] == 1:
raise httpx.ConnectError("boom")
return httpx.Response(200, json={"message": {"content": "recovered"}})
out = chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert out == "recovered"
assert calls["n"] == 2
def test_two_transport_failures_raise_modelerror():
def handler(request):
raise httpx.ConnectError("down")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_timeout_exhausted_raises_modelerror():
def handler(request):
raise httpx.TimeoutException("slow")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_2xx_retried_then_modelerror():
def handler(request):
return httpx.Response(500, json={"error": "nope"})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_empty_content_retried_then_modelerror():
def handler(request):
return httpx.Response(200, json={"message": {"content": " "}})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_json_200_retried_then_modelerror():
"""A 200 response with a non-JSON body (e.g. a proxy error page) must
degrade to ModelError after the one retry, not escape as a bare
JSONDecodeError/ValueError (charter §12/§13).
"""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))

65
api/tests/test_prompts.py Normal file
View File

@@ -0,0 +1,65 @@
import json
from pathlib import Path
from app.prompts import render_digest, system_prompt
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_system_prompt_strips_the_header():
body = system_prompt("narrator")
assert isinstance(body, str) and body.strip() != ""
# The human-facing markdown title above the '---' is not part of the prompt.
assert "# Narrator prompt" not in body
def test_digest_has_the_narrative_context():
d = render_digest(VALID)
assert "Location: the Greywater docks" in d
assert "Arrived at Greywater by barge before dawn" in d
assert "Established facts:" in d
assert "the harbourmaster is named Oda Fenn" in d
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
assert "Fortune: Fortune spits on you" in d
assert "Player: Aldric, a sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
def test_digest_omits_raw_numbers_and_ids():
d = render_digest(VALID)
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
assert "disposition" not in d
assert "40" not in d and "15" not in d # the deserter dispositions
assert "brannoc_thane" not in d # ids stay out; names only
assert "schema_version" not in d
def test_narrator_body_is_authored():
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
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

29
api/tests/test_routing.py Normal file
View File

@@ -0,0 +1,29 @@
import pytest
from app.routing import for_role
def test_narrator_defaults():
cfg = for_role("narrator")
assert cfg.model == "qwen3.5:latest"
assert cfg.think is False
assert cfg.options["num_predict"] == 300
assert cfg.options["temperature"] == 0.8
def test_env_overrides_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NARRATOR_MODEL", "llama3.1:latest")
assert for_role("narrator").model == "llama3.1:latest"
def test_unknown_role_raises():
with pytest.raises(KeyError):
for_role("wizard")
def test_npc_role_routes_to_npc_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
cfg = for_role("npc")
assert cfg.model == "some-npc-model"
assert cfg.think is False
assert "num_predict" in cfg.options

View File

@@ -0,0 +1,9 @@
{
"considering": [
"The Master considers your words.",
"Somewhere, dice are gathered.",
"The tale weighs its next turn.",
"The dark shifts, thinking.",
"A page is turned, unhurried."
]
}

View File

@@ -0,0 +1,5 @@
{
"narrator": [
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
]
}

View File

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

View File

@@ -11,3 +11,21 @@ Put here:
- HTTP client + fallback-degradation handling (§13)
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
## Narrate live smoke (M2 client HTTP loop)
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
loop. It is not the game UI.
**Prereqs:** the proxy running and Ollama reachable.
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
`facts harvested: N` with N ≥ 0.
4. **Degraded path:** stop the proxy and click again. Expect the authored
fallback line and `(degraded)` in the status — never an error.
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.

View File

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

View File

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

View File

@@ -58,6 +58,20 @@ func adjust_disposition(id: String, delta: int) -> bool:
return true
func has_quest(id: String) -> bool:
for q in active_quests:
if q.id == id:
return true
return false
func add_quest(id: String, name: String, objective: String) -> bool:
if has_quest(id):
return false
active_quests.append(Quest.new(id, name, "active", objective))
return true
func set_quest_status(id: String, status: String) -> bool:
for q in active_quests:
if q.id == id:

View File

@@ -0,0 +1,72 @@
extends Control
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
## transport to a button and a label. NOT the game UI (a later wireframe owns
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
## smoke steps in client/docs/README.md.
const DmService = preload("res://scripts/net/dm_service.gd")
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
var _service: DmService
var _log: CanonLog
var _label: RichTextLabel
var _status: Label
var _button: Button
var _indicator: ConsideringIndicator
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
_log = _build_scene_log()
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 12)
add_child(vbox)
_button = Button.new()
_button.text = "Narrate this scene"
_button.pressed.connect(_on_narrate_pressed)
vbox.add_child(_button)
_label = RichTextLabel.new()
_label.fit_content = true
_label.custom_minimum_size = Vector2(640, 320)
vbox.add_child(_label)
_status = Label.new()
vbox.add_child(_status)
_indicator = ConsideringIndicator.new()
add_child(_indicator)
func _on_narrate_pressed() -> void:
_button.disabled = true
_indicator.start(_status)
var r := await _service.narrate(_log)
_indicator.stop()
_label.text = r.display_text
for f in r.facts:
_log.add_fact(f)
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
_button.disabled = false
func _build_scene_log() -> CanonLog:
var log := CanonLog.new()
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
log.set_location("greywater_docks", "the Greywater docks")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
log.push_event("Arrived at Greywater by barge before dawn")
log.push_event("Brannoc recognised the harbourmaster and went quiet")
log.add_fact("the eastern bridge out of Greywater is washed out")
log.add_fact("the harbourmaster is named Oda Fenn")
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
return log

View File

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

View File

@@ -0,0 +1,107 @@
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 ConsideringIndicator = preload("res://scripts/ui/considering_indicator.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
var _status: Label
var _indicator: ConsideringIndicator
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")
# Build the player through LogPlayer so luck_descriptor is non-empty — the
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
# the digest surfaces to the NPC. An empty string here 422s the request.
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
# 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)
_status = Label.new()
vbox.add_child(_status)
_indicator = ConsideringIndicator.new()
add_child(_indicator)
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)
_indicator.start(_status)
var r: NpcResult = await _service.speak(
NPC_ID, utterance, _canon_log, disposition, available)
_indicator.stop()
# Apply state explicitly (§2) — the service applied nothing.
MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID)
for f in r.facts:
_canon_log.add_fact(f)
_append("[b]Fenn:[/b] %s" % r.display_text)
_append("[i]moves: %d applied, %d dropped · disposition %d%s[/i]" % [
r.valid_moves.size(), r.dropped_moves.size(),
int(_game_state.npc_dispositions.get(NPC_ID, 0)),
" (degraded)" if r.degraded else ""])
if r.ends_conversation:
_append("[i]— Fenn ends the conversation. —[/i]")
else:
_speak_btn.disabled = false
func _append(bbcode: String) -> void:
_output.append_text(bbcode + "\n\n")

View File

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

View File

@@ -0,0 +1,25 @@
class_name DmResponse
extends RefCounted
## The raw outcome of one HTTP attempt, independent of any game meaning.
## `status` is 0 when the transport never reached the server; `body` is the
## parsed JSON dict, or null when the body was absent or not JSON.
var status: int
var body # Variant: parsed JSON (Dictionary) or null
var transport_ok: bool
var error: String
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
status = p_status
body = p_body
transport_ok = p_transport_ok
error = p_error
static func ok(p_status: int, p_body) -> DmResponse:
return DmResponse.new(p_status, p_body, true, "")
static func failed(p_error: String) -> DmResponse:
return DmResponse.new(0, null, false, p_error)

View File

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

View File

@@ -0,0 +1,27 @@
class_name DmService
extends RefCounted
## The client narrate loop (charter §2: the client owns the loop and consumes
## text). Posts the canon log to /dm/narrate through an injected transport,
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
## it returns an authored fallback line — never an error. It harvests facts
## only; the Narrator has no authority over disposition or moves, so those tags
## are dropped even if the model emits them (§6). narrate() does not mutate the
## log — the caller applies the returned facts, keeping the state write explicit.
var _transport: DmTransport
var _fallback: FallbackLibrary
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
_transport = transport
_fallback = fallback
func narrate(canon_log: CanonLog) -> NarrateResult:
var resp: DmResponse = await _transport.post_json(
"/dm/narrate", {"canon_log": canon_log.to_dict()})
if not resp.transport_ok or resp.status != 200 \
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
return NarrateResult.fallback(_fallback.narrator_line())
var extracted := TagExtractor.extract(str(resp.body["prose"]))
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)

View File

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

View File

@@ -0,0 +1,10 @@
class_name DmTransport
extends RefCounted
## The network seam. DmService talks to this, never to HTTPRequest directly, so
## the loop logic is testable with an injected fake. Concrete transports
## override post_json. The base returns a failed response (rather than assert())
## so a missing override is a loud, catchable failure in tests.
func post_json(_path: String, _body: Dictionary) -> DmResponse:
push_error("DmTransport.post_json must be overridden")
return DmResponse.failed("transport not implemented")

View File

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

View File

@@ -0,0 +1,42 @@
class_name FallbackLibrary
extends RefCounted
## Authored degraded-DM fallback text (charter §13). On ANY failure the client
## shows one of these lines in-voice — never an error. Missing/malformed content
## yields a hardcoded constant, so the fallback itself cannot fail.
const PATH := "res://content/fallback/narrator.json"
const NPC_PATH := "res://content/fallback/npc.json"
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
const NPC_LAST_RESORT := "The figure regards you in silence, and the moment passes."
var _lines: Array = []
var _npc_lines: Array = []
func _init(path := PATH, npc_path := NPC_PATH) -> void:
_lines = _load(path, "narrator")
_npc_lines = _load(npc_path, "npc")
func narrator_line() -> String:
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
func npc_line() -> String:
return str(_npc_lines[0]) if not _npc_lines.is_empty() else NPC_LAST_RESORT
static func _load(path: String, key: String) -> Array:
if not FileAccess.file_exists(path):
return []
var text := FileAccess.get_file_as_string(path)
# Instance parse() (not JSON.parse_string) so malformed input returns an
# Error code instead of pushing an engine error the test config promotes.
var json := JSON.new()
if json.parse(text) != OK:
return []
var data = json.get_data()
if typeof(data) != TYPE_DICTIONARY:
return []
var lines = data.get(key, [])
return lines if typeof(lines) == TYPE_ARRAY else []

View File

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

View File

@@ -0,0 +1,44 @@
extends "res://scripts/net/dm_transport.gd"
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
## This is the one untested shim in the slice — it needs a live Node and a
## server, so it is verified by reading and by the harness live smoke, the same
## way the server's HTTP boundary is covered only by its gated --run-live test.
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
## interprets. Only a transport-level failure (no response) yields failed().
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
var _http: HTTPRequest
func _init(http: HTTPRequest) -> void:
_http = http
# Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed()
# → the service degrades) instead of never returning. Without this the
# considering-state indicator would rotate forever on a hang.
_http.timeout = ProxyConfig.request_timeout_seconds()
func post_json(path: String, body: Dictionary) -> DmResponse:
var url := ProxyConfig.base_url() + path
var headers := ["Content-Type: application/json"]
var payload := JSON.stringify(body)
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
if err != OK:
return DmResponse.failed("request() failed to start: error %d" % err)
# request_completed(result, response_code, headers, body) — awaiting a
# multi-arg signal returns the args as an Array.
var signal_args: Array = await _http.request_completed
var result: int = signal_args[0]
var response_code: int = signal_args[1]
var raw: PackedByteArray = signal_args[3]
if result != HTTPRequest.RESULT_SUCCESS:
return DmResponse.failed("transport result %d" % result)
# Parse via JSON.new() rather than JSON.parse_string(): the latter pushes an
# engine-level error on a non-JSON body (e.g. a gateway's HTML 502), which the
# project's gutconfig promotes to a false test failure. A non-JSON body stays
# null here — the service treats a non-dict body as a failure and degrades.
var json := JSON.new()
var parsed = null if json.parse(raw.get_string_from_utf8()) != OK else json.data
return DmResponse.ok(response_code, parsed)

View File

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

View File

@@ -0,0 +1,18 @@
class_name NarrateResult
extends RefCounted
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
## strings the caller applies to the canon log; empty when degraded.
var display_text: String
var facts: Array
var degraded: bool
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
display_text = p_display_text
facts = p_facts
degraded = p_degraded
static func fallback(line: String) -> NarrateResult:
return NarrateResult.new(line, [], true)

View File

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

View File

@@ -0,0 +1,27 @@
# client/scripts/net/npc_result.gd
class_name NpcResult
extends RefCounted
## The game-meaningful result of one /npc/speak call. The caller applies
## valid_moves (via MoveApplier) and harvests facts — state writes stay explicit
## (§2). dropped_moves are kept only so the caller can log the §6 drops.
var display_text: String
var facts: Array
var valid_moves: Array
var dropped_moves: Array
var ends_conversation: bool
var degraded: bool
func _init(p_display_text := "", p_facts := [], p_valid := [], p_dropped := [],
p_ends := false, p_degraded := false) -> void:
display_text = p_display_text
facts = p_facts
valid_moves = p_valid
dropped_moves = p_dropped
ends_conversation = p_ends
degraded = p_degraded
static func fallback(line: String) -> NpcResult:
return NpcResult.new(line, [], [], [], false, true)

View File

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

View File

@@ -0,0 +1,48 @@
class_name NpcService
extends RefCounted
## The client /npc/speak loop (§6). Posts persona-free request (npc_id +
## disposition + client-computed available_moves + utterance) through an injected
## transport, degrades to an authored NPC fallback on ANY failure, extracts tags
## with TagExtractor, validates moves against available_moves (MoveValidator),
## and returns an NpcResult. It applies NO state — the caller runs MoveApplier,
## keeping state writes explicit (§2).
const ENDING_MOVES := ["end_conversation", "become_hostile"]
var _transport: DmTransport
var _fallback: FallbackLibrary
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
_transport = transport
_fallback = fallback
func speak(npc_id: String, utterance: String, canon_log: CanonLog,
disposition: int, available_moves: Array) -> NpcResult:
var resp: DmResponse = await _transport.post_json("/npc/speak", {
"canon_log": canon_log.to_dict(),
"npc_id": npc_id,
"disposition": disposition,
"available_moves": available_moves,
"utterance": utterance,
})
if not resp.transport_ok or resp.status != 200 \
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
return NpcResult.fallback(_fallback.npc_line())
var extracted := TagExtractor.extract(str(resp.body["prose"]))
var moves: Array = extracted["moves"].duplicate()
# Fold the [ADJUST_DISPOSITION:n] alias into the move stream so the validator
# sees one uniform shape (§ tag convention).
for delta in extracted["dispositions"]:
moves.append({"name": "adjust_disposition", "args": [str(delta)]})
var checked := MoveValidator.validate(moves, available_moves)
var ends := false
for m in checked["valid"]:
if m["name"] in ENDING_MOVES:
ends = true
return NpcResult.new(
extracted["clean_text"], extracted["facts"],
checked["valid"], checked["dropped"], ends, false)

View File

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

View File

@@ -0,0 +1,25 @@
class_name ProxyConfig
extends RefCounted
## The client's one piece of network config: the proxy base URL. Never a key,
## never a model name (charter §4). Overridable per environment via project
## settings; defaults to the dev proxy.
const SETTING := "coc_rpg/proxy_base_url"
const DEFAULT := "http://localhost:8000"
const TIMEOUT_SETTING := "coc_rpg/proxy_request_timeout_seconds"
const TIMEOUT_DEFAULT := 35.0
static func base_url() -> String:
var v = ProjectSettings.get_setting(SETTING, DEFAULT)
var s := str(v)
return s if s != "" else DEFAULT
static func request_timeout_seconds() -> float:
# Just above the server's OLLAMA_TIMEOUT_SECONDS (30) so a slow model surfaces
# the server's own error first, while a fully-hung proxy is still bounded. A
# non-positive value would re-enable the infinite hang, so reject it.
var v := float(ProjectSettings.get_setting(TIMEOUT_SETTING, TIMEOUT_DEFAULT))
return v if v > 0.0 else TIMEOUT_DEFAULT

View File

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

View File

@@ -0,0 +1,37 @@
class_name MoveApplier
extends RefCounted
## Writes validated §6 moves to game state. Town-NPC disposition lives in
## GameState.npc_dispositions (NOT CanonLog.party — that is companions only).
## Called by the conversation caller after NpcService.speak, so state writes
## stay explicit (§2). Unknown move names are ignored (they never pass the
## validator, but this stays defensive).
const MAX_DELTA := 15 # one line cannot swing standing wholesale (§7 spirit)
const HOSTILE_FLOOR := -100
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
for move in moves:
var name: String = move.get("name", "")
var args: Array = move.get("args", [])
match name:
"offer_quest":
var q: String = str(args[0])
var qd: Dictionary = content_db.quest(q)
canon_log.add_quest(q, qd.get("name", ""), qd.get("objective", ""))
"reveal":
game_state.mark_revealed(str(args[0]))
"give_item":
var i: String = str(args[0])
game_state.add_item(i, 1)
game_state.mark_gift_given(i)
"accept_item":
game_state.remove_item(str(args[0]), 1)
"adjust_disposition":
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
game_state.set_npc_disposition(npc_id, cur + delta)
"become_hostile":
game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
_:
pass # refuse, end_conversation, and anything unknown: no state

View File

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

View File

@@ -0,0 +1,29 @@
class_name MoveValidator
extends RefCounted
## Pure §6 validation: an emitted move is legal iff its canonical key is in the
## turn's available_moves (which NpcContent already computed from live state).
## No state reads here — membership is the whole test. Invalid moves are not an
## error; they are dropped and returned separately so the caller can log them.
const ID_MOVES := ["reveal", "offer_quest", "give_item", "accept_item"]
const BARE_MOVES := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]
static func _key(move: Dictionary) -> String:
var name: String = move.get("name", "")
var args: Array = move.get("args", [])
if name in ID_MOVES:
return "%s(%s)" % [name, str(args[0])] if not args.is_empty() else name
return name # bare/value moves key on the name alone
static func validate(moves: Array, available_moves: Array) -> Dictionary:
var valid: Array = []
var dropped: Array = []
for move in moves:
var name: String = move.get("name", "")
if (name in ID_MOVES or name in BARE_MOVES) and _key(move) in available_moves:
valid.append(move)
else:
dropped.append(move)
return {"valid": valid, "dropped": dropped}

View File

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

View File

@@ -0,0 +1,32 @@
class_name NpcContent
extends RefCounted
## Computes available_moves — the single home of move legality (§6). Reads the
## NPC's non-spoiler capability block and filters by live game state so the model
## is shown exactly what it may legally do this turn. persona/knowledge on the
## same file are server-only and ignored here.
const UNIVERSAL := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]
static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Array:
var moves: Array = []
var caps: Dictionary = npc_dict.get("capabilities", {})
for q in caps.get("offerable_quests", []):
if not canon_log.has_quest(q):
moves.append("offer_quest(%s)" % q)
for t in caps.get("revealable_topics", []):
if not game_state.is_revealed(t):
moves.append("reveal(%s)" % t)
for i in caps.get("giveable_items", []):
if not game_state.is_gift_given(i):
moves.append("give_item(%s)" % i)
for item_id in game_state.inventory.keys():
if int(game_state.inventory[item_id]) > 0:
moves.append("accept_item(%s)" % item_id)
moves.append_array(UNIVERSAL)
return moves

View File

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

View File

@@ -9,6 +9,8 @@ var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty
var revealed_topics: Dictionary = {} # topic_id -> true
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
func set_luck(v: int) -> void:
@@ -29,3 +31,27 @@ func set_npc_disposition(id: String, v: int) -> void:
func add_item(item_id: String, qty: int) -> void:
inventory[item_id] = int(inventory.get(item_id, 0)) + qty
func remove_item(item_id: String, qty: int) -> void:
var left := int(inventory.get(item_id, 0)) - qty
if left > 0:
inventory[item_id] = left
else:
inventory.erase(item_id)
func mark_revealed(topic_id: String) -> void:
revealed_topics[topic_id] = true
func is_revealed(topic_id: String) -> bool:
return revealed_topics.has(topic_id)
func mark_gift_given(item_id: String) -> void:
gifts_given[item_id] = true
func is_gift_given(item_id: String) -> bool:
return gifts_given.has(item_id)

View File

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

View File

@@ -0,0 +1,44 @@
class_name ConsideringIndicator
extends Node
## Thin node shim (charter §13). While a call is in flight it rotates an authored
## ConsideringPhrases line onto a Label every ROTATE_SECONDS, reframing the wait
## as the GM thinking. The phrase logic is the pure, tested ConsideringPhrases;
## the Timer/tree wiring here is the untested shim (verified by running the
## harness). One instance persists on the harness so the rotation index is not
## reset per call (spec decision 4).
const ROTATE_SECONDS := 3.0
var _phrases: ConsideringPhrases
var _timer: Timer
var _label: Label
func _init(phrases: ConsideringPhrases = null) -> void:
_phrases = phrases if phrases != null else ConsideringPhrases.new()
func _ready() -> void:
_timer = Timer.new()
_timer.wait_time = ROTATE_SECONDS
_timer.one_shot = false
_timer.timeout.connect(_advance)
add_child(_timer)
func start(label: Label) -> void:
_label = label
_advance() # show a phrase immediately, don't wait ROTATE_SECONDS
_timer.start()
func stop() -> void:
_timer.stop()
if _label != null:
_label.text = ""
_label = null
func _advance() -> void:
if _label != null:
_label.text = _phrases.next()

View File

@@ -0,0 +1,41 @@
class_name ConsideringPhrases
extends RefCounted
## Authored "the GM is thinking" lines (charter §13 — even loading is content).
## Pure: cycles a phrase pool in order on a running index that is instance state
## (spec decision 4 — the indicator reuses one instance so the first phrase varies
## turn to turn without randomness). A missing/malformed file degrades to one
## hardcoded constant, so the indicator itself cannot fail.
const PATH := "res://content/considering.json"
const LAST_RESORT := "The Master considers your words."
var _pool: Array = []
var _index: int = 0
func _init(path := PATH) -> void:
_pool = _load(path)
func next() -> String:
if _pool.is_empty():
return LAST_RESORT
var phrase := str(_pool[_index % _pool.size()])
_index += 1
return phrase
static func _load(path: String) -> Array:
if not FileAccess.file_exists(path):
return []
var text := FileAccess.get_file_as_string(path)
# Instance parse() (not JSON.parse_string) so malformed input returns an
# Error code instead of the engine error the test config promotes to a fail.
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("considering", [])
return lines if typeof(lines) == TYPE_ARRAY else []

View File

@@ -0,0 +1,19 @@
extends "res://scripts/net/dm_transport.gd"
## Test double for DmTransport. Returns a canned DmResponse and records the last
## call so tests can assert what the service posted. post_json needs no real
## async — awaiting a plain return value resolves immediately, so the same await
## call site in DmService drives both the real and the fake transport.
var _canned
var last_path: String
var last_body: Dictionary
func set_response(resp) -> void:
_canned = resp
func post_json(path: String, body: Dictionary) -> DmResponse:
last_path = path
last_body = body
return _canned

View File

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

View File

@@ -46,3 +46,15 @@ func test_set_quest_status_rejects_bad_enum():
func test_unsupported_schema_version_returns_null():
assert_null(CanonLog.from_dict({"schema_version": 2}))
func test_add_quest_appends_active_and_is_idempotent():
var log = CanonLog.new()
assert_false(log.has_quest("find_the_ledger"))
assert_true(log.add_quest("find_the_ledger", "The Missing Ledger", "Find who took it"))
assert_true(log.has_quest("find_the_ledger"))
assert_eq(log.active_quests.size(), 1)
assert_eq(log.active_quests[0].status, "active")
# second add is a no-op
assert_false(log.add_quest("find_the_ledger", "x", "y"))
assert_eq(log.active_quests.size(), 1)

View File

@@ -0,0 +1,19 @@
extends "res://addons/gut/test.gd"
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
func test_start_shows_first_phrase_and_stop_clears():
# Point phrases at a missing file so next() is deterministically LAST_RESORT.
var phrases = ConsideringPhrases.new("res://content/does_not_exist.json")
var ind = ConsideringIndicator.new(phrases)
add_child_autofree(ind) # _ready() creates the Timer child
var label := Label.new()
add_child_autofree(label)
ind.start(label)
assert_eq(label.text, ConsideringPhrases.LAST_RESORT) # first phrase shown at once
ind.stop()
assert_eq(label.text, "") # cleared on stop

View File

@@ -0,0 +1,33 @@
extends "res://addons/gut/test.gd"
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
func test_missing_file_returns_last_resort():
var p = ConsideringPhrases.new("res://content/does_not_exist.json")
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
func test_cycles_pool_in_order_and_wraps():
# Real authored file has at least 3 lines; assert order + wrap on it.
var p = ConsideringPhrases.new() # default path — real content
var first = p.next()
var second = p.next()
assert_ne(first, "")
assert_ne(first, second) # advances, not stuck
# Drain a full cycle back to `first` to prove it wraps (bounded loop).
var seen := [first, second]
for i in range(20):
var n = p.next()
if n == first:
return # wrapped — success
seen.append(n)
assert_true(false, "did not wrap within 22 calls: %s" % [seen])
func test_index_persists_across_calls_not_reset():
var p = ConsideringPhrases.new()
var a = p.next()
var b = p.next()
assert_ne(a, b) # second call did not re-return the first phrase

View File

@@ -0,0 +1,80 @@
extends "res://addons/gut/test.gd"
const DmService = preload("res://scripts/net/dm_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():
# Point the library at a missing file so narrator_line() is deterministically
# LAST_RESORT — the degraded assertions don't depend on the authored content.
_fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
func _log() -> CanonLog:
return CanonLog.new()
func _service_for(resp) -> Dictionary:
var t = FakeDmTransport.new()
t.set_response(resp)
return {"svc": DmService.new(t, _fallback), "transport": t}
func test_success_strips_tags_and_harvests_facts():
var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"}
var pair := _service_for(DmResponse.ok(200, body))
var r = await pair["svc"].narrate(_log())
assert_false(r.degraded)
assert_eq(r.display_text, "The docks reek of tar.")
assert_eq(r.facts, ["the eastern bridge is out"])
func test_success_posts_canon_log_to_narrate_path():
var pair := _service_for(DmResponse.ok(200, {"prose": "x"}))
await pair["svc"].narrate(_log())
assert_eq(pair["transport"].last_path, "/dm/narrate")
assert_true(pair["transport"].last_body.has("canon_log"))
func test_502_degrades_to_fallback_with_no_facts():
var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
assert_eq(r.display_text, FallbackLibrary.LAST_RESORT)
assert_eq(r.facts, [])
func test_422_degrades():
var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_transport_failure_degrades():
var pair := _service_for(DmResponse.failed("connection refused"))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_malformed_200_missing_prose_degrades():
var pair := _service_for(DmResponse.ok(200, {"nope": 1}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_malformed_200_non_dict_body_degrades():
var pair := _service_for(DmResponse.ok(200, null))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_only_facts_harvested_dispositions_and_moves_dropped():
var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"}
var pair := _service_for(DmResponse.ok(200, body))
var r = await pair["svc"].narrate(_log())
assert_eq(r.facts, ["a"])
assert_eq(r.display_text, "Hi bye")

View File

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

View File

@@ -0,0 +1,45 @@
extends "res://addons/gut/test.gd"
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
func test_loads_authored_line_from_default_file():
var lib = FallbackLibrary.new()
# The authored line is distinct from LAST_RESORT, so a match proves the file was read.
assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
assert_true(lib.narrator_line().length() > 0)
func test_missing_file_uses_last_resort():
var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
func test_malformed_file_uses_last_resort():
var path := "user://bad_fallback.json"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("{ this is not valid json")
f.close()
var lib = FallbackLibrary.new(path)
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
func test_empty_list_uses_last_resort():
var path := "user://empty_fallback.json"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string('{"narrator": []}')
f.close()
var lib = FallbackLibrary.new(path)
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
func test_npc_line_falls_back_to_constant_when_missing():
var lib = FallbackLibrary.new(
"res://content/fallback/does_not_exist.json",
"res://content/fallback/does_not_exist.json")
assert_eq(lib.npc_line(), FallbackLibrary.NPC_LAST_RESORT)
func test_npc_line_reads_authored_file():
var lib = FallbackLibrary.new() # default paths — real content
assert_ne(lib.npc_line(), "")

View File

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

View File

@@ -28,3 +28,26 @@ func test_luck_descriptor_delegates():
var s = GameState.new()
s.luck = 4
assert_eq(s.luck_descriptor(), "Fortune spits on you")
func test_remove_item_decrements_and_floors_at_zero():
var gs = GameState.new()
gs.add_item("coin", 3)
gs.remove_item("coin", 2)
assert_eq(gs.inventory.get("coin", 0), 1)
gs.remove_item("coin", 5)
assert_eq(gs.inventory.get("coin", 0), 0)
func test_revealed_topics_tracked():
var gs = GameState.new()
assert_false(gs.is_revealed("varrell_twins"))
gs.mark_revealed("varrell_twins")
assert_true(gs.is_revealed("varrell_twins"))
func test_gifts_given_tracked():
var gs = GameState.new()
assert_false(gs.is_gift_given("amulet"))
gs.mark_gift_given("amulet")
assert_true(gs.is_gift_given("amulet"))

View File

@@ -0,0 +1,55 @@
# client/tests/unit/test_move_applier.gd
extends "res://addons/gut/test.gd"
const MoveApplier = preload("res://scripts/npc/move_applier.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _m(name, args := []) -> Dictionary:
return {"name": name, "args": args}
func _content() -> ContentDB:
var c = ContentDB.new()
c.quests = {"find_the_ledger": {"id": "find_the_ledger",
"name": "The Missing Ledger", "objective": "Find who took it"}}
return c
func test_offer_quest_adds_active_quest():
var log = CanonLog.new()
MoveApplier.apply([_m("offer_quest", ["find_the_ledger"])],
GameState.new(), log, _content(), "fenn")
assert_true(log.has_quest("find_the_ledger"))
assert_eq(log.active_quests[0].name, "The Missing Ledger")
func test_reveal_marks_topic():
var gs = GameState.new()
MoveApplier.apply([_m("reveal", ["varrell_twins"])], gs, CanonLog.new(), _content(), "fenn")
assert_true(gs.is_revealed("varrell_twins"))
func test_adjust_disposition_clamps_to_max_delta():
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+50"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.MAX_DELTA)
func test_become_hostile_sets_floor():
var gs = GameState.new()
MoveApplier.apply([_m("become_hostile")], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.HOSTILE_FLOOR)
func test_accept_item_removes_from_inventory():
var gs = GameState.new()
gs.add_item("coin", 2)
MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("coin", 0), 1)
func test_refuse_and_end_are_noops():
var gs = GameState.new()
MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.size(), 0)

View File

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

View File

@@ -0,0 +1,38 @@
extends "res://addons/gut/test.gd"
const MoveValidator = preload("res://scripts/npc/move_validator.gd")
func _m(name, args := []) -> Dictionary:
return {"name": name, "args": args}
func test_id_move_in_available_is_valid():
var res = MoveValidator.validate(
[_m("reveal", ["varrell_twins"])], ["reveal(varrell_twins)", "refuse"])
assert_eq(res["valid"].size(), 1)
assert_eq(res["dropped"].size(), 0)
func test_id_move_not_in_available_is_dropped():
var res = MoveValidator.validate(
[_m("offer_quest", ["some_other_quest"])], ["offer_quest(find_the_ledger)"])
assert_eq(res["valid"].size(), 0)
assert_eq(res["dropped"].size(), 1)
func test_bare_move_keys_on_name():
var res = MoveValidator.validate([_m("refuse")], ["refuse"])
assert_eq(res["valid"].size(), 1)
func test_adjust_disposition_keys_on_name_keeps_delta():
var res = MoveValidator.validate(
[_m("adjust_disposition", ["+5"])], ["adjust_disposition"])
assert_eq(res["valid"].size(), 1)
assert_eq(res["valid"][0]["args"], ["+5"])
func test_unknown_move_name_is_dropped():
var res = MoveValidator.validate([_m("cast_fireball", ["9"])], ["refuse"])
assert_eq(res["dropped"].size(), 1)

View File

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

View File

@@ -0,0 +1,90 @@
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")
func test_dm_response_ok():
var r = DmResponse.ok(200, {"prose": "hi"})
assert_eq(r.status, 200)
assert_true(r.transport_ok)
assert_eq(r.body, {"prose": "hi"})
assert_eq(r.error, "")
func test_dm_response_failed():
var r = DmResponse.failed("boom")
assert_false(r.transport_ok)
assert_eq(r.status, 0)
assert_eq(r.body, null)
assert_eq(r.error, "boom")
func test_narrate_result_basic():
var r = NarrateResult.new("text", ["a"], false)
assert_eq(r.display_text, "text")
assert_eq(r.facts, ["a"])
assert_false(r.degraded)
func test_narrate_result_fallback():
var r = NarrateResult.fallback("cold chamber")
assert_eq(r.display_text, "cold chamber")
assert_eq(r.facts, [])
assert_true(r.degraded)
func test_transport_base_returns_not_implemented():
var t = DmTransport.new()
var r = await t.post_json("/x", {})
assert_false(r.transport_ok)
assert_eq(r.error, "transport not implemented")
func test_proxy_config_default():
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT)
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)
func test_request_timeout_default():
if ProjectSettings.has_setting(ProxyConfig.TIMEOUT_SETTING):
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
func test_request_timeout_override_and_reject_nonpositive():
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 12.5)
assert_eq(ProxyConfig.request_timeout_seconds(), 12.5)
# A non-positive override must NOT re-enable the hang — falls back to default.
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 0.0)
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
func test_http_transport_sets_request_timeout():
var HttpTransport = preload("res://scripts/net/http_transport.gd")
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
var http := HTTPRequest.new()
autofree(http)
HttpTransport.new(http)
assert_eq(http.timeout, ProxyConfig.request_timeout_seconds())
assert_gt(http.timeout, 0.0) # the whole point — no longer disabled

View File

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

View File

@@ -0,0 +1,44 @@
extends "res://addons/gut/test.gd"
const NpcContent = preload("res://scripts/npc/npc_content.gd")
func _fenn() -> Dictionary:
return {"id": "fenn", "capabilities": {
"offerable_quests": ["find_the_ledger"],
"giveable_items": [],
"revealable_topics": ["varrell_twins", "fenns_debt"]}}
func test_universal_moves_always_present():
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
for m in ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]:
assert_true(m in moves, "missing universal move %s" % m)
func test_offers_unstarted_quest_and_unrevealed_topics():
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
assert_true("offer_quest(find_the_ledger)" in moves)
assert_true("reveal(varrell_twins)" in moves)
assert_true("reveal(fenns_debt)" in moves)
func test_active_quest_is_not_offered_again():
var log = CanonLog.new()
log.add_quest("find_the_ledger", "x", "y")
var moves = NpcContent.available_moves(_fenn(), GameState.new(), log)
assert_false("offer_quest(find_the_ledger)" in moves)
func test_revealed_topic_drops_out():
var gs = GameState.new()
gs.mark_revealed("varrell_twins")
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_false("reveal(varrell_twins)" in moves)
assert_true("reveal(fenns_debt)" in moves)
func test_accept_item_offered_per_held_item():
var gs = GameState.new()
gs.add_item("coin", 2)
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_true("accept_item(coin)" in moves)

View File

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

View File

@@ -0,0 +1,92 @@
# client/tests/unit/test_npc_service.gd
extends "res://addons/gut/test.gd"
const NpcService = preload("res://scripts/net/npc_service.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd")
var _fallback
func before_each():
_fallback = FallbackLibrary.new(
"res://content/fallback/missing.json", "res://content/fallback/missing.json")
func _svc(resp) -> Dictionary:
var t = FakeDmTransport.new()
t.set_response(resp)
return {"svc": NpcService.new(t, _fallback), "transport": t}
func _speak(pair, available):
return await pair["svc"].speak("fenn", "what happened?", CanonLog.new(), -10, available)
func test_posts_full_request_body():
var pair = _svc(DmResponse.ok(200, {"prose": "Aye."}))
await _speak(pair, ["refuse"])
assert_eq(pair["transport"].last_path, "/npc/speak")
var body = pair["transport"].last_body
assert_eq(body["npc_id"], "fenn")
assert_eq(body["disposition"], -10)
assert_eq(body["utterance"], "what happened?")
assert_true(body.has("available_moves"))
assert_true(body.has("canon_log"))
func test_valid_move_kept_prose_cleaned():
var prose = "Aye, I knew them. [MOVE: reveal(varrell_twins)]"
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
var r = await _speak(pair, ["reveal(varrell_twins)", "refuse"])
assert_false(r.degraded)
assert_eq(r.display_text, "Aye, I knew them.")
assert_eq(r.valid_moves.size(), 1)
assert_eq(r.dropped_moves.size(), 0)
func test_illegal_move_dropped_prose_kept():
var prose = "No. [MOVE: offer_quest(some_other)]"
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
var r = await _speak(pair, ["offer_quest(find_the_ledger)", "refuse"])
assert_eq(r.display_text, "No.")
assert_eq(r.valid_moves.size(), 0)
assert_eq(r.dropped_moves.size(), 1)
func test_adjust_disposition_alias_is_validated():
var prose = "Fine. [ADJUST_DISPOSITION: +5]"
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
var r = await _speak(pair, ["adjust_disposition"])
assert_eq(r.valid_moves.size(), 1)
assert_eq(r.valid_moves[0]["name"], "adjust_disposition")
assert_eq(r.valid_moves[0]["args"], ["5"])
func test_end_conversation_sets_flag():
var prose = "We're done. [MOVE: end_conversation()]"
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
var r = await _speak(pair, ["end_conversation"])
assert_true(r.ends_conversation)
func test_facts_harvested():
var prose = "The bridge fell. [FACT: the eastern bridge is out]"
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
var r = await _speak(pair, ["refuse"])
assert_eq(r.facts, ["the eastern bridge is out"])
func test_502_degrades_no_moves():
var pair = _svc(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
var r = await _speak(pair, ["refuse"])
assert_true(r.degraded)
assert_eq(r.display_text, FallbackLibrary.NPC_LAST_RESORT)
assert_eq(r.valid_moves, [])
func test_transport_failure_degrades():
var pair = _svc(DmResponse.failed("refused"))
var r = await _speak(pair, ["refuse"])
assert_true(r.degraded)

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
{
"id": "fenn",
"name": "Fenn",
"role": "townsfolk",
"persona": "A harried dockside clerk who counts everything and trusts no one. He speaks in short, aggrieved sentences and keeps glancing at the door. He is frightened, and covers it with irritation.",
"knowledge": [
"His counting-house ledger vanished two nights ago, sometime after the third bell.",
"The Varrell twins were drinking in the counting-room the night it went missing; he threw them out but did not check the strongbox until morning.",
"The ledger records a debt he cannot cover if the wrong people read it, and he will not say to whom.",
"He has not told the harbourmaster, because the harbourmaster is one of the people the ledger names."
],
"capabilities": {
"offerable_quests": ["find_the_ledger"],
"giveable_items": [],
"revealable_topics": ["varrell_twins", "fenns_debt"]
}
}

View File

@@ -14,9 +14,18 @@ services:
required: false
environment:
PORT: 8000
# Mount source + schemas so edits are live without a rebuild.
# Reach a host-run Ollama from inside the container. On Linux, localhost in
# the container is the container itself, so OLLAMA_BASE_URL must be
# http://host.docker.internal:11434 (set in api/.env); this maps that name
# to the Docker host. Harmless when Ollama is on the homelab instead.
extra_hosts:
- "host.docker.internal:host-gateway"
# Mount source + prompts + schemas so edits are live without a rebuild.
# Prompts are source code (§16) — editing a prompt should reload like code.
volumes:
- ./api/app:/app/app:ro
- ./api/prompts:/app/prompts:ro
- ./docs/schemas:/app/schemas:ro
- ./content:/app/content:ro
command: >
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -2,33 +2,95 @@
Source of truth for scope is [`CLAUDE.md`](../CLAUDE.md). This tracks sequencing.
## POC — the one question (charter §17)
## The game — building the tactical CRPG (charter §17)
> **Does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
> **The POC's one question — *does bounded AI dialogue feel alive?* — is answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0M2). We are now building the game the mockups describe (§16, `/mockups`): a tactical CRPG across 11 screens.
Test that before building anything else. ~30 minutes of play.
The engine is done and proven. What's left is the **game on top of it** — recreated screen by screen against the mockups' shared visual system, on the canon log / DMNPC pipeline / bounded-moves / Luck / character-creation model we already built.
### POC scope
### Reconcile as we build (charter §17)
- One town, **two NPCs** with authored knowledge lists
- One dungeon: **three fights, one boss**
- **Two companions:** Cadwyn, Brannoc
- **Three playable classes:** Sellsword, Assassin, Priest
- Luck fully wired: generation, drift, one shrine, one cursed item
- Full proxy, full role split, full canon log
- Authored fallback text for every AI surface
- **Damage determinism** — §7/§10 (deterministic) vs the Combat HUD mock's damage ranges → combat brainstorm (M5).
- **Class names** — §8 names vs the mock's callings (same archetypes) → character-creation brainstorm (M4).
### Out of scope (charter §17)
### Out of scope (never / later)
AI-generated story skeletons · multiplayer (never) · mobile · credits/payments/auth · companions leaving · positional combat · more than three classes.
Multiplayer (never) · mobile (later) · AI-generated story skeletons (v2) · credits/payments/auth (retrofit onto the proxy, §4).
## Sequencing — TBD
## Sequencing
<!-- Fill in as phases are planned. Suggested spine:
1. Proxy skeleton + role endpoints + Ollama routing + logging
2. Canon log + one Narrator scene end-to-end (proves the loop)
3. One bounded NPC conversation (proves §6 — the core experiment)
4. Luck + Improviser + one shrine + one cursed item
5. Combat (deterministic, seeded) + the dungeon
6. Banter + humiliation log (proves the comedy loop)
-->
Status: ✅ done · ▶ next · ○ planned. Each item is its own spec → plan → implementation cycle.
Each carries its **§2 side** (state = code owns it · text = AI owns it · both = it is two systems), what it **depends on**, and the **milestone goal** it advances.
M0M2 built the **engine** (the contract, the client, the server loop, bounded dialogue) and answered the aliveness question. M3+ build the **game** — the CRPG screens/systems of the mockups, sequenced toward a playable vertical slice first, then breadth:
| Milestone | Goal it advances |
|---|---|
| **M0 — Foundations** ✅ | The contract and the client-side engine exist and are trustworthy. |
| **M1 — Prove the server loop** ✅ | A real model sits behind `/dm/narrate` and the pipeline is reusable. |
| **M2 — Prove aliveness** ✅ | **Does bounded AI dialogue feel alive?** — answered; bounded NPC dialogue + DM loop live-proven. |
| **M3 — Visual foundation & shell***here* | The `Theme` + the exploration HUD; every screen inherits the look and lives in the shell. |
| **M4 — Character creation** | The player enters the world; the proven stat/Luck model gets its UI. |
| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. |
| **M6 — Dialogue screen** | The proven NPC free-text loop inside the mock's conversation UI. |
| **M7 — Inventory & character sheet** | State the player can see and manage. |
| **M8 — World systems** | World-map travel, a shop economy, the quest log. |
| **M9 — Framing, AI-flavor & persistence** | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
---
### M0 — Foundations ✅
-**Canon log + origin JSON-Schema contract** — api validation, unified 422 envelope (Plan A). *§2: state · foundation.*
-**Client canon log engine** — typed GDScript model (invariants in the model), new-game construction (origin + world + creation → canon log), turn-to-turn maintenance mutators, pure `[FACT]`/`[MOVE]`/`[ADJUST_DISPOSITION]` tag extractor (Plan B). *§2: state · depends on the contract.*
### M1 — Prove the server loop ✅
-**Narrator / Ollama pipeline (server)** — the shared server-side model-call pipeline (Ollama httpx client, role→model routing config, prompt loader + canon-log digest renderer, §10 call logging, single-retry → typed 502) wired through `/dm/narrate`. Non-streaming. Both phases landed: **(1) pipeline foundation** (`config`/`routing`/`prompts`/`ollama_client`/`call_log`), **(2) Narrator online** (authored `narrator.md` body + `narrate` service + gated live smoke). Server returns raw prose; the client extracts tags. **The other four roles become thin drop-ins on this pipeline.** Live wire proven against qwen3.5; 49 tests. Merged to `dev` (`ddc00d4`). *§2: text · depends on the contract.*
### M2 — Prove aliveness ✅ *(the POC's one question — build complete + live-proven; the *feel* is confirmed in playtest)*
-**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 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.*
-**Considering-state + transport timeout** — while a model call is in flight the client rotates authored in-character "the Master considers…" lines (§13 spirit — even *loading* is content; `ConsideringPhrases` + `ConsideringIndicator`, 3.0s cadence) instead of a frozen surface, and `HttpTransport` sets a request timeout (35s, rejects ≤0) so a hung call degrades to the authored fallback instead of spinning forever. Meets §14's anti-freeze *goal* by **reframing** the wait, on the *proven* non-streaming loop, with no new failure surface. **Human-confirmed live** including the degrade path (API down → fallback). Merged to `dev` (`099714d`); 110/110 client tests. *§2: text/UI · goal: the wait is framed (in-character) and bounded (never hangs).*
- ~~**Streaming** (§14)~~ — **SHELVED for the POC.** Real token streaming would genuinely *hide* the ~2s call, but it is the POC's largest transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds mid-stream error/tag-boundary failure surface not worth the risk. The considering-state above reframes the wait instead and is tested and live. Design kept as a record (`specs/2026-07-10-narrator-streaming-design.md`, banner SHELVED); revive only if playtest proves the wait genuinely hurts. Conscious deviation from §14's "stream everything," recorded per §18.
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each `.dc.html`'s `Component` class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
### M3 — Visual foundation & shell ◀ *here*
-**Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
-**Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
-**Title screen** — new game / continue / load / settings; the entry point. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
### M4 — Character creation
-**Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
### M5 — Tactical combat (gridless)
-**Combat turn manager + HUD** — the Combat HUD `Component` state machine as GDScript: initiative order, the action economy (Action/Bonus/Move), ability resolution, click-to-target, victory/defeat; tokens on the iso board as **flavor only, no movement grid** (§10). Seeded per encounter (§10). **Reconcile damage determinism** here (§7/§10 vs the mock's ranges). Combat flavor is async optional Narrator calls (reuses M1). *§2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.*
### M6 — Dialogue screen
-**Dialogue UI + reshaped Adjudicator** — wrap the *proven* NPC free-text loop (`/npc/speak`, M2) in the mock's conversation screen: NPC portrait, live disposition meter, the NPC's spoken line + DM narration beat, and the **hybrid** input (choice chips with skill/approach tags **plus** the free-text line, §15). The **Adjudicator** (`/dm/adjudicate`, strict JSON §12, one-retry) lands here as the interpreter for the free-text "describe your own action" escape hatch — most turns use buttons. *§2: both (text = NPC voice · state = validated moves/actions) · depends on the NPC role (M2) · goal: conversation inside the game's UI.*
### M7 — Inventory & character sheet
-**Inventory & equipment** — the paper-doll (12 slots) + Satchel grid, category tabs, item detail, encumbrance, derived stats; item seed-data from the mock's `items()` map as `Resource`s. *§2: state · depends on the Theme + item model · goal: gear the player manages.*
-**Character sheet** — attributes (modifier-forward, LCK still hidden), saving throws, the "in a fight" derived grid, skills, talents, afflictions/conditions, the DM's-notes bio. *§2: state (display) · depends on the stat model · goal: the character, legible.*
### M8 — World systems
-**World-map travel** — region map, location markers (settlement/dungeon/fog), roads + planned route, danger ratings, travel time, set-out. *§2: state · depends on the shell · goal: move between places.*
-**Shop economy** — buy/sell with a fence: purses, stock/owned, the ~40%-sell loop, gritty disabled-CTA reasons, merchant reaction lines. *§2: state (merchant reaction = a thin authored/AI layer) · depends on inventory · goal: an economy loop.*
-**Quest log** — active/completed/failed tabs, objectives checklists, rewards, tracked quest; reads the canon log's `active_quests` (§11). *§2: state · depends on the quest model · goal: track the story.*
### M9 — Framing, AI-flavor & persistence
-**Save / load + pause / settings** — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). *§2: state · depends on the systems existing · goal: persistence + framing.*
-**Luck fully wired** — generation exists (Plan B); this adds **drift events** (real bed, shrine blessing, killing something praying), **one shrine**, **one cursed item** (+STR / LCK). Visible only in prose (§7). Gates the Improviser. *§2: state · depends on the item + event systems · goal: the signature system, live.*
-**Improviser**`/dm/improvise`: minor off-script events, **§7-sandboxed by construction** — a validator that only applies whitelisted state changes (dignity, never progress; structurally incapable of ending a quest). Fires on Luck; hooks Cadwyn. *§2: both · depends on Luck being wired · goal: bad luck as a story, not a fine.*
-**Banter**`/party/banter`: companions comment on recent events; low stakes, high charm, **aggressively cacheable**; reads Brannoc's humiliation log (§9), stacking not overwriting. Prose only, no tags. *§2: text · depends on the humiliation log (M0) + events · goal: the comedy loop closes.*
-**Authored-fallback sweep** — every AI-dependent surface has authored fallback *content* (§13), written as writing; the final pass so no surface can show the player an error. *§2: text · depends on every surface existing · goal: a degraded DM still has a voice.*
### Deferred (later / never)
Replicate provider (prod) · auth · metering · credits/payments (§4) — retrofit onto the pipeline later, the game doesn't notice. AI-generated story skeletons (v2 — the seeded story is authored for now). Streaming (§14) — SHELVED (`specs/2026-07-10-narrator-streaming-design.md`); the considering-state reframes the wait instead.
#### NPC & relationship depth (v2)
Three ideas mined from [NarrativeEngine-P](https://github.com/Sagesheep/NarrativeEngine-P) (MIT) — design borrowed, not code. All three are **§2: state** (code owns them; the AI only voices from what code decides), so they strengthen the §2 boundary rather than loosen it. They fold into the NPC role (§6) and canon log (§11); sequence them **with or after the Dialogue screen (M6)**, since they enrich the same conversation surface.
-**Directional dispositions** — split the single per-pair disposition into two directed fields (A→B and B→A), so liking is asymmetric. Unrequited regard, undisclosed betrayal (player still trusts the captain who already sold them out), and the Cadwyn↔Brannoc mutual-but-differently-flavored dislike (§9) all become expressible. Player→NPC gates dialogue *tone*; NPC→player gates *offered moves* (§6 `available_moves`) — two arrows, two levers. Extends `party[]` in the canon log (§11) and `npc_dispositions` on the client; keep the 100..100 range, just double the field. *§2: state · depends on the disposition model existing (M2, done) · goal: relationships that aren't symmetric.*
-**Witness tracking** — every logged event carries a `witnesses: [...]` list; a fact enters an NPC's `knowledge[]` (§6) only if they witnessed it or a witness told them. This is the missing **input-side rule** for the knowledge list — CLAUDE.md §6 says the list is "the entire content the NPC has to draw on" but never says how a fact gets *in*. Structurally prevents the blacksmith spoiling a twist he never saw, gives shady acts real "were you seen?" stakes without a stealth system, and turns rumor spread into a code operation (witness meets NPC → code copies the fact, optionally degraded) rather than AI invention. Adds a field to `recent_events[]`/`established_facts[]` (§11) and a propagation rule on character encounters. *§2: state · depends on the canon log event model (M0, done) + knowledge lists (§6) · goal: NPCs know only what they could plausibly know.*
-**Personality Hexagon persona** — a richer NPC persona schema: six axes (Drive, Diligence, Boldness, Warmth, Empathy, Composure) supplied to the NPC prompt (§6) as structured voice-shaping input, replacing free-form persona blurbs. Server-owned (like `knowledge[]`), so it never ships to the client. Purely additive to the §6 prompt contract — it steers *how* an NPC speaks, not *what* it may do (the move vocabulary is unchanged). *§2: state (the schema) feeding text (the voice) · depends on the NPC role (M2, done) · goal: consistent, distinct NPC voices from data, not prose.*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
# Client HTTP Loop (M2) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the first end-to-end vertical slice — a running Godot client posts its canon log to `/dm/narrate`, shows the returned prose on screen, harvests `[FACT:]` tags into its own log, and degrades to an authored fallback line on any failure.
**Architecture:** A pure `RefCounted` `DmService` owns all loop logic (build request body, parse the response, choose the fallback, harvest facts) behind a small `DmTransport` seam. The real `HttpTransport` wraps Godot's `HTTPRequest` Node; a `FakeDmTransport` double drives the service in headless GUT tests. A throwaway harness scene wires the real transport to a button and a label so a human can see prose. Facts are returned by `narrate()` and applied to the log by the caller — code owns the state mutation (charter §2).
**Tech Stack:** Godot 4.7, GDScript, GUT (headless test runner). No new dependencies. No autoloads.
Spec: `docs/superpowers/specs/2026-07-10-client-http-loop-design.md`.
## Global Constraints
Every task's requirements implicitly include this section.
- **Language/style:** GDScript. Pure classes are `class_name X extends RefCounted`. Follow the existing entity idiom (`client/scripts/canon_log/party_member.gd`): `_init` takes `p_`-prefixed args with defaults; value objects expose plain fields.
- **Client holds only the base URL** — never an API key, never a model name (charter §4).
- **Request/response contract (server, already live):** POST `/dm/narrate`, body exactly `{"canon_log": <log.to_dict()>}`. Success is **only** HTTP `200` with a dict body containing a `"prose"` key. `502``{"detail":{"model_error":…}}`; `422``{"detail":{"canon_log_errors":[…]}}`. Any non-200, transport failure, or malformed 200 → the fallback line. **No client-side retry.**
- **Harvest facts only.** The narrate loop harvests `[FACT:]` tags and nothing else; stray `[ADJUST_DISPOSITION]`/`[MOVE]` are dropped (they belong to the NPC role). Facts are harvested only on a real 200.
- **`narrate()` returns, it does not mutate.** It returns a `NarrateResult`; the caller applies facts via `CanonLog.add_fact(...)`.
- **Fallback content home:** `client/content/fallback/narrator.json` (renderer owns the file's home — root `/content` is server/authoring). The JSON is `{"narrator": [<lines>]}`. The library's hardcoded last-resort constant must be a **different string** from the authored line so tests can tell "loaded from file" from "fell back".
- **Base URL:** default `http://localhost:8000`, overridable via `ProjectSettings` key `coc_rpg/proxy_base_url`.
- **Naming (two deliberate departures from the spec's prose):**
- The `NarrateResult` fallback constructor is `NarrateResult.fallback(line)`, **not** `.degraded(...)` — GDScript shares one namespace for members and methods, so a `degraded()` method would clash with the `degraded` field.
- `DmTransport.post_json` base returns `DmResponse.failed("transport not implemented")` and `push_error`s, rather than `assert()` — GUT cannot catch a Godot `assert()`, so the not-implemented path must be a testable return value.
- **Tests:** GUT, `extends "res://addons/gut/test.gd"`, files under `client/tests/unit/` (the only dir GUT scans, per `.gutconfig.json`). Test doubles live under `client/tests/doubles/` (a sibling dir GUT does **not** scan). Run headless from `client/`:
- Whole suite: `./run_tests.sh`
- One file: `./run_tests.sh -gtest=res://tests/unit/<file>.gd`
- `run_tests.sh` runs `godot --headless --import` first, so new `class_name` scripts resolve.
- **Untested-by-nature:** `HttpTransport` (needs a live `HTTPRequest` Node + server) and the harness scene are verified by reading + a by-hand live smoke against a running proxy — not by the unit suite. This parallels the server's `--run-live` gated boundary.
- **Git (charter §18):** all work on branch `feature/client-http-loop`, cut from `dev`. Commit after every task.
---
### Task 1: Net primitives — `DmResponse`, `NarrateResult`, `DmTransport`, `ProxyConfig`
The value/interface layer the service is built from. All pure, all headless-testable.
**Files:**
- Create: `client/scripts/net/dm_response.gd`
- Create: `client/scripts/net/narrate_result.gd`
- Create: `client/scripts/net/dm_transport.gd`
- Create: `client/scripts/net/proxy_config.gd`
- Test: `client/tests/unit/test_net_primitives.gd`
**Interfaces:**
- Consumes: nothing (leaf layer).
- Produces:
- `DmResponse` — fields `status:int`, `body` (Variant: parsed JSON dict or `null`), `transport_ok:bool`, `error:String`. Static ctors `DmResponse.ok(status:int, body) -> DmResponse`, `DmResponse.failed(error:String) -> DmResponse`.
- `NarrateResult` — fields `display_text:String`, `facts:Array`, `degraded:bool`. Ctor `NarrateResult.new(display_text:="", facts:=[], degraded:=false)`. Static ctor `NarrateResult.fallback(line:String) -> NarrateResult`.
- `DmTransport` — base with coroutine `post_json(path:String, body:Dictionary) -> DmResponse`; base returns `DmResponse.failed("transport not implemented")`.
- `ProxyConfig``const SETTING := "coc_rpg/proxy_base_url"`, `const DEFAULT := "http://localhost:8000"`, static `base_url() -> String`.
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_net_primitives.gd`:
```gdscript
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 DmTransport = preload("res://scripts/net/dm_transport.gd")
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
func test_dm_response_ok():
var r = DmResponse.ok(200, {"prose": "hi"})
assert_eq(r.status, 200)
assert_true(r.transport_ok)
assert_eq(r.body, {"prose": "hi"})
assert_eq(r.error, "")
func test_dm_response_failed():
var r = DmResponse.failed("boom")
assert_false(r.transport_ok)
assert_eq(r.status, 0)
assert_eq(r.body, null)
assert_eq(r.error, "boom")
func test_narrate_result_basic():
var r = NarrateResult.new("text", ["a"], false)
assert_eq(r.display_text, "text")
assert_eq(r.facts, ["a"])
assert_false(r.degraded)
func test_narrate_result_fallback():
var r = NarrateResult.fallback("cold chamber")
assert_eq(r.display_text, "cold chamber")
assert_eq(r.facts, [])
assert_true(r.degraded)
func test_transport_base_returns_not_implemented():
var t = DmTransport.new()
var r = await t.post_json("/x", {})
assert_false(r.transport_ok)
assert_eq(r.error, "transport not implemented")
func test_proxy_config_default():
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT)
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)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: FAIL — GUT reports a load/parse error because the four preloaded scripts do not exist yet.
- [ ] **Step 3: Implement the four primitives**
Create `client/scripts/net/dm_response.gd`:
```gdscript
class_name DmResponse
extends RefCounted
## The raw outcome of one HTTP attempt, independent of any game meaning.
## `status` is 0 when the transport never reached the server; `body` is the
## parsed JSON dict, or null when the body was absent or not JSON.
var status: int
var body # Variant: parsed JSON (Dictionary) or null
var transport_ok: bool
var error: String
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
status = p_status
body = p_body
transport_ok = p_transport_ok
error = p_error
static func ok(p_status: int, p_body) -> DmResponse:
return DmResponse.new(p_status, p_body, true, "")
static func failed(p_error: String) -> DmResponse:
return DmResponse.new(0, null, false, p_error)
```
Create `client/scripts/net/narrate_result.gd`:
```gdscript
class_name NarrateResult
extends RefCounted
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
## strings the caller applies to the canon log; empty when degraded.
var display_text: String
var facts: Array
var degraded: bool
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
display_text = p_display_text
facts = p_facts
degraded = p_degraded
static func fallback(line: String) -> NarrateResult:
return NarrateResult.new(line, [], true)
```
Create `client/scripts/net/dm_transport.gd`:
```gdscript
class_name DmTransport
extends RefCounted
## The network seam. DmService talks to this, never to HTTPRequest directly, so
## the loop logic is testable with an injected fake. Concrete transports
## override post_json. The base returns a failed response (rather than assert())
## so a missing override is a loud, catchable failure in tests.
func post_json(_path: String, _body: Dictionary) -> DmResponse:
push_error("DmTransport.post_json must be overridden")
return DmResponse.failed("transport not implemented")
```
Create `client/scripts/net/proxy_config.gd`:
```gdscript
class_name ProxyConfig
extends RefCounted
## The client's one piece of network config: the proxy base URL. Never a key,
## never a model name (charter §4). Overridable per environment via project
## settings; defaults to the dev proxy.
const SETTING := "coc_rpg/proxy_base_url"
const DEFAULT := "http://localhost:8000"
static func base_url() -> String:
var v = ProjectSettings.get_setting(SETTING, DEFAULT)
var s := str(v)
return s if s != "" else DEFAULT
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: PASS — 7/7 passing, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/net/dm_response.gd client/scripts/net/narrate_result.gd client/scripts/net/dm_transport.gd client/scripts/net/proxy_config.gd client/tests/unit/test_net_primitives.gd
git commit -m "feat(client): net primitives — DmResponse, NarrateResult, DmTransport, ProxyConfig"
```
---
### Task 2: `FallbackLibrary` + authored fallback content
Loads the authored §13 fallback line; falls back to a hardcoded constant if the file is missing or malformed (the fallback has a fallback — the player must never see an error).
**Files:**
- Create: `client/content/fallback/narrator.json`
- Create: `client/scripts/net/fallback_library.gd`
- Test: `client/tests/unit/test_fallback_library.gd`
**Interfaces:**
- Consumes: nothing.
- Produces: `FallbackLibrary` — ctor `FallbackLibrary.new(path := FallbackLibrary.PATH)`; method `narrator_line() -> String`; `const PATH := "res://content/fallback/narrator.json"`; `const LAST_RESORT := "The chamber is cold. Something waits in the dark."`
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_fallback_library.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
func test_loads_authored_line_from_default_file():
var lib = FallbackLibrary.new()
# The authored line is distinct from LAST_RESORT, so a match proves the file was read.
assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
assert_true(lib.narrator_line().length() > 0)
func test_missing_file_uses_last_resort():
var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
func test_malformed_file_uses_last_resort():
var path := "user://bad_fallback.json"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("{ this is not valid json")
f.close()
var lib = FallbackLibrary.new(path)
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
func test_empty_list_uses_last_resort():
var path := "user://empty_fallback.json"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string('{"narrator": []}')
f.close()
var lib = FallbackLibrary.new(path)
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd`
Expected: FAIL — load/parse error, `fallback_library.gd` does not exist.
- [ ] **Step 3: Write the authored content**
Create `client/content/fallback/narrator.json` (Narrator voice per §3 — dry, gritty not grim, never winks; distinct from `LAST_RESORT`):
```json
{
"narrator": [
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
]
}
```
- [ ] **Step 4: Write minimal implementation**
Create `client/scripts/net/fallback_library.gd`:
```gdscript
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.
const PATH := "res://content/fallback/narrator.json"
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
var _lines: Array = []
func _init(path := PATH) -> void:
_lines = _load(path)
func narrator_line() -> String:
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
static func _load(path: String) -> Array:
if not FileAccess.file_exists(path):
return []
var text := FileAccess.get_file_as_string(path)
var data = JSON.parse_string(text)
if typeof(data) != TYPE_DICTIONARY:
return []
var lines = data.get("narrator", [])
return lines if typeof(lines) == TYPE_ARRAY else []
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd`
Expected: PASS — 4/4 passing.
- [ ] **Step 6: Commit**
```bash
git add client/content/fallback/narrator.json client/scripts/net/fallback_library.gd client/tests/unit/test_fallback_library.gd
git commit -m "feat(client): FallbackLibrary + authored narrator fallback (§13)"
```
---
### Task 3: `DmService` core + `FakeDmTransport` double
The heart of the slice: build the request, branch on the response, harvest facts, choose the fallback. Every branch is covered headless by injecting a fake transport.
**Files:**
- Create: `client/scripts/net/dm_service.gd`
- Create: `client/tests/doubles/fake_transport.gd`
- Test: `client/tests/unit/test_dm_service.gd`
**Interfaces:**
- Consumes: `DmResponse`, `NarrateResult`, `DmTransport` (Task 1); `FallbackLibrary` (Task 2); existing `CanonLog` (`to_dict()`, `add_fact()`) and `TagExtractor` (`extract(prose) -> {clean_text, facts, …}`).
- Produces: `DmService` — ctor `DmService.new(transport: DmTransport, fallback: FallbackLibrary)`; coroutine `narrate(canon_log: CanonLog) -> NarrateResult`.
- Produces (test double): `FakeDmTransport extends DmTransport``set_response(resp: DmResponse)`, records `last_path: String`, `last_body: Dictionary`.
- [ ] **Step 1: Write the failing test**
Create `client/tests/doubles/fake_transport.gd`:
```gdscript
extends "res://scripts/net/dm_transport.gd"
## Test double for DmTransport. Returns a canned DmResponse and records the last
## call so tests can assert what the service posted. post_json needs no real
## async — awaiting a plain return value resolves immediately, so the same await
## call site in DmService drives both the real and the fake transport.
var _canned
var last_path: String
var last_body: Dictionary
func set_response(resp) -> void:
_canned = resp
func post_json(path: String, body: Dictionary) -> DmResponse:
last_path = path
last_body = body
return _canned
```
Create `client/tests/unit/test_dm_service.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const DmService = preload("res://scripts/net/dm_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():
# Point the library at a missing file so narrator_line() is deterministically
# LAST_RESORT — the degraded assertions don't depend on the authored content.
_fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
func _log() -> CanonLog:
return CanonLog.new()
func _service_for(resp) -> Dictionary:
var t = FakeDmTransport.new()
t.set_response(resp)
return {"svc": DmService.new(t, _fallback), "transport": t}
func test_success_strips_tags_and_harvests_facts():
var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"}
var pair := _service_for(DmResponse.ok(200, body))
var r = await pair["svc"].narrate(_log())
assert_false(r.degraded)
assert_eq(r.display_text, "The docks reek of tar.")
assert_eq(r.facts, ["the eastern bridge is out"])
func test_success_posts_canon_log_to_narrate_path():
var pair := _service_for(DmResponse.ok(200, {"prose": "x"}))
await pair["svc"].narrate(_log())
assert_eq(pair["transport"].last_path, "/dm/narrate")
assert_true(pair["transport"].last_body.has("canon_log"))
func test_502_degrades_to_fallback_with_no_facts():
var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
assert_eq(r.display_text, FallbackLibrary.LAST_RESORT)
assert_eq(r.facts, [])
func test_422_degrades():
var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_transport_failure_degrades():
var pair := _service_for(DmResponse.failed("connection refused"))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_malformed_200_missing_prose_degrades():
var pair := _service_for(DmResponse.ok(200, {"nope": 1}))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_malformed_200_non_dict_body_degrades():
var pair := _service_for(DmResponse.ok(200, null))
var r = await pair["svc"].narrate(_log())
assert_true(r.degraded)
func test_only_facts_harvested_dispositions_and_moves_dropped():
var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"}
var pair := _service_for(DmResponse.ok(200, body))
var r = await pair["svc"].narrate(_log())
assert_eq(r.facts, ["a"])
assert_eq(r.display_text, "Hi bye")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd`
Expected: FAIL — load/parse error, `dm_service.gd` does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `client/scripts/net/dm_service.gd`:
```gdscript
class_name DmService
extends RefCounted
## The client narrate loop (charter §2: the client owns the loop and consumes
## text). Posts the canon log to /dm/narrate through an injected transport,
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
## it returns an authored fallback line — never an error. It harvests facts
## only; the Narrator has no authority over disposition or moves, so those tags
## are dropped even if the model emits them (§6). narrate() does not mutate the
## log — the caller applies the returned facts, keeping the state write explicit.
var _transport: DmTransport
var _fallback: FallbackLibrary
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
_transport = transport
_fallback = fallback
func narrate(canon_log: CanonLog) -> NarrateResult:
var resp: DmResponse = await _transport.post_json(
"/dm/narrate", {"canon_log": canon_log.to_dict()})
if not resp.transport_ok or resp.status != 200 \
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
return NarrateResult.fallback(_fallback.narrator_line())
var extracted := TagExtractor.extract(str(resp.body["prose"]))
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd`
Expected: PASS — 8/8 passing.
- [ ] **Step 5: Run the whole suite to confirm no regressions**
Run: `cd client && ./run_tests.sh`
Expected: PASS — all prior tests plus the three new files green, 0 errors.
- [ ] **Step 6: Commit**
```bash
git add client/scripts/net/dm_service.gd client/tests/doubles/fake_transport.gd client/tests/unit/test_dm_service.gd
git commit -m "feat(client): DmService narrate loop + FakeDmTransport (all branches tested)"
```
---
### Task 4: `HttpTransport` — the real transport (untested shim)
Wraps a live `HTTPRequest` Node and maps its async signal onto a `DmResponse`. No unit test: it needs a tree Node and a server. It is verified by reading here and by the live smoke in Task 5.
**Files:**
- Create: `client/scripts/net/http_transport.gd`
**Interfaces:**
- Consumes: `DmResponse` (Task 1), `ProxyConfig` (Task 1). Constructed with an `HTTPRequest` Node the owner has already added to the scene tree.
- Produces: `HttpTransport extends DmTransport` — ctor `HttpTransport.new(http: HTTPRequest)`; overrides `post_json(path, body) -> DmResponse`.
- [ ] **Step 1: Write the implementation**
Create `client/scripts/net/http_transport.gd`:
```gdscript
extends "res://scripts/net/dm_transport.gd"
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
## This is the one untested shim in the slice — it needs a live Node and a
## server, so it is verified by reading and by the harness live smoke, the same
## way the server's HTTP boundary is covered only by its gated --run-live test.
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
## interprets. Only a transport-level failure (no response) yields failed().
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
var _http: HTTPRequest
func _init(http: HTTPRequest) -> void:
_http = http
func post_json(path: String, body: Dictionary) -> DmResponse:
var url := ProxyConfig.base_url() + path
var headers := ["Content-Type: application/json"]
var payload := JSON.stringify(body)
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
if err != OK:
return DmResponse.failed("request() failed to start: error %d" % err)
# request_completed(result, response_code, headers, body) — awaiting a
# multi-arg signal returns the args as an Array.
var signal_args: Array = await _http.request_completed
var result: int = signal_args[0]
var response_code: int = signal_args[1]
var raw: PackedByteArray = signal_args[3]
if result != HTTPRequest.RESULT_SUCCESS:
return DmResponse.failed("transport result %d" % result)
var parsed = JSON.parse_string(raw.get_string_from_utf8()) # null if not JSON
return DmResponse.ok(response_code, parsed)
```
- [ ] **Step 2: Verify it parses**
Run: `cd client && godot --headless --import`
Expected: import completes with no parse/script errors mentioning `http_transport.gd`. (There is no unit test for this file by design; the review and Task 5's live smoke cover it.)
- [ ] **Step 3: Commit**
```bash
git add client/scripts/net/http_transport.gd
git commit -m "feat(client): HttpTransport — real HTTPRequest transport (untested shim)"
```
---
### Task 5: Throwaway harness scene + live-smoke doc
The "human sees prose" surface: a button, a label, a status line. Builds the transport → service, posts a hand-built valid canon log, shows the prose, applies harvested facts. Disposable — the real UI is a later wireframe. Verified by hand against a running proxy.
**Files:**
- Create: `client/scripts/harness/narrate_harness.gd`
- Create: `client/scenes/narrate_harness.tscn`
- Modify: `client/docs/README.md` (append a "Narrate live smoke" section)
**Interfaces:**
- Consumes: `HttpTransport` (Task 4), `DmService` (Task 3), `FallbackLibrary` (Task 2); existing `CanonLog`, `LogPlayer`, `LogLocation`, `PartyMember`, `Quest`, `Humiliation`.
- Produces: a runnable scene; no code other tasks depend on.
- [ ] **Step 1: Write the harness script**
Create `client/scripts/harness/narrate_harness.gd`. The fixture mirrors the proven-valid `api/tests/fixtures/canon_log_valid.json` so the request passes the server's schema check and the prose is grounded:
```gdscript
extends Control
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
## transport to a button and a label. NOT the game UI (a later wireframe owns
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
## smoke steps in client/docs/README.md.
const DmService = preload("res://scripts/net/dm_service.gd")
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
var _service: DmService
var _log: CanonLog
var _label: RichTextLabel
var _status: Label
var _button: Button
func _ready() -> void:
var http := HTTPRequest.new()
add_child(http)
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
_log = _build_scene_log()
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 12)
add_child(vbox)
_button = Button.new()
_button.text = "Narrate this scene"
_button.pressed.connect(_on_narrate_pressed)
vbox.add_child(_button)
_label = RichTextLabel.new()
_label.fit_content = true
_label.custom_minimum_size = Vector2(640, 320)
vbox.add_child(_label)
_status = Label.new()
vbox.add_child(_status)
func _on_narrate_pressed() -> void:
_button.disabled = true
_status.text = "…narrating…"
var r := await _service.narrate(_log)
_label.text = r.display_text
for f in r.facts:
_log.add_fact(f)
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
_button.disabled = false
func _build_scene_log() -> CanonLog:
var log := CanonLog.new()
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
log.set_location("greywater_docks", "the Greywater docks")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
log.push_event("Arrived at Greywater by barge before dawn")
log.push_event("Brannoc recognised the harbourmaster and went quiet")
log.add_fact("the eastern bridge out of Greywater is washed out")
log.add_fact("the harbourmaster is named Oda Fenn")
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
return log
```
Argument orders are confirmed against the source: `Quest.new(id, name, status, objective)` (`client/scripts/canon_log/quest.gd:13`) and `CanonLog.add_humiliation(id, text, weight, turn)` (`client/scripts/canon_log/canon_log.gd:38`). Use them as written.
- [ ] **Step 2: Write the scene file**
Create `client/scenes/narrate_harness.tscn`. Minimal root + script (all children are built in `_ready`, so the scene needs no hand-authored node tree). Godot will assign a `uid` on first import; that is expected and fine to commit:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"]
[node name="NarrateHarness" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_harness")
```
- [ ] **Step 3: Verify it imports and parses**
Run: `cd client && godot --headless --import`
Expected: import completes with no script/scene errors mentioning `narrate_harness`. Then confirm the unit suite is still green: `./run_tests.sh` → all pass (the harness has no unit test; this step guards against a parse error leaking in).
- [ ] **Step 4: Document the live smoke**
Append to `client/docs/README.md`:
```markdown
## Narrate live smoke (M2 client HTTP loop)
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
loop. It is not the game UI.
**Prereqs:** the proxy running and Ollama reachable.
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
`facts harvested: N` with N ≥ 0.
4. **Degraded path:** stop the proxy and click again. Expect the authored
fallback line and `(degraded)` in the status — never an error.
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.
```
- [ ] **Step 5: Commit**
```bash
git add client/scripts/harness/narrate_harness.gd client/scenes/narrate_harness.tscn client/docs/README.md
git commit -m "feat(client): throwaway narrate harness scene + live-smoke doc"
```
---
## Self-Review
**Spec coverage:**
- Data flow (spec) → Task 3 `DmService.narrate`. ✓
- `DmResponse` / `DmTransport` / `NarrateResult` / `ProxyConfig` (spec components) → Task 1. ✓
- `FallbackLibrary` + authored JSON + content-home rule → Task 2. ✓
- `HttpTransport` untested shim → Task 4. ✓
- Harness scene + live smoke → Task 5. ✓
- Facts-only harvest, degraded on every failure branch, returns-not-mutates → Task 3 tests. ✓
- Base URL default + override (§4) → Task 1 `ProxyConfig` tests. ✓
- Out-of-scope items (real UI, streaming, NPC moves, retry) → not implemented, as specified. ✓
- Follow-up (CLAUDE.md §16 note) → recorded in the spec's "Follow-ups"; not a task here (doc edit, deliberately deferred). ✓
**Placeholder scan:** No TBD/TODO. Every code step shows complete code. The one "confirm the signature before running" note (Task 5, `Quest.new`) is a real instruction with the known-good order given, not a placeholder.
**Type consistency:** `DmResponse.ok/failed`, `NarrateResult.new/fallback`, `DmTransport.post_json`, `FallbackLibrary.new/narrator_line/PATH/LAST_RESORT`, `DmService.new/narrate`, `HttpTransport.new`, `ProxyConfig.SETTING/DEFAULT/base_url` — all used consistently across tasks and match the Interfaces blocks. `FakeDmTransport.post_json` matches the `DmTransport` base signature. The `narrate()` return (`NarrateResult`) is consumed correctly by the harness.

View File

@@ -0,0 +1,520 @@
# Considering-state + transport timeout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** While a model call is in flight, the client shows a rotating in-character "the Master considers…" indicator instead of a frozen surface, and a hung call gives way to the authored fallback instead of spinning forever.
**Architecture:** All client-side, no server change. A pure `ConsideringPhrases` (cycles an authored phrase pool loaded from content, robust to a missing file) drives a thin `ConsideringIndicator` node (owns a `Timer`, rotates a `Label`). `ProxyConfig` gains a request-timeout setting; `HttpTransport` applies it to its `HTTPRequest`, so a hang maps through the existing `RESULT_TIMEOUT → failed() → degrade` path. Both harnesses show the indicator around their `await`.
**Tech Stack:** Godot 4.7 / GDScript / GUT.
## Global Constraints
- **§2 — this is text/UI only.** No game state, no AI, no canon-log mutation. The phrases are authored content.
- **§13 — loading is content.** Phrases live in `client/content/considering.json`, loaded like the fallback lines; a missing/malformed file degrades to one hardcoded constant so the indicator itself cannot fail.
- **JSON-parse GOTCHA:** in any code the tests touch, parse with `var j := JSON.new(); j.parse(text)` and check `!= OK` — NOT `JSON.parse_string()` (the project's `.gutconfig.json` `failure_error_types` turns its engine error into a false test failure).
- **Timeout default 35.0s** — just above the server's `OLLAMA_TIMEOUT_SECONDS=30`, so a slow *model* surfaces the server's own error first while a fully-hung proxy is still bounded. A hang reuses the existing `HttpTransport` `result != RESULT_SUCCESS → DmResponse.failed()` path (already covered by `test_transport_failure_degrades`); do not add a new service-level degrade test.
- **Rotation index is instance state, not reset per call** (spec decision 4): one `ConsideringPhrases`/`ConsideringIndicator` instance persists on the harness; `next()` advances a running index that `start()`/`stop()` never reset.
- **Branch:** all work on `feature/considering-state`, branched off `dev`. Never commit to `dev`/`master` directly (CLAUDE.md §18); the human owns the `dev` merge and push.
- **Client tests:** `cd client && ./run_tests.sh` (full) or `./run_tests.sh -gtest=res://tests/unit/test_X.gd` (one file). A new `class_name` may need `cd client && godot --headless --import` once before it resolves.
---
## File Structure
- `client/scripts/ui/considering_phrases.gd` — CREATE: pure phrase-pool cycler.
- `client/content/considering.json` — CREATE: authored phrase pool.
- `client/scripts/net/proxy_config.gd` — MODIFY: add `request_timeout_seconds()`.
- `client/scripts/net/http_transport.gd` — MODIFY: set `_http.timeout`.
- `client/scripts/ui/considering_indicator.gd` — CREATE: node shim (Timer + Label).
- `client/scripts/harness/narrate_harness.gd` — MODIFY: show indicator around the await.
- `client/scripts/harness/npc_harness.gd` — MODIFY: add a status Label + show indicator.
- Tests: `client/tests/unit/test_considering_phrases.gd` (create), `client/tests/unit/test_considering_indicator.gd` (create), `client/tests/unit/test_net_primitives.gd` (add ProxyConfig + HttpTransport timeout tests).
---
## Task 1: `ConsideringPhrases` (pure) + authored pool
**Files:**
- Create: `client/scripts/ui/considering_phrases.gd`
- Create: `client/content/considering.json`
- Test: `client/tests/unit/test_considering_phrases.gd`
**Interfaces:**
- Produces: `ConsideringPhrases.new(path := PATH)`; `next() -> String` (returns the pool entry at a running index and advances it, wrapping; `LAST_RESORT` when the pool is empty/missing). Constants `PATH`, `LAST_RESORT`.
- [ ] **Step 1: Write the failing test**
```gdscript
# client/tests/unit/test_considering_phrases.gd
extends "res://addons/gut/test.gd"
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
func test_missing_file_returns_last_resort():
var p = ConsideringPhrases.new("res://content/does_not_exist.json")
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
func test_cycles_pool_in_order_and_wraps():
# Real authored file has at least 3 lines; assert order + wrap on it.
var p = ConsideringPhrases.new() # default path — real content
var first = p.next()
var second = p.next()
assert_ne(first, "")
assert_ne(first, second) # advances, not stuck
# Drain a full cycle back to `first` to prove it wraps (bounded loop).
var seen := [first, second]
for i in range(20):
var n = p.next()
if n == first:
return # wrapped — success
seen.append(n)
assert_true(false, "did not wrap within 22 calls: %s" % [seen])
func test_index_persists_across_calls_not_reset():
var p = ConsideringPhrases.new()
var a = p.next()
var b = p.next()
assert_ne(a, b) # second call did not re-return the first phrase
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_considering_phrases.gd`
Expected: FAIL — script does not exist.
- [ ] **Step 3: Implement**
Create `client/content/considering.json`:
```json
{
"considering": [
"The Master considers your words.",
"Somewhere, dice are gathered.",
"The tale weighs its next turn.",
"The dark shifts, thinking.",
"A page is turned, unhurried."
]
}
```
Create `client/scripts/ui/considering_phrases.gd`:
```gdscript
class_name ConsideringPhrases
extends RefCounted
## Authored "the GM is thinking" lines (charter §13 — even loading is content).
## Pure: cycles a phrase pool in order on a running index that is instance state
## (spec decision 4 — the indicator reuses one instance so the first phrase varies
## turn to turn without randomness). A missing/malformed file degrades to one
## hardcoded constant, so the indicator itself cannot fail.
const PATH := "res://content/considering.json"
const LAST_RESORT := "The Master considers your words."
var _pool: Array = []
var _index: int = 0
func _init(path := PATH) -> void:
_pool = _load(path)
func next() -> String:
if _pool.is_empty():
return LAST_RESORT
var phrase := str(_pool[_index % _pool.size()])
_index += 1
return phrase
static func _load(path: String) -> Array:
if not FileAccess.file_exists(path):
return []
var text := FileAccess.get_file_as_string(path)
# Instance parse() (not JSON.parse_string) so malformed input returns an
# Error code instead of the engine error the test config promotes to a fail.
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("considering", [])
return lines if typeof(lines) == TYPE_ARRAY else []
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh -gtest=res://tests/unit/test_considering_phrases.gd`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add client/scripts/ui/considering_phrases.gd client/content/considering.json client/tests/unit/test_considering_phrases.gd
git commit -m "feat(client): ConsideringPhrases — authored rotating think-lines (§13)"
```
---
## Task 2: `ProxyConfig.request_timeout_seconds`
**Files:**
- Modify: `client/scripts/net/proxy_config.gd`
- Test: `client/tests/unit/test_net_primitives.gd`
**Interfaces:**
- Produces: `ProxyConfig.request_timeout_seconds() -> float` (a `ProjectSettings` key, default `TIMEOUT_DEFAULT := 35.0`; a non-positive/invalid value falls back to the default so a hang can never be re-enabled). Constants `TIMEOUT_SETTING`, `TIMEOUT_DEFAULT`.
- [ ] **Step 1: Write the failing test**
Add to `client/tests/unit/test_net_primitives.gd`:
```gdscript
func test_request_timeout_default():
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
if ProjectSettings.has_setting(ProxyConfig.TIMEOUT_SETTING):
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
func test_request_timeout_override_and_reject_nonpositive():
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 12.5)
assert_eq(ProxyConfig.request_timeout_seconds(), 12.5)
# A non-positive override must NOT re-enable the hang — falls back to default.
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 0.0)
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: FAIL — `request_timeout_seconds` / `TIMEOUT_SETTING` not found.
- [ ] **Step 3: Implement**
In `client/scripts/net/proxy_config.gd`, add after the `base_url` block:
```gdscript
const TIMEOUT_SETTING := "coc_rpg/proxy_request_timeout_seconds"
const TIMEOUT_DEFAULT := 35.0
static func request_timeout_seconds() -> float:
# Just above the server's OLLAMA_TIMEOUT_SECONDS (30) so a slow model surfaces
# the server's own error first, while a fully-hung proxy is still bounded. A
# non-positive value would re-enable the infinite hang, so reject it.
var v := float(ProjectSettings.get_setting(TIMEOUT_SETTING, TIMEOUT_DEFAULT))
return v if v > 0.0 else TIMEOUT_DEFAULT
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/net/proxy_config.gd client/tests/unit/test_net_primitives.gd
git commit -m "feat(client): ProxyConfig.request_timeout_seconds (default 35s, reject <=0)"
```
---
## Task 3: `HttpTransport` applies the request timeout
**Files:**
- Modify: `client/scripts/net/http_transport.gd`
- Test: `client/tests/unit/test_net_primitives.gd`
**Interfaces:**
- Consumes: `ProxyConfig.request_timeout_seconds()` (Task 2).
- Produces: constructing `HttpTransport.new(http)` sets `http.timeout` to the configured value. Closes the `_http.timeout=0` gap; a hang now completes with `RESULT_TIMEOUT`, which the existing code maps to `DmResponse.failed()`.
- [ ] **Step 1: Write the failing test**
Add to `client/tests/unit/test_net_primitives.gd`:
```gdscript
func test_http_transport_sets_request_timeout():
var HttpTransport = preload("res://scripts/net/http_transport.gd")
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
var http := HTTPRequest.new()
autofree(http)
HttpTransport.new(http)
assert_eq(http.timeout, ProxyConfig.request_timeout_seconds())
assert_gt(http.timeout, 0.0) # the whole point — no longer disabled
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: FAIL — `http.timeout` is `0.0` (the default), not the configured value.
- [ ] **Step 3: Implement**
In `client/scripts/net/http_transport.gd`, in `_init` (it already `preload`s `ProxyConfig`), set the timeout:
```gdscript
func _init(http: HTTPRequest) -> void:
_http = http
# Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed()
# → the service degrades) instead of never returning. Without this the
# considering-state indicator would rotate forever on a hang.
_http.timeout = ProxyConfig.request_timeout_seconds()
```
(Keep the rest of `_init`/`post_json` unchanged.)
- [ ] **Step 4: Run to verify it passes**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/net/http_transport.gd client/tests/unit/test_net_primitives.gd
git commit -m "fix(client): HttpTransport sets request timeout (no more infinite hang)"
```
---
## Task 4: `ConsideringIndicator` (node shim)
**Files:**
- Create: `client/scripts/ui/considering_indicator.gd`
- Test: `client/tests/unit/test_considering_indicator.gd`
**Interfaces:**
- Consumes: `ConsideringPhrases` (Task 1).
- Produces: `ConsideringIndicator.new(phrases := null)` (defaults to a fresh `ConsideringPhrases`); `start(label: Label)` shows the first phrase immediately and starts a repeating `Timer` (`ROTATE_SECONDS := 2.0`) that rotates the label; `stop()` stops the timer and clears the label. Extends `Node` (needs a `Timer` in the tree). Holds one `ConsideringPhrases` for its lifetime.
- [ ] **Step 1: Write the failing test**
```gdscript
# client/tests/unit/test_considering_indicator.gd
extends "res://addons/gut/test.gd"
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
func test_start_shows_first_phrase_and_stop_clears():
# Point phrases at a missing file so next() is deterministically LAST_RESORT.
var phrases = ConsideringPhrases.new("res://content/does_not_exist.json")
var ind = ConsideringIndicator.new(phrases)
add_child_autofree(ind) # _ready() creates the Timer child
var label := Label.new()
add_child_autofree(label)
ind.start(label)
assert_eq(label.text, ConsideringPhrases.LAST_RESORT) # first phrase shown at once
ind.stop()
assert_eq(label.text, "") # cleared on stop
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_considering_indicator.gd`
Expected: FAIL — script does not exist.
- [ ] **Step 3: Implement**
```gdscript
# client/scripts/ui/considering_indicator.gd
class_name ConsideringIndicator
extends Node
## Thin node shim (charter §13). While a call is in flight it rotates an authored
## ConsideringPhrases line onto a Label every ROTATE_SECONDS, reframing the wait
## as the GM thinking. The phrase logic is the pure, tested ConsideringPhrases;
## the Timer/tree wiring here is the untested shim (verified by running the
## harness). One instance persists on the harness so the rotation index is not
## reset per call (spec decision 4).
const ROTATE_SECONDS := 2.0
var _phrases: ConsideringPhrases
var _timer: Timer
var _label: Label
func _init(phrases: ConsideringPhrases = null) -> void:
_phrases = phrases if phrases != null else ConsideringPhrases.new()
func _ready() -> void:
_timer = Timer.new()
_timer.wait_time = ROTATE_SECONDS
_timer.one_shot = false
_timer.timeout.connect(_advance)
add_child(_timer)
func start(label: Label) -> void:
_label = label
_advance() # show a phrase immediately, don't wait ROTATE_SECONDS
_timer.start()
func stop() -> void:
_timer.stop()
if _label != null:
_label.text = ""
_label = null
func _advance() -> void:
if _label != null:
_label.text = _phrases.next()
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh -gtest=res://tests/unit/test_considering_indicator.gd`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/ui/considering_indicator.gd client/tests/unit/test_considering_indicator.gd
git commit -m "feat(client): ConsideringIndicator — rotates think-lines on a Timer"
```
---
## Task 5: Wire the indicator into both harnesses
**Files:**
- Modify: `client/scripts/harness/narrate_harness.gd`
- Modify: `client/scripts/harness/npc_harness.gd`
- (No unit test — throwaway harnesses, verified by running.)
**Interfaces:**
- Consumes: `ConsideringIndicator` (Task 4).
- [ ] **Step 1: Wire `narrate_harness.gd`**
It already has a `_status: Label`. Add the indicator field, build it in `_ready`, and wrap the await.
Add the preload near the other consts:
```gdscript
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
```
Add the field near the other vars:
```gdscript
var _indicator: ConsideringIndicator
```
At the end of `_ready()` (after the status label is created), add:
```gdscript
_indicator = ConsideringIndicator.new()
add_child(_indicator)
```
Replace the body of `_on_narrate_pressed()` with (the change: `start`/`stop` around the await instead of the static "…narrating…" text):
```gdscript
func _on_narrate_pressed() -> void:
_button.disabled = true
_indicator.start(_status)
var r := await _service.narrate(_log)
_indicator.stop()
_label.text = r.display_text
for f in r.facts:
_log.add_fact(f)
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
_button.disabled = false
```
- [ ] **Step 2: Wire `npc_harness.gd`**
It has no status label. Add one, add the indicator, and wrap the await.
Add the preload near the other consts:
```gdscript
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
```
Add the fields near the other vars:
```gdscript
var _status: Label
var _indicator: ConsideringIndicator
```
In `_ready()`, after the Speak button is added to the vbox, add a status label and the indicator:
```gdscript
_status = Label.new()
vbox.add_child(_status)
_indicator = ConsideringIndicator.new()
add_child(_indicator)
```
In `_on_speak()`, wrap the service call — start after appending "You:", stop before rendering Fenn's reply:
```gdscript
_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)
_indicator.start(_status)
var r: NpcResult = await _service.speak(
NPC_ID, utterance, _canon_log, disposition, available)
_indicator.stop()
```
(The rest of `_on_speak()` — MoveApplier, fact harvest, appends, `ends_conversation` — is unchanged.)
- [ ] **Step 3: Verify the project imports and the suite is green**
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh`
Expected: all unit tests PASS (the harness changes must not break import/parse or any existing test).
- [ ] **Step 4: Run the harness by hand (proxy + Ollama up)**
In the Godot editor, run `npc_harness.tscn` (F6). Confirm: on Speak, the status line rotates through the authored think-lines (~2s cadence) while waiting; it clears and Fenn's prose appears on return. Then stop the proxy and Speak again: the line rotates, then after ~35s the harness shows the authored fallback (not an infinite spin).
- [ ] **Step 5: Commit**
```bash
git add client/scripts/harness/narrate_harness.gd client/scripts/harness/npc_harness.gd
git commit -m "feat(client): harnesses show the considering-state around the call"
```
---
## Self-Review
**Spec coverage** — every spec section maps to a task:
- Decision 12 (reframe; loading is content) → Task 1 (`ConsideringPhrases` + `considering.json`).
- Decision 3 (rotate ~2s; pure core / node shim) → Task 1 (pure) + Task 4 (shim, `ROTATE_SECONDS`).
- Decision 4 (index is instance state, not reset) → Task 1 (running `_index`, test `test_index_persists…`) + Task 4 (one persistent instance).
- Decision 5 (timeout fix, default 35, reject ≤0) → Task 2 + Task 3.
- Decision 6 (no server change) → honoured; no server files in any task.
- Decision 7 (both harnesses) → Task 5.
- Components list → Tasks 15 one-to-one.
- Error handling (hang → timeout → degrade; missing file → LAST_RESORT) → Task 3 (relies on existing `test_transport_failure_degrades`, noted in constraints, not duplicated) + Task 1 (`test_missing_file_returns_last_resort`).
- Testing section → each task's tests; the Timer-fire path is explicitly run-verified in Task 5 Step 4, not unit-tested (matches the spec).
**Placeholder scan** — no TBD/TODO. Task 5 has no unit test *by design* (throwaway harness), verified by Step 3 (suite) + Step 4 (by-hand run); every code step shows complete code.
**Type consistency**`ConsideringPhrases.next() -> String` and `.LAST_RESORT`/`.PATH` used identically across Tasks 1/4; `ConsideringIndicator.new(phrases)` / `start(label: Label)` / `stop()` match between Task 4 and Task 5; `ProxyConfig.request_timeout_seconds()` / `TIMEOUT_SETTING` / `TIMEOUT_DEFAULT` match between Tasks 2 and 3; `_status` is a `Label` in both harnesses (narrate already has it; npc adds it) so `start(_status)` type-checks.

View File

@@ -0,0 +1,220 @@
# Narrator / Ollama Pipeline (server) — Design
**Date:** 2026-07-09
**Status:** approved (design); implementation plan to follow
**Charter refs:** §2 (code owns state / AI owns text), §3 (tone/voice), §4 (the guarding proxy, model routing as config, logging from day one), §5 (the DM is not one prompt; Narrator = the good model), §10 (seed + full prompt logged), §11 (canon log), §12 (output contracts, tags, retry policy), §13 (degraded DM), §14 (cost/latency), §16 (prompts are source code)
**Binds to:** the canon log contract (`docs/canon-log.md`, `docs/schemas/canon-log.schema.json`) and the existing proxy skeleton (`api/app/main.py` — role endpoints + `valid_turn` + unified 422). Consumed by the later client HTTP loop.
## Problem
The proxy validates every posted canon log but every role endpoint still `return {"detail": "not implemented"}` — no model is wired, no prompt is authored, nothing is logged. Until a real model sits behind `/dm/narrate`, the POC's one question — *does bounded AI dialogue feel alive?* (charter §17) — cannot be tested at all.
This plan makes `/dm/narrate` real: assemble the Narrator prompt + a canon-log digest, call Ollama, return the prose, log the call, and signal failure honestly — building the reusable server-side model-call pipeline that the other four roles later drop into.
## Scope
**In scope — the shared pipeline + the Narrator as its first, proving role:**
- `ollama_client.py` — httpx call to Ollama `/api/chat` (non-streaming), single retry, typed `ModelError`.
- `routing.py` — role → `{model, options}` config, env-driven (§4/§5).
- `prompts.py` — load a role's `.md` system prompt (cached) + `render_digest(canon_log)`.
- `call_log.py` — structured JSON-lines logging of every call (§10).
- `narrate.py` — the Narrator service: render → route → call → log → return.
- The authored `narrator.md` system-prompt body (source code, §16).
- `/dm/narrate` wired to the service; `main.py` handler stays thin.
- Unit tests (mocked Ollama boundary) + one gated live smoke test.
**Out of scope — later plans (recorded in `docs/roadmap.md`):**
- Streaming (§14) — non-streaming request/response now; the Ollama client is shaped so a streaming mode is additive, not a rewrite.
- The client HTTP layer, degraded-DM fallback display, and `[FACT]` harvest (the client already owns `TagExtractor`).
- The other four roles (Adjudicator / Improviser / NPC / Banter).
- The Replicate provider; auth / metering.
## Decisions (locked in brainstorming)
1. **Shared pipeline + Narrator first** — build the reusable machinery once; the Narrator proves it; the other roles become thin drop-ins.
2. **Test: mocked boundary + gated live smoke** — unit tests use httpx `MockTransport` (no new dependency); a `@pytest.mark.live` smoke test hits the real reachable Ollama and is skipped by default.
3. **Non-streaming now, streaming later** — request/response; streaming is a named later phase landed with the client HTTP layer that can consume it.
4. **Typed error on model failure** — after one retry, `/dm/narrate` returns a non-200 (`502`) in the `{"detail": {...}}` family; the client owns the degraded-DM fallback on any non-200 (already specified in `docs/canon-log.md`). No server-side fallback prose.
5. **Rendered digest into the prompt**`narrator.md` is the system prompt; code renders the canon log into a curated, labeled digest in the user message; no raw JSON, no numbers.
6. **Tag handling stays client-side** — the server returns raw prose (tags intact) and logs it; the client's `TagExtractor` strips for display and harvests `[FACT]` into its own canon log. §2 (client owns state) + DRY (one extractor).
## Architecture — layered modules, thin handler
```
POST /dm/narrate (main.py handler — thin)
└─ valid_turn (EXISTING) → 422 envelope on a bad canon log
└─ narrate.run(canon_log):
system = prompts.system_prompt("narrator") # narrator.md body (the IP)
user = prompts.render_digest(canon_log) # curated labeled digest (§11 context)
cfg = routing.for_role("narrator") # model + options (+ per-call seed) + think flag
text = ollama_client.chat(cfg.model, [system, user], cfg.options, think=cfg.think) # 1 retry, else ModelError
call_log.record(role, model, options, seed, messages, canon_log, text|error, latency_ms, ok)
return text
└─ 200 {"prose": text} | except ModelError → 502 {"detail": {"model_error": "<reason>"}}
```
`ollama_client` / `routing` / `prompts` / `call_log` are the shared pipeline; `narrate.py` is the first role service. Adding a role later is a new service module + prompt + output parsing — the four shared modules are reused untouched. Mocks enter the system at the `ollama_client` boundary.
## The prompt — source code (§16)
### `narrator.md` system-prompt body (authored in this plan)
The file already carries role/voice header; this plan writes the instruction body. It must encode:
- **Task:** describe the *current* scene from the supplied context, second person, present tense. Scenes, outcomes, transitions only.
- **Voice (§3):** dry; gritty, not grim; plays the world straight; never winks; nothing is "hilariously" anything; profanity earned, not decorative.
- **Hard constraints** (enforced by wording — output is prose, not a schema):
- Never invent a proper noun (person/place/thing) without emitting `[FACT: <the fact>]` so the client can harvest it (§11).
- Never contradict an Established fact in the supplied context.
- Never state a number, stat, HP, or Luck value; never decide or narrate the **player's** choices or actions (code owns those — describe the world, not the protagonist's will).
- Output prose only, plus zero or more `[FACT:]` tags. No JSON, no headers, no meta-talk.
- Brevity — a few sentences (§14, small-model discipline).
### `render_digest(canon_log) -> str` — a curated projection
Turns the posted log into a labeled block for the user message:
```
Location: the Greywater docks
Recent events:
- Arrived at Greywater by barge before dawn
- Brannoc recognised the harbourmaster and went quiet
Established facts:
- the eastern bridge out of Greywater is washed out
- the harbourmaster is named Oda Fenn
Companions present: Brannoc Thane, Cadwyn Vell
Active quest: The Missing Ledger — Find who took Fenn's ledger
Fortune: Fortune spits on you
Player: Aldric, a sellsword
Describe the scene as it is now.
```
**The digest is a projection, not a dump.** It surfaces narrative context and deliberately omits raw integers: companion `disposition` values render as names only, `luck_descriptor` renders as flavor ("Fortune: …", never the number, §7), `schema_version`/ids are dropped. This generalizes the §7 rule — the Narrator gets nouns and situation, never anything to do arithmetic on. Deterministic and unit-testable (assert the rendered string).
## Model routing (§4/§5)
```python
# routing.py
@dataclass(frozen=True)
class RoleConfig:
model: str
options: dict # Ollama generation options, includes a per-call seed at call time
think: bool | None = None # Ollama top-level `think` flag; None omits it
ROLES = {
"narrator": RoleConfig(
model=env("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest"), # §5 "the good model" — swappable per deploy
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300}, # voice; capped length (§14)
think=False, # qwen3.x thinking OFF — no <think> preamble in narrator prose
),
}
def for_role(role: str) -> RoleConfig: ...
```
- Base URL from `OLLAMA_BASE_URL` (already in `.env.example`).
- Model and options are **config, not law** — swapping the Narrator model is an env change, exactly §4's "a deploy, not a client patch." `qwen3.5:latest` is the default (local models available: `qwen3.5`, `llama3.1`, `llama3.2`).
- **`think: False` (qwen3.x thinking disabled):** qwen3.5 is a thinking model; without this it emits a `<think>…</think>` preamble that would pollute the narrator prose. `ollama_client` passes `think` as a **top-level** `/api/chat` field (not inside `options`) when `RoleConfig.think is not None`. Verified in the 2026-07-09 probe: `think:false` yields an empty `thinking` field and no leakage, and is **gracefully accepted by non-thinking models too** (llama3.1/3.2 return normally) — so it is safe across an env model swap.
- **Per-call seed (§10):** the server sets an explicit `options.seed` per call (a random int, captured in the log) so any call is replayable against a candidate model — the "free eval infrastructure" of §4. Without setting it, Ollama's own random seed is unrecoverable.
- **Provider abstraction (Ollama → Replicate) is deferred** (YAGNI). `routing.py` is the seam it slots into later; today, Ollama only.
### Model probe (2026-07-09)
Warm, seeded, narrator-shaped call (system + digest, `num_predict:300`) against the reachable Ollama:
| model | warm latency | tok/s | notes |
|---|---|---|---|
| **qwen3.5** (`think:false`) | ~2.0s | ~107 | richest prose; only model to emit `[FACT:]`; no `<think>` leak |
| llama3.1 | ~1.2s | ~149 | solid 2nd-person prose |
| llama3.2 | ~0.5s | ~295 | fastest, coherent, shorter |
Decision: **qwen3.5 is the default** (§5 "the good model") — ~2s non-streaming is acceptable for the POC and streaming hides it later. llama3.1/llama3.2 remain one-env-var swaps if latency needs to drop; the speed data is on record for that tuning call.
## Failure, retry, error contract (§12/§13)
`ollama_client.chat()` owns the retry; callers see success-or-`ModelError`.
Retry (exactly one, no backoff — §12 "never retry more than once, latency compounds") triggers on:
- transport error (connection refused / DNS),
- timeout (`OLLAMA_TIMEOUT_SECONDS`, default ~30s),
- non-2xx from Ollama,
- empty / whitespace-only content (the one "bad output" case; no JSON/tag parse to fail for prose).
Flow: attempt → on any of the above, retry once → still failing → raise `ModelError(reason)`.
Handler mapping:
```
200 {"prose": text} # success only
502 {"detail": {"model_error": "<short reason>"}} # ModelError — same {"detail":{...}} family as the 422
```
The client contract is already satisfied — `docs/canon-log.md` says the degraded-DM fallback fires on **any non-200**, so the client treats 422 and 502 identically. The `model_error` string is for logs/dev, never shown to the player. No authored fallback prose on the server (decision 4).
## Call logging (§10) — the eval infrastructure
`call_log.py` writes one structured JSON object per line, every call, success and failure — charter §10's "log the seed and the full prompt with every call" and §4's "replay yesterday's real traffic against a candidate model."
```json
{
"ts": "2026-07-09T…Z",
"role": "narrator",
"model": "llama3.1:latest",
"options": { "temperature": 0.8, "top_p": 0.9, "num_predict": 300, "seed": 481923 },
"messages": [ {"role":"system","content":"…narrator.md…"}, {"role":"user","content":"…digest…"} ],
"canon_log": { "…the posted log…": true },
"ok": true,
"response": "You stand on the rot-black wharf… [FACT: …]",
"latency_ms": 1840
}
```
On failure: `"ok": false, "error": "<reason>"`, no `response`.
- Everything needed to replay is in the line — `canon_log` + `messages` + `model` + `seed`.
- **Sink:** JSON-lines to **stdout by default** (12-factor; captured by Docker/fly platform logs), redirectable to a file via `CALL_LOG_PATH` for local replay. The writer takes an injectable sink so tests capture and assert the record.
- No secrets to redact — the client never sends keys (§4), the canon log carries no PII, and the prompt is exactly what we want logged.
## Testing
**Unit — httpx `MockTransport`, deterministic, no new dependency:**
- `ollama_client`: success parses content; retry (fail-once → recovers; fail-twice → `ModelError`); timeout → `ModelError`; non-2xx → retry → `ModelError`; empty content → retry → `ModelError`. Asserts the outgoing payload (model, `messages`, `stream:false`, options incl. `seed`, and top-level `think` present only when set).
- `prompts`: `render_digest` on `canon_log_valid.json` — correct labeled block, dispositions omitted, luck as descriptor-not-number; `system_prompt("narrator")` loads the file.
- `routing`: `for_role` returns the configured model + options; env override respected.
- `call_log`: writes a parseable JSON line with every field to the injected sink; failure-record shape.
- endpoint (`TestClient`, Ollama boundary mocked): valid log → 200 `{"prose": …}`; `ModelError` → 502 envelope; invalid log → still 422 (existing behavior intact).
**Live smoke — `@pytest.mark.live`, skipped by default** (registered marker; run with `-m live` or an env flag): hits the real reachable Ollama, asserts 200 + non-empty prose of sane length (no tag assertion — nondeterministic). Run during implementation to prove the wire.
## Phases (milestones)
- **Phase 1 — Pipeline foundation:** `ollama_client`, `routing`, `call_log`, `prompts` (loader + digest), each mock/unit-tested. Endpoint untouched.
- **Phase 2 — Narrator online:** author `narrator.md` body → `narrate` service → wire `/dm/narrate` → endpoint tests (mock) + gated live smoke green. "Prove the loop" on the server side.
## Storage & config
```
api/app/
ollama_client.py routing.py prompts.py call_log.py narrate.py
main.py (modified — /dm/narrate wired)
api/prompts/
narrator.md (body authored)
api/tests/
test_ollama_client.py test_routing.py test_prompts.py
test_call_log.py test_narrate_endpoint.py test_live_smoke.py (gated)
```
New env (add to `.env.example`): `OLLAMA_NARRATOR_MODEL`, `OLLAMA_TIMEOUT_SECONDS`, `CALL_LOG_PATH` (optional). `OLLAMA_BASE_URL` already present.
## Consequences / follow-ups
- The client HTTP plan consumes `{"prose": …}` / the 502 envelope, runs `TagExtractor`, harvests `[FACT]`, and displays the degraded-DM fallback on non-200.
- The streaming plan adds a streaming mode to `ollama_client` + an SSE/chunked response, landed with the client stream consumer.
- The other four roles reuse `ollama_client`/`routing`/`prompts`/`call_log`, adding their own service + prompt + output parsing (the Adjudicator adds strict-JSON parsing + the §12 one-retry-with-error-appended path).
- Replicate provider slots into `routing.py`; auth/metering are middleware, retrofit later (§4).
## Open questions
None blocking. The Narrator model default and generation options (temperature/length) are tuning values, seeded as env-overridable defaults. Prompt wording is authored and reviewed in Phase 2.

View File

@@ -0,0 +1,281 @@
# Bounded NPC conversation (M2) — design
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). This is the second M2 item, and
the surface §17 is really asking about.
Charter side (§2): **both** — the NPC's voice is *text* (AI owns it); the moves
it performs are *state* (code validates and applies them). Two systems sharing
one HTTP call.
## Goal
Put a bounded NPC on screen. The player types free text to a specific NPC; the
NPC answers in its own voice and may emit zero or more inline *move* tags. Code
extracts the moves, validates each against real game state, applies the valid
ones, **silently drops the invalid ones, and always keeps the prose** (§6).
```
utterance + canon_log + npc_id + disposition + available_moves
→ HTTP → /npc/speak → prose(+move tags) → screen
→ extract → validate → apply valid moves → drop invalid → harvest [FACT]
```
This is the core POC experiment: **does bounded AI dialogue feel alive, or does
it feel like a chatbot in a costume?** Everything here exists to make that test
possible with one real NPC.
## What already exists (and is proven)
- **Server pipeline (M1, merged):** `narrate.py` is the template — render a
digest, route the model (`routing.for_role`), call Ollama with one retry, log
the call (§10), return **raw prose with tags intact**. `/dm/narrate` returns
`{"prose": …}` / `502 {"detail":{"model_error":…}}` / `422
{"detail":{"canon_log_errors":…}}`. `content.py` already reads
`/content/world/npcs/*.json` (ids only, today). `prompts.system_prompt(role)`
loads `api/prompts/<role>.md` below its `---` front-matter delimiter.
- **Client loop (M2, merged):** `DmService` posts the canon log through an
injectable `DmTransport`, parses status, degrades to an authored fallback,
and returns a result the caller applies (state writes stay explicit, §2).
`TagExtractor.extract(prose)` already returns
`{clean_text, facts, dispositions, moves}`**moves are already parsed**; the
new work is the validate-and-apply layer, not the parse.
- **Client state homes:** `GameState` (`client/scripts/state/game_state.gd`) is
the sole home of `npc_dispositions` (world-NPC id → int) and `inventory`
(item_id → qty). Town NPCs are **not** canon-log `party` rows — companions
are; town NPCs live only in `GameState`. `CanonLog` owns `active_quests` and
its mutators (`set_quest_status`, `add_fact`).
- **Content:** `/content/world/npcs/{cadwyn_vell,brannoc_thane}.json` (companions,
empty knowledge). `/content/world/quests/find_the_ledger.json` exists. The POC
town NPC **Fenn** — who owns that quest — does not exist yet.
## Decisions
Settled during brainstorming; each is load-bearing.
1. **Server owns the secret text; client owns all live state.** The proxy stays
stateless for *game* state — no DB. It loads static authored material
(prompts, persona, knowledge lists) into memory: zero per-player cost, and
the spoilers never ship in the client binary. Every piece of dynamic
state (saves, inventory, disposition, luck, quest progress) lives on the
client (§4). The only DB this project ever grows is auth + a credits ledger,
later, and it is not game state. This matches the intended pay-per-AI-credit
model and the self-host-your-own-Ollama option: a self-hoster runs the client
and points the proxy at their model — no server state to run.
2. **Trio split by sensitivity (fork 1).** The §6 trio is assembled across the
boundary:
- **Server-side (spoilers/IP):** `persona` + `knowledge[]`, loaded from
`/content/world/npcs/<id>.json`. The client never sends or sees these.
- **Client-side (live legality):** `available_moves[]` is computed by the
client from the NPC's non-spoiler **capability** block plus live game
state, and sent in the request. Legality depends on state the client owns
(§2), so the client must compute it.
3. **Disposition is sent as an integer.** The request carries this NPC's current
disposition (`GameState.npc_dispositions[npc_id]`, default 0) as an int.
§6 explicitly gives the NPC prompt "disposition (integer, 100..100)". This
does **not** violate §7 — §7 hides *Luck*, not disposition; the narrate
digest's number-hiding is a Narrator concern, not an NPC one. `/npc/speak`
therefore has its own request contract, not the bare `{canon_log}` of narrate.
4. **Free text goes straight to the NPC prompt — no Adjudicator.** The moves are
the *NPC's*, not the player's, so nothing needs to be mapped onto a legal
*player* action. The player's raw utterance is passed to the prompt as
context. This is what lets the aliveness experiment run now, before M3's
Adjudicator exists — and a menu would blunt the exact free-form quality under
test (§15's "free text → Adjudicator" is about player game-actions).
5. **One canonical tag form.** All eight moves use `[MOVE: name(args)]`.
`[ADJUST_DISPOSITION: n]` stays an accepted alias (already wired in
`TagExtractor`). §6's bare `[REFUSE]` shorthand is dropped — the extractor
never parsed it. The prompt instructs the model to use `[MOVE: …]` only.
6. **Client architecture: sibling service + pure validator.** A new `NpcService`
parallels `DmService`, reusing `DmTransport`, `FallbackLibrary`, and
`TagExtractor`. Move logic lives in a **pure `MoveValidator`**
(`(extracted_moves, available_moves, state_view) → {valid, dropped}`) with no
side effects, plus a small `MoveApplier` the caller invokes to write state.
Application stays explicit at the call site, mirroring how `narrate()` returns
facts for the caller to apply (§2). Two small pure units beat one branchy
service.
7. **Authoring scope: mechanism + one live NPC.** Build the full machinery and
author exactly one town NPC end-to-end — **Fenn**, who already owns
`find_the_ledger`. The second town NPC's knowledge list is authoring work
(§6: "real authoring work") that follows once the mechanism is proven. One
live NPC answers the aliveness question.
8. **Throwaway NPC harness scene.** Like the narrate harness: a `RichTextLabel`,
a `LineEdit` for the utterance, a "Speak" button, and a small readout of
applied/dropped moves + current disposition. Enough to drive a multi-turn
conversation until `end_conversation`. Real dialogue UI is a later wireframe;
do not gold-plate.
## The NPC content file
`/content/world/npcs/fenn.json` — one file, three sections split by who reads
them:
```json
{
"id": "fenn",
"name": "Fenn",
"role": "townsfolk",
"persona": "Harried dockside clerk; talks in ledgers and grievances. Server-only.",
"knowledge": [
"His ledger went missing two nights ago.",
"He last saw it when the Varrell twins were drinking in the counting-room.",
"He owes money he cannot cover if the ledger surfaces in the wrong hands."
],
"capabilities": {
"offerable_quests": ["find_the_ledger"],
"giveable_items": [],
"revealable_topics": ["varrell_twins", "fenns_debt"]
}
}
```
- **Server reads** `persona` + `knowledge`. These are the spoilers; they are
sent to the model, never to or from the client.
- **Client reads** `capabilities` to compute `available_moves`. Non-spoiler
handles only — ids, not content.
- **Deferred hardening (not this piece):** for the shipped binary, physically
split `persona`/`knowledge` into a server-only file so they cannot be
datamined from the client bundle. Today the client simply ignores those keys.
Captured as a deferred minor; §4 already puts us server-authoritative for the
prose, so this is a bundling nicety, not a correctness gap.
## Contracts
### Request — `POST /npc/speak`
```json
{
"canon_log": { "...schema-v1 canon log..." },
"npc_id": "fenn",
"disposition": 0,
"available_moves": ["offer_quest(find_the_ledger)", "reveal(varrell_twins)",
"reveal(fenns_debt)", "refuse", "end_conversation",
"become_hostile", "adjust_disposition"],
"utterance": "I heard you lost something."
}
```
`available_moves` entries are **concrete signatures** where the move takes an id
(`offer_quest(find_the_ledger)`), and bare names for the universal moves
(`refuse`, `end_conversation`, `become_hostile`, `adjust_disposition`,
`accept_item`). The server injects this list verbatim into the prompt so the
model knows exactly what it may do; the client re-validates every emitted move
against it anyway (belt and suspenders — the model can still hallucinate).
### Responses
- `200``{"prose": "<raw prose with [MOVE:]/[FACT:] tags>"}` — same shape as
narrate; the client extracts.
- `502``{"detail": {"model_error": "<str>"}}`.
- `422``{"detail": {"canon_log_errors": [ … ]}}` — invalid canon log, or an
`npc_id` that resolves to no content file, or a malformed request body (reuses
the unified 422 envelope).
## Client: available_moves computation and the move → state map
`available_moves` starts from the NPC's `capabilities` and is filtered by live
state:
| Move | Offered when | Applied to |
|---|---|---|
| `offer_quest(q)` | `q ∈ offerable_quests` **and** `q` not already active/complete in `canon_log.active_quests` | append active quest to `canon_log.active_quests` |
| `reveal(t)` | `t ∈ revealable_topics` and not already revealed | mark revealed (`GameState`) + `CanonLog.add_fact` |
| `give_item(i)` | `i ∈ giveable_items` and not already given | `GameState.add_item(i, 1)` |
| `accept_item(i)` | player holds `i` in `GameState.inventory` | decrement inventory |
| `adjust_disposition(delta)` | always | `GameState.set_npc_disposition`, **delta clamped to ±15** so one line can't swing standing wholesale |
| `refuse` | always | no state change (prose-only signal) |
| `end_conversation` | always | flow control — harness ends the loop |
| `become_hostile` | always | disposition → hostile floor + end conversation |
Validation for an emitted move = **name ∈ available_moves** *and* the per-move
precondition still holds against live state. Fail either → drop it, log it, keep
the prose. `adjust_disposition`, `end_conversation`, `refuse`, and
`become_hostile` carry no authored capability and no state precondition, so they
are always in `available_moves`. `accept_item` also carries no authored
capability, but is offered only while the player's inventory is non-empty (the
NPC can only take what the player has).
Disposition target is `GameState.npc_dispositions`**not** `CanonLog`'s
party-only `adjust_disposition`, which only finds companion rows. This is the
subtle correctness point: town NPCs have no canon-log row.
## Components
**Server (mirrors `narrate.py`):**
- `api/app/npc.py``run(req) -> str`: load persona+knowledge via `content.py`,
render an NPC digest (canon-log context + persona + disposition +
`knowledge[]` + `available_moves[]` + the player's utterance), route
`for_role("npc")`, call Ollama with one retry, log (§10), return raw prose.
- `api/app/content.py` — add `load_npc(npc_id) -> dict | None` returning
`persona`/`knowledge`; `None` when unknown.
- `api/app/main.py` — replace the `/npc/speak` stub: a new `NpcSpeakRequest`
pydantic model (still runs the shared canon-log validator), 422 on unknown
`npc_id`, `502` on `ModelError`, else `{"prose": …}`.
- `api/prompts/npc.md` — author the prompt body (voice bounded by `knowledge`
only; `[MOVE: …]` vocabulary; use only moves from `available_moves`;
disposition colours tone; never invent proper nouns without `[FACT: …]`).
**Client (mirrors `DmService`):**
- `client/scripts/net/npc_service.gd` — `NpcService.speak(npc_id, utterance,
canon_log, state_view) -> NpcResult`. Builds the request, posts through
`DmTransport`, degrades to the NPC fallback on any failure, extracts tags,
runs `MoveValidator`, returns the result. Applies no state itself.
- `client/scripts/npc/move_validator.gd` — **pure**: `(extracted_moves,
available_moves, state_view) -> {valid, dropped}`.
- `client/scripts/npc/move_applier.gd` — writes validated moves to
`GameState`/`CanonLog`; invoked by the caller (harness) after `speak`.
- `client/scripts/npc/npc_content.gd` — loads a `capabilities` block and computes
`available_moves` from it + live state.
- `client/scripts/net/npc_result.gd` — `{display_text, facts, valid_moves,
dropped_moves, degraded}`.
- `client/content/fallback/npc.json` — one authored in-voice degraded line;
no moves applied on degrade (§13).
- `client/scenes/npc_harness.tscn` (+ script) — throwaway driver.
## Error handling
- Any non-200, transport failure, or missing `prose` → `NpcResult` degraded with
the authored fallback line and **zero moves** (a degraded turn never mutates
state).
- Model failure server-side → one retry (pipeline default), then `502`; client
degrades.
- The whole point of §6 is that a hallucinated or illegal move is **not an
error**: it is dropped and logged, prose kept.
## Testing
- **`MoveValidator` (pure, GUT):** name-not-in-available dropped; precondition
failures (quest already active, item not held, unknown id) dropped; delta
clamp; valid moves pass. The densest test surface — the heart of §6.
- **`NpcService` with `FakeDmTransport`:** 200 with mixed valid/invalid move
tags → correct split + prose kept; 502/422/transport-fail → degraded, no
moves; facts harvested.
- **`NpcContent` available_moves computation:** capability + state → expected
list.
- **Server `npc.py` with a fake Ollama** (mirror the narrate tests): digest
includes persona/knowledge/disposition/available_moves/utterance; prose
returned raw; unknown `npc_id` → 422.
- **Gated live smoke:** real model as Fenn — grounded answer, a legal move
landing, an illegal move (if emitted) dropped, no leaked spoilers beyond
`knowledge`.
## Scope
**In:** `/npc/speak` server role; `content.load_npc`; `npc.md` prompt; Fenn's
content file; client `NpcService` + `MoveValidator` + `MoveApplier` +
`NpcContent` + `NpcResult`; NPC fallback content; throwaway harness; full test
set + gated live smoke.
**Out:** the second town NPC's knowledge list (mechanism first); real dialogue
UI (later wireframe); Adjudicator / free-text player *actions* (M3); streaming
(next M2 item, gated on aliveness); combat/hostility resolution beyond setting
the flag; physically splitting server-only content out of the client bundle
(deferred hardening).

View File

@@ -0,0 +1,245 @@
# Client HTTP loop (M2) — design
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). This is the first item.
Charter side (§2): **state** — the client owns the loop and consumes AI text; it
never lets prose write game state directly.
## Goal
Ship the first end-to-end vertical slice of the game:
```
CanonLog → HTTP → /dm/narrate → prose → screen → fact-harvest
```
A human presses a button, the running client posts its canon log to the proxy,
and authored-quality generated prose appears on screen. Every `[FACT: …]` the
Narrator emits is harvested into the client's own canon log. Any failure shows a
degraded-DM fallback (§13) in the Narrator's voice — never an error.
This slice proves the pipe. It does not build real game UI (that waits for a
separate wireframe) and it does not add NPC moves or disposition (that is the
next M2 item, `/npc/speak`).
## What already exists
- **Server:** `/dm/narrate` is fully wired (M1, merged `ddc00d4`). Contract:
- Request body: `{"canon_log": { …schema-v1 canon log… }}`
- `200``{"prose": "<raw prose with [FACT:] tags>"}`
- `502``{"detail": {"model_error": "<str>"}}` (model call failed)
- `422``{"detail": {"canon_log_errors": [ … ]}}` (invalid log)
- **Client:** `CanonLog.to_dict()` already emits the exact schema-v1 JSON the
server validates. `TagExtractor.extract(prose)` already returns
`{clean_text, facts, dispositions, moves}`. `new_game.gd` builds a starting
canon log. No autoloads are registered yet.
The client loop is therefore mostly plumbing between two things that already
work. The design's real weight is the network seam and the failure path.
## Decisions
These were settled during brainstorming; each is load-bearing.
1. **UI scope — throwaway bare harness.** This slice ships a disposable harness
scene (a `RichTextLabel` + a "Narrate" button), enough to see prose on
screen and prove the slice. The real prose-display UI is a later wireframe
and is out of scope here. Do not gold-plate the harness.
2. **Network seam — pure core + injectable transport.** Godot's `HTTPRequest`
is a tree `Node` that fires an async signal, which does not fit headless GUT
unit tests. So all loop logic (build body, parse `200`/`502`/`422`, choose
fallback, harvest facts) lives in a pure `RefCounted` `DmService` behind a
small `DmTransport` interface. The real transport wraps `HTTPRequest`; tests
inject a fake transport returning canned responses. This mirrors how the
server split `call_log`'s write sink for injection.
3. **Fallback text — one authored line, in client content.** On any failure the
client shows an authored Narrator line loaded from
`client/content/fallback/narrator.json`. §13 says fallbacks are content,
written as writing, living with the rest of the writing — not error strings
in code. One line is enough for the slice; the M4 fallback sweep enriches it.
The JSON is a list from day one so it can grow to a pool without a shape
change.
4. **Content home — renderer owns the file's home.** §16 designates root
`/content` as the home for authored content, but that path is outside the
Godot client's `res://` tree and an exported build cannot read it. §13
requires the fallback to exist when shipped, so client-rendered content ships
inside `client/` (`res://content/…`). Root `/content` remains the home for
server-side and authoring-tool content (quests, NPC knowledge, server
fallbacks). The rule: **whoever renders the content owns where it lives.**
CLAUDE.md §16 should get a one-line note recording this; that edit is a
follow-up, not part of this slice.
5. **Harvest facts only, on success only.** The narrate loop harvests `[FACT:]`
tags and nothing else. The Narrator role has no authority to move disposition
or perform moves; even if the model emits `[ADJUST_DISPOSITION]` or `[MOVE]`,
code drops it. This enforces §2/§6 (a role's vocabulary is enforced in code,
not trusted from prose) at the client seam. Facts are harvested only from a
real `200`; a fallback carries no tags and harvests nothing.
6. **`DmService.narrate()` returns, it does not mutate.** The core is a pure
function of `(canon_log, transport response)`: it returns a `NarrateResult`
carrying the display text and the harvested facts. Applying those facts to
the canon log (`log.add_fact(...)`) is the caller's step. This keeps the
harvested facts assertable in tests and keeps the state mutation (§2) at an
explicit, code-owned call site.
7. **No client-side retry.** Any non-`200`, transport failure, timeout, or
malformed `200` body maps straight to the fallback. The server already owns
the one-retry-then-fallback policy (§12) for model/parse failures; the client
does not add a second retry layer — it degrades immediately.
## Components
All paths are under `client/` unless noted.
### `scripts/net/dm_response.gd` — `class_name DmResponse` (RefCounted, value)
The raw outcome of one HTTP attempt, independent of any game meaning.
- `status: int` — HTTP status code, or `0` when the transport never got a
response.
- `body: Variant` — parsed JSON (`Dictionary`), or `null` when the body was
absent or not JSON.
- `transport_ok: bool``true` if the request reached the server and returned a
response (any status); `false` on connection/timeout errors.
- `error: String` — human-readable transport error, empty when `transport_ok`.
- Static ctors: `DmResponse.ok(status, body)`, `DmResponse.failed(error)`.
### `scripts/net/dm_transport.gd` — `class_name DmTransport` (RefCounted, base)
The seam interface.
- `func post_json(path: String, body: Dictionary) -> DmResponse:` — a coroutine
(callers `await` it). The base implementation asserts "not implemented" so a
missing override fails loudly in a test rather than silently returning null.
### `scripts/net/http_transport.gd` — `class_name HttpTransport` (RefCounted)
`extends DmTransport`. The real transport, and the **one thin untested shim** in
this slice (it parallels the server's untested Node boundary).
- Constructed with a reference to an `HTTPRequest` Node that the owner has added
to the scene tree.
- `post_json`: JSON-encode `body`, set `Content-Type: application/json`, call
`http.request(proxy_config.base_url() + path, headers, METHOD_POST, json)`,
`await http.request_completed`, and map the result:
- `result != RESULT_SUCCESS``DmResponse.failed(<result desc>)`
- otherwise → `DmResponse.ok(response_code, JSON.parse_string(body))`, where a
non-JSON body parses to `null`.
### `scripts/net/proxy_config.gd` — `class_name ProxyConfig` (static)
- `static func base_url() -> String` — returns the `ProjectSettings` override
`coc_rpg/proxy_base_url` if set, else the default `http://localhost:8000`.
The client holds only this URL — never a key, never a model name (§4).
### `scripts/net/narrate_result.gd` — `class_name NarrateResult` (RefCounted, value)
The game-meaningful result of one narrate call.
- `display_text: String` — the prose to show (tags stripped), or the fallback
line.
- `facts: Array``[FACT:]` strings to apply to the canon log (empty when
degraded).
- `degraded: bool``true` when this is fallback text, not model prose.
### `scripts/net/fallback_library.gd` — `class_name FallbackLibrary` (RefCounted)
- Loads `res://content/fallback/narrator.json` on construction (path
overridable for tests).
- `narrator_line() -> String` — returns the first authored line. If the file is
missing, unreadable, malformed, or the list is empty, returns a hardcoded
last-resort constant. §13 forbids ever showing the player an error, so the
fallback itself has a fallback.
### `scripts/net/dm_service.gd` — `class_name DmService` (RefCounted, pure core)
Owns a `DmTransport` and a `FallbackLibrary` (both injected via the
constructor).
```gdscript
func narrate(canon_log: CanonLog) -> NarrateResult:
var resp := await _transport.post_json("/dm/narrate", {"canon_log": canon_log.to_dict()})
if not resp.transport_ok or resp.status != 200 \
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
return NarrateResult.degraded(_fallback.narrator_line())
var extracted := TagExtractor.extract(str(resp.body["prose"]))
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
```
`NarrateResult.degraded(line)` is a named constructor for the fallback case
(`display_text = line`, `facts = []`, `degraded = true`).
### `content/fallback/narrator.json` — authored content
```json
{
"narrator": [
"The chamber is cold. Something waits in the dark."
]
}
```
In the Narrator's voice per §3 (dry, gritty not grim, never winks).
### `scenes/narrate_harness.tscn` + `scripts/harness/narrate_harness.gd` — throwaway
The "human sees prose" surface. A `RichTextLabel` and a "Narrate" button.
- On ready: create an `HTTPRequest` child, build `HttpTransport`,
`FallbackLibrary`, and `DmService`; build a starting canon log via
`new_game.gd`.
- On button press: `var result := await _service.narrate(_log)`, set the label
to `result.display_text`, apply `result.facts` to `_log` via `add_fact`, and
show the harvested-fact count (and a subtle degraded indicator for the
developer).
Explicitly disposable. The real display panel is a later wireframe.
## Testing
Headless GUT, hermetic — no live server in the unit suite.
- `tests/doubles/fake_transport.gd``class_name FakeDmTransport`
(`extends DmTransport`), returns a preset `DmResponse` and records the last
`path`/`body` for assertions. `post_json` needs no real async: `await` on a
plain return value resolves immediately, so one `await` call site drives both
the real and the fake transport.
- `tests/unit/test_dm_service.gd`:
- `200` + prose with a `[FACT:]` tag → `display_text` is stripped of tags,
`facts` contains the fact, `degraded == false`, and the posted path/body are
`"/dm/narrate"` / `{"canon_log": …}`.
- `502``degraded`, `display_text` is the fallback line, `facts` empty.
- `422``degraded`.
- transport failure (`transport_ok == false`) → `degraded`.
- malformed `200` (missing `prose`, or non-dict body) → `degraded`.
- a `200` whose prose also carries `[ADJUST_DISPOSITION]`/`[MOVE]`
those are dropped; only facts are harvested.
- `tests/unit/test_fallback_library.gd`:
- loads a temp JSON and returns its first line.
- missing/malformed file → the last-resort constant.
`HttpTransport` stays the untested shim; its live smoke is the harness scene run
by hand against a running proxy (documented in `client/docs`, not in the unit
suite — parallels the server's `--run-live` gated test).
## Out of scope
- Real prose-display UI (later wireframe).
- Streaming (M2, gated on aliveness — deferred per the roadmap).
- NPC moves / disposition application (next M2 item, `/npc/speak`).
- Client-side retry, request cancellation, loading spinners, caching (§14
concerns land when the real UI does).
- Editing CLAUDE.md §16 to record the content-home rule — a follow-up doc edit,
noted here so it is not lost.
## Follow-ups noted for later
- Add a one-line note to CLAUDE.md §16: client-rendered content ships under
`client/res://`; root `/content` is authoring + server source. Rule: the
renderer owns the file's home.
- When `/npc/speak` lands, the same transport/service seam extends to move and
disposition harvesting; `DmService` is the natural home or sibling.

View File

@@ -0,0 +1,167 @@
# Considering-state + transport timeout (M2) — design
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). Replaces streaming as the M2
anti-freeze item; **polish, not proof**.
Charter side (§2): **text/UI** — authored presentation while a call is in flight,
plus a reliability fix. No game state; no AI; nothing mutates the canon log.
## Goal
Make the model-call wait read as *the DM is speaking* rather than *the game is
frozen* (§14) — by **reframing** the wait in-character instead of hiding it.
While a `narrate()` / `speak()` call is in flight, the client shows a rotating,
authored "the Master considers…" indicator (Claude-Code-style verb rotation on a
~2s cadence). When the response lands, the indicator clears and the prose
appears. If the call hangs, a request **timeout** gives way to the authored
fallback instead of spinning forever — so the wait is both **framed**
(in-character) and **bounded** (never hangs).
This meets §14's *goal* on the **proven, non-streaming loop**, with essentially
no new failure surface. It is the counter-proposal to Narrator streaming (parked,
`2026-07-10-narrator-streaming-design.md`), chosen because streaming is the POC's
largest transport rework and adds mid-stream error/tag-boundary failure surface
that a polish feature shouldn't carry while the aliveness question is already
answered.
## What already exists (and is proven)
- **Client loop:** `DmService.narrate(canon_log)` and `NpcService.speak(...)` are
async; the harness disables its button, `await`s the call, then renders the
result. On any failure the service already degrades to an authored fallback
line (§13) via `FallbackLibrary` (loads `res://content/fallback/*.json`, returns
a hardcoded constant if the file is missing/malformed).
- **Transport:** `HttpTransport` wraps a Godot `HTTPRequest` node and maps any
non-`RESULT_SUCCESS` completion — **including `RESULT_TIMEOUT`** — to
`DmResponse.failed(...)`, which the service degrades on. But `HTTPRequest.timeout`
is left at its default **`0` (disabled)**: a hung proxy never completes, so the
await never returns. This is the deferred `_http.timeout=0` minor.
- **Config:** `ProxyConfig` already exposes `base_url()` from a `ProjectSettings`
key with a default — the pattern for the new timeout setting.
- **Harnesses:** `narrate_harness.gd` and `npc_harness.gd` both build their UI in
code and follow the disable-button → await → render shape.
The two seams this needs (the fallback-as-content loader; the transport's
non-success → degrade path) already work. The wait is currently just *blank*.
## Decisions
Settled during brainstorming; each is load-bearing.
1. **Reframe the wait, don't hide it.** §14's goal is that latency not read as a
freeze. A rotating in-character indicator achieves that by framing the pause as
the GM *thinking* — arguably more in-voice, for a turn-based game, than text
spraying onto the screen. Streaming (which genuinely hides latency) is parked
behind this, revisited only if playtesting shows the wait hurts.
2. **Loading is content (§13 spirit).** The considering phrases are **authored
content**, loaded like the fallback lines — a small pool of dry, on-voice lines
(§3: the narrator is dry and never winks). Not a spinner asset, not a
hardcoded "Loading…". A missing/malformed file degrades to one hardcoded
constant, so the indicator itself cannot fail.
3. **Rotate through the pool on a ~2s cadence.** Like Claude Code cycling
*thinking / considering / cooking*. A typical ~2s call shows one or two lines;
a slow one keeps rotating until the response or the timeout. The **phrase
selection is a pure unit**; the **timer + label wiring is a thin node shim**
the project's standard pure-core/node-shim split.
4. **The rotation index is instance state, not reset per call.** One indicator
instance persists on the harness and is reused across turns; `next()` advances
a running index that `start()`/`stop()` do **not** reset. So turn 2 continues
where turn 1 left off — the first phrase varies turn to turn **without**
randomness (keeps the core deterministic and unit-testable; no `randi()` in the
tested path).
5. **The timeout fix lands here.** `ProxyConfig` gains
`request_timeout_seconds()` (a `ProjectSettings` key, default **35.0**
just above the server's `OLLAMA_TIMEOUT_SECONDS=30` so the server's own
error/degrade surfaces first when the *model* is slow, while still bounding a
fully-hung proxy). `HttpTransport` sets `_http.timeout` to it. A hang →
`RESULT_TIMEOUT` → the **existing** `failed()` → degrade path fires. This is
why the timeout is essential *to this feature specifically*: without it the
considering-state would rotate **forever** on a hang — worse than a frozen
button, because it looks like progress.
6. **No server changes.** The proxy and the `/dm/narrate` · `/npc/speak`
contracts are untouched. This is entirely client-side presentation + one client
config value.
7. **Wire it into both harnesses.** Both prose surfaces (narrate + npc) get the
indicator — same small integration, and the NPC surface is where the aliveness
feel matters most. The component is reusable by the eventual real dialogue UI.
## Components
- `client/scripts/ui/considering_phrases.gd``ConsideringPhrases` (`class_name`,
pure `RefCounted`). `_init(path := DEFAULT_PATH)` loads a string pool from
`res://content/considering.json` (`{"considering": ["…", …]}`) using the same
robust instance-`JSON.parse()` loader `FallbackLibrary` uses (never
`JSON.parse_string()` — the project's gutconfig turns its engine error into a
false test failure). `next() -> String` returns the pool entry at a running
index and advances it (wrapping); an empty/missing pool returns
`LAST_RESORT` ("The Master considers your words."). Unit-tested.
- `client/scripts/ui/considering_indicator.gd``ConsideringIndicator` extends
`Node` (a repeating rotation needs a `Timer` in the tree, so it can't be a pure
`RefCounted`). The harness `add_child()`s it; in `_ready` it creates a repeating
`Timer` child (`ROTATE_SECONDS := 2.0`, `one_shot=false`). `start(label:
Control)` sets the label to `phrases.next()` and starts the timer, whose timeout
sets the label to `phrases.next()`; `stop()` stops the timer and clears the
label. Holds one `ConsideringPhrases` for its lifetime (decision 4). The
timer/tree wiring is the untested node shim (verified by the harness run); the
phrase logic it drives is tested via `ConsideringPhrases`.
- `client/content/considering.json` — authored pool. Starter lines (dry, §3),
reviewed like writing:
- "The Master considers your words."
- "Somewhere, dice are gathered."
- "The tale weighs its next turn."
- "The dark shifts, thinking."
- "A page is turned, unhurried."
- `client/scripts/net/proxy_config.gd` — add `TIMEOUT_SETTING`,
`TIMEOUT_DEFAULT := 35.0`, `request_timeout_seconds() -> float` (mirrors
`base_url()`).
- `client/scripts/net/http_transport.gd` — in `_init`, set
`_http.timeout = ProxyConfig.request_timeout_seconds()`. No other change; the
existing `result != RESULT_SUCCESS → failed()` path already handles the timeout.
- `client/scripts/harness/narrate_harness.gd` and `.../npc_harness.gd` — build a
`ConsideringIndicator`; call `start(status_label)` before `await`ing the service
and `stop()` after it returns (before rendering the result). Add a small status
`Label` if the harness lacks one to host the rotating text.
## Error handling
- **Slow call** → the indicator rotates; on return it stops and the prose renders.
- **Hung proxy / unreachable** → `HTTPRequest` times out at
`request_timeout_seconds()``RESULT_TIMEOUT``DmResponse.failed(...)` → the
service returns its degraded fallback; the harness `stop()`s the indicator and
shows the authored fallback line. No infinite spin, no error string (§13).
- **Missing/malformed `considering.json`** → `ConsideringPhrases` returns its
hardcoded `LAST_RESORT`; the indicator still works.
## Testing
- **`ConsideringPhrases` (pure, GUT):** cycles the pool in order and wraps; the
running index is **not** reset between logical "calls" (decision 4); a
missing/empty file yields `LAST_RESORT`; a well-formed file loads its pool.
- **`ProxyConfig.request_timeout_seconds`:** returns `TIMEOUT_DEFAULT` with no
setting; honours a `ProjectSettings` override.
- **`HttpTransport` timeout wiring:** constructing `HttpTransport` with a real
`HTTPRequest` sets `http.timeout == ProxyConfig.request_timeout_seconds()`
(read the property — a light assertion on the otherwise-untested shim).
- **Degrade-on-timeout is already covered:** the existing
`test_transport_failure_degrades` service tests exercise `failed()` → degraded
result; a timeout maps to `failed()`, so no new service test is needed (note it,
don't duplicate).
- **Indicator:** the `Timer`/tree wiring is verified by running the harness (see
the rotation, see it stop when prose arrives, kill the proxy → see it rotate
then yield to the fallback after the timeout). No unit test for the timer.
## Scope
**In:** `ConsideringPhrases` (pure) + `considering.json`; `ConsideringIndicator`
(node shim); `ProxyConfig.request_timeout_seconds` + `HttpTransport` timeout
wiring; both harnesses showing the indicator around the await; the test set above.
**Out:** streaming (parked, `2026-07-10-narrator-streaming-design.md`); any server
change; real dialogue UI; combat flavor; companion-flavored considering lines
(a nice later touch — Brannoc/Cadwyn voicing the wait — but not now).

View File

@@ -0,0 +1,264 @@
# Narrator streaming (M2) — design
> **STATUS: SHELVED for the POC (2026-07-10).** The considering-state
> (`2026-07-10-considering-state-design.md`) shipped and is live-confirmed; it
> meets §14's anti-freeze *goal* by reframing the wait in-character rather than
> hiding it, for a fraction of the risk. Real streaming is the POC's largest
> transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds
> mid-stream error/tag-boundary failure surface judged not worth it. The roadmap
> item is struck. This design is retained only as a record — revive it solely if
> playtesting proves the wait genuinely hurts immersion (long Narrator responses
> or a slow prod model). Not planned work.
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). The third M2 item; **polish, not
proof** — gated on the aliveness question, which the NPC work answered.
Charter side (§2): **text** — the client streams and displays AI prose; it still
never lets prose write state directly (facts harvested at stream end, applied by
the caller).
## Goal
Make the ~2s model call read as *the DM is speaking* rather than *the game is
frozen* (§14). The server streams tokens from Ollama as they are produced; the
client renders them as they arrive — first words on screen in ~200ms instead of
a 2s blank wait.
```
CanonLog → HTTP → /dm/narrate (200 NDJSON stream) → tokens as they arrive
→ client renders incrementally → at stream end: TagExtractor → fact-harvest
```
This piece proves the streaming mechanism on the **Narrator only** — pure prose,
no moves. The NPC surface (which must run move validation on the complete text
at stream end) is a deliberate fast-follow on the *same* transport. This is the
riskiest transport rework in the POC, so it lands on the simplest surface first.
## What already exists (and is proven)
- **Server:** `ollama_client.chat(...)` posts `{"stream": False}` to Ollama's
`/api/chat` with one retry, raising `ModelError`; its docstring already says it
was "built so a streaming mode is an additive path later." `narrate.run(canon_log)`
assembles messages, calls `chat`, logs via `call_log.record(...)`, returns raw
prose. `/dm/narrate` returns `{"prose": …}` / `502` / `422`. `call_log.record`
takes `response=`/`error=` and an injectable `write` sink.
- **Client:** `DmService.narrate(canon_log) -> NarrateResult` posts through an
injectable `DmTransport` (`post_json(path, body) -> DmResponse`), degrades to
an authored fallback on any failure, harvests `[FACT]` via `TagExtractor`, and
returns facts for the caller to apply (§2). The real `HttpTransport` wraps a
Godot `HTTPRequest` **Node** (which buffers the whole body and fires
`request_completed` once — it cannot stream). `FakeDmTransport` returns a
canned `DmResponse`. `narrate_harness.gd` drives it on screen.
The server is genuinely one flag from streaming. The client is not: Godot's
`HTTPRequest` has no incremental-read path, so real streaming requires the
lower-level `HTTPClient` (poll + `read_response_body_chunk()`). That rework is
the weight of this piece.
## Decisions
Settled during brainstorming; each is load-bearing.
1. **Real token streaming, not a cosmetic reveal.** A client-side typewriter over
already-received text does not hide the initial wait — it just animates it.
Meeting §14 requires the server to emit tokens as Ollama produces them and the
client to render them live. That mandates the `HTTPClient` rework; there is no
cheaper path that meets the goal.
2. **Narrator first; NPC is a fast-follow.** Both prose endpoints share the
machinery, but NPC must run `TagExtractor`/`MoveValidator`/`MoveApplier` on the
*complete* text at stream end. Proving the transport on the move-free Narrator
means a transport bug cannot corrupt move application. The NPC extension reuses
this transport unchanged and adds only parse-at-end.
3. **Proxy emits its own minimal NDJSON, not Ollama's shape.** §4 — the client
knows nothing about the model. One JSON object per line:
`{"delta": "..."}` per token, terminated by `{"done": true}`, or by
`{"error": "..."}` on a model failure. NDJSON (not SSE) because
`HTTPClient.read_response_body_chunk()` returns arbitrary byte boundaries; the
client buffers bytes and splits on `\n`, and line-delimited JSON is trivial to
reassemble. The proxy translates Ollama's `{"message":{"content":…},"done":…}`
lines into these frames.
4. **Endpoint contract shift on `/dm/narrate`: 422 stays synchronous; model
errors become an in-stream frame.** Canon-log validation runs **before** any
streaming — a bad log still returns a plain `422` JSON (never a stream). A
valid log returns a **200 NDJSON stream**. Because a `StreamingResponse`'s
status is fixed at 200 once it starts, a **model failure is no longer a `502`**
— it is an `{"error": …}` frame inside the 200 stream. Same principle (the
client degrades on failure), new shape. The non-streaming JSON contract is
retired for this endpoint (the client is its only consumer and moves to
streaming).
5. **§12 retry moves to before-first-token, server-side.** Once bytes flow you
cannot cleanly retry. `chat_stream` retries the Ollama call **only if it fails
before yielding the first delta**; once streaming, an error propagates as the
`{"error"}` frame. The client never retries a stream — it degrades. This keeps
the retry in one place (server) and the client purely reactive, as today.
6. **Client streaming transport coexists with `HttpTransport`.** The proven
non-streaming transport keeps its job (the Adjudicator's strict JSON, later).
A new `StreamTransport` seam is added, not a replacement. `DmService` gains a
streaming method; the old `narrate()` path may remain for reference but the
harness uses the streaming one.
7. **Testable seam: `post_stream(path, body, on_delta) -> StreamResult`.** The
service passes an `on_delta: Callable` that buffers text and forwards to the
UI. `StreamResult` carries `{ok: bool, error: String}` at the end. A
`FakeStreamTransport` invokes `on_delta` for each canned chunk then returns a
canned `StreamResult`, so the service's accumulate/hold-back/degrade logic is
pure and unit-testable. The real `HTTPClient` poll loop is the one untested
shim — exactly the posture `HttpTransport` holds today.
8. **On-screen tags: hold-back buffering.** When a `[` arrives, the visible
reveal holds from that point until the matching `]`, then the tag is stripped.
This prevents `[FACT: the we…` flashing then vanishing, which reads as a bug
and undercuts the "feels alive" goal. Implemented as a small running buffer in
the on-delta path — the full raw text is still accumulated separately for the
end-of-stream `TagExtractor` pass.
9. **Mid-stream failure: keep the partial prose, mark degraded, harvest no
facts.** If the stream dies after some tokens, the player keeps the valid
prose already shown, the result is flagged `degraded`, and **no** facts are
harvested — the tag pipeline needs the complete text to be trustworthy. The
pre-first-token failure path (retry once server-side, then fail) shows the full
authored fallback, as today. No surface ever shows an error (§13).
10. **The `HttpTransport` timeout gap is closed here.** The `HTTPClient` transport
gets a **connect timeout + a time-to-first-token timeout + an inter-chunk
timeout** — not a total-duration timeout (a long stream is legitimate). A stall
at any of those is a stream failure → degrade. This retires the deferred
`_http.timeout=0` minor in the code that is reworking the transport anyway.
## Contracts
### `POST /dm/narrate` — request unchanged
Body: `{"canon_log": { …schema-v1… }}`.
### Responses
- Invalid log → `422 {"detail": {"canon_log_errors": [ … ]}}` (synchronous JSON,
before any stream — unchanged).
- Valid log → `200`, `Content-Type: application/x-ndjson`, body is a sequence of
newline-terminated JSON objects:
```
{"delta": "The chamber "}
{"delta": "is cold. "}
{"delta": "[FACT: the well is dry]"}
{"done": true}
```
A model failure replaces the terminal frame:
```
{"delta": "The chamber "}
{"error": "connection reset"}
```
`done` and `error` are mutually exclusive and terminal. Frames carry no model
identity, seed, or Ollama metadata (§4).
## Components
**Server:**
- `api/app/ollama_client.py` — add `chat_stream(model, messages, options, *,
think=None, client=None) -> Iterator[str]`. Uses `httpx` `client.stream("POST",
"/api/chat", json={... "stream": True ...})`, iterates `iter_lines()`, parses
each Ollama line, yields `message.content` deltas. Retries once **only** if the
request fails before the first delta; a failure after the first delta raises
`ModelError` from within the generator (the service converts it to an error
frame). The existing `chat()` is untouched.
- `api/app/narrate.py` — add `run_stream(canon_log) -> Iterator[str]` (or a
sibling module `narrate_stream.py` if `narrate.py` grows unwieldy): assembles
the same messages, calls `chat_stream`, yields proxy NDJSON frames
(`{"delta":…}` / terminal `{"done":true}` / `{"error":…}`), accumulates the full
text, and logs via `call_log.record(...)` at stream end (both success — with the
accumulated `response` — and error paths). Logs time-to-first-token.
- `api/app/main.py` — `/dm/narrate` validates the canon log synchronously
(unchanged `valid_turn`), then returns
`StreamingResponse(narrate_stream_gen(...), media_type="application/x-ndjson")`.
A `ModelError` raised before the first frame still surfaces as an in-stream
`{"error"}` frame (the generator catches and emits it), not a 502.
**Client:**
- `client/scripts/net/stream_transport.gd` — `StreamTransport` (`class_name`), the
seam: `post_stream(path: String, body: Dictionary, on_delta: Callable) ->
StreamResult` (async). Base pushes an error like `DmTransport` does.
- `client/scripts/net/http_stream_transport.gd` — the real one: wraps an
`HTTPClient`, connects, POSTs, polls `poll()` + `read_response_body_chunk()`,
buffers bytes, splits on `\n`, parses each frame, calls `on_delta(delta)` per
`{"delta"}`, and returns a `StreamResult` on `{"done"}` / `{"error"}` /
transport failure / timeout. Untested shim (verified by the harness + gated live
smoke), like `HttpTransport`. Holds the connect / first-token / inter-chunk
timeouts (§decision 10).
- `client/scripts/net/stream_result.gd` — `StreamResult{ok: bool, error: String}`
+ `StreamResult.failed(msg)`.
- `client/scripts/net/dm_service.gd` — add `narrate_stream(canon_log, on_delta:
Callable) -> NarrateResult`. It wraps the caller's `on_delta` in a hold-back
buffer (reveals text, holding an open `[`…`]` span), accumulates the full raw
text, awaits `post_stream`, and at the end: if the `StreamResult` failed with no
text → `NarrateResult.fallback(...)`; if it failed mid-stream → degraded result
carrying the partial `display_text` and **no** facts; on success →
`TagExtractor.extract(full_text)` and return `{clean_text, facts, degraded:false}`.
Reuses `NarrateResult` and `FallbackLibrary` unchanged.
- `client/scripts/text/tag_stream_filter.gd` — a small **pure** hold-back filter:
`feed(delta) -> String` returns the safe-to-show text (buffering from an
unmatched `[`), `flush() -> String` returns any trailing buffered text at end.
Pure and unit-tested; `DmService.narrate_stream` uses it for the on-screen
reveal while `TagExtractor` still runs on the full raw text.
- `client/scripts/harness/narrate_harness.gd` — switch the button handler to
`narrate_stream`, appending each `on_delta` chunk to the `RichTextLabel` live,
then applying facts at the end (unchanged §2 apply).
## Error handling
- **Invalid canon log** → synchronous `422`; client shows fallback (unchanged).
- **Pre-first-token model failure** → `chat_stream` retries once; still failing →
the generator's first frame is `{"error"}` → client shows the full authored
fallback line, no facts.
- **Mid-stream failure** (`{"error"}` after deltas, dropped connection, or an
inter-chunk timeout) → keep the partial prose on screen, mark degraded, harvest
no facts.
- **Stall** (no connect / no first token / gap between chunks past the timeout) →
treated as the matching failure above.
- No surface ever shows an error string (§13).
## Testing
- **`TagStreamFilter` (pure, GUT):** a tag split across two deltas is held then
revealed cleanly; a `[FACT:…]` is stripped from the visible reveal; plain text
passes straight through; `flush()` returns a dangling partial.
- **`DmService.narrate_stream` with `FakeStreamTransport`:** deltas accumulate →
`display_text` is the cleaned full text and facts are harvested; a canned
`{"error"}` after deltas → degraded, partial text kept, no facts; a pre-token
failure → full fallback, no facts; the on-screen reveal (via the injected
`on_delta`) never shows raw tag text.
- **Server `chat_stream` with a fake httpx stream:** yields deltas in order;
retries once before the first delta; a mid-stream raise surfaces as `ModelError`.
- **Server `narrate` stream service with a fake `chat_stream`:** emits well-formed
NDJSON frames terminated by `done`; a raise emits a terminal `error` frame; the
call is logged once with the accumulated text.
- **Endpoint (`TestClient`):** a valid log streams `application/x-ndjson` frames
ending in `{"done":true}`; an invalid log still returns `422` JSON (no stream);
a model failure yields a 200 stream whose last frame is `{"error"}`.
- **Gated live stream smoke** (`@pytest.mark.live`): real Ollama, assert deltas
arrive incrementally (more than one frame) and the concatenation is non-empty
grounded prose, no Luck number.
## Scope
**In:** proxy NDJSON stream format; `ollama_client.chat_stream`; the streaming
narrate service + `/dm/narrate` streaming endpoint (422 synchronous, model error
as in-stream frame); the client `HTTPClient` `StreamTransport` (+ connect /
first-token / inter-chunk timeouts) behind a testable seam; `StreamResult`;
`DmService.narrate_stream`; the pure `TagStreamFilter` hold-back; keep-partial
mid-stream degrade; `narrate_harness` streaming on screen; full test set + gated
live stream smoke.
**Out:** NPC streaming (the fast-follow — reuses this transport, adds
parse-at-end); the Adjudicator; combat flavor (async, later); any real dialogue
UI; a total-duration timeout; SSE (NDJSON chosen).

Some files were not shown because too many files have changed in this diff Show More