from __future__ import annotations import random from engine.character import Ailment, Character from engine.character_creator import DEFAULT_MAX_SKILL_LEVEL from engine.defs import DefRegistry from resources.logic.combat import DiceRoller, roll_skill_check # Every leg-ish slot across every species this engine defines (see resources/defs/body_parts.json) # - a character only ever has a subset of these, checked via Character.get_body_part. LEG_SLOTS = ("left_leg", "right_leg", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg") # Heights 1-2: no acrobatics check at all, just a small flat chance of the mildest ailment - # "rare chances on any height". Never anything worse than a sprain this low. LOW_FALL_SPRAIN_CHANCE = 0.08 # Heights 3 up to (but not including) the severe threshold: an actual acrobatics check, tiered # by roll + effective skill (see resources/logic/combat.py::roll_skill_check) - a good enough # landing avoids injury entirely, a bad one breaks something. ACROBATICS_CHECK_MIN_FALL = 3 CHECK_CLEAN_LANDING_DC = 20 CHECK_SPRAIN_DC = 14 CHECK_TENDON_DC = 8 # below CHECK_TENDON_DC -> broken bone # Height 5+ (6+ for a skilled acrobat) skips the check entirely: you're definitely hurt, just a # coin-weighted roll for which of the two worst injuries. SEVERE_FALL_HEIGHT = 5 SEVERE_FALL_HEIGHT_SKILLED = 6 SKILLED_ACROBATICS_THRESHOLD = 0.6 * DEFAULT_MAX_SKILL_LEVEL # "60% of max" - 3.0 today SEVERE_BROKEN_BONE_CHANCE = 0.9 # the other 10% is a ripped tendon instead # Least to most damage. Severity is fixed per ailment id regardless of which path triggered it - # same "one canonical severity per ailment id" convention as combat.py's COMBAT_AILMENT_SEVERITY. FALL_AILMENT_SEVERITY = { "sprained_ankle": 0.3, "ripped_tendon": 0.6, "broken_bone": 0.9, } FALL_AILMENT_NAMES = { "sprained_ankle": "Sprained Ankle", "ripped_tendon": "Ripped Tendon", "broken_bone": "Broken Bone", } # Synthetics (see SpeciesDef.is_synthetic) get the same injuries under mechanical names, not a # different mechanic - popped_fluid_hose additionally leaks (see AilmentDef.causes_bleeding / # resources/logic/health_ticks.py::apply_bleeding_tick), a synthetic-only quirk plain organic # ripped tendons don't have. SYNTHETIC_AILMENT_IDS = { "sprained_ankle": "cracked_foot_joint", "ripped_tendon": "popped_fluid_hose", "broken_bone": "broken_frame_piece", } SYNTHETIC_AILMENT_NAMES = { "cracked_foot_joint": "Cracked Foot Joint", "popped_fluid_hose": "Popped Fluid Hose", "broken_frame_piece": "Broken Structural Frame Piece", } def _is_synthetic(character: Character, registry: DefRegistry) -> bool: if character.species_id is None: return False return registry.get_species_def(character.species_id).is_synthetic def _ailment_id_for(base_id: str, is_synthetic: bool) -> str: return SYNTHETIC_AILMENT_IDS[base_id] if is_synthetic else base_id def _ailment_name_for(ailment_id: str) -> str: return SYNTHETIC_AILMENT_NAMES.get(ailment_id) or FALL_AILMENT_NAMES[ailment_id] def _random_leg_slot(character: Character, rng: DiceRoller) -> str | None: present = [slot for slot in LEG_SLOTS if character.get_body_part(slot) is not None] if not present: return None return rng.choice(present) def _severe_fall_threshold(character: Character) -> int: if character.bio.get("acrobatics", 0) >= SKILLED_ACROBATICS_THRESHOLD: return SEVERE_FALL_HEIGHT_SKILLED return SEVERE_FALL_HEIGHT def _base_ailment_for_fall(character: Character, registry: DefRegistry, fallen_levels: int, rng: DiceRoller) -> str | None: """The organic ailment id (before any synthetic rename) this fall causes, or None for a clean landing. See module docstring-equivalent constants above for the three height bands. """ if fallen_levels >= _severe_fall_threshold(character): return "broken_bone" if rng.uniform(0.0, 1.0) < SEVERE_BROKEN_BONE_CHANCE else "ripped_tendon" if fallen_levels >= ACROBATICS_CHECK_MIN_FALL: roll, effective_skill = roll_skill_check(character, "acrobatics", registry, rng) total = roll + effective_skill if total >= CHECK_CLEAN_LANDING_DC: return None if total >= CHECK_SPRAIN_DC: return "sprained_ankle" if total >= CHECK_TENDON_DC: return "ripped_tendon" return "broken_bone" if rng.uniform(0.0, 1.0) < LOW_FALL_SPRAIN_CHANCE: return "sprained_ankle" return None def apply_fall_damage( character: Character, registry: DefRegistry, fallen_levels: int, rng: DiceRoller | None = None ) -> str | None: """Rolls and applies a leg injury appropriate to how far `character` just fell (see engine/world.py::World.consume_fall_distance, fed by World._apply_gravity - the only current source of falls). Returns the ailment id actually applied, or None if they landed clean or have no leg to hurt in the first place (e.g. a legless prop entity). """ if fallen_levels <= 0: return None rng = rng or random.Random() slot = _random_leg_slot(character, rng) if slot is None: return None base_id = _base_ailment_for_fall(character, registry, fallen_levels, rng) if base_id is None: return None ailment_id = _ailment_id_for(base_id, _is_synthetic(character, registry)) part = character.get_or_create_body_part_override(slot) if part is None: return None existing = next((a for a in part.ailments if a.id == ailment_id), None) severity = FALL_AILMENT_SEVERITY[base_id] if existing is not None: existing.severity = max(existing.severity, severity) else: part.ailments.append(Ailment(id=ailment_id, name=_ailment_name_for(ailment_id), severity=severity)) character.log_medical_event(f"Fell {fallen_levels} level(s): {_ailment_name_for(ailment_id)} ({slot})") return ailment_id