Mi2dRPGamEng/resources/logic/falling.py

145 lines
5.8 KiB
Python

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