From 78e18107b23e1e6f7e3ccea9bd8111f76bc2073b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 20:14:44 -0500 Subject: [PATCH] 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