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