Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-12-creation-model.md
Phillip Tarrant 89d4c65a7a docs(plan): M4-a — the creation model, six tasks
TDD, green suite at every boundary. Task 4 is deliberately one cross-cutting
commit: the origin's key and the log's player field are read on both sides of
the HTTP boundary, so renaming them in separate tasks would leave a suite red
at the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:08:21 -05:00

64 KiB
Raw Blame History

M4-a — Character Creation Model 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 rules a character is made of — races, callings, skills, and a derived character sheet — and make NewGame.construct a seeded, reproducible creation pipeline. No UI.

Architecture: Race/calling/skill mechanics become static tables in code (client/scripts/rules/), shaped exactly like the existing Luck and Currency. Their blurbs are hand-written content (content/world/callings/, races/), loaded by ContentDB like items/, guarded by a parity test. CharacterSheet stores only what was rolled or chosen and derives everything else. The creation Dictionary carries a seed, not a stat blockconstruct reconstructs the RNG and rolls the attributes itself, so the same seed always yields the same character and the UI cannot inject a number.

Tech Stack: Godot 4.7 / GDScript, GUT (headless, client/run_tests.sh). Python / FastAPI / pytest for the API. Hand-written JSON content. JSON Schema for the cross-boundary contract.

Spec: docs/superpowers/specs/2026-07-12-creation-model-design.md — read it before starting. Everything below implements it.

Global Constraints

  • Charter §2 — code owns state. construct never trusts numbers handed to it. It re-derives them from the seed.
  • Charter §7 — LCK is not on the sheet. No save, no skill, no row, no accessor. It stays in GameState.luck / luck_base. LCK is never spendable at creation. Numeric Luck must never reach the canon log.
  • Charter §10 — seeded. Same seed + same choices → identical character, including the hidden LCK. The RNG order (five attributes in the order str · dex · con · fth · mag, then LCK) is part of the contract, not an implementation detail.
  • The roll is 3d6 with a hard floor of 8maxi(8, 3d6).
  • The spend pool is +3 total, additive only. Non-negative integers, sum ≤ 3, spread across any of the five attributes.
  • Rules live in code; blurbs live in content. The item JSONs carry id + name + blurb and no mechanics. A second copy of a hit die in content would be a second source of truth.
  • The saga guardrail: NewGame.construct takes creation as a plain Dictionary. A later saga synthesizes one and skips the UI, so the creation scene must never become the only thing that can produce it.
  • The 7 callings: sellsword · reaver · cutpurse · trapper · hedge_mage · bonesetter · bloodsworn. The 4 races: human · elf · dwarf · beastfolk.
  • Charter §18 — git. Code, so it lands on feat/creation-model off dev. Never master. Merge only after the human confirms.
  • The client suite must be green at the end of every task. From client/: ./run_tests.sh (188 tests before this plan; the -gtest= flag is ignored — the repo's .gutconfig.json always runs the whole suite).
  • The API suite must be green at the end of every task that touches api/. From api/: venv/bin/pytest.
  • The content build must stay green. From the repo root: PYTHONPATH=tools python3 -m content_build --check.
  • Godot writes .uid sidecars for new scripts and this repo tracks them — stage any that appear.

File Structure

File Responsibility
client/scripts/rules/attributes.gd new. The five attribute ids + modifier(score). The one place floor((score10)/2) lives.
client/scripts/rules/skills.gd new. The 9 skills → governing attribute.
client/scripts/rules/races.gd new. The 4 races as feature flags. No race touches an attribute score.
client/scripts/rules/callings.gd new. The 7 callings: hit die, saves, skill pool + count, resource, armor, talent.
content/world/callings/*.json, content/world/races/*.json new. Blurbs only — {id, name, blurb}. Placeholders now; authored prose later.
client/scripts/content/content_db.gd Loads callings/ + races/.
client/scripts/state/character_sheet.gd new. Stores rolled/chosen inputs + current hp/mp; derives everything else.
client/scripts/state/game_state.gd stats Dictionary → sheet: CharacterSheet.
client/scripts/canon_log/log_player.gd class_idcalling_id; adds race_id; validates against Callings.IDS / Races.IDS.
client/scripts/newgame/new_game.gd The seeded creation pipeline.
docs/schemas/canon-log.schema.json calling_id (enum of 7) + required race_id (enum of 4).
docs/schemas/origin.schema.json allowed_classesallowed_callings (enum of 7).
content/origins/deserter.json allowed_callings, all seven.
api/app/prompts.py Renders the player's race and calling into the Narrator/NPC digests.

Task order and why: the tables (T1) and their content (T2) come first because everything reads them. The sheet (T3) needs the tables. T4 is a single cross-cutting rename — the schema, the origin JSON, the client, and the API must move together, because the origin's key and the log's field are read on both sides of the HTTP boundary; splitting them leaves a suite red at the boundary. T5 then swaps the creation pipeline in behind the already-renamed contract.


Task 0: Branch

  • Step 1: Cut the branch off dev
git checkout dev
git status --porcelain   # expect: clean
git checkout -b feat/creation-model

Task 1: The rules tables

Files:

  • Create: client/scripts/rules/attributes.gd, client/scripts/rules/skills.gd, client/scripts/rules/races.gd, client/scripts/rules/callings.gd
  • Test: client/tests/unit/test_rules_tables.gd

Interfaces:

  • Consumes: nothing.

  • Produces — every later task depends on these exact names:

    • Attributes.IDS["str","dex","con","fth","mag"]; Attributes.modifier(score: int) -> int
    • Skills.exists(skill: String) -> bool; Skills.attribute(skill: String) -> String; Skills.IDS -> Array
    • Races.IDS -> Array; Races.exists(id) -> bool; Races.save_bonus(id) -> int; Races.granted_skills(id) -> Array; Races.wants_bonus_skill(id) -> bool; Races.poison_save_bonus(id) -> int; Races.flag(id, name: String) -> bool
    • Callings.IDS -> Array; Callings.exists(id) -> bool; Callings.hit_die(id) -> int; Callings.saves(id) -> Array; Callings.skill_pool(id) -> Array; Callings.skill_count(id) -> int; Callings.is_caster(id) -> bool; Callings.casting_stat(id) -> String; Callings.primary(id) -> String; Callings.armor(id) -> String; Callings.talent(id) -> String
  • Step 1: Write the failing test

Create client/tests/unit/test_rules_tables.gd. These classes are reached by global class_name (the project has no autoloads); run_tests.sh runs godot --headless --import first, which is what makes a brand-new class_name resolve.

extends "res://addons/gut/test.gd"


func test_modifier_at_the_boundaries():
	assert_eq(Attributes.modifier(7), -2)
	assert_eq(Attributes.modifier(8), -1)
	assert_eq(Attributes.modifier(9), -1)
	assert_eq(Attributes.modifier(10), 0)
	assert_eq(Attributes.modifier(11), 0)
	assert_eq(Attributes.modifier(12), 1)
	assert_eq(Attributes.modifier(18), 4)


func test_the_five_attributes_exclude_luck():
	assert_eq(Attributes.IDS, ["str", "dex", "con", "fth", "mag"])
	assert_false("lck" in Attributes.IDS, "LCK is never an attribute row (§7)")


func test_nine_skills_each_governed_by_an_attribute():
	assert_eq(Skills.IDS.size(), 9)
	for s in Skills.IDS:
		assert_true(Skills.attribute(s) in Attributes.IDS, "%s has no governing attribute" % s)


func test_perception_is_faith_and_sorcery_is_magic():
	assert_eq(Skills.attribute("perception"), "fth")
	assert_eq(Skills.attribute("sorcery"), "mag")
	assert_false(Skills.exists("lockpicking"))


func test_four_races():
	assert_eq(Races.IDS, ["human", "elf", "dwarf", "beastfolk"])


func test_human_gets_a_save_bonus_and_a_skill_choice():
	assert_eq(Races.save_bonus("human"), 1)
	assert_true(Races.wants_bonus_skill("human"))
	assert_eq(Races.granted_skills("human"), [])


func test_elf_is_granted_perception_and_nightsight():
	assert_eq(Races.granted_skills("elf"), ["perception"])
	assert_true(Races.flag("elf", "nightsight"))
	assert_false(Races.wants_bonus_skill("elf"))


func test_dwarf_poison_bonus_is_situational_not_a_save_bonus():
	assert_eq(Races.poison_save_bonus("dwarf"), 2)
	assert_eq(Races.save_bonus("dwarf"), 0, "the +2 is vs poison only  it is NOT a flat save bonus")


func test_beastfolk_has_claws_and_scent():
	assert_true(Races.flag("beastfolk", "claws"))
	assert_true(Races.flag("beastfolk", "keen_scent"))


func test_seven_callings():
	assert_eq(Callings.IDS,
		["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"])


func test_hit_dice():
	assert_eq(Callings.hit_die("reaver"), 12)
	assert_eq(Callings.hit_die("sellsword"), 10)
	assert_eq(Callings.hit_die("hedge_mage"), 6)


func test_casters_have_a_casting_stat_and_martials_do_not():
	for id in ["hedge_mage", "bonesetter", "bloodsworn"]:
		assert_true(Callings.is_caster(id), "%s is a caster" % id)
		assert_true(Callings.casting_stat(id) in Attributes.IDS)
	for id in ["sellsword", "reaver", "cutpurse", "trapper"]:
		assert_false(Callings.is_caster(id), "%s lives on cooldowns" % id)
		assert_eq(Callings.casting_stat(id), "")


func test_cutpurse_picks_four_skills_trapper_three_rest_two():
	assert_eq(Callings.skill_count("cutpurse"), 4)
	assert_eq(Callings.skill_count("trapper"), 3)
	for id in ["sellsword", "reaver", "hedge_mage", "bonesetter", "bloodsworn"]:
		assert_eq(Callings.skill_count(id), 2, "%s picks 2" % id)


func test_every_skill_pool_is_real_skills_and_big_enough_to_choose_from():
	for id in Callings.IDS:
		var pool: Array = Callings.skill_pool(id)
		for s in pool:
			assert_true(Skills.exists(s), "%s pool has a bogus skill: %s" % [id, s])
		assert_true(pool.size() > Callings.skill_count(id),
			"%s must have more pool than picks or the choice is fake" % id)


func test_every_calling_saves_are_real_attributes():
	for id in Callings.IDS:
		assert_eq(Callings.saves(id).size(), 2)
		for s in Callings.saves(id):
			assert_true(s in Attributes.IDS)
  • Step 2: Run the tests to verify they fail

Run from client/:

./run_tests.sh

Expected: FAIL — the parser cannot resolve Attributes / Skills / Races / Callings.

  • Step 3: Write attributes.gd
class_name Attributes
extends RefCounted
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
## here — it is never an attribute row, never a save, never a skill (§7). It lives
## in GameState.luck.

const IDS := ["str", "dex", "con", "fth", "mag"]


static func exists(id: String) -> bool:
	return id in IDS


static func modifier(score: int) -> int:
	return floori((score - 10) / 2.0)
  • Step 4: Write skills.gd
class_name Skills
extends RefCounted
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).

const BY_ATTRIBUTE := {
	"athletics": "str",
	"intimidation": "str",
	"stealth": "dex",
	"sleight_of_hand": "dex",
	"acrobatics": "dex",
	"endurance": "con",
	"perception": "fth",
	"faith_lore": "fth",
	"sorcery": "mag",
}

const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
	"endurance", "perception", "faith_lore", "sorcery"]


static func exists(skill: String) -> bool:
	return BY_ATTRIBUTE.has(skill)


static func attribute(skill: String) -> String:
	return str(BY_ATTRIBUTE.get(skill, ""))
  • Step 5: Write races.gd
class_name Races
extends RefCounted
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
## attribute score, which is what keeps the roll + spend pure.
##
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
## exploration checks consume them; creation only stores them.
##
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
## lying. It is exposed separately as poison_save_bonus().

const IDS := ["human", "elf", "dwarf", "beastfolk"]

const TABLE := {
	"human": {
		"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
		"poison_save_bonus": 0,
		"flags": {"nightsight": false, "claws": false, "keen_scent": false},
	},
	"elf": {
		"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
		"poison_save_bonus": 0,
		"flags": {"nightsight": true, "claws": false, "keen_scent": false},
	},
	"dwarf": {
		"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
		"poison_save_bonus": 2,
		"flags": {"nightsight": true, "claws": false, "keen_scent": false},
	},
	"beastfolk": {
		"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
		"poison_save_bonus": 0,
		"flags": {"nightsight": false, "claws": true, "keen_scent": true},
	},
}


static func exists(id: String) -> bool:
	return TABLE.has(id)


static func save_bonus(id: String) -> int:
	return int(TABLE.get(id, {}).get("save_bonus", 0))


static func granted_skills(id: String) -> Array:
	return TABLE.get(id, {}).get("granted_skills", [])


static func wants_bonus_skill(id: String) -> bool:
	return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))


static func poison_save_bonus(id: String) -> int:
	return int(TABLE.get(id, {}).get("poison_save_bonus", 0))


static func flag(id: String, name: String) -> bool:
	return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))
  • Step 6: Write callings.gd
class_name Callings
extends RefCounted
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
##
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
## calling's primary-stat skills plus a small adjacent slice, always larger than the
## pick count so the choice is real.
##
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
## armor is an item (M7).

const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]

const TABLE := {
	"sellsword": {
		"primary": "str", "hit_die": 10, "saves": ["str", "con"],
		"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
		"casting_stat": "", "armor": "heavy", "talent": "second_wind",
	},
	"reaver": {
		"primary": "str", "hit_die": 12, "saves": ["str", "con"],
		"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
		"casting_stat": "", "armor": "medium", "talent": "blood_fury",
	},
	"cutpurse": {
		"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
		"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
		"skill_count": 4,
		"casting_stat": "", "armor": "light", "talent": "backstab",
	},
	"trapper": {
		"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
		"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
		"casting_stat": "", "armor": "light", "talent": "set_snare",
	},
	"hedge_mage": {
		"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
		"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
		"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
	},
	"bonesetter": {
		"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
		"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
		"casting_stat": "fth", "armor": "medium", "talent": "mend",
	},
	"bloodsworn": {
		"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
		"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
		"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
	},
}


static func exists(id: String) -> bool:
	return TABLE.has(id)


static func primary(id: String) -> String:
	return str(TABLE.get(id, {}).get("primary", ""))


static func hit_die(id: String) -> int:
	return int(TABLE.get(id, {}).get("hit_die", 0))


static func saves(id: String) -> Array:
	return TABLE.get(id, {}).get("saves", [])


static func skill_pool(id: String) -> Array:
	return TABLE.get(id, {}).get("skill_pool", [])


static func skill_count(id: String) -> int:
	return int(TABLE.get(id, {}).get("skill_count", 0))


static func casting_stat(id: String) -> String:
	return str(TABLE.get(id, {}).get("casting_stat", ""))


static func is_caster(id: String) -> bool:
	return casting_stat(id) != ""


static func armor(id: String) -> String:
	return str(TABLE.get(id, {}).get("armor", ""))


static func talent(id: String) -> String:
	return str(TABLE.get(id, {}).get("talent", ""))
  • Step 7: Run the tests to verify they pass
./run_tests.sh

Expected: all green (188 prior + 14 new).

  • Step 8: Commit
git add client/scripts/rules/ client/tests/unit/test_rules_tables.gd
git commit -m "feat(rules): the race, calling, skill and attribute tables

Mechanics are rules and live in code, beside Luck and Currency. The skill pools
are new — the races/classes spec said only 'the stat-appropriate slice' and never
enumerated them; each pool is larger than its pick count so the choice is real.

The dwarf's +2 vs poison is exposed separately from save() rather than folded
into it: baking a conditional into a flat number is how a sheet starts lying."

Task 2: The blurbs (content) + ContentDB

Files:

  • Create: content/world/callings/{sellsword,reaver,cutpurse,trapper,hedge_mage,bonesetter,bloodsworn}.json
  • Create: content/world/races/{human,elf,dwarf,beastfolk}.json
  • Modify: client/scripts/content/content_db.gd
  • Test: client/tests/unit/test_content_db.gd

Interfaces:

  • Consumes: Callings.IDS, Races.IDS (Task 1).
  • Produces: ContentDB.callings / ContentDB.races (Dictionaries keyed by id); ContentDB.calling(id) -> Dictionary, ContentDB.race(id) -> Dictionary, ContentDB.has_calling(id) -> bool, ContentDB.has_race(id) -> bool.

These are hand-written directories, like content/world/items/not emitted by content_build, so no build-tool namespace is needed. The blurbs are placeholders; authored prose replaces them later without touching code. That split is exactly what stops the content BOM from blocking this milestone.

  • Step 1: Write the failing test

Append to client/tests/unit/test_content_db.gd:

func test_loads_calling_and_race_content():
	assert_true(db.has_calling("reaver"))
	assert_true(db.has_calling("bloodsworn"))
	assert_true(db.has_race("beastfolk"))
	assert_eq(db.calling("reaver")["name"], "Reaver")
	assert_eq(db.race("beastfolk")["name"], "Beastfolk")


func test_calling_content_matches_the_code_table():
	# The roster exists in code (Callings.IDS — mechanics are rules) and in content
	# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
	var content_ids: Array = db.callings.keys()
	content_ids.sort()
	var code_ids: Array = Callings.IDS.duplicate()
	code_ids.sort()
	assert_eq(content_ids, code_ids)


func test_race_content_matches_the_code_table():
	var content_ids: Array = db.races.keys()
	content_ids.sort()
	var code_ids: Array = Races.IDS.duplicate()
	code_ids.sort()
	assert_eq(content_ids, code_ids)


func test_blurb_content_carries_no_mechanics():
	# Mechanics are code. A hit_die in a content file is a second source of truth.
	for id in db.callings:
		for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]:
			assert_false(db.callings[id].has(banned),
				"%s.json leaks the mechanic '%s' — that lives in Callings" % [id, banned])
  • Step 2: Run the test to verify it fails
./run_tests.sh

Expected: FAIL — has_calling does not exist on ContentDB.

  • Step 3: Write the seven calling blurbs

Placeholder prose in the project's register (§3: gritty, dry, no winking). Each file is exactly {id, name, blurb}no mechanics.

content/world/callings/sellsword.json:

{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }

content/world/callings/reaver.json:

{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it." }

content/world/callings/cutpurse.json:

{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it." }

content/world/callings/trapper.json:

{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait." }

content/world/callings/hedge_mage.json:

{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it." }

content/world/callings/bonesetter.json:

{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough." }

content/world/callings/bloodsworn.json:

{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled." }
  • Step 4: Write the four race blurbs

content/world/races/human.json:

{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }

content/world/races/elf.json:

{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed." }

content/world/races/dwarf.json:

{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." }

content/world/races/beastfolk.json:

{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak." }
  • Step 5: Load them in ContentDB

In client/scripts/content/content_db.gd, add the two fields beside the existing ones:

var items: Dictionary = {}
var callings: Dictionary = {}
var races: Dictionary = {}

In load_from, add the two _load_dir calls beside the others:

	items = _load_dir(world.path_join("items"))
	callings = _load_dir(world.path_join("callings"))
	races = _load_dir(world.path_join("races"))

And the accessors, beside the existing one-liners:

func calling(id: String) -> Dictionary: return callings.get(id, {})
func race(id: String) -> Dictionary: return races.get(id, {})
func has_calling(id: String) -> bool: return callings.has(id)
func has_race(id: String) -> bool: return races.has(id)
  • Step 6: Run the tests + the content build
./run_tests.sh

Expected: green.

From the repo root:

PYTHONPATH=tools python3 -m content_build --check
git status --porcelain content/

Expected: the build check passes, and git status shows only the eleven new files under content/world/callings/ and content/world/races/. If any other file under content/world/ or content/server/ appears as modified, STOP — the generated tree has drifted and that is a client-visible change.

  • Step 7: Commit
git add content/world/callings/ content/world/races/ \
        client/scripts/content/content_db.gd client/tests/unit/test_content_db.gd
git commit -m "feat(content): calling + race blurbs, loaded by ContentDB

Hand-written like items/ — no build-tool namespace needed. Blurbs are content;
mechanics are code, and a test asserts the blurb files carry no hit dice. The
prose is placeholder; swapping it later touches no code, which is what stops the
content BOM from blocking the engine."

Task 3: CharacterSheet

Files:

  • Create: client/scripts/state/character_sheet.gd
  • Test: client/tests/unit/test_character_sheet.gd

Interfaces:

  • Consumes: Attributes.modifier, Skills.attribute, Races.*, Callings.* (Task 1).
  • Produces: CharacterSheet with stored attributes: Dictionary, race_id: String, calling_id: String, level: int, skills: Array, bonus_skill: String, hp: int, mp: int; and derived modifier(stat), prof_bonus(), max_hp(), max_mp(), ac(), is_proficient(skill), save(stat), poison_save_bonus(), skill_bonus(skill), spell_dc(), initiative(), has_nightsight(), has_claws(), has_keen_scent(). Task 5 constructs it.

bonus_skill is stored for display, but the human's chosen skill is ALSO appended to skills at construction (Task 5). So is_proficient only ever checks skills — one list, no double-counting.

  • Step 1: Write the failing test

Create client/tests/unit/test_character_sheet.gd:

extends "res://addons/gut/test.gd"

const CharacterSheet = preload("res://scripts/state/character_sheet.gd")


func _sheet(race := "human", calling := "sellsword", attrs := {}) -> CharacterSheet:
	var s = CharacterSheet.new()
	s.race_id = race
	s.calling_id = calling
	s.attributes = {"str": 14, "dex": 12, "con": 14, "fth": 10, "mag": 8}
	for k in attrs:
		s.attributes[k] = attrs[k]
	return s


func test_max_hp_is_hit_die_plus_con_mod():
	var s = _sheet("human", "reaver")          # d12, CON 14 -> +2
	assert_eq(s.max_hp(), 14)
	var frail = _sheet("human", "hedge_mage", {"con": 8})   # d6, CON 8 -> -1
	assert_eq(frail.max_hp(), 5)


func test_ac_is_ten_plus_dex():
	assert_eq(_sheet().ac(), 11)               # DEX 12 -> +1. Armor is an item (M7).


func test_initiative_is_dex():
	assert_eq(_sheet().initiative(), 1)


func test_save_picks_up_calling_proficiency():
	var s = _sheet("elf", "sellsword")         # saves STR, CON; prof +2
	assert_eq(s.save("str"), 4)                # STR 14 -> +2, +2 prof
	assert_eq(s.save("dex"), 1)                # DEX 12 -> +1, not proficient


func test_human_plus_one_lands_on_all_five_saves():
	var human = _sheet("human", "sellsword")
	var elf = _sheet("elf", "sellsword")
	for stat in ["str", "dex", "con", "fth", "mag"]:
		assert_eq(human.save(stat), elf.save(stat) + 1, "human +1 on %s" % stat)


func test_dwarf_poison_bonus_is_not_in_the_con_save():
	var dwarf = _sheet("dwarf", "sellsword")
	var elf = _sheet("elf", "sellsword")
	assert_eq(dwarf.save("con"), elf.save("con"), "the +2 is situational, not a flat CON save")
	assert_eq(dwarf.poison_save_bonus(), 2)
	assert_eq(elf.poison_save_bonus(), 0)


func test_skill_bonus_uses_the_governing_attribute():
	var s = _sheet()                            # STR 14 -> +2
	assert_eq(s.skill_bonus("athletics"), 2, "not proficient: attribute only")
	s.skills = ["athletics"]
	assert_eq(s.skill_bonus("athletics"), 4, "proficient: +2 prof")


func test_bonus_skill_confers_proficiency_via_the_skills_list():
	var s = _sheet()
	s.skills = ["athletics", "endurance"]       # Task 5 appends the human's pick here
	s.bonus_skill = "endurance"
	assert_true(s.is_proficient("endurance"))
	assert_false(s.is_proficient("stealth"))


func test_martials_have_no_mp_and_no_spell_dc():
	var s = _sheet("human", "sellsword")
	assert_eq(s.max_mp(), 0)
	assert_eq(s.spell_dc(), 0)


func test_casters_have_mp_and_a_spell_dc():
	var s = _sheet("human", "bonesetter", {"fth": 16})   # FTH 16 -> +3
	assert_true(s.max_mp() > 0, "a caster has a pool")
	assert_eq(s.spell_dc(), 13)                          # 8 + 2 prof + 3


func test_race_flags_are_exposed():
	assert_true(_sheet("elf").has_nightsight())
	assert_false(_sheet("human").has_nightsight())
	assert_true(_sheet("beastfolk").has_claws())
	assert_true(_sheet("beastfolk").has_keen_scent())
	assert_false(_sheet("dwarf").has_claws())


func test_luck_is_nowhere_on_the_sheet():
	# §7: LCK has no save, no skill, no row, no accessor. It lives in GameState.
	var s = _sheet()
	assert_false("lck" in s.attributes, "LCK is never an attribute row")
	assert_false(s.has_method("luck"), "the sheet must not expose Luck")
  • Step 2: Run the test to verify it fails
./run_tests.sh

Expected: FAIL — character_sheet.gd does not exist.

  • Step 3: Write the implementation

Create client/scripts/state/character_sheet.gd:

class_name CharacterSheet
extends RefCounted
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
## so a derived number can never drift from the inputs that produced it, and M5's level
## curve becomes a function change rather than a stored-field migration.
##
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
## save, no skill, no row (§7). That holds by construction, not by discipline.

const PROF_BONUS_L1 := 2

# --- stored: rolled, chosen, or genuinely mutable -------------------------------
var attributes: Dictionary = {}   # {str,dex,con,fth,mag} — final, post-spend
var race_id: String = ""
var calling_id: String = ""
var level: int = 1
var skills: Array = []            # chosen + race-granted + the human's bonus pick
var bonus_skill: String = ""      # human only; also present in `skills`
var hp: int = 0                   # CURRENT hp — combat writes this
var mp: int = 0                   # CURRENT mp


# --- derived: functions, never stored -------------------------------------------
func modifier(stat: String) -> int:
	return Attributes.modifier(int(attributes.get(stat, 10)))


func prof_bonus() -> int:
	return PROF_BONUS_L1  # the level curve is M5's; creation is L1


func max_hp() -> int:
	return Callings.hit_die(calling_id) + modifier("con")


func max_mp() -> int:
	# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
	# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
	# caster's scales off the casting stat, with a floor so a bad roll still casts.
	if not Callings.is_caster(calling_id):
		return 0
	return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))


func ac() -> int:
	return 10 + modifier("dex")  # armor is an item -> M7


func initiative() -> int:
	return modifier("dex")


func is_proficient(skill: String) -> bool:
	return skill in skills


func save(stat: String) -> int:
	var v := modifier(stat)
	if stat in Callings.saves(calling_id):
		v += prof_bonus()
	v += Races.save_bonus(race_id)  # human's +1 to all five
	return v


func poison_save_bonus() -> int:
	# Situational (vs poison + the affliction track) — deliberately NOT folded into
	# save("con"), because a conditional inside a flat number is a sheet that lies.
	return Races.poison_save_bonus(race_id)


func skill_bonus(skill: String) -> int:
	var v := modifier(Skills.attribute(skill))
	if is_proficient(skill):
		v += prof_bonus()
	return v


func spell_dc() -> int:
	if not Callings.is_caster(calling_id):
		return 0
	return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))


func has_nightsight() -> bool:
	return Races.flag(race_id, "nightsight")


func has_claws() -> bool:
	return Races.flag(race_id, "claws")


func has_keen_scent() -> bool:
	return Races.flag(race_id, "keen_scent")
  • Step 4: Run the tests to verify they pass
./run_tests.sh

Expected: green.

  • Step 5: Commit
git add client/scripts/state/character_sheet.gd client/tests/unit/test_character_sheet.gd
git commit -m "feat(state): CharacterSheet — store the inputs, derive the rest

Stores only what was rolled or chosen, plus current hp/mp (the only two numbers
that genuinely mutate). Everything else is a function, so no derived number can
drift from its inputs and M5's level curve is a function change, not a migration.

LCK has no row and no accessor here — §7 holds by construction."

Task 4: The contract migration — calling_id + race_id

Files:

  • Modify: docs/schemas/canon-log.schema.json, docs/schemas/origin.schema.json
  • Modify: content/origins/deserter.json
  • Modify: client/scripts/canon_log/log_player.gd
  • Modify: client/scripts/newgame/new_game.gd (minimal adaptation only)
  • Modify: api/app/prompts.py
  • Modify: api/tests/fixtures/canon_log_valid.json, and any api/client test referencing class_id / allowed_classes
  • Test: client/tests/unit/test_schema_parity.gd (new)

Interfaces:

  • Consumes: Callings.IDS, Races.IDS (Task 1).
  • Produces: LogPlayer.new(name, race_id, calling_id, luck_descriptor); LogPlayer.to_dict() -> {name, race_id, calling_id, luck_descriptor}; the origin key build_constraints.allowed_callings.

This is one task on purpose. The origin's key and the log's field are read on both sides of the HTTP boundary — the client validates allowed_callings and the server renders calling_id into the prompt. Renaming them in separate tasks leaves one suite red at the boundary. Do it as one commit.

class_id is the rulebook's word; the world has callings. There are no saves (persistence is M9) and no deployed clients, so this rename is as cheap now as it will ever be — and strictly more expensive every milestone after.

Do not build the new creation pipeline here. construct keeps rolling its old flat stat block; Task 5 replaces that. This task only makes the contract say the right words.

  • Step 1: Find every reference
grep -rn "class_id\|allowed_classes\|CLASSES" client api docs content --include=*.gd --include=*.py --include=*.json

Every hit is in scope for this task. Expect: both schemas, log_player.gd, new_game.gd, deserter.json, api/app/prompts.py, api/tests/{test_npc,test_prompts,test_origin_schema,test_live_smoke}.py, api/tests/fixtures/canon_log_valid.json, and the client tests test_new_game.gd / test_round_trip.gd.

  • Step 2: Write the failing tests

Create client/tests/unit/test_schema_parity.gd. The roster now lives in two places — Callings.IDS and the schema's enum — so this is the guard that they cannot drift. ContentDB already reaches ../content from res://, so the same trick reaches ../docs.

extends "res://addons/gut/test.gd"
## The canon log crosses the HTTP boundary, so its roster exists twice: in code
## (Callings.IDS / Races.IDS) and in the JSON Schema the API validates against.
## Nothing at runtime reconciles them. This does.

const ContentDB = preload("res://scripts/content/content_db.gd")


func _player_schema() -> Dictionary:
	var path := ProjectSettings.globalize_path("res://") \
		.path_join("../docs/schemas/canon-log.schema.json").simplify_path()
	var doc: Variant = ContentDB.load_json(path)
	assert_eq(typeof(doc), TYPE_DICTIONARY, "canon-log schema did not parse")
	return doc["properties"]["player"]


func test_schema_calling_enum_matches_the_code():
	var enum_ids: Array = _player_schema()["properties"]["calling_id"]["enum"]
	enum_ids.sort()
	var code_ids: Array = Callings.IDS.duplicate()
	code_ids.sort()
	assert_eq(enum_ids, code_ids)


func test_schema_race_enum_matches_the_code():
	var enum_ids: Array = _player_schema()["properties"]["race_id"]["enum"]
	enum_ids.sort()
	var code_ids: Array = Races.IDS.duplicate()
	code_ids.sort()
	assert_eq(enum_ids, code_ids)


func test_schema_requires_race_and_calling():
	var required: Array = _player_schema()["required"]
	assert_true("race_id" in required)
	assert_true("calling_id" in required)
	assert_false("class_id" in required, "class_id is the rulebook's word — it is gone")

Add to client/tests/unit/test_canon_log.gd (or wherever LogPlayer is exercised — find it with grep -rln LogPlayer client/tests):

func test_log_player_carries_race_and_calling_and_no_luck_number():
	var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
	var d := p.to_dict()
	assert_eq(d["race_id"], "beastfolk")
	assert_eq(d["calling_id"], "cutpurse")
	assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
	assert_false("class_id" in d)
	assert_eq(d.keys().size(), 4)


func test_log_player_rejects_a_dead_calling():
	var p := LogPlayer.new()
	assert_false(p.set_calling_id("priest"), "priest is not a calling")
	assert_true(p.set_calling_id("bonesetter"))
	assert_false(p.set_race_id("orc"))
	assert_true(p.set_race_id("dwarf"))

In api/tests/test_prompts.py, note the existing shape before you edit: the renderer is render_digest (not render_narrate_digest), and the fixture is a module-level VALID loaded from fixtures/canon_log_valid.json — there is no _valid_log() helper.

First, an existing assertion breaks and must be updated, at test_digest_has_the_narrative_context:

    assert "Player: Aldric, a human sellsword" in d   # was: "Player: Aldric, a sellsword"

(That matches the fixture you update in Step 10, which gains race_id: "human".)

Then add:

def _with_player(**player) -> dict:
    log = json.loads(json.dumps(VALID))   # deep copy — VALID is module-level and shared
    log["player"] = {"luck_descriptor": "the dice are kind", **player}
    return log


def test_digest_names_the_race_and_the_calling():
    out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
    assert "Aldric, a beastfolk cutpurse" in out


def test_digest_says_an_elf_not_a_elf():
    out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
    assert "an elf sellsword" in out


def test_digest_does_not_leak_a_snake_case_id():
    out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
    assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
    assert "hedge-mage" in out
  • Step 3: Run the tests to verify they fail
cd client && ./run_tests.sh          # FAIL: schema has no calling_id/race_id
cd ../api && venv/bin/pytest         # FAIL: digest still renders class_id
  • Step 4: Migrate docs/schemas/canon-log.schema.json

Replace the player block's properties and required:

"player": {
  "type": "object",
  "additionalProperties": false,
  "required": ["name", "race_id", "calling_id", "luck_descriptor"],
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] },
    "calling_id": {
      "enum": ["sellsword", "reaver", "cutpurse", "trapper",
               "hedge_mage", "bonesetter", "bloodsworn"]
    },
    "luck_descriptor": { "type": "string", "minLength": 1 }
  }
}
  • Step 5: Migrate docs/schemas/origin.schema.json

In build_constraints, rename the key and update the enum. It stays additionalProperties: false and minItems: 1:

"build_constraints": {
  "type": "object",
  "additionalProperties": false,
  "required": ["allowed_callings", "luck_modifier"],
  "properties": {
    "allowed_callings": {
      "type": "array",
      "minItems": 1,
      "items": {
        "enum": ["sellsword", "reaver", "cutpurse", "trapper",
                 "hedge_mage", "bonesetter", "bloodsworn"]
      }
    },
    "luck_modifier": { "type": "integer" }
  }
}
  • Step 6: Migrate content/origins/deserter.json

allowed_classes: ["sellsword", "assassin", "priest"] names three callings that no longer exist. He is a mercenary-company deserter; a thematic gate is a content decision for the Greywater authoring, not an engine change invented here — so he allows all seven:

  "build_constraints": {
    "allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
                         "hedge_mage", "bonesetter", "bloodsworn"],
    "luck_modifier": 0
  }
  • Step 7: Migrate LogPlayer

Rewrite client/scripts/canon_log/log_player.gd. The hardcoded CLASSES const is deleted — the roster is Callings.IDS, one source of truth:

class_name LogPlayer
extends RefCounted
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
## AI. race_id is here because the Narrator and NPCs must be able to describe the
## player ("a beastfolk cutpurse"); the mechanical sheet must not.

var name: String
var race_id: String
var calling_id: String
var luck_descriptor: String


func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
	name = p_name
	luck_descriptor = p_luck_descriptor
	if p_race_id != "":
		set_race_id(p_race_id)
	if p_calling_id != "":
		set_calling_id(p_calling_id)


func set_race_id(v: String) -> bool:
	if not Races.exists(v):
		push_error("invalid race_id: %s" % v)
		return false
	race_id = v
	return true


func set_calling_id(v: String) -> bool:
	if not Callings.exists(v):
		push_error("invalid calling_id: %s" % v)
		return false
	calling_id = v
	return true


func to_dict() -> Dictionary:
	return {"name": name, "race_id": race_id, "calling_id": calling_id,
		"luck_descriptor": luck_descriptor}


static func from_dict(d: Dictionary) -> LogPlayer:
	return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
		d.get("calling_id", ""), d.get("luck_descriptor", ""))
  • Step 8: Adapt NewGame minimally

Not the new pipeline — that is Task 5. Only make the existing code speak the new contract. In client/scripts/newgame/new_game.gd:

	# 2. build constraints — chosen calling must be allowed by the origin.
	var bc: Dictionary = origin.get("build_constraints", {})
	var allowed: Array = bc.get("allowed_callings", [])
	var calling_id: String = creation.get("calling_id", "")
	if calling_id not in allowed:
		errors.append("calling not allowed by origin: %s" % calling_id)

	var race_id: String = creation.get("race_id", "")
	if not Races.exists(race_id):
		errors.append("unknown race: %s" % race_id)

and where the log player is built:

	log.player = LogPlayer.new(creation.get("name", ""), race_id, calling_id,
		state.luck_descriptor())

Update client/tests/unit/test_new_game.gd and client/tests/unit/test_round_trip.gd: every {"name": "...", "class_id": "sellsword"} creation dict becomes {"name": "...", "race_id": "human", "calling_id": "sellsword"} (and "class_id": "priest""calling_id": "bonesetter", since priest no longer exists).

  • Step 9: Render race + calling in the API prompts

api/app/prompts.py currently emits a {class_id}, which would now read a hedge_mage — a raw snake_case id going to the model — and a elf, which is broken English. Fix both at the render edge. Add near the top of the module:

def _humanize(id_: str) -> str:
    """Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
    return id_.replace("_", "-")


def _article(word: str) -> str:
    return "an" if word[:1].lower() in "aeiou" else "a"


def _describe_player(player: dict) -> str:
    """'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
    race = _humanize(player.get("race_id", ""))
    calling = _humanize(player.get("calling_id", ""))
    name = player.get("name", "")
    return f"{name}, {_article(race)} {race} {calling}".strip()

Then line 53 becomes:

    lines.append(f"Player: {_describe_player(player)}")

and line 107 becomes:

    lines.append(f"The player is {_describe_player(player)}.")
  • Step 10: Update the API fixtures and tests

In api/tests/fixtures/canon_log_valid.json, api/tests/test_npc.py, api/tests/test_prompts.py, and api/tests/test_live_smoke.py, every player block becomes:

{"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"}

In api/tests/test_origin_schema.py, "allowed_classes": ["sellsword", "assassin", "priest"] becomes "allowed_callings": ["sellsword", "cutpurse", "bonesetter"], and the negative case (= ["bard"]) keeps testing that an unknown id is rejected — bard is still not a calling (§8: Bard is NPC-only).

  • Step 11: Run both suites + the content build
cd client && ./run_tests.sh
cd ../api && venv/bin/pytest
cd .. && PYTHONPATH=tools python3 -m content_build --check

Expected: all green. Then confirm no class_id survives:

grep -rn "class_id\|allowed_classes" client api docs content --include=*.gd --include=*.py --include=*.json

Expected: no matches.

  • Step 12: Commit
git add -A
git commit -m "refactor(contract)!: class_id -> calling_id, and the log gains race_id

class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.

race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').

LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.

The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring."

Task 5: The seeded creation pipeline

Files:

  • Modify: client/scripts/newgame/new_game.gd
  • Modify: client/scripts/state/game_state.gd
  • Test: client/tests/unit/test_new_game.gd, client/tests/unit/test_game_state.gd, client/tests/unit/test_round_trip.gd

Interfaces:

  • Consumes: CharacterSheet (Task 3); Attributes.IDS, Races.*, Callings.*, Skills.* (Task 1); LogPlayer.new(name, race_id, calling_id, luck_descriptor) (Task 4).
  • Produces: NewGame.construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionarythe rng parameter is dropped; the seed inside creation is now the only source of randomness. Returns {"ok": bool, "errors": Array, "log": CanonLog, "state": GameState}. GameState.sheet: CharacterSheet replaces GameState.stats.

The creation Dictionary:

{
  "name": "Aldric",
  "race_id": "beastfolk",
  "calling_id": "cutpurse",
  "seed": 8675309,
  "spend": {"dex": 2, "con": 1},
  "skills": ["stealth", "sleight_of_hand", "acrobatics", "athletics"],
  "bonus_skill": ""          # human only
}

It carries a seed, not a stat block. construct rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed always yields the same character (§10).

  • Step 1: Write the failing tests

Rewrite the helpers at the top of client/tests/unit/test_new_game.gd (the _rng() helper is deleted — construct no longer takes one):

func _creation(overrides := {}) -> Dictionary:
	var c := {
		"name": "Aldric",
		"race_id": "human",
		"calling_id": "sellsword",
		"seed": 8675309,
		"spend": {},
		"skills": ["athletics", "endurance"],
		"bonus_skill": "perception",
	}
	for k in overrides:
		c[k] = overrides[k]
	return c


func _build(creation: Dictionary) -> Dictionary:
	return NewGame.construct(_deserter(), world, creation)

Then the new tests:

func test_same_seed_yields_an_identical_character():
	# The determinism guarantee (§10). Two independent constructs, one seed.
	var a := _build(_creation())
	var b := _build(_creation())
	assert_true(a["ok"] and b["ok"])
	assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
	assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")


func test_a_different_seed_yields_a_different_character():
	var a := _build(_creation())
	var b := _build(_creation({"seed": 1234567}))
	assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)


func test_every_attribute_respects_the_floor_of_eight():
	# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
	for s in range(40):
		var res := _build(_creation({"seed": s}))
		assert_true(res["ok"], str(res["errors"]))
		for stat in Attributes.IDS:
			assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
				"seed %d rolled %s below the floor" % [s, stat])


func test_spend_is_additive_on_top_of_the_roll():
	var base := _build(_creation())
	var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
	assert_eq(int(spent["state"].sheet.attributes["str"]),
		int(base["state"].sheet.attributes["str"]) + 2)
	assert_eq(int(spent["state"].sheet.attributes["con"]),
		int(base["state"].sheet.attributes["con"]) + 1)
	assert_eq(int(spent["state"].sheet.attributes["dex"]),
		int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")


func test_spend_over_the_pool_is_rejected():
	var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
	assert_false(res["ok"])
	assert_true("spend" in str(res["errors"]).to_lower())


func test_spend_cannot_be_negative():
	var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
	assert_false(res["ok"], "additive only — there is no [-]")


func test_luck_can_never_be_spent():
	# §7: LCK is untouchable at creation. Not hidden — impossible.
	var res := _build(_creation({"spend": {"lck": 3}}))
	assert_false(res["ok"])


func test_skills_must_come_from_the_callings_pool():
	var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
	assert_false(res["ok"], "sorcery is not in the sellsword's pool")


func test_skills_must_be_the_right_count():
	var res := _build(_creation({"skills": ["athletics"]}))
	assert_false(res["ok"], "a sellsword picks 2")


func test_skills_reject_a_duplicate():
	var res := _build(_creation({"skills": ["athletics", "athletics"]}))
	assert_false(res["ok"])


func test_an_elf_cannot_spend_a_pick_on_perception():
	# He already has it. A pick that buys nothing is a trap, not a choice.
	var res := _build(_creation({
		"race_id": "elf", "bonus_skill": "",
		"skills": ["athletics", "perception"]}))
	assert_false(res["ok"])


func test_the_elf_is_granted_perception_anyway():
	var res := _build(_creation({
		"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
	assert_true(res["ok"], str(res["errors"]))
	assert_true(res["state"].sheet.is_proficient("perception"))


func test_a_human_must_choose_a_bonus_skill():
	var res := _build(_creation({"bonus_skill": ""}))
	assert_false(res["ok"])


func test_a_non_human_must_not_have_one():
	var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
	assert_false(res["ok"])


func test_the_humans_bonus_skill_confers_proficiency():
	var res := _build(_creation())      # human, bonus_skill perception
	assert_true(res["ok"], str(res["errors"]))
	assert_true(res["state"].sheet.is_proficient("perception"))


func test_the_character_starts_whole():
	var res := _build(_creation())
	var sheet = res["state"].sheet
	assert_eq(sheet.hp, sheet.max_hp())
	assert_eq(sheet.mp, sheet.max_mp())


func test_a_calling_the_origin_forbids_is_rejected():
	var o := _deserter()
	o["build_constraints"] = o["build_constraints"].duplicate(true)
	o["build_constraints"]["allowed_callings"] = ["bonesetter"]
	var res := NewGame.construct(o, world, _creation())
	assert_false(res["ok"])


func test_no_luck_number_and_no_attributes_reach_the_log():
	# §7 + §11: the log carries only what the AI narrates from.
	var res := _build(_creation())
	var pd: Dictionary = res["log"].player.to_dict()
	assert_eq(pd.keys().size(), 4)
	assert_false("luck" in pd)
	assert_false("attributes" in pd)
	assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)

In client/tests/unit/test_game_state.gd, replace any assertion on the old stats Dictionary with the sheet:

func test_state_holds_a_sheet_not_a_stat_bag():
	var gs = GameState.new()
	assert_null(gs.sheet, "the sheet is built by NewGame.construct")
	assert_false("stats" in gs, "the flat stats Dictionary is gone")

Update client/tests/unit/test_round_trip.gd to the new construct signature and creation dict.

  • Step 2: Run the tests to verify they fail
./run_tests.sh

Expected: FAIL — construct still takes an rng, GameState has no sheet, and none of the validation exists.

  • Step 3: Give GameState a sheet

In client/scripts/state/game_state.gd, replace the stats field:

var sheet: CharacterSheet = null       # the whole character (§2). Built by NewGame.

Delete var stats: Dictionary = {}. luck and luck_base stay exactly where they are — LCK is not on the sheet (§7).

  • Step 4: Write the pipeline

Rewrite client/scripts/newgame/new_game.gd:

class_name NewGame
extends RefCounted
## New-game construction. Reads three immutable inputs (origin, world, creation) and
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
##
## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls
## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same
## seed always yields the same character (§10 — "the difference between a bug you can
## reproduce and a bug you cannot"). The whole character is reproducible from four
## primitives: seed, race_id, calling_id, spend.
##
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
## UI entirely, so the creation SCENE must never become the only thing that can make it.

const SPEND_POOL := 3


static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
	var errors := _validate(origin, world, creation)
	if not errors.is_empty():
		return {"ok": false, "errors": errors, "log": null, "state": null}

	var rng := RandomNumberGenerator.new()
	rng.seed = int(creation.get("seed", 0))

	var state := GameState.new()
	var sheet := CharacterSheet.new()
	sheet.race_id = str(creation.get("race_id", ""))
	sheet.calling_id = str(creation.get("calling_id", ""))

	# The RNG ORDER is part of the contract: five attributes in a fixed order, then
	# Luck. Change the order and every existing seed produces a different character.
	var attrs: Dictionary = {}
	for stat in Attributes.IDS:
		attrs[stat] = _roll_attribute(rng)

	var bc: Dictionary = origin.get("build_constraints", {})
	state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
	state.luck = state.luck_base

	var spend: Dictionary = creation.get("spend", {})
	for stat in spend:
		attrs[stat] = int(attrs[stat]) + int(spend[stat])
	sheet.attributes = attrs

	# Proficiencies: what the race grants + what the player picked + the human's bonus.
	# All land in one list, so is_proficient() has exactly one thing to read.
	var skills: Array = []
	skills.append_array(Races.granted_skills(sheet.race_id))
	skills.append_array(creation.get("skills", []))
	var bonus := str(creation.get("bonus_skill", ""))
	if bonus != "":
		skills.append(bonus)
		sheet.bonus_skill = bonus
	sheet.skills = skills

	sheet.hp = sheet.max_hp()   # the character starts whole
	sheet.mp = sheet.max_mp()
	state.sheet = sheet

	var log := CanonLog.new()
	log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
		sheet.calling_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))))
	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). §2. grant() routes currency to the
	# purse; everything else to the inventory dict.
	for g in origin.get("inventory_grants", []):
		state.grant(g.get("item_id", ""), int(g.get("qty", 0)))

	return {"ok": true, "errors": [], "log": log, "state": state}


static func _roll_attribute(rng: RandomNumberGenerator) -> int:
	# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
	# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
	# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
	# twenty hours.
	var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
	return maxi(8, roll)


static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
	var errors: Array = []

	for ref in world.unresolved_refs(origin):
		errors.append("unresolved ref: %s" % ref)

	if str(creation.get("name", "")).strip_edges() == "":
		errors.append("player name is required")

	var race_id: String = str(creation.get("race_id", ""))
	if not Races.exists(race_id):
		errors.append("unknown race: %s" % race_id)

	var calling_id: String = str(creation.get("calling_id", ""))
	if not Callings.exists(calling_id):
		errors.append("unknown calling: %s" % calling_id)
	else:
		var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
		if calling_id not in allowed:
			errors.append("calling not allowed by origin: %s" % calling_id)

	errors.append_array(_validate_spend(creation.get("spend", {})))
	if Races.exists(race_id) and Callings.exists(calling_id):
		errors.append_array(_validate_skills(creation, race_id, calling_id))

	return errors


static func _validate_spend(spend: Dictionary) -> Array:
	var errors: Array = []
	var total := 0
	for stat in spend:
		# LCK is not merely hidden at creation — it is unspendable (§7).
		if not Attributes.exists(str(stat)):
			errors.append("cannot spend on '%s' — not an attribute" % stat)
			continue
		var v := int(spend[stat])
		if v < 0:
			errors.append("spend is additive only: %s is negative" % stat)
		total += v
	if total > SPEND_POOL:
		errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
	return errors


static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
	var errors: Array = []
	var picks: Array = creation.get("skills", [])
	var granted: Array = Races.granted_skills(race_id)
	var pool: Array = Callings.skill_pool(calling_id)
	var want := Callings.skill_count(calling_id)

	if picks.size() != want:
		errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])

	var seen := {}
	for s in picks:
		if s in seen:
			errors.append("duplicate skill pick: %s" % s)
		seen[s] = true
		if s not in pool:
			errors.append("%s is not in the %s pool" % [s, calling_id])
		if s in granted:
			errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])

	var bonus := str(creation.get("bonus_skill", ""))
	if Races.wants_bonus_skill(race_id):
		if bonus == "":
			errors.append("%s must choose a bonus skill" % race_id)
		elif not Skills.exists(bonus):
			errors.append("unknown bonus skill: %s" % bonus)
		elif bonus in picks or bonus in granted:
			errors.append("bonus skill %s is already proficient" % bonus)
	elif bonus != "":
		errors.append("%s does not get a bonus skill" % race_id)

	return errors
  • Step 5: Run the tests to verify they pass
./run_tests.sh

Expected: all green.

  • Step 6: Commit
git add client/scripts/newgame/new_game.gd client/scripts/state/game_state.gd \
        client/tests/unit/test_new_game.gd client/tests/unit/test_game_state.gd \
        client/tests/unit/test_round_trip.gd
git commit -m "feat(newgame): the seeded creation pipeline

construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the
attributes itself, so it never trusts a number handed to it (§2) and the same seed
yields an identical character, hidden Luck included (§10). The whole character is
reproducible from four primitives: seed, race_id, calling_id, spend. The rng
parameter is dropped — the seed is now the only source of randomness.

The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3
pool cannot rescue, and a character bad at the one thing he is FOR is not grit —
it is the fine §7 forbids, paid for twenty hours.

LCK is unspendable, not merely hidden: spending on it is a validation error.
GameState.stats becomes GameState.sheet."

Task 6: Final verification

Files: none — this task only runs things.

  • Step 1: The client suite
cd client && ./run_tests.sh

Expected: green, zero failures. Paste the summary line into the report — do not claim green without it.

  • Step 2: The API suite
cd api && venv/bin/pytest

Expected: green.

  • Step 3: The content build
PYTHONPATH=tools python3 -m content_build --check

Expected: exit 0.

  • Step 4: The generated tree is untouched
git diff --stat dev...HEAD -- content/world content/server

Expected: only the eleven new files under content/world/callings/ and content/world/races/. Any other path under content/world/ or content/server/ means the generated tree drifted and must be explained before merging.

  • Step 5: The dead words are gone
grep -rn "class_id\|allowed_classes\|\"assassin\"\|\"priest\"" client api docs/schemas content --include=*.gd --include=*.py --include=*.json

Expected: no matches. (Prose mentions of "priest" in content/lore/*.md or the charter are fine — this greps code, schemas, and content JSON only.)

  • Step 6: Report, and stop

Report the results. Do not merge. §18: feat/creation-model merges into dev with --no-ff only after the human confirms, and master is never touched.


Self-Review

Spec coverage:

Spec section Task
§3 rules in code, blurbs in content 1 (tables), 2 (blurbs + ContentDB + parity)
§4 the three tables (skills, races, callings) 1
§5 the sheet — store inputs, derive the rest 3
§5 LCK nowhere on the sheet 3 (asserted), 5 (unspendable)
§5 the two seams (AC = 10+DEX, max_mp TUNABLE) 3
§6 creation carries a seed; construct re-derives 5
§6 order of operations (attrs then LCK) 5
§6 3d6 floor 8 5
§6 validation (spend, skills, bonus_skill, origin gate) 5
§7 the migrations (both schemas, LogPlayer, origin, api prompts) 4
§7 schema/code parity test 4
§8 testing every task, tests first
§9 out of scope (screen, flow, MP curve, armor, talents, blurb prose) not built; stated in Global Constraints

Placeholder scan: none. max_mp()'s TUNABLE is a deliberate, spec-mandated placeholder with a concrete implementation and a test that asserts only its shape — not a plan gap.

Type consistency: race_id, calling_id, seed, spend, skills, bonus_skill, sheet, attributes, is_proficient, max_hp, max_mp, save, poison_save_bonus, skill_bonus, spell_dc, Callings.IDS, Races.IDS, Attributes.IDS, Skills.exists are spelled identically in every task that defines or consumes them. construct drops rng in Task 5 and every call site in that task is updated to the three-argument form.