From 78e18107b23e1e6f7e3ccea9bd8111f76bc2073b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:14:44 -0500 Subject: [PATCH 1/9] feat(rules): the race, calling, skill and attribute tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/rules/attributes.gd | 15 ++++ client/scripts/rules/attributes.gd.uid | 1 + client/scripts/rules/callings.gd | 93 +++++++++++++++++++++ client/scripts/rules/callings.gd.uid | 1 + client/scripts/rules/races.gd | 60 +++++++++++++ client/scripts/rules/races.gd.uid | 1 + client/scripts/rules/skills.gd | 28 +++++++ client/scripts/rules/skills.gd.uid | 1 + client/tests/unit/test_rules_tables.gd | 97 ++++++++++++++++++++++ client/tests/unit/test_rules_tables.gd.uid | 1 + 10 files changed, 298 insertions(+) create mode 100644 client/scripts/rules/attributes.gd create mode 100644 client/scripts/rules/attributes.gd.uid create mode 100644 client/scripts/rules/callings.gd create mode 100644 client/scripts/rules/callings.gd.uid create mode 100644 client/scripts/rules/races.gd create mode 100644 client/scripts/rules/races.gd.uid create mode 100644 client/scripts/rules/skills.gd create mode 100644 client/scripts/rules/skills.gd.uid create mode 100644 client/tests/unit/test_rules_tables.gd create mode 100644 client/tests/unit/test_rules_tables.gd.uid diff --git a/client/scripts/rules/attributes.gd b/client/scripts/rules/attributes.gd new file mode 100644 index 0000000..64b4001 --- /dev/null +++ b/client/scripts/rules/attributes.gd @@ -0,0 +1,15 @@ +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) diff --git a/client/scripts/rules/attributes.gd.uid b/client/scripts/rules/attributes.gd.uid new file mode 100644 index 0000000..881146b --- /dev/null +++ b/client/scripts/rules/attributes.gd.uid @@ -0,0 +1 @@ +uid://dnlwajfs3vysm diff --git a/client/scripts/rules/callings.gd b/client/scripts/rules/callings.gd new file mode 100644 index 0000000..cb1b863 --- /dev/null +++ b/client/scripts/rules/callings.gd @@ -0,0 +1,93 @@ +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", "")) diff --git a/client/scripts/rules/callings.gd.uid b/client/scripts/rules/callings.gd.uid new file mode 100644 index 0000000..dba102c --- /dev/null +++ b/client/scripts/rules/callings.gd.uid @@ -0,0 +1 @@ +uid://cewlixhavh1h4 diff --git a/client/scripts/rules/races.gd b/client/scripts/rules/races.gd new file mode 100644 index 0000000..aa7e702 --- /dev/null +++ b/client/scripts/rules/races.gd @@ -0,0 +1,60 @@ +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)) diff --git a/client/scripts/rules/races.gd.uid b/client/scripts/rules/races.gd.uid new file mode 100644 index 0000000..743d943 --- /dev/null +++ b/client/scripts/rules/races.gd.uid @@ -0,0 +1 @@ +uid://bmkoxq4eabfcq diff --git a/client/scripts/rules/skills.gd b/client/scripts/rules/skills.gd new file mode 100644 index 0000000..a318305 --- /dev/null +++ b/client/scripts/rules/skills.gd @@ -0,0 +1,28 @@ +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, "")) diff --git a/client/scripts/rules/skills.gd.uid b/client/scripts/rules/skills.gd.uid new file mode 100644 index 0000000..c5bc53a --- /dev/null +++ b/client/scripts/rules/skills.gd.uid @@ -0,0 +1 @@ +uid://d2pvtvrrhc27b diff --git a/client/tests/unit/test_rules_tables.gd b/client/tests/unit/test_rules_tables.gd new file mode 100644 index 0000000..e6a5468 --- /dev/null +++ b/client/tests/unit/test_rules_tables.gd @@ -0,0 +1,97 @@ +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) diff --git a/client/tests/unit/test_rules_tables.gd.uid b/client/tests/unit/test_rules_tables.gd.uid new file mode 100644 index 0000000..d8c3f77 --- /dev/null +++ b/client/tests/unit/test_rules_tables.gd.uid @@ -0,0 +1 @@ +uid://bjld6ctq8tqnk From a7e153884f7e8b35d3310613e71ba9af38343a9e Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:18:19 -0500 Subject: [PATCH 2/9] feat(content): calling + race blurbs, loaded by ContentDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/content/content_db.gd | 8 ++++++ client/tests/unit/test_content_db.gd | 34 ++++++++++++++++++++++++++ content/world/callings/bloodsworn.json | 1 + content/world/callings/bonesetter.json | 1 + content/world/callings/cutpurse.json | 1 + content/world/callings/hedge_mage.json | 1 + content/world/callings/reaver.json | 1 + content/world/callings/sellsword.json | 1 + content/world/callings/trapper.json | 1 + content/world/races/beastfolk.json | 1 + content/world/races/dwarf.json | 1 + content/world/races/elf.json | 1 + content/world/races/human.json | 1 + 13 files changed, 53 insertions(+) create mode 100644 content/world/callings/bloodsworn.json create mode 100644 content/world/callings/bonesetter.json create mode 100644 content/world/callings/cutpurse.json create mode 100644 content/world/callings/hedge_mage.json create mode 100644 content/world/callings/reaver.json create mode 100644 content/world/callings/sellsword.json create mode 100644 content/world/callings/trapper.json create mode 100644 content/world/races/beastfolk.json create mode 100644 content/world/races/dwarf.json create mode 100644 content/world/races/elf.json create mode 100644 content/world/races/human.json diff --git a/client/scripts/content/content_db.gd b/client/scripts/content/content_db.gd index 6752e4c..732281c 100644 --- a/client/scripts/content/content_db.gd +++ b/client/scripts/content/content_db.gd @@ -8,6 +8,8 @@ var locations: Dictionary = {} var npcs: Dictionary = {} var quests: Dictionary = {} var items: Dictionary = {} +var callings: Dictionary = {} +var races: Dictionary = {} var canon_entities: Dictionary = {} var topics: Dictionary = {} @@ -31,6 +33,8 @@ func load_from(content_root: String) -> void: npcs = _load_dir(world.path_join("npcs")) quests = _load_dir(world.path_join("quests")) items = _load_dir(world.path_join("items")) + callings = _load_dir(world.path_join("callings")) + races = _load_dir(world.path_join("races")) canon_entities = _load_dir(world.path_join("canon")) topics = _load_dir(world.path_join("topics")) @@ -61,6 +65,10 @@ func has_location(id: String) -> bool: return locations.has(id) func has_npc(id: String) -> bool: return npcs.has(id) func has_quest(id: String) -> bool: return quests.has(id) func has_item(id: String) -> bool: return items.has(id) +func 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) func canon(id: String) -> Dictionary: return canon_entities.get(id, {}) func topic(id: String) -> Dictionary: return topics.get(id, {}) func has_canon(id: String) -> bool: return canon_entities.has(id) diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index a7955b3..c6a1963 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -91,3 +91,37 @@ func test_no_loaded_topic_has_a_body(): func test_legacy_npcs_still_load(): assert_true(db.has_npc("fenn")) + + +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]) diff --git a/content/world/callings/bloodsworn.json b/content/world/callings/bloodsworn.json new file mode 100644 index 0000000..ff30160 --- /dev/null +++ b/content/world/callings/bloodsworn.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json new file mode 100644 index 0000000..0ad877f --- /dev/null +++ b/content/world/callings/bonesetter.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/callings/cutpurse.json b/content/world/callings/cutpurse.json new file mode 100644 index 0000000..697a3f6 --- /dev/null +++ b/content/world/callings/cutpurse.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/callings/hedge_mage.json b/content/world/callings/hedge_mage.json new file mode 100644 index 0000000..26ff24b --- /dev/null +++ b/content/world/callings/hedge_mage.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/callings/reaver.json b/content/world/callings/reaver.json new file mode 100644 index 0000000..24b3fc2 --- /dev/null +++ b/content/world/callings/reaver.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/callings/sellsword.json b/content/world/callings/sellsword.json new file mode 100644 index 0000000..2aff2e5 --- /dev/null +++ b/content/world/callings/sellsword.json @@ -0,0 +1 @@ +{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." } diff --git a/content/world/callings/trapper.json b/content/world/callings/trapper.json new file mode 100644 index 0000000..3031d0a --- /dev/null +++ b/content/world/callings/trapper.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/races/beastfolk.json b/content/world/races/beastfolk.json new file mode 100644 index 0000000..4dde59a --- /dev/null +++ b/content/world/races/beastfolk.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/races/dwarf.json b/content/world/races/dwarf.json new file mode 100644 index 0000000..1a5bc4b --- /dev/null +++ b/content/world/races/dwarf.json @@ -0,0 +1 @@ +{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." } diff --git a/content/world/races/elf.json b/content/world/races/elf.json new file mode 100644 index 0000000..6755cb9 --- /dev/null +++ b/content/world/races/elf.json @@ -0,0 +1 @@ +{ "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." } diff --git a/content/world/races/human.json b/content/world/races/human.json new file mode 100644 index 0000000..373054e --- /dev/null +++ b/content/world/races/human.json @@ -0,0 +1 @@ +{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." } From bb5d1762626a3f3c4a6192c13cf0c8224adde09c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:21:45 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(state):=20CharacterSheet=20=E2=80=94?= =?UTF-8?q?=20store=20the=20inputs,=20derive=20the=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/state/character_sheet.gd | 94 +++++++++++++++++++ client/scripts/state/character_sheet.gd.uid | 1 + client/tests/unit/test_character_sheet.gd | 91 ++++++++++++++++++ client/tests/unit/test_character_sheet.gd.uid | 1 + 4 files changed, 187 insertions(+) create mode 100644 client/scripts/state/character_sheet.gd create mode 100644 client/scripts/state/character_sheet.gd.uid create mode 100644 client/tests/unit/test_character_sheet.gd create mode 100644 client/tests/unit/test_character_sheet.gd.uid diff --git a/client/scripts/state/character_sheet.gd b/client/scripts/state/character_sheet.gd new file mode 100644 index 0000000..eebcbf0 --- /dev/null +++ b/client/scripts/state/character_sheet.gd @@ -0,0 +1,94 @@ +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") diff --git a/client/scripts/state/character_sheet.gd.uid b/client/scripts/state/character_sheet.gd.uid new file mode 100644 index 0000000..9a628db --- /dev/null +++ b/client/scripts/state/character_sheet.gd.uid @@ -0,0 +1 @@ +uid://mofimtmsxuui diff --git a/client/tests/unit/test_character_sheet.gd b/client/tests/unit/test_character_sheet.gd new file mode 100644 index 0000000..dc2993c --- /dev/null +++ b/client/tests/unit/test_character_sheet.gd @@ -0,0 +1,91 @@ +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") diff --git a/client/tests/unit/test_character_sheet.gd.uid b/client/tests/unit/test_character_sheet.gd.uid new file mode 100644 index 0000000..96a9701 --- /dev/null +++ b/client/tests/unit/test_character_sheet.gd.uid @@ -0,0 +1 @@ +uid://dgi5hu3y2fg2u From a668cf98ebad282491d07c3517b5b2f2569bc90b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:27:56 -0500 Subject: [PATCH 4/9] refactor(contract)!: class_id -> calling_id, and the log gains race_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- api/app/prompts.py | 21 ++++++++++- api/tests/fixtures/canon_log_valid.json | 3 +- api/tests/test_live_smoke.py | 3 +- api/tests/test_npc.py | 2 +- api/tests/test_origin_schema.py | 4 +- api/tests/test_prompts.py | 26 ++++++++++++- client/scripts/canon_log/log_player.gd | 41 ++++++++++++++------- client/scripts/newgame/new_game.gd | 17 ++++++--- client/tests/unit/test_entities.gd | 24 +++++++----- client/tests/unit/test_new_game.gd | 26 ++++++------- client/tests/unit/test_round_trip.gd | 7 ++-- client/tests/unit/test_schema_parity.gd | 37 +++++++++++++++++++ client/tests/unit/test_schema_parity.gd.uid | 1 + content/origins/deserter.json | 3 +- docs/schemas/canon-log.schema.json | 8 +++- docs/schemas/origin.schema.json | 9 +++-- 16 files changed, 171 insertions(+), 61 deletions(-) create mode 100644 client/tests/unit/test_schema_parity.gd create mode 100644 client/tests/unit/test_schema_parity.gd.uid diff --git a/api/app/prompts.py b/api/app/prompts.py index 094f3ab..9225f70 100644 --- a/api/app/prompts.py +++ b/api/app/prompts.py @@ -12,6 +12,23 @@ from pathlib import Path _PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts" +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() + + @lru_cache(maxsize=None) def system_prompt(role: str) -> str: text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8") @@ -50,7 +67,7 @@ def render_digest(canon_log: dict) -> str: descriptor = player.get("luck_descriptor") if descriptor: lines.append(f"Fortune: {descriptor}") - lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}") + lines.append(f"Player: {_describe_player(player)}") lines.append("") lines.append("Describe the scene as it is now.") @@ -104,7 +121,7 @@ def render_npc_digest( lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}") player = canon_log.get("player", {}) - lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.") + lines.append(f"The player is {_describe_player(player)}.") lines.append("") lines.append(f'The player says to you: "{utterance}"') diff --git a/api/tests/fixtures/canon_log_valid.json b/api/tests/fixtures/canon_log_valid.json index b0587c7..a4e8996 100644 --- a/api/tests/fixtures/canon_log_valid.json +++ b/api/tests/fixtures/canon_log_valid.json @@ -2,7 +2,8 @@ "schema_version": 1, "player": { "name": "Aldric", - "class_id": "sellsword", + "race_id": "human", + "calling_id": "sellsword", "luck_descriptor": "Fortune spits on you" }, "location": { "id": "greywater_docks", "name": "the Greywater docks" }, diff --git a/api/tests/test_live_smoke.py b/api/tests/test_live_smoke.py index 887741b..2b4a681 100644 --- a/api/tests/test_live_smoke.py +++ b/api/tests/test_live_smoke.py @@ -30,7 +30,8 @@ def test_live_npc_speak_fenn(): log = { "player": { "name": "Aldric", - "class_id": "sellsword", + "race_id": "human", + "calling_id": "sellsword", "luck_descriptor": "the dice are kind", }, "location": {"id": "greywater_docks", "name": "Greywater Docks"}, diff --git a/api/tests/test_npc.py b/api/tests/test_npc.py index c4d4893..a547482 100644 --- a/api/tests/test_npc.py +++ b/api/tests/test_npc.py @@ -4,7 +4,7 @@ import app.npc as npc from app.ollama_client import ModelError LOG = { - "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"}, + "player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"}, "location": {"id": "greywater_docks", "name": "Greywater Docks"}, "party": [], "recent_events": [], "established_facts": [], "active_quests": [], "humiliations": [], diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py index 036b58e..e6f9025 100644 --- a/api/tests/test_origin_schema.py +++ b/api/tests/test_origin_schema.py @@ -14,7 +14,7 @@ VALID_ORIGIN = { "inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}], "start_quest_id": "find_the_ledger", "build_constraints": { - "allowed_classes": ["sellsword", "assassin", "priest"], + "allowed_callings": ["sellsword", "cutpurse", "bonesetter"], "luck_modifier": 0, }, } @@ -32,7 +32,7 @@ def test_null_start_quest_is_allowed(): def test_unknown_class_is_rejected(): doc = copy.deepcopy(VALID_ORIGIN) - doc["build_constraints"]["allowed_classes"] = ["bard"] + doc["build_constraints"]["allowed_callings"] = ["bard"] assert validate_origin(doc) != [] diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py index d1fceb1..4831c56 100644 --- a/api/tests/test_prompts.py +++ b/api/tests/test_prompts.py @@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context(): assert "Companions present: Brannoc Thane, Cadwyn Vell" in d assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d assert "Fortune: Fortune spits on you" in d - assert "Player: Aldric, a sellsword" in d + assert "Player: Aldric, a human sellsword" in d assert d.rstrip().endswith("Describe the scene as it is now.") @@ -42,10 +42,32 @@ def test_narrator_body_is_authored(): assert "second person" in body # the core task is present +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 + + def test_render_npc_digest_includes_all_sections(): from app import prompts log = { - "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"}, + "player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"}, "location": {"id": "greywater_docks", "name": "Greywater Docks"}, "party": [], "recent_events": ["a gull screamed"], "established_facts": ["the eastern bridge is out"], diff --git a/client/scripts/canon_log/log_player.gd b/client/scripts/canon_log/log_player.gd index d0620df..d0a268c 100644 --- a/client/scripts/canon_log/log_player.gd +++ b/client/scripts/canon_log/log_player.gd @@ -1,33 +1,46 @@ class_name LogPlayer extends RefCounted -## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7): -## no numeric Luck, no stats — those live in GameState and never reach the AI. - -const CLASSES := ["sellsword", "assassin", "priest"] +## 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 class_id: String +var race_id: String +var calling_id: String var luck_descriptor: String -func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void: +func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void: name = p_name luck_descriptor = p_luck_descriptor - if p_class_id != "": - set_class_id(p_class_id) + if p_race_id != "": + set_race_id(p_race_id) + if p_calling_id != "": + set_calling_id(p_calling_id) -func set_class_id(v: String) -> bool: - if v not in CLASSES: - push_error("invalid class_id: %s" % v) +func set_race_id(v: String) -> bool: + if not Races.exists(v): + push_error("invalid race_id: %s" % v) return false - class_id = v + 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, "class_id": class_id, "luck_descriptor": luck_descriptor} + 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("class_id", ""), d.get("luck_descriptor", "")) + return LogPlayer.new(d.get("name", ""), d.get("race_id", ""), + d.get("calling_id", ""), d.get("luck_descriptor", "")) diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 3f52e46..a07e219 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -12,12 +12,16 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary for ref in world.unresolved_refs(origin): errors.append("unresolved ref: %s" % ref) - # 2. build constraints — chosen class must be allowed by the origin. + # 2. build constraints — chosen calling must be allowed by the origin. var bc: Dictionary = origin.get("build_constraints", {}) - var allowed: Array = bc.get("allowed_classes", []) - var class_id: String = creation.get("class_id", "") - if class_id not in allowed: - errors.append("class not allowed by origin: %s" % class_id) + 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) if creation.get("name", "").strip_edges() == "": errors.append("player name is required") @@ -36,7 +40,8 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary # 5. assemble the canon log. var log := CanonLog.new() - log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor()) + log.player = LogPlayer.new(creation.get("name", ""), race_id, calling_id, + state.luck_descriptor()) var loc: Dictionary = world.location(origin["start_location_id"]) log.set_location(loc.get("id", ""), loc.get("name", "")) diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd index e18fa1a..c405c39 100644 --- a/client/tests/unit/test_entities.gd +++ b/client/tests/unit/test_entities.gd @@ -14,18 +14,22 @@ func test_ids_regex(): assert_false(Ids.is_valid("has-hyphen")) -func test_player_dict_has_only_three_keys(): - var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") - var d = p.to_dict() - assert_eq(d.keys().size(), 3) - assert_false("luck" in d) - assert_eq(d["class_id"], "sellsword") +func test_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_player_rejects_unknown_class(): - var p = LogPlayer.new("Aldric", "sellsword", "x") - assert_false(p.set_class_id("bard")) - assert_eq(p.class_id, "sellsword") +func test_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")) func test_party_member_clamps_disposition(): diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 18049f4..dcef883 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -26,20 +26,20 @@ func _build(creation: Dictionary) -> Dictionary: func test_construct_succeeds(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_true(res["ok"], str(res["errors"])) func test_numeric_luck_absent_from_log(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) var pd: Dictionary = res["log"].player.to_dict() assert_false("luck" in pd) - assert_eq(pd.keys().size(), 3) + assert_eq(pd.keys().size(), 4) assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX) func test_inventory_and_dispo_land_in_state_not_log(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin") assert_false("copper" in res["state"].inventory, "money is never an inventory row") @@ -47,20 +47,20 @@ func test_inventory_and_dispo_land_in_state_not_log(): func test_companion_dispositions_from_overrides(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_eq(res["log"].party_member("brannoc_thane").disposition, 40) assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15) func test_situation_and_facts_seeded(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_eq(res["log"].recent_events.size(), 2) assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts) assert_eq(res["log"].humiliations.size(), 0) func test_active_quest_resolved_from_world(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_eq(res["log"].active_quests.size(), 1) assert_eq(res["log"].active_quests[0].id, "find_the_ledger") assert_eq(res["log"].active_quests[0].status, "active") @@ -69,12 +69,12 @@ func test_active_quest_resolved_from_world(): func test_null_start_quest_yields_no_quest(): var o := _deserter() o["start_quest_id"] = null - var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) assert_eq(res["log"].active_quests.size(), 0) func test_class_not_allowed_is_rejected(): - var res := _build({"name": "X", "class_id": "berserker"}) + var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"}) assert_false(res["ok"]) assert_true(res["errors"].size() > 0) assert_null(res["log"]) @@ -83,18 +83,18 @@ func test_class_not_allowed_is_rejected(): func test_unresolved_origin_is_rejected(): var o := _deserter() o["start_location_id"] = "nowhere" - var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) assert_false(res["ok"]) assert_true("unresolved ref: location:nowhere" in res["errors"]) func test_empty_name_is_rejected(): - var missing_name := _build({"class_id": "sellsword"}) + var missing_name := _build({"race_id": "human", "calling_id": "sellsword"}) assert_false(missing_name["ok"]) assert_true(missing_name["errors"].size() > 0) assert_null(missing_name["log"]) - var whitespace_name := _build({"name": " ", "class_id": "sellsword"}) + var whitespace_name := _build({"name": " ", "race_id": "human", "calling_id": "sellsword"}) assert_false(whitespace_name["ok"]) assert_true(whitespace_name["errors"].size() > 0) assert_null(whitespace_name["log"]) @@ -104,7 +104,7 @@ func test_non_companion_override_goes_to_state_not_log(): world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"} var o := _deserter() o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25} - var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) assert_true(res["ok"], str(res["errors"])) assert_eq(res["state"].npc_dispositions["oda_fenn"], -25) assert_null(res["log"].party_member("oda_fenn")) diff --git a/client/tests/unit/test_round_trip.gd b/client/tests/unit/test_round_trip.gd index 3fb1e3c..5108147 100644 --- a/client/tests/unit/test_round_trip.gd +++ b/client/tests/unit/test_round_trip.gd @@ -11,7 +11,7 @@ func _built_log(): var rng := RandomNumberGenerator.new() rng.seed = 3 var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter")) - var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng) + var res := NewGame.construct(origin, world, {"name": "Aldric", "race_id": "human", "calling_id": "bonesetter"}, rng) assert_true(res["ok"], str(res["errors"])) return res["log"] @@ -28,9 +28,10 @@ func test_constructed_log_satisfies_contract_invariants(): var d: Dictionary = _built_log().to_dict() assert_eq(d["schema_version"], 1) assert_true(d["recent_events"].size() <= 5) - assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor + assert_eq(d["player"].keys().size(), 4) # exactly name/race_id/calling_id/luck_descriptor assert_false("luck" in d["player"]) # §7: no numeric luck - assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"]) + assert_true(d["player"]["calling_id"] in Callings.IDS) + assert_true(d["player"]["race_id"] in Races.IDS) for m in d["party"]: assert_true(m["disposition"] >= -100 and m["disposition"] <= 100) for q in d["active_quests"]: diff --git a/client/tests/unit/test_schema_parity.gd b/client/tests/unit/test_schema_parity.gd new file mode 100644 index 0000000..42f8512 --- /dev/null +++ b/client/tests/unit/test_schema_parity.gd @@ -0,0 +1,37 @@ +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") diff --git a/client/tests/unit/test_schema_parity.gd.uid b/client/tests/unit/test_schema_parity.gd.uid new file mode 100644 index 0000000..ec2cb5a --- /dev/null +++ b/client/tests/unit/test_schema_parity.gd.uid @@ -0,0 +1 @@ +uid://dond21vwh46rt diff --git a/content/origins/deserter.json b/content/origins/deserter.json index f68cc1a..03add65 100644 --- a/content/origins/deserter.json +++ b/content/origins/deserter.json @@ -19,7 +19,8 @@ ], "start_quest_id": "find_the_ledger", "build_constraints": { - "allowed_classes": ["sellsword", "assassin", "priest"], + "allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper", + "hedge_mage", "bonesetter", "bloodsworn"], "luck_modifier": 0 } } diff --git a/docs/schemas/canon-log.schema.json b/docs/schemas/canon-log.schema.json index 9853159..8ecf4a5 100644 --- a/docs/schemas/canon-log.schema.json +++ b/docs/schemas/canon-log.schema.json @@ -13,10 +13,14 @@ "player": { "type": "object", "additionalProperties": false, - "required": ["name", "class_id", "luck_descriptor"], + "required": ["name", "race_id", "calling_id", "luck_descriptor"], "properties": { "name": { "type": "string", "minLength": 1 }, - "class_id": { "enum": ["sellsword", "assassin", "priest"] }, + "race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] }, + "calling_id": { + "enum": ["sellsword", "reaver", "cutpurse", "trapper", + "hedge_mage", "bonesetter", "bloodsworn"] + }, "luck_descriptor": { "type": "string", "minLength": 1 } } }, diff --git a/docs/schemas/origin.schema.json b/docs/schemas/origin.schema.json index 0485da9..7f6a445 100644 --- a/docs/schemas/origin.schema.json +++ b/docs/schemas/origin.schema.json @@ -39,12 +39,15 @@ "build_constraints": { "type": "object", "additionalProperties": false, - "required": ["allowed_classes", "luck_modifier"], + "required": ["allowed_callings", "luck_modifier"], "properties": { - "allowed_classes": { + "allowed_callings": { "type": "array", "minItems": 1, - "items": { "enum": ["sellsword", "assassin", "priest"] } + "items": { + "enum": ["sellsword", "reaver", "cutpurse", "trapper", + "hedge_mage", "bonesetter", "bloodsworn"] + } }, "luck_modifier": { "type": "integer" } } From 5149e817f6fb12dbe6a8b412581316e6ae27eee9 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:34:05 -0500 Subject: [PATCH 5/9] fix(contract): repair 3-arg LogPlayer callers + digest article bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd) still built LogPlayer with the OLD 3-arg shape (name, class_id, luck_descriptor) after LogPlayer.new() was migrated to the 4-arg (name, race_id, calling_id, luck_descriptor) shape. This was invisible to both grep and the test suite: every ctor param carries a default, so GDScript compiles a 3-arg call against a 4-arg signature without error — it just silently mis-binds positionally. "sellsword" landed in the race_id slot (rejected — not a race), the luck descriptor landed in the calling_id slot (rejected — not a calling), and luck_descriptor itself fell back to its default "". Every field in the emitted dict came out empty, which 422s against the canon-log schema's minLength/enum constraints. Grepping for class_id can't see this, because there is no class_id token left anywhere — the bug is purely positional. Also fixes prompts.py's _article(""), which returned "an" because Python's "" in "aeiou" is True, producing a doubled-space/wrong-article digest line when race_id is empty. _describe_player now builds its descriptor from whichever of race/calling are present and omits the clause entirely when both are absent. Adds a schema-parity guard for origin.schema.json's inlined allowed_callings enum, which duplicated the calling roster with no runtime check tying it to Callings.IDS. Renames leftover "class" vocabulary in test names/comments to "calling". Regression coverage: - client/tests/unit/test_entities.gd: ctor populates both race_id and calling_id; a calling passed into the race_id slot (the exact shape of the three broken call sites) yields an all-empty row. - api/tests/test_prompts.py: empty-race and empty-race-and-calling digest rendering, asserting no doubled space and no dangling article. - client/tests/unit/test_schema_parity.gd: origin.schema.json's allowed_callings enum matches Callings.IDS. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- api/app/prompts.py | 8 +++++- api/tests/test_origin_schema.py | 2 +- api/tests/test_prompts.py | 15 +++++++++++ client/scripts/harness/narrate_harness.gd | 2 +- client/scripts/harness/npc_harness.gd | 2 +- client/scripts/newgame/new_game.gd | 2 +- client/scripts/ui/shell/main_window_shell.gd | 2 +- client/tests/unit/test_entities.gd | 27 ++++++++++++++++++++ client/tests/unit/test_new_game.gd | 2 +- client/tests/unit/test_schema_parity.gd | 16 ++++++++++++ 10 files changed, 71 insertions(+), 7 deletions(-) diff --git a/api/app/prompts.py b/api/app/prompts.py index 9225f70..80f1580 100644 --- a/api/app/prompts.py +++ b/api/app/prompts.py @@ -18,6 +18,8 @@ def _humanize(id_: str) -> str: def _article(word: str) -> str: + if not word: + return "a" return "an" if word[:1].lower() in "aeiou" else "a" @@ -26,7 +28,11 @@ def _describe_player(player: dict) -> str: race = _humanize(player.get("race_id", "")) calling = _humanize(player.get("calling_id", "")) name = player.get("name", "") - return f"{name}, {_article(race)} {race} {calling}".strip() + parts = [p for p in (race, calling) if p] + if not parts: + return name + descriptor = " ".join(parts) + return f"{name}, {_article(parts[0])} {descriptor}".strip() @lru_cache(maxsize=None) diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py index e6f9025..1a947f1 100644 --- a/api/tests/test_origin_schema.py +++ b/api/tests/test_origin_schema.py @@ -30,7 +30,7 @@ def test_null_start_quest_is_allowed(): assert validate_origin(doc) == [] -def test_unknown_class_is_rejected(): +def test_unknown_calling_is_rejected(): doc = copy.deepcopy(VALID_ORIGIN) doc["build_constraints"]["allowed_callings"] = ["bard"] assert validate_origin(doc) != [] diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py index 4831c56..2cde12d 100644 --- a/api/tests/test_prompts.py +++ b/api/tests/test_prompts.py @@ -64,6 +64,21 @@ def test_digest_does_not_leak_a_snake_case_id(): assert "hedge-mage" in out +def test_digest_omits_race_clause_when_race_is_empty(): + # Regression: _article("") used to return "an" (Python's "" in "aeiou" is + # True), rendering "Player: Aldric, an " — wrong article, doubled space. + out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword")) + assert "Player: Aldric, a sellsword" in out + assert " " not in out + assert ", an " not in out + + +def test_digest_omits_both_clauses_when_race_and_calling_are_empty(): + out = render_digest(_with_player(name="Aldric", race_id="", calling_id="")) + assert "\nPlayer: Aldric\n" in out + assert " " not in out + + def test_render_npc_digest_includes_all_sections(): from app import prompts log = { diff --git a/client/scripts/harness/narrate_harness.gd b/client/scripts/harness/narrate_harness.gd index 0a6c862..aaccb91 100644 --- a/client/scripts/harness/narrate_harness.gd +++ b/client/scripts/harness/narrate_harness.gd @@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void: func _build_scene_log() -> CanonLog: var log := CanonLog.new() - log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") + log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you") log.set_location("greywater_docks", "the Greywater docks") log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) diff --git a/client/scripts/harness/npc_harness.gd b/client/scripts/harness/npc_harness.gd index 2166bd2..d71bfa6 100644 --- a/client/scripts/harness/npc_harness.gd +++ b/client/scripts/harness/npc_harness.gd @@ -39,7 +39,7 @@ func _ready() -> void: # Build the player through LogPlayer so luck_descriptor is non-empty — the # canon-log schema requires it (minLength 1), and it's the §7 fortune line # the digest surfaces to the NPC. An empty string here 422s the request. - _canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today") + _canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today") # Deliberately no find_the_ledger quest here — leave it un-started so Fenn # can offer_quest it during the conversation. diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index a07e219..7c40014 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -3,7 +3,7 @@ extends RefCounted ## New-game construction (spec "New-game construction"). Reads three immutable ## inputs (origin, world, creation) and writes two products (CanonLog, GameState). ## Nothing flows back up — charter §2 on the client. Validation is front-loaded: -## a broken origin or illegal class fails here, loudly, not three scenes later. +## a broken origin or illegal calling fails here, loudly, not three scenes later. static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary: var errors: Array = [] diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd index 1e0920c..a5d314b 100644 --- a/client/scripts/ui/shell/main_window_shell.gd +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -121,7 +121,7 @@ func _build_seed_log() -> CanonLog: # save/load (M9) construct the real one — mirrors narrate_harness. No Luck # number is rendered anywhere (§7); the descriptor is prose-only. var log := CanonLog.new() - log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you") + log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you") log.set_location("lower_ward", "the Lower Ward") log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd index c405c39..1f0f643 100644 --- a/client/tests/unit/test_entities.gd +++ b/client/tests/unit/test_entities.gd @@ -32,6 +32,33 @@ func test_log_player_rejects_a_dead_calling(): assert_true(p.set_race_id("dwarf")) +func test_log_player_ctor_populates_both_race_and_calling(): + # Regression: three call sites once passed the OLD 3-arg (name, class_id, + # luck_descriptor) shape against the NEW 4-arg (name, race_id, calling_id, + # luck_descriptor) constructor. Every param has a default, so GDScript + # compiled it silently — the string meant as a calling landed in the + # race_id slot, got rejected, and both fields came out "". This asserts + # a properly-shaped call yields BOTH fields non-empty. + var p := LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you") + var d := p.to_dict() + assert_ne(d["race_id"], "", "race_id must not be empty") + assert_ne(d["calling_id"], "", "calling_id must not be empty") + + +func test_log_player_ctor_rejects_calling_passed_as_race(): + # The exact shape of the bug: calling three broken call sites used to pass — + # LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor + # (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the + # race_id slot (rejected — not a race), the descriptor lands in the + # calling_id slot (rejected — not a calling), and luck_descriptor is left + # at its default. Every field ends up "", which 422s against the schema. + var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") + var d := p.to_dict() + assert_eq(d["race_id"], "", "a calling id is not a legal race_id") + assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id") + assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row") + + func test_party_member_clamps_disposition(): var m = PartyMember.new("brannoc_thane", "Brannoc Thane", 200) assert_eq(m.disposition, 100) diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index dcef883..27bd201 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -73,7 +73,7 @@ func test_null_start_quest_yields_no_quest(): assert_eq(res["log"].active_quests.size(), 0) -func test_class_not_allowed_is_rejected(): +func test_calling_not_allowed_is_rejected(): var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"}) assert_false(res["ok"]) assert_true(res["errors"].size() > 0) diff --git a/client/tests/unit/test_schema_parity.gd b/client/tests/unit/test_schema_parity.gd index 42f8512..547350d 100644 --- a/client/tests/unit/test_schema_parity.gd +++ b/client/tests/unit/test_schema_parity.gd @@ -35,3 +35,19 @@ func test_schema_requires_race_and_calling(): 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") + + +func test_origin_schema_allowed_callings_matches_the_code(): + # origin.schema.json's build_constraints.allowed_callings duplicates the + # same seven calling ids inline (a JSON Schema "enum" cannot $ref another + # file's enum), so nothing at runtime reconciles it with Callings.IDS + # either. This guards it the same way _player_schema() guards canon-log. + var path := ProjectSettings.globalize_path("res://") \ + .path_join("../docs/schemas/origin.schema.json").simplify_path() + var doc: Variant = ContentDB.load_json(path) + assert_eq(typeof(doc), TYPE_DICTIONARY, "origin schema did not parse") + var enum_ids: Array = doc["properties"]["build_constraints"]["properties"]["allowed_callings"]["items"]["enum"] + enum_ids.sort() + var code_ids: Array = Callings.IDS.duplicate() + code_ids.sort() + assert_eq(enum_ids, code_ids) From eba706b906509d3c78b14ee98dc6b975527cfb68 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:39:48 -0500 Subject: [PATCH 6/9] test: assert seed-log fields, not a fresh LogPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regression tests in test_entities.gd only constructed their own LogPlayer and never touched the seed logs the real call sites build — reverting main_window_shell.gd:124 to the broken 3-arg call still left the whole suite green. Not a real regression guard. Now assert against node._log.player in the shell test and against the two dev-harness scenes' seed logs (narrate_harness, npc_harness — both cheaply testable via instantiate + add_child_autofree, no scaffolding needed). Proved the new shell assertion fails when the call site is reverted to the 3-arg shape (226/227, one failure), then passes restored (227/227). Removed a redundant ctor test from test_entities.gd; kept the one that exercises the exact broken-shape call. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- .../tests/unit/test_dev_harness_seed_logs.gd | 25 +++++++++++++++++++ .../unit/test_dev_harness_seed_logs.gd.uid | 1 + client/tests/unit/test_entities.gd | 13 ---------- client/tests/unit/test_main_window_shell.gd | 12 +++++++++ 4 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 client/tests/unit/test_dev_harness_seed_logs.gd create mode 100644 client/tests/unit/test_dev_harness_seed_logs.gd.uid diff --git a/client/tests/unit/test_dev_harness_seed_logs.gd b/client/tests/unit/test_dev_harness_seed_logs.gd new file mode 100644 index 0000000..d8fb985 --- /dev/null +++ b/client/tests/unit/test_dev_harness_seed_logs.gd @@ -0,0 +1,25 @@ +extends "res://addons/gut/test.gd" +## Guards the two dev-only proving-scene seed logs (narrate_harness, +## npc_harness) against the same LogPlayer arg-slot regression the shell test +## guards (see test_main_window_shell.gd). Both scenes build their seed log's +## LogPlayer in _ready() with no network call until a button is pressed, so +## instantiate + add_child_autofree is cheap and touches no network. + +const NARRATE_SCENE := "res://scenes/narrate_harness.tscn" +const NPC_SCENE := "res://scenes/npc_harness.tscn" + + +func test_narrate_harness_seed_log_has_real_race_and_calling(): + var node = load(NARRATE_SCENE).instantiate() + add_child_autofree(node) + assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race") + assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling") + assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift") + + +func test_npc_harness_seed_log_has_real_race_and_calling(): + var node = load(NPC_SCENE).instantiate() + add_child_autofree(node) + assert_true(Races.exists(node._canon_log.player.race_id), "seed race must be a real race") + assert_true(Callings.exists(node._canon_log.player.calling_id), "seed calling must be a real calling") + assert_ne(node._canon_log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift") diff --git a/client/tests/unit/test_dev_harness_seed_logs.gd.uid b/client/tests/unit/test_dev_harness_seed_logs.gd.uid new file mode 100644 index 0000000..3adeb1a --- /dev/null +++ b/client/tests/unit/test_dev_harness_seed_logs.gd.uid @@ -0,0 +1 @@ +uid://bmaooitlx0ox4 diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd index 1f0f643..77e6347 100644 --- a/client/tests/unit/test_entities.gd +++ b/client/tests/unit/test_entities.gd @@ -32,19 +32,6 @@ func test_log_player_rejects_a_dead_calling(): assert_true(p.set_race_id("dwarf")) -func test_log_player_ctor_populates_both_race_and_calling(): - # Regression: three call sites once passed the OLD 3-arg (name, class_id, - # luck_descriptor) shape against the NEW 4-arg (name, race_id, calling_id, - # luck_descriptor) constructor. Every param has a default, so GDScript - # compiled it silently — the string meant as a calling landed in the - # race_id slot, got rejected, and both fields came out "". This asserts - # a properly-shaped call yields BOTH fields non-empty. - var p := LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you") - var d := p.to_dict() - assert_ne(d["race_id"], "", "race_id must not be empty") - assert_ne(d["calling_id"], "", "calling_id must not be empty") - - func test_log_player_ctor_rejects_calling_passed_as_race(): # The exact shape of the bug: calling three broken call sites used to pass — # LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd index 44413ab..13527d6 100644 --- a/client/tests/unit/test_main_window_shell.gd +++ b/client/tests/unit/test_main_window_shell.gd @@ -35,3 +35,15 @@ func test_shell_builds_a_code_owned_seed_log(): var node := _shell() assert_not_null(node._log, "a canon log exists to post (§11)") assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned") + + +func test_shell_seed_player_has_a_real_race_and_calling(): + # Regression: the shell's _build_seed_log() once called LogPlayer.new() with + # the OLD 3-arg (name, class_id, luck_descriptor) shape against the NEW + # 4-arg ctor. Every param defaults, so it compiled silently and both + # race_id/calling_id came out "" — which 422s against the canon-log schema. + # Assert against the log the shell actually builds, not a fresh LogPlayer. + var node := _shell() + assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race") + assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling") + assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift") From bd49e118d5c15bdb13bc248272342bdef904cd49 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:43:51 -0500 Subject: [PATCH 7/9] feat(newgame): the seeded creation pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/newgame/new_game.gd | 182 +++++++++++++++++++++------ client/scripts/state/game_state.gd | 2 +- client/tests/unit/test_game_state.gd | 6 + client/tests/unit/test_new_game.gd | 165 ++++++++++++++++++++---- client/tests/unit/test_round_trip.gd | 13 +- 5 files changed, 298 insertions(+), 70 deletions(-) diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 7c40014..19c3921 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -1,47 +1,66 @@ class_name NewGame extends RefCounted -## New-game construction (spec "New-game construction"). Reads three immutable -## inputs (origin, world, creation) and writes two products (CanonLog, GameState). -## Nothing flows back up — charter §2 on the client. Validation is front-loaded: -## a broken origin or illegal calling fails here, loudly, not three scenes later. +## 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. -static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary: - var errors: Array = [] +const SPEND_POOL := 3 - # 1. content integrity — every referenced id must resolve in world content. - for ref in world.unresolved_refs(origin): - errors.append("unresolved ref: %s" % ref) - - # 2. build constraints — chosen 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) - - if creation.get("name", "").strip_edges() == "": - errors.append("player name is required") +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} - # 3. roll Luck (numeric -> state only) and 4. the stat block (data only). + 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 - state.stats = { - "str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng), - "fth": _roll_stat(rng), "mag": _roll_stat(rng), - } - # 5. assemble the canon log. + var 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(creation.get("name", ""), race_id, calling_id, - state.luck_descriptor()) + 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", "")) @@ -51,7 +70,6 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary for c in world.companions(): companion_ids[c["id"]] = true log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0)))) - # non-companion overrides go to game state, never the log (spec §5 / charter §6). for npc_id in overrides: if not companion_ids.has(npc_id): state.set_npc_disposition(npc_id, int(overrides[npc_id])) @@ -64,16 +82,100 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary 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", ""))) + log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", + qd.get("objective", ""))) - # inventory grants -> game state (NOT the log). Charter §2. grant() routes - # currency to the purse; everything else to the inventory dict. - for grant in origin.get("inventory_grants", []): - state.grant(grant.get("item_id", ""), int(grant.get("qty", 0))) + # 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_stat(rng: RandomNumberGenerator) -> int: - # 3d6 placeholder; the stat block is data-only in this plan. TUNABLE. - return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6) +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 diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index e7e820d..d7077ce 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -6,7 +6,7 @@ extends RefCounted var luck: int = 0 var luck_base: int = 0 -var stats: Dictionary = {} # {str, dex, con, fth, mag} +var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame. var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100) var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper) var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec). diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd index d3bd06f..d385df1 100644 --- a/client/tests/unit/test_game_state.gd +++ b/client/tests/unit/test_game_state.gd @@ -3,6 +3,12 @@ extends "res://addons/gut/test.gd" const GameState = preload("res://scripts/state/game_state.gd") +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") + + func test_add_item_accumulates(): var s = GameState.new() s.add_item("worn_shortsword", 3) diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 27bd201..3a21e0f 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -15,31 +15,151 @@ func _deserter() -> Dictionary: return ContentDB.load_json(ContentDB.origin_path("deserter")) -func _rng() -> RandomNumberGenerator: - var r := RandomNumberGenerator.new() - r.seed = 7 - return r +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, _rng()) + return NewGame.construct(_deserter(), world, creation) -func test_construct_succeeds(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) +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_numeric_luck_absent_from_log(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) +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_false("luck" in pd) 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) func test_inventory_and_dispo_land_in_state_not_log(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin") assert_false("copper" in res["state"].inventory, "money is never an inventory row") @@ -47,20 +167,20 @@ func test_inventory_and_dispo_land_in_state_not_log(): func test_companion_dispositions_from_overrides(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].party_member("brannoc_thane").disposition, 40) assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15) func test_situation_and_facts_seeded(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].recent_events.size(), 2) assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts) assert_eq(res["log"].humiliations.size(), 0) func test_active_quest_resolved_from_world(): - var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) + var res := _build(_creation()) assert_eq(res["log"].active_quests.size(), 1) assert_eq(res["log"].active_quests[0].id, "find_the_ledger") assert_eq(res["log"].active_quests[0].status, "active") @@ -69,32 +189,25 @@ func test_active_quest_resolved_from_world(): func test_null_start_quest_yields_no_quest(): var o := _deserter() o["start_quest_id"] = null - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_eq(res["log"].active_quests.size(), 0) -func test_calling_not_allowed_is_rejected(): - var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"}) - assert_false(res["ok"]) - assert_true(res["errors"].size() > 0) - assert_null(res["log"]) - - func test_unresolved_origin_is_rejected(): var o := _deserter() o["start_location_id"] = "nowhere" - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_false(res["ok"]) assert_true("unresolved ref: location:nowhere" in res["errors"]) func test_empty_name_is_rejected(): - var missing_name := _build({"race_id": "human", "calling_id": "sellsword"}) + var missing_name := _build(_creation({"name": ""})) assert_false(missing_name["ok"]) assert_true(missing_name["errors"].size() > 0) assert_null(missing_name["log"]) - var whitespace_name := _build({"name": " ", "race_id": "human", "calling_id": "sellsword"}) + var whitespace_name := _build(_creation({"name": " "})) assert_false(whitespace_name["ok"]) assert_true(whitespace_name["errors"].size() > 0) assert_null(whitespace_name["log"]) @@ -104,7 +217,7 @@ func test_non_companion_override_goes_to_state_not_log(): world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"} var o := _deserter() o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25} - var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) + var res := NewGame.construct(o, world, _creation()) assert_true(res["ok"], str(res["errors"])) assert_eq(res["state"].npc_dispositions["oda_fenn"], -25) assert_null(res["log"].party_member("oda_fenn")) diff --git a/client/tests/unit/test_round_trip.gd b/client/tests/unit/test_round_trip.gd index 5108147..502653a 100644 --- a/client/tests/unit/test_round_trip.gd +++ b/client/tests/unit/test_round_trip.gd @@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd") func _built_log(): var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) - var rng := RandomNumberGenerator.new() - rng.seed = 3 var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter")) - var res := NewGame.construct(origin, world, {"name": "Aldric", "race_id": "human", "calling_id": "bonesetter"}, rng) + var creation := { + "name": "Aldric", + "race_id": "human", + "calling_id": "bonesetter", + "seed": 3, + "spend": {}, + "skills": ["faith_lore", "perception"], + "bonus_skill": "endurance", + } + var res := NewGame.construct(origin, world, creation) assert_true(res["ok"], str(res["errors"])) return res["log"] From 62b8113ffaee3818a67e008d83a94c7d19c06285 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:50:06 -0500 Subject: [PATCH 8/9] fix(creation): pin RNG stream, tighten spend/seed validation Finding 1 (important): test_same_seed_yields_an_identical_character only compared two construct() runs against each other, so swapping dex/con in Attributes.IDS still passed the whole suite while every persisted seed would silently become a different character. Added a golden-vector test that pins one fixed seed to its exact rolled attributes and hidden Luck, so reordering Attributes.IDS or moving the Luck roll fails loudly instead of silently rewriting every saved character. Proved it: swapped IDS, watched the golden vector fail, restored, confirmed green. Also: _validate_spend no longer lets a negative value mask the over-pool check (continue after each error instead of falling through to `total += v`); non-integer spend values (float/bool/string) are now rejected instead of coerced; a missing or non-integer seed is now a validation error instead of silently defaulting to 0. GameState's header comment no longer advertises "stats" it no longer holds. 243 -> 248 tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/newgame/new_game.gd | 7 ++++ client/scripts/state/game_state.gd | 5 ++- client/tests/unit/test_new_game.gd | 61 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 19c3921..8dbffd3 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -111,6 +111,9 @@ static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary if str(creation.get("name", "")).strip_edges() == "": errors.append("player name is required") + if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT: + errors.append("seed is required and must be an integer") + var race_id: String = str(creation.get("race_id", "")) if not Races.exists(race_id): errors.append("unknown race: %s" % race_id) @@ -138,9 +141,13 @@ static func _validate_spend(spend: Dictionary) -> Array: if not Attributes.exists(str(stat)): errors.append("cannot spend on '%s' — not an attribute" % stat) continue + if typeof(spend[stat]) != TYPE_INT: + errors.append("spend value for '%s' must be an integer" % stat) + continue var v := int(spend[stat]) if v < 0: errors.append("spend is additive only: %s is negative" % stat) + continue total += v if total > SPEND_POOL: errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total]) diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index d7077ce..ce6df82 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -1,8 +1,9 @@ class_name GameState extends RefCounted ## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck, -## stats, world-NPC dispositions, and inventory — none of which enter the canon -## log (charter §7/§2). The log reads only luck_descriptor() from here. +## the character sheet, world-NPC dispositions, and inventory — none of which +## enter the canon log (charter §7/§2). The log reads only luck_descriptor() +## from here. var luck: int = 0 var luck_base: int = 0 diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 3a21e0f..8fe8d83 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -43,6 +43,45 @@ func test_same_seed_yields_an_identical_character(): assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible") +func test_golden_vector_pins_the_rng_stream(): + # The determinism test above (`test_same_seed_yields_an_identical_character`) + # compares two `construct` runs against EACH OTHER. Both would drift together + # if the roll order changed, so it proves nothing about the ORDER itself. + # Swap two entries in `Attributes.IDS` and that test still passes while every + # already-persisted seed silently becomes a different character. + # + # This test pins one seed to its EXACT rolled values — all five attributes and + # the hidden Luck, drawn from the same stream immediately after — so reordering + # `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of + # silently rewriting every saved character. + var res := _build(_creation({"seed": 8675309})) + assert_true(res["ok"], str(res["errors"])) + var attrs: Dictionary = res["state"].sheet.attributes + assert_eq(int(attrs["str"]), 8) + assert_eq(int(attrs["dex"]), 13) + assert_eq(int(attrs["con"]), 8) + assert_eq(int(attrs["fth"]), 14) + assert_eq(int(attrs["mag"]), 8) + assert_eq(int(res["state"].luck), 6) + + +func test_a_missing_seed_is_rejected(): + # A seed silently defaulting to 0 builds a perfectly valid, deterministic + # character — which is exactly the problem: a saga that forgets the key + # gets no signal at all. Require it explicitly. + var creation := _creation() + creation.erase("seed") + var res := NewGame.construct(_deserter(), world, creation) + assert_false(res["ok"]) + assert_true("seed" in str(res["errors"]).to_lower()) + + +func test_a_non_integer_seed_is_rejected(): + var res := _build(_creation({"seed": "8675309"})) + assert_false(res["ok"]) + assert_true("seed" in str(res["errors"]).to_lower()) + + func test_a_different_seed_yields_a_different_character(): var a := _build(_creation()) var b := _build(_creation({"seed": 1234567})) @@ -81,6 +120,28 @@ func test_spend_cannot_be_negative(): assert_false(res["ok"], "additive only — there is no [-]") +func test_a_negative_spend_does_not_mask_an_over_pool_spend(): + # {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a + # `continue`/floor after the negative-value error, the over-pool check + # never independently fires; only the negative-value error does. + var res := _build(_creation({"spend": {"str": -1, "dex": 4}})) + assert_false(res["ok"]) + var joined := str(res["errors"]).to_lower() + assert_true("negative" in joined, "the negative-value error should fire") + assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire") + + +func test_spend_rejects_non_integer_values(): + var floats := _build(_creation({"spend": {"str": 2.9}})) + assert_false(floats["ok"], "a float must be rejected, not truncated to +2") + + var bools := _build(_creation({"spend": {"str": true}})) + assert_false(bools["ok"], "a bool must be rejected, not coerced to +1") + + var strings := _build(_creation({"spend": {"str": "abc"}})) + assert_false(strings["ok"], "a string must be rejected, not coerced to +0") + + func test_luck_can_never_be_spent(): # §7: LCK is untouchable at creation. Not hidden — impossible. var res := _build(_creation({"spend": {"lck": 3}})) From a9357fb787a772a88ded60810f7ad4988e7e8234 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:58:49 -0500 Subject: [PATCH 9/9] fix(creation-model): migrate stray class_id/allowed_classes copies The canon-log schema and NewGame code already migrated to race_id/ calling_id + allowed_callings, but two copies of the roster outside that schema were missed, and no parity test guards them: - docs/canon-log.md (the cross-boundary contract doc) still documented the OLD player shape and allowed_classes. Anyone building a player block from the doc would get a 422. - .claude/skills/world-building's schema reference still emitted allowed_classes with two dead calling ids (assassin, priest). Author a new origin with that skill and it fails origin.schema.json (additionalProperties: false, allowed_callings required) AND, if that ever loosened, silently allows zero callings at runtime. Also: - add schema tests rejecting a dead class_id field and an unknown calling_id (priest) - guard NewGame._validate's container types (spend/skills) so a malformed JSON round-trip (null spend, string skills) produces a front-loaded error list instead of a GDScript runtime crash - extend the no-mechanics-in-content test to db.races, not just db.callings - rewrite the roadmap's M4 bullet: the two contract migrations landed; only the creation screen and title->creation->shell flow remain client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- .../skills/world-building/references/schema.md | 2 +- api/tests/test_canon_log_schema.py | 17 +++++++++++++++++ client/scripts/newgame/new_game.gd | 15 +++++++++++++-- client/tests/unit/test_content_db.gd | 5 +++++ client/tests/unit/test_new_game.gd | 15 +++++++++++++++ docs/canon-log.md | 14 +++++++++----- docs/roadmap.md | 4 +++- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.claude/skills/world-building/references/schema.md b/.claude/skills/world-building/references/schema.md index 2ebea44..0bd49f7 100644 --- a/.claude/skills/world-building/references/schema.md +++ b/.claude/skills/world-building/references/schema.md @@ -252,7 +252,7 @@ construction fails loudly. "disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 }, "inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }], "start_quest_id": "find_the_ledger", - "build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 } + "build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 } } ``` diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py index c5b3908..24b248a 100644 --- a/api/tests/test_canon_log_schema.py +++ b/api/tests/test_canon_log_schema.py @@ -68,3 +68,20 @@ def test_negative_turn_is_rejected(): doc = _valid() doc["humiliations"][0]["turn"] = -1 assert validate_canon_log(doc) != [] + + +def test_dead_class_id_field_is_rejected(): + # The player block migrated from {name, class_id, luck_descriptor} to + # {name, race_id, calling_id, luck_descriptor}. class_id must not slip + # back in via additionalProperties: false. + doc = _valid() + doc["player"]["class_id"] = "sellsword" + assert validate_canon_log(doc) != [] + + +def test_unknown_calling_id_is_rejected(): + # assassin/priest are dead ids from the pre-migration class roster and + # must not validate as calling_id values. + doc = _valid() + doc["player"]["calling_id"] = "priest" + assert validate_canon_log(doc) != [] diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 8dbffd3..bc6f765 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -126,8 +126,19 @@ static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary 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): + # Guard container SHAPES before any statically-typed call below touches them. + # A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} — + # without this check, assigning that into a typed Dictionary/Array parameter + # throws a GDScript runtime error INSIDE construct() instead of landing in the + # front-loaded {"ok": false, "errors": [...]} the spec promises. + if typeof(creation.get("spend", {})) != TYPE_DICTIONARY: + errors.append("spend must be an object") + else: + errors.append_array(_validate_spend(creation.get("spend", {}))) + + if typeof(creation.get("skills", [])) != TYPE_ARRAY: + errors.append("skills must be an array") + elif Races.exists(race_id) and Callings.exists(calling_id): errors.append_array(_validate_skills(creation, race_id, calling_id)) return errors diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index c6a1963..f321786 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -125,3 +125,8 @@ func test_blurb_content_carries_no_mechanics(): 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]) + for id in db.races: + for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags", + "nightsight", "claws", "keen_scent"]: + assert_false(db.races[id].has(banned), + "%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned]) diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 8fe8d83..c0d0f98 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -274,6 +274,21 @@ func test_empty_name_is_rejected(): assert_null(whitespace_name["log"]) +func test_a_null_spend_is_rejected_not_crashed(): + # A JSON round-trip can hand back {"spend": null}. Without a container-type + # guard, assigning null into a statically-typed Dictionary parameter throws + # a GDScript runtime error INSIDE construct() instead of a front-loaded error. + var res := _build(_creation({"spend": null})) + assert_false(res["ok"]) + assert_true("spend" in str(res["errors"]).to_lower()) + + +func test_non_array_skills_is_rejected_not_crashed(): + var res := _build(_creation({"skills": "athletics"})) + assert_false(res["ok"]) + assert_true("skills" in str(res["errors"]).to_lower()) + + func test_non_companion_override_goes_to_state_not_log(): world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"} var o := _deserter() diff --git a/docs/canon-log.md b/docs/canon-log.md index b090df6..b7c99f6 100644 --- a/docs/canon-log.md +++ b/docs/canon-log.md @@ -27,7 +27,7 @@ Full field rules live in `canon-log.schema.json`. Summary: ```json { "schema_version": 1, - "player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" }, + "player": { "name": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "luck_descriptor": "…" }, "location": { "id": "…", "name": "…" }, "party": [ { "id": "…", "name": "…", "disposition": -100..100 } ], "recent_events": ["… (≤5, rolling)"], @@ -39,9 +39,13 @@ Full field rules live in `canon-log.schema.json`. Summary: **The §7 boundary (enforced structurally).** The schema sets `additionalProperties: false` on the root and on `player`, and `player` admits -only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and -inventory cannot be represented in the canon log** — the AI never sees a number -it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on +only `name`, `race_id`, `calling_id`, `luck_descriptor`. `race_id` and +`calling_id` are there so the Narrator and NPCs can *describe* the player (a +"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") — +they are identity for prose, not mechanics. **Numeric Luck, stats, HP/MP, and +inventory cannot be represented in the canon log** — the mechanical sheet stays +in `GameState` and never reaches the log, and the AI never sees a number it +could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on you") crosses the boundary. All string ids match `^[a-z0-9_]+$`. @@ -59,7 +63,7 @@ Full rules in `origin.schema.json`. An origin seeds the initial log: "disposition_overrides": { "": -100..100 }, "inventory_grants": [ { "item_id": "…", "qty": 1.. } ], "start_quest_id": "…|null", - "build_constraints": { "allowed_classes": ["sellsword",…], "luck_modifier": 0 } + "build_constraints": { "allowed_callings": ["sellsword",…], "luck_modifier": 0 } } ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 8971edd..9972bb3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,7 +75,9 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups ( ### M4 — Character creation *Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.*** -- ○ **Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built). **Two contract migrations land here, not silently:** the canon log gains a `race` field (the Narrator needs it to describe the player), and `content/origins/deserter.json` still carries the dead `allowed_classes: ["sellsword","assassin","priest"]`. **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). Emit `race_id` + `calling_id` into it. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.* +- ✅ **Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree. +- ○ **Character creation UI (M4-b)** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.* +- ○ **Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.* ### M5 — Tactical combat (gridless) *Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***