# 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.
