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>
168 lines
9.5 KiB
Markdown
168 lines
9.5 KiB
Markdown
# 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, `await`s 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.gd` — `ConsideringPhrases` (`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.gd` — `ConsideringIndicator` 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 `await`ing 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 / unreachable** → `HTTPRequest` times out at
|
|
`request_timeout_seconds()` → `RESULT_TIMEOUT` → `DmResponse.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.json`** → `ConsideringPhrases` 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).
|