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>
This commit is contained in:
2026-07-10 09:33:44 -05:00
parent 4ae9cdbe63
commit 3a85d11439

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.