feat(state): CharacterSheet — store the inputs, derive the rest
Stores only what was rolled or chosen, plus current hp/mp (the only two numbers that genuinely mutate). Everything else is a function, so no derived number can drift from its inputs and M5's level curve is a function change, not a migration. LCK has no row and no accessor here — §7 holds by construction. 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:
94
client/scripts/state/character_sheet.gd
Normal file
94
client/scripts/state/character_sheet.gd
Normal file
@@ -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")
|
||||
1
client/scripts/state/character_sheet.gd.uid
Normal file
1
client/scripts/state/character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://mofimtmsxuui
|
||||
91
client/tests/unit/test_character_sheet.gd
Normal file
91
client/tests/unit/test_character_sheet.gd
Normal file
@@ -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")
|
||||
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgi5hu3y2fg2u
|
||||
Reference in New Issue
Block a user