From 3a85d114395d3a1e698f7fb6736d4eec241803bc Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 09:33:44 -0500 Subject: [PATCH] docs(spec): Client HTTP loop (M2) design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-07-10-client-http-loop-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-client-http-loop-design.md diff --git a/docs/superpowers/specs/2026-07-10-client-http-loop-design.md b/docs/superpowers/specs/2026-07-10-client-http-loop-design.md new file mode 100644 index 0000000..cc00dfe --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-client-http-loop-design.md @@ -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": ""}` + - `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.