diff --git a/docs/superpowers/plans/2026-07-09-client-canon-log-engine.md b/docs/superpowers/plans/2026-07-09-client-canon-log-engine.md new file mode 100644 index 0000000..c6b13cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-client-canon-log-engine.md @@ -0,0 +1,1413 @@ +# 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.