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>
48 KiB
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 viabash client/run_tests.sh. - Model: RefCounted typed classes with
to_dict()/from_dict(). NoResource/.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_descriptorcrosses. Enforced by keeping numbers inGameStateand the log'splayerdict carrying exactly{name, class_id, luck_descriptor}. recent_eventscapped at 5 (charter §11) — enforced byCanonLog.push_event.- POC classes:
sellsword|assassin|priestonly (charter §8). - All string ids match
^[a-z0-9_]+$(viaIds.is_valid). - Test convention: test files
extends "res://addons/gut/test.gd"(path form, cache-independent) andpreloadthe script-under-test by explicitres://path (a missing file is a clean red). Production code cross-references byclass_name;run_tests.shrunsgodot --headless --importfirst so those global names resolve. - Content root:
ContentDB.default_content_root()=res://../contentglobalized (dev reads the repo/content). Export packaging is a later plan. - Execution branch: run this plan on
feature/client-canon-log-enginebranched offdev(CLAUDE.md §18). Per-task commits land there; the human merges todev.
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 everyres://tests/unit/**/*.gdGUT test; prints a summary containingFailing: 0when 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) -> boolLogPlayer.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) -> LogPlayerLogLocation.new(id := "", name := "");to_dict();LogLocation.from_dict(d) -> LogLocationPartyMember.new(id := "", name := "", disposition := 0);set_disposition(v)(clamps −100..100);to_dict();PartyMember.from_dict(d) -> PartyMemberQuest.new(id := "", name := "", status := "active", objective := "");set_status(v) -> bool(enum);to_dict();Quest.from_dict(d) -> QuestHumiliation.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 emptyLogPlayer/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(returnsnullon unsupportedschema_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 fieldsluck: int,luck_base: int,stats: Dictionary,npc_dispositions: Dictionary,inventory: Dictionary; methodsset_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(globalizedres://../content)ContentDB.origin_path(id: String) -> StringContentDB.load_json(abs_path: String) -> VariantContentDB.new();load_from(content_root: String) -> void- Lookups returning the raw content dict (or
{}):location(id),npc(id),quest(id),item(id); predicateshas_location/has_npc/has_quest/has_item(id) -> bool companions() -> Array(NPC dicts withrole == "companion")unresolved_refs(origin: Dictionary) -> Array(e.g.["location:nowhere", "item:ghost_blade"]; empty = clean) — the GDScript twin ofapi/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 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,okisfalse,errorsis non-empty, andlog/statearenull. -
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) -> Dictionaryreturning{ clean_text: String, facts: Array, dispositions: Array, moves: Array }, where eachmovesentry 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_dictunchanged 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 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
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,
TagExtractorTask 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_dictshape +GameStateownership; asserted in Tasks 2, 6, 8. ✔ recent_eventscap 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):
constructreturns a{ok, errors, …}result dict rather than raising — GDScript has no exceptions; this keeps failures testable. The spec described "fail loudly"; a populatederrorsarray + null products is the loud, testable form.LogLocation/Questid-regex validationpush_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.