Files
code_of_conquest_dnd/docs/roadmap.md
Phillip Tarrant f5e6c59f2a docs(roadmap): add Ollama Cloud provider + backend switch as the next item
A laptop-dev enabler (planned, not specced): extend the proxy's M1 model-call
pipeline with an Ollama Cloud backend reachable via API key, plus a switch to
flip local-ollama <-> ollama-cloud, so AI works off the homelab.

Reconciles against §4: the API key and provider choice stay server-side in the
proxy (the client never holds a key, never picks the backend). The title
Settings-button "config file" splits — the client may hold only the proxy
base URL / profile (folds into M9); the provider/key switch is proxy config.

Filed under a renamed "Landed / planned out of band" section and pointed to
from the sequencing table as the next task before M5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:27:04 -05:00

31 KiB
Raw Blame History

Roadmap

Source of truth for scope is CLAUDE.md. This tracks sequencing.

The game — building the tactical CRPG (charter §17)

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 (M0M2). We are now building the game the mockups describe (§16, /mockups): a tactical CRPG across 11 screens.

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 / DMNPC pipeline / bounded-moves / Luck / character-creation model we already built.

Reconcile as we build (charter §17)

  • 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).

Guarded direction — the seven-campaign saga (spec)

One character, seven campaigns (one per demon king), one saga — then it ends. The character, companions, deeds and level carry between campaigns; the world does not. Difficulty is chosen once, at world-construction time, from the campaign index (tier 1..7) — never during play.

The spec adds no milestone and no content. It exists because M5 and M9 are about to be built blank and would foreclose the shape. What it constrains is marked ⛨ saga on the milestones below. World generation itself stays v2 — the spec only guarantees the Saga is the object that will feed it.

Out of scope (never / later)

Multiplayer (never) · mobile (later) · AI-generated story skeletons (v2) · credits/payments/auth (retrofit onto the proxy, §4).

Sequencing

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.

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

▶ Next (before M5): Provider flexibility — Ollama Cloud + a backend switch (in Landed / planned out of band, below). A laptop-dev enabler: it lets the proxy serve the same Ollama-hosted models over an API key from any machine, so development continues away from the homelab. Not a game-screen milestone — a cross-cutting infra item taken now because the alternative is a laptop with no AI.

Landed / planned out of band

Not every item is a milestone. Some shipped on their own because a later milestone would have been more expensive to build on top of the old shape; some are planned cross-cutting work that does not belong to a single screen.

  • Provider flexibility — Ollama Cloud + a backend switch (next; not yet specced — a future session builds it). Development spans two machines: a desktop with the homelab Ollama, and a laptop with no homelab reach. Today the proxy's model-call pipeline (M1) only knows a local Ollama, so the laptop has no AI at all. This adds Ollama Cloud as a backend so the same Ollama-hosted models are reachable over an API key from anywhere, and a switch to flip between local-ollama (desktop, free/offline) and ollama-cloud (laptop) — and the door stays open for prod's Replicate (§4) as a third backend.
    • §4 is load-bearing — the key and the provider choice live server-side, in the proxy. The client never holds an API key and never learns which backend serves a role (§4). Concretely: extend the proxy's Ollama httpx client (M1) to target an Ollama-compatible cloud endpoint with an Authorization: Bearer <key> header, the key read from the proxy's environment / .env, never a client binary. The "flip a switch" is a proxy config/env value selecting the backend (endpoint + key + per-role model ids) — this is the M1 role→model routing config growing a backend axis, a deploy/config change, not a client patch, exactly as §4 intends.
    • The client's Settings-button angle (reconcile, don't silently adopt): the "config file surfaced by the title Settings button" is a real feature, but under §4 it splits in two. Legit client setting: the proxy base URL / profile (§4 lets the client know one base URL) — a persisted client config choosing "laptop proxy" vs "desktop proxy," editable via the M9 Settings screen. NOT a client setting: the AI provider and API key — those stay in the proxy; the Settings screen must never hold the key or pick the backend, or the §4 boundary leaks into a shippable binary. So the honest "flip a switch" is two switches — a proxy-side provider/key config (this item, the immediate laptop-dev enabler) and a client-side base-URL/profile that folds into M9's settings + persistence. For laptop work right now, only the proxy switch is needed.
    • §2: text (the provider serves text; the client stays provider-agnostic) · depends on the M1 pipeline · goal: AI on any machine, switchable — and a proxy that is already multi-backend before Replicate (§4) arrives.
  • Currency — copper with real denominations (spec). 1g = 100s = 10,000c, stored entirely in copper as one GameState.purse_copper int, formatted only at the display edge (◈ 3s 47c). coin.jsoncopper/silver/gold. Money is not an inventory item: give_item/accept_item carry no quantity and gifts_given is a global one-shot, so a lone copper id would have been givable once, for one copper, per campaign — the denominations are move tokens instead (give_item(gold) = +10,000c), routed to the purse and never into inventory, so money cannot surface in an inventory grid by construction. Taken before M7 while content/world/items/ held two files. Prices remain M8's content BOM — nothing is priced yet. §2: state · goal: money that means something.
  • A move applies at most once per reply — found by the review of the above. TagExtractor returns every tag occurrence and MoveValidator is a membership test, so a model repeating a tag N times picked an amount in unary (§2 breach: the AI choosing a number). Now deduped by (name, id) across a reply, and adjust_disposition's MAX_DELTA caps the reply's net swing rather than each tag — it was per-tag, so three +15s moved standing by 45, the exact "wholesale swing" the constant forbids. §2: state · goal: the bounded vocabulary stays bounded.

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

  • 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. Merged to dev. §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/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. Established the editor-first UI-scene convention (ADR 0001; how-to in client/docs/README.md) and added Button-base DockButton/ParchmentButton + a base RichTextLabel prose style to the theme. §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. Author it editor-first (ADR 0001). Built and committed (a77bf03, f071392); this entry was stale — the roadmap had not caught up and M4-c would have been misled into thinking it still had to build one. ⛨ saga: a fourth entry path (begin the next campaign of an existing saga) lands later — don't build it, just don't hardcode the flow as new game → creation → world so it can't be added. §2: n/a · depends on the Theme · goal: first impression, into the world.

M4 — Character creation

Content it consumes → BOM: calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). ~4 pieces.

  • Creation model (M4-a) — the rules a character is made of, no UI (spec). Race/calling/skill mechanics are static tables in code (client/scripts/rules/); their blurbs are hand-written content (content/world/{callings,races}/, loaded by ContentDB, parity-tested), so the content BOM does not block the engine. CharacterSheet stores only what was rolled or chosen (attributes, race, calling, skills, current hp/mp) and derives the rest (max_hp, ac, save, skill_bonus, spell_dc) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration. NewGame.construct carries a seed, not a stat block: it rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed yields an identical character, hidden Luck included (§10) — pinned by a golden-vector test. The roll is 3d6 with a hard floor of 8 (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is for is the "fine" §7 forbids). LCK is unspendable, not merely hidden. Open seams marked honestly: ac() is 10 + DEX until armor exists (M7), max_mp() is a TUNABLE placeholder (M5 owns the curve), L1 talents are id stubs (M5). Live-proven end-to-end (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation → Player: Vexcca, a beastfolk cutpurse in the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). §2: state · goal: the character exists in code.
  • Contract migration — the two contract migrations this milestone owed have landed: the canon log's player block is now {name, race_id, calling_id, luck_descriptor} (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now allowed_callings (content/origins/deserter.json lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree.
  • Character creation UI (M4-b) — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the chosen calling), the +3 additive spend pool with flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — never six, and the word "luck" appears nowhere on the screen (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only, +/ both present, the portrait arrows cut for lacking art). Architecture: a pure, node-free CreationDraft (RefCounted) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend, errors() delegates to NewGame.validate rather than keeping a second copy of the rules; CreationCopy is a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from the Races/Callings/Skills tables, so retuning a calling retunes its card without touching a string literal; the .tscn owns the layout per ADR 0001 and character_creation.gd only binds. The §2 property holds without the screen having to be trustworthy: the screen shows its five numbers by calling the same newly-public NewGame.roll_attributes(seed) that construct calls internally — the screen never hands a number to anything, only a seed and four choices; construct rolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emits creation_confirmed(creation: Dictionary); does not call construct itself, so a saga can synthesize the same Dictionary later and skip the scene entirely. Test count: 250 → 302. The honest seams: three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, a Label sweep that missed RichTextLabel, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red per docs/traps.md's standard; the new species are folded into that file. §2: state · depends on the creation model (M0) + Theme · goal: build a character.
  • Title → creation → shell flow (M4-c) — the new game path is wired end-to-end (spec). A script-first Main coordinator node (scenes/Main.tscn, now the main_scene) owns the Title → Creation → Shell sequence in one file, swapping screens via instantiate → inject → add_child — the injection seam change_scene_to_file cannot provide, and the reason it, not a linear chain, holds the flow. The Title became emit-only (it announces menu_activated; the coordinator routes it — it no longer knows what comes next), and the shell stopped faking a character: new injected_log/injected_state seams + a ShellState.for_character(state, log) factory mean the created character's HP/MP/purse, location, and opening narration are real (seed()/_build_seed_log() stay as the F6/standalone fallback, exactly like service == null). The flow carries only a plain creation Dictionary; NewGame.construct re-rolls the character from its seed itself (§2). ⛨ saga honored structurally: _show_creation() and _start_campaign(creation) are separate methods, so the fourth entry path ("begin the next campaign of an existing saga") is an added menu_activated arm that synthesizes a Dictionary and calls _start_campaign directly — no existing edge changes. §13: a construct that returns {ok:false} (unreachable via the CTA gate, but the coordinator distrusts its caller anyway) logs and stays on creation — never a crash, never a half-built shell. Built subagent-per-task with a per-task + final whole-branch review; every new guard reverted-and-reconfirmed-red per docs/traps.md. Test count: 302 → 327. The coordinator was named Main (not GameFlow) so the boot scene is self-evident; TitleScreen is unchanged and shown by Main. §2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.

M5 — Tactical combat (gridless)

Content it consumes → BOM: abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, tier 1 (variable per tier). ~15 pieces.

  • 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). ⛨ saga — the level curve is no longer open: design it to a ceiling of L20 across 7 tiers (tier N ≈ levels 3N2 … 3N; exact bands tunable, the ceiling is not). Enemy stat blocks carry tier: 1..7; there is no scaling multiplier, and there never will be — a tier-6 world has different monsters (proximity to a king is the power scale), not a rat with 40× HP. Class abilities are planned to L20. The M5 content BOM (1 enemy family + boss) is tier 1. §2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.

M6 — Dialogue screen

Content it consumes → BOM: 6 NPCs — persona + knowledge[] + gated topics (§6), variable. ~6 pieces.

  • 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

Content it consumes → BOM: 6 weapons, 4 armour, 3 consumables, 1 cursed item (+STR / LCK — §7 says day-one). All fixed. ~14 pieces. Prerequisite: the build tool gains item + quest namespaces (and tier: 1..7).

  • 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 Resources. §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

Content it consumes → BOM: the price table in copper (fixed); 3 map locations + the road, shop stock, the quest chain (variable). ~8 pieces. The currency value type already landed (see Landed out of band, above) — everything is stored in copper and formatted at the display edge, so the Shop economy inherits a real purse. What M8 still owes is the numbers: nothing is priced yet.

  • 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

Content it consumes → BOM: authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds. All fixed. ~10 pieces.

  • Save / load + pause / settings — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). ⛨ saga — the save is two objects, not one: a durable Saga (sheet, level, companions + approval, humiliations, deeds, kings_slain, campaigns_completed, signature item) and a per-campaign Campaign (CanonLog + GameState + world + seed). Humiliations move to the Saga and are projected into the canon log — today they live in the log and would die with the campaign. This is the §10 seed argument applied to persistence: cheap now, a rewrite once saves exist in the wild. §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 (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.