from pathlib import Path import pytest from engine.character import Character from engine.defs import DefRegistry from resources.logic.falling import ( ACROBATICS_CHECK_MIN_FALL, CHECK_CLEAN_LANDING_DC, CHECK_TENDON_DC, LEG_SLOTS, LOW_FALL_SPRAIN_CHANCE, SEVERE_BROKEN_BONE_CHANCE, SEVERE_FALL_HEIGHT, SEVERE_FALL_HEIGHT_SKILLED, apply_fall_damage, ) DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" @pytest.fixture() def registry(): return DefRegistry.load(DEFS_DIR) def make_human(registry, **kwargs) -> Character: return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) def make_hydraulibot(registry, **kwargs) -> Character: return Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"), **kwargs) def make_motorbot(registry, **kwargs) -> Character: return Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"), **kwargs) class FixedRng: """A random.Random look-alike with independently-fixed outputs for every method apply_fall_damage/roll_skill_check might call. """ def __init__(self, randint_value: int = 10, choice_index: int = 0, uniform_value: float = 0.5): self.randint_value = randint_value self.choice_index = choice_index self.uniform_value = uniform_value def randint(self, a, b): return self.randint_value def choice(self, seq): return seq[self.choice_index] def uniform(self, a, b): return a + (b - a) * self.uniform_value def any_leg_has(character: Character, ailment_id: str) -> bool: return any( any(a.id == ailment_id for a in part.ailments) for slot in LEG_SLOTS if (part := character.get_body_part(slot)) is not None ) # --- height 0: never fires ----------------------------------------------------------------- def test_no_fall_no_ailment(registry): human = make_human(registry) assert apply_fall_damage(human, registry, 0, FixedRng()) is None # --- heights 1-2: flat rare chance, sprain only -------------------------------------------- def test_low_fall_below_chance_threshold_causes_a_sprain(registry): human = make_human(registry) rng = FixedRng(uniform_value=(LOW_FALL_SPRAIN_CHANCE / 2) / 1.0) # well under the chance result = apply_fall_damage(human, registry, 1, rng) assert result == "sprained_ankle" assert any_leg_has(human, "sprained_ankle") def test_low_fall_above_chance_threshold_causes_nothing(registry): human = make_human(registry) rng = FixedRng(uniform_value=0.99) # well over the chance assert apply_fall_damage(human, registry, 2, rng) is None # --- heights 3+ (below severe threshold): acrobatics check tiers ---------------------------- def test_acrobatics_check_clean_landing(registry): human = make_human(registry) human.bio["acrobatics"] = 0 rng = FixedRng(randint_value=CHECK_CLEAN_LANDING_DC) assert apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng) is None def test_acrobatics_check_mid_roll_causes_ripped_tendon(registry): human = make_human(registry) human.bio["acrobatics"] = 0 rng = FixedRng(randint_value=CHECK_TENDON_DC + 1) # lands in the tendon band result = apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng) assert result == "ripped_tendon" def test_acrobatics_check_low_roll_causes_broken_bone(registry): human = make_human(registry) human.bio["acrobatics"] = 0 rng = FixedRng(randint_value=1) result = apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng) assert result == "broken_bone" # --- severe heights: no check, flat 90/10 roll ---------------------------------------------- def test_severe_fall_mostly_breaks_a_bone(registry): human = make_human(registry) human.bio["acrobatics"] = 0 rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2)) result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) assert result == "broken_bone" def test_severe_fall_sometimes_rips_a_tendon_instead(registry): human = make_human(registry) human.bio["acrobatics"] = 0 rng = FixedRng(uniform_value=0.99) # above SEVERE_BROKEN_BONE_CHANCE result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) assert result == "ripped_tendon" def test_severe_fall_never_results_in_just_a_sprain(registry): human = make_human(registry) human.bio["acrobatics"] = 0 for uniform_value in (0.0, 0.5, 0.99): result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, FixedRng(uniform_value=uniform_value)) assert result in ("broken_bone", "ripped_tendon") def test_skilled_acrobat_gets_the_extended_threshold(registry): # a skilled acrobat isn't subject to the flat severe roll until height 6, not 5 - height 5 # should still go through the tiered check instead human = make_human(registry) human.bio["acrobatics"] = 3 # 60% of DEFAULT_MAX_SKILL_LEVEL (5) rng = FixedRng(randint_value=CHECK_CLEAN_LANDING_DC) assert apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) is None assert SEVERE_FALL_HEIGHT < SEVERE_FALL_HEIGHT_SKILLED def test_skilled_acrobat_still_hits_the_severe_roll_one_level_higher(registry): human = make_human(registry) human.bio["acrobatics"] = 3 rng = FixedRng(uniform_value=0.0) result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT_SKILLED, rng) assert result == "broken_bone" # --- synthetic renames ----------------------------------------------------------------------- def test_hydraulibot_gets_renamed_ailments(registry): hydraulibot = make_hydraulibot(registry) rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2)) result = apply_fall_damage(hydraulibot, registry, SEVERE_FALL_HEIGHT, rng) assert result == "broken_frame_piece" assert any_leg_has(hydraulibot, "broken_frame_piece") assert not any_leg_has(hydraulibot, "broken_bone") def test_hydraulibot_ripped_tendon_becomes_popped_fluid_hose(registry): hydraulibot = make_hydraulibot(registry) rng = FixedRng(uniform_value=0.99) result = apply_fall_damage(hydraulibot, registry, SEVERE_FALL_HEIGHT, rng) assert result == "popped_fluid_hose" def test_motorbot_also_gets_renamed_ailments(registry): motorbot = make_motorbot(registry) rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2)) result = apply_fall_damage(motorbot, registry, SEVERE_FALL_HEIGHT, rng) assert result == "broken_frame_piece" # --- no legs: no-op --------------------------------------------------------------------------- def test_no_legs_no_ailment(registry): legless = Character(species_id="human", default_body_parts=[]) rng = FixedRng(uniform_value=0.0) assert apply_fall_damage(legless, registry, SEVERE_FALL_HEIGHT, rng) is None # --- refreshing an existing ailment doesn't duplicate it -------------------------------------- def test_repeated_falls_refresh_rather_than_duplicate(registry): human = make_human(registry) rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2)) apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) matching_parts = [ part for slot in LEG_SLOTS if (part := human.get_body_part(slot)) is not None for a in part.ailments if a.id == "broken_bone" ] assert len(matching_parts) == 1