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:
245
docs/superpowers/specs/2026-07-10-client-http-loop-design.md
Normal file
245
docs/superpowers/specs/2026-07-10-client-http-loop-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user