Merge canon log schema design + implementation plan

Design spec + Plan A (contract & api validation) + content/world reorg.
This commit is contained in:
2026-07-09 12:22:01 -05:00
9 changed files with 1371 additions and 7 deletions

View File

@@ -1,15 +1,37 @@
# /content — authored game data
Cross-cutting authored writing. Consumed by **both** sides: the client ships fallback text and quest/story data; the api reads NPC knowledge lists to build prompts. That shared ownership is why it sits at the repo root, not inside either folder.
Cross-cutting authored writing, split by role in the data model (see the canon
log spec in [`/docs`](../docs)):
```
/quests Story skeletons and quest definitions (authored, not AI-generated — §17)
/npcs Per-NPC knowledge lists — the entire content an NPC can draw on (§6)
/fallback Authored degraded-DM text for every AI surface (§13)
/world Static, ID-referenced game content. Identical every playthrough.
/locations The map — towns, dungeons, points of interest (by id)
/npcs Per-NPC persona + knowledge lists (charter §6)
/quests Story skeletons and quest definitions (charter §17)
/items Item definitions — gear, consumables, cursed items (§7)
/origins Thin starting-state seeds. One per starting point. POC authors one.
/fallback Authored degraded-DM text for every AI surface (charter §13)
```
## The three layers
- **`world/`** is static content the origin and the canon log reference by stable
string **id**. Authored once; the same for every play.
- **`origins/`** are thin seeds — where the player starts, the situation, and
disposition/quest/item/build seeds. Values, not maps. Increase replayability
without multiplying authoring work.
- **`fallback/`** is not world content — it is degraded-DM prose (§13), consumed
when the API is down. It sits outside `world/` on purpose: nothing resolves an
id against it.
At new-game, code constructs the runtime **canon log** from a chosen origin +
world content + character creation. See [`/docs/canon-log.md`](../docs) (spec) —
authored via the design under [`/docs/superpowers/specs`](../docs/superpowers/specs).
## Authoring notes
- **NPC knowledge lists are the whole design** (§6). They are the only thing stopping the blacksmith from revealing the twist. Real authoring work — budget for it.
- **Fallback text is content, not error handling** (§13). Written in the DM's voice, lives beside the rest of the writing. Every AI-dependent surface needs one before it ships.
- Story skeletons are authored for now. AI-generated skeletons are v2, out of POC scope (§17).
- **NPC knowledge lists are the whole design** (§6). The only thing stopping the
blacksmith from revealing the twist. Real authoring work — budget for it.
- **Fallback text is content, not error handling** (§13). Every AI-dependent
surface needs one before it ships.
- Story skeletons are authored for now. AI-generated skeletons are v2 (§17).

26
content/origins/README.md Normal file
View File

@@ -0,0 +1,26 @@
# /content/origins — starting-state seeds
Thin authored files, one per starting point. An origin is an **initial-state
overlay** on the static shared world (`/content/world`): where the player begins,
the opening situation, and seeds for dispositions, inventory, starting quest, and
character-creation constraints. Values and id references — never maps or personas.
At new-game the player picks an origin; code constructs the runtime canon log
from origin + world content + character creation. See the spec in
[`/docs/canon-log.md`](../../docs) (design under
[`/docs/superpowers/specs`](../../docs/superpowers/specs)).
## Scope (§17)
Schema supports **N** origins; the POC authors **one**. One world, one map, fixed
NPCs — origins change only the player's starting point and situation. More
replayability, flat authoring cost.
## Fields (summary)
`id` · `display_name` · `description` · `start_location_id` · `situation[]` ·
`opening_facts[]` · `disposition_overrides{}` · `inventory_grants[]` ·
`start_quest_id` · `build_constraints{}`
`humiliations` are never seeded — they are earned in play (§7/§9). Validated
against `origin.schema.json`; every id must resolve in `/content/world`.

16
content/world/README.md Normal file
View File

@@ -0,0 +1,16 @@
# /content/world — static, ID-referenced content
The shared world. Authored once, identical every playthrough. Everything here
carries a stable string **id** that origins and the canon log reference. Never
seed initial *state* here — that is an origin's job (`/content/origins`).
```
/locations Towns, dungeons, points of interest. id → location
/npcs Persona + knowledge lists (charter §6). id → npc
/quests Story skeletons and quest definitions (charter §17). id → quest
/items Gear, consumables, cursed items (charter §7). id → item
```
New-game construction resolves every id an origin references (start location,
start quest, granted items, seeded companions) against this content, and fails
loudly if one is missing. Keep ids stable — a rename is a breaking change.

View File

@@ -0,0 +1,9 @@
# /content/world/items
Item definitions — gear, consumables, cursed items — each with a stable **id**.
An origin's `inventory_grants` resolves item ids here.
Cursed/blessed items move Luck (charter §7): *a cursed blade granting +4 STR and
5 LCK is the most interesting item in this game.* The item system supports the
STR/LCK split on day one. Numeric effects live in game state; only narrative-worthy
items surface as canon-log facts.

View File

@@ -0,0 +1,8 @@
# /content/world/locations
The map — towns, dungeons, points of interest, each with a stable **id**. An
origin's `start_location_id` resolves here; the canon log's `location` mirrors
the current one (id + display name).
POC scope (§17): one town, one dungeon (three fights, one boss). One reusable
world; origins vary only where the player begins within it.

File diff suppressed because it is too large Load Diff

View 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` 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.
```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.