Pivot M2 anti-freeze from streaming to a lighter considering-state: a rotating in-character 'the Master considers…' indicator (authored content, §13 spirit) on the proven non-streaming loop, plus the HttpTransport request-timeout fix so a hung call yields to the fallback instead of spinning forever. Meets §14's anti-freeze goal by reframing the wait; near-zero new failure surface. Streaming design PARKED (banner + roadmap ○), revisit if playtest shows the wait hurts. Conscious §14 deviation recorded per §18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.5 KiB
Considering-state + transport timeout (M2) — design
Date: 2026-07-10 Milestone: M2 — prove aliveness (roadmap). Replaces streaming as the M2 anti-freeze item; polish, not proof. Charter side (§2): text/UI — authored presentation while a call is in flight, plus a reliability fix. No game state; no AI; nothing mutates the canon log.
Goal
Make the model-call wait read as the DM is speaking rather than the game is
frozen (§14) — by reframing the wait in-character instead of hiding it.
While a narrate() / speak() call is in flight, the client shows a rotating,
authored "the Master considers…" indicator (Claude-Code-style verb rotation on a
~2s cadence). When the response lands, the indicator clears and the prose
appears. If the call hangs, a request timeout gives way to the authored
fallback instead of spinning forever — so the wait is both framed
(in-character) and bounded (never hangs).
This meets §14's goal on the proven, non-streaming loop, with essentially
no new failure surface. It is the counter-proposal to Narrator streaming (parked,
2026-07-10-narrator-streaming-design.md), chosen because streaming is the POC's
largest transport rework and adds mid-stream error/tag-boundary failure surface
that a polish feature shouldn't carry while the aliveness question is already
answered.
What already exists (and is proven)
- Client loop:
DmService.narrate(canon_log)andNpcService.speak(...)are async; the harness disables its button,awaits the call, then renders the result. On any failure the service already degrades to an authored fallback line (§13) viaFallbackLibrary(loadsres://content/fallback/*.json, returns a hardcoded constant if the file is missing/malformed). - Transport:
HttpTransportwraps a GodotHTTPRequestnode and maps any non-RESULT_SUCCESScompletion — includingRESULT_TIMEOUT— toDmResponse.failed(...), which the service degrades on. ButHTTPRequest.timeoutis left at its default0(disabled): a hung proxy never completes, so the await never returns. This is the deferred_http.timeout=0minor. - Config:
ProxyConfigalready exposesbase_url()from aProjectSettingskey with a default — the pattern for the new timeout setting. - Harnesses:
narrate_harness.gdandnpc_harness.gdboth build their UI in code and follow the disable-button → await → render shape.
The two seams this needs (the fallback-as-content loader; the transport's non-success → degrade path) already work. The wait is currently just blank.
Decisions
Settled during brainstorming; each is load-bearing.
-
Reframe the wait, don't hide it. §14's goal is that latency not read as a freeze. A rotating in-character indicator achieves that by framing the pause as the GM thinking — arguably more in-voice, for a turn-based game, than text spraying onto the screen. Streaming (which genuinely hides latency) is parked behind this, revisited only if playtesting shows the wait hurts.
-
Loading is content (§13 spirit). The considering phrases are authored content, loaded like the fallback lines — a small pool of dry, on-voice lines (§3: the narrator is dry and never winks). Not a spinner asset, not a hardcoded "Loading…". A missing/malformed file degrades to one hardcoded constant, so the indicator itself cannot fail.
-
Rotate through the pool on a ~2s cadence. Like Claude Code cycling thinking / considering / cooking. A typical ~2s call shows one or two lines; a slow one keeps rotating until the response or the timeout. The phrase selection is a pure unit; the timer + label wiring is a thin node shim — the project's standard pure-core/node-shim split.
-
The rotation index is instance state, not reset per call. One indicator instance persists on the harness and is reused across turns;
next()advances a running index thatstart()/stop()do not reset. So turn 2 continues where turn 1 left off — the first phrase varies turn to turn without randomness (keeps the core deterministic and unit-testable; norandi()in the tested path). -
The timeout fix lands here.
ProxyConfiggainsrequest_timeout_seconds()(aProjectSettingskey, default 35.0 — just above the server'sOLLAMA_TIMEOUT_SECONDS=30so the server's own error/degrade surfaces first when the model is slow, while still bounding a fully-hung proxy).HttpTransportsets_http.timeoutto it. A hang →RESULT_TIMEOUT→ the existingfailed()→ degrade path fires. This is why the timeout is essential to this feature specifically: without it the considering-state would rotate forever on a hang — worse than a frozen button, because it looks like progress. -
No server changes. The proxy and the
/dm/narrate·/npc/speakcontracts are untouched. This is entirely client-side presentation + one client config value. -
Wire it into both harnesses. Both prose surfaces (narrate + npc) get the indicator — same small integration, and the NPC surface is where the aliveness feel matters most. The component is reusable by the eventual real dialogue UI.
Components
client/scripts/ui/considering_phrases.gd—ConsideringPhrases(class_name, pureRefCounted)._init(path := DEFAULT_PATH)loads a string pool fromres://content/considering.json({"considering": ["…", …]}) using the same robust instance-JSON.parse()loaderFallbackLibraryuses (neverJSON.parse_string()— the project's gutconfig turns its engine error into a false test failure).next() -> Stringreturns the pool entry at a running index and advances it (wrapping); an empty/missing pool returnsLAST_RESORT("The Master considers your words."). Unit-tested.client/scripts/ui/considering_indicator.gd—ConsideringIndicatorextendsNode(a repeating rotation needs aTimerin the tree, so it can't be a pureRefCounted). The harnessadd_child()s it; in_readyit creates a repeatingTimerchild (ROTATE_SECONDS := 2.0,one_shot=false).start(label: Control)sets the label tophrases.next()and starts the timer, whose timeout sets the label tophrases.next();stop()stops the timer and clears the label. Holds oneConsideringPhrasesfor its lifetime (decision 4). The timer/tree wiring is the untested node shim (verified by the harness run); the phrase logic it drives is tested viaConsideringPhrases.client/content/considering.json— authored pool. Starter lines (dry, §3), reviewed like writing:- "The Master considers your words."
- "Somewhere, dice are gathered."
- "The tale weighs its next turn."
- "The dark shifts, thinking."
- "A page is turned, unhurried."
client/scripts/net/proxy_config.gd— addTIMEOUT_SETTING,TIMEOUT_DEFAULT := 35.0,request_timeout_seconds() -> float(mirrorsbase_url()).client/scripts/net/http_transport.gd— in_init, set_http.timeout = ProxyConfig.request_timeout_seconds(). No other change; the existingresult != RESULT_SUCCESS → failed()path already handles the timeout.client/scripts/harness/narrate_harness.gdand.../npc_harness.gd— build aConsideringIndicator; callstart(status_label)beforeawaiting the service andstop()after it returns (before rendering the result). Add a small statusLabelif the harness lacks one to host the rotating text.
Error handling
- Slow call → the indicator rotates; on return it stops and the prose renders.
- Hung proxy / unreachable →
HTTPRequesttimes out atrequest_timeout_seconds()→RESULT_TIMEOUT→DmResponse.failed(...)→ the service returns its degraded fallback; the harnessstop()s the indicator and shows the authored fallback line. No infinite spin, no error string (§13). - Missing/malformed
considering.json→ConsideringPhrasesreturns its hardcodedLAST_RESORT; the indicator still works.
Testing
ConsideringPhrases(pure, GUT): cycles the pool in order and wraps; the running index is not reset between logical "calls" (decision 4); a missing/empty file yieldsLAST_RESORT; a well-formed file loads its pool.ProxyConfig.request_timeout_seconds: returnsTIMEOUT_DEFAULTwith no setting; honours aProjectSettingsoverride.HttpTransporttimeout wiring: constructingHttpTransportwith a realHTTPRequestsetshttp.timeout == ProxyConfig.request_timeout_seconds()(read the property — a light assertion on the otherwise-untested shim).- Degrade-on-timeout is already covered: the existing
test_transport_failure_degradesservice tests exercisefailed()→ degraded result; a timeout maps tofailed(), so no new service test is needed (note it, don't duplicate). - Indicator: the
Timer/tree wiring is verified by running the harness (see the rotation, see it stop when prose arrives, kill the proxy → see it rotate then yield to the fallback after the timeout). No unit test for the timer.
Scope
In: ConsideringPhrases (pure) + considering.json; ConsideringIndicator
(node shim); ProxyConfig.request_timeout_seconds + HttpTransport timeout
wiring; both harnesses showing the indicator around the await; the test set above.
Out: streaming (parked, 2026-07-10-narrator-streaming-design.md); any server
change; real dialogue UI; combat flavor; companion-flavored considering lines
(a nice later touch — Brannoc/Cadwyn voicing the wait — but not now).