Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-10-client-http-loop.md
2026-07-10 09:51:49 -05:00

30 KiB

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": <log.to_dict()>}. 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": [<lines>]}. 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_errors, 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/<file>.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").
    • ProxyConfigconst 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:

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:

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:

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:

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:

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
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:

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):

{
  "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:

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
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 DmTransportset_response(resp: DmResponse), records last_path: String, last_body: Dictionary.

  • Step 1: Write the failing test

Create client/tests/doubles/fake_transport.gd:

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:

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:

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
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:

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
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:

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:


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