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>
This commit is contained in:
2026-07-09 13:46:53 -05:00
parent 272995fb6c
commit 5a1f1ac0ca

View File

@@ -0,0 +1,204 @@
# 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.