From 800811dac2b26c94769084df823dc4fb08744611 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:03:37 -0500 Subject: [PATCH 1/2] docs: canon log schema + origin seed design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- content/README.md | 36 ++- content/origins/README.md | 26 ++ content/world/README.md | 16 ++ content/world/items/README.md | 9 + content/world/locations/README.md | 8 + content/{ => world}/npcs/README.md | 0 content/{ => world}/quests/README.md | 0 .../2026-07-09-canon-log-schema-design.md | 263 ++++++++++++++++++ 8 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 content/origins/README.md create mode 100644 content/world/README.md create mode 100644 content/world/items/README.md create mode 100644 content/world/locations/README.md rename content/{ => world}/npcs/README.md (100%) rename content/{ => world}/quests/README.md (100%) create mode 100644 docs/superpowers/specs/2026-07-09-canon-log-schema-design.md diff --git a/content/README.md b/content/README.md index eb4e7ab..2063c9b 100644 --- a/content/README.md +++ b/content/README.md @@ -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). diff --git a/content/origins/README.md b/content/origins/README.md new file mode 100644 index 0000000..398e692 --- /dev/null +++ b/content/origins/README.md @@ -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`. diff --git a/content/world/README.md b/content/world/README.md new file mode 100644 index 0000000..5e3337a --- /dev/null +++ b/content/world/README.md @@ -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. diff --git a/content/world/items/README.md b/content/world/items/README.md new file mode 100644 index 0000000..8d769e4 --- /dev/null +++ b/content/world/items/README.md @@ -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. diff --git a/content/world/locations/README.md b/content/world/locations/README.md new file mode 100644 index 0000000..09404b8 --- /dev/null +++ b/content/world/locations/README.md @@ -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. diff --git a/content/npcs/README.md b/content/world/npcs/README.md similarity index 100% rename from content/npcs/README.md rename to content/world/npcs/README.md diff --git a/content/quests/README.md b/content/world/quests/README.md similarity index 100% rename from content/quests/README.md rename to content/world/quests/README.md diff --git a/docs/superpowers/specs/2026-07-09-canon-log-schema-design.md b/docs/superpowers/specs/2026-07-09-canon-log-schema-design.md new file mode 100644 index 0000000..1947670 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-canon-log-schema-design.md @@ -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/.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. From 87c5748295f83d7bc4fe1d20c6723880d2a96f99 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:18:41 -0500 Subject: [PATCH 2/2] docs: implementation plan for canon log contract + api validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan A of two: the JSON Schema contract, authored POC fixtures (deserter origin + minimal world), and the api's runtime validation of every canon log (422 on invalid, enforcing the §7 luck boundary). Five TDD tasks, all runnable with pytest + Docker in-repo. Plan B (GDScript client construction/maintenance) deferred until we're in Godot. Plan: docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md Co-Authored-By: Claude Opus 4.8 (1M context) --- ...9-canon-log-contract-and-api-validation.md | 1020 +++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md diff --git a/docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md b/docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md new file mode 100644 index 0000000..b49958d --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md @@ -0,0 +1,1020 @@ +# Canon Log Contract & API Validation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the canon log + origin JSON Schema contract, minimal authored fixtures, and the api's runtime validation of every incoming canon log — the enforceable half of the [canon log design](../specs/2026-07-09-canon-log-schema-design.md). + +**Architecture:** Two JSON Schemas in `/docs/schemas` are the single source of truth. The api (Python/FastAPI) loads them and validates any canon log posted to a role endpoint, rejecting invalid ones with HTTP 422 before they could ever reach a prompt. Authored fixtures (one origin + minimal world content) exercise a language-neutral content-integrity check: every id an origin references must resolve in world content. The GDScript client that *constructs* and *maintains* the log is a separate plan (Plan B). + +**Tech Stack:** Python 3.12, FastAPI, `jsonschema` (Draft 2020-12), pytest. JSON everywhere. Docker for parity. + +## Global Constraints + +- **Python 3.12**, FastAPI. Runtime deps pinned in `api/requirements.txt`; dev deps in `api/requirements-dev.txt`. +- **Schemas are the single source of truth**, at `/docs/schemas/*.json`, validated by both sides (this plan wires the api side). +- **Numeric Luck, stats, HP/MP, inventory contents never appear in the canon log** (charter §7/§2). The canon-log schema enforces this via `additionalProperties: false` and a `player` object that admits only `name`, `class_id`, `luck_descriptor`. +- **`recent_events` is capped at 5 items** (charter §11). Enforced with `maxItems: 5`. +- **POC classes:** `sellsword` | `assassin` | `priest` only (charter §8). +- **All string ids match `^[a-z0-9_]+$`.** +- **Execution branch:** run this plan on a `feature/canon-log-contract` branch off `dev` (per the git workflow in CLAUDE.md §18); the design/plan docs themselves live on `docs/canon-log-schema`. Per-task commits below land on the feature branch. +- **Test setup (do once before Task 1):** + +```bash +python3 -m venv .venv +.venv/bin/pip install -r api/requirements.txt -r api/requirements-dev.txt +``` + +All test commands below assume the venv is active (`source .venv/bin/activate`) and are run **from the `api/` directory** (`cd api`), where `pytest.ini` puts `app` on the path. + +--- + +### Task 1: Dev tooling + origin schema + origin validator + +**Files:** +- Modify: `api/requirements.txt` (add `jsonschema`) +- Create: `api/requirements-dev.txt` +- Create: `api/pytest.ini` +- Create: `docs/schemas/origin.schema.json` +- Create: `api/app/canon_log.py` +- Test: `api/tests/__init__.py`, `api/tests/test_origin_schema.py` + +**Interfaces:** +- Produces: `app.canon_log.validate_origin(doc: dict) -> list[str]` — returns a list of human-readable error messages; empty list means valid. Also `app.canon_log.validate_canon_log(doc: dict) -> list[str]` (used by Task 2/4) and internal `app.canon_log.validation_errors(doc: dict, schema_name: str) -> list[str]`. + +- [ ] **Step 1: Add the runtime dependency** + +Modify `api/requirements.txt` to add one line (keep existing pins): + +``` +# FastAPI proxy runtime deps. Pinned from first resolve; bump deliberately. +fastapi==0.139.0 +uvicorn[standard]==0.51.0 +httpx==0.28.1 # calling Ollama / Replicate +pydantic==2.13.4 # request/response contracts +jsonschema==4.23.0 # canon log / origin contract validation +``` + +- [ ] **Step 2: Create the dev dependency file** + +Create `api/requirements-dev.txt`: + +``` +# Dev/test deps. Installed on top of requirements.txt. +pytest==8.3.2 +``` + +- [ ] **Step 3: Create the pytest config** + +Create `api/pytest.ini` so `app` is importable and tests are discovered: + +```ini +[pytest] +pythonpath = . +testpaths = tests +``` + +- [ ] **Step 4: Install (if not already done in Test setup)** + +Run: `.venv/bin/pip install -r api/requirements.txt -r api/requirements-dev.txt` +Expected: installs `jsonschema` and `pytest` with no errors. + +- [ ] **Step 5: Create the origin JSON Schema** + +Create `docs/schemas/origin.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://coc-rpg/schemas/origin.schema.json", + "title": "Origin Seed", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "id", "display_name", "description", + "start_location_id", "situation", "opening_facts", + "disposition_overrides", "inventory_grants", "start_quest_id", + "build_constraints" + ], + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "display_name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "start_location_id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "situation": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "opening_facts": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "disposition_overrides": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": -100, "maximum": 100 } + }, + "inventory_grants": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["item_id", "qty"], + "properties": { + "item_id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "qty": { "type": "integer", "minimum": 1 } + } + } + }, + "start_quest_id": { "type": ["string", "null"], "pattern": "^[a-z0-9_]+$" }, + "build_constraints": { + "type": "object", + "additionalProperties": false, + "required": ["allowed_classes", "luck_modifier"], + "properties": { + "allowed_classes": { + "type": "array", + "minItems": 1, + "items": { "enum": ["sellsword", "assassin", "priest"] } + }, + "luck_modifier": { "type": "integer" } + } + } + } +} +``` + +- [ ] **Step 6: Create the empty test package marker** + +Create `api/tests/__init__.py` (empty file). + +- [ ] **Step 7: Write the failing test** + +Create `api/tests/test_origin_schema.py`: + +```python +import copy + +from app.canon_log import validate_origin + +VALID_ORIGIN = { + "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"], + "opening_facts": ["the player deserted the Iron Kettle mercenary company"], + "disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15}, + "inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}], + "start_quest_id": "find_the_ledger", + "build_constraints": { + "allowed_classes": ["sellsword", "assassin", "priest"], + "luck_modifier": 0, + }, +} + + +def test_valid_origin_passes(): + assert validate_origin(VALID_ORIGIN) == [] + + +def test_null_start_quest_is_allowed(): + doc = copy.deepcopy(VALID_ORIGIN) + doc["start_quest_id"] = None + assert validate_origin(doc) == [] + + +def test_unknown_class_is_rejected(): + doc = copy.deepcopy(VALID_ORIGIN) + doc["build_constraints"]["allowed_classes"] = ["bard"] + assert validate_origin(doc) != [] + + +def test_missing_required_field_is_rejected(): + doc = copy.deepcopy(VALID_ORIGIN) + del doc["start_location_id"] + assert validate_origin(doc) != [] + + +def test_extra_field_is_rejected(): + doc = copy.deepcopy(VALID_ORIGIN) + doc["surprise"] = True + assert validate_origin(doc) != [] +``` + +- [ ] **Step 8: Run test to verify it fails** + +Run: `cd api && python -m pytest tests/test_origin_schema.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.canon_log'`. + +- [ ] **Step 9: Write the validator module** + +Create `api/app/canon_log.py`: + +```python +"""Load the JSON Schema contracts and validate canon logs / origin seeds. + +Schemas are the single source of truth in /docs/schemas. This module is the +api's half of the contract (charter §11): every canon log the client posts is +validated here before it could ever reach a prompt. +""" + +import json +import os +from functools import lru_cache +from pathlib import Path + +from jsonschema import Draft202012Validator + + +def _schema_dir() -> Path: + """Locate the schema directory. + + Honours CANON_SCHEMA_DIR, else walks up from this file looking for + docs/schemas (local checkout) or schemas (bundled into the Docker image). + """ + env = os.environ.get("CANON_SCHEMA_DIR") + if env: + return Path(env) + here = Path(__file__).resolve() + for parent in here.parents: + for candidate in (parent / "docs" / "schemas", parent / "schemas"): + if candidate.is_dir(): + return candidate + raise RuntimeError("schema directory not found") + + +@lru_cache(maxsize=None) +def _validator(schema_name: str) -> Draft202012Validator: + with open(_schema_dir() / schema_name) as f: + return Draft202012Validator(json.load(f)) + + +def validation_errors(doc: dict, schema_name: str) -> list[str]: + validator = _validator(schema_name) + return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: list(e.path))] + + +def validate_origin(doc: dict) -> list[str]: + return validation_errors(doc, "origin.schema.json") + + +def validate_canon_log(doc: dict) -> list[str]: + return validation_errors(doc, "canon-log.schema.json") +``` + +- [ ] **Step 10: Run test to verify it passes** + +Run: `cd api && python -m pytest tests/test_origin_schema.py -v` +Expected: PASS — 5 passed. + +- [ ] **Step 11: Commit** + +```bash +git add api/requirements.txt api/requirements-dev.txt api/pytest.ini \ + docs/schemas/origin.schema.json api/app/canon_log.py \ + api/tests/__init__.py api/tests/test_origin_schema.py +git commit -m "feat(api): origin seed JSON Schema + validator" +``` + +--- + +### Task 2: Canon log schema + validator (enforces the §7 Luck boundary) + +**Files:** +- Create: `docs/schemas/canon-log.schema.json` +- Create: `api/tests/fixtures/canon_log_valid.json` +- Test: `api/tests/test_canon_log_schema.py` + +**Interfaces:** +- Consumes: `app.canon_log.validate_canon_log` (from Task 1). +- Produces: `api/tests/fixtures/canon_log_valid.json` — a canonical valid canon log reused by Task 4's endpoint tests. + +- [ ] **Step 1: Create the canon log JSON Schema** + +Create `docs/schemas/canon-log.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://coc-rpg/schemas/canon-log.schema.json", + "title": "Canon Log", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "player", "location", "party", + "recent_events", "established_facts", "active_quests", "humiliations" + ], + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "player": { + "type": "object", + "additionalProperties": false, + "required": ["name", "class_id", "luck_descriptor"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "class_id": { "enum": ["sellsword", "assassin", "priest"] }, + "luck_descriptor": { "type": "string", "minLength": 1 } + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "name": { "type": "string", "minLength": 1 } + } + }, + "party": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "disposition"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "name": { "type": "string", "minLength": 1 }, + "disposition": { "type": "integer", "minimum": -100, "maximum": 100 } + } + } + }, + "recent_events": { + "type": "array", + "maxItems": 5, + "items": { "type": "string", "minLength": 1 } + }, + "established_facts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "active_quests": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "status", "objective"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "name": { "type": "string", "minLength": 1 }, + "status": { "enum": ["active", "complete", "failed"] }, + "objective": { "type": "string", "minLength": 1 } + } + } + }, + "humiliations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "text", "weight", "turn"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "text": { "type": "string", "minLength": 1 }, + "weight": { "type": "integer", "minimum": 1, "maximum": 10 }, + "turn": { "type": "integer", "minimum": 0 } + } + } + } + } +} +``` + +- [ ] **Step 2: Create the valid fixture** + +Create `api/tests/fixtures/canon_log_valid.json`: + +```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 } + ] +} +``` + +- [ ] **Step 3: Write the failing test** + +Create `api/tests/test_canon_log_schema.py`: + +```python +import copy +import json +from pathlib import Path + +from app.canon_log import validate_canon_log + +FIXTURE = Path(__file__).parent / "fixtures" / "canon_log_valid.json" + + +def _valid(): + with open(FIXTURE) as f: + return json.load(f) + + +def test_valid_canon_log_passes(): + assert validate_canon_log(_valid()) == [] + + +def test_numeric_luck_is_rejected(): + # Charter §7: the AI must never be able to calculate Luck. A stray numeric + # luck field must fail validation, not slip through. + doc = _valid() + doc["player"]["luck"] = 5 + assert validate_canon_log(doc) != [] + + +def test_recent_events_over_five_is_rejected(): + doc = _valid() + doc["recent_events"] = [f"event {i}" for i in range(6)] + assert validate_canon_log(doc) != [] + + +def test_disposition_out_of_range_is_rejected(): + doc = _valid() + doc["party"][0]["disposition"] = 200 + assert validate_canon_log(doc) != [] + + +def test_unknown_quest_status_is_rejected(): + doc = _valid() + doc["active_quests"][0]["status"] = "abandoned" + assert validate_canon_log(doc) != [] + + +def test_humiliation_weight_bounds_enforced(): + doc = _valid() + doc["humiliations"][0]["weight"] = 11 + assert validate_canon_log(doc) != [] + + +def test_empty_optional_arrays_are_allowed(): + doc = _valid() + doc["party"] = [] + doc["recent_events"] = [] + doc["established_facts"] = [] + doc["active_quests"] = [] + doc["humiliations"] = [] + assert validate_canon_log(doc) == [] +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd api && python -m pytest tests/test_canon_log_schema.py -v` +Expected: FAIL — `FileNotFoundError` for `canon-log.schema.json` on the first test (schema not yet found) OR assertion failures. (If Step 1 already created the schema, the numeric-luck / bounds tests still prove behavior; all must pass only after Step 1 is in place. If any test unexpectedly passes before the schema exists, stop — the schema resolver is pointing at the wrong directory.) + +- [ ] **Step 5: (No new code)** The schema from Step 1 is the implementation. Confirm `docs/schemas/canon-log.schema.json` exists. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd api && python -m pytest tests/test_canon_log_schema.py -v` +Expected: PASS — 7 passed. + +- [ ] **Step 7: Commit** + +```bash +git add docs/schemas/canon-log.schema.json \ + api/tests/fixtures/canon_log_valid.json \ + api/tests/test_canon_log_schema.py +git commit -m "feat(api): canon log JSON Schema + validator, enforce §7 luck boundary" +``` + +--- + +### Task 3: Authored fixtures + content-integrity resolution + +**Files:** +- Create: `content/world/locations/greywater_docks.json` +- Create: `content/world/npcs/brannoc_thane.json`, `content/world/npcs/cadwyn_vell.json` +- Create: `content/world/quests/find_the_ledger.json` +- Create: `content/world/items/worn_shortsword.json`, `content/world/items/coin.json` +- Create: `content/origins/deserter.json` +- Create: `api/app/content.py` +- Test: `api/tests/test_content_resolution.py` + +**Interfaces:** +- Consumes: `app.canon_log.validate_origin` (Task 1). +- Produces: `app.content.load_world(content_root: Path) -> dict[str, set[str]]` (keys: `locations`, `npcs`, `quests`, `items`); `app.content.load_origin(path: Path) -> dict`; `app.content.unresolved_refs(origin: dict, world: dict) -> list[str]` (empty means every referenced id resolves). + +- [ ] **Step 1: Create the world content fixtures** + +Create `content/world/locations/greywater_docks.json`: + +```json +{ "id": "greywater_docks", "name": "the Greywater docks", + "description": "A rot-black wharf where the river meets the sea trade." } +``` + +Create `content/world/npcs/brannoc_thane.json`: + +```json +{ "id": "brannoc_thane", "name": "Brannoc Thane", "role": "companion", + "persona": "Dry, warm, economical. Twenty years past his prime and at peace with it.", + "knowledge": [] } +``` + +Create `content/world/npcs/cadwyn_vell.json`: + +```json +{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "role": "companion", + "persona": "Florid when performing, clipped when scared. A fine musician and a finer liar.", + "knowledge": [] } +``` + +Create `content/world/quests/find_the_ledger.json`: + +```json +{ "id": "find_the_ledger", "name": "The Missing Ledger", + "objective": "Find who took Fenn's ledger" } +``` + +Create `content/world/items/worn_shortsword.json`: + +```json +{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" } +``` + +Create `content/world/items/coin.json`: + +```json +{ "id": "coin", "name": "coin", "slot": "currency" } +``` + +- [ ] **Step 2: Create the POC origin fixture** + +Create `content/origins/deserter.json`: + +```json +{ + "schema_version": 1, + "id": "deserter", + "display_name": "The Deserter", + "description": "You walked away from a company that doesn't allow walking away. Greywater was just far enough. You hoped.", + "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 + } +} +``` + +- [ ] **Step 3: Write the failing test** + +Create `api/tests/test_content_resolution.py`: + +```python +import copy +import json +from pathlib import Path + +from app.canon_log import validate_origin +from app.content import load_world, load_origin, unresolved_refs + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONTENT_ROOT = REPO_ROOT / "content" +DESERTER = CONTENT_ROOT / "origins" / "deserter.json" + + +def test_deserter_origin_matches_schema(): + assert validate_origin(load_origin(DESERTER)) == [] + + +def test_deserter_ids_all_resolve(): + world = load_world(CONTENT_ROOT) + assert unresolved_refs(load_origin(DESERTER), world) == [] + + +def test_broken_location_ref_is_detected(): + world = load_world(CONTENT_ROOT) + origin = copy.deepcopy(load_origin(DESERTER)) + origin["start_location_id"] = "nowhere" + assert "location:nowhere" in unresolved_refs(origin, world) + + +def test_broken_item_ref_is_detected(): + world = load_world(CONTENT_ROOT) + origin = copy.deepcopy(load_origin(DESERTER)) + origin["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1}) + assert "item:ghost_blade" in unresolved_refs(origin, world) + + +def test_null_quest_ref_resolves(): + world = load_world(CONTENT_ROOT) + origin = copy.deepcopy(load_origin(DESERTER)) + origin["start_quest_id"] = None + assert unresolved_refs(origin, world) == [] +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd api && python -m pytest tests/test_content_resolution.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.content'`. + +- [ ] **Step 5: Write the content module** + +Create `api/app/content.py`: + +```python +"""Load authored world content and cross-check origin references. + +Language-neutral content integrity: every id an origin references (start +location, start quest, granted items, seeded npc dispositions) must resolve in +world content. New-game construction (client-side, Plan B) relies on this +holding; catching it here fails a broken origin at authoring time, not three +scenes into play. +""" + +import json +from pathlib import Path + + +def _load_ids(dir_path: Path) -> set[str]: + ids: set[str] = set() + for f in dir_path.glob("*.json"): + with open(f) as fh: + ids.add(json.load(fh)["id"]) + return ids + + +def load_world(content_root: Path) -> dict[str, set[str]]: + world = content_root / "world" + return { + "locations": _load_ids(world / "locations"), + "npcs": _load_ids(world / "npcs"), + "quests": _load_ids(world / "quests"), + "items": _load_ids(world / "items"), + } + + +def load_origin(path: Path) -> dict: + with open(path) as f: + return json.load(f) + + +def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]: + missing: list[str] = [] + if origin["start_location_id"] not in world["locations"]: + missing.append(f"location:{origin['start_location_id']}") + quest = origin.get("start_quest_id") + if quest is not None and quest not in world["quests"]: + missing.append(f"quest:{quest}") + for grant in origin["inventory_grants"]: + if grant["item_id"] not in world["items"]: + missing.append(f"item:{grant['item_id']}") + for npc_id in origin["disposition_overrides"]: + if npc_id not in world["npcs"]: + missing.append(f"npc:{npc_id}") + return missing +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd api && python -m pytest tests/test_content_resolution.py -v` +Expected: PASS — 5 passed. + +- [ ] **Step 7: Commit** + +```bash +git add content/world content/origins/deserter.json \ + api/app/content.py api/tests/test_content_resolution.py +git commit -m "feat(content): POC deserter origin + world fixtures with id-resolution check" +``` + +--- + +### Task 4: Enforce the contract at the api boundary + +**Files:** +- Modify: `api/app/main.py` +- Test: `api/tests/test_endpoints.py` + +**Interfaces:** +- Consumes: `app.canon_log.validate_canon_log` (Task 1/2), `api/tests/fixtures/canon_log_valid.json` (Task 2). +- Produces: a shared FastAPI dependency `app.main.valid_turn` and request model `app.main.TurnRequest` (`{ "canon_log": dict }`). All five role endpoints require it; an invalid canon log returns HTTP 422 with `{"detail": {"canon_log_errors": [...]}}`. + +- [ ] **Step 1: Write the failing test** + +Create `api/tests/test_endpoints.py`: + +```python +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) +VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json")) + +ROLE_ENDPOINTS = [ + "/dm/narrate", "/dm/adjudicate", "/dm/improvise", + "/npc/speak", "/party/banter", +] + + +def test_health_ok(): + assert client.get("/health").json() == {"status": "ok"} + + +def test_every_role_endpoint_accepts_a_valid_canon_log(): + for path in ROLE_ENDPOINTS: + r = client.post(path, json={"canon_log": VALID_LOG}) + assert r.status_code == 200, f"{path} rejected a valid log: {r.text}" + + +def test_every_role_endpoint_rejects_an_invalid_canon_log(): + bad = json.loads(json.dumps(VALID_LOG)) + bad["player"]["luck"] = 5 # §7 leak + for path in ROLE_ENDPOINTS: + r = client.post(path, json={"canon_log": bad}) + assert r.status_code == 422, f"{path} accepted an invalid log" + assert "canon_log_errors" in r.json()["detail"] + + +def test_missing_canon_log_is_a_422(): + r = client.post("/dm/narrate", json={}) + assert r.status_code == 422 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd api && python -m pytest tests/test_endpoints.py -v` +Expected: FAIL — endpoints currently take no body, so posting `{"canon_log": ...}` returns 200 with `{"detail": "not implemented"}` and the *reject* test fails (invalid log still returns 200). + +- [ ] **Step 3: Wire validation into the endpoints** + +Replace the contents of `api/app/main.py` with: + +```python +"""FastAPI proxy entrypoint — the guarding proxy of charter §4. + +Skeleton: a health check plus the five role endpoints. Each role endpoint now +validates the posted canon log against the contract (charter §11) before doing +anything else — an invalid log is rejected with 422 and never reaches a prompt. +Prompt routing, model selection, and logging land later. +""" + +from fastapi import Depends, FastAPI, HTTPException +from pydantic import BaseModel + +from .canon_log import validate_canon_log + +app = FastAPI(title="coc-rpg proxy", version="0.0.1") + + +class TurnRequest(BaseModel): + canon_log: dict + + +def valid_turn(req: TurnRequest) -> TurnRequest: + """Shared dependency: reject any request whose canon log breaks the contract.""" + errors = validate_canon_log(req.canon_log) + if errors: + raise HTTPException(status_code=422, detail={"canon_log_errors": errors}) + return req + + +@app.get("/health") +def health() -> dict: + """Liveness probe for compose / fly.io.""" + return {"status": "ok"} + + +# ── Role endpoints (charter §4) ────────────────────────────────────────────── +# The client knows these paths and nothing about which model or prompt serves +# them. Bodies are validated against the canon log contract; the AI half is a +# stub until prompt routing lands. + + +@app.post("/dm/narrate") +def narrate(req: TurnRequest = Depends(valid_turn)) -> dict: + return {"detail": "not implemented"} + + +@app.post("/dm/adjudicate") +def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict: + return {"detail": "not implemented"} + + +@app.post("/dm/improvise") +def improvise(req: TurnRequest = Depends(valid_turn)) -> dict: + return {"detail": "not implemented"} + + +@app.post("/npc/speak") +def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict: + return {"detail": "not implemented"} + + +@app.post("/party/banter") +def banter(req: TurnRequest = Depends(valid_turn)) -> dict: + return {"detail": "not implemented"} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd api && python -m pytest tests/test_endpoints.py -v` +Expected: PASS — 4 passed. + +- [ ] **Step 5: Run the whole suite** + +Run: `cd api && python -m pytest -v` +Expected: PASS — all tests from Tasks 1–4 green. + +- [ ] **Step 6: Commit** + +```bash +git add api/app/main.py api/tests/test_endpoints.py +git commit -m "feat(api): validate canon log at every role endpoint (422 on invalid)" +``` + +--- + +### Task 5: Bundle schemas into the Docker image + smoke test + +**Files:** +- Modify: `api/Dockerfile` +- Modify: `docker-compose.yml` +- Create: `.dockerignore` (repo root) + +**Interfaces:** +- Consumes: everything above. No new code interfaces. +- Produces: a runnable image whose build context is the repo root, so `/docs/schemas` is bundled at `/app/schemas` and the resolver in `canon_log.py` finds it at runtime. + +- [ ] **Step 1: Rewrite the Dockerfile for a repo-root build context** + +Replace `api/Dockerfile` with: + +```dockerfile +# /api — FastAPI proxy (charter §4). Build context is the repo root so the +# canon log schemas (/docs/schemas) are bundled into the image. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# Install deps first so the layer caches when only app code changes. +COPY api/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# App code + the schema contract (single source of truth in docs/schemas). +COPY api/app ./app +COPY docs/schemas ./schemas + +# Run as non-root. +RUN useradd --create-home --uid 1000 appuser +USER appuser + +EXPOSE 8000 + +# fly.io / compose set PORT; default to 8000 (charter localhost:8000). +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] +``` + +- [ ] **Step 2: Point compose at the repo-root context** + +Replace `docker-compose.yml` with: + +```yaml +# Local dev — run the proxy in Docker with live reload. +# docker compose up --build +# Build context is the repo root so docs/schemas is available to the image. +# Ollama runs on the homelab (charter §4), not here — point OLLAMA_BASE_URL at it. +services: + api: + build: + context: . + dockerfile: api/Dockerfile + ports: + - "8000:8000" + env_file: + - ./api/.env + environment: + PORT: 8000 + # Mount source + schemas so edits are live without a rebuild. + volumes: + - ./api/app:/app/app:ro + - ./docs/schemas:/app/schemas:ro + command: > + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +- [ ] **Step 3: Add a repo-root .dockerignore** + +Create `.dockerignore` (the build context is now the repo root — keep the client, content, and the rest of docs out of the image, but let `docs/schemas` through): + +``` +.git +.claude +client +content +docs/* +!docs/schemas +**/__pycache__ +**/*.py[cod] +.venv +venv +**/.pytest_cache +api/tests +api/.env +api/.env.* +``` + +- [ ] **Step 4: Build the image** + +Run: `docker build -f api/Dockerfile -t coc-rpg-proxy:test .` +Expected: build succeeds; `COPY docs/schemas ./schemas` present in the output. + +- [ ] **Step 5: Smoke test the running container** + +Run: + +```bash +docker run -d --name coc-smoke -p 8000:8000 coc-rpg-proxy:test +sleep 1 +# health +curl -s http://localhost:8000/health +# valid canon log -> 200 +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/dm/narrate \ + -H 'content-type: application/json' \ + --data-binary @api/tests/fixtures/canon_log_valid.json \ + || true +# NOTE: the fixture is the bare log; wrap it for the endpoint: +curl -s -o /dev/null -w 'valid=%{http_code}\n' -X POST http://localhost:8000/dm/narrate \ + -H 'content-type: application/json' \ + -d "{\"canon_log\": $(cat api/tests/fixtures/canon_log_valid.json)}" +# invalid canon log (numeric luck) -> 422 +curl -s -o /dev/null -w 'invalid=%{http_code}\n' -X POST http://localhost:8000/dm/narrate \ + -H 'content-type: application/json' \ + -d '{"canon_log": {"schema_version": 1, "player": {"name": "x", "class_id": "sellsword", "luck_descriptor": "y", "luck": 5}, "location": {"id": "greywater_docks", "name": "z"}, "party": [], "recent_events": [], "established_facts": [], "active_quests": [], "humiliations": []}}' +docker rm -f coc-smoke +docker rmi coc-rpg-proxy:test +``` + +Expected: `{"status":"ok"}`, then `valid=200`, then `invalid=422`. This proves the schema is bundled and enforced at runtime, not just in tests. + +- [ ] **Step 6: Commit** + +```bash +git add api/Dockerfile docker-compose.yml .dockerignore +git commit -m "chore(api): bundle canon log schemas into image; repo-root build context" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Three-layer model — world content, origin seed, canon log: fixtures (Task 3) + schemas (Tasks 1–2). ✔ +- In/out boundary, numeric Luck never in log (§7): `additionalProperties:false` + explicit numeric-luck rejection test (Task 2). ✔ +- Canon log schema, all fields + `recent_events` cap 5, uncapped facts, append-only humiliations shape: Task 2. ✔ (Append-only *behavior* is client-side, Plan B; the schema only constrains shape.) +- Origin seed schema, six seed axes: Task 1. ✔ +- New-game construction: **client-side (Plan B)** — out of this plan's scope by design; Task 3 covers the language-neutral precondition (id resolution). ✔ (noted, not a gap) +- Maintenance mutations: **client-side (Plan B)** — out of scope. ✔ (noted) +- Per-role injection: Task 4 wires all five endpoints to require a valid log; role-specific extras are later work. ✔ +- Storage/format, JSON, one schema both sides, bundled for runtime: Task 5. ✔ + +**Placeholder scan:** No TBD/TODO; every code and test step shows complete content. ✔ + +**Type consistency:** `validate_canon_log` / `validate_origin` / `validation_errors` signatures match across Tasks 1, 2, 4. `load_world` / `load_origin` / `unresolved_refs` signatures match across Task 3. `TurnRequest` / `valid_turn` names consistent in Task 4. ✔ + +**Deviations from spec (flag for reviewer):** +- Spec suggested `origin.schema.json` would be validated "later"; this plan validates it now (Task 1) — strictly additive. +- No `/docs/canon-log.md` living-contract prose doc is created here; the design spec + the schemas themselves serve as the contract for the POC. Add the prose doc in a later docs pass if desired. + +**Out of scope (Plan B):** GDScript canon log model, new-game construction routine, turn-to-turn maintenance hooks, GUT tests.