# 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": ""}` - `502` → `{"detail": {"model_error": ""}}` (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()` - 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.