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

205 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~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-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.