Compare commits
70 Commits
1f39ec709e
...
29474e5751
| Author | SHA1 | Date | |
|---|---|---|---|
| 29474e5751 | |||
| 49bc6b22c0 | |||
| b639cab0c2 | |||
| 57acfb75d9 | |||
| 099714d4f6 | |||
| 6d7eeaab27 | |||
| c5644d381e | |||
| 82b2de19bb | |||
| 6bd5e4f84a | |||
| 377adb10b2 | |||
| a171d40781 | |||
| 2c32bba320 | |||
| 96432bee87 | |||
| 1ed863d3fe | |||
| 1a6e8e8616 | |||
| 81db962a9d | |||
| d788225754 | |||
| af1a3aa726 | |||
| 2b1c11b1e6 | |||
| 86d1352b19 | |||
| ed2311b62c | |||
| 87f337bffd | |||
| c347da6cec | |||
| 832cdc65ca | |||
| 5181c3e74a | |||
| c0dcc8df82 | |||
| a2100f802f | |||
| 4c0b4e41c3 | |||
| 1b67b3984b | |||
| 3b71fcdcc0 | |||
| 1d1ae37281 | |||
| 9d2f93bbf2 | |||
| a2c654c047 | |||
| 0c3d64d068 | |||
| 004cc21724 | |||
| 76c04b45d2 | |||
| 6cd5003930 | |||
| 69729f7673 | |||
| 55a2ae6f19 | |||
| 097ac618f4 | |||
| 7db1eb8f26 | |||
| b949cd2164 | |||
| 5df67dcd8a | |||
| 004bd53a39 | |||
| f18d306059 | |||
| 23ccc799aa | |||
| 89afd85bb2 | |||
| c208d3f1c4 | |||
| 513225df3f | |||
| 32fa372124 | |||
| 18a18820fe | |||
| b4d22f5c5f | |||
| 3a85d11439 | |||
| 4ae9cdbe63 | |||
| 1189d2e24d | |||
| ddc00d42da | |||
| f7c51b79da | |||
| 470ccb2935 | |||
| 45d5f0360b | |||
| 5a35802075 | |||
| 2027e7a9b9 | |||
| 2558e2a2a0 | |||
| 15995c57ee | |||
| be133c4f3a | |||
| 4d8a19353e | |||
| 42b3720721 | |||
| ea2cfafd34 | |||
| ec01ce5c9b | |||
| 959abc79eb | |||
| b15e81e242 |
@@ -2,6 +2,7 @@
|
||||
.claude
|
||||
client
|
||||
content
|
||||
!content
|
||||
docs/*
|
||||
!docs/schemas
|
||||
**/__pycache__
|
||||
|
||||
91
CLAUDE.md
91
CLAUDE.md
@@ -8,13 +8,13 @@ Read this before writing code. When a request conflicts with something written h
|
||||
|
||||
## 1. What this is
|
||||
|
||||
A single-player, party-based fantasy RPG. Combat is deterministic JRPG-style. The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
|
||||
**Code of Conquest** — a single-player, party-based **tactical CRPG** in a gritty high-fantasy world ("The Margreave"). Turn-based, D&D-flavored combat is deterministic (§7/§10). The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
|
||||
|
||||
**Not multiplayer.** Not now, not later. Companions are code-controlled NPCs. Do not introduce networking, lockstep, or authority abstractions "in case."
|
||||
|
||||
**Target:** Desktop first (Windows/Linux). Mobile is a later consideration and should not constrain POC decisions.
|
||||
**Target:** Desktop first (Windows/Linux), **1920×1080 (16:9), Godot 4.7.** Mobile is a later consideration and should not constrain design.
|
||||
|
||||
**Current phase:** POC. The question the POC answers is *does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?* Everything else is scaffolding for that experiment.
|
||||
**Phase.** The original POC question — *does bounded AI dialogue feel alive, or a chatbot in a costume?* — is **answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0–M2). The project is now **building the game the mockups describe** (§16, `/mockups`): the tactical CRPG across the 11 screens of that UI bible. The AI's job is unchanged — narrate, voice NPCs, improvise — and it never owns state.
|
||||
|
||||
---
|
||||
|
||||
@@ -348,15 +348,16 @@ Single integer, −100..100, per companion. Adjusted by player choices weighed a
|
||||
|
||||
## 10. Combat
|
||||
|
||||
**Positionless, turn-based.** No grid. No flanking. No terrain.
|
||||
**Tactical, turn-based, gridless.** Turn order by initiative; an **action economy** (Action · Bonus · Move) per turn; abilities picked from a bar; enemies targeted by click. AC, HP, and initiative in the D&D-flavored idiom of the Combat HUD mockup (§16).
|
||||
|
||||
Combat is not the risk surface of this project. Ship the boring one; prove the interesting one.
|
||||
**No real movement grid.** The isometric board and tokens are *visual flavor* — there are no positional rules, no flanking, no terrain, no reachable-tile movement. Positioning was the risk surface the old charter warned against; it stays out. The tactics live in the **action economy and resources**, not the map.
|
||||
|
||||
- **Damage is deterministic.** No variance rolls on damage.
|
||||
- **The player controls only their own character.** The DM resolves the AI companions' and enemies' turns.
|
||||
- **Damage is deterministic (§7). OPEN:** the Combat HUD mock rolls damage from a range, but §7 forbids damage variance so Luck can *select* outcomes, not fudge numbers. Reconcile at the combat brainstorm — either fixed damage with Luck governing crit thresholds + borderline status checks, or a conscious, written change to §7. Do **not** silently adopt damage ranges.
|
||||
- **Initiative is rolled once at encounter start** and is relatively random. DEX modifies.
|
||||
- **The interesting decision is resource management** — MP, cooldowns, consumables. Not matchup-reading. Pick one axis for the POC.
|
||||
- **Resources are the interesting axis** — the action economy, cooldowns, consumables. Not matchup-reading.
|
||||
- **Luck influences crit thresholds and borderline status checks.** Nothing else. See §7.
|
||||
- **Combat flavor text is async and optional.** The numbers land immediately. The prose catches up. If the AI call fails, the fight continues with authored text.
|
||||
- **Combat flavor text is async and optional.** The numbers land immediately; the prose (a Narrator/DM call) catches up. If the AI call fails, the fight continues with authored text.
|
||||
|
||||
### Determinism and seeding
|
||||
|
||||
@@ -366,6 +367,8 @@ This costs nothing now and is nearly impossible to retrofit. It is the differenc
|
||||
|
||||
Log the seed *and* the full prompt with every AI call, for the same reason.
|
||||
|
||||
The Combat HUD mock's `Component` state machine (initiative order, action economy, ability resolution, victory/defeat) is the reference spec for the GDScript turn manager.
|
||||
|
||||
---
|
||||
|
||||
## 11. The canon log
|
||||
@@ -454,14 +457,14 @@ Roughly 1–3k tokens per call, dozens of calls per session.
|
||||
|
||||
## 15. Input model
|
||||
|
||||
**Hybrid.**
|
||||
**Button-driven actions; free-text dialogue.**
|
||||
|
||||
- **Menus:** combat, navigation, inventory.
|
||||
- **Free text:** dialogue, and explicit "what do you do?" moments.
|
||||
- **Buttons / menus:** combat abilities, world actions, navigation, inventory, shop. The player picks from **legal options code presents** — no input can produce an illegal or absurd action ("I triple-backflip off the wall and roundhouse him" never reaches the rules). Bulletproof by construction.
|
||||
- **Free text:** **NPC dialogue** — the aliveness surface, kept free-text (bounded by the move vocabulary §6, so wild input is harmless). Plus an optional **"describe your own action"** line on the main window (2a mock) for the tabletop feel.
|
||||
|
||||
**The boundary must be visible.** The player should always know which mode they are in. Ambiguity here reads as broken.
|
||||
**The boundary must be visible.** The player should always know which mode they are in.
|
||||
|
||||
Free text goes to the Adjudicator, which maps it to a legal action or rejects it with an in-world reason.
|
||||
**The Adjudicator (§5) is reshaped.** Its primary job is no longer parsing free text into a world action — code presents legal actions as buttons. It survives only as the interpreter for the *optional* free-text "describe your own action" escape hatch: free text → a legal action or an in-world rejection. Most turns never touch it.
|
||||
|
||||
---
|
||||
|
||||
@@ -470,7 +473,7 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
|
||||
- **Client:** Godot 4, **GDScript.** Prefer readability. Drop to C# only if GDScript genuinely cannot do the thing — and say why before you do.
|
||||
- **Proxy:** Python, **FastAPI.**
|
||||
- **Models:** Ollama (self-hosted, dev/staging) → Replicate (prod). Routed per-role, server-side.
|
||||
- **Repo:** greenfield. No existing code.
|
||||
- **Repo:** the engine (M0–M2) is built — canon-log contract + client engine, the server model-call pipeline, `/dm/narrate` + `/npc/speak`, bounded moves, considering-state. Build the game on top; follow the existing patterns.
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -481,9 +484,23 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
|
||||
/docs API-specific docs
|
||||
/prompts Role prompts (versioned, reviewed like code)
|
||||
/content Authored: quests, NPC knowledge lists, fallback text
|
||||
/mockups The UI/systems bible — 11 hi-fi HTML/CSS/JS screen refs + README
|
||||
/docs Cross-cutting docs — roadmap, ADRs, anything affecting both sides
|
||||
```
|
||||
|
||||
**`/mockups` is the UI + systems bible.** Eleven high-fidelity HTML/CSS/JS screen
|
||||
references (title, character creation, character sheet, inventory, world map,
|
||||
quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
|
||||
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
||||
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
||||
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
||||
system). Each `.dc.html`'s `Component` class is the **behavior spec** — the
|
||||
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
||||
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
||||
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
||||
art. When the mock and this charter disagree, the disagreement is a decision to
|
||||
make, not a default to follow — see §17's reconcile list.
|
||||
|
||||
`/api` is still the proxy described in §4 — auth, metering, prompt-hiding, model
|
||||
routing. It is named `api` for the client's convenience, not because it is a
|
||||
generic pass-through. Docs are split: side-specific docs live under
|
||||
@@ -494,33 +511,39 @@ Prompts are source code. They get reviewed. They get versioned. Changing a promp
|
||||
|
||||
---
|
||||
|
||||
## 17. POC scope
|
||||
## 17. Scope — the game (the POC is done)
|
||||
|
||||
**~30 minutes of play.**
|
||||
The aliveness POC is **answered** (M0–M2, live-proven). The target is now the
|
||||
tactical CRPG in the mockups (§16): the 11 screens and the systems they imply —
|
||||
character creation, the exploration shell + DM narration, tactical (gridless)
|
||||
combat, free-text NPC dialogue, inventory/equipment, character sheet, world-map
|
||||
travel, a shop economy, a quest log, title/pause.
|
||||
|
||||
- One town, **two NPCs** with authored knowledge lists
|
||||
- One dungeon: **three fights, one boss**
|
||||
- **Two companions:** Cadwyn, Brannoc
|
||||
- **Three playable classes:** Sellsword, Assassin, Priest
|
||||
- Luck fully wired: generation, drift, one shrine, one cursed item
|
||||
- Full proxy, full role split, full canon log
|
||||
- Authored fallback text for every AI surface
|
||||
Build it **screen by screen** against the mockups' shared visual system, on the
|
||||
proven engine (canon log, DM/NPC pipeline, bounded moves, Luck, character-creation
|
||||
model — all already aligned with the mocks). Each screen/system is its own
|
||||
**spec → plan → implementation** cycle; the roadmap sequences them toward a
|
||||
playable vertical slice first, then breadth.
|
||||
|
||||
### The POC's job
|
||||
**Unchanged and load-bearing:** §2 (code owns state, AI owns text) — a tactical
|
||||
CRPG has *far* more state, all code-owned; the AI stays narrator/DM/NPC-voice.
|
||||
Also §3 (tone), §7 (Luck), §9 (companions), the canon log (§11), the output
|
||||
contracts (§12), and Failure UX (§13).
|
||||
|
||||
Answer one question: **does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
|
||||
### To reconcile as we build (flagged, not silently decided)
|
||||
|
||||
Test that before building anything else. Every other system exists to make that test possible.
|
||||
- **Damage determinism** — §7/§10's deterministic damage vs the Combat HUD mock's
|
||||
damage *ranges* (settle at the combat brainstorm).
|
||||
- **Class names** — §8 uses Sellsword/Assassin/Priest/Scholar/Oathbound/Tracker/
|
||||
Berserker; the Character-Creation mock uses callings Sellsword/Cutpurse/
|
||||
Hedge-Mage/Bonesetter/Trapper (same archetypes + primary stats, different
|
||||
names). Settle at the character-creation brainstorm.
|
||||
|
||||
### Explicitly out of scope
|
||||
### Still out of scope (never / later)
|
||||
|
||||
- AI-generated story skeletons (v2 — the seeded story is authored for now)
|
||||
- Multiplayer (never)
|
||||
- Mobile
|
||||
- Credits, payments, auth
|
||||
- Companions leaving the party
|
||||
- Positional combat
|
||||
- More than three classes
|
||||
- Multiplayer (**never**) · mobile (later) · AI-generated story skeletons (v2 —
|
||||
the seeded story is authored for now) · credits/payments/auth (retrofit onto
|
||||
the proxy later, §4).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# any of these — keys live only in the proxy (charter §4).
|
||||
|
||||
# Where the proxy sends model calls in dev (Ollama on the homelab).
|
||||
# Running the proxy IN DOCKER with Ollama on THIS host? localhost points at the
|
||||
# container, not the host — use http://host.docker.internal:11434 instead
|
||||
# (docker-compose.yml maps that name via host-gateway). Bare-metal/venv: keep
|
||||
# localhost. Homelab Ollama: use its address.
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Prod model provider (charter §4). Leave blank in dev.
|
||||
@@ -9,3 +13,10 @@ REPLICATE_API_TOKEN=
|
||||
|
||||
# Port the proxy binds. compose / fly.io override this.
|
||||
PORT=8000
|
||||
|
||||
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
|
||||
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
|
||||
# Per-attempt model-call timeout (seconds).
|
||||
OLLAMA_TIMEOUT_SECONDS=30
|
||||
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
|
||||
CALL_LOG_PATH=
|
||||
|
||||
@@ -12,9 +12,12 @@ WORKDIR /app
|
||||
COPY api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App code + the schema contract (single source of truth in docs/schemas).
|
||||
# App code, the role prompts (§16 — prompts are source code), and the schema
|
||||
# contract (single source of truth in docs/schemas).
|
||||
COPY api/app ./app
|
||||
COPY api/prompts ./prompts
|
||||
COPY docs/schemas ./schemas
|
||||
COPY content ./content
|
||||
|
||||
# Run as non-root.
|
||||
RUN useradd --create-home --uid 1000 appuser
|
||||
|
||||
64
api/app/call_log.py
Normal file
64
api/app/call_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Structured JSON-lines logging of every model call (charter §10 — the seed and
|
||||
the full prompt logged with every call; §4 — free eval/replay infrastructure).
|
||||
|
||||
One JSON object per line: everything needed to replay the call (canon_log +
|
||||
messages + model + seed) against a candidate model. No secrets to redact — the
|
||||
client never sends keys, the canon log carries no PII, and the prompt is exactly
|
||||
what we want on record.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
def _default_write(line: str) -> None:
|
||||
"""Best-effort (charter §13): a good model call must never become a 500
|
||||
because the log write failed (disk full, bad permissions, a misconfigured
|
||||
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
|
||||
never stdout, since stdout may itself be the default sink and must stay
|
||||
clean JSON-lines for downstream tooling.
|
||||
"""
|
||||
try:
|
||||
path = config.call_log_path()
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
else:
|
||||
sys.stdout.write(line + "\n")
|
||||
except Exception as exc: # noqa: BLE001 - logging must never break a request
|
||||
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def record(
|
||||
*,
|
||||
role: str,
|
||||
model: str,
|
||||
options: dict,
|
||||
messages: list[dict],
|
||||
canon_log: dict,
|
||||
ok: bool,
|
||||
latency_ms: int,
|
||||
response: str | None = None,
|
||||
error: str | None = None,
|
||||
write: Callable[[str], None] = _default_write,
|
||||
) -> dict:
|
||||
rec: dict = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"role": role,
|
||||
"model": model,
|
||||
"options": options,
|
||||
"messages": messages,
|
||||
"canon_log": canon_log,
|
||||
"ok": ok,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
if ok:
|
||||
rec["response"] = response
|
||||
else:
|
||||
rec["error"] = error
|
||||
write(json.dumps(rec, ensure_ascii=False))
|
||||
return rec
|
||||
26
api/app/config.py
Normal file
26
api/app/config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Server config, read from the environment at call time so tests can override
|
||||
without reimport. Charter §4 — all of this lives server-side; the client never
|
||||
sees it.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def ollama_base_url() -> str:
|
||||
return os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
|
||||
|
||||
def ollama_timeout_seconds() -> float:
|
||||
return float(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
|
||||
def narrator_model() -> str:
|
||||
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
|
||||
|
||||
|
||||
def npc_model() -> str:
|
||||
return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")
|
||||
|
||||
|
||||
def call_log_path() -> str | None:
|
||||
return os.environ.get("CALL_LOG_PATH") or None
|
||||
@@ -8,6 +8,7 @@ scenes into play.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -48,3 +49,30 @@ def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
|
||||
if npc_id not in world["npcs"]:
|
||||
missing.append(f"npc:{npc_id}")
|
||||
return missing
|
||||
|
||||
|
||||
def _content_root() -> Path:
|
||||
"""Locate the authored content root. Honours CONTENT_ROOT, else walks up
|
||||
from this file for a dir containing world/ — repo-root/content in a local
|
||||
checkout, /app/content when bundled into the Docker image. Mirrors
|
||||
canon_log._schema_dir().
|
||||
"""
|
||||
env = os.environ.get("CONTENT_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / "content"
|
||||
if (candidate / "world").is_dir():
|
||||
return candidate
|
||||
raise RuntimeError("content root not found")
|
||||
|
||||
|
||||
def load_npc(npc_id: str) -> dict | None:
|
||||
"""Load one NPC's authored file (persona + knowledge + capabilities), or
|
||||
None if no file resolves. Server-only fields never travel to the client."""
|
||||
path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Skeleton: a health check plus the five role endpoints. Each role endpoint now
|
||||
validates the posted canon log against the contract (charter §11) before doing
|
||||
anything else — an invalid log is rejected with 422 and never reaches a prompt.
|
||||
Prompt routing, model selection, and logging land later.
|
||||
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
|
||||
call-log logging. The other four roles remain stubs until they get the same
|
||||
treatment.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
@@ -12,6 +14,9 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .canon_log import validate_canon_log
|
||||
from .narrate import run as narrate_run
|
||||
from .npc import UnknownNpc, run as npc_run
|
||||
from .ollama_client import ModelError
|
||||
|
||||
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
||||
|
||||
@@ -38,6 +43,22 @@ def valid_turn(req: TurnRequest) -> TurnRequest:
|
||||
return req
|
||||
|
||||
|
||||
class NpcSpeakRequest(BaseModel):
|
||||
canon_log: dict
|
||||
npc_id: str
|
||||
disposition: int
|
||||
available_moves: list[str]
|
||||
utterance: str
|
||||
|
||||
|
||||
def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest:
|
||||
"""Reject any /npc/speak request whose canon log breaks the contract."""
|
||||
errors = validate_canon_log(req.canon_log)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
|
||||
return req
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
"""Liveness probe for compose / fly.io."""
|
||||
@@ -46,13 +67,17 @@ def health() -> dict:
|
||||
|
||||
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||
# The client knows these paths and nothing about which model or prompt serves
|
||||
# them. Bodies are validated against the canon log contract; the AI half is a
|
||||
# stub until prompt routing lands.
|
||||
# them. Bodies are validated against the canon log contract. /dm/narrate is
|
||||
# fully wired (routing + model call + logging); the other four roles remain
|
||||
# stubs until prompt routing, model selection, and logging land for them too.
|
||||
|
||||
|
||||
@app.post("/dm/narrate")
|
||||
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
try:
|
||||
return {"prose": narrate_run(req.canon_log)}
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/dm/adjudicate")
|
||||
@@ -66,8 +91,18 @@ def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
|
||||
|
||||
@app.post("/npc/speak")
|
||||
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
|
||||
try:
|
||||
return {"prose": npc_run(
|
||||
canon_log=req.canon_log, npc_id=req.npc_id,
|
||||
disposition=req.disposition, available_moves=req.available_moves,
|
||||
utterance=req.utterance,
|
||||
)}
|
||||
except UnknownNpc as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]})
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/party/banter")
|
||||
|
||||
37
api/app/narrate.py
Normal file
37
api/app/narrate.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""The Narrator role service (charter §5). Renders the digest, routes the model,
|
||||
calls Ollama with one retry, logs the call (§10), and returns raw prose — tags
|
||||
left intact for the client's TagExtractor to harvest (§2/§6). Raises ModelError
|
||||
on failure for the handler to map to a 502.
|
||||
"""
|
||||
|
||||
import random
|
||||
from time import perf_counter
|
||||
|
||||
from . import call_log, ollama_client, prompts, routing
|
||||
|
||||
|
||||
def run(canon_log: dict) -> str:
|
||||
cfg = routing.for_role("narrator")
|
||||
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
|
||||
messages = [
|
||||
{"role": "system", "content": prompts.system_prompt("narrator")},
|
||||
{"role": "user", "content": prompts.render_digest(canon_log)},
|
||||
]
|
||||
|
||||
start = perf_counter()
|
||||
try:
|
||||
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
|
||||
except ollama_client.ModelError as exc:
|
||||
call_log.record(
|
||||
role="narrator", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=False,
|
||||
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
call_log.record(
|
||||
role="narrator", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=True,
|
||||
latency_ms=int((perf_counter() - start) * 1000), response=text,
|
||||
)
|
||||
return text
|
||||
59
api/app/npc.py
Normal file
59
api/app/npc.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona +
|
||||
knowledge, renders the digest, routes the model, calls Ollama with one retry,
|
||||
logs the call (§10), and returns raw prose — move/fact tags left intact for the
|
||||
client to extract, validate, and apply (§2/§6). Mirrors narrate.py.
|
||||
"""
|
||||
|
||||
import random
|
||||
from time import perf_counter
|
||||
|
||||
from . import call_log, ollama_client, prompts, routing
|
||||
from .content import load_npc
|
||||
|
||||
|
||||
class UnknownNpc(Exception):
|
||||
"""No authored content resolves for the requested npc_id."""
|
||||
|
||||
|
||||
def run(
|
||||
canon_log: dict,
|
||||
npc_id: str,
|
||||
disposition: int,
|
||||
available_moves: list[str],
|
||||
utterance: str,
|
||||
) -> str:
|
||||
npc = load_npc(npc_id)
|
||||
if npc is None:
|
||||
raise UnknownNpc(npc_id)
|
||||
|
||||
cfg = routing.for_role("npc")
|
||||
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
|
||||
messages = [
|
||||
{"role": "system", "content": prompts.system_prompt("npc")},
|
||||
{"role": "user", "content": prompts.render_npc_digest(
|
||||
canon_log,
|
||||
persona=npc.get("persona", ""),
|
||||
knowledge=npc.get("knowledge", []),
|
||||
disposition=disposition,
|
||||
available_moves=available_moves,
|
||||
utterance=utterance,
|
||||
)},
|
||||
]
|
||||
|
||||
start = perf_counter()
|
||||
try:
|
||||
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
|
||||
except ollama_client.ModelError as exc:
|
||||
call_log.record(
|
||||
role="npc", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=False,
|
||||
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
call_log.record(
|
||||
role="npc", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=True,
|
||||
latency_ms=int((perf_counter() - start) * 1000), response=text,
|
||||
)
|
||||
return text
|
||||
50
api/app/ollama_client.py
Normal file
50
api/app/ollama_client.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see
|
||||
success or ModelError (charter §12/§13 — one retry, then fail; the client
|
||||
degrades on any non-200). Built so a streaming mode is an additive path later.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
class ModelError(Exception):
|
||||
"""The model call failed after one retry (transport / timeout / non-2xx / empty)."""
|
||||
|
||||
|
||||
def chat(
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
options: dict,
|
||||
*,
|
||||
think: bool | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
) -> str:
|
||||
payload: dict = {"model": model, "messages": messages, "stream": False, "options": options}
|
||||
if think is not None:
|
||||
payload["think"] = think
|
||||
|
||||
owns = client is None
|
||||
http = client or httpx.Client(
|
||||
base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds()
|
||||
)
|
||||
try:
|
||||
last = "no attempt made"
|
||||
for _ in range(2): # one try + one retry (§12: never more than once)
|
||||
try:
|
||||
resp = http.post("/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
content = resp.json().get("message", {}).get("content", "")
|
||||
if content.strip():
|
||||
return content
|
||||
last = "empty response from model"
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
|
||||
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
|
||||
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
|
||||
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
raise ModelError(last)
|
||||
finally:
|
||||
if owns:
|
||||
http.close()
|
||||
111
api/app/prompts.py
Normal file
111
api/app/prompts.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Load a role's system prompt (§16 — prompts are source code) and render the
|
||||
canon log into a curated digest for the user message (charter §11).
|
||||
|
||||
The digest is a projection, not a dump: it surfaces narrative context and
|
||||
deliberately omits raw integers — companion dispositions render as names only,
|
||||
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def system_prompt(role: str) -> str:
|
||||
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
||||
# Everything below the first '---' line is the model-facing prompt; the
|
||||
# header above it is human/dev front-matter.
|
||||
parts = text.split("\n---\n", 1)
|
||||
body = parts[1] if len(parts) == 2 else text
|
||||
return body.strip()
|
||||
|
||||
|
||||
def render_digest(canon_log: dict) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
location = canon_log.get("location", {})
|
||||
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
||||
|
||||
events = canon_log.get("recent_events", [])
|
||||
if events:
|
||||
lines.append("Recent events:")
|
||||
lines += [f"- {e}" for e in events]
|
||||
|
||||
facts = canon_log.get("established_facts", [])
|
||||
if facts:
|
||||
lines.append("Established facts:")
|
||||
lines += [f"- {f}" for f in facts]
|
||||
|
||||
party = canon_log.get("party", [])
|
||||
if party:
|
||||
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
|
||||
|
||||
for quest in canon_log.get("active_quests", []):
|
||||
if quest.get("status") == "active":
|
||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||
|
||||
player = canon_log.get("player", {})
|
||||
descriptor = player.get("luck_descriptor")
|
||||
if descriptor:
|
||||
lines.append(f"Fortune: {descriptor}")
|
||||
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Describe the scene as it is now.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_npc_digest(
|
||||
canon_log: dict,
|
||||
*,
|
||||
persona: str,
|
||||
knowledge: list[str],
|
||||
disposition: int,
|
||||
available_moves: list[str],
|
||||
utterance: str,
|
||||
) -> str:
|
||||
"""User-message digest for the NPC role (§6). Unlike the narrator digest,
|
||||
this deliberately carries the disposition integer (§6 permits it; §7 hides
|
||||
Luck, not disposition) and the closed move vocabulary the NPC may use."""
|
||||
lines: list[str] = []
|
||||
lines.append(f"You are: {persona}")
|
||||
lines.append(
|
||||
f"Your disposition toward the player: {disposition} "
|
||||
f"(-100 hostile, 0 neutral, 100 devoted)."
|
||||
)
|
||||
|
||||
if knowledge:
|
||||
lines.append("")
|
||||
lines.append("What you know (this is ALL you know — do not invent beyond it):")
|
||||
lines += [f"- {k}" for k in knowledge]
|
||||
|
||||
lines.append("")
|
||||
lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
|
||||
lines += [f"- {m}" for m in available_moves]
|
||||
|
||||
location = canon_log.get("location", {})
|
||||
lines.append("")
|
||||
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
||||
|
||||
events = canon_log.get("recent_events", [])
|
||||
if events:
|
||||
lines.append("Recent events:")
|
||||
lines += [f"- {e}" for e in events]
|
||||
|
||||
facts = canon_log.get("established_facts", [])
|
||||
if facts:
|
||||
lines.append("Established facts:")
|
||||
lines += [f"- {f}" for f in facts]
|
||||
|
||||
for quest in canon_log.get("active_quests", []):
|
||||
if quest.get("status") == "active":
|
||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||
|
||||
player = canon_log.get("player", {})
|
||||
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f'The player says to you: "{utterance}"')
|
||||
return "\n".join(lines)
|
||||
31
api/app/routing.py
Normal file
31
api/app/routing.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Role → model routing (charter §4/§5). Model choice is config, not law — a
|
||||
deploy, not a client patch. Read at call time so an env override needs no reimport.
|
||||
The provider abstraction (Ollama → Replicate) slots in here later (YAGNI now).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleConfig:
|
||||
model: str
|
||||
options: dict
|
||||
think: bool | None = None # Ollama top-level `think`; None omits it
|
||||
|
||||
|
||||
def for_role(role: str) -> RoleConfig:
|
||||
if role == "narrator":
|
||||
return RoleConfig(
|
||||
model=config.narrator_model(),
|
||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
|
||||
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
|
||||
)
|
||||
if role == "npc":
|
||||
return RoleConfig(
|
||||
model=config.npc_model(),
|
||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 320},
|
||||
think=False,
|
||||
)
|
||||
raise KeyError(f"no routing for role: {role}")
|
||||
21
api/conftest.py
Normal file
21
api/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Gate `@pytest.mark.live` tests (they hit a real Ollama) behind --run-live so
|
||||
the default suite stays hermetic. Run the live layer with: pytest --run-live
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--run-live", action="store_true", default=False,
|
||||
help="run tests marked @pytest.mark.live (require a real Ollama)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if config.getoption("--run-live"):
|
||||
return
|
||||
skip_live = pytest.mark.skip(reason="needs --run-live (real Ollama)")
|
||||
for item in items:
|
||||
if "live" in item.keywords:
|
||||
item.add_marker(skip_live)
|
||||
@@ -2,11 +2,24 @@
|
||||
|
||||
**Role:** Describe scenes, outcomes, transitions. **Quality matters most — use the good model** (charter §5).
|
||||
|
||||
**Input:** scene state + canon log (§11).
|
||||
**Input:** scene state + canon log (§11), rendered as a labeled digest in the user message.
|
||||
**Output:** prose with tags. Never invent a proper noun without emitting `[FACT: ...]` so code can harvest it into `established_facts` (§11).
|
||||
|
||||
**Voice:** dry, gritty not grim, plays the world straight. Never winks. Nothing is "hilariously" anything (§3).
|
||||
|
||||
---
|
||||
|
||||
<!-- Prompt body TBD. This file is source code — version and review changes. -->
|
||||
You are the Narrator of a gritty high-fantasy RPG. You describe the world; you never control the player.
|
||||
|
||||
Given the current scene state below, describe the scene as it is right now, in the second person ("you"), present tense. Cover what the player sees, hears, and smells, and what the people present are doing. Three to five sentences, then stop.
|
||||
|
||||
Voice:
|
||||
- Dry and economical. Gritty, not grim. Play the world straight — never wink, never point out that a thing is funny. Nothing is "hilariously" or "comically" anything.
|
||||
- Profanity and ugliness are allowed when the moment earns them, never as decoration.
|
||||
|
||||
Hard rules:
|
||||
- Do not introduce a proper noun — a person's name, a place name, a named object — unless it already appears below. If you must introduce one, append a tag on its own: `[FACT: <the new fact as a short clause>]`, one per new proper noun, so the game can record it.
|
||||
- Never contradict anything under "Established facts".
|
||||
- Never state a number, a statistic, a hit-point total, or a Luck value. "Fortune" is a feeling, never a figure.
|
||||
- Never decide, narrate, or assume the player's choices, words, or actions. Describe the world and the other characters; leave the player's next move to the player.
|
||||
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.
|
||||
|
||||
@@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops
|
||||
|
||||
---
|
||||
|
||||
<!-- Prompt body TBD. This file is source code — version and review changes. -->
|
||||
You voice a single character in a gritty high-fantasy world (the tone is Batman,
|
||||
not Warhammer and not Care Bears). Play the character straight. Never wink at the
|
||||
player; never describe anything as funny. Profanity is allowed when earned.
|
||||
|
||||
You are given: who you are, your disposition toward the player, the complete list
|
||||
of what you know, the moves you may perform this turn, the scene's canon, and
|
||||
what the player just said.
|
||||
|
||||
Two hard rules:
|
||||
|
||||
1. **Knowledge is a closed list.** You know ONLY what "What you know" tells you.
|
||||
Do not invent people, places, quests, or facts beyond it. If you must name
|
||||
something new that becomes true in the world, emit it as `[FACT: ...]` so the
|
||||
game can record it — but prefer to stay within what you know.
|
||||
|
||||
2. **Moves are a closed vocabulary.** You may *say* anything in character, but you
|
||||
may only *do* something by emitting an inline tag, and only from the moves
|
||||
listed for this turn. Use exactly this form:
|
||||
|
||||
```
|
||||
[MOVE: reveal(varrell_twins)]
|
||||
[MOVE: offer_quest(find_the_ledger)]
|
||||
[MOVE: adjust_disposition(+5)]
|
||||
[MOVE: refuse()]
|
||||
[MOVE: end_conversation()]
|
||||
[MOVE: become_hostile()]
|
||||
```
|
||||
|
||||
Emit zero or more move tags, placed inline where they happen. If a move is not
|
||||
in your list for this turn, do not emit it — the game will drop it anyway.
|
||||
Let your disposition colour your tone: low disposition is curt and suspicious,
|
||||
high disposition is warm and forthcoming.
|
||||
|
||||
Return prose in your character's voice, with any move/fact tags inline. Do not
|
||||
explain the tags or break character to describe them.
|
||||
|
||||
@@ -3,3 +3,5 @@ pythonpath = .
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore:Using `httpx` with `starlette.testclient` is deprecated
|
||||
markers =
|
||||
live: exercises a real Ollama; skipped unless --run-live
|
||||
|
||||
56
api/tests/test_call_log.py
Normal file
56
api/tests/test_call_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
|
||||
from app.call_log import record
|
||||
|
||||
|
||||
def test_success_record_shape():
|
||||
captured = []
|
||||
rec = record(
|
||||
role="narrator", model="qwen3.5:latest", options={"seed": 7},
|
||||
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
|
||||
ok=True, latency_ms=120, response="prose", write=captured.append,
|
||||
)
|
||||
assert len(captured) == 1
|
||||
parsed = json.loads(captured[0])
|
||||
assert parsed["ok"] is True
|
||||
assert parsed["response"] == "prose"
|
||||
assert parsed["options"]["seed"] == 7
|
||||
assert parsed["role"] == "narrator"
|
||||
assert parsed["canon_log"] == {"a": 1}
|
||||
assert "error" not in parsed
|
||||
assert "ts" in parsed
|
||||
assert rec["response"] == "prose"
|
||||
|
||||
|
||||
def test_failure_record_shape():
|
||||
captured = []
|
||||
record(
|
||||
role="narrator", model="m", options={}, messages=[], canon_log={},
|
||||
ok=False, latency_ms=5, error="ModelError: down", write=captured.append,
|
||||
)
|
||||
parsed = json.loads(captured[0])
|
||||
assert parsed["ok"] is False
|
||||
assert parsed["error"] == "ModelError: down"
|
||||
assert "response" not in parsed
|
||||
|
||||
|
||||
def test_default_write_failure_does_not_propagate(monkeypatch, capsys):
|
||||
"""§13 — a good narration must never become a 500 because the log write
|
||||
failed. Point CALL_LOG_PATH at a path whose parent directory does not
|
||||
exist, so the default sink's `open()` raises. record() must swallow it,
|
||||
report to stderr (never stdout — stdout may be the log sink itself), and
|
||||
still return the record.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"CALL_LOG_PATH", "/nonexistent-dir-for-test/does/not/exist.log"
|
||||
)
|
||||
rec = record(
|
||||
role="narrator", model="qwen3.5:latest", options={"seed": 7},
|
||||
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
|
||||
ok=True, latency_ms=120, response="prose",
|
||||
)
|
||||
assert rec["response"] == "prose"
|
||||
assert rec["ok"] is True
|
||||
out, err = capsys.readouterr()
|
||||
assert out == ""
|
||||
assert err != ""
|
||||
41
api/tests/test_content_npc.py
Normal file
41
api/tests/test_content_npc.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
|
||||
import app.content as content
|
||||
|
||||
|
||||
def test_load_npc_returns_parsed_file(tmp_path, monkeypatch):
|
||||
root = tmp_path / "content"
|
||||
(root / "world" / "npcs").mkdir(parents=True)
|
||||
(root / "world" / "npcs" / "fenn.json").write_text(
|
||||
json.dumps({"id": "fenn", "name": "Fenn", "persona": "clerk",
|
||||
"knowledge": ["a"], "capabilities": {}})
|
||||
)
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(root))
|
||||
npc = content.load_npc("fenn")
|
||||
assert npc["name"] == "Fenn"
|
||||
assert npc["knowledge"] == ["a"]
|
||||
|
||||
|
||||
def test_load_npc_unknown_returns_none(tmp_path, monkeypatch):
|
||||
(tmp_path / "content" / "world").mkdir(parents=True)
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path / "content"))
|
||||
assert content.load_npc("nobody") is None
|
||||
|
||||
|
||||
def test_content_root_env_override(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path))
|
||||
assert content._content_root() == tmp_path
|
||||
|
||||
|
||||
def test_fenn_resolves_from_repo_content():
|
||||
# No CONTENT_ROOT env — exercises the walk-up resolver against real content.
|
||||
npc = content.load_npc("fenn")
|
||||
assert npc is not None
|
||||
assert npc["capabilities"]["offerable_quests"] == ["find_the_ledger"]
|
||||
assert npc["knowledge"] # non-empty authored knowledge list
|
||||
|
||||
|
||||
def test_npc_prompt_body_is_authored():
|
||||
from app import prompts
|
||||
body = prompts.system_prompt("npc")
|
||||
assert "MOVE" in body and len(body) > 200
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.narrate as narrate
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -14,13 +16,30 @@ ROLE_ENDPOINTS = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_narrate_model_call(monkeypatch):
|
||||
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
|
||||
# canon-log validation across ALL role endpoints, not model behavior, so
|
||||
# stub the model boundary — keeps the default suite hermetic (no network).
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
|
||||
|
||||
|
||||
def test_health_ok():
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
||||
for path in ROLE_ENDPOINTS:
|
||||
r = client.post(path, json={"canon_log": VALID_LOG})
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": VALID_LOG, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": VALID_LOG}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
|
||||
|
||||
|
||||
@@ -28,7 +47,15 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log():
|
||||
bad = json.loads(json.dumps(VALID_LOG))
|
||||
bad["player"]["luck"] = 5 # §7 leak
|
||||
for path in ROLE_ENDPOINTS:
|
||||
r = client.post(path, json={"canon_log": bad})
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": bad, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": bad}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 422, f"{path} accepted an invalid log"
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
56
api/tests/test_live_smoke.py
Normal file
56
api/tests/test_live_smoke.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import app.narrate as narrate
|
||||
import app.npc as npc
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_narrator_returns_real_prose():
|
||||
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the wire end-to-end:
|
||||
prompt assembly → model → non-empty prose. Not asserted: tag content
|
||||
(nondeterministic). Run with: pytest --run-live
|
||||
"""
|
||||
text = narrate.run(VALID)
|
||||
assert isinstance(text, str)
|
||||
assert len(text.strip()) > 40 # real prose, not a blip
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_live_npc_speak_fenn():
|
||||
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the /npc/speak wire
|
||||
end-to-end against the authored "fenn" NPC: persona + knowledge digest →
|
||||
model → non-empty in-voice prose. Not asserted: tag content
|
||||
(nondeterministic). Run with: pytest --run-live
|
||||
"""
|
||||
log = {
|
||||
"player": {
|
||||
"name": "Aldric",
|
||||
"class_id": "sellsword",
|
||||
"luck_descriptor": "the dice are kind",
|
||||
},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [],
|
||||
"recent_events": [],
|
||||
"established_facts": [],
|
||||
"active_quests": [],
|
||||
"humiliations": [],
|
||||
}
|
||||
available = [
|
||||
"offer_quest(find_the_ledger)",
|
||||
"reveal(varrell_twins)",
|
||||
"reveal(fenns_debt)",
|
||||
"adjust_disposition",
|
||||
"refuse",
|
||||
"end_conversation",
|
||||
"become_hostile",
|
||||
]
|
||||
prose = npc.run(log, "fenn", 0, available, "I heard you lost something. What happened?")
|
||||
assert isinstance(prose, str)
|
||||
assert len(prose.strip()) > 40 # real prose, not a blip
|
||||
# No Luck numbers/mechanics leaked into NPC dialogue (§7).
|
||||
assert "luck" not in prose.lower()
|
||||
43
api/tests/test_narrate.py
Normal file
43
api/tests/test_narrate.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import app.narrate as narrate
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_run_returns_prose_and_logs_success(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_chat(model, messages, options, *, think=None):
|
||||
seen.update(model=model, messages=messages, options=options, think=think)
|
||||
return "You stand on the wharf. [FACT: the gulls have gone quiet]"
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", fake_chat)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
out = narrate.run(VALID)
|
||||
assert out.startswith("You stand on the wharf.")
|
||||
assert seen["model"] == "qwen3.5:latest"
|
||||
assert seen["think"] is False
|
||||
assert seen["messages"][0]["role"] == "system"
|
||||
assert seen["messages"][1]["role"] == "user"
|
||||
assert "seed" in seen["options"]
|
||||
assert logs and logs[0]["ok"] is True and logs[0]["response"] == out
|
||||
|
||||
|
||||
def test_run_logs_failure_and_reraises(monkeypatch):
|
||||
def boom(*args, **kwargs):
|
||||
raise ModelError("upstream down")
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
narrate.run(VALID)
|
||||
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]
|
||||
39
api/tests/test_narrate_endpoint.py
Normal file
39
api/tests/test_narrate_endpoint.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
import app.narrate as narrate
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
client = TestClient(main.app)
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_valid_log_returns_prose(monkeypatch):
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "You stand on the wharf.")
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None) # keep test output pristine
|
||||
r = client.post("/dm/narrate", json={"canon_log": VALID})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"prose": "You stand on the wharf."}
|
||||
|
||||
|
||||
def test_model_error_maps_to_502(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise ModelError("upstream down")
|
||||
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
|
||||
r = client.post("/dm/narrate", json={"canon_log": VALID})
|
||||
assert r.status_code == 502
|
||||
assert "model_error" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_log_still_422(monkeypatch):
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "x")
|
||||
bad = json.loads(json.dumps(VALID))
|
||||
bad["player"]["luck"] = 5 # §7 leak — schema rejects
|
||||
r = client.post("/dm/narrate", json={"canon_log": bad})
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
55
api/tests/test_npc.py
Normal file
55
api/tests/test_npc.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
|
||||
import app.npc as npc
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
LOG = {
|
||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": [], "established_facts": [],
|
||||
"active_quests": [], "humiliations": [],
|
||||
}
|
||||
FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.",
|
||||
"knowledge": ["The ledger is gone."], "capabilities": {}}
|
||||
|
||||
|
||||
def test_run_returns_prose_and_logs(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_chat(model, messages, options, *, think=None):
|
||||
seen.update(messages=messages, think=think)
|
||||
return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]'
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
|
||||
monkeypatch.setattr(npc.ollama_client, "chat", fake_chat)
|
||||
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?")
|
||||
assert out.startswith("Aye.")
|
||||
# persona + knowledge reached the model; utterance is present
|
||||
user_msg = seen["messages"][1]["content"]
|
||||
assert "A clerk." in user_msg and "The ledger is gone." in user_msg
|
||||
assert "What happened?" in user_msg
|
||||
assert seen["think"] is False
|
||||
assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc"
|
||||
|
||||
|
||||
def test_run_unknown_npc_raises(monkeypatch):
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: None)
|
||||
with pytest.raises(npc.UnknownNpc):
|
||||
npc.run(LOG, "nobody", 0, [], "hi")
|
||||
|
||||
|
||||
def test_run_logs_failure_and_reraises(monkeypatch):
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise ModelError("down")
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(npc.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
|
||||
with pytest.raises(ModelError):
|
||||
npc.run(LOG, "fenn", 0, [], "hi")
|
||||
assert logs and logs[0]["ok"] is False
|
||||
59
api/tests/test_npc_endpoint.py
Normal file
59
api/tests/test_npc_endpoint.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
import app.npc as npc
|
||||
|
||||
client = TestClient(main.app)
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def _body(**over):
|
||||
b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0,
|
||||
"available_moves": ["refuse"], "utterance": "hello"}
|
||||
b.update(over)
|
||||
return b
|
||||
|
||||
|
||||
def test_valid_request_returns_prose(monkeypatch):
|
||||
monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.")
|
||||
r = client.post("/npc/speak", json=_body())
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"prose": "Aye, well met."}
|
||||
|
||||
|
||||
def test_unknown_npc_maps_to_422(monkeypatch):
|
||||
def boom(**k):
|
||||
raise npc.UnknownNpc("nobody")
|
||||
monkeypatch.setattr(main, "npc_run", boom)
|
||||
r = client.post("/npc/speak", json=_body(npc_id="nobody"))
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_model_error_maps_to_502(monkeypatch):
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
def boom(**k):
|
||||
raise ModelError("down")
|
||||
monkeypatch.setattr(main, "npc_run", boom)
|
||||
r = client.post("/npc/speak", json=_body())
|
||||
assert r.status_code == 502
|
||||
assert "model_error" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_log_still_422():
|
||||
bad = json.loads(json.dumps(VALID))
|
||||
bad["player"]["luck"] = 5 # §7 leak — schema rejects
|
||||
r = client.post("/npc/speak", json=_body(canon_log=bad))
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_missing_field_is_422():
|
||||
b = _body()
|
||||
del b["utterance"]
|
||||
r = client.post("/npc/speak", json=b)
|
||||
assert r.status_code == 422
|
||||
95
api/tests/test_ollama_client.py
Normal file
95
api/tests/test_ollama_client.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.ollama_client import ModelError, chat
|
||||
|
||||
|
||||
def _client(handler):
|
||||
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://ollama")
|
||||
|
||||
|
||||
def test_success_returns_content_and_sends_payload():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"message": {"content": "You stand on the wharf."}})
|
||||
|
||||
out = chat("qwen3.5:latest", [{"role": "user", "content": "hi"}], {"seed": 7},
|
||||
think=False, client=_client(handler))
|
||||
assert out == "You stand on the wharf."
|
||||
assert seen["body"]["model"] == "qwen3.5:latest"
|
||||
assert seen["body"]["stream"] is False
|
||||
assert seen["body"]["options"]["seed"] == 7
|
||||
assert seen["body"]["think"] is False
|
||||
|
||||
|
||||
def test_think_omitted_when_none():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"message": {"content": "ok"}})
|
||||
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
assert "think" not in seen["body"]
|
||||
|
||||
|
||||
def test_retries_once_then_succeeds():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise httpx.ConnectError("boom")
|
||||
return httpx.Response(200, json={"message": {"content": "recovered"}})
|
||||
|
||||
out = chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
assert out == "recovered"
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_two_transport_failures_raise_modelerror():
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_timeout_exhausted_raises_modelerror():
|
||||
def handler(request):
|
||||
raise httpx.TimeoutException("slow")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_non_2xx_retried_then_modelerror():
|
||||
def handler(request):
|
||||
return httpx.Response(500, json={"error": "nope"})
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_empty_content_retried_then_modelerror():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"message": {"content": " "}})
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_non_json_200_retried_then_modelerror():
|
||||
"""A 200 response with a non-JSON body (e.g. a proxy error page) must
|
||||
degrade to ModelError after the one retry, not escape as a bare
|
||||
JSONDecodeError/ValueError (charter §12/§13).
|
||||
"""
|
||||
def handler(request):
|
||||
return httpx.Response(200, text="<html>not json</html>")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
65
api/tests/test_prompts.py
Normal file
65
api/tests/test_prompts.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.prompts import render_digest, system_prompt
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_system_prompt_strips_the_header():
|
||||
body = system_prompt("narrator")
|
||||
assert isinstance(body, str) and body.strip() != ""
|
||||
# The human-facing markdown title above the '---' is not part of the prompt.
|
||||
assert "# Narrator prompt" not in body
|
||||
|
||||
|
||||
def test_digest_has_the_narrative_context():
|
||||
d = render_digest(VALID)
|
||||
assert "Location: the Greywater docks" in d
|
||||
assert "Arrived at Greywater by barge before dawn" in d
|
||||
assert "Established facts:" in d
|
||||
assert "the harbourmaster is named Oda Fenn" in d
|
||||
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
||||
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
||||
assert "Fortune: Fortune spits on you" in d
|
||||
assert "Player: Aldric, a sellsword" in d
|
||||
assert d.rstrip().endswith("Describe the scene as it is now.")
|
||||
|
||||
|
||||
def test_digest_omits_raw_numbers_and_ids():
|
||||
d = render_digest(VALID)
|
||||
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
|
||||
assert "disposition" not in d
|
||||
assert "40" not in d and "15" not in d # the deserter dispositions
|
||||
assert "brannoc_thane" not in d # ids stay out; names only
|
||||
assert "schema_version" not in d
|
||||
|
||||
|
||||
def test_narrator_body_is_authored():
|
||||
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
|
||||
assert "TBD" not in body
|
||||
assert "[FACT:" in body # the tag instruction is present
|
||||
assert "second person" in body # the core task is present
|
||||
|
||||
|
||||
def test_render_npc_digest_includes_all_sections():
|
||||
from app import prompts
|
||||
log = {
|
||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": ["a gull screamed"],
|
||||
"established_facts": ["the eastern bridge is out"],
|
||||
"active_quests": [], "humiliations": [],
|
||||
}
|
||||
out = prompts.render_npc_digest(
|
||||
log, persona="A harried clerk.", knowledge=["The ledger is gone."],
|
||||
disposition=-10, available_moves=["reveal(varrell_twins)", "refuse"],
|
||||
utterance="What happened here?",
|
||||
)
|
||||
assert "A harried clerk." in out
|
||||
assert "-10" in out
|
||||
assert "The ledger is gone." in out
|
||||
assert "reveal(varrell_twins)" in out
|
||||
assert "Greywater Docks" in out
|
||||
assert "the eastern bridge is out" in out
|
||||
assert out.rstrip().endswith('What happened here?"') # utterance last
|
||||
29
api/tests/test_routing.py
Normal file
29
api/tests/test_routing.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from app.routing import for_role
|
||||
|
||||
|
||||
def test_narrator_defaults():
|
||||
cfg = for_role("narrator")
|
||||
assert cfg.model == "qwen3.5:latest"
|
||||
assert cfg.think is False
|
||||
assert cfg.options["num_predict"] == 300
|
||||
assert cfg.options["temperature"] == 0.8
|
||||
|
||||
|
||||
def test_env_overrides_model(monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_NARRATOR_MODEL", "llama3.1:latest")
|
||||
assert for_role("narrator").model == "llama3.1:latest"
|
||||
|
||||
|
||||
def test_unknown_role_raises():
|
||||
with pytest.raises(KeyError):
|
||||
for_role("wizard")
|
||||
|
||||
|
||||
def test_npc_role_routes_to_npc_model(monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
|
||||
cfg = for_role("npc")
|
||||
assert cfg.model == "some-npc-model"
|
||||
assert cfg.think is False
|
||||
assert "num_predict" in cfg.options
|
||||
9
client/content/considering.json
Normal file
9
client/content/considering.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"considering": [
|
||||
"The Master considers your words.",
|
||||
"Somewhere, dice are gathered.",
|
||||
"The tale weighs its next turn.",
|
||||
"The dark shifts, thinking.",
|
||||
"A page is turned, unhurried."
|
||||
]
|
||||
}
|
||||
5
client/content/fallback/narrator.json
Normal file
5
client/content/fallback/narrator.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"narrator": [
|
||||
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
|
||||
]
|
||||
}
|
||||
5
client/content/fallback/npc.json
Normal file
5
client/content/fallback/npc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"npc": [
|
||||
"The figure looks you over, says nothing you can use, and turns back to their work."
|
||||
]
|
||||
}
|
||||
@@ -11,3 +11,21 @@ Put here:
|
||||
- HTTP client + fallback-degradation handling (§13)
|
||||
|
||||
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
||||
|
||||
## Narrate live smoke (M2 client HTTP loop)
|
||||
|
||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||
loop. It is not the game UI.
|
||||
|
||||
**Prereqs:** the proxy running and Ollama reachable.
|
||||
|
||||
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
|
||||
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
|
||||
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
|
||||
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
|
||||
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
|
||||
`facts harvested: N` with N ≥ 0.
|
||||
4. **Degraded path:** stop the proxy and click again. Expect the authored
|
||||
fallback line and `(degraded)` in the status — never an error.
|
||||
|
||||
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.
|
||||
|
||||
10
client/scenes/narrate_harness.tscn
Normal file
10
client/scenes/narrate_harness.tscn
Normal file
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NarrateHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
10
client/scenes/npc_harness.tscn
Normal file
10
client/scenes/npc_harness.tscn
Normal file
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/npc_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NpcHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
@@ -58,6 +58,20 @@ func adjust_disposition(id: String, delta: int) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func has_quest(id: String) -> bool:
|
||||
for q in active_quests:
|
||||
if q.id == id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func add_quest(id: String, name: String, objective: String) -> bool:
|
||||
if has_quest(id):
|
||||
return false
|
||||
active_quests.append(Quest.new(id, name, "active", objective))
|
||||
return true
|
||||
|
||||
|
||||
func set_quest_status(id: String, status: String) -> bool:
|
||||
for q in active_quests:
|
||||
if q.id == id:
|
||||
|
||||
72
client/scripts/harness/narrate_harness.gd
Normal file
72
client/scripts/harness/narrate_harness.gd
Normal file
@@ -0,0 +1,72 @@
|
||||
extends Control
|
||||
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
|
||||
## transport to a button and a label. NOT the game UI (a later wireframe owns
|
||||
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
|
||||
## smoke steps in client/docs/README.md.
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
|
||||
var _service: DmService
|
||||
var _log: CanonLog
|
||||
var _label: RichTextLabel
|
||||
var _status: Label
|
||||
var _button: Button
|
||||
var _indicator: ConsideringIndicator
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
_log = _build_scene_log()
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_button = Button.new()
|
||||
_button.text = "Narrate this scene"
|
||||
_button.pressed.connect(_on_narrate_pressed)
|
||||
vbox.add_child(_button)
|
||||
|
||||
_label = RichTextLabel.new()
|
||||
_label.fit_content = true
|
||||
_label.custom_minimum_size = Vector2(640, 320)
|
||||
vbox.add_child(_label)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
|
||||
|
||||
func _on_narrate_pressed() -> void:
|
||||
_button.disabled = true
|
||||
_indicator.start(_status)
|
||||
var r := await _service.narrate(_log)
|
||||
_indicator.stop()
|
||||
_label.text = r.display_text
|
||||
for f in r.facts:
|
||||
_log.add_fact(f)
|
||||
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
|
||||
_button.disabled = false
|
||||
|
||||
|
||||
func _build_scene_log() -> CanonLog:
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
log.set_location("greywater_docks", "the Greywater docks")
|
||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||
log.push_event("Arrived at Greywater by barge before dawn")
|
||||
log.push_event("Brannoc recognised the harbourmaster and went quiet")
|
||||
log.add_fact("the eastern bridge out of Greywater is washed out")
|
||||
log.add_fact("the harbourmaster is named Oda Fenn")
|
||||
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
|
||||
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
|
||||
return log
|
||||
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxh7t3rdoqgq5
|
||||
107
client/scripts/harness/npc_harness.gd
Normal file
107
client/scripts/harness/npc_harness.gd
Normal file
@@ -0,0 +1,107 @@
|
||||
extends Control
|
||||
## Throwaway harness — the bounded-NPC loop on screen, not shipped UI. Type an
|
||||
## utterance to Fenn, see his voiced prose, watch moves apply and disposition
|
||||
## change, until he ends the conversation. Mirrors narrate_harness. Run this
|
||||
## scene directly (F6) with the proxy + Ollama up.
|
||||
|
||||
const NpcService = preload("res://scripts/net/npc_service.gd")
|
||||
const NpcContent = preload("res://scripts/npc/npc_content.gd")
|
||||
const MoveApplier = preload("res://scripts/npc/move_applier.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
|
||||
const NPC_ID := "fenn"
|
||||
|
||||
var _service: NpcService
|
||||
var _content: ContentDB
|
||||
var _game_state: GameState
|
||||
var _canon_log: CanonLog
|
||||
var _output: RichTextLabel
|
||||
var _entry: LineEdit
|
||||
var _speak_btn: Button
|
||||
var _status: Label
|
||||
var _indicator: ConsideringIndicator
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = NpcService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
|
||||
_content = ContentDB.new()
|
||||
_content.load_from(ContentDB.default_content_root())
|
||||
|
||||
_game_state = GameState.new()
|
||||
_canon_log = CanonLog.new()
|
||||
_canon_log.set_location("greywater_docks", "Greywater Docks")
|
||||
# Build the player through LogPlayer so luck_descriptor is non-empty — the
|
||||
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
|
||||
# the digest surfaces to the NPC. An empty string here 422s the request.
|
||||
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
|
||||
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
|
||||
# can offer_quest it during the conversation.
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_output = RichTextLabel.new()
|
||||
_output.bbcode_enabled = true
|
||||
_output.fit_content = true
|
||||
_output.custom_minimum_size = Vector2(600, 400)
|
||||
vbox.add_child(_output)
|
||||
|
||||
_entry = LineEdit.new()
|
||||
_entry.placeholder_text = "Say something to Fenn…"
|
||||
vbox.add_child(_entry)
|
||||
|
||||
_speak_btn = Button.new()
|
||||
_speak_btn.text = "Speak"
|
||||
_speak_btn.pressed.connect(_on_speak)
|
||||
vbox.add_child(_speak_btn)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
|
||||
|
||||
func _on_speak() -> void:
|
||||
var utterance := _entry.text
|
||||
if utterance.strip_edges() == "":
|
||||
return
|
||||
_entry.text = ""
|
||||
_speak_btn.disabled = true
|
||||
_append("[b]You:[/b] %s" % utterance)
|
||||
|
||||
var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0))
|
||||
var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log)
|
||||
|
||||
_indicator.start(_status)
|
||||
var r: NpcResult = await _service.speak(
|
||||
NPC_ID, utterance, _canon_log, disposition, available)
|
||||
_indicator.stop()
|
||||
|
||||
# Apply state explicitly (§2) — the service applied nothing.
|
||||
MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID)
|
||||
for f in r.facts:
|
||||
_canon_log.add_fact(f)
|
||||
|
||||
_append("[b]Fenn:[/b] %s" % r.display_text)
|
||||
_append("[i]moves: %d applied, %d dropped · disposition %d%s[/i]" % [
|
||||
r.valid_moves.size(), r.dropped_moves.size(),
|
||||
int(_game_state.npc_dispositions.get(NPC_ID, 0)),
|
||||
" (degraded)" if r.degraded else ""])
|
||||
|
||||
if r.ends_conversation:
|
||||
_append("[i]— Fenn ends the conversation. —[/i]")
|
||||
else:
|
||||
_speak_btn.disabled = false
|
||||
|
||||
|
||||
func _append(bbcode: String) -> void:
|
||||
_output.append_text(bbcode + "\n\n")
|
||||
1
client/scripts/harness/npc_harness.gd.uid
Normal file
1
client/scripts/harness/npc_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cbmr6caohwsxb
|
||||
25
client/scripts/net/dm_response.gd
Normal file
25
client/scripts/net/dm_response.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
class_name DmResponse
|
||||
extends RefCounted
|
||||
## The raw outcome of one HTTP attempt, independent of any game meaning.
|
||||
## `status` is 0 when the transport never reached the server; `body` is the
|
||||
## parsed JSON dict, or null when the body was absent or not JSON.
|
||||
|
||||
var status: int
|
||||
var body # Variant: parsed JSON (Dictionary) or null
|
||||
var transport_ok: bool
|
||||
var error: String
|
||||
|
||||
|
||||
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
|
||||
status = p_status
|
||||
body = p_body
|
||||
transport_ok = p_transport_ok
|
||||
error = p_error
|
||||
|
||||
|
||||
static func ok(p_status: int, p_body) -> DmResponse:
|
||||
return DmResponse.new(p_status, p_body, true, "")
|
||||
|
||||
|
||||
static func failed(p_error: String) -> DmResponse:
|
||||
return DmResponse.new(0, null, false, p_error)
|
||||
1
client/scripts/net/dm_response.gd.uid
Normal file
1
client/scripts/net/dm_response.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bdy2347pcqm4v
|
||||
27
client/scripts/net/dm_service.gd
Normal file
27
client/scripts/net/dm_service.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
class_name DmService
|
||||
extends RefCounted
|
||||
## The client narrate loop (charter §2: the client owns the loop and consumes
|
||||
## text). Posts the canon log to /dm/narrate through an injected transport,
|
||||
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
|
||||
## it returns an authored fallback line — never an error. It harvests facts
|
||||
## only; the Narrator has no authority over disposition or moves, so those tags
|
||||
## are dropped even if the model emits them (§6). narrate() does not mutate the
|
||||
## log — the caller applies the returned facts, keeping the state write explicit.
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func narrate(canon_log: CanonLog) -> NarrateResult:
|
||||
var resp: DmResponse = await _transport.post_json(
|
||||
"/dm/narrate", {"canon_log": canon_log.to_dict()})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NarrateResult.fallback(_fallback.narrator_line())
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
|
||||
1
client/scripts/net/dm_service.gd.uid
Normal file
1
client/scripts/net/dm_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b467yu4cut0hp
|
||||
10
client/scripts/net/dm_transport.gd
Normal file
10
client/scripts/net/dm_transport.gd
Normal file
@@ -0,0 +1,10 @@
|
||||
class_name DmTransport
|
||||
extends RefCounted
|
||||
## The network seam. DmService talks to this, never to HTTPRequest directly, so
|
||||
## the loop logic is testable with an injected fake. Concrete transports
|
||||
## override post_json. The base returns a failed response (rather than assert())
|
||||
## so a missing override is a loud, catchable failure in tests.
|
||||
|
||||
func post_json(_path: String, _body: Dictionary) -> DmResponse:
|
||||
push_error("DmTransport.post_json must be overridden")
|
||||
return DmResponse.failed("transport not implemented")
|
||||
1
client/scripts/net/dm_transport.gd.uid
Normal file
1
client/scripts/net/dm_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://crgmujan6vj2l
|
||||
42
client/scripts/net/fallback_library.gd
Normal file
42
client/scripts/net/fallback_library.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
class_name FallbackLibrary
|
||||
extends RefCounted
|
||||
## Authored degraded-DM fallback text (charter §13). On ANY failure the client
|
||||
## shows one of these lines in-voice — never an error. Missing/malformed content
|
||||
## yields a hardcoded constant, so the fallback itself cannot fail.
|
||||
|
||||
const PATH := "res://content/fallback/narrator.json"
|
||||
const NPC_PATH := "res://content/fallback/npc.json"
|
||||
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
|
||||
const NPC_LAST_RESORT := "The figure regards you in silence, and the moment passes."
|
||||
|
||||
var _lines: Array = []
|
||||
var _npc_lines: Array = []
|
||||
|
||||
|
||||
func _init(path := PATH, npc_path := NPC_PATH) -> void:
|
||||
_lines = _load(path, "narrator")
|
||||
_npc_lines = _load(npc_path, "npc")
|
||||
|
||||
|
||||
func narrator_line() -> String:
|
||||
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
|
||||
|
||||
|
||||
func npc_line() -> String:
|
||||
return str(_npc_lines[0]) if not _npc_lines.is_empty() else NPC_LAST_RESORT
|
||||
|
||||
|
||||
static func _load(path: String, key: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
# Instance parse() (not JSON.parse_string) so malformed input returns an
|
||||
# Error code instead of pushing an engine error the test config promotes.
|
||||
var json := JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
return []
|
||||
var data = json.get_data()
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get(key, [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
1
client/scripts/net/fallback_library.gd.uid
Normal file
1
client/scripts/net/fallback_library.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b8jhu0e4yg6tf
|
||||
44
client/scripts/net/http_transport.gd
Normal file
44
client/scripts/net/http_transport.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
|
||||
## This is the one untested shim in the slice — it needs a live Node and a
|
||||
## server, so it is verified by reading and by the harness live smoke, the same
|
||||
## way the server's HTTP boundary is covered only by its gated --run-live test.
|
||||
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
|
||||
## interprets. Only a transport-level failure (no response) yields failed().
|
||||
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _init(http: HTTPRequest) -> void:
|
||||
_http = http
|
||||
# Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed()
|
||||
# → the service degrades) instead of never returning. Without this the
|
||||
# considering-state indicator would rotate forever on a hang.
|
||||
_http.timeout = ProxyConfig.request_timeout_seconds()
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
var url := ProxyConfig.base_url() + path
|
||||
var headers := ["Content-Type: application/json"]
|
||||
var payload := JSON.stringify(body)
|
||||
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
|
||||
if err != OK:
|
||||
return DmResponse.failed("request() failed to start: error %d" % err)
|
||||
# request_completed(result, response_code, headers, body) — awaiting a
|
||||
# multi-arg signal returns the args as an Array.
|
||||
var signal_args: Array = await _http.request_completed
|
||||
var result: int = signal_args[0]
|
||||
var response_code: int = signal_args[1]
|
||||
var raw: PackedByteArray = signal_args[3]
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
return DmResponse.failed("transport result %d" % result)
|
||||
# Parse via JSON.new() rather than JSON.parse_string(): the latter pushes an
|
||||
# engine-level error on a non-JSON body (e.g. a gateway's HTML 502), which the
|
||||
# project's gutconfig promotes to a false test failure. A non-JSON body stays
|
||||
# null here — the service treats a non-dict body as a failure and degrades.
|
||||
var json := JSON.new()
|
||||
var parsed = null if json.parse(raw.get_string_from_utf8()) != OK else json.data
|
||||
return DmResponse.ok(response_code, parsed)
|
||||
1
client/scripts/net/http_transport.gd.uid
Normal file
1
client/scripts/net/http_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cuvhnqsgr3liq
|
||||
18
client/scripts/net/narrate_result.gd
Normal file
18
client/scripts/net/narrate_result.gd
Normal file
@@ -0,0 +1,18 @@
|
||||
class_name NarrateResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
|
||||
## strings the caller applies to the canon log; empty when degraded.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NarrateResult:
|
||||
return NarrateResult.new(line, [], true)
|
||||
1
client/scripts/net/narrate_result.gd.uid
Normal file
1
client/scripts/net/narrate_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dye0nd5h05cks
|
||||
27
client/scripts/net/npc_result.gd
Normal file
27
client/scripts/net/npc_result.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
# client/scripts/net/npc_result.gd
|
||||
class_name NpcResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one /npc/speak call. The caller applies
|
||||
## valid_moves (via MoveApplier) and harvests facts — state writes stay explicit
|
||||
## (§2). dropped_moves are kept only so the caller can log the §6 drops.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var valid_moves: Array
|
||||
var dropped_moves: Array
|
||||
var ends_conversation: bool
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_valid := [], p_dropped := [],
|
||||
p_ends := false, p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
valid_moves = p_valid
|
||||
dropped_moves = p_dropped
|
||||
ends_conversation = p_ends
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NpcResult:
|
||||
return NpcResult.new(line, [], [], [], false, true)
|
||||
1
client/scripts/net/npc_result.gd.uid
Normal file
1
client/scripts/net/npc_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://chpx73tbklp51
|
||||
48
client/scripts/net/npc_service.gd
Normal file
48
client/scripts/net/npc_service.gd
Normal file
@@ -0,0 +1,48 @@
|
||||
class_name NpcService
|
||||
extends RefCounted
|
||||
## The client /npc/speak loop (§6). Posts persona-free request (npc_id +
|
||||
## disposition + client-computed available_moves + utterance) through an injected
|
||||
## transport, degrades to an authored NPC fallback on ANY failure, extracts tags
|
||||
## with TagExtractor, validates moves against available_moves (MoveValidator),
|
||||
## and returns an NpcResult. It applies NO state — the caller runs MoveApplier,
|
||||
## keeping state writes explicit (§2).
|
||||
|
||||
const ENDING_MOVES := ["end_conversation", "become_hostile"]
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func speak(npc_id: String, utterance: String, canon_log: CanonLog,
|
||||
disposition: int, available_moves: Array) -> NpcResult:
|
||||
var resp: DmResponse = await _transport.post_json("/npc/speak", {
|
||||
"canon_log": canon_log.to_dict(),
|
||||
"npc_id": npc_id,
|
||||
"disposition": disposition,
|
||||
"available_moves": available_moves,
|
||||
"utterance": utterance,
|
||||
})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NpcResult.fallback(_fallback.npc_line())
|
||||
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
var moves: Array = extracted["moves"].duplicate()
|
||||
# Fold the [ADJUST_DISPOSITION:n] alias into the move stream so the validator
|
||||
# sees one uniform shape (§ tag convention).
|
||||
for delta in extracted["dispositions"]:
|
||||
moves.append({"name": "adjust_disposition", "args": [str(delta)]})
|
||||
|
||||
var checked := MoveValidator.validate(moves, available_moves)
|
||||
var ends := false
|
||||
for m in checked["valid"]:
|
||||
if m["name"] in ENDING_MOVES:
|
||||
ends = true
|
||||
return NpcResult.new(
|
||||
extracted["clean_text"], extracted["facts"],
|
||||
checked["valid"], checked["dropped"], ends, false)
|
||||
1
client/scripts/net/npc_service.gd.uid
Normal file
1
client/scripts/net/npc_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b5perr1c1a61o
|
||||
25
client/scripts/net/proxy_config.gd
Normal file
25
client/scripts/net/proxy_config.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
class_name ProxyConfig
|
||||
extends RefCounted
|
||||
## The client's one piece of network config: the proxy base URL. Never a key,
|
||||
## never a model name (charter §4). Overridable per environment via project
|
||||
## settings; defaults to the dev proxy.
|
||||
|
||||
const SETTING := "coc_rpg/proxy_base_url"
|
||||
const DEFAULT := "http://localhost:8000"
|
||||
|
||||
const TIMEOUT_SETTING := "coc_rpg/proxy_request_timeout_seconds"
|
||||
const TIMEOUT_DEFAULT := 35.0
|
||||
|
||||
|
||||
static func base_url() -> String:
|
||||
var v = ProjectSettings.get_setting(SETTING, DEFAULT)
|
||||
var s := str(v)
|
||||
return s if s != "" else DEFAULT
|
||||
|
||||
|
||||
static func request_timeout_seconds() -> float:
|
||||
# Just above the server's OLLAMA_TIMEOUT_SECONDS (30) so a slow model surfaces
|
||||
# the server's own error first, while a fully-hung proxy is still bounded. A
|
||||
# non-positive value would re-enable the infinite hang, so reject it.
|
||||
var v := float(ProjectSettings.get_setting(TIMEOUT_SETTING, TIMEOUT_DEFAULT))
|
||||
return v if v > 0.0 else TIMEOUT_DEFAULT
|
||||
1
client/scripts/net/proxy_config.gd.uid
Normal file
1
client/scripts/net/proxy_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://n7p287j05isf
|
||||
37
client/scripts/npc/move_applier.gd
Normal file
37
client/scripts/npc/move_applier.gd
Normal file
@@ -0,0 +1,37 @@
|
||||
class_name MoveApplier
|
||||
extends RefCounted
|
||||
## Writes validated §6 moves to game state. Town-NPC disposition lives in
|
||||
## GameState.npc_dispositions (NOT CanonLog.party — that is companions only).
|
||||
## Called by the conversation caller after NpcService.speak, so state writes
|
||||
## stay explicit (§2). Unknown move names are ignored (they never pass the
|
||||
## validator, but this stays defensive).
|
||||
|
||||
const MAX_DELTA := 15 # one line cannot swing standing wholesale (§7 spirit)
|
||||
const HOSTILE_FLOOR := -100
|
||||
|
||||
|
||||
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
|
||||
for move in moves:
|
||||
var name: String = move.get("name", "")
|
||||
var args: Array = move.get("args", [])
|
||||
match name:
|
||||
"offer_quest":
|
||||
var q: String = str(args[0])
|
||||
var qd: Dictionary = content_db.quest(q)
|
||||
canon_log.add_quest(q, qd.get("name", ""), qd.get("objective", ""))
|
||||
"reveal":
|
||||
game_state.mark_revealed(str(args[0]))
|
||||
"give_item":
|
||||
var i: String = str(args[0])
|
||||
game_state.add_item(i, 1)
|
||||
game_state.mark_gift_given(i)
|
||||
"accept_item":
|
||||
game_state.remove_item(str(args[0]), 1)
|
||||
"adjust_disposition":
|
||||
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
|
||||
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
|
||||
game_state.set_npc_disposition(npc_id, cur + delta)
|
||||
"become_hostile":
|
||||
game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
|
||||
_:
|
||||
pass # refuse, end_conversation, and anything unknown: no state
|
||||
1
client/scripts/npc/move_applier.gd.uid
Normal file
1
client/scripts/npc/move_applier.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dhd5r0ugcx5bs
|
||||
29
client/scripts/npc/move_validator.gd
Normal file
29
client/scripts/npc/move_validator.gd
Normal file
@@ -0,0 +1,29 @@
|
||||
class_name MoveValidator
|
||||
extends RefCounted
|
||||
## Pure §6 validation: an emitted move is legal iff its canonical key is in the
|
||||
## turn's available_moves (which NpcContent already computed from live state).
|
||||
## No state reads here — membership is the whole test. Invalid moves are not an
|
||||
## error; they are dropped and returned separately so the caller can log them.
|
||||
|
||||
const ID_MOVES := ["reveal", "offer_quest", "give_item", "accept_item"]
|
||||
const BARE_MOVES := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]
|
||||
|
||||
|
||||
static func _key(move: Dictionary) -> String:
|
||||
var name: String = move.get("name", "")
|
||||
var args: Array = move.get("args", [])
|
||||
if name in ID_MOVES:
|
||||
return "%s(%s)" % [name, str(args[0])] if not args.is_empty() else name
|
||||
return name # bare/value moves key on the name alone
|
||||
|
||||
|
||||
static func validate(moves: Array, available_moves: Array) -> Dictionary:
|
||||
var valid: Array = []
|
||||
var dropped: Array = []
|
||||
for move in moves:
|
||||
var name: String = move.get("name", "")
|
||||
if (name in ID_MOVES or name in BARE_MOVES) and _key(move) in available_moves:
|
||||
valid.append(move)
|
||||
else:
|
||||
dropped.append(move)
|
||||
return {"valid": valid, "dropped": dropped}
|
||||
1
client/scripts/npc/move_validator.gd.uid
Normal file
1
client/scripts/npc/move_validator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cc7n3rqt1pvdv
|
||||
32
client/scripts/npc/npc_content.gd
Normal file
32
client/scripts/npc/npc_content.gd
Normal file
@@ -0,0 +1,32 @@
|
||||
class_name NpcContent
|
||||
extends RefCounted
|
||||
## Computes available_moves — the single home of move legality (§6). Reads the
|
||||
## NPC's non-spoiler capability block and filters by live game state so the model
|
||||
## is shown exactly what it may legally do this turn. persona/knowledge on the
|
||||
## same file are server-only and ignored here.
|
||||
|
||||
const UNIVERSAL := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]
|
||||
|
||||
|
||||
static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Array:
|
||||
var moves: Array = []
|
||||
var caps: Dictionary = npc_dict.get("capabilities", {})
|
||||
|
||||
for q in caps.get("offerable_quests", []):
|
||||
if not canon_log.has_quest(q):
|
||||
moves.append("offer_quest(%s)" % q)
|
||||
|
||||
for t in caps.get("revealable_topics", []):
|
||||
if not game_state.is_revealed(t):
|
||||
moves.append("reveal(%s)" % t)
|
||||
|
||||
for i in caps.get("giveable_items", []):
|
||||
if not game_state.is_gift_given(i):
|
||||
moves.append("give_item(%s)" % i)
|
||||
|
||||
for item_id in game_state.inventory.keys():
|
||||
if int(game_state.inventory[item_id]) > 0:
|
||||
moves.append("accept_item(%s)" % item_id)
|
||||
|
||||
moves.append_array(UNIVERSAL)
|
||||
return moves
|
||||
1
client/scripts/npc/npc_content.gd.uid
Normal file
1
client/scripts/npc/npc_content.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c64k81yn5arnn
|
||||
@@ -9,6 +9,8 @@ var luck_base: int = 0
|
||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||
var inventory: Dictionary = {} # item_id -> qty
|
||||
var revealed_topics: Dictionary = {} # topic_id -> true
|
||||
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
|
||||
|
||||
|
||||
func set_luck(v: int) -> void:
|
||||
@@ -29,3 +31,27 @@ func set_npc_disposition(id: String, v: int) -> void:
|
||||
|
||||
func add_item(item_id: String, qty: int) -> void:
|
||||
inventory[item_id] = int(inventory.get(item_id, 0)) + qty
|
||||
|
||||
|
||||
func remove_item(item_id: String, qty: int) -> void:
|
||||
var left := int(inventory.get(item_id, 0)) - qty
|
||||
if left > 0:
|
||||
inventory[item_id] = left
|
||||
else:
|
||||
inventory.erase(item_id)
|
||||
|
||||
|
||||
func mark_revealed(topic_id: String) -> void:
|
||||
revealed_topics[topic_id] = true
|
||||
|
||||
|
||||
func is_revealed(topic_id: String) -> bool:
|
||||
return revealed_topics.has(topic_id)
|
||||
|
||||
|
||||
func mark_gift_given(item_id: String) -> void:
|
||||
gifts_given[item_id] = true
|
||||
|
||||
|
||||
func is_gift_given(item_id: String) -> bool:
|
||||
return gifts_given.has(item_id)
|
||||
|
||||
@@ -7,8 +7,9 @@ extends RefCounted
|
||||
|
||||
static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]")
|
||||
static var _dispo_re: RegEx = RegEx.create_from_string("\\[ADJUST_DISPOSITION:\\s*([+-]?\\d+)\\]")
|
||||
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)\\(([^)]*)\\)\\]")
|
||||
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)(?:\\(([^)]*)\\))?\\]")
|
||||
static var _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]")
|
||||
static var _bare_move_re: RegEx = RegEx.create_from_string("\\[[a-z_]+\\([^\\]]*\\)\\]")
|
||||
|
||||
|
||||
static func extract(prose: String) -> Dictionary:
|
||||
@@ -30,6 +31,7 @@ static func extract(prose: String) -> Dictionary:
|
||||
moves.append({"name": m.get_string(1), "args": args})
|
||||
|
||||
var clean := _any_re.sub(prose, "", true)
|
||||
clean = _bare_move_re.sub(clean, "", true)
|
||||
# Collapse whitespace left where inline tags were removed.
|
||||
while clean.contains(" "):
|
||||
clean = clean.replace(" ", " ")
|
||||
|
||||
44
client/scripts/ui/considering_indicator.gd
Normal file
44
client/scripts/ui/considering_indicator.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
class_name ConsideringIndicator
|
||||
extends Node
|
||||
## Thin node shim (charter §13). While a call is in flight it rotates an authored
|
||||
## ConsideringPhrases line onto a Label every ROTATE_SECONDS, reframing the wait
|
||||
## as the GM thinking. The phrase logic is the pure, tested ConsideringPhrases;
|
||||
## the Timer/tree wiring here is the untested shim (verified by running the
|
||||
## harness). One instance persists on the harness so the rotation index is not
|
||||
## reset per call (spec decision 4).
|
||||
|
||||
const ROTATE_SECONDS := 3.0
|
||||
|
||||
var _phrases: ConsideringPhrases
|
||||
var _timer: Timer
|
||||
var _label: Label
|
||||
|
||||
|
||||
func _init(phrases: ConsideringPhrases = null) -> void:
|
||||
_phrases = phrases if phrases != null else ConsideringPhrases.new()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_timer = Timer.new()
|
||||
_timer.wait_time = ROTATE_SECONDS
|
||||
_timer.one_shot = false
|
||||
_timer.timeout.connect(_advance)
|
||||
add_child(_timer)
|
||||
|
||||
|
||||
func start(label: Label) -> void:
|
||||
_label = label
|
||||
_advance() # show a phrase immediately, don't wait ROTATE_SECONDS
|
||||
_timer.start()
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
_timer.stop()
|
||||
if _label != null:
|
||||
_label.text = ""
|
||||
_label = null
|
||||
|
||||
|
||||
func _advance() -> void:
|
||||
if _label != null:
|
||||
_label.text = _phrases.next()
|
||||
41
client/scripts/ui/considering_phrases.gd
Normal file
41
client/scripts/ui/considering_phrases.gd
Normal file
@@ -0,0 +1,41 @@
|
||||
class_name ConsideringPhrases
|
||||
extends RefCounted
|
||||
## Authored "the GM is thinking" lines (charter §13 — even loading is content).
|
||||
## Pure: cycles a phrase pool in order on a running index that is instance state
|
||||
## (spec decision 4 — the indicator reuses one instance so the first phrase varies
|
||||
## turn to turn without randomness). A missing/malformed file degrades to one
|
||||
## hardcoded constant, so the indicator itself cannot fail.
|
||||
|
||||
const PATH := "res://content/considering.json"
|
||||
const LAST_RESORT := "The Master considers your words."
|
||||
|
||||
var _pool: Array = []
|
||||
var _index: int = 0
|
||||
|
||||
|
||||
func _init(path := PATH) -> void:
|
||||
_pool = _load(path)
|
||||
|
||||
|
||||
func next() -> String:
|
||||
if _pool.is_empty():
|
||||
return LAST_RESORT
|
||||
var phrase := str(_pool[_index % _pool.size()])
|
||||
_index += 1
|
||||
return phrase
|
||||
|
||||
|
||||
static func _load(path: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
# Instance parse() (not JSON.parse_string) so malformed input returns an
|
||||
# Error code instead of the engine error the test config promotes to a fail.
|
||||
var json := JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
return []
|
||||
var data = json.get_data()
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get("considering", [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
19
client/tests/doubles/fake_transport.gd
Normal file
19
client/tests/doubles/fake_transport.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## Test double for DmTransport. Returns a canned DmResponse and records the last
|
||||
## call so tests can assert what the service posted. post_json needs no real
|
||||
## async — awaiting a plain return value resolves immediately, so the same await
|
||||
## call site in DmService drives both the real and the fake transport.
|
||||
|
||||
var _canned
|
||||
var last_path: String
|
||||
var last_body: Dictionary
|
||||
|
||||
|
||||
func set_response(resp) -> void:
|
||||
_canned = resp
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
last_path = path
|
||||
last_body = body
|
||||
return _canned
|
||||
1
client/tests/doubles/fake_transport.gd.uid
Normal file
1
client/tests/doubles/fake_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ddctedl778jdx
|
||||
@@ -46,3 +46,15 @@ func test_set_quest_status_rejects_bad_enum():
|
||||
|
||||
func test_unsupported_schema_version_returns_null():
|
||||
assert_null(CanonLog.from_dict({"schema_version": 2}))
|
||||
|
||||
|
||||
func test_add_quest_appends_active_and_is_idempotent():
|
||||
var log = CanonLog.new()
|
||||
assert_false(log.has_quest("find_the_ledger"))
|
||||
assert_true(log.add_quest("find_the_ledger", "The Missing Ledger", "Find who took it"))
|
||||
assert_true(log.has_quest("find_the_ledger"))
|
||||
assert_eq(log.active_quests.size(), 1)
|
||||
assert_eq(log.active_quests[0].status, "active")
|
||||
# second add is a no-op
|
||||
assert_false(log.add_quest("find_the_ledger", "x", "y"))
|
||||
assert_eq(log.active_quests.size(), 1)
|
||||
|
||||
19
client/tests/unit/test_considering_indicator.gd
Normal file
19
client/tests/unit/test_considering_indicator.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
|
||||
|
||||
|
||||
func test_start_shows_first_phrase_and_stop_clears():
|
||||
# Point phrases at a missing file so next() is deterministically LAST_RESORT.
|
||||
var phrases = ConsideringPhrases.new("res://content/does_not_exist.json")
|
||||
var ind = ConsideringIndicator.new(phrases)
|
||||
add_child_autofree(ind) # _ready() creates the Timer child
|
||||
var label := Label.new()
|
||||
add_child_autofree(label)
|
||||
|
||||
ind.start(label)
|
||||
assert_eq(label.text, ConsideringPhrases.LAST_RESORT) # first phrase shown at once
|
||||
|
||||
ind.stop()
|
||||
assert_eq(label.text, "") # cleared on stop
|
||||
33
client/tests/unit/test_considering_phrases.gd
Normal file
33
client/tests/unit/test_considering_phrases.gd
Normal file
@@ -0,0 +1,33 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
|
||||
|
||||
|
||||
func test_missing_file_returns_last_resort():
|
||||
var p = ConsideringPhrases.new("res://content/does_not_exist.json")
|
||||
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
|
||||
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
|
||||
|
||||
|
||||
func test_cycles_pool_in_order_and_wraps():
|
||||
# Real authored file has at least 3 lines; assert order + wrap on it.
|
||||
var p = ConsideringPhrases.new() # default path — real content
|
||||
var first = p.next()
|
||||
var second = p.next()
|
||||
assert_ne(first, "")
|
||||
assert_ne(first, second) # advances, not stuck
|
||||
# Drain a full cycle back to `first` to prove it wraps (bounded loop).
|
||||
var seen := [first, second]
|
||||
for i in range(20):
|
||||
var n = p.next()
|
||||
if n == first:
|
||||
return # wrapped — success
|
||||
seen.append(n)
|
||||
assert_true(false, "did not wrap within 22 calls: %s" % [seen])
|
||||
|
||||
|
||||
func test_index_persists_across_calls_not_reset():
|
||||
var p = ConsideringPhrases.new()
|
||||
var a = p.next()
|
||||
var b = p.next()
|
||||
assert_ne(a, b) # second call did not re-return the first phrase
|
||||
80
client/tests/unit/test_dm_service.gd
Normal file
80
client/tests/unit/test_dm_service.gd
Normal file
@@ -0,0 +1,80 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||
|
||||
var _fallback
|
||||
|
||||
|
||||
func before_each():
|
||||
# Point the library at a missing file so narrator_line() is deterministically
|
||||
# LAST_RESORT — the degraded assertions don't depend on the authored content.
|
||||
_fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
|
||||
|
||||
func _log() -> CanonLog:
|
||||
return CanonLog.new()
|
||||
|
||||
|
||||
func _service_for(resp) -> Dictionary:
|
||||
var t = FakeDmTransport.new()
|
||||
t.set_response(resp)
|
||||
return {"svc": DmService.new(t, _fallback), "transport": t}
|
||||
|
||||
|
||||
func test_success_strips_tags_and_harvests_facts():
|
||||
var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_false(r.degraded)
|
||||
assert_eq(r.display_text, "The docks reek of tar.")
|
||||
assert_eq(r.facts, ["the eastern bridge is out"])
|
||||
|
||||
|
||||
func test_success_posts_canon_log_to_narrate_path():
|
||||
var pair := _service_for(DmResponse.ok(200, {"prose": "x"}))
|
||||
await pair["svc"].narrate(_log())
|
||||
assert_eq(pair["transport"].last_path, "/dm/narrate")
|
||||
assert_true(pair["transport"].last_body.has("canon_log"))
|
||||
|
||||
|
||||
func test_502_degrades_to_fallback_with_no_facts():
|
||||
var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
assert_eq(r.display_text, FallbackLibrary.LAST_RESORT)
|
||||
assert_eq(r.facts, [])
|
||||
|
||||
|
||||
func test_422_degrades():
|
||||
var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_failure_degrades():
|
||||
var pair := _service_for(DmResponse.failed("connection refused"))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_missing_prose_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, {"nope": 1}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_non_dict_body_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, null))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_only_facts_harvested_dispositions_and_moves_dropped():
|
||||
var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_eq(r.display_text, "Hi bye")
|
||||
1
client/tests/unit/test_dm_service.gd.uid
Normal file
1
client/tests/unit/test_dm_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://brm51u1xsq2iy
|
||||
45
client/tests/unit/test_fallback_library.gd
Normal file
45
client/tests/unit/test_fallback_library.gd
Normal file
@@ -0,0 +1,45 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
|
||||
|
||||
func test_loads_authored_line_from_default_file():
|
||||
var lib = FallbackLibrary.new()
|
||||
# The authored line is distinct from LAST_RESORT, so a match proves the file was read.
|
||||
assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
assert_true(lib.narrator_line().length() > 0)
|
||||
|
||||
|
||||
func test_missing_file_uses_last_resort():
|
||||
var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_malformed_file_uses_last_resort():
|
||||
var path := "user://bad_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string("{ this is not valid json")
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_empty_list_uses_last_resort():
|
||||
var path := "user://empty_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string('{"narrator": []}')
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_npc_line_falls_back_to_constant_when_missing():
|
||||
var lib = FallbackLibrary.new(
|
||||
"res://content/fallback/does_not_exist.json",
|
||||
"res://content/fallback/does_not_exist.json")
|
||||
assert_eq(lib.npc_line(), FallbackLibrary.NPC_LAST_RESORT)
|
||||
|
||||
|
||||
func test_npc_line_reads_authored_file():
|
||||
var lib = FallbackLibrary.new() # default paths — real content
|
||||
assert_ne(lib.npc_line(), "")
|
||||
1
client/tests/unit/test_fallback_library.gd.uid
Normal file
1
client/tests/unit/test_fallback_library.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bs7jxst7fmyqx
|
||||
@@ -28,3 +28,26 @@ func test_luck_descriptor_delegates():
|
||||
var s = GameState.new()
|
||||
s.luck = 4
|
||||
assert_eq(s.luck_descriptor(), "Fortune spits on you")
|
||||
|
||||
|
||||
func test_remove_item_decrements_and_floors_at_zero():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 3)
|
||||
gs.remove_item("coin", 2)
|
||||
assert_eq(gs.inventory.get("coin", 0), 1)
|
||||
gs.remove_item("coin", 5)
|
||||
assert_eq(gs.inventory.get("coin", 0), 0)
|
||||
|
||||
|
||||
func test_revealed_topics_tracked():
|
||||
var gs = GameState.new()
|
||||
assert_false(gs.is_revealed("varrell_twins"))
|
||||
gs.mark_revealed("varrell_twins")
|
||||
assert_true(gs.is_revealed("varrell_twins"))
|
||||
|
||||
|
||||
func test_gifts_given_tracked():
|
||||
var gs = GameState.new()
|
||||
assert_false(gs.is_gift_given("amulet"))
|
||||
gs.mark_gift_given("amulet")
|
||||
assert_true(gs.is_gift_given("amulet"))
|
||||
|
||||
55
client/tests/unit/test_move_applier.gd
Normal file
55
client/tests/unit/test_move_applier.gd
Normal file
@@ -0,0 +1,55 @@
|
||||
# client/tests/unit/test_move_applier.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const MoveApplier = preload("res://scripts/npc/move_applier.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _m(name, args := []) -> Dictionary:
|
||||
return {"name": name, "args": args}
|
||||
|
||||
|
||||
func _content() -> ContentDB:
|
||||
var c = ContentDB.new()
|
||||
c.quests = {"find_the_ledger": {"id": "find_the_ledger",
|
||||
"name": "The Missing Ledger", "objective": "Find who took it"}}
|
||||
return c
|
||||
|
||||
|
||||
func test_offer_quest_adds_active_quest():
|
||||
var log = CanonLog.new()
|
||||
MoveApplier.apply([_m("offer_quest", ["find_the_ledger"])],
|
||||
GameState.new(), log, _content(), "fenn")
|
||||
assert_true(log.has_quest("find_the_ledger"))
|
||||
assert_eq(log.active_quests[0].name, "The Missing Ledger")
|
||||
|
||||
|
||||
func test_reveal_marks_topic():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("reveal", ["varrell_twins"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_true(gs.is_revealed("varrell_twins"))
|
||||
|
||||
|
||||
func test_adjust_disposition_clamps_to_max_delta():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("adjust_disposition", ["+50"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.MAX_DELTA)
|
||||
|
||||
|
||||
func test_become_hostile_sets_floor():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("become_hostile")], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.HOSTILE_FLOOR)
|
||||
|
||||
|
||||
func test_accept_item_removes_from_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 2)
|
||||
MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("coin", 0), 1)
|
||||
|
||||
|
||||
func test_refuse_and_end_are_noops():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.npc_dispositions.size(), 0)
|
||||
1
client/tests/unit/test_move_applier.gd.uid
Normal file
1
client/tests/unit/test_move_applier.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cysf45uyit1hv
|
||||
38
client/tests/unit/test_move_validator.gd
Normal file
38
client/tests/unit/test_move_validator.gd
Normal file
@@ -0,0 +1,38 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const MoveValidator = preload("res://scripts/npc/move_validator.gd")
|
||||
|
||||
|
||||
func _m(name, args := []) -> Dictionary:
|
||||
return {"name": name, "args": args}
|
||||
|
||||
|
||||
func test_id_move_in_available_is_valid():
|
||||
var res = MoveValidator.validate(
|
||||
[_m("reveal", ["varrell_twins"])], ["reveal(varrell_twins)", "refuse"])
|
||||
assert_eq(res["valid"].size(), 1)
|
||||
assert_eq(res["dropped"].size(), 0)
|
||||
|
||||
|
||||
func test_id_move_not_in_available_is_dropped():
|
||||
var res = MoveValidator.validate(
|
||||
[_m("offer_quest", ["some_other_quest"])], ["offer_quest(find_the_ledger)"])
|
||||
assert_eq(res["valid"].size(), 0)
|
||||
assert_eq(res["dropped"].size(), 1)
|
||||
|
||||
|
||||
func test_bare_move_keys_on_name():
|
||||
var res = MoveValidator.validate([_m("refuse")], ["refuse"])
|
||||
assert_eq(res["valid"].size(), 1)
|
||||
|
||||
|
||||
func test_adjust_disposition_keys_on_name_keeps_delta():
|
||||
var res = MoveValidator.validate(
|
||||
[_m("adjust_disposition", ["+5"])], ["adjust_disposition"])
|
||||
assert_eq(res["valid"].size(), 1)
|
||||
assert_eq(res["valid"][0]["args"], ["+5"])
|
||||
|
||||
|
||||
func test_unknown_move_name_is_dropped():
|
||||
var res = MoveValidator.validate([_m("cast_fireball", ["9"])], ["refuse"])
|
||||
assert_eq(res["dropped"].size(), 1)
|
||||
1
client/tests/unit/test_move_validator.gd.uid
Normal file
1
client/tests/unit/test_move_validator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bubsx2qbuesxi
|
||||
90
client/tests/unit/test_net_primitives.gd
Normal file
90
client/tests/unit/test_net_primitives.gd
Normal file
@@ -0,0 +1,90 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const NarrateResult = preload("res://scripts/net/narrate_result.gd")
|
||||
const NpcResult = preload("res://scripts/net/npc_result.gd")
|
||||
const DmTransport = preload("res://scripts/net/dm_transport.gd")
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
|
||||
|
||||
func test_dm_response_ok():
|
||||
var r = DmResponse.ok(200, {"prose": "hi"})
|
||||
assert_eq(r.status, 200)
|
||||
assert_true(r.transport_ok)
|
||||
assert_eq(r.body, {"prose": "hi"})
|
||||
assert_eq(r.error, "")
|
||||
|
||||
|
||||
func test_dm_response_failed():
|
||||
var r = DmResponse.failed("boom")
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.status, 0)
|
||||
assert_eq(r.body, null)
|
||||
assert_eq(r.error, "boom")
|
||||
|
||||
|
||||
func test_narrate_result_basic():
|
||||
var r = NarrateResult.new("text", ["a"], false)
|
||||
assert_eq(r.display_text, "text")
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_false(r.degraded)
|
||||
|
||||
|
||||
func test_narrate_result_fallback():
|
||||
var r = NarrateResult.fallback("cold chamber")
|
||||
assert_eq(r.display_text, "cold chamber")
|
||||
assert_eq(r.facts, [])
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_base_returns_not_implemented():
|
||||
var t = DmTransport.new()
|
||||
var r = await t.post_json("/x", {})
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.error, "transport not implemented")
|
||||
|
||||
|
||||
func test_proxy_config_default():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT)
|
||||
|
||||
|
||||
func test_proxy_config_override():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000")
|
||||
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
|
||||
|
||||
func test_npc_result_fallback_is_degraded_with_no_moves():
|
||||
var r = NpcResult.fallback("The figure says nothing.")
|
||||
assert_true(r.degraded)
|
||||
assert_eq(r.display_text, "The figure says nothing.")
|
||||
assert_eq(r.valid_moves, [])
|
||||
assert_eq(r.facts, [])
|
||||
assert_false(r.ends_conversation)
|
||||
|
||||
|
||||
func test_request_timeout_default():
|
||||
if ProjectSettings.has_setting(ProxyConfig.TIMEOUT_SETTING):
|
||||
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
|
||||
|
||||
|
||||
func test_request_timeout_override_and_reject_nonpositive():
|
||||
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 12.5)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), 12.5)
|
||||
# A non-positive override must NOT re-enable the hang — falls back to default.
|
||||
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 0.0)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
|
||||
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
|
||||
|
||||
|
||||
func test_http_transport_sets_request_timeout():
|
||||
var HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
var http := HTTPRequest.new()
|
||||
autofree(http)
|
||||
HttpTransport.new(http)
|
||||
assert_eq(http.timeout, ProxyConfig.request_timeout_seconds())
|
||||
assert_gt(http.timeout, 0.0) # the whole point — no longer disabled
|
||||
1
client/tests/unit/test_net_primitives.gd.uid
Normal file
1
client/tests/unit/test_net_primitives.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cpihryriwysay
|
||||
44
client/tests/unit/test_npc_content.gd
Normal file
44
client/tests/unit/test_npc_content.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const NpcContent = preload("res://scripts/npc/npc_content.gd")
|
||||
|
||||
func _fenn() -> Dictionary:
|
||||
return {"id": "fenn", "capabilities": {
|
||||
"offerable_quests": ["find_the_ledger"],
|
||||
"giveable_items": [],
|
||||
"revealable_topics": ["varrell_twins", "fenns_debt"]}}
|
||||
|
||||
|
||||
func test_universal_moves_always_present():
|
||||
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
|
||||
for m in ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]:
|
||||
assert_true(m in moves, "missing universal move %s" % m)
|
||||
|
||||
|
||||
func test_offers_unstarted_quest_and_unrevealed_topics():
|
||||
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
|
||||
assert_true("offer_quest(find_the_ledger)" in moves)
|
||||
assert_true("reveal(varrell_twins)" in moves)
|
||||
assert_true("reveal(fenns_debt)" in moves)
|
||||
|
||||
|
||||
func test_active_quest_is_not_offered_again():
|
||||
var log = CanonLog.new()
|
||||
log.add_quest("find_the_ledger", "x", "y")
|
||||
var moves = NpcContent.available_moves(_fenn(), GameState.new(), log)
|
||||
assert_false("offer_quest(find_the_ledger)" in moves)
|
||||
|
||||
|
||||
func test_revealed_topic_drops_out():
|
||||
var gs = GameState.new()
|
||||
gs.mark_revealed("varrell_twins")
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_false("reveal(varrell_twins)" in moves)
|
||||
assert_true("reveal(fenns_debt)" in moves)
|
||||
|
||||
|
||||
func test_accept_item_offered_per_held_item():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("coin", 2)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(coin)" in moves)
|
||||
1
client/tests/unit/test_npc_content.gd.uid
Normal file
1
client/tests/unit/test_npc_content.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxbq6nm43vlva
|
||||
92
client/tests/unit/test_npc_service.gd
Normal file
92
client/tests/unit/test_npc_service.gd
Normal file
@@ -0,0 +1,92 @@
|
||||
# client/tests/unit/test_npc_service.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const NpcService = preload("res://scripts/net/npc_service.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||
|
||||
var _fallback
|
||||
|
||||
|
||||
func before_each():
|
||||
_fallback = FallbackLibrary.new(
|
||||
"res://content/fallback/missing.json", "res://content/fallback/missing.json")
|
||||
|
||||
|
||||
func _svc(resp) -> Dictionary:
|
||||
var t = FakeDmTransport.new()
|
||||
t.set_response(resp)
|
||||
return {"svc": NpcService.new(t, _fallback), "transport": t}
|
||||
|
||||
|
||||
func _speak(pair, available):
|
||||
return await pair["svc"].speak("fenn", "what happened?", CanonLog.new(), -10, available)
|
||||
|
||||
|
||||
func test_posts_full_request_body():
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": "Aye."}))
|
||||
await _speak(pair, ["refuse"])
|
||||
assert_eq(pair["transport"].last_path, "/npc/speak")
|
||||
var body = pair["transport"].last_body
|
||||
assert_eq(body["npc_id"], "fenn")
|
||||
assert_eq(body["disposition"], -10)
|
||||
assert_eq(body["utterance"], "what happened?")
|
||||
assert_true(body.has("available_moves"))
|
||||
assert_true(body.has("canon_log"))
|
||||
|
||||
|
||||
func test_valid_move_kept_prose_cleaned():
|
||||
var prose = "Aye, I knew them. [MOVE: reveal(varrell_twins)]"
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
|
||||
var r = await _speak(pair, ["reveal(varrell_twins)", "refuse"])
|
||||
assert_false(r.degraded)
|
||||
assert_eq(r.display_text, "Aye, I knew them.")
|
||||
assert_eq(r.valid_moves.size(), 1)
|
||||
assert_eq(r.dropped_moves.size(), 0)
|
||||
|
||||
|
||||
func test_illegal_move_dropped_prose_kept():
|
||||
var prose = "No. [MOVE: offer_quest(some_other)]"
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
|
||||
var r = await _speak(pair, ["offer_quest(find_the_ledger)", "refuse"])
|
||||
assert_eq(r.display_text, "No.")
|
||||
assert_eq(r.valid_moves.size(), 0)
|
||||
assert_eq(r.dropped_moves.size(), 1)
|
||||
|
||||
|
||||
func test_adjust_disposition_alias_is_validated():
|
||||
var prose = "Fine. [ADJUST_DISPOSITION: +5]"
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
|
||||
var r = await _speak(pair, ["adjust_disposition"])
|
||||
assert_eq(r.valid_moves.size(), 1)
|
||||
assert_eq(r.valid_moves[0]["name"], "adjust_disposition")
|
||||
assert_eq(r.valid_moves[0]["args"], ["5"])
|
||||
|
||||
|
||||
func test_end_conversation_sets_flag():
|
||||
var prose = "We're done. [MOVE: end_conversation()]"
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
|
||||
var r = await _speak(pair, ["end_conversation"])
|
||||
assert_true(r.ends_conversation)
|
||||
|
||||
|
||||
func test_facts_harvested():
|
||||
var prose = "The bridge fell. [FACT: the eastern bridge is out]"
|
||||
var pair = _svc(DmResponse.ok(200, {"prose": prose}))
|
||||
var r = await _speak(pair, ["refuse"])
|
||||
assert_eq(r.facts, ["the eastern bridge is out"])
|
||||
|
||||
|
||||
func test_502_degrades_no_moves():
|
||||
var pair = _svc(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
|
||||
var r = await _speak(pair, ["refuse"])
|
||||
assert_true(r.degraded)
|
||||
assert_eq(r.display_text, FallbackLibrary.NPC_LAST_RESORT)
|
||||
assert_eq(r.valid_moves, [])
|
||||
|
||||
|
||||
func test_transport_failure_degrades():
|
||||
var pair = _svc(DmResponse.failed("refused"))
|
||||
var r = await _speak(pair, ["refuse"])
|
||||
assert_true(r.degraded)
|
||||
1
client/tests/unit/test_npc_service.gd.uid
Normal file
1
client/tests/unit/test_npc_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dt8idmag53m7u
|
||||
@@ -38,3 +38,45 @@ func test_no_tags_is_clean_passthrough():
|
||||
assert_eq(r["dispositions"], [])
|
||||
assert_eq(r["moves"], [])
|
||||
assert_eq(r["clean_text"], "no tags here")
|
||||
|
||||
|
||||
# LF1 — a live model emitted [MOVE: refuse] with no parens at all. The move
|
||||
# must still be recognized so the legal move isn't silently dropped.
|
||||
func test_move_without_parens_is_parsed():
|
||||
var r := TagExtractor.extract("[MOVE: refuse]")
|
||||
assert_eq(r["moves"][0]["name"], "refuse")
|
||||
assert_eq(r["moves"][0]["args"], [])
|
||||
|
||||
|
||||
# LF1 regression guard — explicit empty parens must still work as before.
|
||||
func test_move_with_explicit_empty_parens_still_parsed():
|
||||
var r := TagExtractor.extract("[MOVE: end_conversation()]")
|
||||
assert_eq(r["moves"][0]["name"], "end_conversation")
|
||||
assert_eq(r["moves"][0]["args"], [])
|
||||
|
||||
|
||||
# LF1 regression guard — well-formed args must still parse unchanged.
|
||||
func test_move_with_args_still_parsed():
|
||||
var r := TagExtractor.extract("[MOVE: reveal(varrell_twins)]")
|
||||
assert_eq(r["moves"][0]["name"], "reveal")
|
||||
assert_eq(r["moves"][0]["args"], ["varrell_twins"])
|
||||
|
||||
|
||||
# LF2 — a live model emitted [adjust_disposition(-5)] without the MOVE:
|
||||
# prefix. It must not be parsed/applied (untrusted, malformed), but it must
|
||||
# also not leak into player-visible prose as literal tag text.
|
||||
func test_bare_lowercase_move_shape_is_scrubbed_not_parsed():
|
||||
var r := TagExtractor.extract("He glares. [adjust_disposition(-5)] Fine.")
|
||||
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))
|
||||
assert_eq(r["moves"], [])
|
||||
assert_eq(r["dispositions"], [])
|
||||
|
||||
|
||||
# LF2 regression guard — a well-formed MOVE tag alongside a bare lowercase
|
||||
# shape in the same prose must still be parsed and stripped correctly.
|
||||
func test_wellformed_move_still_parsed_alongside_bare_lowercase_shape():
|
||||
var r := TagExtractor.extract("[MOVE: reveal(x)] He glares. [adjust_disposition(-5)] Fine.")
|
||||
assert_eq(r["moves"][0]["name"], "reveal")
|
||||
assert_eq(r["moves"][0]["args"], ["x"])
|
||||
assert_false(r["clean_text"].contains("[MOVE:"))
|
||||
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))
|
||||
|
||||
17
content/world/npcs/fenn.json
Normal file
17
content/world/npcs/fenn.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "fenn",
|
||||
"name": "Fenn",
|
||||
"role": "townsfolk",
|
||||
"persona": "A harried dockside clerk who counts everything and trusts no one. He speaks in short, aggrieved sentences and keeps glancing at the door. He is frightened, and covers it with irritation.",
|
||||
"knowledge": [
|
||||
"His counting-house ledger vanished two nights ago, sometime after the third bell.",
|
||||
"The Varrell twins were drinking in the counting-room the night it went missing; he threw them out but did not check the strongbox until morning.",
|
||||
"The ledger records a debt he cannot cover if the wrong people read it, and he will not say to whom.",
|
||||
"He has not told the harbourmaster, because the harbourmaster is one of the people the ledger names."
|
||||
],
|
||||
"capabilities": {
|
||||
"offerable_quests": ["find_the_ledger"],
|
||||
"giveable_items": [],
|
||||
"revealable_topics": ["varrell_twins", "fenns_debt"]
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,18 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
PORT: 8000
|
||||
# Mount source + schemas so edits are live without a rebuild.
|
||||
# Reach a host-run Ollama from inside the container. On Linux, localhost in
|
||||
# the container is the container itself, so OLLAMA_BASE_URL must be
|
||||
# http://host.docker.internal:11434 (set in api/.env); this maps that name
|
||||
# to the Docker host. Harmless when Ollama is on the homelab instead.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Mount source + prompts + schemas so edits are live without a rebuild.
|
||||
# Prompts are source code (§16) — editing a prompt should reload like code.
|
||||
volumes:
|
||||
- ./api/app:/app/app:ro
|
||||
- ./api/prompts:/app/prompts:ro
|
||||
- ./docs/schemas:/app/schemas:ro
|
||||
- ./content:/app/content:ro
|
||||
command: >
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
106
docs/roadmap.md
106
docs/roadmap.md
@@ -2,33 +2,95 @@
|
||||
|
||||
Source of truth for scope is [`CLAUDE.md`](../CLAUDE.md). This tracks sequencing.
|
||||
|
||||
## POC — the one question (charter §17)
|
||||
## The game — building the tactical CRPG (charter §17)
|
||||
|
||||
> **Does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
|
||||
> **The POC's one question — *does bounded AI dialogue feel alive?* — is answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0–M2). We are now building the game the mockups describe (§16, `/mockups`): a tactical CRPG across 11 screens.
|
||||
|
||||
Test that before building anything else. ~30 minutes of play.
|
||||
The engine is done and proven. What's left is the **game on top of it** — recreated screen by screen against the mockups' shared visual system, on the canon log / DM–NPC pipeline / bounded-moves / Luck / character-creation model we already built.
|
||||
|
||||
### POC scope
|
||||
### Reconcile as we build (charter §17)
|
||||
|
||||
- One town, **two NPCs** with authored knowledge lists
|
||||
- One dungeon: **three fights, one boss**
|
||||
- **Two companions:** Cadwyn, Brannoc
|
||||
- **Three playable classes:** Sellsword, Assassin, Priest
|
||||
- Luck fully wired: generation, drift, one shrine, one cursed item
|
||||
- Full proxy, full role split, full canon log
|
||||
- Authored fallback text for every AI surface
|
||||
- **Damage determinism** — §7/§10 (deterministic) vs the Combat HUD mock's damage ranges → combat brainstorm (M5).
|
||||
- **Class names** — §8 names vs the mock's callings (same archetypes) → character-creation brainstorm (M4).
|
||||
|
||||
### Out of scope (charter §17)
|
||||
### Out of scope (never / later)
|
||||
|
||||
AI-generated story skeletons · multiplayer (never) · mobile · credits/payments/auth · companions leaving · positional combat · more than three classes.
|
||||
Multiplayer (never) · mobile (later) · AI-generated story skeletons (v2) · credits/payments/auth (retrofit onto the proxy, §4).
|
||||
|
||||
## Sequencing — TBD
|
||||
## Sequencing
|
||||
|
||||
<!-- Fill in as phases are planned. Suggested spine:
|
||||
1. Proxy skeleton + role endpoints + Ollama routing + logging
|
||||
2. Canon log + one Narrator scene end-to-end (proves the loop)
|
||||
3. One bounded NPC conversation (proves §6 — the core experiment)
|
||||
4. Luck + Improviser + one shrine + one cursed item
|
||||
5. Combat (deterministic, seeded) + the dungeon
|
||||
6. Banter + humiliation log (proves the comedy loop)
|
||||
-->
|
||||
Status: ✅ done · ▶ next · ○ planned. Each item is its own spec → plan → implementation cycle.
|
||||
Each carries its **§2 side** (state = code owns it · text = AI owns it · both = it is two systems), what it **depends on**, and the **milestone goal** it advances.
|
||||
|
||||
M0–M2 built the **engine** (the contract, the client, the server loop, bounded dialogue) and answered the aliveness question. M3+ build the **game** — the CRPG screens/systems of the mockups, sequenced toward a playable vertical slice first, then breadth:
|
||||
|
||||
| Milestone | Goal it advances |
|
||||
|---|---|
|
||||
| **M0 — Foundations** ✅ | The contract and the client-side engine exist and are trustworthy. |
|
||||
| **M1 — Prove the server loop** ✅ | A real model sits behind `/dm/narrate` and the pipeline is reusable. |
|
||||
| **M2 — Prove aliveness** ✅ | **Does bounded AI dialogue feel alive?** — answered; bounded NPC dialogue + DM loop live-proven. |
|
||||
| **M3 — Visual foundation & shell** ◀ *here* | The `Theme` + the exploration HUD; every screen inherits the look and lives in the shell. |
|
||||
| **M4 — Character creation** | The player enters the world; the proven stat/Luck model gets its UI. |
|
||||
| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. |
|
||||
| **M6 — Dialogue screen** | The proven NPC free-text loop inside the mock's conversation UI. |
|
||||
| **M7 — Inventory & character sheet** | State the player can see and manage. |
|
||||
| **M8 — World systems** | World-map travel, a shop economy, the quest log. |
|
||||
| **M9 — Framing, AI-flavor & persistence** | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
|
||||
|
||||
---
|
||||
|
||||
### M0 — Foundations ✅
|
||||
- ✅ **Canon log + origin JSON-Schema contract** — api validation, unified 422 envelope (Plan A). *§2: state · foundation.*
|
||||
- ✅ **Client canon log engine** — typed GDScript model (invariants in the model), new-game construction (origin + world + creation → canon log), turn-to-turn maintenance mutators, pure `[FACT]`/`[MOVE]`/`[ADJUST_DISPOSITION]` tag extractor (Plan B). *§2: state · depends on the contract.*
|
||||
|
||||
### M1 — Prove the server loop ✅
|
||||
- ✅ **Narrator / Ollama pipeline (server)** — the shared server-side model-call pipeline (Ollama httpx client, role→model routing config, prompt loader + canon-log digest renderer, §10 call logging, single-retry → typed 502) wired through `/dm/narrate`. Non-streaming. Both phases landed: **(1) pipeline foundation** (`config`/`routing`/`prompts`/`ollama_client`/`call_log`), **(2) Narrator online** (authored `narrator.md` body + `narrate` service + gated live smoke). Server returns raw prose; the client extracts tags. **The other four roles become thin drop-ins on this pipeline.** Live wire proven against qwen3.5; 49 tests. Merged to `dev` (`ddc00d4`). *§2: text · depends on the contract.*
|
||||
|
||||
### M2 — Prove aliveness ✅ *(the POC's one question — build complete + live-proven; the *feel* is confirmed in playtest)*
|
||||
- ✅ **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.*
|
||||
- ✅ **Considering-state + transport timeout** — while a model call is in flight the client rotates authored in-character "the Master considers…" lines (§13 spirit — even *loading* is content; `ConsideringPhrases` + `ConsideringIndicator`, 3.0s cadence) instead of a frozen surface, and `HttpTransport` sets a request timeout (35s, rejects ≤0) so a hung call degrades 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 no new failure surface. **Human-confirmed live** including the degrade path (API down → fallback). Merged to `dev` (`099714d`); 110/110 client tests. *§2: text/UI · goal: the wait is framed (in-character) and bounded (never hangs).*
|
||||
- ~~**Streaming** (§14)~~ — **SHELVED for the POC.** Real token streaming would genuinely *hide* the ~2s call, but it is the POC's largest transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds mid-stream error/tag-boundary failure surface not worth the risk. The considering-state above reframes the wait instead and is tested and live. Design kept as a record (`specs/2026-07-10-narrator-streaming-design.md`, banner SHELVED); revive only if playtest proves the wait genuinely hurts. Conscious deviation from §14's "stream everything," recorded per §18.
|
||||
|
||||
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each `.dc.html`'s `Component` class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
|
||||
|
||||
### M3 — Visual foundation & shell ◀ *here*
|
||||
- ○ **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
|
||||
- ○ **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
|
||||
- ○ **Title screen** — new game / continue / load / settings; the entry point. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||
|
||||
### M4 — Character creation
|
||||
- ○ **Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
||||
|
||||
### M5 — Tactical combat (gridless)
|
||||
- ○ **Combat turn manager + HUD** — the Combat HUD `Component` state machine as GDScript: initiative order, the action economy (Action/Bonus/Move), ability resolution, click-to-target, victory/defeat; tokens on the iso board as **flavor only, no movement grid** (§10). Seeded per encounter (§10). **Reconcile damage determinism** here (§7/§10 vs the mock's ranges). Combat flavor is async optional Narrator calls (reuses M1). *§2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.*
|
||||
|
||||
### M6 — Dialogue screen
|
||||
- ○ **Dialogue UI + reshaped Adjudicator** — wrap the *proven* NPC free-text loop (`/npc/speak`, M2) in the mock's conversation screen: NPC portrait, live disposition meter, the NPC's spoken line + DM narration beat, and the **hybrid** input (choice chips with skill/approach tags **plus** the free-text line, §15). The **Adjudicator** (`/dm/adjudicate`, strict JSON §12, one-retry) lands here as the interpreter for the free-text "describe your own action" escape hatch — most turns use buttons. *§2: both (text = NPC voice · state = validated moves/actions) · depends on the NPC role (M2) · goal: conversation inside the game's UI.*
|
||||
|
||||
### M7 — Inventory & character sheet
|
||||
- ○ **Inventory & equipment** — the paper-doll (12 slots) + Satchel grid, category tabs, item detail, encumbrance, derived stats; item seed-data from the mock's `items()` map as `Resource`s. *§2: state · depends on the Theme + item model · goal: gear the player manages.*
|
||||
- ○ **Character sheet** — attributes (modifier-forward, LCK still hidden), saving throws, the "in a fight" derived grid, skills, talents, afflictions/conditions, the DM's-notes bio. *§2: state (display) · depends on the stat model · goal: the character, legible.*
|
||||
|
||||
### M8 — World systems
|
||||
- ○ **World-map travel** — region map, location markers (settlement/dungeon/fog), roads + planned route, danger ratings, travel time, set-out. *§2: state · depends on the shell · goal: move between places.*
|
||||
- ○ **Shop economy** — buy/sell with a fence: purses, stock/owned, the ~40%-sell loop, gritty disabled-CTA reasons, merchant reaction lines. *§2: state (merchant reaction = a thin authored/AI layer) · depends on inventory · goal: an economy loop.*
|
||||
- ○ **Quest log** — active/completed/failed tabs, objectives checklists, rewards, tracked quest; reads the canon log's `active_quests` (§11). *§2: state · depends on the quest model · goal: track the story.*
|
||||
|
||||
### M9 — Framing, AI-flavor & persistence
|
||||
- ○ **Save / load + pause / settings** — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). *§2: state · depends on the systems existing · goal: persistence + framing.*
|
||||
- ○ **Luck fully wired** — generation exists (Plan B); this adds **drift events** (real bed, shrine blessing, killing something praying), **one shrine**, **one cursed item** (+STR / −LCK). Visible only in prose (§7). Gates the Improviser. *§2: state · depends on the item + event systems · goal: the signature system, live.*
|
||||
- ○ **Improviser** — `/dm/improvise`: minor off-script events, **§7-sandboxed by construction** — a validator that only applies whitelisted state changes (dignity, never progress; structurally incapable of ending a quest). Fires on Luck; hooks Cadwyn. *§2: both · depends on Luck being wired · goal: bad luck as a story, not a fine.*
|
||||
- ○ **Banter** — `/party/banter`: companions comment on recent events; low stakes, high charm, **aggressively cacheable**; reads Brannoc's humiliation log (§9), stacking not overwriting. Prose only, no tags. *§2: text · depends on the humiliation log (M0) + events · goal: the comedy loop closes.*
|
||||
- ○ **Authored-fallback sweep** — every AI-dependent surface has authored fallback *content* (§13), written as writing; the final pass so no surface can show the player an error. *§2: text · depends on every surface existing · goal: a degraded DM still has a voice.*
|
||||
|
||||
### Deferred (later / never)
|
||||
Replicate provider (prod) · auth · metering · credits/payments (§4) — retrofit onto the pipeline later, the game doesn't notice. AI-generated story skeletons (v2 — the seeded story is authored for now). Streaming (§14) — SHELVED (`specs/2026-07-10-narrator-streaming-design.md`); the considering-state reframes the wait instead.
|
||||
|
||||
#### NPC & relationship depth (v2)
|
||||
|
||||
Three ideas mined from [NarrativeEngine-P](https://github.com/Sagesheep/NarrativeEngine-P) (MIT) — design borrowed, not code. All three are **§2: state** (code owns them; the AI only voices from what code decides), so they strengthen the §2 boundary rather than loosen it. They fold into the NPC role (§6) and canon log (§11); sequence them **with or after the Dialogue screen (M6)**, since they enrich the same conversation surface.
|
||||
|
||||
- ○ **Directional dispositions** — split the single per-pair disposition into two directed fields (A→B and B→A), so liking is asymmetric. Unrequited regard, undisclosed betrayal (player still trusts the captain who already sold them out), and the Cadwyn↔Brannoc mutual-but-differently-flavored dislike (§9) all become expressible. Player→NPC gates dialogue *tone*; NPC→player gates *offered moves* (§6 `available_moves`) — two arrows, two levers. Extends `party[]` in the canon log (§11) and `npc_dispositions` on the client; keep the −100..100 range, just double the field. *§2: state · depends on the disposition model existing (M2, done) · goal: relationships that aren't symmetric.*
|
||||
- ○ **Witness tracking** — every logged event carries a `witnesses: [...]` list; a fact enters an NPC's `knowledge[]` (§6) only if they witnessed it or a witness told them. This is the missing **input-side rule** for the knowledge list — CLAUDE.md §6 says the list is "the entire content the NPC has to draw on" but never says how a fact gets *in*. Structurally prevents the blacksmith spoiling a twist he never saw, gives shady acts real "were you seen?" stakes without a stealth system, and turns rumor spread into a code operation (witness meets NPC → code copies the fact, optionally degraded) rather than AI invention. Adds a field to `recent_events[]`/`established_facts[]` (§11) and a propagation rule on character encounters. *§2: state · depends on the canon log event model (M0, done) + knowledge lists (§6) · goal: NPCs know only what they could plausibly know.*
|
||||
- ○ **Personality Hexagon persona** — a richer NPC persona schema: six axes (Drive, Diligence, Boldness, Warmth, Empathy, Composure) supplied to the NPC prompt (§6) as structured voice-shaping input, replacing free-form persona blurbs. Server-owned (like `knowledge[]`), so it never ships to the client. Purely additive to the §6 prompt contract — it steers *how* an NPC speaks, not *what* it may do (the move vocabulary is unchanged). *§2: state (the schema) feeding text (the voice) · depends on the NPC role (M2, done) · goal: consistent, distinct NPC voices from data, not prose.*
|
||||
|
||||
1026
docs/superpowers/plans/2026-07-09-narrator-ollama-pipeline.md
Normal file
1026
docs/superpowers/plans/2026-07-09-narrator-ollama-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
1875
docs/superpowers/plans/2026-07-10-bounded-npc-conversation.md
Normal file
1875
docs/superpowers/plans/2026-07-10-bounded-npc-conversation.md
Normal file
File diff suppressed because it is too large
Load Diff
754
docs/superpowers/plans/2026-07-10-client-http-loop.md
Normal file
754
docs/superpowers/plans/2026-07-10-client-http-loop.md
Normal file
@@ -0,0 +1,754 @@
|
||||
# Client HTTP Loop (M2) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the first end-to-end vertical slice — a running Godot client posts its canon log to `/dm/narrate`, shows the returned prose on screen, harvests `[FACT:]` tags into its own log, and degrades to an authored fallback line on any failure.
|
||||
|
||||
**Architecture:** A pure `RefCounted` `DmService` owns all loop logic (build request body, parse the response, choose the fallback, harvest facts) behind a small `DmTransport` seam. The real `HttpTransport` wraps Godot's `HTTPRequest` Node; a `FakeDmTransport` double drives the service in headless GUT tests. A throwaway harness scene wires the real transport to a button and a label so a human can see prose. Facts are returned by `narrate()` and applied to the log by the caller — code owns the state mutation (charter §2).
|
||||
|
||||
**Tech Stack:** Godot 4.7, GDScript, GUT (headless test runner). No new dependencies. No autoloads.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-10-client-http-loop-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
Every task's requirements implicitly include this section.
|
||||
|
||||
- **Language/style:** GDScript. Pure classes are `class_name X extends RefCounted`. Follow the existing entity idiom (`client/scripts/canon_log/party_member.gd`): `_init` takes `p_`-prefixed args with defaults; value objects expose plain fields.
|
||||
- **Client holds only the base URL** — never an API key, never a model name (charter §4).
|
||||
- **Request/response contract (server, already live):** POST `/dm/narrate`, body exactly `{"canon_log": <log.to_dict()>}`. Success is **only** HTTP `200` with a dict body containing a `"prose"` key. `502` → `{"detail":{"model_error":…}}`; `422` → `{"detail":{"canon_log_errors":[…]}}`. Any non-200, transport failure, or malformed 200 → the fallback line. **No client-side retry.**
|
||||
- **Harvest facts only.** The narrate loop harvests `[FACT:]` tags and nothing else; stray `[ADJUST_DISPOSITION]`/`[MOVE]` are dropped (they belong to the NPC role). Facts are harvested only on a real 200.
|
||||
- **`narrate()` returns, it does not mutate.** It returns a `NarrateResult`; the caller applies facts via `CanonLog.add_fact(...)`.
|
||||
- **Fallback content home:** `client/content/fallback/narrator.json` (renderer owns the file's home — root `/content` is server/authoring). The JSON is `{"narrator": [<lines>]}`. The library's hardcoded last-resort constant must be a **different string** from the authored line so tests can tell "loaded from file" from "fell back".
|
||||
- **Base URL:** default `http://localhost:8000`, overridable via `ProjectSettings` key `coc_rpg/proxy_base_url`.
|
||||
- **Naming (two deliberate departures from the spec's prose):**
|
||||
- The `NarrateResult` fallback constructor is `NarrateResult.fallback(line)`, **not** `.degraded(...)` — GDScript shares one namespace for members and methods, so a `degraded()` method would clash with the `degraded` field.
|
||||
- `DmTransport.post_json` base returns `DmResponse.failed("transport not implemented")` and `push_error`s, rather than `assert()` — GUT cannot catch a Godot `assert()`, so the not-implemented path must be a testable return value.
|
||||
- **Tests:** GUT, `extends "res://addons/gut/test.gd"`, files under `client/tests/unit/` (the only dir GUT scans, per `.gutconfig.json`). Test doubles live under `client/tests/doubles/` (a sibling dir GUT does **not** scan). Run headless from `client/`:
|
||||
- Whole suite: `./run_tests.sh`
|
||||
- One file: `./run_tests.sh -gtest=res://tests/unit/<file>.gd`
|
||||
- `run_tests.sh` runs `godot --headless --import` first, so new `class_name` scripts resolve.
|
||||
- **Untested-by-nature:** `HttpTransport` (needs a live `HTTPRequest` Node + server) and the harness scene are verified by reading + a by-hand live smoke against a running proxy — not by the unit suite. This parallels the server's `--run-live` gated boundary.
|
||||
- **Git (charter §18):** all work on branch `feature/client-http-loop`, cut from `dev`. Commit after every task.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Net primitives — `DmResponse`, `NarrateResult`, `DmTransport`, `ProxyConfig`
|
||||
|
||||
The value/interface layer the service is built from. All pure, all headless-testable.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/net/dm_response.gd`
|
||||
- Create: `client/scripts/net/narrate_result.gd`
|
||||
- Create: `client/scripts/net/dm_transport.gd`
|
||||
- Create: `client/scripts/net/proxy_config.gd`
|
||||
- Test: `client/tests/unit/test_net_primitives.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (leaf layer).
|
||||
- Produces:
|
||||
- `DmResponse` — fields `status:int`, `body` (Variant: parsed JSON dict or `null`), `transport_ok:bool`, `error:String`. Static ctors `DmResponse.ok(status:int, body) -> DmResponse`, `DmResponse.failed(error:String) -> DmResponse`.
|
||||
- `NarrateResult` — fields `display_text:String`, `facts:Array`, `degraded:bool`. Ctor `NarrateResult.new(display_text:="", facts:=[], degraded:=false)`. Static ctor `NarrateResult.fallback(line:String) -> NarrateResult`.
|
||||
- `DmTransport` — base with coroutine `post_json(path:String, body:Dictionary) -> DmResponse`; base returns `DmResponse.failed("transport not implemented")`.
|
||||
- `ProxyConfig` — `const SETTING := "coc_rpg/proxy_base_url"`, `const DEFAULT := "http://localhost:8000"`, static `base_url() -> String`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `client/tests/unit/test_net_primitives.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const NarrateResult = preload("res://scripts/net/narrate_result.gd")
|
||||
const DmTransport = preload("res://scripts/net/dm_transport.gd")
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
|
||||
|
||||
func test_dm_response_ok():
|
||||
var r = DmResponse.ok(200, {"prose": "hi"})
|
||||
assert_eq(r.status, 200)
|
||||
assert_true(r.transport_ok)
|
||||
assert_eq(r.body, {"prose": "hi"})
|
||||
assert_eq(r.error, "")
|
||||
|
||||
|
||||
func test_dm_response_failed():
|
||||
var r = DmResponse.failed("boom")
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.status, 0)
|
||||
assert_eq(r.body, null)
|
||||
assert_eq(r.error, "boom")
|
||||
|
||||
|
||||
func test_narrate_result_basic():
|
||||
var r = NarrateResult.new("text", ["a"], false)
|
||||
assert_eq(r.display_text, "text")
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_false(r.degraded)
|
||||
|
||||
|
||||
func test_narrate_result_fallback():
|
||||
var r = NarrateResult.fallback("cold chamber")
|
||||
assert_eq(r.display_text, "cold chamber")
|
||||
assert_eq(r.facts, [])
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_base_returns_not_implemented():
|
||||
var t = DmTransport.new()
|
||||
var r = await t.post_json("/x", {})
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.error, "transport not implemented")
|
||||
|
||||
|
||||
func test_proxy_config_default():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT)
|
||||
|
||||
|
||||
func test_proxy_config_override():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000")
|
||||
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: FAIL — GUT reports a load/parse error because the four preloaded scripts do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the four primitives**
|
||||
|
||||
Create `client/scripts/net/dm_response.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name DmResponse
|
||||
extends RefCounted
|
||||
## The raw outcome of one HTTP attempt, independent of any game meaning.
|
||||
## `status` is 0 when the transport never reached the server; `body` is the
|
||||
## parsed JSON dict, or null when the body was absent or not JSON.
|
||||
|
||||
var status: int
|
||||
var body # Variant: parsed JSON (Dictionary) or null
|
||||
var transport_ok: bool
|
||||
var error: String
|
||||
|
||||
|
||||
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
|
||||
status = p_status
|
||||
body = p_body
|
||||
transport_ok = p_transport_ok
|
||||
error = p_error
|
||||
|
||||
|
||||
static func ok(p_status: int, p_body) -> DmResponse:
|
||||
return DmResponse.new(p_status, p_body, true, "")
|
||||
|
||||
|
||||
static func failed(p_error: String) -> DmResponse:
|
||||
return DmResponse.new(0, null, false, p_error)
|
||||
```
|
||||
|
||||
Create `client/scripts/net/narrate_result.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name NarrateResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
|
||||
## strings the caller applies to the canon log; empty when degraded.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NarrateResult:
|
||||
return NarrateResult.new(line, [], true)
|
||||
```
|
||||
|
||||
Create `client/scripts/net/dm_transport.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name DmTransport
|
||||
extends RefCounted
|
||||
## The network seam. DmService talks to this, never to HTTPRequest directly, so
|
||||
## the loop logic is testable with an injected fake. Concrete transports
|
||||
## override post_json. The base returns a failed response (rather than assert())
|
||||
## so a missing override is a loud, catchable failure in tests.
|
||||
|
||||
func post_json(_path: String, _body: Dictionary) -> DmResponse:
|
||||
push_error("DmTransport.post_json must be overridden")
|
||||
return DmResponse.failed("transport not implemented")
|
||||
```
|
||||
|
||||
Create `client/scripts/net/proxy_config.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name ProxyConfig
|
||||
extends RefCounted
|
||||
## The client's one piece of network config: the proxy base URL. Never a key,
|
||||
## never a model name (charter §4). Overridable per environment via project
|
||||
## settings; defaults to the dev proxy.
|
||||
|
||||
const SETTING := "coc_rpg/proxy_base_url"
|
||||
const DEFAULT := "http://localhost:8000"
|
||||
|
||||
|
||||
static func base_url() -> String:
|
||||
var v = ProjectSettings.get_setting(SETTING, DEFAULT)
|
||||
var s := str(v)
|
||||
return s if s != "" else DEFAULT
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: PASS — 7/7 passing, 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/net/dm_response.gd client/scripts/net/narrate_result.gd client/scripts/net/dm_transport.gd client/scripts/net/proxy_config.gd client/tests/unit/test_net_primitives.gd
|
||||
git commit -m "feat(client): net primitives — DmResponse, NarrateResult, DmTransport, ProxyConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `FallbackLibrary` + authored fallback content
|
||||
|
||||
Loads the authored §13 fallback line; falls back to a hardcoded constant if the file is missing or malformed (the fallback has a fallback — the player must never see an error).
|
||||
|
||||
**Files:**
|
||||
- Create: `client/content/fallback/narrator.json`
|
||||
- Create: `client/scripts/net/fallback_library.gd`
|
||||
- Test: `client/tests/unit/test_fallback_library.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `FallbackLibrary` — ctor `FallbackLibrary.new(path := FallbackLibrary.PATH)`; method `narrator_line() -> String`; `const PATH := "res://content/fallback/narrator.json"`; `const LAST_RESORT := "The chamber is cold. Something waits in the dark."`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `client/tests/unit/test_fallback_library.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
|
||||
|
||||
func test_loads_authored_line_from_default_file():
|
||||
var lib = FallbackLibrary.new()
|
||||
# The authored line is distinct from LAST_RESORT, so a match proves the file was read.
|
||||
assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
assert_true(lib.narrator_line().length() > 0)
|
||||
|
||||
|
||||
func test_missing_file_uses_last_resort():
|
||||
var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_malformed_file_uses_last_resort():
|
||||
var path := "user://bad_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string("{ this is not valid json")
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_empty_list_uses_last_resort():
|
||||
var path := "user://empty_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string('{"narrator": []}')
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd`
|
||||
Expected: FAIL — load/parse error, `fallback_library.gd` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write the authored content**
|
||||
|
||||
Create `client/content/fallback/narrator.json` (Narrator voice per §3 — dry, gritty not grim, never winks; distinct from `LAST_RESORT`):
|
||||
|
||||
```json
|
||||
{
|
||||
"narrator": [
|
||||
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write minimal implementation**
|
||||
|
||||
Create `client/scripts/net/fallback_library.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name FallbackLibrary
|
||||
extends RefCounted
|
||||
## Authored degraded-DM fallback text (charter §13). The client shows one of
|
||||
## these lines on ANY failure, in the Narrator's voice — never an error. If the
|
||||
## content file is missing or malformed the library returns a hardcoded
|
||||
## constant, so the fallback itself cannot fail.
|
||||
|
||||
const PATH := "res://content/fallback/narrator.json"
|
||||
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
|
||||
|
||||
var _lines: Array = []
|
||||
|
||||
|
||||
func _init(path := PATH) -> void:
|
||||
_lines = _load(path)
|
||||
|
||||
|
||||
func narrator_line() -> String:
|
||||
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
|
||||
|
||||
|
||||
static func _load(path: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
var data = JSON.parse_string(text)
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get("narrator", [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fallback_library.gd`
|
||||
Expected: PASS — 4/4 passing.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/content/fallback/narrator.json client/scripts/net/fallback_library.gd client/tests/unit/test_fallback_library.gd
|
||||
git commit -m "feat(client): FallbackLibrary + authored narrator fallback (§13)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `DmService` core + `FakeDmTransport` double
|
||||
|
||||
The heart of the slice: build the request, branch on the response, harvest facts, choose the fallback. Every branch is covered headless by injecting a fake transport.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/net/dm_service.gd`
|
||||
- Create: `client/tests/doubles/fake_transport.gd`
|
||||
- Test: `client/tests/unit/test_dm_service.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DmResponse`, `NarrateResult`, `DmTransport` (Task 1); `FallbackLibrary` (Task 2); existing `CanonLog` (`to_dict()`, `add_fact()`) and `TagExtractor` (`extract(prose) -> {clean_text, facts, …}`).
|
||||
- Produces: `DmService` — ctor `DmService.new(transport: DmTransport, fallback: FallbackLibrary)`; coroutine `narrate(canon_log: CanonLog) -> NarrateResult`.
|
||||
- Produces (test double): `FakeDmTransport extends DmTransport` — `set_response(resp: DmResponse)`, records `last_path: String`, `last_body: Dictionary`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `client/tests/doubles/fake_transport.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## Test double for DmTransport. Returns a canned DmResponse and records the last
|
||||
## call so tests can assert what the service posted. post_json needs no real
|
||||
## async — awaiting a plain return value resolves immediately, so the same await
|
||||
## call site in DmService drives both the real and the fake transport.
|
||||
|
||||
var _canned
|
||||
var last_path: String
|
||||
var last_body: Dictionary
|
||||
|
||||
|
||||
func set_response(resp) -> void:
|
||||
_canned = resp
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
last_path = path
|
||||
last_body = body
|
||||
return _canned
|
||||
```
|
||||
|
||||
Create `client/tests/unit/test_dm_service.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||
|
||||
var _fallback
|
||||
|
||||
|
||||
func before_each():
|
||||
# Point the library at a missing file so narrator_line() is deterministically
|
||||
# LAST_RESORT — the degraded assertions don't depend on the authored content.
|
||||
_fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
|
||||
|
||||
func _log() -> CanonLog:
|
||||
return CanonLog.new()
|
||||
|
||||
|
||||
func _service_for(resp) -> Dictionary:
|
||||
var t = FakeDmTransport.new()
|
||||
t.set_response(resp)
|
||||
return {"svc": DmService.new(t, _fallback), "transport": t}
|
||||
|
||||
|
||||
func test_success_strips_tags_and_harvests_facts():
|
||||
var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_false(r.degraded)
|
||||
assert_eq(r.display_text, "The docks reek of tar.")
|
||||
assert_eq(r.facts, ["the eastern bridge is out"])
|
||||
|
||||
|
||||
func test_success_posts_canon_log_to_narrate_path():
|
||||
var pair := _service_for(DmResponse.ok(200, {"prose": "x"}))
|
||||
await pair["svc"].narrate(_log())
|
||||
assert_eq(pair["transport"].last_path, "/dm/narrate")
|
||||
assert_true(pair["transport"].last_body.has("canon_log"))
|
||||
|
||||
|
||||
func test_502_degrades_to_fallback_with_no_facts():
|
||||
var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
assert_eq(r.display_text, FallbackLibrary.LAST_RESORT)
|
||||
assert_eq(r.facts, [])
|
||||
|
||||
|
||||
func test_422_degrades():
|
||||
var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_failure_degrades():
|
||||
var pair := _service_for(DmResponse.failed("connection refused"))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_missing_prose_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, {"nope": 1}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_non_dict_body_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, null))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_only_facts_harvested_dispositions_and_moves_dropped():
|
||||
var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_eq(r.display_text, "Hi bye")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd`
|
||||
Expected: FAIL — load/parse error, `dm_service.gd` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `client/scripts/net/dm_service.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name DmService
|
||||
extends RefCounted
|
||||
## The client narrate loop (charter §2: the client owns the loop and consumes
|
||||
## text). Posts the canon log to /dm/narrate through an injected transport,
|
||||
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
|
||||
## it returns an authored fallback line — never an error. It harvests facts
|
||||
## only; the Narrator has no authority over disposition or moves, so those tags
|
||||
## are dropped even if the model emits them (§6). narrate() does not mutate the
|
||||
## log — the caller applies the returned facts, keeping the state write explicit.
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func narrate(canon_log: CanonLog) -> NarrateResult:
|
||||
var resp: DmResponse = await _transport.post_json(
|
||||
"/dm/narrate", {"canon_log": canon_log.to_dict()})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NarrateResult.fallback(_fallback.narrator_line())
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_dm_service.gd`
|
||||
Expected: PASS — 8/8 passing.
|
||||
|
||||
- [ ] **Step 5: Run the whole suite to confirm no regressions**
|
||||
|
||||
Run: `cd client && ./run_tests.sh`
|
||||
Expected: PASS — all prior tests plus the three new files green, 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/net/dm_service.gd client/tests/doubles/fake_transport.gd client/tests/unit/test_dm_service.gd
|
||||
git commit -m "feat(client): DmService narrate loop + FakeDmTransport (all branches tested)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `HttpTransport` — the real transport (untested shim)
|
||||
|
||||
Wraps a live `HTTPRequest` Node and maps its async signal onto a `DmResponse`. No unit test: it needs a tree Node and a server. It is verified by reading here and by the live smoke in Task 5.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/net/http_transport.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DmResponse` (Task 1), `ProxyConfig` (Task 1). Constructed with an `HTTPRequest` Node the owner has already added to the scene tree.
|
||||
- Produces: `HttpTransport extends DmTransport` — ctor `HttpTransport.new(http: HTTPRequest)`; overrides `post_json(path, body) -> DmResponse`.
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
Create `client/scripts/net/http_transport.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
|
||||
## This is the one untested shim in the slice — it needs a live Node and a
|
||||
## server, so it is verified by reading and by the harness live smoke, the same
|
||||
## way the server's HTTP boundary is covered only by its gated --run-live test.
|
||||
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
|
||||
## interprets. Only a transport-level failure (no response) yields failed().
|
||||
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _init(http: HTTPRequest) -> void:
|
||||
_http = http
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
var url := ProxyConfig.base_url() + path
|
||||
var headers := ["Content-Type: application/json"]
|
||||
var payload := JSON.stringify(body)
|
||||
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
|
||||
if err != OK:
|
||||
return DmResponse.failed("request() failed to start: error %d" % err)
|
||||
# request_completed(result, response_code, headers, body) — awaiting a
|
||||
# multi-arg signal returns the args as an Array.
|
||||
var signal_args: Array = await _http.request_completed
|
||||
var result: int = signal_args[0]
|
||||
var response_code: int = signal_args[1]
|
||||
var raw: PackedByteArray = signal_args[3]
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
return DmResponse.failed("transport result %d" % result)
|
||||
var parsed = JSON.parse_string(raw.get_string_from_utf8()) # null if not JSON
|
||||
return DmResponse.ok(response_code, parsed)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it parses**
|
||||
|
||||
Run: `cd client && godot --headless --import`
|
||||
Expected: import completes with no parse/script errors mentioning `http_transport.gd`. (There is no unit test for this file by design; the review and Task 5's live smoke cover it.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/net/http_transport.gd
|
||||
git commit -m "feat(client): HttpTransport — real HTTPRequest transport (untested shim)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Throwaway harness scene + live-smoke doc
|
||||
|
||||
The "human sees prose" surface: a button, a label, a status line. Builds the transport → service, posts a hand-built valid canon log, shows the prose, applies harvested facts. Disposable — the real UI is a later wireframe. Verified by hand against a running proxy.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/harness/narrate_harness.gd`
|
||||
- Create: `client/scenes/narrate_harness.tscn`
|
||||
- Modify: `client/docs/README.md` (append a "Narrate live smoke" section)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HttpTransport` (Task 4), `DmService` (Task 3), `FallbackLibrary` (Task 2); existing `CanonLog`, `LogPlayer`, `LogLocation`, `PartyMember`, `Quest`, `Humiliation`.
|
||||
- Produces: a runnable scene; no code other tasks depend on.
|
||||
|
||||
- [ ] **Step 1: Write the harness script**
|
||||
|
||||
Create `client/scripts/harness/narrate_harness.gd`. The fixture mirrors the proven-valid `api/tests/fixtures/canon_log_valid.json` so the request passes the server's schema check and the prose is grounded:
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
|
||||
## transport to a button and a label. NOT the game UI (a later wireframe owns
|
||||
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
|
||||
## smoke steps in client/docs/README.md.
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
|
||||
var _service: DmService
|
||||
var _log: CanonLog
|
||||
var _label: RichTextLabel
|
||||
var _status: Label
|
||||
var _button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
_log = _build_scene_log()
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_button = Button.new()
|
||||
_button.text = "Narrate this scene"
|
||||
_button.pressed.connect(_on_narrate_pressed)
|
||||
vbox.add_child(_button)
|
||||
|
||||
_label = RichTextLabel.new()
|
||||
_label.fit_content = true
|
||||
_label.custom_minimum_size = Vector2(640, 320)
|
||||
vbox.add_child(_label)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
|
||||
func _on_narrate_pressed() -> void:
|
||||
_button.disabled = true
|
||||
_status.text = "…narrating…"
|
||||
var r := await _service.narrate(_log)
|
||||
_label.text = r.display_text
|
||||
for f in r.facts:
|
||||
_log.add_fact(f)
|
||||
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
|
||||
_button.disabled = false
|
||||
|
||||
|
||||
func _build_scene_log() -> CanonLog:
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
log.set_location("greywater_docks", "the Greywater docks")
|
||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||
log.push_event("Arrived at Greywater by barge before dawn")
|
||||
log.push_event("Brannoc recognised the harbourmaster and went quiet")
|
||||
log.add_fact("the eastern bridge out of Greywater is washed out")
|
||||
log.add_fact("the harbourmaster is named Oda Fenn")
|
||||
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
|
||||
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
|
||||
return log
|
||||
```
|
||||
|
||||
Argument orders are confirmed against the source: `Quest.new(id, name, status, objective)` (`client/scripts/canon_log/quest.gd:13`) and `CanonLog.add_humiliation(id, text, weight, turn)` (`client/scripts/canon_log/canon_log.gd:38`). Use them as written.
|
||||
|
||||
- [ ] **Step 2: Write the scene file**
|
||||
|
||||
Create `client/scenes/narrate_harness.tscn`. Minimal root + script (all children are built in `_ready`, so the scene needs no hand-authored node tree). Godot will assign a `uid` on first import; that is expected and fine to commit:
|
||||
|
||||
```
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NarrateHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify it imports and parses**
|
||||
|
||||
Run: `cd client && godot --headless --import`
|
||||
Expected: import completes with no script/scene errors mentioning `narrate_harness`. Then confirm the unit suite is still green: `./run_tests.sh` → all pass (the harness has no unit test; this step guards against a parse error leaking in).
|
||||
|
||||
- [ ] **Step 4: Document the live smoke**
|
||||
|
||||
Append to `client/docs/README.md`:
|
||||
|
||||
```markdown
|
||||
|
||||
## Narrate live smoke (M2 client HTTP loop)
|
||||
|
||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||
loop. It is not the game UI.
|
||||
|
||||
**Prereqs:** the proxy running and Ollama reachable.
|
||||
|
||||
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
|
||||
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
|
||||
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
|
||||
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
|
||||
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
|
||||
`facts harvested: N` with N ≥ 0.
|
||||
4. **Degraded path:** stop the proxy and click again. Expect the authored
|
||||
fallback line and `(degraded)` in the status — never an error.
|
||||
|
||||
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/harness/narrate_harness.gd client/scenes/narrate_harness.tscn client/docs/README.md
|
||||
git commit -m "feat(client): throwaway narrate harness scene + live-smoke doc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Data flow (spec) → Task 3 `DmService.narrate`. ✓
|
||||
- `DmResponse` / `DmTransport` / `NarrateResult` / `ProxyConfig` (spec components) → Task 1. ✓
|
||||
- `FallbackLibrary` + authored JSON + content-home rule → Task 2. ✓
|
||||
- `HttpTransport` untested shim → Task 4. ✓
|
||||
- Harness scene + live smoke → Task 5. ✓
|
||||
- Facts-only harvest, degraded on every failure branch, returns-not-mutates → Task 3 tests. ✓
|
||||
- Base URL default + override (§4) → Task 1 `ProxyConfig` tests. ✓
|
||||
- Out-of-scope items (real UI, streaming, NPC moves, retry) → not implemented, as specified. ✓
|
||||
- Follow-up (CLAUDE.md §16 note) → recorded in the spec's "Follow-ups"; not a task here (doc edit, deliberately deferred). ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. Every code step shows complete code. The one "confirm the signature before running" note (Task 5, `Quest.new`) is a real instruction with the known-good order given, not a placeholder.
|
||||
|
||||
**Type consistency:** `DmResponse.ok/failed`, `NarrateResult.new/fallback`, `DmTransport.post_json`, `FallbackLibrary.new/narrator_line/PATH/LAST_RESORT`, `DmService.new/narrate`, `HttpTransport.new`, `ProxyConfig.SETTING/DEFAULT/base_url` — all used consistently across tasks and match the Interfaces blocks. `FakeDmTransport.post_json` matches the `DmTransport` base signature. The `narrate()` return (`NarrateResult`) is consumed correctly by the harness.
|
||||
520
docs/superpowers/plans/2026-07-10-considering-state.md
Normal file
520
docs/superpowers/plans/2026-07-10-considering-state.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Considering-state + transport timeout Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** While a model call is in flight, the client shows a rotating in-character "the Master considers…" indicator instead of a frozen surface, and a hung call gives way to the authored fallback instead of spinning forever.
|
||||
|
||||
**Architecture:** All client-side, no server change. A pure `ConsideringPhrases` (cycles an authored phrase pool loaded from content, robust to a missing file) drives a thin `ConsideringIndicator` node (owns a `Timer`, rotates a `Label`). `ProxyConfig` gains a request-timeout setting; `HttpTransport` applies it to its `HTTPRequest`, so a hang maps through the existing `RESULT_TIMEOUT → failed() → degrade` path. Both harnesses show the indicator around their `await`.
|
||||
|
||||
**Tech Stack:** Godot 4.7 / GDScript / GUT.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **§2 — this is text/UI only.** No game state, no AI, no canon-log mutation. The phrases are authored content.
|
||||
- **§13 — loading is content.** Phrases live in `client/content/considering.json`, loaded like the fallback lines; a missing/malformed file degrades to one hardcoded constant so the indicator itself cannot fail.
|
||||
- **JSON-parse GOTCHA:** in any code the tests touch, parse with `var j := JSON.new(); j.parse(text)` and check `!= OK` — NOT `JSON.parse_string()` (the project's `.gutconfig.json` `failure_error_types` turns its engine error into a false test failure).
|
||||
- **Timeout default 35.0s** — just above the server's `OLLAMA_TIMEOUT_SECONDS=30`, so a slow *model* surfaces the server's own error first while a fully-hung proxy is still bounded. A hang reuses the existing `HttpTransport` `result != RESULT_SUCCESS → DmResponse.failed()` path (already covered by `test_transport_failure_degrades`); do not add a new service-level degrade test.
|
||||
- **Rotation index is instance state, not reset per call** (spec decision 4): one `ConsideringPhrases`/`ConsideringIndicator` instance persists on the harness; `next()` advances a running index that `start()`/`stop()` never reset.
|
||||
- **Branch:** all work on `feature/considering-state`, branched off `dev`. Never commit to `dev`/`master` directly (CLAUDE.md §18); the human owns the `dev` merge and push.
|
||||
- **Client tests:** `cd client && ./run_tests.sh` (full) or `./run_tests.sh -gtest=res://tests/unit/test_X.gd` (one file). A new `class_name` may need `cd client && godot --headless --import` once before it resolves.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `client/scripts/ui/considering_phrases.gd` — CREATE: pure phrase-pool cycler.
|
||||
- `client/content/considering.json` — CREATE: authored phrase pool.
|
||||
- `client/scripts/net/proxy_config.gd` — MODIFY: add `request_timeout_seconds()`.
|
||||
- `client/scripts/net/http_transport.gd` — MODIFY: set `_http.timeout`.
|
||||
- `client/scripts/ui/considering_indicator.gd` — CREATE: node shim (Timer + Label).
|
||||
- `client/scripts/harness/narrate_harness.gd` — MODIFY: show indicator around the await.
|
||||
- `client/scripts/harness/npc_harness.gd` — MODIFY: add a status Label + show indicator.
|
||||
- Tests: `client/tests/unit/test_considering_phrases.gd` (create), `client/tests/unit/test_considering_indicator.gd` (create), `client/tests/unit/test_net_primitives.gd` (add ProxyConfig + HttpTransport timeout tests).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ConsideringPhrases` (pure) + authored pool
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/ui/considering_phrases.gd`
|
||||
- Create: `client/content/considering.json`
|
||||
- Test: `client/tests/unit/test_considering_phrases.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConsideringPhrases.new(path := PATH)`; `next() -> String` (returns the pool entry at a running index and advances it, wrapping; `LAST_RESORT` when the pool is empty/missing). Constants `PATH`, `LAST_RESORT`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_considering_phrases.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
|
||||
|
||||
|
||||
func test_missing_file_returns_last_resort():
|
||||
var p = ConsideringPhrases.new("res://content/does_not_exist.json")
|
||||
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
|
||||
assert_eq(p.next(), ConsideringPhrases.LAST_RESORT)
|
||||
|
||||
|
||||
func test_cycles_pool_in_order_and_wraps():
|
||||
# Real authored file has at least 3 lines; assert order + wrap on it.
|
||||
var p = ConsideringPhrases.new() # default path — real content
|
||||
var first = p.next()
|
||||
var second = p.next()
|
||||
assert_ne(first, "")
|
||||
assert_ne(first, second) # advances, not stuck
|
||||
# Drain a full cycle back to `first` to prove it wraps (bounded loop).
|
||||
var seen := [first, second]
|
||||
for i in range(20):
|
||||
var n = p.next()
|
||||
if n == first:
|
||||
return # wrapped — success
|
||||
seen.append(n)
|
||||
assert_true(false, "did not wrap within 22 calls: %s" % [seen])
|
||||
|
||||
|
||||
func test_index_persists_across_calls_not_reset():
|
||||
var p = ConsideringPhrases.new()
|
||||
var a = p.next()
|
||||
var b = p.next()
|
||||
assert_ne(a, b) # second call did not re-return the first phrase
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_considering_phrases.gd`
|
||||
Expected: FAIL — script does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `client/content/considering.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"considering": [
|
||||
"The Master considers your words.",
|
||||
"Somewhere, dice are gathered.",
|
||||
"The tale weighs its next turn.",
|
||||
"The dark shifts, thinking.",
|
||||
"A page is turned, unhurried."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Create `client/scripts/ui/considering_phrases.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name ConsideringPhrases
|
||||
extends RefCounted
|
||||
## Authored "the GM is thinking" lines (charter §13 — even loading is content).
|
||||
## Pure: cycles a phrase pool in order on a running index that is instance state
|
||||
## (spec decision 4 — the indicator reuses one instance so the first phrase varies
|
||||
## turn to turn without randomness). A missing/malformed file degrades to one
|
||||
## hardcoded constant, so the indicator itself cannot fail.
|
||||
|
||||
const PATH := "res://content/considering.json"
|
||||
const LAST_RESORT := "The Master considers your words."
|
||||
|
||||
var _pool: Array = []
|
||||
var _index: int = 0
|
||||
|
||||
|
||||
func _init(path := PATH) -> void:
|
||||
_pool = _load(path)
|
||||
|
||||
|
||||
func next() -> String:
|
||||
if _pool.is_empty():
|
||||
return LAST_RESORT
|
||||
var phrase := str(_pool[_index % _pool.size()])
|
||||
_index += 1
|
||||
return phrase
|
||||
|
||||
|
||||
static func _load(path: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
# Instance parse() (not JSON.parse_string) so malformed input returns an
|
||||
# Error code instead of the engine error the test config promotes to a fail.
|
||||
var json := JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
return []
|
||||
var data = json.get_data()
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get("considering", [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh -gtest=res://tests/unit/test_considering_phrases.gd`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/considering_phrases.gd client/content/considering.json client/tests/unit/test_considering_phrases.gd
|
||||
git commit -m "feat(client): ConsideringPhrases — authored rotating think-lines (§13)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ProxyConfig.request_timeout_seconds`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/net/proxy_config.gd`
|
||||
- Test: `client/tests/unit/test_net_primitives.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ProxyConfig.request_timeout_seconds() -> float` (a `ProjectSettings` key, default `TIMEOUT_DEFAULT := 35.0`; a non-positive/invalid value falls back to the default so a hang can never be re-enabled). Constants `TIMEOUT_SETTING`, `TIMEOUT_DEFAULT`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `client/tests/unit/test_net_primitives.gd`:
|
||||
|
||||
```gdscript
|
||||
func test_request_timeout_default():
|
||||
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
if ProjectSettings.has_setting(ProxyConfig.TIMEOUT_SETTING):
|
||||
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
|
||||
|
||||
|
||||
func test_request_timeout_override_and_reject_nonpositive():
|
||||
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 12.5)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), 12.5)
|
||||
# A non-positive override must NOT re-enable the hang — falls back to default.
|
||||
ProjectSettings.set_setting(ProxyConfig.TIMEOUT_SETTING, 0.0)
|
||||
assert_eq(ProxyConfig.request_timeout_seconds(), ProxyConfig.TIMEOUT_DEFAULT)
|
||||
ProjectSettings.clear(ProxyConfig.TIMEOUT_SETTING)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: FAIL — `request_timeout_seconds` / `TIMEOUT_SETTING` not found.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `client/scripts/net/proxy_config.gd`, add after the `base_url` block:
|
||||
|
||||
```gdscript
|
||||
const TIMEOUT_SETTING := "coc_rpg/proxy_request_timeout_seconds"
|
||||
const TIMEOUT_DEFAULT := 35.0
|
||||
|
||||
|
||||
static func request_timeout_seconds() -> float:
|
||||
# Just above the server's OLLAMA_TIMEOUT_SECONDS (30) so a slow model surfaces
|
||||
# the server's own error first, while a fully-hung proxy is still bounded. A
|
||||
# non-positive value would re-enable the infinite hang, so reject it.
|
||||
var v := float(ProjectSettings.get_setting(TIMEOUT_SETTING, TIMEOUT_DEFAULT))
|
||||
return v if v > 0.0 else TIMEOUT_DEFAULT
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/net/proxy_config.gd client/tests/unit/test_net_primitives.gd
|
||||
git commit -m "feat(client): ProxyConfig.request_timeout_seconds (default 35s, reject <=0)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `HttpTransport` applies the request timeout
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/net/http_transport.gd`
|
||||
- Test: `client/tests/unit/test_net_primitives.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProxyConfig.request_timeout_seconds()` (Task 2).
|
||||
- Produces: constructing `HttpTransport.new(http)` sets `http.timeout` to the configured value. Closes the `_http.timeout=0` gap; a hang now completes with `RESULT_TIMEOUT`, which the existing code maps to `DmResponse.failed()`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `client/tests/unit/test_net_primitives.gd`:
|
||||
|
||||
```gdscript
|
||||
func test_http_transport_sets_request_timeout():
|
||||
var HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
var ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
var http := HTTPRequest.new()
|
||||
autofree(http)
|
||||
HttpTransport.new(http)
|
||||
assert_eq(http.timeout, ProxyConfig.request_timeout_seconds())
|
||||
assert_gt(http.timeout, 0.0) # the whole point — no longer disabled
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: FAIL — `http.timeout` is `0.0` (the default), not the configured value.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `client/scripts/net/http_transport.gd`, in `_init` (it already `preload`s `ProxyConfig`), set the timeout:
|
||||
|
||||
```gdscript
|
||||
func _init(http: HTTPRequest) -> void:
|
||||
_http = http
|
||||
# Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed()
|
||||
# → the service degrades) instead of never returning. Without this the
|
||||
# considering-state indicator would rotate forever on a hang.
|
||||
_http.timeout = ProxyConfig.request_timeout_seconds()
|
||||
```
|
||||
|
||||
(Keep the rest of `_init`/`post_json` unchanged.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_net_primitives.gd`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/net/http_transport.gd client/tests/unit/test_net_primitives.gd
|
||||
git commit -m "fix(client): HttpTransport sets request timeout (no more infinite hang)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ConsideringIndicator` (node shim)
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/ui/considering_indicator.gd`
|
||||
- Test: `client/tests/unit/test_considering_indicator.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ConsideringPhrases` (Task 1).
|
||||
- Produces: `ConsideringIndicator.new(phrases := null)` (defaults to a fresh `ConsideringPhrases`); `start(label: Label)` shows the first phrase immediately and starts a repeating `Timer` (`ROTATE_SECONDS := 2.0`) that rotates the label; `stop()` stops the timer and clears the label. Extends `Node` (needs a `Timer` in the tree). Holds one `ConsideringPhrases` for its lifetime.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_considering_indicator.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
const ConsideringPhrases = preload("res://scripts/ui/considering_phrases.gd")
|
||||
|
||||
|
||||
func test_start_shows_first_phrase_and_stop_clears():
|
||||
# Point phrases at a missing file so next() is deterministically LAST_RESORT.
|
||||
var phrases = ConsideringPhrases.new("res://content/does_not_exist.json")
|
||||
var ind = ConsideringIndicator.new(phrases)
|
||||
add_child_autofree(ind) # _ready() creates the Timer child
|
||||
var label := Label.new()
|
||||
add_child_autofree(label)
|
||||
|
||||
ind.start(label)
|
||||
assert_eq(label.text, ConsideringPhrases.LAST_RESORT) # first phrase shown at once
|
||||
|
||||
ind.stop()
|
||||
assert_eq(label.text, "") # cleared on stop
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_considering_indicator.gd`
|
||||
Expected: FAIL — script does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```gdscript
|
||||
# client/scripts/ui/considering_indicator.gd
|
||||
class_name ConsideringIndicator
|
||||
extends Node
|
||||
## Thin node shim (charter §13). While a call is in flight it rotates an authored
|
||||
## ConsideringPhrases line onto a Label every ROTATE_SECONDS, reframing the wait
|
||||
## as the GM thinking. The phrase logic is the pure, tested ConsideringPhrases;
|
||||
## the Timer/tree wiring here is the untested shim (verified by running the
|
||||
## harness). One instance persists on the harness so the rotation index is not
|
||||
## reset per call (spec decision 4).
|
||||
|
||||
const ROTATE_SECONDS := 2.0
|
||||
|
||||
var _phrases: ConsideringPhrases
|
||||
var _timer: Timer
|
||||
var _label: Label
|
||||
|
||||
|
||||
func _init(phrases: ConsideringPhrases = null) -> void:
|
||||
_phrases = phrases if phrases != null else ConsideringPhrases.new()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_timer = Timer.new()
|
||||
_timer.wait_time = ROTATE_SECONDS
|
||||
_timer.one_shot = false
|
||||
_timer.timeout.connect(_advance)
|
||||
add_child(_timer)
|
||||
|
||||
|
||||
func start(label: Label) -> void:
|
||||
_label = label
|
||||
_advance() # show a phrase immediately, don't wait ROTATE_SECONDS
|
||||
_timer.start()
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
_timer.stop()
|
||||
if _label != null:
|
||||
_label.text = ""
|
||||
_label = null
|
||||
|
||||
|
||||
func _advance() -> void:
|
||||
if _label != null:
|
||||
_label.text = _phrases.next()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh -gtest=res://tests/unit/test_considering_indicator.gd`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/considering_indicator.gd client/tests/unit/test_considering_indicator.gd
|
||||
git commit -m "feat(client): ConsideringIndicator — rotates think-lines on a Timer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire the indicator into both harnesses
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/harness/narrate_harness.gd`
|
||||
- Modify: `client/scripts/harness/npc_harness.gd`
|
||||
- (No unit test — throwaway harnesses, verified by running.)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ConsideringIndicator` (Task 4).
|
||||
|
||||
- [ ] **Step 1: Wire `narrate_harness.gd`**
|
||||
|
||||
It already has a `_status: Label`. Add the indicator field, build it in `_ready`, and wrap the await.
|
||||
|
||||
Add the preload near the other consts:
|
||||
|
||||
```gdscript
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
```
|
||||
|
||||
Add the field near the other vars:
|
||||
|
||||
```gdscript
|
||||
var _indicator: ConsideringIndicator
|
||||
```
|
||||
|
||||
At the end of `_ready()` (after the status label is created), add:
|
||||
|
||||
```gdscript
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
```
|
||||
|
||||
Replace the body of `_on_narrate_pressed()` with (the change: `start`/`stop` around the await instead of the static "…narrating…" text):
|
||||
|
||||
```gdscript
|
||||
func _on_narrate_pressed() -> void:
|
||||
_button.disabled = true
|
||||
_indicator.start(_status)
|
||||
var r := await _service.narrate(_log)
|
||||
_indicator.stop()
|
||||
_label.text = r.display_text
|
||||
for f in r.facts:
|
||||
_log.add_fact(f)
|
||||
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
|
||||
_button.disabled = false
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `npc_harness.gd`**
|
||||
|
||||
It has no status label. Add one, add the indicator, and wrap the await.
|
||||
|
||||
Add the preload near the other consts:
|
||||
|
||||
```gdscript
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
```
|
||||
|
||||
Add the fields near the other vars:
|
||||
|
||||
```gdscript
|
||||
var _status: Label
|
||||
var _indicator: ConsideringIndicator
|
||||
```
|
||||
|
||||
In `_ready()`, after the Speak button is added to the vbox, add a status label and the indicator:
|
||||
|
||||
```gdscript
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
```
|
||||
|
||||
In `_on_speak()`, wrap the service call — start after appending "You:", stop before rendering Fenn's reply:
|
||||
|
||||
```gdscript
|
||||
_append("[b]You:[/b] %s" % utterance)
|
||||
|
||||
var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0))
|
||||
var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log)
|
||||
|
||||
_indicator.start(_status)
|
||||
var r: NpcResult = await _service.speak(
|
||||
NPC_ID, utterance, _canon_log, disposition, available)
|
||||
_indicator.stop()
|
||||
```
|
||||
|
||||
(The rest of `_on_speak()` — MoveApplier, fact harvest, appends, `ends_conversation` — is unchanged.)
|
||||
|
||||
- [ ] **Step 3: Verify the project imports and the suite is green**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1; ./run_tests.sh`
|
||||
Expected: all unit tests PASS (the harness changes must not break import/parse or any existing test).
|
||||
|
||||
- [ ] **Step 4: Run the harness by hand (proxy + Ollama up)**
|
||||
|
||||
In the Godot editor, run `npc_harness.tscn` (F6). Confirm: on Speak, the status line rotates through the authored think-lines (~2s cadence) while waiting; it clears and Fenn's prose appears on return. Then stop the proxy and Speak again: the line rotates, then after ~35s the harness shows the authored fallback (not an infinite spin).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/harness/narrate_harness.gd client/scripts/harness/npc_harness.gd
|
||||
git commit -m "feat(client): harnesses show the considering-state around the call"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage** — every spec section maps to a task:
|
||||
- Decision 1–2 (reframe; loading is content) → Task 1 (`ConsideringPhrases` + `considering.json`).
|
||||
- Decision 3 (rotate ~2s; pure core / node shim) → Task 1 (pure) + Task 4 (shim, `ROTATE_SECONDS`).
|
||||
- Decision 4 (index is instance state, not reset) → Task 1 (running `_index`, test `test_index_persists…`) + Task 4 (one persistent instance).
|
||||
- Decision 5 (timeout fix, default 35, reject ≤0) → Task 2 + Task 3.
|
||||
- Decision 6 (no server change) → honoured; no server files in any task.
|
||||
- Decision 7 (both harnesses) → Task 5.
|
||||
- Components list → Tasks 1–5 one-to-one.
|
||||
- Error handling (hang → timeout → degrade; missing file → LAST_RESORT) → Task 3 (relies on existing `test_transport_failure_degrades`, noted in constraints, not duplicated) + Task 1 (`test_missing_file_returns_last_resort`).
|
||||
- Testing section → each task's tests; the Timer-fire path is explicitly run-verified in Task 5 Step 4, not unit-tested (matches the spec).
|
||||
|
||||
**Placeholder scan** — no TBD/TODO. Task 5 has no unit test *by design* (throwaway harness), verified by Step 3 (suite) + Step 4 (by-hand run); every code step shows complete code.
|
||||
|
||||
**Type consistency** — `ConsideringPhrases.next() -> String` and `.LAST_RESORT`/`.PATH` used identically across Tasks 1/4; `ConsideringIndicator.new(phrases)` / `start(label: Label)` / `stop()` match between Task 4 and Task 5; `ProxyConfig.request_timeout_seconds()` / `TIMEOUT_SETTING` / `TIMEOUT_DEFAULT` match between Tasks 2 and 3; `_status` is a `Label` in both harnesses (narrate already has it; npc adds it) so `start(_status)` type-checks.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Narrator / Ollama Pipeline (server) — Design
|
||||
|
||||
**Date:** 2026-07-09
|
||||
**Status:** approved (design); implementation plan to follow
|
||||
**Charter refs:** §2 (code owns state / AI owns text), §3 (tone/voice), §4 (the guarding proxy, model routing as config, logging from day one), §5 (the DM is not one prompt; Narrator = the good model), §10 (seed + full prompt logged), §11 (canon log), §12 (output contracts, tags, retry policy), §13 (degraded DM), §14 (cost/latency), §16 (prompts are source code)
|
||||
**Binds to:** the canon log contract (`docs/canon-log.md`, `docs/schemas/canon-log.schema.json`) and the existing proxy skeleton (`api/app/main.py` — role endpoints + `valid_turn` + unified 422). Consumed by the later client HTTP loop.
|
||||
|
||||
## Problem
|
||||
|
||||
The proxy validates every posted canon log but every role endpoint still `return {"detail": "not implemented"}` — no model is wired, no prompt is authored, nothing is logged. Until a real model sits behind `/dm/narrate`, the POC's one question — *does bounded AI dialogue feel alive?* (charter §17) — cannot be tested at all.
|
||||
|
||||
This plan makes `/dm/narrate` real: assemble the Narrator prompt + a canon-log digest, call Ollama, return the prose, log the call, and signal failure honestly — building the reusable server-side model-call pipeline that the other four roles later drop into.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope — the shared pipeline + the Narrator as its first, proving role:**
|
||||
|
||||
- `ollama_client.py` — httpx call to Ollama `/api/chat` (non-streaming), single retry, typed `ModelError`.
|
||||
- `routing.py` — role → `{model, options}` config, env-driven (§4/§5).
|
||||
- `prompts.py` — load a role's `.md` system prompt (cached) + `render_digest(canon_log)`.
|
||||
- `call_log.py` — structured JSON-lines logging of every call (§10).
|
||||
- `narrate.py` — the Narrator service: render → route → call → log → return.
|
||||
- The authored `narrator.md` system-prompt body (source code, §16).
|
||||
- `/dm/narrate` wired to the service; `main.py` handler stays thin.
|
||||
- Unit tests (mocked Ollama boundary) + one gated live smoke test.
|
||||
|
||||
**Out of scope — later plans (recorded in `docs/roadmap.md`):**
|
||||
|
||||
- Streaming (§14) — non-streaming request/response now; the Ollama client is shaped so a streaming mode is additive, not a rewrite.
|
||||
- The client HTTP layer, degraded-DM fallback display, and `[FACT]` harvest (the client already owns `TagExtractor`).
|
||||
- The other four roles (Adjudicator / Improviser / NPC / Banter).
|
||||
- The Replicate provider; auth / metering.
|
||||
|
||||
## Decisions (locked in brainstorming)
|
||||
|
||||
1. **Shared pipeline + Narrator first** — build the reusable machinery once; the Narrator proves it; the other roles become thin drop-ins.
|
||||
2. **Test: mocked boundary + gated live smoke** — unit tests use httpx `MockTransport` (no new dependency); a `@pytest.mark.live` smoke test hits the real reachable Ollama and is skipped by default.
|
||||
3. **Non-streaming now, streaming later** — request/response; streaming is a named later phase landed with the client HTTP layer that can consume it.
|
||||
4. **Typed error on model failure** — after one retry, `/dm/narrate` returns a non-200 (`502`) in the `{"detail": {...}}` family; the client owns the degraded-DM fallback on any non-200 (already specified in `docs/canon-log.md`). No server-side fallback prose.
|
||||
5. **Rendered digest into the prompt** — `narrator.md` is the system prompt; code renders the canon log into a curated, labeled digest in the user message; no raw JSON, no numbers.
|
||||
6. **Tag handling stays client-side** — the server returns raw prose (tags intact) and logs it; the client's `TagExtractor` strips for display and harvests `[FACT]` into its own canon log. §2 (client owns state) + DRY (one extractor).
|
||||
|
||||
## Architecture — layered modules, thin handler
|
||||
|
||||
```
|
||||
POST /dm/narrate (main.py handler — thin)
|
||||
└─ valid_turn (EXISTING) → 422 envelope on a bad canon log
|
||||
└─ narrate.run(canon_log):
|
||||
system = prompts.system_prompt("narrator") # narrator.md body (the IP)
|
||||
user = prompts.render_digest(canon_log) # curated labeled digest (§11 context)
|
||||
cfg = routing.for_role("narrator") # model + options (+ per-call seed) + think flag
|
||||
text = ollama_client.chat(cfg.model, [system, user], cfg.options, think=cfg.think) # 1 retry, else ModelError
|
||||
call_log.record(role, model, options, seed, messages, canon_log, text|error, latency_ms, ok)
|
||||
return text
|
||||
└─ 200 {"prose": text} | except ModelError → 502 {"detail": {"model_error": "<reason>"}}
|
||||
```
|
||||
|
||||
`ollama_client` / `routing` / `prompts` / `call_log` are the shared pipeline; `narrate.py` is the first role service. Adding a role later is a new service module + prompt + output parsing — the four shared modules are reused untouched. Mocks enter the system at the `ollama_client` boundary.
|
||||
|
||||
## The prompt — source code (§16)
|
||||
|
||||
### `narrator.md` system-prompt body (authored in this plan)
|
||||
|
||||
The file already carries role/voice header; this plan writes the instruction body. It must encode:
|
||||
|
||||
- **Task:** describe the *current* scene from the supplied context, second person, present tense. Scenes, outcomes, transitions only.
|
||||
- **Voice (§3):** dry; gritty, not grim; plays the world straight; never winks; nothing is "hilariously" anything; profanity earned, not decorative.
|
||||
- **Hard constraints** (enforced by wording — output is prose, not a schema):
|
||||
- Never invent a proper noun (person/place/thing) without emitting `[FACT: <the fact>]` so the client can harvest it (§11).
|
||||
- Never contradict an Established fact in the supplied context.
|
||||
- Never state a number, stat, HP, or Luck value; never decide or narrate the **player's** choices or actions (code owns those — describe the world, not the protagonist's will).
|
||||
- Output prose only, plus zero or more `[FACT:]` tags. No JSON, no headers, no meta-talk.
|
||||
- Brevity — a few sentences (§14, small-model discipline).
|
||||
|
||||
### `render_digest(canon_log) -> str` — a curated projection
|
||||
|
||||
Turns the posted log into a labeled block for the user message:
|
||||
|
||||
```
|
||||
Location: the Greywater docks
|
||||
Recent events:
|
||||
- Arrived at Greywater by barge before dawn
|
||||
- Brannoc recognised the harbourmaster and went quiet
|
||||
Established facts:
|
||||
- the eastern bridge out of Greywater is washed out
|
||||
- the harbourmaster is named Oda Fenn
|
||||
Companions present: Brannoc Thane, Cadwyn Vell
|
||||
Active quest: The Missing Ledger — Find who took Fenn's ledger
|
||||
Fortune: Fortune spits on you
|
||||
Player: Aldric, a sellsword
|
||||
|
||||
Describe the scene as it is now.
|
||||
```
|
||||
|
||||
**The digest is a projection, not a dump.** It surfaces narrative context and deliberately omits raw integers: companion `disposition` values render as names only, `luck_descriptor` renders as flavor ("Fortune: …", never the number, §7), `schema_version`/ids are dropped. This generalizes the §7 rule — the Narrator gets nouns and situation, never anything to do arithmetic on. Deterministic and unit-testable (assert the rendered string).
|
||||
|
||||
## Model routing (§4/§5)
|
||||
|
||||
```python
|
||||
# routing.py
|
||||
@dataclass(frozen=True)
|
||||
class RoleConfig:
|
||||
model: str
|
||||
options: dict # Ollama generation options, includes a per-call seed at call time
|
||||
think: bool | None = None # Ollama top-level `think` flag; None omits it
|
||||
|
||||
ROLES = {
|
||||
"narrator": RoleConfig(
|
||||
model=env("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest"), # §5 "the good model" — swappable per deploy
|
||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300}, # voice; capped length (§14)
|
||||
think=False, # qwen3.x thinking OFF — no <think> preamble in narrator prose
|
||||
),
|
||||
}
|
||||
def for_role(role: str) -> RoleConfig: ...
|
||||
```
|
||||
|
||||
- Base URL from `OLLAMA_BASE_URL` (already in `.env.example`).
|
||||
- Model and options are **config, not law** — swapping the Narrator model is an env change, exactly §4's "a deploy, not a client patch." `qwen3.5:latest` is the default (local models available: `qwen3.5`, `llama3.1`, `llama3.2`).
|
||||
- **`think: False` (qwen3.x thinking disabled):** qwen3.5 is a thinking model; without this it emits a `<think>…</think>` preamble that would pollute the narrator prose. `ollama_client` passes `think` as a **top-level** `/api/chat` field (not inside `options`) when `RoleConfig.think is not None`. Verified in the 2026-07-09 probe: `think:false` yields an empty `thinking` field and no leakage, and is **gracefully accepted by non-thinking models too** (llama3.1/3.2 return normally) — so it is safe across an env model swap.
|
||||
- **Per-call seed (§10):** the server sets an explicit `options.seed` per call (a random int, captured in the log) so any call is replayable against a candidate model — the "free eval infrastructure" of §4. Without setting it, Ollama's own random seed is unrecoverable.
|
||||
- **Provider abstraction (Ollama → Replicate) is deferred** (YAGNI). `routing.py` is the seam it slots into later; today, Ollama only.
|
||||
|
||||
### Model probe (2026-07-09)
|
||||
|
||||
Warm, seeded, narrator-shaped call (system + digest, `num_predict:300`) against the reachable Ollama:
|
||||
|
||||
| model | warm latency | tok/s | notes |
|
||||
|---|---|---|---|
|
||||
| **qwen3.5** (`think:false`) | ~2.0s | ~107 | richest prose; only model to emit `[FACT:]`; no `<think>` leak |
|
||||
| llama3.1 | ~1.2s | ~149 | solid 2nd-person prose |
|
||||
| llama3.2 | ~0.5s | ~295 | fastest, coherent, shorter |
|
||||
|
||||
Decision: **qwen3.5 is the default** (§5 "the good model") — ~2s non-streaming is acceptable for the POC and streaming hides it later. llama3.1/llama3.2 remain one-env-var swaps if latency needs to drop; the speed data is on record for that tuning call.
|
||||
|
||||
## Failure, retry, error contract (§12/§13)
|
||||
|
||||
`ollama_client.chat()` owns the retry; callers see success-or-`ModelError`.
|
||||
|
||||
Retry (exactly one, no backoff — §12 "never retry more than once, latency compounds") triggers on:
|
||||
- transport error (connection refused / DNS),
|
||||
- timeout (`OLLAMA_TIMEOUT_SECONDS`, default ~30s),
|
||||
- non-2xx from Ollama,
|
||||
- empty / whitespace-only content (the one "bad output" case; no JSON/tag parse to fail for prose).
|
||||
|
||||
Flow: attempt → on any of the above, retry once → still failing → raise `ModelError(reason)`.
|
||||
|
||||
Handler mapping:
|
||||
```
|
||||
200 {"prose": text} # success only
|
||||
502 {"detail": {"model_error": "<short reason>"}} # ModelError — same {"detail":{...}} family as the 422
|
||||
```
|
||||
|
||||
The client contract is already satisfied — `docs/canon-log.md` says the degraded-DM fallback fires on **any non-200**, so the client treats 422 and 502 identically. The `model_error` string is for logs/dev, never shown to the player. No authored fallback prose on the server (decision 4).
|
||||
|
||||
## Call logging (§10) — the eval infrastructure
|
||||
|
||||
`call_log.py` writes one structured JSON object per line, every call, success and failure — charter §10's "log the seed and the full prompt with every call" and §4's "replay yesterday's real traffic against a candidate model."
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-09T…Z",
|
||||
"role": "narrator",
|
||||
"model": "llama3.1:latest",
|
||||
"options": { "temperature": 0.8, "top_p": 0.9, "num_predict": 300, "seed": 481923 },
|
||||
"messages": [ {"role":"system","content":"…narrator.md…"}, {"role":"user","content":"…digest…"} ],
|
||||
"canon_log": { "…the posted log…": true },
|
||||
"ok": true,
|
||||
"response": "You stand on the rot-black wharf… [FACT: …]",
|
||||
"latency_ms": 1840
|
||||
}
|
||||
```
|
||||
|
||||
On failure: `"ok": false, "error": "<reason>"`, no `response`.
|
||||
|
||||
- Everything needed to replay is in the line — `canon_log` + `messages` + `model` + `seed`.
|
||||
- **Sink:** JSON-lines to **stdout by default** (12-factor; captured by Docker/fly platform logs), redirectable to a file via `CALL_LOG_PATH` for local replay. The writer takes an injectable sink so tests capture and assert the record.
|
||||
- No secrets to redact — the client never sends keys (§4), the canon log carries no PII, and the prompt is exactly what we want logged.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit — httpx `MockTransport`, deterministic, no new dependency:**
|
||||
|
||||
- `ollama_client`: success parses content; retry (fail-once → recovers; fail-twice → `ModelError`); timeout → `ModelError`; non-2xx → retry → `ModelError`; empty content → retry → `ModelError`. Asserts the outgoing payload (model, `messages`, `stream:false`, options incl. `seed`, and top-level `think` present only when set).
|
||||
- `prompts`: `render_digest` on `canon_log_valid.json` — correct labeled block, dispositions omitted, luck as descriptor-not-number; `system_prompt("narrator")` loads the file.
|
||||
- `routing`: `for_role` returns the configured model + options; env override respected.
|
||||
- `call_log`: writes a parseable JSON line with every field to the injected sink; failure-record shape.
|
||||
- endpoint (`TestClient`, Ollama boundary mocked): valid log → 200 `{"prose": …}`; `ModelError` → 502 envelope; invalid log → still 422 (existing behavior intact).
|
||||
|
||||
**Live smoke — `@pytest.mark.live`, skipped by default** (registered marker; run with `-m live` or an env flag): hits the real reachable Ollama, asserts 200 + non-empty prose of sane length (no tag assertion — nondeterministic). Run during implementation to prove the wire.
|
||||
|
||||
## Phases (milestones)
|
||||
|
||||
- **Phase 1 — Pipeline foundation:** `ollama_client`, `routing`, `call_log`, `prompts` (loader + digest), each mock/unit-tested. Endpoint untouched.
|
||||
- **Phase 2 — Narrator online:** author `narrator.md` body → `narrate` service → wire `/dm/narrate` → endpoint tests (mock) + gated live smoke green. "Prove the loop" on the server side.
|
||||
|
||||
## Storage & config
|
||||
|
||||
```
|
||||
api/app/
|
||||
ollama_client.py routing.py prompts.py call_log.py narrate.py
|
||||
main.py (modified — /dm/narrate wired)
|
||||
api/prompts/
|
||||
narrator.md (body authored)
|
||||
api/tests/
|
||||
test_ollama_client.py test_routing.py test_prompts.py
|
||||
test_call_log.py test_narrate_endpoint.py test_live_smoke.py (gated)
|
||||
```
|
||||
|
||||
New env (add to `.env.example`): `OLLAMA_NARRATOR_MODEL`, `OLLAMA_TIMEOUT_SECONDS`, `CALL_LOG_PATH` (optional). `OLLAMA_BASE_URL` already present.
|
||||
|
||||
## Consequences / follow-ups
|
||||
|
||||
- The client HTTP plan consumes `{"prose": …}` / the 502 envelope, runs `TagExtractor`, harvests `[FACT]`, and displays the degraded-DM fallback on non-200.
|
||||
- The streaming plan adds a streaming mode to `ollama_client` + an SSE/chunked response, landed with the client stream consumer.
|
||||
- The other four roles reuse `ollama_client`/`routing`/`prompts`/`call_log`, adding their own service + prompt + output parsing (the Adjudicator adds strict-JSON parsing + the §12 one-retry-with-error-appended path).
|
||||
- Replicate provider slots into `routing.py`; auth/metering are middleware, retrofit later (§4).
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. The Narrator model default and generation options (temperature/length) are tuning values, seeded as env-overridable defaults. Prompt wording is authored and reviewed in Phase 2.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Bounded NPC conversation (M2) — design
|
||||
|
||||
Date: 2026-07-10
|
||||
Milestone: **M2 — prove aliveness** (roadmap). This is the second M2 item, and
|
||||
the surface §17 is really asking about.
|
||||
Charter side (§2): **both** — the NPC's voice is *text* (AI owns it); the moves
|
||||
it performs are *state* (code validates and applies them). Two systems sharing
|
||||
one HTTP call.
|
||||
|
||||
## Goal
|
||||
|
||||
Put a bounded NPC on screen. The player types free text to a specific NPC; the
|
||||
NPC answers in its own voice and may emit zero or more inline *move* tags. Code
|
||||
extracts the moves, validates each against real game state, applies the valid
|
||||
ones, **silently drops the invalid ones, and always keeps the prose** (§6).
|
||||
|
||||
```
|
||||
utterance + canon_log + npc_id + disposition + available_moves
|
||||
→ HTTP → /npc/speak → prose(+move tags) → screen
|
||||
→ extract → validate → apply valid moves → drop invalid → harvest [FACT]
|
||||
```
|
||||
|
||||
This is the core POC experiment: **does bounded AI dialogue feel alive, or does
|
||||
it feel like a chatbot in a costume?** Everything here exists to make that test
|
||||
possible with one real NPC.
|
||||
|
||||
## What already exists (and is proven)
|
||||
|
||||
- **Server pipeline (M1, merged):** `narrate.py` is the template — render a
|
||||
digest, route the model (`routing.for_role`), call Ollama with one retry, log
|
||||
the call (§10), return **raw prose with tags intact**. `/dm/narrate` returns
|
||||
`{"prose": …}` / `502 {"detail":{"model_error":…}}` / `422
|
||||
{"detail":{"canon_log_errors":…}}`. `content.py` already reads
|
||||
`/content/world/npcs/*.json` (ids only, today). `prompts.system_prompt(role)`
|
||||
loads `api/prompts/<role>.md` below its `---` front-matter delimiter.
|
||||
- **Client loop (M2, merged):** `DmService` posts the canon log through an
|
||||
injectable `DmTransport`, parses status, degrades to an authored fallback,
|
||||
and returns a result the caller applies (state writes stay explicit, §2).
|
||||
`TagExtractor.extract(prose)` already returns
|
||||
`{clean_text, facts, dispositions, moves}` — **moves are already parsed**; the
|
||||
new work is the validate-and-apply layer, not the parse.
|
||||
- **Client state homes:** `GameState` (`client/scripts/state/game_state.gd`) is
|
||||
the sole home of `npc_dispositions` (world-NPC id → int) and `inventory`
|
||||
(item_id → qty). Town NPCs are **not** canon-log `party` rows — companions
|
||||
are; town NPCs live only in `GameState`. `CanonLog` owns `active_quests` and
|
||||
its mutators (`set_quest_status`, `add_fact`).
|
||||
- **Content:** `/content/world/npcs/{cadwyn_vell,brannoc_thane}.json` (companions,
|
||||
empty knowledge). `/content/world/quests/find_the_ledger.json` exists. The POC
|
||||
town NPC **Fenn** — who owns that quest — does not exist yet.
|
||||
|
||||
## Decisions
|
||||
|
||||
Settled during brainstorming; each is load-bearing.
|
||||
|
||||
1. **Server owns the secret text; client owns all live state.** The proxy stays
|
||||
stateless for *game* state — no DB. It loads static authored material
|
||||
(prompts, persona, knowledge lists) into memory: zero per-player cost, and
|
||||
the spoilers never ship in the client binary. Every piece of dynamic
|
||||
state (saves, inventory, disposition, luck, quest progress) lives on the
|
||||
client (§4). The only DB this project ever grows is auth + a credits ledger,
|
||||
later, and it is not game state. This matches the intended pay-per-AI-credit
|
||||
model and the self-host-your-own-Ollama option: a self-hoster runs the client
|
||||
and points the proxy at their model — no server state to run.
|
||||
|
||||
2. **Trio split by sensitivity (fork 1).** The §6 trio is assembled across the
|
||||
boundary:
|
||||
- **Server-side (spoilers/IP):** `persona` + `knowledge[]`, loaded from
|
||||
`/content/world/npcs/<id>.json`. The client never sends or sees these.
|
||||
- **Client-side (live legality):** `available_moves[]` is computed by the
|
||||
client from the NPC's non-spoiler **capability** block plus live game
|
||||
state, and sent in the request. Legality depends on state the client owns
|
||||
(§2), so the client must compute it.
|
||||
|
||||
3. **Disposition is sent as an integer.** The request carries this NPC's current
|
||||
disposition (`GameState.npc_dispositions[npc_id]`, default 0) as an int.
|
||||
§6 explicitly gives the NPC prompt "disposition (integer, −100..100)". This
|
||||
does **not** violate §7 — §7 hides *Luck*, not disposition; the narrate
|
||||
digest's number-hiding is a Narrator concern, not an NPC one. `/npc/speak`
|
||||
therefore has its own request contract, not the bare `{canon_log}` of narrate.
|
||||
|
||||
4. **Free text goes straight to the NPC prompt — no Adjudicator.** The moves are
|
||||
the *NPC's*, not the player's, so nothing needs to be mapped onto a legal
|
||||
*player* action. The player's raw utterance is passed to the prompt as
|
||||
context. This is what lets the aliveness experiment run now, before M3's
|
||||
Adjudicator exists — and a menu would blunt the exact free-form quality under
|
||||
test (§15's "free text → Adjudicator" is about player game-actions).
|
||||
|
||||
5. **One canonical tag form.** All eight moves use `[MOVE: name(args)]`.
|
||||
`[ADJUST_DISPOSITION: n]` stays an accepted alias (already wired in
|
||||
`TagExtractor`). §6's bare `[REFUSE]` shorthand is dropped — the extractor
|
||||
never parsed it. The prompt instructs the model to use `[MOVE: …]` only.
|
||||
|
||||
6. **Client architecture: sibling service + pure validator.** A new `NpcService`
|
||||
parallels `DmService`, reusing `DmTransport`, `FallbackLibrary`, and
|
||||
`TagExtractor`. Move logic lives in a **pure `MoveValidator`**
|
||||
(`(extracted_moves, available_moves, state_view) → {valid, dropped}`) with no
|
||||
side effects, plus a small `MoveApplier` the caller invokes to write state.
|
||||
Application stays explicit at the call site, mirroring how `narrate()` returns
|
||||
facts for the caller to apply (§2). Two small pure units beat one branchy
|
||||
service.
|
||||
|
||||
7. **Authoring scope: mechanism + one live NPC.** Build the full machinery and
|
||||
author exactly one town NPC end-to-end — **Fenn**, who already owns
|
||||
`find_the_ledger`. The second town NPC's knowledge list is authoring work
|
||||
(§6: "real authoring work") that follows once the mechanism is proven. One
|
||||
live NPC answers the aliveness question.
|
||||
|
||||
8. **Throwaway NPC harness scene.** Like the narrate harness: a `RichTextLabel`,
|
||||
a `LineEdit` for the utterance, a "Speak" button, and a small readout of
|
||||
applied/dropped moves + current disposition. Enough to drive a multi-turn
|
||||
conversation until `end_conversation`. Real dialogue UI is a later wireframe;
|
||||
do not gold-plate.
|
||||
|
||||
## The NPC content file
|
||||
|
||||
`/content/world/npcs/fenn.json` — one file, three sections split by who reads
|
||||
them:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "fenn",
|
||||
"name": "Fenn",
|
||||
"role": "townsfolk",
|
||||
"persona": "Harried dockside clerk; talks in ledgers and grievances. Server-only.",
|
||||
"knowledge": [
|
||||
"His ledger went missing two nights ago.",
|
||||
"He last saw it when the Varrell twins were drinking in the counting-room.",
|
||||
"He owes money he cannot cover if the ledger surfaces in the wrong hands."
|
||||
],
|
||||
"capabilities": {
|
||||
"offerable_quests": ["find_the_ledger"],
|
||||
"giveable_items": [],
|
||||
"revealable_topics": ["varrell_twins", "fenns_debt"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Server reads** `persona` + `knowledge`. These are the spoilers; they are
|
||||
sent to the model, never to or from the client.
|
||||
- **Client reads** `capabilities` to compute `available_moves`. Non-spoiler
|
||||
handles only — ids, not content.
|
||||
- **Deferred hardening (not this piece):** for the shipped binary, physically
|
||||
split `persona`/`knowledge` into a server-only file so they cannot be
|
||||
datamined from the client bundle. Today the client simply ignores those keys.
|
||||
Captured as a deferred minor; §4 already puts us server-authoritative for the
|
||||
prose, so this is a bundling nicety, not a correctness gap.
|
||||
|
||||
## Contracts
|
||||
|
||||
### Request — `POST /npc/speak`
|
||||
|
||||
```json
|
||||
{
|
||||
"canon_log": { "...schema-v1 canon log..." },
|
||||
"npc_id": "fenn",
|
||||
"disposition": 0,
|
||||
"available_moves": ["offer_quest(find_the_ledger)", "reveal(varrell_twins)",
|
||||
"reveal(fenns_debt)", "refuse", "end_conversation",
|
||||
"become_hostile", "adjust_disposition"],
|
||||
"utterance": "I heard you lost something."
|
||||
}
|
||||
```
|
||||
|
||||
`available_moves` entries are **concrete signatures** where the move takes an id
|
||||
(`offer_quest(find_the_ledger)`), and bare names for the universal moves
|
||||
(`refuse`, `end_conversation`, `become_hostile`, `adjust_disposition`,
|
||||
`accept_item`). The server injects this list verbatim into the prompt so the
|
||||
model knows exactly what it may do; the client re-validates every emitted move
|
||||
against it anyway (belt and suspenders — the model can still hallucinate).
|
||||
|
||||
### Responses
|
||||
|
||||
- `200` → `{"prose": "<raw prose with [MOVE:]/[FACT:] tags>"}` — same shape as
|
||||
narrate; the client extracts.
|
||||
- `502` → `{"detail": {"model_error": "<str>"}}`.
|
||||
- `422` → `{"detail": {"canon_log_errors": [ … ]}}` — invalid canon log, or an
|
||||
`npc_id` that resolves to no content file, or a malformed request body (reuses
|
||||
the unified 422 envelope).
|
||||
|
||||
## Client: available_moves computation and the move → state map
|
||||
|
||||
`available_moves` starts from the NPC's `capabilities` and is filtered by live
|
||||
state:
|
||||
|
||||
| Move | Offered when | Applied to |
|
||||
|---|---|---|
|
||||
| `offer_quest(q)` | `q ∈ offerable_quests` **and** `q` not already active/complete in `canon_log.active_quests` | append active quest to `canon_log.active_quests` |
|
||||
| `reveal(t)` | `t ∈ revealable_topics` and not already revealed | mark revealed (`GameState`) + `CanonLog.add_fact` |
|
||||
| `give_item(i)` | `i ∈ giveable_items` and not already given | `GameState.add_item(i, 1)` |
|
||||
| `accept_item(i)` | player holds `i` in `GameState.inventory` | decrement inventory |
|
||||
| `adjust_disposition(delta)` | always | `GameState.set_npc_disposition`, **delta clamped to ±15** so one line can't swing standing wholesale |
|
||||
| `refuse` | always | no state change (prose-only signal) |
|
||||
| `end_conversation` | always | flow control — harness ends the loop |
|
||||
| `become_hostile` | always | disposition → hostile floor + end conversation |
|
||||
|
||||
Validation for an emitted move = **name ∈ available_moves** *and* the per-move
|
||||
precondition still holds against live state. Fail either → drop it, log it, keep
|
||||
the prose. `adjust_disposition`, `end_conversation`, `refuse`, and
|
||||
`become_hostile` carry no authored capability and no state precondition, so they
|
||||
are always in `available_moves`. `accept_item` also carries no authored
|
||||
capability, but is offered only while the player's inventory is non-empty (the
|
||||
NPC can only take what the player has).
|
||||
|
||||
Disposition target is `GameState.npc_dispositions` — **not** `CanonLog`'s
|
||||
party-only `adjust_disposition`, which only finds companion rows. This is the
|
||||
subtle correctness point: town NPCs have no canon-log row.
|
||||
|
||||
## Components
|
||||
|
||||
**Server (mirrors `narrate.py`):**
|
||||
|
||||
- `api/app/npc.py` — `run(req) -> str`: load persona+knowledge via `content.py`,
|
||||
render an NPC digest (canon-log context + persona + disposition +
|
||||
`knowledge[]` + `available_moves[]` + the player's utterance), route
|
||||
`for_role("npc")`, call Ollama with one retry, log (§10), return raw prose.
|
||||
- `api/app/content.py` — add `load_npc(npc_id) -> dict | None` returning
|
||||
`persona`/`knowledge`; `None` when unknown.
|
||||
- `api/app/main.py` — replace the `/npc/speak` stub: a new `NpcSpeakRequest`
|
||||
pydantic model (still runs the shared canon-log validator), 422 on unknown
|
||||
`npc_id`, `502` on `ModelError`, else `{"prose": …}`.
|
||||
- `api/prompts/npc.md` — author the prompt body (voice bounded by `knowledge`
|
||||
only; `[MOVE: …]` vocabulary; use only moves from `available_moves`;
|
||||
disposition colours tone; never invent proper nouns without `[FACT: …]`).
|
||||
|
||||
**Client (mirrors `DmService`):**
|
||||
|
||||
- `client/scripts/net/npc_service.gd` — `NpcService.speak(npc_id, utterance,
|
||||
canon_log, state_view) -> NpcResult`. Builds the request, posts through
|
||||
`DmTransport`, degrades to the NPC fallback on any failure, extracts tags,
|
||||
runs `MoveValidator`, returns the result. Applies no state itself.
|
||||
- `client/scripts/npc/move_validator.gd` — **pure**: `(extracted_moves,
|
||||
available_moves, state_view) -> {valid, dropped}`.
|
||||
- `client/scripts/npc/move_applier.gd` — writes validated moves to
|
||||
`GameState`/`CanonLog`; invoked by the caller (harness) after `speak`.
|
||||
- `client/scripts/npc/npc_content.gd` — loads a `capabilities` block and computes
|
||||
`available_moves` from it + live state.
|
||||
- `client/scripts/net/npc_result.gd` — `{display_text, facts, valid_moves,
|
||||
dropped_moves, degraded}`.
|
||||
- `client/content/fallback/npc.json` — one authored in-voice degraded line;
|
||||
no moves applied on degrade (§13).
|
||||
- `client/scenes/npc_harness.tscn` (+ script) — throwaway driver.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Any non-200, transport failure, or missing `prose` → `NpcResult` degraded with
|
||||
the authored fallback line and **zero moves** (a degraded turn never mutates
|
||||
state).
|
||||
- Model failure server-side → one retry (pipeline default), then `502`; client
|
||||
degrades.
|
||||
- The whole point of §6 is that a hallucinated or illegal move is **not an
|
||||
error**: it is dropped and logged, prose kept.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`MoveValidator` (pure, GUT):** name-not-in-available dropped; precondition
|
||||
failures (quest already active, item not held, unknown id) dropped; delta
|
||||
clamp; valid moves pass. The densest test surface — the heart of §6.
|
||||
- **`NpcService` with `FakeDmTransport`:** 200 with mixed valid/invalid move
|
||||
tags → correct split + prose kept; 502/422/transport-fail → degraded, no
|
||||
moves; facts harvested.
|
||||
- **`NpcContent` available_moves computation:** capability + state → expected
|
||||
list.
|
||||
- **Server `npc.py` with a fake Ollama** (mirror the narrate tests): digest
|
||||
includes persona/knowledge/disposition/available_moves/utterance; prose
|
||||
returned raw; unknown `npc_id` → 422.
|
||||
- **Gated live smoke:** real model as Fenn — grounded answer, a legal move
|
||||
landing, an illegal move (if emitted) dropped, no leaked spoilers beyond
|
||||
`knowledge`.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** `/npc/speak` server role; `content.load_npc`; `npc.md` prompt; Fenn's
|
||||
content file; client `NpcService` + `MoveValidator` + `MoveApplier` +
|
||||
`NpcContent` + `NpcResult`; NPC fallback content; throwaway harness; full test
|
||||
set + gated live smoke.
|
||||
|
||||
**Out:** the second town NPC's knowledge list (mechanism first); real dialogue
|
||||
UI (later wireframe); Adjudicator / free-text player *actions* (M3); streaming
|
||||
(next M2 item, gated on aliveness); combat/hostility resolution beyond setting
|
||||
the flag; physically splitting server-only content out of the client bundle
|
||||
(deferred hardening).
|
||||
245
docs/superpowers/specs/2026-07-10-client-http-loop-design.md
Normal file
245
docs/superpowers/specs/2026-07-10-client-http-loop-design.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Client HTTP loop (M2) — design
|
||||
|
||||
Date: 2026-07-10
|
||||
Milestone: **M2 — prove aliveness** (roadmap). This is the first item.
|
||||
Charter side (§2): **state** — the client owns the loop and consumes AI text; it
|
||||
never lets prose write game state directly.
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the first end-to-end vertical slice of the game:
|
||||
|
||||
```
|
||||
CanonLog → HTTP → /dm/narrate → prose → screen → fact-harvest
|
||||
```
|
||||
|
||||
A human presses a button, the running client posts its canon log to the proxy,
|
||||
and authored-quality generated prose appears on screen. Every `[FACT: …]` the
|
||||
Narrator emits is harvested into the client's own canon log. Any failure shows a
|
||||
degraded-DM fallback (§13) in the Narrator's voice — never an error.
|
||||
|
||||
This slice proves the pipe. It does not build real game UI (that waits for a
|
||||
separate wireframe) and it does not add NPC moves or disposition (that is the
|
||||
next M2 item, `/npc/speak`).
|
||||
|
||||
## What already exists
|
||||
|
||||
- **Server:** `/dm/narrate` is fully wired (M1, merged `ddc00d4`). Contract:
|
||||
- Request body: `{"canon_log": { …schema-v1 canon log… }}`
|
||||
- `200` → `{"prose": "<raw prose with [FACT:] tags>"}`
|
||||
- `502` → `{"detail": {"model_error": "<str>"}}` (model call failed)
|
||||
- `422` → `{"detail": {"canon_log_errors": [ … ]}}` (invalid log)
|
||||
- **Client:** `CanonLog.to_dict()` already emits the exact schema-v1 JSON the
|
||||
server validates. `TagExtractor.extract(prose)` already returns
|
||||
`{clean_text, facts, dispositions, moves}`. `new_game.gd` builds a starting
|
||||
canon log. No autoloads are registered yet.
|
||||
|
||||
The client loop is therefore mostly plumbing between two things that already
|
||||
work. The design's real weight is the network seam and the failure path.
|
||||
|
||||
## Decisions
|
||||
|
||||
These were settled during brainstorming; each is load-bearing.
|
||||
|
||||
1. **UI scope — throwaway bare harness.** This slice ships a disposable harness
|
||||
scene (a `RichTextLabel` + a "Narrate" button), enough to see prose on
|
||||
screen and prove the slice. The real prose-display UI is a later wireframe
|
||||
and is out of scope here. Do not gold-plate the harness.
|
||||
|
||||
2. **Network seam — pure core + injectable transport.** Godot's `HTTPRequest`
|
||||
is a tree `Node` that fires an async signal, which does not fit headless GUT
|
||||
unit tests. So all loop logic (build body, parse `200`/`502`/`422`, choose
|
||||
fallback, harvest facts) lives in a pure `RefCounted` `DmService` behind a
|
||||
small `DmTransport` interface. The real transport wraps `HTTPRequest`; tests
|
||||
inject a fake transport returning canned responses. This mirrors how the
|
||||
server split `call_log`'s write sink for injection.
|
||||
|
||||
3. **Fallback text — one authored line, in client content.** On any failure the
|
||||
client shows an authored Narrator line loaded from
|
||||
`client/content/fallback/narrator.json`. §13 says fallbacks are content,
|
||||
written as writing, living with the rest of the writing — not error strings
|
||||
in code. One line is enough for the slice; the M4 fallback sweep enriches it.
|
||||
The JSON is a list from day one so it can grow to a pool without a shape
|
||||
change.
|
||||
|
||||
4. **Content home — renderer owns the file's home.** §16 designates root
|
||||
`/content` as the home for authored content, but that path is outside the
|
||||
Godot client's `res://` tree and an exported build cannot read it. §13
|
||||
requires the fallback to exist when shipped, so client-rendered content ships
|
||||
inside `client/` (`res://content/…`). Root `/content` remains the home for
|
||||
server-side and authoring-tool content (quests, NPC knowledge, server
|
||||
fallbacks). The rule: **whoever renders the content owns where it lives.**
|
||||
CLAUDE.md §16 should get a one-line note recording this; that edit is a
|
||||
follow-up, not part of this slice.
|
||||
|
||||
5. **Harvest facts only, on success only.** The narrate loop harvests `[FACT:]`
|
||||
tags and nothing else. The Narrator role has no authority to move disposition
|
||||
or perform moves; even if the model emits `[ADJUST_DISPOSITION]` or `[MOVE]`,
|
||||
code drops it. This enforces §2/§6 (a role's vocabulary is enforced in code,
|
||||
not trusted from prose) at the client seam. Facts are harvested only from a
|
||||
real `200`; a fallback carries no tags and harvests nothing.
|
||||
|
||||
6. **`DmService.narrate()` returns, it does not mutate.** The core is a pure
|
||||
function of `(canon_log, transport response)`: it returns a `NarrateResult`
|
||||
carrying the display text and the harvested facts. Applying those facts to
|
||||
the canon log (`log.add_fact(...)`) is the caller's step. This keeps the
|
||||
harvested facts assertable in tests and keeps the state mutation (§2) at an
|
||||
explicit, code-owned call site.
|
||||
|
||||
7. **No client-side retry.** Any non-`200`, transport failure, timeout, or
|
||||
malformed `200` body maps straight to the fallback. The server already owns
|
||||
the one-retry-then-fallback policy (§12) for model/parse failures; the client
|
||||
does not add a second retry layer — it degrades immediately.
|
||||
|
||||
## Components
|
||||
|
||||
All paths are under `client/` unless noted.
|
||||
|
||||
### `scripts/net/dm_response.gd` — `class_name DmResponse` (RefCounted, value)
|
||||
|
||||
The raw outcome of one HTTP attempt, independent of any game meaning.
|
||||
|
||||
- `status: int` — HTTP status code, or `0` when the transport never got a
|
||||
response.
|
||||
- `body: Variant` — parsed JSON (`Dictionary`), or `null` when the body was
|
||||
absent or not JSON.
|
||||
- `transport_ok: bool` — `true` if the request reached the server and returned a
|
||||
response (any status); `false` on connection/timeout errors.
|
||||
- `error: String` — human-readable transport error, empty when `transport_ok`.
|
||||
- Static ctors: `DmResponse.ok(status, body)`, `DmResponse.failed(error)`.
|
||||
|
||||
### `scripts/net/dm_transport.gd` — `class_name DmTransport` (RefCounted, base)
|
||||
|
||||
The seam interface.
|
||||
|
||||
- `func post_json(path: String, body: Dictionary) -> DmResponse:` — a coroutine
|
||||
(callers `await` it). The base implementation asserts "not implemented" so a
|
||||
missing override fails loudly in a test rather than silently returning null.
|
||||
|
||||
### `scripts/net/http_transport.gd` — `class_name HttpTransport` (RefCounted)
|
||||
|
||||
`extends DmTransport`. The real transport, and the **one thin untested shim** in
|
||||
this slice (it parallels the server's untested Node boundary).
|
||||
|
||||
- Constructed with a reference to an `HTTPRequest` Node that the owner has added
|
||||
to the scene tree.
|
||||
- `post_json`: JSON-encode `body`, set `Content-Type: application/json`, call
|
||||
`http.request(proxy_config.base_url() + path, headers, METHOD_POST, json)`,
|
||||
`await http.request_completed`, and map the result:
|
||||
- `result != RESULT_SUCCESS` → `DmResponse.failed(<result desc>)`
|
||||
- otherwise → `DmResponse.ok(response_code, JSON.parse_string(body))`, where a
|
||||
non-JSON body parses to `null`.
|
||||
|
||||
### `scripts/net/proxy_config.gd` — `class_name ProxyConfig` (static)
|
||||
|
||||
- `static func base_url() -> String` — returns the `ProjectSettings` override
|
||||
`coc_rpg/proxy_base_url` if set, else the default `http://localhost:8000`.
|
||||
The client holds only this URL — never a key, never a model name (§4).
|
||||
|
||||
### `scripts/net/narrate_result.gd` — `class_name NarrateResult` (RefCounted, value)
|
||||
|
||||
The game-meaningful result of one narrate call.
|
||||
|
||||
- `display_text: String` — the prose to show (tags stripped), or the fallback
|
||||
line.
|
||||
- `facts: Array` — `[FACT:]` strings to apply to the canon log (empty when
|
||||
degraded).
|
||||
- `degraded: bool` — `true` when this is fallback text, not model prose.
|
||||
|
||||
### `scripts/net/fallback_library.gd` — `class_name FallbackLibrary` (RefCounted)
|
||||
|
||||
- Loads `res://content/fallback/narrator.json` on construction (path
|
||||
overridable for tests).
|
||||
- `narrator_line() -> String` — returns the first authored line. If the file is
|
||||
missing, unreadable, malformed, or the list is empty, returns a hardcoded
|
||||
last-resort constant. §13 forbids ever showing the player an error, so the
|
||||
fallback itself has a fallback.
|
||||
|
||||
### `scripts/net/dm_service.gd` — `class_name DmService` (RefCounted, pure core)
|
||||
|
||||
Owns a `DmTransport` and a `FallbackLibrary` (both injected via the
|
||||
constructor).
|
||||
|
||||
```gdscript
|
||||
func narrate(canon_log: CanonLog) -> NarrateResult:
|
||||
var resp := await _transport.post_json("/dm/narrate", {"canon_log": canon_log.to_dict()})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NarrateResult.degraded(_fallback.narrator_line())
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
|
||||
```
|
||||
|
||||
`NarrateResult.degraded(line)` is a named constructor for the fallback case
|
||||
(`display_text = line`, `facts = []`, `degraded = true`).
|
||||
|
||||
### `content/fallback/narrator.json` — authored content
|
||||
|
||||
```json
|
||||
{
|
||||
"narrator": [
|
||||
"The chamber is cold. Something waits in the dark."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In the Narrator's voice per §3 (dry, gritty not grim, never winks).
|
||||
|
||||
### `scenes/narrate_harness.tscn` + `scripts/harness/narrate_harness.gd` — throwaway
|
||||
|
||||
The "human sees prose" surface. A `RichTextLabel` and a "Narrate" button.
|
||||
|
||||
- On ready: create an `HTTPRequest` child, build `HttpTransport`,
|
||||
`FallbackLibrary`, and `DmService`; build a starting canon log via
|
||||
`new_game.gd`.
|
||||
- On button press: `var result := await _service.narrate(_log)`, set the label
|
||||
to `result.display_text`, apply `result.facts` to `_log` via `add_fact`, and
|
||||
show the harvested-fact count (and a subtle degraded indicator for the
|
||||
developer).
|
||||
|
||||
Explicitly disposable. The real display panel is a later wireframe.
|
||||
|
||||
## Testing
|
||||
|
||||
Headless GUT, hermetic — no live server in the unit suite.
|
||||
|
||||
- `tests/doubles/fake_transport.gd` — `class_name FakeDmTransport`
|
||||
(`extends DmTransport`), returns a preset `DmResponse` and records the last
|
||||
`path`/`body` for assertions. `post_json` needs no real async: `await` on a
|
||||
plain return value resolves immediately, so one `await` call site drives both
|
||||
the real and the fake transport.
|
||||
- `tests/unit/test_dm_service.gd`:
|
||||
- `200` + prose with a `[FACT:]` tag → `display_text` is stripped of tags,
|
||||
`facts` contains the fact, `degraded == false`, and the posted path/body are
|
||||
`"/dm/narrate"` / `{"canon_log": …}`.
|
||||
- `502` → `degraded`, `display_text` is the fallback line, `facts` empty.
|
||||
- `422` → `degraded`.
|
||||
- transport failure (`transport_ok == false`) → `degraded`.
|
||||
- malformed `200` (missing `prose`, or non-dict body) → `degraded`.
|
||||
- a `200` whose prose also carries `[ADJUST_DISPOSITION]`/`[MOVE]` →
|
||||
those are dropped; only facts are harvested.
|
||||
- `tests/unit/test_fallback_library.gd`:
|
||||
- loads a temp JSON and returns its first line.
|
||||
- missing/malformed file → the last-resort constant.
|
||||
|
||||
`HttpTransport` stays the untested shim; its live smoke is the harness scene run
|
||||
by hand against a running proxy (documented in `client/docs`, not in the unit
|
||||
suite — parallels the server's `--run-live` gated test).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Real prose-display UI (later wireframe).
|
||||
- Streaming (M2, gated on aliveness — deferred per the roadmap).
|
||||
- NPC moves / disposition application (next M2 item, `/npc/speak`).
|
||||
- Client-side retry, request cancellation, loading spinners, caching (§14
|
||||
concerns land when the real UI does).
|
||||
- Editing CLAUDE.md §16 to record the content-home rule — a follow-up doc edit,
|
||||
noted here so it is not lost.
|
||||
|
||||
## Follow-ups noted for later
|
||||
|
||||
- Add a one-line note to CLAUDE.md §16: client-rendered content ships under
|
||||
`client/res://`; root `/content` is authoring + server source. Rule: the
|
||||
renderer owns the file's home.
|
||||
- When `/npc/speak` lands, the same transport/service seam extends to move and
|
||||
disposition harvesting; `DmService` is the natural home or sibling.
|
||||
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).
|
||||
264
docs/superpowers/specs/2026-07-10-narrator-streaming-design.md
Normal file
264
docs/superpowers/specs/2026-07-10-narrator-streaming-design.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Narrator streaming (M2) — design
|
||||
|
||||
> **STATUS: SHELVED for the POC (2026-07-10).** The considering-state
|
||||
> (`2026-07-10-considering-state-design.md`) shipped and is live-confirmed; it
|
||||
> meets §14's anti-freeze *goal* by reframing the wait in-character rather than
|
||||
> hiding it, for a fraction of the risk. Real streaming is the POC's largest
|
||||
> transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds
|
||||
> mid-stream error/tag-boundary failure surface judged not worth it. The roadmap
|
||||
> item is struck. This design is retained only as a record — revive it solely if
|
||||
> playtesting proves the wait genuinely hurts immersion (long Narrator responses
|
||||
> or a slow prod model). Not planned work.
|
||||
|
||||
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.
|
||||
Charter side (§2): **text** — the client streams and displays AI prose; it still
|
||||
never lets prose write state directly (facts harvested at stream end, applied by
|
||||
the caller).
|
||||
|
||||
## Goal
|
||||
|
||||
Make the ~2s model call read as *the DM is speaking* rather than *the game is
|
||||
frozen* (§14). The server streams tokens from Ollama as they are produced; the
|
||||
client renders them as they arrive — first words on screen in ~200ms instead of
|
||||
a 2s blank wait.
|
||||
|
||||
```
|
||||
CanonLog → HTTP → /dm/narrate (200 NDJSON stream) → tokens as they arrive
|
||||
→ client renders incrementally → at stream end: TagExtractor → fact-harvest
|
||||
```
|
||||
|
||||
This piece proves the streaming mechanism on the **Narrator only** — pure prose,
|
||||
no moves. The NPC surface (which must run move validation on the complete text
|
||||
at stream end) is a deliberate fast-follow on the *same* transport. This is the
|
||||
riskiest transport rework in the POC, so it lands on the simplest surface first.
|
||||
|
||||
## What already exists (and is proven)
|
||||
|
||||
- **Server:** `ollama_client.chat(...)` posts `{"stream": False}` to Ollama's
|
||||
`/api/chat` with one retry, raising `ModelError`; its docstring already says it
|
||||
was "built so a streaming mode is an additive path later." `narrate.run(canon_log)`
|
||||
assembles messages, calls `chat`, logs via `call_log.record(...)`, returns raw
|
||||
prose. `/dm/narrate` returns `{"prose": …}` / `502` / `422`. `call_log.record`
|
||||
takes `response=`/`error=` and an injectable `write` sink.
|
||||
- **Client:** `DmService.narrate(canon_log) -> NarrateResult` posts through an
|
||||
injectable `DmTransport` (`post_json(path, body) -> DmResponse`), degrades to
|
||||
an authored fallback on any failure, harvests `[FACT]` via `TagExtractor`, and
|
||||
returns facts for the caller to apply (§2). The real `HttpTransport` wraps a
|
||||
Godot `HTTPRequest` **Node** (which buffers the whole body and fires
|
||||
`request_completed` once — it cannot stream). `FakeDmTransport` returns a
|
||||
canned `DmResponse`. `narrate_harness.gd` drives it on screen.
|
||||
|
||||
The server is genuinely one flag from streaming. The client is not: Godot's
|
||||
`HTTPRequest` has no incremental-read path, so real streaming requires the
|
||||
lower-level `HTTPClient` (poll + `read_response_body_chunk()`). That rework is
|
||||
the weight of this piece.
|
||||
|
||||
## Decisions
|
||||
|
||||
Settled during brainstorming; each is load-bearing.
|
||||
|
||||
1. **Real token streaming, not a cosmetic reveal.** A client-side typewriter over
|
||||
already-received text does not hide the initial wait — it just animates it.
|
||||
Meeting §14 requires the server to emit tokens as Ollama produces them and the
|
||||
client to render them live. That mandates the `HTTPClient` rework; there is no
|
||||
cheaper path that meets the goal.
|
||||
|
||||
2. **Narrator first; NPC is a fast-follow.** Both prose endpoints share the
|
||||
machinery, but NPC must run `TagExtractor`/`MoveValidator`/`MoveApplier` on the
|
||||
*complete* text at stream end. Proving the transport on the move-free Narrator
|
||||
means a transport bug cannot corrupt move application. The NPC extension reuses
|
||||
this transport unchanged and adds only parse-at-end.
|
||||
|
||||
3. **Proxy emits its own minimal NDJSON, not Ollama's shape.** §4 — the client
|
||||
knows nothing about the model. One JSON object per line:
|
||||
`{"delta": "..."}` per token, terminated by `{"done": true}`, or by
|
||||
`{"error": "..."}` on a model failure. NDJSON (not SSE) because
|
||||
`HTTPClient.read_response_body_chunk()` returns arbitrary byte boundaries; the
|
||||
client buffers bytes and splits on `\n`, and line-delimited JSON is trivial to
|
||||
reassemble. The proxy translates Ollama's `{"message":{"content":…},"done":…}`
|
||||
lines into these frames.
|
||||
|
||||
4. **Endpoint contract shift on `/dm/narrate`: 422 stays synchronous; model
|
||||
errors become an in-stream frame.** Canon-log validation runs **before** any
|
||||
streaming — a bad log still returns a plain `422` JSON (never a stream). A
|
||||
valid log returns a **200 NDJSON stream**. Because a `StreamingResponse`'s
|
||||
status is fixed at 200 once it starts, a **model failure is no longer a `502`**
|
||||
— it is an `{"error": …}` frame inside the 200 stream. Same principle (the
|
||||
client degrades on failure), new shape. The non-streaming JSON contract is
|
||||
retired for this endpoint (the client is its only consumer and moves to
|
||||
streaming).
|
||||
|
||||
5. **§12 retry moves to before-first-token, server-side.** Once bytes flow you
|
||||
cannot cleanly retry. `chat_stream` retries the Ollama call **only if it fails
|
||||
before yielding the first delta**; once streaming, an error propagates as the
|
||||
`{"error"}` frame. The client never retries a stream — it degrades. This keeps
|
||||
the retry in one place (server) and the client purely reactive, as today.
|
||||
|
||||
6. **Client streaming transport coexists with `HttpTransport`.** The proven
|
||||
non-streaming transport keeps its job (the Adjudicator's strict JSON, later).
|
||||
A new `StreamTransport` seam is added, not a replacement. `DmService` gains a
|
||||
streaming method; the old `narrate()` path may remain for reference but the
|
||||
harness uses the streaming one.
|
||||
|
||||
7. **Testable seam: `post_stream(path, body, on_delta) -> StreamResult`.** The
|
||||
service passes an `on_delta: Callable` that buffers text and forwards to the
|
||||
UI. `StreamResult` carries `{ok: bool, error: String}` at the end. A
|
||||
`FakeStreamTransport` invokes `on_delta` for each canned chunk then returns a
|
||||
canned `StreamResult`, so the service's accumulate/hold-back/degrade logic is
|
||||
pure and unit-testable. The real `HTTPClient` poll loop is the one untested
|
||||
shim — exactly the posture `HttpTransport` holds today.
|
||||
|
||||
8. **On-screen tags: hold-back buffering.** When a `[` arrives, the visible
|
||||
reveal holds from that point until the matching `]`, then the tag is stripped.
|
||||
This prevents `[FACT: the we…` flashing then vanishing, which reads as a bug
|
||||
and undercuts the "feels alive" goal. Implemented as a small running buffer in
|
||||
the on-delta path — the full raw text is still accumulated separately for the
|
||||
end-of-stream `TagExtractor` pass.
|
||||
|
||||
9. **Mid-stream failure: keep the partial prose, mark degraded, harvest no
|
||||
facts.** If the stream dies after some tokens, the player keeps the valid
|
||||
prose already shown, the result is flagged `degraded`, and **no** facts are
|
||||
harvested — the tag pipeline needs the complete text to be trustworthy. The
|
||||
pre-first-token failure path (retry once server-side, then fail) shows the full
|
||||
authored fallback, as today. No surface ever shows an error (§13).
|
||||
|
||||
10. **The `HttpTransport` timeout gap is closed here.** The `HTTPClient` transport
|
||||
gets a **connect timeout + a time-to-first-token timeout + an inter-chunk
|
||||
timeout** — not a total-duration timeout (a long stream is legitimate). A stall
|
||||
at any of those is a stream failure → degrade. This retires the deferred
|
||||
`_http.timeout=0` minor in the code that is reworking the transport anyway.
|
||||
|
||||
## Contracts
|
||||
|
||||
### `POST /dm/narrate` — request unchanged
|
||||
|
||||
Body: `{"canon_log": { …schema-v1… }}`.
|
||||
|
||||
### Responses
|
||||
|
||||
- Invalid log → `422 {"detail": {"canon_log_errors": [ … ]}}` (synchronous JSON,
|
||||
before any stream — unchanged).
|
||||
- Valid log → `200`, `Content-Type: application/x-ndjson`, body is a sequence of
|
||||
newline-terminated JSON objects:
|
||||
|
||||
```
|
||||
{"delta": "The chamber "}
|
||||
{"delta": "is cold. "}
|
||||
{"delta": "[FACT: the well is dry]"}
|
||||
{"done": true}
|
||||
```
|
||||
|
||||
A model failure replaces the terminal frame:
|
||||
|
||||
```
|
||||
{"delta": "The chamber "}
|
||||
{"error": "connection reset"}
|
||||
```
|
||||
|
||||
`done` and `error` are mutually exclusive and terminal. Frames carry no model
|
||||
identity, seed, or Ollama metadata (§4).
|
||||
|
||||
## Components
|
||||
|
||||
**Server:**
|
||||
|
||||
- `api/app/ollama_client.py` — add `chat_stream(model, messages, options, *,
|
||||
think=None, client=None) -> Iterator[str]`. Uses `httpx` `client.stream("POST",
|
||||
"/api/chat", json={... "stream": True ...})`, iterates `iter_lines()`, parses
|
||||
each Ollama line, yields `message.content` deltas. Retries once **only** if the
|
||||
request fails before the first delta; a failure after the first delta raises
|
||||
`ModelError` from within the generator (the service converts it to an error
|
||||
frame). The existing `chat()` is untouched.
|
||||
- `api/app/narrate.py` — add `run_stream(canon_log) -> Iterator[str]` (or a
|
||||
sibling module `narrate_stream.py` if `narrate.py` grows unwieldy): assembles
|
||||
the same messages, calls `chat_stream`, yields proxy NDJSON frames
|
||||
(`{"delta":…}` / terminal `{"done":true}` / `{"error":…}`), accumulates the full
|
||||
text, and logs via `call_log.record(...)` at stream end (both success — with the
|
||||
accumulated `response` — and error paths). Logs time-to-first-token.
|
||||
- `api/app/main.py` — `/dm/narrate` validates the canon log synchronously
|
||||
(unchanged `valid_turn`), then returns
|
||||
`StreamingResponse(narrate_stream_gen(...), media_type="application/x-ndjson")`.
|
||||
A `ModelError` raised before the first frame still surfaces as an in-stream
|
||||
`{"error"}` frame (the generator catches and emits it), not a 502.
|
||||
|
||||
**Client:**
|
||||
|
||||
- `client/scripts/net/stream_transport.gd` — `StreamTransport` (`class_name`), the
|
||||
seam: `post_stream(path: String, body: Dictionary, on_delta: Callable) ->
|
||||
StreamResult` (async). Base pushes an error like `DmTransport` does.
|
||||
- `client/scripts/net/http_stream_transport.gd` — the real one: wraps an
|
||||
`HTTPClient`, connects, POSTs, polls `poll()` + `read_response_body_chunk()`,
|
||||
buffers bytes, splits on `\n`, parses each frame, calls `on_delta(delta)` per
|
||||
`{"delta"}`, and returns a `StreamResult` on `{"done"}` / `{"error"}` /
|
||||
transport failure / timeout. Untested shim (verified by the harness + gated live
|
||||
smoke), like `HttpTransport`. Holds the connect / first-token / inter-chunk
|
||||
timeouts (§decision 10).
|
||||
- `client/scripts/net/stream_result.gd` — `StreamResult{ok: bool, error: String}`
|
||||
+ `StreamResult.failed(msg)`.
|
||||
- `client/scripts/net/dm_service.gd` — add `narrate_stream(canon_log, on_delta:
|
||||
Callable) -> NarrateResult`. It wraps the caller's `on_delta` in a hold-back
|
||||
buffer (reveals text, holding an open `[`…`]` span), accumulates the full raw
|
||||
text, awaits `post_stream`, and at the end: if the `StreamResult` failed with no
|
||||
text → `NarrateResult.fallback(...)`; if it failed mid-stream → degraded result
|
||||
carrying the partial `display_text` and **no** facts; on success →
|
||||
`TagExtractor.extract(full_text)` and return `{clean_text, facts, degraded:false}`.
|
||||
Reuses `NarrateResult` and `FallbackLibrary` unchanged.
|
||||
- `client/scripts/text/tag_stream_filter.gd` — a small **pure** hold-back filter:
|
||||
`feed(delta) -> String` returns the safe-to-show text (buffering from an
|
||||
unmatched `[`), `flush() -> String` returns any trailing buffered text at end.
|
||||
Pure and unit-tested; `DmService.narrate_stream` uses it for the on-screen
|
||||
reveal while `TagExtractor` still runs on the full raw text.
|
||||
- `client/scripts/harness/narrate_harness.gd` — switch the button handler to
|
||||
`narrate_stream`, appending each `on_delta` chunk to the `RichTextLabel` live,
|
||||
then applying facts at the end (unchanged §2 apply).
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Invalid canon log** → synchronous `422`; client shows fallback (unchanged).
|
||||
- **Pre-first-token model failure** → `chat_stream` retries once; still failing →
|
||||
the generator's first frame is `{"error"}` → client shows the full authored
|
||||
fallback line, no facts.
|
||||
- **Mid-stream failure** (`{"error"}` after deltas, dropped connection, or an
|
||||
inter-chunk timeout) → keep the partial prose on screen, mark degraded, harvest
|
||||
no facts.
|
||||
- **Stall** (no connect / no first token / gap between chunks past the timeout) →
|
||||
treated as the matching failure above.
|
||||
- No surface ever shows an error string (§13).
|
||||
|
||||
## Testing
|
||||
|
||||
- **`TagStreamFilter` (pure, GUT):** a tag split across two deltas is held then
|
||||
revealed cleanly; a `[FACT:…]` is stripped from the visible reveal; plain text
|
||||
passes straight through; `flush()` returns a dangling partial.
|
||||
- **`DmService.narrate_stream` with `FakeStreamTransport`:** deltas accumulate →
|
||||
`display_text` is the cleaned full text and facts are harvested; a canned
|
||||
`{"error"}` after deltas → degraded, partial text kept, no facts; a pre-token
|
||||
failure → full fallback, no facts; the on-screen reveal (via the injected
|
||||
`on_delta`) never shows raw tag text.
|
||||
- **Server `chat_stream` with a fake httpx stream:** yields deltas in order;
|
||||
retries once before the first delta; a mid-stream raise surfaces as `ModelError`.
|
||||
- **Server `narrate` stream service with a fake `chat_stream`:** emits well-formed
|
||||
NDJSON frames terminated by `done`; a raise emits a terminal `error` frame; the
|
||||
call is logged once with the accumulated text.
|
||||
- **Endpoint (`TestClient`):** a valid log streams `application/x-ndjson` frames
|
||||
ending in `{"done":true}`; an invalid log still returns `422` JSON (no stream);
|
||||
a model failure yields a 200 stream whose last frame is `{"error"}`.
|
||||
- **Gated live stream smoke** (`@pytest.mark.live`): real Ollama, assert deltas
|
||||
arrive incrementally (more than one frame) and the concatenation is non-empty
|
||||
grounded prose, no Luck number.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** proxy NDJSON stream format; `ollama_client.chat_stream`; the streaming
|
||||
narrate service + `/dm/narrate` streaming endpoint (422 synchronous, model error
|
||||
as in-stream frame); the client `HTTPClient` `StreamTransport` (+ connect /
|
||||
first-token / inter-chunk timeouts) behind a testable seam; `StreamResult`;
|
||||
`DmService.narrate_stream`; the pure `TagStreamFilter` hold-back; keep-partial
|
||||
mid-stream degrade; `narrate_harness` streaming on screen; full test set + gated
|
||||
live stream smoke.
|
||||
|
||||
**Out:** NPC streaming (the fast-follow — reuses this transport, adds
|
||||
parse-at-end); the Adjudicator; combat flavor (async, later); any real dialogue
|
||||
UI; a total-duration timeout; SSE (NDJSON chosen).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user