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:
15
client/scripts/rules/attributes.gd
Normal file
15
client/scripts/rules/attributes.gd
Normal 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)
|
||||
1
client/scripts/rules/attributes.gd.uid
Normal file
1
client/scripts/rules/attributes.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnlwajfs3vysm
|
||||
93
client/scripts/rules/callings.gd
Normal file
93
client/scripts/rules/callings.gd
Normal 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", ""))
|
||||
1
client/scripts/rules/callings.gd.uid
Normal file
1
client/scripts/rules/callings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cewlixhavh1h4
|
||||
60
client/scripts/rules/races.gd
Normal file
60
client/scripts/rules/races.gd
Normal 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))
|
||||
1
client/scripts/rules/races.gd.uid
Normal file
1
client/scripts/rules/races.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmkoxq4eabfcq
|
||||
28
client/scripts/rules/skills.gd
Normal file
28
client/scripts/rules/skills.gd
Normal 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, ""))
|
||||
1
client/scripts/rules/skills.gd.uid
Normal file
1
client/scripts/rules/skills.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2pvtvrrhc27b
|
||||
97
client/tests/unit/test_rules_tables.gd
Normal file
97
client/tests/unit/test_rules_tables.gd
Normal 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)
|
||||
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bjld6ctq8tqnk
|
||||
Reference in New Issue
Block a user