feat(client): DmService narrate loop + FakeDmTransport (all branches tested)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 10:04:33 -05:00
parent 32fa372124
commit 513225df3f
6 changed files with 129 additions and 0 deletions

View File

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

View File

@@ -0,0 +1 @@
uid://b467yu4cut0hp

View File

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

View File

@@ -0,0 +1 @@
uid://ddctedl778jdx

View File

@@ -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")

View File

@@ -0,0 +1 @@
uid://brm51u1xsq2iy