Merge feature/considering-state into dev
Considering-state + transport timeout (M2 anti-freeze; streaming parked). While a model call is in flight the client rotates authored in-character 'the Master considers…' lines (§13); HttpTransport now sets a request timeout (35s, rejects <=0) so a hung call degrades to the fallback instead of spinning forever. Human-confirmed working live, including the degrade path (API down → fallback). 5 tasks, whole-branch review MERGE AS-IS. Rotation tuned to 3.0s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
client/content/considering.json
Normal file
9
client/content/considering.json
Normal file
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
44
client/scripts/ui/considering_indicator.gd
Normal file
44
client/scripts/ui/considering_indicator.gd
Normal file
@@ -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()
|
||||
41
client/scripts/ui/considering_phrases.gd
Normal file
41
client/scripts/ui/considering_phrases.gd
Normal file
@@ -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 []
|
||||
19
client/tests/unit/test_considering_indicator.gd
Normal file
19
client/tests/unit/test_considering_indicator.gd
Normal file
@@ -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
|
||||
33
client/tests/unit/test_considering_phrases.gd
Normal file
33
client/tests/unit/test_considering_phrases.gd
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user