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>
11 KiB
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/narrateis fully wired (M1, mergedddc00d4). 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)
- Request body:
- 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.gdbuilds 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.
-
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. -
Network seam — pure core + injectable transport. Godot's
HTTPRequestis a treeNodethat fires an async signal, which does not fit headless GUT unit tests. So all loop logic (build body, parse200/502/422, choose fallback, harvest facts) lives in a pureRefCountedDmServicebehind a smallDmTransportinterface. The real transport wrapsHTTPRequest; tests inject a fake transport returning canned responses. This mirrors how the server splitcall_log's write sink for injection. -
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. -
Content home — renderer owns the file's home. §16 designates root
/contentas the home for authored content, but that path is outside the Godot client'sres://tree and an exported build cannot read it. §13 requires the fallback to exist when shipped, so client-rendered content ships insideclient/(res://content/…). Root/contentremains 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. -
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 real200; a fallback carries no tags and harvests nothing. -
DmService.narrate()returns, it does not mutate. The core is a pure function of(canon_log, transport response): it returns aNarrateResultcarrying 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. -
No client-side retry. Any non-
200, transport failure, timeout, or malformed200body 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, or0when the transport never got a response.body: Variant— parsed JSON (Dictionary), ornullwhen the body was absent or not JSON.transport_ok: bool—trueif the request reached the server and returned a response (any status);falseon connection/timeout errors.error: String— human-readable transport error, empty whentransport_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 (callersawaitit). 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
HTTPRequestNode that the owner has added to the scene tree. post_json: JSON-encodebody, setContent-Type: application/json, callhttp.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 tonull.
scripts/net/proxy_config.gd — class_name ProxyConfig (static)
static func base_url() -> String— returns theProjectSettingsoverridecoc_rpg/proxy_base_urlif set, else the defaulthttp://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—truewhen this is fallback text, not model prose.
scripts/net/fallback_library.gd — class_name FallbackLibrary (RefCounted)
- Loads
res://content/fallback/narrator.jsonon 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).
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
{
"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
HTTPRequestchild, buildHttpTransport,FallbackLibrary, andDmService; build a starting canon log vianew_game.gd. - On button press:
var result := await _service.narrate(_log), set the label toresult.display_text, applyresult.factsto_logviaadd_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 presetDmResponseand records the lastpath/bodyfor assertions.post_jsonneeds no real async:awaiton a plain return value resolves immediately, so oneawaitcall site drives both the real and the fake transport.tests/unit/test_dm_service.gd:200+ prose with a[FACT:]tag →display_textis stripped of tags,factscontains the fact,degraded == false, and the posted path/body are"/dm/narrate"/{"canon_log": …}.502→degraded,display_textis the fallback line,factsempty.422→degraded.- transport failure (
transport_ok == false) →degraded. - malformed
200(missingprose, or non-dict body) →degraded. - a
200whose 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/contentis authoring + server source. Rule: the renderer owns the file's home. - When
/npc/speaklands, the same transport/service seam extends to move and disposition harvesting;DmServiceis the natural home or sibling.