From 18a18820fe331d32dab66a738f809be92872d5eb Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 09:54:03 -0500 Subject: [PATCH 1/6] =?UTF-8?q?feat(client):=20net=20primitives=20?= =?UTF-8?q?=E2=80=94=20DmResponse,=20NarrateResult,=20DmTransport,=20Proxy?= =?UTF-8?q?Config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/net/dm_response.gd | 25 +++++++++ client/scripts/net/dm_response.gd.uid | 1 + client/scripts/net/dm_transport.gd | 10 ++++ client/scripts/net/dm_transport.gd.uid | 1 + client/scripts/net/narrate_result.gd | 18 +++++++ client/scripts/net/narrate_result.gd.uid | 1 + client/scripts/net/proxy_config.gd | 14 +++++ client/scripts/net/proxy_config.gd.uid | 1 + client/tests/unit/test_net_primitives.gd | 54 ++++++++++++++++++++ client/tests/unit/test_net_primitives.gd.uid | 1 + 10 files changed, 126 insertions(+) create mode 100644 client/scripts/net/dm_response.gd create mode 100644 client/scripts/net/dm_response.gd.uid create mode 100644 client/scripts/net/dm_transport.gd create mode 100644 client/scripts/net/dm_transport.gd.uid create mode 100644 client/scripts/net/narrate_result.gd create mode 100644 client/scripts/net/narrate_result.gd.uid create mode 100644 client/scripts/net/proxy_config.gd create mode 100644 client/scripts/net/proxy_config.gd.uid create mode 100644 client/tests/unit/test_net_primitives.gd create mode 100644 client/tests/unit/test_net_primitives.gd.uid diff --git a/client/scripts/net/dm_response.gd b/client/scripts/net/dm_response.gd new file mode 100644 index 0000000..05aedf4 --- /dev/null +++ b/client/scripts/net/dm_response.gd @@ -0,0 +1,25 @@ +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) diff --git a/client/scripts/net/dm_response.gd.uid b/client/scripts/net/dm_response.gd.uid new file mode 100644 index 0000000..021a85a --- /dev/null +++ b/client/scripts/net/dm_response.gd.uid @@ -0,0 +1 @@ +uid://bdy2347pcqm4v diff --git a/client/scripts/net/dm_transport.gd b/client/scripts/net/dm_transport.gd new file mode 100644 index 0000000..aadc8c9 --- /dev/null +++ b/client/scripts/net/dm_transport.gd @@ -0,0 +1,10 @@ +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") diff --git a/client/scripts/net/dm_transport.gd.uid b/client/scripts/net/dm_transport.gd.uid new file mode 100644 index 0000000..22b5b8b --- /dev/null +++ b/client/scripts/net/dm_transport.gd.uid @@ -0,0 +1 @@ +uid://crgmujan6vj2l diff --git a/client/scripts/net/narrate_result.gd b/client/scripts/net/narrate_result.gd new file mode 100644 index 0000000..f66e554 --- /dev/null +++ b/client/scripts/net/narrate_result.gd @@ -0,0 +1,18 @@ +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) diff --git a/client/scripts/net/narrate_result.gd.uid b/client/scripts/net/narrate_result.gd.uid new file mode 100644 index 0000000..70d1fb1 --- /dev/null +++ b/client/scripts/net/narrate_result.gd.uid @@ -0,0 +1 @@ +uid://dye0nd5h05cks diff --git a/client/scripts/net/proxy_config.gd b/client/scripts/net/proxy_config.gd new file mode 100644 index 0000000..7573518 --- /dev/null +++ b/client/scripts/net/proxy_config.gd @@ -0,0 +1,14 @@ +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 diff --git a/client/scripts/net/proxy_config.gd.uid b/client/scripts/net/proxy_config.gd.uid new file mode 100644 index 0000000..3d18a0e --- /dev/null +++ b/client/scripts/net/proxy_config.gd.uid @@ -0,0 +1 @@ +uid://n7p287j05isf diff --git a/client/tests/unit/test_net_primitives.gd b/client/tests/unit/test_net_primitives.gd new file mode 100644 index 0000000..3c74103 --- /dev/null +++ b/client/tests/unit/test_net_primitives.gd @@ -0,0 +1,54 @@ +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) diff --git a/client/tests/unit/test_net_primitives.gd.uid b/client/tests/unit/test_net_primitives.gd.uid new file mode 100644 index 0000000..b5e83dc --- /dev/null +++ b/client/tests/unit/test_net_primitives.gd.uid @@ -0,0 +1 @@ +uid://cpihryriwysay From 32fa37212444be02be723c3539b82ce093bdb71b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 09:59:54 -0500 Subject: [PATCH 2/6] =?UTF-8?q?feat(client):=20FallbackLibrary=20+=20autho?= =?UTF-8?q?red=20narrator=20fallback=20(=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- client/content/fallback/narrator.json | 5 +++ client/scripts/net/fallback_library.gd | 37 +++++++++++++++++++ client/scripts/net/fallback_library.gd.uid | 1 + client/tests/unit/test_fallback_library.gd | 33 +++++++++++++++++ .../tests/unit/test_fallback_library.gd.uid | 1 + 5 files changed, 77 insertions(+) create mode 100644 client/content/fallback/narrator.json create mode 100644 client/scripts/net/fallback_library.gd create mode 100644 client/scripts/net/fallback_library.gd.uid create mode 100644 client/tests/unit/test_fallback_library.gd create mode 100644 client/tests/unit/test_fallback_library.gd.uid diff --git a/client/content/fallback/narrator.json b/client/content/fallback/narrator.json new file mode 100644 index 0000000..a77b885 --- /dev/null +++ b/client/content/fallback/narrator.json @@ -0,0 +1,5 @@ +{ + "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." + ] +} diff --git a/client/scripts/net/fallback_library.gd b/client/scripts/net/fallback_library.gd new file mode 100644 index 0000000..408a7be --- /dev/null +++ b/client/scripts/net/fallback_library.gd @@ -0,0 +1,37 @@ +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) + # Use the instance API, not JSON.parse_string(): the static convenience + # method pushes an engine-level error on malformed input, which is noisy + # and (in this project's test config) turns a handled failure into a + # reported test error. The instance parse() just returns an Error code. + var json := JSON.new() + if json.parse(text) != OK: + return [] + var data = json.get_data() + if typeof(data) != TYPE_DICTIONARY: + return [] + var lines = data.get("narrator", []) + return lines if typeof(lines) == TYPE_ARRAY else [] diff --git a/client/scripts/net/fallback_library.gd.uid b/client/scripts/net/fallback_library.gd.uid new file mode 100644 index 0000000..341a94b --- /dev/null +++ b/client/scripts/net/fallback_library.gd.uid @@ -0,0 +1 @@ +uid://b8jhu0e4yg6tf diff --git a/client/tests/unit/test_fallback_library.gd b/client/tests/unit/test_fallback_library.gd new file mode 100644 index 0000000..e517993 --- /dev/null +++ b/client/tests/unit/test_fallback_library.gd @@ -0,0 +1,33 @@ +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) diff --git a/client/tests/unit/test_fallback_library.gd.uid b/client/tests/unit/test_fallback_library.gd.uid new file mode 100644 index 0000000..19a8c5f --- /dev/null +++ b/client/tests/unit/test_fallback_library.gd.uid @@ -0,0 +1 @@ +uid://bs7jxst7fmyqx From 513225df3fca1b4a4b8ba7e99553a1f1a5af01e2 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 10:04:33 -0500 Subject: [PATCH 3/6] feat(client): DmService narrate loop + FakeDmTransport (all branches tested) Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/net/dm_service.gd | 27 ++++++++ client/scripts/net/dm_service.gd.uid | 1 + client/tests/doubles/fake_transport.gd | 19 +++++ client/tests/doubles/fake_transport.gd.uid | 1 + client/tests/unit/test_dm_service.gd | 80 ++++++++++++++++++++++ client/tests/unit/test_dm_service.gd.uid | 1 + 6 files changed, 129 insertions(+) create mode 100644 client/scripts/net/dm_service.gd create mode 100644 client/scripts/net/dm_service.gd.uid create mode 100644 client/tests/doubles/fake_transport.gd create mode 100644 client/tests/doubles/fake_transport.gd.uid create mode 100644 client/tests/unit/test_dm_service.gd create mode 100644 client/tests/unit/test_dm_service.gd.uid diff --git a/client/scripts/net/dm_service.gd b/client/scripts/net/dm_service.gd new file mode 100644 index 0000000..743c621 --- /dev/null +++ b/client/scripts/net/dm_service.gd @@ -0,0 +1,27 @@ +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) diff --git a/client/scripts/net/dm_service.gd.uid b/client/scripts/net/dm_service.gd.uid new file mode 100644 index 0000000..2a9d9e7 --- /dev/null +++ b/client/scripts/net/dm_service.gd.uid @@ -0,0 +1 @@ +uid://b467yu4cut0hp diff --git a/client/tests/doubles/fake_transport.gd b/client/tests/doubles/fake_transport.gd new file mode 100644 index 0000000..8305692 --- /dev/null +++ b/client/tests/doubles/fake_transport.gd @@ -0,0 +1,19 @@ +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 diff --git a/client/tests/doubles/fake_transport.gd.uid b/client/tests/doubles/fake_transport.gd.uid new file mode 100644 index 0000000..bd7dddf --- /dev/null +++ b/client/tests/doubles/fake_transport.gd.uid @@ -0,0 +1 @@ +uid://ddctedl778jdx diff --git a/client/tests/unit/test_dm_service.gd b/client/tests/unit/test_dm_service.gd new file mode 100644 index 0000000..2cc8480 --- /dev/null +++ b/client/tests/unit/test_dm_service.gd @@ -0,0 +1,80 @@ +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") diff --git a/client/tests/unit/test_dm_service.gd.uid b/client/tests/unit/test_dm_service.gd.uid new file mode 100644 index 0000000..a117c04 --- /dev/null +++ b/client/tests/unit/test_dm_service.gd.uid @@ -0,0 +1 @@ +uid://brm51u1xsq2iy From c208d3f1c435367292f674aee77a647abb2a2089 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 10:08:20 -0500 Subject: [PATCH 4/6] =?UTF-8?q?feat(client):=20HttpTransport=20=E2=80=94?= =?UTF-8?q?=20real=20HTTPRequest=20transport=20(untested=20shim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/net/http_transport.gd | 35 ++++++++++++++++++++++++ client/scripts/net/http_transport.gd.uid | 1 + 2 files changed, 36 insertions(+) create mode 100644 client/scripts/net/http_transport.gd create mode 100644 client/scripts/net/http_transport.gd.uid diff --git a/client/scripts/net/http_transport.gd b/client/scripts/net/http_transport.gd new file mode 100644 index 0000000..2b53f65 --- /dev/null +++ b/client/scripts/net/http_transport.gd @@ -0,0 +1,35 @@ +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) diff --git a/client/scripts/net/http_transport.gd.uid b/client/scripts/net/http_transport.gd.uid new file mode 100644 index 0000000..267fe08 --- /dev/null +++ b/client/scripts/net/http_transport.gd.uid @@ -0,0 +1 @@ +uid://cuvhnqsgr3liq From 89afd85bb254049682f1be3f1f3a8b6caa26dd6b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 10:12:21 -0500 Subject: [PATCH 5/6] feat(client): throwaway narrate harness scene + live-smoke doc Co-Authored-By: Claude Opus 4.8 (1M context) --- client/docs/README.md | 18 +++++ client/scenes/narrate_harness.tscn | 10 +++ client/scripts/harness/narrate_harness.gd | 66 +++++++++++++++++++ client/scripts/harness/narrate_harness.gd.uid | 1 + 4 files changed, 95 insertions(+) create mode 100644 client/scenes/narrate_harness.tscn create mode 100644 client/scripts/harness/narrate_harness.gd create mode 100644 client/scripts/harness/narrate_harness.gd.uid diff --git a/client/docs/README.md b/client/docs/README.md index d9e6a5f..597233b 100644 --- a/client/docs/README.md +++ b/client/docs/README.md @@ -11,3 +11,21 @@ Put here: - HTTP client + fallback-degradation handling (§13) Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here. + +## 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. diff --git a/client/scenes/narrate_harness.tscn b/client/scenes/narrate_harness.tscn new file mode 100644 index 0000000..d4b3ec4 --- /dev/null +++ b/client/scenes/narrate_harness.tscn @@ -0,0 +1,10 @@ +[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") diff --git a/client/scripts/harness/narrate_harness.gd b/client/scripts/harness/narrate_harness.gd new file mode 100644 index 0000000..1ac97d9 --- /dev/null +++ b/client/scripts/harness/narrate_harness.gd @@ -0,0 +1,66 @@ +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 diff --git a/client/scripts/harness/narrate_harness.gd.uid b/client/scripts/harness/narrate_harness.gd.uid new file mode 100644 index 0000000..eda8b4d --- /dev/null +++ b/client/scripts/harness/narrate_harness.gd.uid @@ -0,0 +1 @@ +uid://dxh7t3rdoqgq5 From 23ccc799aab7880309d9990909e3223921aa00bb Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 10:20:26 -0500 Subject: [PATCH 6/6] fix(client): parse HttpTransport body via JSON.new() not parse_string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-branch review Minor #1: JSON.parse_string pushes an engine-level error on a non-JSON body (e.g. a gateway HTML 502), which the project's gutconfig promotes to a false failure — the same reason FallbackLibrary already avoids it. Consistency fix; a non-JSON body still degrades. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/net/http_transport.gd | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/scripts/net/http_transport.gd b/client/scripts/net/http_transport.gd index 2b53f65..dbd768c 100644 --- a/client/scripts/net/http_transport.gd +++ b/client/scripts/net/http_transport.gd @@ -31,5 +31,10 @@ func post_json(path: String, body: Dictionary) -> DmResponse: 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 + # Parse via JSON.new() rather than JSON.parse_string(): the latter pushes an + # engine-level error on a non-JSON body (e.g. a gateway's HTML 502), which the + # project's gutconfig promotes to a false test failure. A non-JSON body stays + # null here — the service treats a non-dict body as a failure and degrades. + var json := JSON.new() + var parsed = null if json.parse(raw.get_string_from_utf8()) != OK else json.data return DmResponse.ok(response_code, parsed)