diff --git a/docs/superpowers/plans/2026-07-12-creation-model.md b/docs/superpowers/plans/2026-07-12-creation-model.md new file mode 100644 index 0000000..7549165 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-creation-model.md @@ -0,0 +1,1707 @@ +# 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 block** — `construct` 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 8** — `maxi(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((score−10)/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_id` → `calling_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_classes` → `allowed_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`** + +```bash +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. + +```gdscript +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/`: + +```bash +./run_tests.sh +``` + +Expected: FAIL — the parser cannot resolve `Attributes` / `Skills` / `Races` / `Callings`. + +- [ ] **Step 3: Write `attributes.gd`** + +```gdscript +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`** + +```gdscript +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`** + +```gdscript +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`** + +```gdscript +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** + +```bash +./run_tests.sh +``` + +Expected: all green (188 prior + 14 new). + +- [ ] **Step 8: Commit** + +```bash +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`: + +```gdscript +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** + +```bash +./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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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`: +```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: + +```gdscript +var items: Dictionary = {} +var callings: Dictionary = {} +var races: Dictionary = {} +``` + +In `load_from`, add the two `_load_dir` calls beside the others: + +```gdscript + 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: + +```gdscript +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** + +```bash +./run_tests.sh +``` +Expected: green. + +From the repo root: +```bash +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** + +```bash +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`: + +```gdscript +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** + +```bash +./run_tests.sh +``` + +Expected: FAIL — `character_sheet.gd` does not exist. + +- [ ] **Step 3: Write the implementation** + +Create `client/scripts/state/character_sheet.gd`: + +```gdscript +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** + +```bash +./run_tests.sh +``` + +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +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** + +```bash +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`. + +```gdscript +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`): + +```gdscript +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`: + +```python + 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: + +```python +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** + +```bash +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`: + +```json +"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`: + +```json +"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: + +```json + "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: + +```gdscript +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`: + +```gdscript + # 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: + +```gdscript + 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: + +```python +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: + +```python + lines.append(f"Player: {_describe_player(player)}") +``` + +and line 107 becomes: + +```python + 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: + +```json +{"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** + +```bash +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: + +```bash +grep -rn "class_id\|allowed_classes" client api docs content --include=*.gd --include=*.py --include=*.json +``` +Expected: **no matches.** + +- [ ] **Step 12: Commit** + +```bash +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) -> Dictionary` — **the `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: + +```gdscript +{ + "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): + +```gdscript +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: + +```gdscript +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: + +```gdscript +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** + +```bash +./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: + +```gdscript +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`: + +```gdscript +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** + +```bash +./run_tests.sh +``` + +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +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** + +```bash +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** + +```bash +cd api && venv/bin/pytest +``` +Expected: green. + +- [ ] **Step 3: The content build** + +```bash +PYTHONPATH=tools python3 -m content_build --check +``` +Expected: exit 0. + +- [ ] **Step 4: The generated tree is untouched** + +```bash +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** + +```bash +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.