# Canon Log Schema & Origin Seeds — Design **Date:** 2026-07-09 **Status:** approved (design); implementation plan to follow **Charter refs:** §2 (code owns state), §7 (Luck), §9 (companions/humiliations), §11 (canon log), §14 (cost/latency), §16 (layout) ## Problem The canon log (§11) is the compact structured fact list code maintains and injects into every AI call. It is the spine that stops the AI contradicting itself, and §11 warns it is "nearly impossible to retrofit." It must be designed before any call site depends on it. Concurrently, the player should be able to select one of several **starting origins**, each with its own starting location and situation — for replayability without multiplying authoring work. ## Reframe: origin = initial-state seed over a static world Rather than "multiple maps," an origin is an **initial-state overlay** on a single shared, static world. The world (one map, its towns, their fixed NPCs and personalities/knowledge) is authored once. An origin only seeds *where the player starts and the situation they are in*. This keeps charter §17 scope intact (one world authored) while raising replayability (N thin origin seeds). Confirmed scope: **schema supports N origins; the POC authors one.** An origin seeds: starting location, opening situation, initial dispositions, initial inventory/items, initial quest/objective, and player build constraints. ## Architecture — three layers, one direction of flow ``` WORLD CONTENT (static) /content/world map · npcs(+knowledge) · quests · items │ referenced by stable string id ORIGIN SEED (thin, N) /content/origins location + situation + dispo/quest/item/build seeds │ new-game construct (origin + world + character creation) CANON LOG (runtime) code-owned, saved the §11 structure, injected into every AI call ``` Content and seed are **immutable inputs**; the canon log is the **single mutable product**. Nothing writes back up. This is §2 (code owns state) made concrete. ### The in/out boundary What the canon log carries vs. what it must never carry: | In the log (narrative context → AI) | Not in the log | |---|---| | Player name, class, **luck _descriptor_** | Numeric Luck value — §7: AI must never be able to calculate it | | Current location (id + name) | Numeric stats, HP/MP, combat state — combat engine owns these | | Party dispositions | Full inventory contents — game state; only narrative items become facts | | Rolling recent events (≤5) | NPC knowledge lists — §6, injected only into that NPC's call | | Established facts (harvested `[FACT]`) | Available moves — §6, injected only into the NPC call | | Active quests | | | Humiliations (Banter, §9) | | Rationale: §7 requires the player to *feel* cursed but never *calculate* it, so the numeric Luck physically cannot enter the AI payload — only `luck_descriptor` does. Keeping stats/HP/inventory out keeps the payload small (§14) and separates combat state from narrative context. ## Canon log schema (runtime state) ```json { "schema_version": 1, "player": { "name": "Aldric", "class_id": "sellsword", "luck_descriptor": "Fortune spits on you" }, "location": { "id": "greywater_docks", "name": "the Greywater docks" }, "party": [ { "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 }, { "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 } ], "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" ], "active_quests": [ { "id": "find_the_ledger", "name": "The Missing Ledger", "status": "active", "objective": "Find who took Fenn's ledger" } ], "humiliations": [ { "id": "h_0001", "text": "vomited on a shrine step in front of a priest", "weight": 7, "turn": 3 } ] } ``` | Field | Type | Notes | |---|---|---| | `schema_version` | int | Migration handle; saved logs upgraded on load. | | `player.name` | string | From character creation. | | `player.class_id` | enum | `sellsword` \| `assassin` \| `priest` (POC, §8). Id, not display name. | | `player.luck_descriptor` | string | **Only** Luck representation in the log (§7). Recomputed when numeric Luck drifts. Never the number. | | `location` | `{id, name}` | id → world content; name → prose. | | `party[]` | `{id, name, disposition}` | Companions. `disposition` int −100..100 (§9). | | `recent_events[]` | string[] | Rolling window, **cap 5** (§11). Code writes each terse line, drops oldest. | | `established_facts[]` | string[] | Durable. Harvested from `[FACT: …]` (§11) + origin seed. Deduped. Uncapped. | | `active_quests[]` | `{id, name, status, objective}` | `status`: `active` \| `complete` \| `failed`. Definitions in world content. | | `humiliations[]` | `{id, text, weight, turn}` | Banter memory (§9). **Append-only, stacks.** `weight` 1–10; `turn` drives decay of reference frequency. | Decisions: - Every entity carries both `id` (code keys/validates) and `name` (prompt reads) — denormalized on purpose so the language model gets natural nouns and code gets stable keys. - `recent_events` cap = 5 (§11 says "3–5"; ceiling taken). Hard rolling window. - `established_facts` uncapped — dropping a fact reintroduces the exact contradiction §11 exists to prevent. If size bites (§14), fix is summarisation, not truncation. - No global `npc_dispositions`; an NPC's live disposition rides only in its `/npc/speak` call (§6). Add later if the Narrator needs town-wide stances. ## Origin seed schema Lives in `/content/origins/.json`. All heavy nouns are id references into world content; the origin owns only starting values. ```json { "schema_version": 1, "id": "deserter", "display_name": "The Deserter", "description": "You walked away from a company that doesn't allow walking away.", "start_location_id": "greywater_docks", "situation": [ "Arrived at Greywater by barge before dawn, hood up", "Down to your last coin and owed a favour you can't repay" ], "opening_facts": [ "the player deserted the Iron Kettle mercenary company", "a bounty notice for the player circulates in the northern towns" ], "disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 }, "inventory_grants": [ { "item_id": "worn_shortsword", "qty": 1 }, { "item_id": "coin", "qty": 3 } ], "start_quest_id": "find_the_ledger", "build_constraints": { "allowed_classes": ["sellsword", "assassin", "priest"], "luck_modifier": 0 } } ``` | Field | Feeds | Notes | |---|---|---| | `id` / `display_name` / `description` | Origin-select screen | `description` is authored flavour shown at pick time. | | `start_location_id` | `location` | Must resolve to a world location, else new-game fails validation. | | `situation[]` | `recent_events` | Opening framing — why you're here, now. | | `opening_facts[]` | `established_facts` | What is already canon in this origin. | | `disposition_overrides{}` | `party[].disposition` (companions) / game-state NPC store (world NPCs) | Absolute starting values; omitted → world default. Companion ids land in the log; world-NPC ids land in game state and surface only at that NPC's `/npc/speak` call (§6) — the log has no global NPC-disposition field. | | `inventory_grants[]` | game-state inventory | Not the log — inventory is state (§2). Narrative items may also seed a fact. | | `start_quest_id` | `active_quests` | Nullable; resolved against world quest defs. `null` → no active quest. | | `build_constraints{}` | character creation | `allowed_classes` gates the picker; `luck_modifier` feeds Luck gen. Acts *before* the log exists. | `humiliations` is never seeded — earned in play only (§7/§9). ## New-game construction ``` 1. Player picks origin load /content/origins/.json → validate every id resolves in world content 2. Apply build_constraints → character creation class picker limited to allowed_classes Luck generated per §7 (5-roll average) + luck_modifier 3. Player creates character (name, class, rolled stats + Luck) 4. Construct initial canon log: player ← creation (name, class_id) + luck→descriptor location ← start_location_id (+ name from world) party ← world default roster, companion dispositions ← disposition_overrides (world-NPC dispositions from disposition_overrides → game-state NPC store, not the log) recent_events ← situation[] established_facts ← opening_facts[] (+ world always-true facts, if any) active_quests ← resolve(start_quest_id) (or []) humiliations ← [] (always empty) schema_version ← 1 inventory_grants → applied to game-state inventory (NOT the log) 5. Log handed to the game loop; first Narrator call fires with it. ``` Construction reads all three layers and writes only the log. `build_constraints` firing at step 2 (before the log exists at step 4) is why an origin is a distinct schema, not a partial canon log: Luck is rolled during creation with the origin's modifier, and only its *descriptor* reaches the log. Validation is front-loaded at step 1 — a broken origin fails at new-game, loudly, not three scenes later. ## Maintenance — turn-to-turn evolution Only code mutates the log. AI prose is never the source of truth (§2/§11); a `[FACT]` tag is a *request* to record, and code records the canonical string that later calls read. | Trigger | Mutation | Rule | |---|---|---| | Any role emits `[FACT: …]` | Append to `established_facts` (dedup) | §11 harvest; only way facts enter. | | A narrative beat resolves | Append terse line to `recent_events`, drop oldest past 5 | Code writes the line, not the AI. | | Validated `[ADJUST_DISPOSITION: ±n]` (§6) / approval change (§9) | Update `party[].disposition`, clamp −100..100 | Only after the move passes validation; invalid dropped. | | Event with embarrassment weight ≥ threshold | Append `{id, text, weight, turn}` to `humiliations` | Append-only, stacks forever (§9). | | Numeric Luck drifts (§7) | Recompute `player.luck_descriptor` | Number stays in game state; only descriptor mirrored. | | Quest state changes | Update `active_quests[].status` / objective | Definitions in world content; log tracks live status. | | Player moves | Update `location` | id + name from world content. | ### Per-role injection The canon log is the shared base of every call; roles add role-specific extras. The log never grows per-role fields. | Role | Log + … | |---|---| | Narrator | log only | | NPC | log + this NPC's persona, `knowledge[]`, `available_moves[]` (§6) | | Improviser | log + the whitelisted event-change set (§7 validator) | | Banter | log, with `humiliations` as primary source (§9) | | Adjudicator | log + the current legal action set | ## Storage & format ``` /content/ origins/ origin seeds (authored) + (later) validated against origin.schema.json world/ static, id-referenced content locations/ npcs/ quests/ items/ fallback/ degraded-DM text (§13) — NOT world content, no ids resolved against it /docs/ canon-log.md living contract: this schema, field tables, example schemas/ canon-log.schema.json JSON Schema — validated on both client and api origin.schema.json JSON Schema — origin seed save file → serialized canon log (JSON) + game state (Luck number, inventory, stats) ``` - **JSON everywhere.** The log crosses the HTTP boundary to `/api` and seeds prompts. The client (GDScript) models it as typed objects and serialises; `/api` (Python) validates against the same `canon-log.schema.json`. One schema, two languages, no drift. - **The log is part of the save.** Load a save → same log → same injected context. Pairs with §10's per-encounter seeding: reproducible AI context, not just reproducible combat. §11 rejected "last N messages"; persisting the *structured log* keeps the anti-amnesia guarantee across save/quit. - **Schemas live in `/docs/schemas/`** — cross-cutting contracts both sides consume (§16 doc split). Prose spec beside them in `/docs/canon-log.md`. ## Consequences / follow-ups - `fallback/` stays outside `world/` (category: authored prose, not id-referenced). - Implementation will create the two JSON Schemas, the construction routine, the maintenance hooks, and one authored POC origin + minimal world content to exercise new-game end-to-end. - Out of scope here: the AI role prompts themselves, combat state, and the save system's non-log portions. This spec defines the data contract they all consume. ## Open questions None blocking. Thresholds (humiliation `weight` cutoff, Banter decay curve) are tuning values, deferred to implementation.