# Client Canon Log Engine (Plan B) — Design **Date:** 2026-07-09 **Status:** approved (design); implementation plan to follow **Charter refs:** §2 (code owns state), §7 (Luck), §9 (companions/humiliations), §10 (determinism/seeding), §11 (canon log), §12 (output contracts/tags), §16 (layout) **Binds to:** the canon log contract shipped by Plan A — [`docs/canon-log.md`](../../canon-log.md), [`docs/schemas/canon-log.schema.json`](../../schemas/canon-log.schema.json), [`docs/schemas/origin.schema.json`](../../schemas/origin.schema.json), and the authored fixtures in `/content`. ## Problem Plan A shipped the **enforceable half** of the canon log: the two JSON Schemas, the authored `deserter` origin + world content, and the api's runtime validation of every posted log. What it deliberately left out — named as Plan B — is the client half: the GDScript engine that **constructs** the canon log at new-game and **maintains** it turn to turn. Per the contract's own ownership table (`docs/canon-log.md`): the client **constructs and maintains** the log; the proxy only **validates** it. This plan builds that construct-and-maintain engine as a headless, testable logic layer, so the client's product provably conforms to the same contract the api enforces — without duplicating the schema. ## Scope **In scope — a pure logic layer:** - The typed canon-log model (RefCounted classes, one per entity). - A Luck-centric game-state store (numeric Luck + drift + descriptor, world-NPC dispositions, inventory). - New-game construction: `(origin + world content + character creation) → {canon log, game state}`. - Turn-to-turn maintenance mutators, each enforcing the invariant it owns. - A pure regex tag-extractor for `[FACT:]` / `[ADJUST_DISPOSITION:]` / `[MOVE:]` (charter §12). - Headless GUT tests for every unit. **Out of scope — deferred to later plans:** - UI scenes (character-creation screen, game loop). - The HTTP client to `/api`. - Save-file I/O and `schema_version` migration beyond a loud-fail seam. - Move **validation and apply** (needs NPC knowledge / available-moves context that only arrives at `/npc/speak`). - Combat, HP/MP, and any consumer of the rolled stat block. - Packaging `/content` into an export build. ## Decisions (locked in brainstorming) 1. **Test framework: GUT**, vendored into `client/addons/gut`, run headless. 2. **Logic layer only** — no UI, no HTTP, no save/load in this plan. 3. **Model: RefCounted typed classes** with explicit `to_dict()` / `from_dict()` at the JSON boundary (no `Resource`/`.tres`, no bare dictionaries). 4. **Validation: invariants live in the model** (correct-by-construction). No general JSON-Schema validator on the client — the api is the authoritative schema gate. This matches the contract doc's split (client constructs/maintains, proxy validates) and avoids the drift the "schemas are the single source of truth" principle warns against. 5. **Game state: Luck-centric minimal** — numeric Luck (gen/drift/descriptor), a world-NPC disposition store, an inventory bag, and the 6-stat block stored as data with no consumers yet. 6. **Maintenance seam: mutators + tag extractor**, but **not** move validation/apply. 7. **Module decomposition: fat-but-decomposed model** — each entity is a small class that owns the invariant guarding its own data; construction and extraction are separate collaborators. ## Architecture — one direction of flow ``` IMMUTABLE INPUTS MUTABLE PRODUCTS origin seed (dict) ┐ world content (ContentDB) ├─ NewGame.construct ─▶ CanonLog (the §11 structure) character creation ┘ └─▶ GameState (numeric Luck, npc dispo, inventory, stats) turn to turn: AI prose ─▶ TagExtractor ─▶ {facts, dispositions, moves} │ (facts, dispositions) ▼ CanonLog mutators (invariant-enforcing) ``` Construction **reads** three immutable inputs and **writes** two products; nothing flows back up. That is charter §2 (code owns state) realized on the client. AI prose is never a source of truth — a tag is a *request*; code records the canonical value. ## File layout (under `client/`) ``` client/ addons/gut/ vendored GUT (Task 1) scripts/ ids.gd class_name Ids shared id-regex ^[a-z0-9_]+$ canon_log/ canon_log.gd class_name CanonLog container + container-level rules log_player.gd class_name LogPlayer name, class_id, luck_descriptor log_location.gd class_name LogLocation id, name party_member.gd class_name PartyMember id, name, disposition quest.gd class_name Quest id, name, status, objective humiliation.gd class_name Humiliation id, text, weight, turn state/ game_state.gd class_name GameState numeric luck, stats, npc dispo, inventory luck.gd class_name Luck gen / drift / descriptor (static) content/ content_db.gd class_name ContentDB loads /content, id lookups, unresolved_refs newgame/ new_game.gd class_name NewGame origin + world + creation -> {log, state} text/ tag_extractor.gd class_name TagExtractor regex parse of §12 tags tests/ unit/*.gd GUT tests (one per unit + round-trip + integration) .gutconfig.json headless run config ``` `Log*` prefixes the two entities that would otherwise shadow plain nouns (`LogPlayer`, `LogLocation`) and signals "a row in the log," not a game object. `Quest` / `Humiliation` / `PartyMember` are unambiguous. ## The canon-log model — entities and owned invariants Every class carries `to_dict()` / `from_dict()`. Each **owns the invariant that guards its own data** (decision 4/7): the rule lives with the field it protects, so no caller can bypass it. | Class | Fields | Invariant enforced on mutation | |---|---|---| | `CanonLog` | `schema_version`, `player`, `location`, `party[]`, `recent_events[]`, `established_facts[]`, `active_quests[]`, `humiliations[]` | `push_event(line)` appends then **drops oldest past 5**; `add_fact(text)` appends **only if absent (dedup)**; `add_humiliation(...)` **append-only, stacks, never dedups**; `set_quest_status(id, status)` requires status ∈ {active, complete, failed}; `adjust_disposition(id, delta)` delegates to the member | | `LogPlayer` | `name`, `class_id`, `luck_descriptor` | `class_id` ∈ {sellsword, assassin, priest} | | `LogLocation` | `id`, `name` | `id` matches `Ids` regex | | `PartyMember` | `id`, `name`, `disposition` | `set_disposition(v)` **clamps −100..100** | | `Quest` | `id`, `name`, `status`, `objective` | `status` ∈ enum; `id` valid | | `Humiliation` | `id`, `text`, `weight`, `turn` | `weight` clamped **1..10**; `turn` ≥ 0 | `CanonLog.from_dict()` rebuilds the typed sub-objects and reads `schema_version`. Only version `1` is understood; an unknown version is a **loud error, not a silent pass** — the migration seam for a future save/load plan. The rows the design spec (2026-07-09-canon-log-schema-design) calls "maintenance mutations" are exactly these methods. A later HTTP plan calls them; Plan B proves they hold under GUT. ## Game state — the Luck-centric store ``` GameState: luck: int # current numeric Luck — NEVER enters the log luck_base: int # drift anchors here stats: Dictionary # {str,dex,con,fth,mag} rolled at creation; stored, no consumers yet npc_dispositions: {} # world-NPC id -> int, clamp -100..100 (surfaces only at /npc/speak, later) inventory: {} # item_id -> qty ``` `GameState` is the sole home of numeric Luck. The log carries only `luck_descriptor`. This makes the §7 boundary physical on the client, mirroring how `additionalProperties:false` enforces it on the api: the number the player must never be able to *calculate* cannot leave this object. ### `Luck` — deterministic static helpers (§7 + §10) - `roll_base(rng, modifier) -> int` — average of **5 rolls** (each `rng.randi_range(1, 20)`), rounded, plus `modifier`, clamped `1..20`. Central-tendency ~10–11 — the "centralizes hard" §7 asks for. - `drift(current, base, delta) -> int` — moves Luck, clamped to `base ± 3` **and** to `1..20`. - `descriptor(luck) -> String` — maps the value to an authored band string. **Tuning values (flagged, non-blocking):** the die (d20), the range (1..20), and the descriptor **bands** (≈5, e.g. ≤4 → "Fortune spits on you" … ≥17 → "The dice are kind today") are tunable constants, called out as such in the implementation plan — not final copy. The `RandomNumberGenerator` is **injected** into construction, so tests seed it and get identical Luck every run. That is the §10 reproducibility principle wired in early, when it is nearly free, rather than retrofitted. ## Content loading — `ContentDB` Binds to the authored content Plan A shipped under `/content`. - `load(content_root: String)` — reads `world/{locations,npcs,quests,items}/*.json`, indexes each by its `id`. - Lookups: `location(id)`, `npc(id)`, `quest(id)`, `item(id)`, and `has_*(id)` predicates. - `companions() -> Array` — NPCs whose `role == "companion"` (the fixtures already mark Brannoc and Cadwyn) — the default party roster. - `unresolved_refs(origin) -> Array[String]` — the **GDScript twin of the Python `content.unresolved_refs`** (Plan A, Task 3): returns entries like `["location:nowhere", "item:ghost_blade"]`; empty means every referenced id resolves. Same content-integrity check, client side. Distinct from JSON-schema validation. **Content root:** dev reads `res://../content`; tests pass the repo path explicitly. Packaging `/content` into an export build is a later plan — noted, not solved here. ## New-game construction `NewGame.construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng) -> {log: CanonLog, state: GameState}`, where `creation = {name, class_id}`. Steps mirror the construction routine in the schema-design spec: 1. **Resolve-check** — `world.unresolved_refs(origin)`; non-empty → fail loudly **now**, not three scenes later. (Content integrity, not schema validation.) 2. **Build constraints** — assert `creation.class_id ∈ origin.build_constraints.allowed_classes`. 3. **Roll Luck** — `Luck.roll_base(rng, build_constraints.luck_modifier)` → `GameState.luck` / `luck_base`; derive descriptor. 4. **Roll stat block** — into `GameState.stats` (data only, no consumers). 5. **Assemble `CanonLog`:** - `player` ← name, class_id, descriptor - `location` ← `start_location_id` (+ name from ContentDB) - `party` ← `companions()`, each `disposition` ← `disposition_overrides[companion_id]`; **non-companion** override ids → `GameState.npc_dispositions` (never the log) - `recent_events` ← `situation[]` (pushed through `push_event`, so a >5 situation self-trims) - `established_facts` ← `opening_facts[]` - `active_quests` ← `[resolve(start_quest_id)]`, or `[]` when null - `humiliations` ← `[]` (never seeded — earned in play, §7/§9) - `schema_version` ← 1 - `inventory_grants` → `GameState.inventory` (**not** the log) 6. Return `{log, state}`. The seam is clean: construction reads three immutable inputs and writes two products; nothing flows back up. ## Tag extraction — `TagExtractor` (charter §12) ``` TagExtractor.extract(prose: String) -> { clean_text: String, # prose with all tags stripped, ready to display facts: Array[String], # [FACT: ...] payloads dispositions: Array[int], # [ADJUST_DISPOSITION: +5] -> 5, [ -10 ] -> -10 moves: Array[Dictionary], # [MOVE: offer_quest(14)] -> {name:"offer_quest", args:["14"]} } ``` Three regexes, one per tag form in §12. Pure and stateless — testable on literal strings, no HTTP, no game state. It **extracts and strips only**; it does not apply anything and does not validate moves (that needs NPC knowledge / available-moves context from `/npc/speak`, a later plan). Its `facts` and `dispositions` outputs are exactly the inputs the `CanonLog` mutators consume — so the future HTTP plan is just wiring: `POST → extract → validate move vs state → apply`. Plan B builds both ends of that pipe except the validate-and-apply glue. ## Testing — GUT, headless Run: `godot --headless -s res://addons/gut/gut_cmdln.gd -gconfig=res://.gutconfig.json` (config sets `-gdir=res://tests/unit`, `-gexit`). Claude runs this per task; scenes can also be run in-editor. | Test file | Proves | |---|---| | `test_canon_log.gd` | `push_event` caps at 5, `add_fact` dedups, `add_humiliation` stacks (no dedup), disposition clamps, quest-status enum, unknown `schema_version` errors | | `test_luck.gd` | same seed → same base/descriptor (determinism); drift stays in `base±3` and `1..20`; modifier applied; band boundaries | | `test_content_db.gd` | loads repo `/content`; `companions()` == Brannoc + Cadwyn; `unresolved_refs` catches a broken location/item ref (mirrors the Python test) | | `test_new_game.gd` | full construct from the **real `deserter.json`**: log shape correct, numeric luck absent from log, inventory/npc-dispo in state not log, null `start_quest` → empty quests, bad class rejected | | `test_tag_extractor.gd` | each tag form parsed, `clean_text` stripped, multiple tags, malformed tag ignored | | `test_round_trip.gd` | `to_dict → from_dict` identity; a constructed log's `to_dict` satisfies every invariant (client-side proof it would pass the api's schema) | `test_round_trip.gd` is the bridge to Plan A: it asserts the client's product conforms to the same contract the api enforces, **without** re-implementing the schema (decision 4). ## What this plan proves The client can turn *(origin + world + character creation)* into a **contract-valid canon log**, and can **maintain** that log's invariants turn to turn — all headless, all tested, ready for the HTTP plan to plug into. It moves Plan B's half of the contract from "designed" to "enforced," exactly as Plan A did for the api half. ## Consequences / follow-ups - The HTTP plan wires `role endpoint call → TagExtractor → move validation → CanonLog mutators`, plus the degraded-DM fallback (§13). - A save/load plan serializes `{CanonLog, GameState}` and fills in the `schema_version` migration seam. - A character-creation UI plan drives `NewGame.construct` from real player input. - Tuning pass: finalize the Luck die/range and the `luck_descriptor` band copy. ## Open questions None blocking. Luck die/range and descriptor band copy are tuning values, seeded as defaults and deferred to a later pass.