feat(rules): the race, calling, skill and attribute tables

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
2026-07-12 20:14:44 -05:00
parent 89d4c65a7a
commit 78e18107b2
10 changed files with 298 additions and 0 deletions

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://dnlwajfs3vysm

View File

@@ -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", ""))

View File

@@ -0,0 +1 @@
uid://cewlixhavh1h4

View File

@@ -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))

View File

@@ -0,0 +1 @@
uid://bmkoxq4eabfcq

View File

@@ -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, ""))

View File

@@ -0,0 +1 @@
uid://d2pvtvrrhc27b

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://bjld6ctq8tqnk