diff --git a/docs/superpowers/plans/2026-07-10-client-http-loop.md b/docs/superpowers/plans/2026-07-10-client-http-loop.md new file mode 100644 index 0000000..6b6b028 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-client-http-loop.md @@ -0,0 +1,754 @@ +# Client HTTP Loop (M2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the first end-to-end vertical slice — a running Godot client posts its canon log to `/dm/narrate`, shows the returned prose on screen, harvests `[FACT:]` tags into its own log, and degrades to an authored fallback line on any failure. + +**Architecture:** A pure `RefCounted` `DmService` owns all loop logic (build request body, parse the response, choose the fallback, harvest facts) behind a small `DmTransport` seam. The real `HttpTransport` wraps Godot's `HTTPRequest` Node; a `FakeDmTransport` double drives the service in headless GUT tests. A throwaway harness scene wires the real transport to a button and a label so a human can see prose. Facts are returned by `narrate()` and applied to the log by the caller — code owns the state mutation (charter §2). + +**Tech Stack:** Godot 4.7, GDScript, GUT (headless test runner). No new dependencies. No autoloads. + +Spec: `docs/superpowers/specs/2026-07-10-client-http-loop-design.md`. + +## Global Constraints + +Every task's requirements implicitly include this section. + +- **Language/style:** GDScript. Pure classes are `class_name X extends RefCounted`. Follow the existing entity idiom (`client/scripts/canon_log/party_member.gd`): `_init` takes `p_`-prefixed args with defaults; value objects expose plain fields. +- **Client holds only the base URL** — never an API key, never a model name (charter §4). +- **Request/response contract (server, already live):** POST `/dm/narrate`, body exactly `{"canon_log": }`. Success is **only** HTTP `200` with a dict body containing a `"prose"` key. `502` → `{"detail":{"model_error":…}}`; `422` → `{"detail":{"canon_log_errors":[…]}}`. Any non-200, transport failure, or malformed 200 → the fallback line. **No client-side retry.** +- **Harvest facts only.** The narrate loop harvests `[FACT:]` tags and nothing else; stray `[ADJUST_DISPOSITION]`/`[MOVE]` are dropped (they belong to the NPC role). Facts are harvested only on a real 200. +- **`narrate()` returns, it does not mutate.** It returns a `NarrateResult`; the caller applies facts via `CanonLog.add_fact(...)`. +- **Fallback content home:** `client/content/fallback/narrator.json` (renderer owns the file's home — root `/content` is server/authoring). The JSON is `{"narrator": []}`. The library's hardcoded last-resort constant must be a **different string** from the authored line so tests can tell "loaded from file" from "fell back". +- **Base URL:** default `http://localhost:8000`, overridable via `ProjectSettings` key `coc_rpg/proxy_base_url`. +- **Naming (two deliberate departures from the spec's prose):** + - The `NarrateResult` fallback constructor is `NarrateResult.fallback(line)`, **not** `.degraded(...)` — GDScript shares one namespace for members and methods, so a `degraded()` method would clash with the `degraded` field. + - `DmTransport.post_json` base returns `DmResponse.failed("transport not implemented")` and `push_error`s, rather than `assert()` — GUT cannot catch a Godot `assert()`, so the not-implemented path must be a testable return value. +- **Tests:** GUT, `extends "res://addons/gut/test.gd"`, files under `client/tests/unit/` (the only dir GUT scans, per `.gutconfig.json`). Test doubles live under `client/tests/doubles/` (a sibling dir GUT does **not** scan). Run headless from `client/`: + - Whole suite: `./run_tests.sh` + - One file: `./run_tests.sh -gtest=res://tests/unit/.gd` + - `run_tests.sh` runs `godot --headless --import` first, so new `class_name` scripts resolve. +- **Untested-by-nature:** `HttpTransport` (needs a live `HTTPRequest` Node + server) and the harness scene are verified by reading + a by-hand live smoke against a running proxy — not by the unit suite. This parallels the server's `--run-live` gated boundary. +- **Git (charter §18):** all work on branch `feature/client-http-loop`, cut from `dev`. Commit after every task. + +--- + +### Task 1: Net primitives — `DmResponse`, `NarrateResult`, `DmTransport`, `ProxyConfig` + +The value/interface layer the service is built from. All pure, all headless-testable. + +**Files:** +- Create: `client/scripts/net/dm_response.gd` +- Create: `client/scripts/net/narrate_result.gd` +- Create: `client/scripts/net/dm_transport.gd` +- Create: `client/scripts/net/proxy_config.gd` +- Test: `client/tests/unit/test_net_primitives.gd` + +**Interfaces:** +- Consumes: nothing (leaf layer). +- Produces: + - `DmResponse` — fields `status:int`, `body` (Variant: parsed JSON dict or `null`), `transport_ok:bool`, `error:String`. Static ctors `DmResponse.ok(status:int, body) -> DmResponse`, `DmResponse.failed(error:String) -> DmResponse`. + - `NarrateResult` — fields `display_text:String`, `facts:Array`, `degraded:bool`. Ctor `NarrateResult.new(display_text:="", facts:=[], degraded:=false)`. Static ctor `NarrateResult.fallback(line:String) -> NarrateResult`. + - `DmTransport` — base with coroutine `post_json(path:String, body:Dictionary) -> DmResponse`; base returns `DmResponse.failed("transport not implemented")`. + - `ProxyConfig` — `const SETTING := "coc_rpg/proxy_base_url"`, `const DEFAULT := "http://localhost:8000"`, static `base_url() -> String`. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_net_primitives.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const DmResponse = preload("res://scripts/net/dm_response.gd") +const NarrateResult = preload("res://scripts/net/narrate_result.gd") +const DmTransport = preload("res://scripts/net/dm_transport.gd") +const ProxyConfig = preload("res://scripts/net/proxy_config.gd") + + +func test_dm_response_ok(): + var r = DmResponse.ok(200, {"prose": "hi"}) + assert_eq(r.status, 200) + assert_true(r.transport_ok) + assert_eq(r.body, {"prose": "hi"}) + assert_eq(r.error, "") + + +func test_dm_response_failed(): + var r = DmResponse.failed("boom") + assert_false(r.transport_ok) + assert_eq(r.status, 0) + assert_eq(r.body, null) + assert_eq(r.error, "boom") + + +func test_narrate_result_basic(): + var r = NarrateResult.new("text", ["a"], false) + assert_eq(r.display_text, "text") + assert_eq(r.facts, ["a"]) + assert_false(r.degraded) + + +func test_narrate_result_fallback(): + var r = NarrateResult.fallback("cold chamber") + assert_eq(r.display_text, "cold chamber") + assert_eq(r.facts, []) + assert_true(r.degraded) + + +func test_transport_base_returns_not_implemented(): + var t = DmTransport.new() + var r = await t.post_json("/x", {}) + assert_false(r.transport_ok) + assert_eq(r.error, "transport not implemented") + + +func test_proxy_config_default(): + ProjectSettings.set_setting(ProxyConfig.SETTING, null) + assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT) + + +func test_proxy_config_override(): + ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000") + assert_eq(ProxyConfig.base_url(), "http://example.test:9000") + ProjectSettings.set_setting(ProxyConfig.SETTING, null) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd` +Expected: FAIL — GUT reports a load/parse error because the four preloaded scripts do not exist yet. + +- [ ] **Step 3: Implement the four primitives** + +Create `client/scripts/net/dm_response.gd`: + +```gdscript +class_name DmResponse +extends RefCounted +## The raw outcome of one HTTP attempt, independent of any game meaning. +## `status` is 0 when the transport never reached the server; `body` is the +## parsed JSON dict, or null when the body was absent or not JSON. + +var status: int +var body # Variant: parsed JSON (Dictionary) or null +var transport_ok: bool +var error: String + + +func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void: + status = p_status + body = p_body + transport_ok = p_transport_ok + error = p_error + + +static func ok(p_status: int, p_body) -> DmResponse: + return DmResponse.new(p_status, p_body, true, "") + + +static func failed(p_error: String) -> DmResponse: + return DmResponse.new(0, null, false, p_error) +``` + +Create `client/scripts/net/narrate_result.gd`: + +```gdscript +class_name NarrateResult +extends RefCounted +## The game-meaningful result of one narrate call. `facts` are the [FACT:] +## strings the caller applies to the canon log; empty when degraded. + +var display_text: String +var facts: Array +var degraded: bool + + +func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void: + display_text = p_display_text + facts = p_facts + degraded = p_degraded + + +static func fallback(line: String) -> NarrateResult: + return NarrateResult.new(line, [], true) +``` + +Create `client/scripts/net/dm_transport.gd`: + +```gdscript +class_name DmTransport +extends RefCounted +## The network seam. DmService talks to this, never to HTTPRequest directly, so +## the loop logic is testable with an injected fake. Concrete transports +## override post_json. The base returns a failed response (rather than assert()) +## so a missing override is a loud, catchable failure in tests. + +func post_json(_path: String, _body: Dictionary) -> DmResponse: + push_error("DmTransport.post_json must be overridden") + return DmResponse.failed("transport not implemented") +``` + +Create `client/scripts/net/proxy_config.gd`: + +```gdscript +class_name ProxyConfig +extends RefCounted +## The client's one piece of network config: the proxy base URL. Never a key, +## never a model name (charter §4). Overridable per environment via project +## settings; defaults to the dev proxy. + +const SETTING := "coc_rpg/proxy_base_url" +const DEFAULT := "http://localhost:8000" + + +static func base_url() -> String: + var v = ProjectSettings.get_setting(SETTING, DEFAULT) + var s := str(v) + return s if s != "" else DEFAULT +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd` +Expected: PASS — 7/7 passing, 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/net/dm_response.gd client/scripts/net/narrate_result.gd client/scripts/net/dm_transport.gd client/scripts/net/proxy_config.gd client/tests/unit/test_net_primitives.gd +git commit -m "feat(client): net primitives — DmResponse, NarrateResult, DmTransport, ProxyConfig" +``` + +--- + +### Task 2: `FallbackLibrary` + authored fallback content + +Loads the authored §13 fallback line; falls back to a hardcoded constant if the file is missing or malformed (the fallback has a fallback — the player must never see an error). + +**Files:** +- Create: `client/content/fallback/narrator.json` +- Create: `client/scripts/net/fallback_library.gd` +- Test: `client/tests/unit/test_fallback_library.gd` + +**Interfaces:** +- Consumes: nothing. +- Produces: `FallbackLibrary` — ctor `FallbackLibrary.new(path := FallbackLibrary.PATH)`; method `narrator_line() -> String`; `const PATH := "res://content/fallback/narrator.json"`; `const LAST_RESORT := "The chamber is cold. Something waits in the dark."` + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_fallback_library.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") + + +func test_loads_authored_line_from_default_file(): + var lib = FallbackLibrary.new() + # The authored line is distinct from LAST_RESORT, so a match proves the file was read. + assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT) + assert_true(lib.narrator_line().length() > 0) + + +func test_missing_file_uses_last_resort(): + var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json") + assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT) + + +func test_malformed_file_uses_last_resort(): + var path := "user://bad_fallback.json" + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string("{ this is not valid json") + f.close() + var lib = FallbackLibrary.new(path) + assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT) + + +func test_empty_list_uses_last_resort(): + var path := "user://empty_fallback.json" + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string('{"narrator": []}') + f.close() + var lib = FallbackLibrary.new(path) + assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd` +Expected: FAIL — load/parse error, `fallback_library.gd` does not exist. + +- [ ] **Step 3: Write the authored content** + +Create `client/content/fallback/narrator.json` (Narrator voice per §3 — dry, gritty not grim, never winks; distinct from `LAST_RESORT`): + +```json +{ + "narrator": [ + "The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while." + ] +} +``` + +- [ ] **Step 4: Write minimal implementation** + +Create `client/scripts/net/fallback_library.gd`: + +```gdscript +class_name FallbackLibrary +extends RefCounted +## Authored degraded-DM fallback text (charter §13). The client shows one of +## these lines on ANY failure, in the Narrator's voice — never an error. If the +## content file is missing or malformed the library returns a hardcoded +## constant, so the fallback itself cannot fail. + +const PATH := "res://content/fallback/narrator.json" +const LAST_RESORT := "The chamber is cold. Something waits in the dark." + +var _lines: Array = [] + + +func _init(path := PATH) -> void: + _lines = _load(path) + + +func narrator_line() -> String: + return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT + + +static func _load(path: String) -> Array: + if not FileAccess.file_exists(path): + return [] + var text := FileAccess.get_file_as_string(path) + var data = JSON.parse_string(text) + if typeof(data) != TYPE_DICTIONARY: + return [] + var lines = data.get("narrator", []) + return lines if typeof(lines) == TYPE_ARRAY else [] +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd` +Expected: PASS — 4/4 passing. + +- [ ] **Step 6: Commit** + +```bash +git add client/content/fallback/narrator.json client/scripts/net/fallback_library.gd client/tests/unit/test_fallback_library.gd +git commit -m "feat(client): FallbackLibrary + authored narrator fallback (§13)" +``` + +--- + +### Task 3: `DmService` core + `FakeDmTransport` double + +The heart of the slice: build the request, branch on the response, harvest facts, choose the fallback. Every branch is covered headless by injecting a fake transport. + +**Files:** +- Create: `client/scripts/net/dm_service.gd` +- Create: `client/tests/doubles/fake_transport.gd` +- Test: `client/tests/unit/test_dm_service.gd` + +**Interfaces:** +- Consumes: `DmResponse`, `NarrateResult`, `DmTransport` (Task 1); `FallbackLibrary` (Task 2); existing `CanonLog` (`to_dict()`, `add_fact()`) and `TagExtractor` (`extract(prose) -> {clean_text, facts, …}`). +- Produces: `DmService` — ctor `DmService.new(transport: DmTransport, fallback: FallbackLibrary)`; coroutine `narrate(canon_log: CanonLog) -> NarrateResult`. +- Produces (test double): `FakeDmTransport extends DmTransport` — `set_response(resp: DmResponse)`, records `last_path: String`, `last_body: Dictionary`. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/doubles/fake_transport.gd`: + +```gdscript +extends "res://scripts/net/dm_transport.gd" +## Test double for DmTransport. Returns a canned DmResponse and records the last +## call so tests can assert what the service posted. post_json needs no real +## async — awaiting a plain return value resolves immediately, so the same await +## call site in DmService drives both the real and the fake transport. + +var _canned +var last_path: String +var last_body: Dictionary + + +func set_response(resp) -> void: + _canned = resp + + +func post_json(path: String, body: Dictionary) -> DmResponse: + last_path = path + last_body = body + return _canned +``` + +Create `client/tests/unit/test_dm_service.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const DmService = preload("res://scripts/net/dm_service.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") +const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") +const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd") + +var _fallback + + +func before_each(): + # Point the library at a missing file so narrator_line() is deterministically + # LAST_RESORT — the degraded assertions don't depend on the authored content. + _fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json") + + +func _log() -> CanonLog: + return CanonLog.new() + + +func _service_for(resp) -> Dictionary: + var t = FakeDmTransport.new() + t.set_response(resp) + return {"svc": DmService.new(t, _fallback), "transport": t} + + +func test_success_strips_tags_and_harvests_facts(): + var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"} + var pair := _service_for(DmResponse.ok(200, body)) + var r = await pair["svc"].narrate(_log()) + assert_false(r.degraded) + assert_eq(r.display_text, "The docks reek of tar.") + assert_eq(r.facts, ["the eastern bridge is out"]) + + +func test_success_posts_canon_log_to_narrate_path(): + var pair := _service_for(DmResponse.ok(200, {"prose": "x"})) + await pair["svc"].narrate(_log()) + assert_eq(pair["transport"].last_path, "/dm/narrate") + assert_true(pair["transport"].last_body.has("canon_log")) + + +func test_502_degrades_to_fallback_with_no_facts(): + var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}})) + var r = await pair["svc"].narrate(_log()) + assert_true(r.degraded) + assert_eq(r.display_text, FallbackLibrary.LAST_RESORT) + assert_eq(r.facts, []) + + +func test_422_degrades(): + var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}})) + var r = await pair["svc"].narrate(_log()) + assert_true(r.degraded) + + +func test_transport_failure_degrades(): + var pair := _service_for(DmResponse.failed("connection refused")) + var r = await pair["svc"].narrate(_log()) + assert_true(r.degraded) + + +func test_malformed_200_missing_prose_degrades(): + var pair := _service_for(DmResponse.ok(200, {"nope": 1})) + var r = await pair["svc"].narrate(_log()) + assert_true(r.degraded) + + +func test_malformed_200_non_dict_body_degrades(): + var pair := _service_for(DmResponse.ok(200, null)) + var r = await pair["svc"].narrate(_log()) + assert_true(r.degraded) + + +func test_only_facts_harvested_dispositions_and_moves_dropped(): + var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"} + var pair := _service_for(DmResponse.ok(200, body)) + var r = await pair["svc"].narrate(_log()) + assert_eq(r.facts, ["a"]) + assert_eq(r.display_text, "Hi bye") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd` +Expected: FAIL — load/parse error, `dm_service.gd` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `client/scripts/net/dm_service.gd`: + +```gdscript +class_name DmService +extends RefCounted +## The client narrate loop (charter §2: the client owns the loop and consumes +## text). Posts the canon log to /dm/narrate through an injected transport, +## returns the prose to display and the [FACT:] tags to harvest. On ANY failure +## it returns an authored fallback line — never an error. It harvests facts +## only; the Narrator has no authority over disposition or moves, so those tags +## are dropped even if the model emits them (§6). narrate() does not mutate the +## log — the caller applies the returned facts, keeping the state write explicit. + +var _transport: DmTransport +var _fallback: FallbackLibrary + + +func _init(transport: DmTransport, fallback: FallbackLibrary) -> void: + _transport = transport + _fallback = fallback + + +func narrate(canon_log: CanonLog) -> NarrateResult: + var resp: DmResponse = 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.fallback(_fallback.narrator_line()) + var extracted := TagExtractor.extract(str(resp.body["prose"])) + return NarrateResult.new(extracted["clean_text"], extracted["facts"], false) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd` +Expected: PASS — 8/8 passing. + +- [ ] **Step 5: Run the whole suite to confirm no regressions** + +Run: `cd client && ./run_tests.sh` +Expected: PASS — all prior tests plus the three new files green, 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/net/dm_service.gd client/tests/doubles/fake_transport.gd client/tests/unit/test_dm_service.gd +git commit -m "feat(client): DmService narrate loop + FakeDmTransport (all branches tested)" +``` + +--- + +### Task 4: `HttpTransport` — the real transport (untested shim) + +Wraps a live `HTTPRequest` Node and maps its async signal onto a `DmResponse`. No unit test: it needs a tree Node and a server. It is verified by reading here and by the live smoke in Task 5. + +**Files:** +- Create: `client/scripts/net/http_transport.gd` + +**Interfaces:** +- Consumes: `DmResponse` (Task 1), `ProxyConfig` (Task 1). Constructed with an `HTTPRequest` Node the owner has already added to the scene tree. +- Produces: `HttpTransport extends DmTransport` — ctor `HttpTransport.new(http: HTTPRequest)`; overrides `post_json(path, body) -> DmResponse`. + +- [ ] **Step 1: Write the implementation** + +Create `client/scripts/net/http_transport.gd`: + +```gdscript +extends "res://scripts/net/dm_transport.gd" +## The real transport: wraps an HTTPRequest Node the owner added to the tree. +## This is the one untested shim in the slice — it needs a live Node and a +## server, so it is verified by reading and by the harness live smoke, the same +## way the server's HTTP boundary is covered only by its gated --run-live test. +## A non-2xx status is NOT an error here; it is a normal DmResponse the service +## interprets. Only a transport-level failure (no response) yields failed(). + +const ProxyConfig = preload("res://scripts/net/proxy_config.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + +var _http: HTTPRequest + + +func _init(http: HTTPRequest) -> void: + _http = http + + +func post_json(path: String, body: Dictionary) -> DmResponse: + var url := ProxyConfig.base_url() + path + var headers := ["Content-Type: application/json"] + var payload := JSON.stringify(body) + var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload) + if err != OK: + return DmResponse.failed("request() failed to start: error %d" % err) + # request_completed(result, response_code, headers, body) — awaiting a + # multi-arg signal returns the args as an Array. + var signal_args: Array = await _http.request_completed + var result: int = signal_args[0] + var response_code: int = signal_args[1] + var raw: PackedByteArray = signal_args[3] + if result != HTTPRequest.RESULT_SUCCESS: + return DmResponse.failed("transport result %d" % result) + var parsed = JSON.parse_string(raw.get_string_from_utf8()) # null if not JSON + return DmResponse.ok(response_code, parsed) +``` + +- [ ] **Step 2: Verify it parses** + +Run: `cd client && godot --headless --import` +Expected: import completes with no parse/script errors mentioning `http_transport.gd`. (There is no unit test for this file by design; the review and Task 5's live smoke cover it.) + +- [ ] **Step 3: Commit** + +```bash +git add client/scripts/net/http_transport.gd +git commit -m "feat(client): HttpTransport — real HTTPRequest transport (untested shim)" +``` + +--- + +### Task 5: Throwaway harness scene + live-smoke doc + +The "human sees prose" surface: a button, a label, a status line. Builds the transport → service, posts a hand-built valid canon log, shows the prose, applies harvested facts. Disposable — the real UI is a later wireframe. Verified by hand against a running proxy. + +**Files:** +- Create: `client/scripts/harness/narrate_harness.gd` +- Create: `client/scenes/narrate_harness.tscn` +- Modify: `client/docs/README.md` (append a "Narrate live smoke" section) + +**Interfaces:** +- Consumes: `HttpTransport` (Task 4), `DmService` (Task 3), `FallbackLibrary` (Task 2); existing `CanonLog`, `LogPlayer`, `LogLocation`, `PartyMember`, `Quest`, `Humiliation`. +- Produces: a runnable scene; no code other tasks depend on. + +- [ ] **Step 1: Write the harness script** + +Create `client/scripts/harness/narrate_harness.gd`. The fixture mirrors the proven-valid `api/tests/fixtures/canon_log_valid.json` so the request passes the server's schema check and the prose is grounded: + +```gdscript +extends Control +## Throwaway M2 harness — the first on-screen generated prose. Wires the real +## transport to a button and a label. NOT the game UI (a later wireframe owns +## that). Run this scene directly (F6) with the proxy + Ollama up; see the live +## smoke steps in client/docs/README.md. + +const DmService = preload("res://scripts/net/dm_service.gd") +const HttpTransport = preload("res://scripts/net/http_transport.gd") +const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") + +var _service: DmService +var _log: CanonLog +var _label: RichTextLabel +var _status: Label +var _button: Button + + +func _ready() -> void: + var http := HTTPRequest.new() + add_child(http) + _service = DmService.new(HttpTransport.new(http), FallbackLibrary.new()) + _log = _build_scene_log() + + var vbox := VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 12) + add_child(vbox) + + _button = Button.new() + _button.text = "Narrate this scene" + _button.pressed.connect(_on_narrate_pressed) + vbox.add_child(_button) + + _label = RichTextLabel.new() + _label.fit_content = true + _label.custom_minimum_size = Vector2(640, 320) + vbox.add_child(_label) + + _status = Label.new() + vbox.add_child(_status) + + +func _on_narrate_pressed() -> void: + _button.disabled = true + _status.text = "…narrating…" + var r := await _service.narrate(_log) + _label.text = r.display_text + for f in r.facts: + _log.add_fact(f) + _status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""] + _button.disabled = false + + +func _build_scene_log() -> CanonLog: + var log := CanonLog.new() + log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") + log.set_location("greywater_docks", "the Greywater docks") + log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) + log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) + log.push_event("Arrived at Greywater by barge before dawn") + log.push_event("Brannoc recognised the harbourmaster and went quiet") + log.add_fact("the eastern bridge out of Greywater is washed out") + log.add_fact("the harbourmaster is named Oda Fenn") + log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger")) + log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3) + return log +``` + +Argument orders are confirmed against the source: `Quest.new(id, name, status, objective)` (`client/scripts/canon_log/quest.gd:13`) and `CanonLog.add_humiliation(id, text, weight, turn)` (`client/scripts/canon_log/canon_log.gd:38`). Use them as written. + +- [ ] **Step 2: Write the scene file** + +Create `client/scenes/narrate_harness.tscn`. Minimal root + script (all children are built in `_ready`, so the scene needs no hand-authored node tree). Godot will assign a `uid` on first import; that is expected and fine to commit: + +``` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"] + +[node name="NarrateHarness" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_harness") +``` + +- [ ] **Step 3: Verify it imports and parses** + +Run: `cd client && godot --headless --import` +Expected: import completes with no script/scene errors mentioning `narrate_harness`. Then confirm the unit suite is still green: `./run_tests.sh` → all pass (the harness has no unit test; this step guards against a parse error leaking in). + +- [ ] **Step 4: Document the live smoke** + +Append to `client/docs/README.md`: + +```markdown + +## Narrate live smoke (M2 client HTTP loop) + +The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate` +loop. It is not the game UI. + +**Prereqs:** the proxy running and Ollama reachable. + +1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the + `.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`). +2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6). +3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice + (referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and + `facts harvested: N` with N ≥ 0. +4. **Degraded path:** stop the proxy and click again. Expect the authored + fallback line and `(degraded)` in the status — never an error. + +Override the proxy URL with the `coc_rpg/proxy_base_url` project setting. +``` + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/harness/narrate_harness.gd client/scenes/narrate_harness.tscn client/docs/README.md +git commit -m "feat(client): throwaway narrate harness scene + live-smoke doc" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Data flow (spec) → Task 3 `DmService.narrate`. ✓ +- `DmResponse` / `DmTransport` / `NarrateResult` / `ProxyConfig` (spec components) → Task 1. ✓ +- `FallbackLibrary` + authored JSON + content-home rule → Task 2. ✓ +- `HttpTransport` untested shim → Task 4. ✓ +- Harness scene + live smoke → Task 5. ✓ +- Facts-only harvest, degraded on every failure branch, returns-not-mutates → Task 3 tests. ✓ +- Base URL default + override (§4) → Task 1 `ProxyConfig` tests. ✓ +- Out-of-scope items (real UI, streaming, NPC moves, retry) → not implemented, as specified. ✓ +- Follow-up (CLAUDE.md §16 note) → recorded in the spec's "Follow-ups"; not a task here (doc edit, deliberately deferred). ✓ + +**Placeholder scan:** No TBD/TODO. Every code step shows complete code. The one "confirm the signature before running" note (Task 5, `Quest.new`) is a real instruction with the known-good order given, not a placeholder. + +**Type consistency:** `DmResponse.ok/failed`, `NarrateResult.new/fallback`, `DmTransport.post_json`, `FallbackLibrary.new/narrator_line/PATH/LAST_RESORT`, `DmService.new/narrate`, `HttpTransport.new`, `ProxyConfig.SETTING/DEFAULT/base_url` — all used consistently across tasks and match the Interfaces blocks. `FakeDmTransport.post_json` matches the `DmTransport` base signature. The `narrate()` return (`NarrateResult`) is consumed correctly by the harness.