Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-09-canon-log-schema-design.md
Phillip Tarrant 800811dac2 docs: canon log schema + origin seed design
Design spec for the §11 canon log, plus origin seeds as thin initial-state
overlays over a single static world. Three layers (world content / origin seed /
runtime canon log), the in/out boundary (numeric Luck never enters the AI
payload, §7), field-level schemas, new-game construction order, turn-to-turn
maintenance, and JSON storage validated on both client and api.

Folds content/npcs and content/quests under content/world/; adds
content/world/{locations,items} and content/origins. fallback/ kept outside
world/ (authored prose, not id-referenced).

Spec: docs/superpowers/specs/2026-07-09-canon-log-schema-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:03:37 -05:00

13 KiB
Raw Permalink Blame History

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)

{
  "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 110; 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 "35"; 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/<id>.json. All heavy nouns are id references into world content; the origin owns only starting values.

{
  "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/<id>.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.