diff --git a/docs/roadmap.md b/docs/roadmap.md index aed7a07..03612f3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.* diff --git a/docs/superpowers/specs/2026-07-10-considering-state-design.md b/docs/superpowers/specs/2026-07-10-considering-state-design.md new file mode 100644 index 0000000..57d14ff --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-considering-state-design.md @@ -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). diff --git a/docs/superpowers/specs/2026-07-10-narrator-streaming-design.md b/docs/superpowers/specs/2026-07-10-narrator-streaming-design.md index b6b82c1..3a8bfc3 100644 --- a/docs/superpowers/specs/2026-07-10-narrator-streaming-design.md +++ b/docs/superpowers/specs/2026-07-10-narrator-streaming-design.md @@ -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.