Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-10-considering-state-design.md
Phillip Tarrant 96432bee87 docs(spec): considering-state + transport timeout; park streaming
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>
2026-07-10 14:37:20 -05:00

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) and NpcService.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) via FallbackLibrary (loads res://content/fallback/*.json, returns a hardcoded constant if the file is missing/malformed).
  • Transport: HttpTransport wraps a Godot HTTPRequest node and maps any non-RESULT_SUCCESS completion — including RESULT_TIMEOUT — to DmResponse.failed(...), which the service degrades on. But HTTPRequest.timeout is left at its default 0 (disabled): a hung proxy never completes, so the await never returns. This is the deferred _http.timeout=0 minor.
  • Config: ProxyConfig already exposes base_url() from a ProjectSettings key with a default — the pattern for the new timeout setting.
  • Harnesses: narrate_harness.gd and npc_harness.gd both 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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 that start()/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; no randi() in the tested path).

  5. The timeout fix lands here. ProxyConfig gains request_timeout_seconds() (a ProjectSettings key, default 35.0 — just above the server's OLLAMA_TIMEOUT_SECONDS=30 so the server's own error/degrade surfaces first when the model is slow, while still bounding a fully-hung proxy). HttpTransport sets _http.timeout to it. A hang → RESULT_TIMEOUT → the existing failed() → 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.

  6. No server changes. The proxy and the /dm/narrate · /npc/speak contracts are untouched. This is entirely client-side presentation + one client config value.

  7. 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.gdConsideringPhrases (class_name, pure RefCounted). _init(path := DEFAULT_PATH) loads a string pool from res://content/considering.json ({"considering": ["…", …]}) using the same robust instance-JSON.parse() loader FallbackLibrary uses (never JSON.parse_string() — the project's gutconfig turns its engine error into a false test failure). next() -> String returns the pool entry at a running index and advances it (wrapping); an empty/missing pool returns LAST_RESORT ("The Master considers your words."). Unit-tested.
  • client/scripts/ui/considering_indicator.gdConsideringIndicator extends Node (a repeating rotation needs a Timer in the tree, so it can't be a pure RefCounted). The harness add_child()s it; in _ready it creates a repeating Timer child (ROTATE_SECONDS := 2.0, one_shot=false). start(label: Control) sets the label to phrases.next() and starts the timer, whose timeout sets the label to phrases.next(); stop() stops the timer and clears the label. Holds one ConsideringPhrases for 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 via ConsideringPhrases.
  • 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 — add TIMEOUT_SETTING, TIMEOUT_DEFAULT := 35.0, request_timeout_seconds() -> float (mirrors base_url()).
  • client/scripts/net/http_transport.gd — in _init, set _http.timeout = ProxyConfig.request_timeout_seconds(). No other change; the existing result != RESULT_SUCCESS → failed() path already handles the timeout.
  • client/scripts/harness/narrate_harness.gd and .../npc_harness.gd — build a ConsideringIndicator; call start(status_label) before awaiting the service and stop() after it returns (before rendering the result). Add a small status Label if 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 / unreachableHTTPRequest times out at request_timeout_seconds()RESULT_TIMEOUTDmResponse.failed(...) → the service returns its degraded fallback; the harness stop()s the indicator and shows the authored fallback line. No infinite spin, no error string (§13).
  • Missing/malformed considering.jsonConsideringPhrases returns its hardcoded LAST_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 yields LAST_RESORT; a well-formed file loads its pool.
  • ProxyConfig.request_timeout_seconds: returns TIMEOUT_DEFAULT with no setting; honours a ProjectSettings override.
  • HttpTransport timeout wiring: constructing HttpTransport with a real HTTPRequest sets http.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_degrades service tests exercise failed() → degraded result; a timeout maps to failed(), 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).