From 513225df3fca1b4a4b8ba7e99553a1f1a5af01e2 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 10:04:33 -0500 Subject: [PATCH] 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