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

48 KiB
Raw Permalink Blame History

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

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
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:

{
  "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:

#!/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:

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:

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
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:

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:

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:

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:

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:

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:

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:

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
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:

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:

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
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:

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:

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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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
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:

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