145 lines
5.8 KiB
Python
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
|