# 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.
