Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-09-client-canon-log-engine.md
Phillip Tarrant 1c5f08c5e7 docs(plan): client canon log engine (Plan B) implementation plan
Eight TDD tasks off feature/client-canon-log-engine: GUT harness, leaf
entities, CanonLog container + mutators, Luck+GameState, ContentDB,
NewGame.construct, TagExtractor, round-trip/contract-invariant bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:58:21 -05:00

1414 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Client Canon Log Engine (Plan B) 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 23); `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 18 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 23. ✔
- 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.