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>
This commit is contained in:
@@ -49,7 +49,8 @@ The milestones are ordered to reach the POC's one question as early as possible,
|
||||
### M2 — Prove aliveness ◀ *(the POC's one question)*
|
||||
- ✅ **Client HTTP loop** — client posts its canon log to `/dm/narrate`, displays the prose, harvests `[FACT]` via its `TagExtractor` into its own log, and shows the degraded-DM authored fallback (§13) on any non-200. First end-to-end vertical slice: **state → HTTP → model → prose → screen → fact-harvest.** Pure `DmService` core behind an injectable transport seam; throwaway harness scene for the first on-screen prose. **Live-proven** against qwen3.5 through Docker (grounded scene prose, no numbers per §7). 67/67 client tests; merged to `dev` (`b949cd2`). Surfaced + fixed two Docker-dev gaps in M1 (prompts not packaged, no container→host-Ollama route) — M1's live proof had been venv-on-host only. *§2: state (client owns the loop, consumes text) · goal: put the Narrator on screen.*
|
||||
- ✅ **Bounded NPC conversation** — `/npc/speak` with server-owned persona + `knowledge[]` (spoilers stay off the client) and client-computed `available_moves[]`; the client validates each emitted move against live state, applies the valid, silently drops the invalid, and always keeps the prose (§6). Server role is a thin drop-in on the M1 pipeline (`npc.run` mirrors `narrate.py`); the client added a pure `MoveValidator` (membership is the whole test) + a `MoveApplier` that writes town-NPC disposition to `GameState.npc_dispositions` (not the party log), plus `NpcContent.available_moves` as the single legality home. Free text goes straight to the NPC prompt (no Adjudicator needed — the moves are the NPC's). **Live-proven** against qwen3.5: Fenn answered in-voice, grounded strictly in his authored knowledge, no Luck leak (§7), with `reveal`/`offer_quest` moves landing. The live smoke also caught two real tag-format drifts a unit test can't — paren-less zero-arg moves and a dropped `MOVE:` prefix leaking into prose — both fixed by making `TagExtractor` tolerant/scrub-safe (backward-compatible; narrator path unaffected). Built on `feature/npc-conversation`; awaiting the human's `--no-ff` merge to `dev` (§18). *§2: both (text = the NPC's voice · state = the validated moves) · goal: answer the one question.*
|
||||
- ▶ **Streaming** (§14) — server streams Ollama → client; latency reads as *the DM is speaking* rather than *the game is frozen*, hiding the ~2s call. **Gated on aliveness:** landed only after the dialogue experiment above says the prose is worth polishing. Additive to `ollama_client` (shaped for it) + an SSE/chunked response + the client stream consumer. *§2: text · depends on the client loop · goal: polish, not proof.*
|
||||
- ▶ **Considering-state + transport timeout** — while a model call is in flight the client shows a rotating in-character "the Master considers…" indicator (authored content, §13 spirit — even *loading* is content) instead of a frozen surface, and the `HttpTransport` request timeout is set so a hung call gives way to the authored fallback instead of spinning forever. Meets §14's anti-freeze *goal* by **reframing** the wait, on the *proven* non-streaming loop, with essentially no new failure surface. *§2: text/UI · depends on the client loop · goal: the wait is framed (in-character) and bounded (never hangs).*
|
||||
- ○ **Streaming** (§14) — **PARKED** (`specs/2026-07-10-narrator-streaming-design.md`). Server streams Ollama → client to genuinely *hide* the ~2s call. Deferred behind the considering-state: it is the POC's largest transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds mid-stream error/tag-boundary failure surface — too much risk for "polish, not proof" while the aliveness question is already answered. **Revisit** only if playtesting shows the wait hurts immersion (long responses or a slow prod model). Conscious deviation from §14's "stream everything," recorded here per §18. *§2: text · goal: polish, not proof.*
|
||||
|
||||
### M3 — Make it a game *(the playable ~30-min slice — dungeon first, per the sequencing call)*
|
||||
- ○ **Adjudicator** — `/dm/adjudicate`: free text → a legal action or an in-world rejection, **strict JSON** (§12), small/fast model, the one-retry-with-error-appended path. Unlocks the hybrid input model (§15) the playable slice needs — the "what do you do?" boundary. *§2: both (text = the in-world reason · state = the chosen action) · depends on the pipeline · goal: free-text world actions.*
|
||||
|
||||
167
docs/superpowers/specs/2026-07-10-considering-state-design.md
Normal file
167
docs/superpowers/specs/2026-07-10-considering-state-design.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 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).
|
||||
@@ -1,5 +1,14 @@
|
||||
# Narrator streaming (M2) — design
|
||||
|
||||
> **STATUS: PARKED (2026-07-10).** Deferred in favour of a lightweight
|
||||
> considering-state (see `2026-07-10-considering-state-design.md`), which meets
|
||||
> §14's anti-freeze *goal* by reframing the wait in-character rather than hiding
|
||||
> it — for a fraction of the risk (this piece is the POC's largest transport
|
||||
> rework and adds mid-stream error/tag-boundary failure surface that a
|
||||
> "polish, not proof" feature shouldn't). Revisit only if playtesting shows the
|
||||
> wait genuinely hurts immersion (long Narrator responses or a slow prod model).
|
||||
> This design stays ready for that moment.
|
||||
|
||||
Date: 2026-07-10
|
||||
Milestone: **M2 — prove aliveness** (roadmap). The third M2 item; **polish, not
|
||||
proof** — gated on the aliveness question, which the NPC work answered.
|
||||
|
||||
Reference in New Issue
Block a user