diff --git a/client/content/considering.json b/client/content/considering.json new file mode 100644 index 0000000..2704bbd --- /dev/null +++ b/client/content/considering.json @@ -0,0 +1,9 @@ +{ + "considering": [ + "The Master considers your words.", + "Somewhere, dice are gathered.", + "The tale weighs its next turn.", + "The dark shifts, thinking.", + "A page is turned, unhurried." + ] +} diff --git a/client/scripts/harness/narrate_harness.gd b/client/scripts/harness/narrate_harness.gd index 1ac97d9..0a6c862 100644 --- a/client/scripts/harness/narrate_harness.gd +++ b/client/scripts/harness/narrate_harness.gd @@ -7,12 +7,14 @@ extends Control 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") +const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd") var _service: DmService var _log: CanonLog var _label: RichTextLabel var _status: Label var _button: Button +var _indicator: ConsideringIndicator func _ready() -> void: @@ -39,11 +41,15 @@ func _ready() -> void: _status = Label.new() vbox.add_child(_status) + _indicator = ConsideringIndicator.new() + add_child(_indicator) + func _on_narrate_pressed() -> void: _button.disabled = true - _status.text = "…narrating…" + _indicator.start(_status) var r := await _service.narrate(_log) + _indicator.stop() _label.text = r.display_text for f in r.facts: _log.add_fact(f) diff --git a/client/scripts/harness/npc_harness.gd b/client/scripts/harness/npc_harness.gd index 0162c67..2166bd2 100644 --- a/client/scripts/harness/npc_harness.gd +++ b/client/scripts/harness/npc_harness.gd @@ -10,6 +10,7 @@ const MoveApplier = preload("res://scripts/npc/move_applier.gd") const ContentDB = preload("res://scripts/content/content_db.gd") const HttpTransport = preload("res://scripts/net/http_transport.gd") const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") +const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd") const NPC_ID := "fenn" @@ -20,6 +21,8 @@ var _canon_log: CanonLog var _output: RichTextLabel var _entry: LineEdit var _speak_btn: Button +var _status: Label +var _indicator: ConsideringIndicator func _ready() -> void: @@ -60,6 +63,12 @@ func _ready() -> void: _speak_btn.pressed.connect(_on_speak) vbox.add_child(_speak_btn) + _status = Label.new() + vbox.add_child(_status) + + _indicator = ConsideringIndicator.new() + add_child(_indicator) + func _on_speak() -> void: var utterance := _entry.text @@ -72,8 +81,10 @@ func _on_speak() -> void: var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0)) var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log) + _indicator.start(_status) var r: NpcResult = await _service.speak( NPC_ID, utterance, _canon_log, disposition, available) + _indicator.stop() # Apply state explicitly (§2) — the service applied nothing. MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID) diff --git a/client/scripts/net/http_transport.gd b/client/scripts/net/http_transport.gd index dbd768c..eac8f8b 100644 --- a/client/scripts/net/http_transport.gd +++ b/client/scripts/net/http_transport.gd @@ -14,6 +14,10 @@ var _http: HTTPRequest func _init(http: HTTPRequest) -> void: _http = http + # Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed() + # → the service degrades) instead of never returning. Without this the + # considering-state indicator would rotate forever on a hang. + _http.timeout = ProxyConfig.request_timeout_seconds() func post_json(path: String, body: Dictionary) -> DmResponse: diff --git a/client/scripts/net/proxy_config.gd b/client/scripts/net/proxy_config.gd index 7573518..fecd109 100644 --- a/client/scripts/net/proxy_config.gd +++ b/client/scripts/net/proxy_config.gd @@ -7,8 +7,19 @@ extends RefCounted const SETTING := "coc_rpg/proxy_base_url" const DEFAULT := "http://localhost:8000" +const TIMEOUT_SETTING := "coc_rpg/proxy_request_timeout_seconds" +const TIMEOUT_DEFAULT := 35.0 + static func base_url() -> String: var v = ProjectSettings.get_setting(SETTING, DEFAULT) var s := str(v) return s if s != "" else DEFAULT + + +static func request_timeout_seconds() -> float: + # Just above the server's OLLAMA_TIMEOUT_SECONDS (30) so a slow model surfaces + # the server's own error first, while a fully-hung proxy is still bounded. A + # non-positive value would re-enable the infinite hang, so reject it. + var v := float(ProjectSettings.get_setting(TIMEOUT_SETTING, TIMEOUT_DEFAULT)) + return v if v > 0.0 else TIMEOUT_DEFAULT diff --git a/client/scripts/ui/considering_indicator.gd b/client/scripts/ui/considering_indicator.gd new file mode 100644 index 0000000..29843e8 --- /dev/null +++ b/client/scripts/ui/considering_indicator.gd @@ -0,0 +1,44 @@ +class_name ConsideringIndicator +extends Node +## Thin node shim (charter §13). While a call is in flight it rotates an authored +## ConsideringPhrases line onto a Label every ROTATE_SECONDS, reframing the wait +## as the GM thinking. The phrase logic is the pure, tested ConsideringPhrases; +## the Timer/tree wiring here is the untested shim (verified by running the +## harness). One instance persists on the harness so the rotation index is not +## reset per call (spec decision 4). + +const ROTATE_SECONDS := 3.0 + +var _phrases: ConsideringPhrases +var _timer: Timer +var _label: Label + + +func _init(phrases: ConsideringPhrases = null) -> void: + _phrases = phrases if phrases != null else ConsideringPhrases.new() + + +func _ready() -> void: + _timer = Timer.new() + _timer.wait_time = ROTATE_SECONDS + _timer.one_shot = false + _timer.timeout.connect(_advance) + add_child(_timer) + + +func start(label: Label) -> void: + _label = label + _advance() # show a phrase immediately, don't wait ROTATE_SECONDS + _timer.start() + + +func stop() -> void: + _timer.stop() + if _label != null: + _label.text = "" + _label = null + + +func _advance() -> void: + if _label != null: + _label.text = _phrases.next() diff --git a/client/scripts/ui/considering_phrases.gd b/client/scripts/ui/considering_phrases.gd new file mode 100644 index 0000000..2e397d8 --- /dev/null +++ b/client/scripts/ui/considering_phrases.gd @@ -0,0 +1,41 @@ +class_name ConsideringPhrases +extends RefCounted +## Authored "the GM is thinking" lines (charter §13 — even loading is content). +## Pure: cycles a phrase pool in order on a running index that is instance state +## (spec decision 4 — the indicator reuses one instance so the first phrase varies +## turn to turn without randomness). A missing/malformed file degrades to one +## hardcoded constant, so the indicator itself cannot fail. + +const PATH := "res://content/considering.json" +const LAST_RESORT := "The Master considers your words." + +var _pool: Array = [] +var _index: int = 0 + + +func _init(path := PATH) -> void: + _pool = _load(path) + + +func next() -> String: + if _pool.is_empty(): + return LAST_RESORT + var phrase := str(_pool[_index % _pool.size()]) + _index += 1 + return phrase + + +static func _load(path: String) -> Array: + if not FileAccess.file_exists(path): + return [] + var text := FileAccess.get_file_as_string(path) + # Instance parse() (not JSON.parse_string) so malformed input returns an + # Error code instead of the engine error the test config promotes to a fail. + 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("considering", []) + return lines if typeof(lines) == TYPE_ARRAY else [] diff --git a/client/tests/unit/test_considering_indicator.gd b/client/tests/unit/test_considering_indicator.gd new file mode 100644 index 0000000..e3e3bc5 --- /dev/null +++ b/client/tests/unit/test_considering_indicator.gd @@ -0,0 +1,19 @@ +extends "res://addons/gut/test.gd" + +const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd") +const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd") + + +func test_start_shows_first_phrase_and_stop_clears(): + # Point phrases at a missing file so next() is deterministically LAST_RESORT. + var phrases = ConsideringPhrases.new("res://content/does_not_exist.json") + var ind = ConsideringIndicator.new(phrases) + add_child_autofree(ind) # _ready() creates the Timer child + var label := Label.new() + add_child_autofree(label) + + ind.start(label) + assert_eq(label.text, ConsideringPhrases.LAST_RESORT) # first phrase shown at once + + ind.stop() + assert_eq(label.text, "") # cleared on stop diff --git a/client/tests/unit/test_considering_phrases.gd b/client/tests/unit/test_considering_phrases.gd new file mode 100644 index 0000000..db2c92d --- /dev/null +++ b/client/tests/unit/test_considering_phrases.gd @@ -0,0 +1,33 @@ +extends "res://addons/gut/test.gd" + +const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd") + + +func test_missing_file_returns_last_resort(): + var p = ConsideringPhrases.new("res://content/does_not_exist.json") + assert_eq(p.next(), ConsideringPhrases.LAST_RESORT) + assert_eq(p.next(), ConsideringPhrases.LAST_RESORT) + + +func test_cycles_pool_in_order_and_wraps(): + # Real authored file has at least 3 lines; assert order + wrap on it. + var p = ConsideringPhrases.new() # default path — real content + var first = p.next() + var second = p.next() + assert_ne(first, "") + assert_ne(first, second) # advances, not stuck + # Drain a full cycle back to `first` to prove it wraps (bounded loop). + var seen := [first, second] + for i in range(20): + var n = p.next() + if n == first: + return # wrapped — success + seen.append(n) + assert_true(false, "did not wrap within 22 calls: %s" % [seen]) + + +func test_index_persists_across_calls_not_reset(): + var p = ConsideringPhrases.new() + var a = p.next() + var b = p.next() + assert_ne(a, b) # second call did not re-return the first phrase diff --git a/client/tests/unit/test_net_primitives.gd b/client/tests/unit/test_net_primitives.gd index 9a6a2ca..df83615 100644 --- a/client/tests/unit/test_net_primitives.gd +++ b/client/tests/unit/test_net_primitives.gd @@ -62,3 +62,29 @@ func test_npc_result_fallback_is_degraded_with_no_moves(): assert_eq(r.valid_moves, []) assert_eq(r.facts, []) assert_false(r.ends_conversation) + + +func test_request_timeout_default(): + if ProjectSettings.has_setting(ProxyConfig.TIMEOUT_SETTING): + ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING) + assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT) + + +func test_request_timeout_override_and_reject_nonpositive(): + var ProxyConfig = preload("res://scripts/net/proxy_config.gd") + ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 12.5) + assert_eq(ProxyConfig.request_timeout_seconds(), 12.5) + # A non-positive override must NOT re-enable the hang — falls back to default. + ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 0.0) + assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT) + ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING) + + +func test_http_transport_sets_request_timeout(): + var HttpTransport = preload("res://scripts/net/http_transport.gd") + var ProxyConfig = preload("res://scripts/net/proxy_config.gd") + var http := HTTPRequest.new() + autofree(http) + HttpTransport.new(http) + assert_eq(http.timeout, ProxyConfig.request_timeout_seconds()) + assert_gt(http.timeout, 0.0) # the whole point — no longer disabled