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>
This commit is contained in:
263
docs/superpowers/specs/2026-07-09-canon-log-schema-design.md
Normal file
263
docs/superpowers/specs/2026-07-09-canon-log-schema-design.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# 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/<id>.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/<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.
|
||||
Reference in New Issue
Block a user