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>
15 KiB
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_versionmigration 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
/contentinto an export build.
Decisions (locked in brainstorming)
- Test framework: GUT, vendored into
client/addons/gut, run headless. - Logic layer only — no UI, no HTTP, no save/load in this plan.
- Model: RefCounted typed classes with explicit
to_dict()/from_dict()at the JSON boundary (noResource/.tres, no bare dictionaries). - 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.
- 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.
- Maintenance seam: mutators + tag extractor, but not move validation/apply.
- 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 (eachrng.randi_range(1, 20)), rounded, plusmodifier, clamped1..20. Central-tendency ~10–11 — the "centralizes hard" §7 asks for.drift(current, base, delta) -> int— moves Luck, clamped tobase ± 3and to1..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)— readsworld/{locations,npcs,quests,items}/*.json, indexes each by itsid.- Lookups:
location(id),npc(id),quest(id),item(id), andhas_*(id)predicates. companions() -> Array— NPCs whoserole == "companion"(the fixtures already mark Brannoc and Cadwyn) — the default party roster.unresolved_refs(origin) -> Array[String]— the GDScript twin of the Pythoncontent.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:
- Resolve-check —
world.unresolved_refs(origin); non-empty → fail loudly now, not three scenes later. (Content integrity, not schema validation.) - Build constraints — assert
creation.class_id ∈ origin.build_constraints.allowed_classes. - Roll Luck —
Luck.roll_base(rng, build_constraints.luck_modifier)→GameState.luck/luck_base; derive descriptor. - Roll stat block — into
GameState.stats(data only, no consumers). - Assemble
CanonLog:player← name, class_id, descriptorlocation←start_location_id(+ name from ContentDB)party←companions(), eachdisposition←disposition_overrides[companion_id]; non-companion override ids →GameState.npc_dispositions(never the log)recent_events←situation[](pushed throughpush_event, so a >5 situation self-trims)established_facts←opening_facts[]active_quests←[resolve(start_quest_id)], or[]when nullhumiliations←[](never seeded — earned in play, §7/§9)schema_version← 1inventory_grants→GameState.inventory(not the log)
- 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 theschema_versionmigration seam. - A character-creation UI plan drives
NewGame.constructfrom real player input. - Tuning pass: finalize the Luck die/range and the
luck_descriptorband copy.
Open questions
None blocking. Luck die/range and descriptor band copy are tuning values, seeded as defaults and deferred to a later pass.