# 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.
