# Client Canon Log Engine (Plan B) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the client-side GDScript engine that constructs the canon log at new-game (origin + world + character creation) and maintains its invariants turn to turn — a headless, GUT-tested logic layer that binds to the contract Plan A shipped. **Architecture:** RefCounted typed entity classes each own the invariant guarding their own data (fat-but-decomposed model). A `NewGame` builder reads three immutable inputs (origin dict, `ContentDB`, character-creation dict) and writes two products (`CanonLog`, `GameState`); numeric Luck lives only in `GameState`, never the log. A pure `TagExtractor` parses §12 tags. No UI, no HTTP, no save/load. The api remains the authoritative schema gate; the client validates only the invariants it actively maintains. **Tech Stack:** Godot 4.7, GDScript, GUT v9.7.1 (headless test runner). JSON everywhere; the client models the log as typed objects and serialises with `to_dict()`/`from_dict()`. ## Global Constraints - **Engine:** Godot 4.7 (`/home/ptarrant/.local/bin/godot`, `4.7.stable`). GDScript only (charter §16). - **Test framework:** GUT **v9.7.1**, vendored into `client/addons/gut`, run headless via `bash client/run_tests.sh`. - **Model:** RefCounted typed classes with `to_dict()`/`from_dict()`. No `Resource`/`.tres`, no bare dictionaries for log entities (spec decision 3). - **Validation:** invariants enforced **in the model** (correct-by-construction). No client-side JSON-Schema validator — the api is the schema authority (spec decision 4). - **§7 Luck boundary:** numeric Luck, stats, HP/MP, inventory contents **never** appear in the canon log. Only `luck_descriptor` crosses. Enforced by keeping numbers in `GameState` and the log's `player` dict carrying exactly `{name, class_id, luck_descriptor}`. - **`recent_events` capped at 5** (charter §11) — enforced by `CanonLog.push_event`. - **POC classes:** `sellsword` | `assassin` | `priest` only (charter §8). - **All string ids match `^[a-z0-9_]+$`** (via `Ids.is_valid`). - **Test convention:** test files `extends "res://addons/gut/test.gd"` (path form, cache-independent) and `preload` the script-under-test by explicit `res://` path (a missing file is a clean red). Production code cross-references by `class_name`; `run_tests.sh` runs `godot --headless --import` first so those global names resolve. - **Content root:** `ContentDB.default_content_root()` = `res://../content` globalized (dev reads the repo `/content`). Export packaging is a later plan. - **Execution branch:** run this plan on `feature/client-canon-log-engine` branched off `dev` (CLAUDE.md §18). Per-task commits land there; the human merges to `dev`. --- ### Task 1: GUT harness — vendored, headless, smoke-tested **Files:** - Create: `client/addons/gut/` (vendored GUT v9.7.1) - Create: `client/.gutconfig.json` - Create: `client/run_tests.sh` - Test: `client/tests/unit/test_smoke.gd` **Interfaces:** - Produces: `bash client/run_tests.sh` — imports the project then runs every `res://tests/unit/**/*.gd` GUT test; prints a summary containing `Failing: 0` when green. All later tasks invoke this exact command. - [ ] **Step 1: Create the feature branch** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git checkout dev && git checkout -b feature/client-canon-log-engine ``` - [ ] **Step 2: Vendor GUT v9.7.1 into the client** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg tmp=$(mktemp -d) git clone --depth 1 --branch v9.7.1 https://github.com/bitwes/Gut.git "$tmp" mkdir -p client/addons cp -r "$tmp/addons/gut" client/addons/gut rm -rf "$tmp" ls client/addons/gut/gut_cmdln.gd ``` Expected: prints `client/addons/gut/gut_cmdln.gd` (the headless runner is present). - [ ] **Step 3: Create the GUT config** Create `client/.gutconfig.json`: ```json { "dirs": ["res://tests/unit"], "include_subdirs": true, "log_level": 1, "should_exit": true } ``` - [ ] **Step 4: Create the headless test runner** Create `client/run_tests.sh`: ```bash #!/usr/bin/env bash # Headless GUT runner. Imports the project first so global class_name refs # resolve on a fresh checkout / after new class_name scripts are added. # Fallback if --import misbehaves on some builds: godot --headless --editor --quit set -uo pipefail cd "$(dirname "$0")" godot --headless --import >/dev/null 2>&1 || true godot --headless -s res://addons/gut/gut_cmdln.gd -gconfig=res://.gutconfig.json "$@" ``` Make it executable: ```bash chmod +x /home/ptarrant/repos/ptarrant/coc-rpg/client/run_tests.sh ``` - [ ] **Step 5: Write the smoke test** Create `client/tests/unit/test_smoke.gd`: ```gdscript extends "res://addons/gut/test.gd" func test_harness_runs(): assert_eq(1, 1, "GUT harness executes a passing assertion") ``` - [ ] **Step 6: Run the harness** Run: `bash client/run_tests.sh` Expected: output contains `Passing: 1` and `Failing: 0`. This proves GUT runs headless against `tests/unit`. - [ ] **Step 7: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/addons/gut client/.gutconfig.json client/run_tests.sh client/tests/unit/test_smoke.gd git commit -m "chore(client): vendor GUT 9.7.1 + headless test harness" ``` --- ### Task 2: Id helper + leaf canon-log entities **Files:** - Create: `client/scripts/ids.gd` - Create: `client/scripts/canon_log/log_player.gd` - Create: `client/scripts/canon_log/log_location.gd` - Create: `client/scripts/canon_log/party_member.gd` - Create: `client/scripts/canon_log/quest.gd` - Create: `client/scripts/canon_log/humiliation.gd` - Test: `client/tests/unit/test_entities.gd` **Interfaces:** - Produces: - `Ids.is_valid(id: String) -> bool` - `LogPlayer.new(name := "", class_id := "", luck_descriptor := "")`; `set_class_id(v) -> bool` (rejects non-POC classes); `to_dict() -> Dictionary` (exactly `{name, class_id, luck_descriptor}`); `LogPlayer.from_dict(d) -> LogPlayer` - `LogLocation.new(id := "", name := "")`; `to_dict()`; `LogLocation.from_dict(d) -> LogLocation` - `PartyMember.new(id := "", name := "", disposition := 0)`; `set_disposition(v)` (clamps −100..100); `to_dict()`; `PartyMember.from_dict(d) -> PartyMember` - `Quest.new(id := "", name := "", status := "active", objective := "")`; `set_status(v) -> bool` (enum); `to_dict()`; `Quest.from_dict(d) -> Quest` - `Humiliation.new(id := "", text := "", weight := 1, turn := 0)` (weight clamped 1..10, turn ≥ 0); `to_dict()`; `Humiliation.from_dict(d) -> Humiliation` - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_entities.gd`: ```gdscript extends "res://addons/gut/test.gd" const Ids = preload("res://scripts/ids.gd") const LogPlayer = preload("res://scripts/canon_log/log_player.gd") const LogLocation = preload("res://scripts/canon_log/log_location.gd") const PartyMember = preload("res://scripts/canon_log/party_member.gd") const Quest = preload("res://scripts/canon_log/quest.gd") const Humiliation = preload("res://scripts/canon_log/humiliation.gd") func test_ids_regex(): assert_true(Ids.is_valid("greywater_docks")) assert_false(Ids.is_valid("Bad Id")) assert_false(Ids.is_valid("has-hyphen")) func test_player_dict_has_only_three_keys(): var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") var d = p.to_dict() assert_eq(d.keys().size(), 3) assert_false("luck" in d) assert_eq(d["class_id"], "sellsword") func test_player_rejects_unknown_class(): var p = LogPlayer.new("Aldric", "sellsword", "x") assert_false(p.set_class_id("bard")) assert_eq(p.class_id, "sellsword") func test_party_member_clamps_disposition(): var m = PartyMember.new("brannoc_thane", "Brannoc Thane", 200) assert_eq(m.disposition, 100) m.set_disposition(-200) assert_eq(m.disposition, -100) func test_quest_status_enum(): var q = Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find it") assert_false(q.set_status("abandoned")) assert_eq(q.status, "active") assert_true(q.set_status("complete")) func test_humiliation_clamps_weight_and_turn(): var h = Humiliation.new("h_1", "vomited on a shrine", 11, -3) assert_eq(h.weight, 10) assert_eq(h.turn, 0) func test_round_trip_each_entity(): var loc = LogLocation.new("greywater_docks", "the Greywater docks") assert_eq(LogLocation.from_dict(loc.to_dict()).to_dict(), loc.to_dict()) var m = PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15) assert_eq(PartyMember.from_dict(m.to_dict()).to_dict(), m.to_dict()) ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — the test script fails to load (`Could not load ... res://scripts/ids.gd`) or reports errors; `Failing`/errored count is non-zero. - [ ] **Step 3: Write the Id helper** Create `client/scripts/ids.gd`: ```gdscript class_name Ids extends RefCounted ## Shared id validation. Every canon-log / content id matches ^[a-z0-9_]+$. static var _re: RegEx = RegEx.create_from_string("^[a-z0-9_]+$") static func is_valid(id: String) -> bool: return _re.search(id) != null ``` - [ ] **Step 4: Write `LogPlayer`** Create `client/scripts/canon_log/log_player.gd`: ```gdscript class_name LogPlayer extends RefCounted ## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7): ## no numeric Luck, no stats — those live in GameState and never reach the AI. const CLASSES := ["sellsword", "assassin", "priest"] var name: String var class_id: String var luck_descriptor: String func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void: name = p_name luck_descriptor = p_luck_descriptor if p_class_id != "": set_class_id(p_class_id) func set_class_id(v: String) -> bool: if v not in CLASSES: push_error("invalid class_id: %s" % v) return false class_id = v return true func to_dict() -> Dictionary: return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor} static func from_dict(d: Dictionary) -> LogPlayer: return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", "")) ``` - [ ] **Step 5: Write `LogLocation`** Create `client/scripts/canon_log/log_location.gd`: ```gdscript class_name LogLocation extends RefCounted ## A canon-log location row: id (code key) + name (prose). var id: String var name: String func _init(p_id := "", p_name := "") -> void: if p_id != "" and not Ids.is_valid(p_id): push_error("invalid location id: %s" % p_id) id = p_id name = p_name func to_dict() -> Dictionary: return {"id": id, "name": name} static func from_dict(d: Dictionary) -> LogLocation: return LogLocation.new(d.get("id", ""), d.get("name", "")) ``` - [ ] **Step 6: Write `PartyMember`** Create `client/scripts/canon_log/party_member.gd`: ```gdscript class_name PartyMember extends RefCounted ## A companion row. Owns the disposition clamp (−100..100, charter §9). var id: String var name: String var disposition: int func _init(p_id := "", p_name := "", p_disposition := 0) -> void: id = p_id name = p_name set_disposition(p_disposition) func set_disposition(v: int) -> void: disposition = clampi(v, -100, 100) func to_dict() -> Dictionary: return {"id": id, "name": name, "disposition": disposition} static func from_dict(d: Dictionary) -> PartyMember: return PartyMember.new(d.get("id", ""), d.get("name", ""), int(d.get("disposition", 0))) ``` - [ ] **Step 7: Write `Quest`** Create `client/scripts/canon_log/quest.gd`: ```gdscript class_name Quest extends RefCounted ## An active-quest row. Owns the status enum {active, complete, failed}. const STATUSES := ["active", "complete", "failed"] var id: String var name: String var status: String var objective: String func _init(p_id := "", p_name := "", p_status := "active", p_objective := "") -> void: id = p_id name = p_name objective = p_objective set_status(p_status) func set_status(v: String) -> bool: if v not in STATUSES: push_error("invalid quest status: %s" % v) return false status = v return true func to_dict() -> Dictionary: return {"id": id, "name": name, "status": status, "objective": objective} static func from_dict(d: Dictionary) -> Quest: return Quest.new(d.get("id", ""), d.get("name", ""), d.get("status", "active"), d.get("objective", "")) ``` - [ ] **Step 8: Write `Humiliation`** Create `client/scripts/canon_log/humiliation.gd`: ```gdscript class_name Humiliation extends RefCounted ## A Banter-memory row (charter §9). weight 1..10 drives reference frequency; ## turn drives decay. Append-only at the container level — these stack. var id: String var text: String var weight: int var turn: int func _init(p_id := "", p_text := "", p_weight := 1, p_turn := 0) -> void: id = p_id text = p_text weight = clampi(p_weight, 1, 10) turn = maxi(p_turn, 0) func to_dict() -> Dictionary: return {"id": id, "text": text, "weight": weight, "turn": turn} static func from_dict(d: Dictionary) -> Humiliation: return Humiliation.new(d.get("id", ""), d.get("text", ""), int(d.get("weight", 1)), int(d.get("turn", 0))) ``` - [ ] **Step 9: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — output contains `Failing: 0`; the 7 `test_entities` tests all pass. - [ ] **Step 10: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/ids.gd client/scripts/canon_log client/tests/unit/test_entities.gd git commit -m "feat(client): id helper + leaf canon-log entities with owned invariants" ``` --- ### Task 3: `CanonLog` container + maintenance mutators **Files:** - Create: `client/scripts/canon_log/canon_log.gd` - Test: `client/tests/unit/test_canon_log.gd` **Interfaces:** - Consumes: `LogPlayer`, `LogLocation`, `PartyMember`, `Quest`, `Humiliation` (Task 2). - Produces: - `CanonLog.new()` — starts with empty `LogPlayer`/`LogLocation`, empty arrays, `schema_version = 1`. - Mutators: `push_event(line)` (cap 5, drop oldest); `add_fact(text)` (dedup); `add_humiliation(id, text, weight, turn)` (append-only); `set_location(id, name)`; `party_member(id) -> PartyMember`; `adjust_disposition(id, delta) -> bool`; `set_quest_status(id, status) -> bool`. - `to_dict() -> Dictionary`; `CanonLog.from_dict(d) -> CanonLog` (returns `null` on unsupported `schema_version`). - Typed fields: `party: Array[PartyMember]`, `active_quests: Array[Quest]`, `humiliations: Array[Humiliation]`, `recent_events: Array[String]`, `established_facts: Array[String]`. - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_canon_log.gd`: ```gdscript extends "res://addons/gut/test.gd" const CanonLog = preload("res://scripts/canon_log/canon_log.gd") const PartyMember = preload("res://scripts/canon_log/party_member.gd") const Quest = preload("res://scripts/canon_log/quest.gd") func test_push_event_caps_at_five(): var log = CanonLog.new() for i in 7: log.push_event("event %d" % i) assert_eq(log.recent_events.size(), 5) assert_eq(log.recent_events[0], "event 2") assert_eq(log.recent_events[4], "event 6") func test_add_fact_dedups(): var log = CanonLog.new() log.add_fact("the bridge is out") log.add_fact("the bridge is out") assert_eq(log.established_facts.size(), 1) func test_humiliations_stack(): var log = CanonLog.new() log.add_humiliation("h_1", "vomited on a shrine", 7, 3) log.add_humiliation("h_2", "vomited on a shrine", 7, 5) assert_eq(log.humiliations.size(), 2) func test_adjust_disposition_clamps_and_reports_missing(): var log = CanonLog.new() log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 98)) assert_true(log.adjust_disposition("brannoc_thane", 50)) assert_eq(log.party_member("brannoc_thane").disposition, 100) assert_false(log.adjust_disposition("nobody", 5)) func test_set_quest_status_rejects_bad_enum(): var log = CanonLog.new() log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find it")) assert_false(log.set_quest_status("find_the_ledger", "abandoned")) assert_true(log.set_quest_status("find_the_ledger", "complete")) assert_false(log.set_quest_status("no_quest", "active")) func test_unsupported_schema_version_returns_null(): assert_null(CanonLog.from_dict({"schema_version": 2})) ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/canon_log/canon_log.gd`; non-zero failing/errored. - [ ] **Step 3: Write `CanonLog`** Create `client/scripts/canon_log/canon_log.gd`: ```gdscript class_name CanonLog extends RefCounted ## The §11 canon log: compact structured state code maintains and injects into ## every AI call. This container owns the container-level invariants; leaf rows ## own theirs. AI prose never writes here directly — mutators are called by code. const SCHEMA_VERSION := 1 const MAX_RECENT_EVENTS := 5 var schema_version: int = SCHEMA_VERSION var player: LogPlayer var location: LogLocation var party: Array[PartyMember] = [] var recent_events: Array[String] = [] var established_facts: Array[String] = [] var active_quests: Array[Quest] = [] var humiliations: Array[Humiliation] = [] func _init() -> void: player = LogPlayer.new() location = LogLocation.new() # ── maintenance mutators (charter §11 maintenance table) ───────────────────── func push_event(line: String) -> void: recent_events.append(line) while recent_events.size() > MAX_RECENT_EVENTS: recent_events.pop_front() func add_fact(text: String) -> void: if text not in established_facts: established_facts.append(text) func add_humiliation(id: String, text: String, weight: int, turn: int) -> void: humiliations.append(Humiliation.new(id, text, weight, turn)) func set_location(id: String, name: String) -> void: location = LogLocation.new(id, name) func party_member(id: String) -> PartyMember: for m in party: if m.id == id: return m return null func adjust_disposition(id: String, delta: int) -> bool: var m := party_member(id) if m == null: return false m.set_disposition(m.disposition + delta) return true func set_quest_status(id: String, status: String) -> bool: for q in active_quests: if q.id == id: return q.set_status(status) return false # ── serialisation ──────────────────────────────────────────────────────────── func to_dict() -> Dictionary: return { "schema_version": schema_version, "player": player.to_dict(), "location": location.to_dict(), "party": party.map(func(m): return m.to_dict()), "recent_events": recent_events.duplicate(), "established_facts": established_facts.duplicate(), "active_quests": active_quests.map(func(q): return q.to_dict()), "humiliations": humiliations.map(func(h): return h.to_dict()), } static func from_dict(d: Dictionary) -> CanonLog: var v := int(d.get("schema_version", -1)) if v != SCHEMA_VERSION: push_error("unsupported canon_log schema_version: %s" % v) return null var log := CanonLog.new() log.player = LogPlayer.from_dict(d.get("player", {})) log.location = LogLocation.from_dict(d.get("location", {})) for m in d.get("party", []): log.party.append(PartyMember.from_dict(m)) for e in d.get("recent_events", []): log.push_event(e) for f in d.get("established_facts", []): log.add_fact(f) for q in d.get("active_quests", []): log.active_quests.append(Quest.from_dict(q)) for h in d.get("humiliations", []): log.humiliations.append(Humiliation.from_dict(h)) return log ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — `Failing: 0`; the 6 `test_canon_log` tests pass. (The `push_error` on the unsupported-version test prints a red line but does not fail the test — GUT does not fail on `push_error` by default.) - [ ] **Step 5: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/canon_log/canon_log.gd client/tests/unit/test_canon_log.gd git commit -m "feat(client): CanonLog container + maintenance mutators (cap 5, dedup, stack)" ``` --- ### Task 4: `Luck` + `GameState` (the §7 numeric store) **Files:** - Create: `client/scripts/state/luck.gd` - Create: `client/scripts/state/game_state.gd` - Test: `client/tests/unit/test_luck.gd`, `client/tests/unit/test_game_state.gd` **Interfaces:** - Produces: - `Luck.MIN`(1), `Luck.MAX`(20), `Luck.DRIFT`(3); `Luck.roll_base(rng: RandomNumberGenerator, modifier: int) -> int`; `Luck.drift(current: int, base: int, delta: int) -> int`; `Luck.descriptor(luck: int) -> String`. - `GameState.new()` with fields `luck: int`, `luck_base: int`, `stats: Dictionary`, `npc_dispositions: Dictionary`, `inventory: Dictionary`; methods `set_luck(v)`, `drift_luck(delta)`, `luck_descriptor() -> String`, `set_npc_disposition(id, v)` (clamp −100..100), `add_item(item_id, qty)`. - [ ] **Step 1: Write the failing Luck test** Create `client/tests/unit/test_luck.gd`: ```gdscript extends "res://addons/gut/test.gd" const Luck = preload("res://scripts/state/luck.gd") func _rng(s: int) -> RandomNumberGenerator: var r := RandomNumberGenerator.new() r.seed = s return r func test_roll_base_is_deterministic_for_a_seed(): assert_eq(Luck.roll_base(_rng(42), 0), Luck.roll_base(_rng(42), 0)) func test_roll_base_stays_in_range(): for s in range(1, 30): var v := Luck.roll_base(_rng(s), 0) assert_true(v >= Luck.MIN and v <= Luck.MAX, "luck %d out of range" % v) func test_modifier_shifts_result(): # +100 modifier clamps to MAX regardless of rolls. assert_eq(Luck.roll_base(_rng(5), 100), Luck.MAX) func test_drift_stays_within_base_band_and_range(): assert_eq(Luck.drift(10, 10, 100), 13) # capped at base+3 assert_eq(Luck.drift(10, 10, -100), 7) # capped at base-3 assert_eq(Luck.drift(20, 19, 100), 20) # base+3=22 -> clamped to MAX func test_descriptor_bands(): assert_eq(Luck.descriptor(4), "Fortune spits on you") assert_eq(Luck.descriptor(5), "The world is not on your side") assert_eq(Luck.descriptor(20), "The dice are kind today") ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/state/luck.gd`. - [ ] **Step 3: Write `Luck`** Create `client/scripts/state/luck.gd`: ```gdscript class_name Luck extends RefCounted ## Numeric Luck (charter §7). Lives in GameState; only descriptor() reaches the ## log. Deterministic given a seeded RNG (charter §10). Die/range/bands are ## TUNABLE constants — not final values. const MIN := 1 const MAX := 20 const DRIFT := 3 const BANDS := [ {"max": 4, "text": "Fortune spits on you"}, {"max": 8, "text": "The world is not on your side"}, {"max": 12, "text": "The dice are indifferent"}, {"max": 16, "text": "Luck rides with you"}, {"max": 20, "text": "The dice are kind today"}, ] static func roll_base(rng: RandomNumberGenerator, modifier: int) -> int: var total := 0 for i in 5: total += rng.randi_range(1, 20) return clampi(roundi(total / 5.0) + modifier, MIN, MAX) static func drift(current: int, base: int, delta: int) -> int: return clampi(clampi(current + delta, base - DRIFT, base + DRIFT), MIN, MAX) static func descriptor(luck: int) -> String: for band in BANDS: if luck <= band["max"]: return band["text"] return BANDS[-1]["text"] ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — the 5 `test_luck` tests pass, `Failing: 0`. - [ ] **Step 5: Write the failing GameState test** Create `client/tests/unit/test_game_state.gd`: ```gdscript extends "res://addons/gut/test.gd" const GameState = preload("res://scripts/state/game_state.gd") func test_add_item_accumulates(): var s = GameState.new() s.add_item("coin", 3) s.add_item("coin", 2) assert_eq(s.inventory["coin"], 5) func test_npc_disposition_clamps(): var s = GameState.new() s.set_npc_disposition("oda_fenn", 250) assert_eq(s.npc_dispositions["oda_fenn"], 100) func test_drift_luck_respects_base(): var s = GameState.new() s.luck_base = 10 s.luck = 10 s.drift_luck(100) assert_eq(s.luck, 13) func test_luck_descriptor_delegates(): var s = GameState.new() s.luck = 4 assert_eq(s.luck_descriptor(), "Fortune spits on you") ``` - [ ] **Step 6: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/state/game_state.gd`. - [ ] **Step 7: Write `GameState`** Create `client/scripts/state/game_state.gd`: ```gdscript class_name GameState extends RefCounted ## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck, ## stats, world-NPC dispositions, and inventory — none of which enter the canon ## log (charter §7/§2). The log reads only luck_descriptor() from here. var luck: int = 0 var luck_base: int = 0 var stats: Dictionary = {} # {str, dex, con, fth, mag} var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100) var inventory: Dictionary = {} # item_id -> qty func set_luck(v: int) -> void: luck = clampi(v, Luck.MIN, Luck.MAX) func drift_luck(delta: int) -> void: luck = Luck.drift(luck, luck_base, delta) func luck_descriptor() -> String: return Luck.descriptor(luck) func set_npc_disposition(id: String, v: int) -> void: npc_dispositions[id] = clampi(v, -100, 100) func add_item(item_id: String, qty: int) -> void: inventory[item_id] = int(inventory.get(item_id, 0)) + qty ``` - [ ] **Step 8: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — the 4 `test_game_state` tests pass, `Failing: 0`. - [ ] **Step 9: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/state client/tests/unit/test_luck.gd client/tests/unit/test_game_state.gd git commit -m "feat(client): Luck (deterministic gen/drift/descriptor) + GameState store" ``` --- ### Task 5: `ContentDB` — load `/content`, resolve origin refs **Files:** - Create: `client/scripts/content/content_db.gd` - Test: `client/tests/unit/test_content_db.gd` **Interfaces:** - Consumes: the authored fixtures under `/content` (Plan A). - Produces: - `ContentDB.default_content_root() -> String` (globalized `res://../content`) - `ContentDB.origin_path(id: String) -> String` - `ContentDB.load_json(abs_path: String) -> Variant` - `ContentDB.new()`; `load_from(content_root: String) -> void` - Lookups returning the raw content dict (or `{}`): `location(id)`, `npc(id)`, `quest(id)`, `item(id)`; predicates `has_location/has_npc/has_quest/has_item(id) -> bool` - `companions() -> Array` (NPC dicts with `role == "companion"`) - `unresolved_refs(origin: Dictionary) -> Array` (e.g. `["location:nowhere", "item:ghost_blade"]`; empty = clean) — the GDScript twin of `api/app/content.py::unresolved_refs`. - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_content_db.gd`: ```gdscript extends "res://addons/gut/test.gd" const ContentDB = preload("res://scripts/content/content_db.gd") var db func before_each(): db = ContentDB.new() db.load_from(ContentDB.default_content_root()) func _deserter() -> Dictionary: return ContentDB.load_json(ContentDB.origin_path("deserter")) func test_loads_world_ids(): assert_true(db.has_location("greywater_docks")) assert_true(db.has_quest("find_the_ledger")) assert_true(db.has_item("worn_shortsword")) assert_true(db.has_item("coin")) func test_companions_are_brannoc_and_cadwyn(): var ids := [] for n in db.companions(): ids.append(n["id"]) ids.sort() assert_eq(ids, ["brannoc_thane", "cadwyn_vell"]) func test_good_origin_has_no_unresolved_refs(): assert_eq(db.unresolved_refs(_deserter()), []) func test_broken_location_ref_detected(): var o := _deserter() o["start_location_id"] = "nowhere" assert_true("location:nowhere" in db.unresolved_refs(o)) func test_broken_item_ref_detected(): var o := _deserter() o["inventory_grants"] = o["inventory_grants"].duplicate(true) o["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1}) assert_true("item:ghost_blade" in db.unresolved_refs(o)) func test_null_quest_ref_resolves(): var o := _deserter() o["start_quest_id"] = null assert_eq(db.unresolved_refs(o), []) ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/content/content_db.gd`. - [ ] **Step 3: Write `ContentDB`** Create `client/scripts/content/content_db.gd`: ```gdscript class_name ContentDB extends RefCounted ## Loads authored world content (/content) and cross-checks origin references. ## Mirrors api/app/content.py so client and api resolve ids identically. This is ## content integrity, distinct from JSON-schema validation. var locations: Dictionary = {} var npcs: Dictionary = {} var quests: Dictionary = {} var items: Dictionary = {} static func default_content_root() -> String: return ProjectSettings.globalize_path("res://").path_join("../content").simplify_path() static func origin_path(id: String) -> String: return default_content_root().path_join("origins").path_join("%s.json" % id) static func load_json(abs_path: String) -> Variant: var text := FileAccess.get_file_as_string(abs_path) return JSON.parse_string(text) func load_from(content_root: String) -> void: var world := content_root.path_join("world") locations = _load_dir(world.path_join("locations")) npcs = _load_dir(world.path_join("npcs")) quests = _load_dir(world.path_join("quests")) items = _load_dir(world.path_join("items")) func _load_dir(dir_path: String) -> Dictionary: var out: Dictionary = {} var dir := DirAccess.open(dir_path) if dir == null: push_error("content dir not found: %s" % dir_path) return out for file in dir.get_files(): if not file.ends_with(".json"): continue var data: Variant = load_json(dir_path.path_join(file)) if typeof(data) != TYPE_DICTIONARY or not data.has("id"): push_error("bad content file: %s" % file) continue out[data["id"]] = data return out func location(id: String) -> Dictionary: return locations.get(id, {}) func npc(id: String) -> Dictionary: return npcs.get(id, {}) func quest(id: String) -> Dictionary: return quests.get(id, {}) func item(id: String) -> Dictionary: return items.get(id, {}) func has_location(id: String) -> bool: return locations.has(id) func has_npc(id: String) -> bool: return npcs.has(id) func has_quest(id: String) -> bool: return quests.has(id) func has_item(id: String) -> bool: return items.has(id) func companions() -> Array: var out: Array = [] for id in npcs: if npcs[id].get("role", "") == "companion": out.append(npcs[id]) return out func unresolved_refs(origin: Dictionary) -> Array: var missing: Array = [] var loc: String = origin.get("start_location_id", "") if not has_location(loc): missing.append("location:%s" % loc) var q: Variant = origin.get("start_quest_id", null) if q != null and not has_quest(q): missing.append("quest:%s" % q) for grant in origin.get("inventory_grants", []): if not has_item(grant.get("item_id", "")): missing.append("item:%s" % grant.get("item_id", "")) for npc_id in origin.get("disposition_overrides", {}): if not has_npc(npc_id): missing.append("npc:%s" % npc_id) return missing ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — the 6 `test_content_db` tests pass, `Failing: 0`. (Proves the globalized `res://../content` path reaches the real repo fixtures headless.) - [ ] **Step 5: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/content/content_db.gd client/tests/unit/test_content_db.gd git commit -m "feat(client): ContentDB loads /content + mirrors the api id-resolution check" ``` --- ### Task 6: `NewGame.construct` — origin + world + creation → {log, state} **Files:** - Create: `client/scripts/newgame/new_game.gd` - Test: `client/tests/unit/test_new_game.gd` **Interfaces:** - Consumes: `CanonLog`, `LogPlayer`, `PartyMember`, `Quest` (Tasks 2–3); `Luck`, `GameState` (Task 4); `ContentDB` (Task 5). - Produces: `NewGame.construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary`, returning `{ ok: bool, errors: Array, log: CanonLog, state: GameState }`. `creation` = `{name, class_id}`. On any content-integrity or build-constraint failure, `ok` is `false`, `errors` is non-empty, and `log`/`state` are `null`. - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_new_game.gd`: ```gdscript extends "res://addons/gut/test.gd" const NewGame = preload("res://scripts/newgame/new_game.gd") const ContentDB = preload("res://scripts/content/content_db.gd") var world func before_each(): world = ContentDB.new() world.load_from(ContentDB.default_content_root()) func _deserter() -> Dictionary: return ContentDB.load_json(ContentDB.origin_path("deserter")) func _rng() -> RandomNumberGenerator: var r := RandomNumberGenerator.new() r.seed = 7 return r func _build(creation: Dictionary) -> Dictionary: return NewGame.construct(_deserter(), world, creation, _rng()) func test_construct_succeeds(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) assert_true(res["ok"], str(res["errors"])) func test_numeric_luck_absent_from_log(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) var pd: Dictionary = res["log"].player.to_dict() assert_false("luck" in pd) assert_eq(pd.keys().size(), 3) assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX) func test_inventory_and_dispo_land_in_state_not_log(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) assert_eq(res["state"].inventory.get("coin", 0), 3) assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) assert_false("inventory" in res["log"].to_dict()) func test_companion_dispositions_from_overrides(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) assert_eq(res["log"].party_member("brannoc_thane").disposition, 40) assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15) func test_situation_and_facts_seeded(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) assert_eq(res["log"].recent_events.size(), 2) assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts) assert_eq(res["log"].humiliations.size(), 0) func test_active_quest_resolved_from_world(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) assert_eq(res["log"].active_quests.size(), 1) assert_eq(res["log"].active_quests[0].id, "find_the_ledger") assert_eq(res["log"].active_quests[0].status, "active") func test_null_start_quest_yields_no_quest(): var o := _deserter() o["start_quest_id"] = null var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng()) assert_eq(res["log"].active_quests.size(), 0) func test_class_not_allowed_is_rejected(): var res := _build({"name": "X", "class_id": "berserker"}) assert_false(res["ok"]) assert_true(res["errors"].size() > 0) assert_null(res["log"]) func test_unresolved_origin_is_rejected(): var o := _deserter() o["start_location_id"] = "nowhere" var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng()) assert_false(res["ok"]) assert_true("unresolved ref: location:nowhere" in res["errors"]) ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/newgame/new_game.gd`. - [ ] **Step 3: Write `NewGame`** Create `client/scripts/newgame/new_game.gd`: ```gdscript class_name NewGame extends RefCounted ## New-game construction (spec "New-game construction"). Reads three immutable ## inputs (origin, world, creation) and writes two products (CanonLog, GameState). ## Nothing flows back up — charter §2 on the client. Validation is front-loaded: ## a broken origin or illegal class fails here, loudly, not three scenes later. static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary: var errors: Array = [] # 1. content integrity — every referenced id must resolve in world content. for ref in world.unresolved_refs(origin): errors.append("unresolved ref: %s" % ref) # 2. build constraints — chosen class must be allowed by the origin. var bc: Dictionary = origin.get("build_constraints", {}) var allowed: Array = bc.get("allowed_classes", []) var class_id: String = creation.get("class_id", "") if class_id not in allowed: errors.append("class not allowed by origin: %s" % class_id) if not errors.is_empty(): return {"ok": false, "errors": errors, "log": null, "state": null} # 3. roll Luck (numeric -> state only) and 4. the stat block (data only). var state := GameState.new() state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0))) state.luck = state.luck_base state.stats = { "str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng), "fth": _roll_stat(rng), "mag": _roll_stat(rng), } # 5. assemble the canon log. var log := CanonLog.new() log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor()) var loc: Dictionary = world.location(origin["start_location_id"]) log.set_location(loc.get("id", ""), loc.get("name", "")) var overrides: Dictionary = origin.get("disposition_overrides", {}) var companion_ids := {} for c in world.companions(): companion_ids[c["id"]] = true log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0)))) # non-companion overrides go to game state, never the log (spec §5 / charter §6). for npc_id in overrides: if not companion_ids.has(npc_id): state.set_npc_disposition(npc_id, int(overrides[npc_id])) for line in origin.get("situation", []): log.push_event(line) for fact in origin.get("opening_facts", []): log.add_fact(fact) var quest_id: Variant = origin.get("start_quest_id", null) if quest_id != null: var qd: Dictionary = world.quest(quest_id) log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", ""))) # inventory grants -> game state (NOT the log). Charter §2. for grant in origin.get("inventory_grants", []): state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0))) return {"ok": true, "errors": [], "log": log, "state": state} static func _roll_stat(rng: RandomNumberGenerator) -> int: # 3d6 placeholder; the stat block is data-only in this plan. TUNABLE. return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6) ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — the 9 `test_new_game` tests pass, `Failing: 0`. (End-to-end construction from the real `deserter.json` produces a correct log + state.) - [ ] **Step 5: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/newgame/new_game.gd client/tests/unit/test_new_game.gd git commit -m "feat(client): NewGame.construct — origin+world+creation -> {canon log, state}" ``` --- ### Task 7: `TagExtractor` — pure §12 tag parsing **Files:** - Create: `client/scripts/text/tag_extractor.gd` - Test: `client/tests/unit/test_tag_extractor.gd` **Interfaces:** - Produces: `TagExtractor.extract(prose: String) -> Dictionary` returning `{ clean_text: String, facts: Array, dispositions: Array, moves: Array }`, where each `moves` entry is `{name: String, args: Array}`. Extracts and strips only — no state mutation, no move validation (deferred to the HTTP plan). - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_tag_extractor.gd`: ```gdscript extends "res://addons/gut/test.gd" const TagExtractor = preload("res://scripts/text/tag_extractor.gd") func test_fact_extracted_and_stripped(): var r := TagExtractor.extract("A muddy road. [FACT: the eastern bridge is out]") assert_eq(r["facts"], ["the eastern bridge is out"]) assert_eq(r["clean_text"], "A muddy road.") func test_positive_and_negative_disposition(): assert_eq(TagExtractor.extract("\"Aye.\" [ADJUST_DISPOSITION: +5]")["dispositions"], [5]) assert_eq(TagExtractor.extract("[ADJUST_DISPOSITION: -10]")["dispositions"], [-10]) func test_move_parsed_with_args(): var r := TagExtractor.extract("[MOVE: offer_quest(14)]") assert_eq(r["moves"][0]["name"], "offer_quest") assert_eq(r["moves"][0]["args"], ["14"]) func test_move_with_no_args(): var r := TagExtractor.extract("[MOVE: refuse()]") assert_eq(r["moves"][0]["name"], "refuse") assert_eq(r["moves"][0]["args"], []) func test_multiple_facts_collected(): var r := TagExtractor.extract("Hi [FACT: a] mid [FACT: b] end") assert_eq(r["facts"], ["a", "b"]) assert_eq(r["clean_text"], "Hi mid end") func test_no_tags_is_clean_passthrough(): var r := TagExtractor.extract("no tags here") assert_eq(r["facts"], []) assert_eq(r["dispositions"], []) assert_eq(r["moves"], []) assert_eq(r["clean_text"], "no tags here") ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash client/run_tests.sh` Expected: FAIL — `Could not load ... res://scripts/text/tag_extractor.gd`. - [ ] **Step 3: Write `TagExtractor`** Create `client/scripts/text/tag_extractor.gd`: ```gdscript class_name TagExtractor extends RefCounted ## Pure §12 tag parsing. Extracts [FACT:], [ADJUST_DISPOSITION:], [MOVE:] tags ## and returns the prose with all tags stripped. It never applies anything and ## never validates a move (that needs NPC context from /npc/speak — a later ## plan). Its outputs are exactly the inputs CanonLog's mutators consume. static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]") static var _dispo_re: RegEx = RegEx.create_from_string("\\[ADJUST_DISPOSITION:\\s*([+-]?\\d+)\\]") static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)\\(([^)]*)\\)\\]") static var _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]") static func extract(prose: String) -> Dictionary: var facts: Array = [] for m in _fact_re.search_all(prose): facts.append(m.get_string(1).strip_edges()) var dispositions: Array = [] for m in _dispo_re.search_all(prose): dispositions.append(int(m.get_string(1))) var moves: Array = [] for m in _move_re.search_all(prose): var raw_args := m.get_string(2).strip_edges() var args: Array = [] if raw_args != "": for a in raw_args.split(","): args.append(a.strip_edges()) moves.append({"name": m.get_string(1), "args": args}) var clean := _any_re.sub(prose, "", true) # Collapse whitespace left where inline tags were removed. while clean.contains(" "): clean = clean.replace(" ", " ") clean = clean.strip_edges() return {"clean_text": clean, "facts": facts, "dispositions": dispositions, "moves": moves} ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash client/run_tests.sh` Expected: PASS — the 6 `test_tag_extractor` tests pass, `Failing: 0`. - [ ] **Step 5: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/scripts/text/tag_extractor.gd client/tests/unit/test_tag_extractor.gd git commit -m "feat(client): TagExtractor — pure regex parse/strip of §12 tags" ``` --- ### Task 8: Round-trip + integration — the bridge to the api contract **Files:** - Test: `client/tests/unit/test_round_trip.gd` **Interfaces:** - Consumes: `NewGame` (Task 6), `ContentDB` (Task 5), `CanonLog` (Task 3). - Produces: no new production code — this task proves the client's product survives `to_dict → from_dict` unchanged and satisfies every contract invariant the api enforces (spec decision 4: parity without duplicating the schema). - [ ] **Step 1: Write the integration test** Create `client/tests/unit/test_round_trip.gd`: ```gdscript extends "res://addons/gut/test.gd" const NewGame = preload("res://scripts/newgame/new_game.gd") const ContentDB = preload("res://scripts/content/content_db.gd") const CanonLog = preload("res://scripts/canon_log/canon_log.gd") func _built_log(): var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) var rng := RandomNumberGenerator.new() rng.seed = 3 var origin := ContentDB.load_json(ContentDB.origin_path("deserter")) var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng) assert_true(res["ok"], str(res["errors"])) return res["log"] func test_to_from_dict_is_identity(): var log = _built_log() var d1: Dictionary = log.to_dict() var log2 = CanonLog.from_dict(d1) assert_not_null(log2) assert_eq(log2.to_dict(), d1) func test_constructed_log_satisfies_contract_invariants(): var d: Dictionary = _built_log().to_dict() assert_eq(d["schema_version"], 1) assert_true(d["recent_events"].size() <= 5) assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor assert_false("luck" in d["player"]) # §7: no numeric luck assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"]) for m in d["party"]: assert_true(m["disposition"] >= -100 and m["disposition"] <= 100) for q in d["active_quests"]: assert_true(q["status"] in ["active", "complete", "failed"]) for h in d["humiliations"]: assert_true(h["weight"] >= 1 and h["weight"] <= 10) ``` - [ ] **Step 2: Run the full suite** Run: `bash client/run_tests.sh` Expected: PASS — every test from Tasks 1–8 green, `Failing: 0`. The `test_round_trip` pair proves the client's canon log conforms to the same contract the api validates, with no schema re-implementation. - [ ] **Step 3: Commit** ```bash cd /home/ptarrant/repos/ptarrant/coc-rpg git add client/tests/unit/test_round_trip.gd git commit -m "test(client): round-trip + contract-invariant integration on a constructed log" ``` - [ ] **Step 4: Hand off for merge** The feature branch `feature/client-canon-log-engine` is complete and green. Per CLAUDE.md §18, the human smoke-tests and merges `--no-ff` into `dev`, then deletes the branch. Claude does not merge or push here. --- ## Self-Review **Spec coverage:** - Decision 1 (GUT, headless): Task 1 vendors GUT v9.7.1 + `run_tests.sh`. ✔ - Decision 2 (logic layer only): no UI/HTTP/save-load task exists; noted as out-of-scope follow-ups. ✔ - Decision 3 (RefCounted typed classes, `to_dict`/`from_dict`): Tasks 2–3. ✔ - Decision 4 (invariants in the model; no client schema validator; api is authority): each entity owns its invariant (Task 2), container owns cap/dedup/stack (Task 3); Task 8 proves parity via invariant checks, not a schema port. ✔ - Decision 5 (Luck-centric game state; stat block data-only): Task 4 (`GameState`, `Luck`) + Task 6 rolls stats into state. ✔ - Decision 6 (mutators + tag extractor; no move validation): mutators Task 3, `TagExtractor` Task 7, move validation explicitly excluded. ✔ - Decision 7 (fat-but-decomposed): leaf entities + container + separate `NewGame`/`ContentDB`/`TagExtractor`. ✔ - §7 boundary (numeric luck never in log): enforced by `LogPlayer.to_dict` shape + `GameState` ownership; asserted in Tasks 2, 6, 8. ✔ - `recent_events` cap 5: `CanonLog.push_event` (Task 3), asserted Task 3 + 8. ✔ - Content resolution mirrors `api/app/content.py`: `ContentDB.unresolved_refs` (Task 5), same `"kind:id"` string format. ✔ - New-game construction ordering (resolve → constraints → roll → assemble): Task 6 mirrors the spec routine, incl. companion vs non-companion override split, situation→push_event trimming, inventory→state. ✔ - Deterministic Luck via injected RNG (§10): Task 4 tests, seeded RNG throughout Task 6/8. ✔ **Placeholder scan:** No TBD/TODO. The two `TUNABLE` markers (Luck bands/die, `_roll_stat`) are deliberate, flagged tuning constants with real working defaults — not unfinished code. ✔ **Type consistency:** `construct` returns `{ok, errors, log, state}` — consumed with those keys in Tasks 6 and 8. `unresolved_refs` returns `"location:…"`/`"item:…"` strings — asserted with those exact prefixes (Tasks 5, 6). `load_from`/`default_content_root`/`origin_path`/`load_json` names match across Tasks 5, 6, 8. `Luck.MIN/MAX/DRIFT`, `roll_base`, `drift`, `descriptor` consistent across Tasks 4, 6, 8. `to_dict`/`from_dict` present on every entity and consumed in round-trip (Task 8). ✔ **Deviations from spec (flag for reviewer):** - `construct` returns a `{ok, errors, …}` result dict rather than raising — GDScript has no exceptions; this keeps failures testable. The spec described "fail loudly"; a populated `errors` array + null products is the loud, testable form. - `LogLocation`/`Quest` id-regex validation `push_error`s but still assigns (does not hard-reject). The meaningful mutation invariants (clamp/cap/dedup/enum) are hard-enforced; id validity is a warning surface since ids originate from already-resolved content. **Out of scope (later plans):** UI scenes, HTTP client + degraded-DM fallback, save/load + `schema_version` migration, move validation & apply, combat/stat consumers, content packaging for export, final Luck die/range + descriptor copy.