Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-10-client-http-loop-design.md
Phillip Tarrant 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

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/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.gdclass_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: booltrue 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.gdclass_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.gdclass_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_SUCCESSDmResponse.failed(<result desc>)
    • otherwise → DmResponse.ok(response_code, JSON.parse_string(body)), where a non-JSON body parses to null.

scripts/net/proxy_config.gdclass_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.gdclass_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: booltrue when this is fallback text, not model prose.

scripts/net/fallback_library.gdclass_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.gdclass_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 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.gdclass_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": …}.
    • 502degraded, display_text is the fallback line, facts empty.
    • 422degraded.
    • 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.