Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-09-client-canon-log-engine-design.md
Phillip Tarrant 5a1f1ac0ca docs(spec): client canon log engine (Plan B) design
The client half of the canon log contract: typed RefCounted model with
invariants-in-the-model, Luck-centric game state, new-game construction
from origin+world+creation, turn-to-turn mutators, and a pure regex tag
extractor. Headless GUT-tested logic layer; UI/HTTP/save-load deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:46:53 -05:00

15 KiB
Raw Permalink Blame History

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, docs/schemas/canon-log.schema.json, docs/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 ~1011 — 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-checkworld.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 LuckLuck.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
    • locationstart_location_id (+ name from ContentDB)
    • partycompanions(), each dispositiondisposition_overrides[companion_id]; non-companion override ids → GameState.npc_dispositions (never the log)
    • recent_eventssituation[] (pushed through push_event, so a >5 situation self-trims)
    • established_factsopening_facts[]
    • active_quests[resolve(start_quest_id)], or [] when null
    • humiliations[] (never seeded — earned in play, §7/§9)
    • schema_version ← 1
    • inventory_grantsGameState.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.