from __future__ import annotations from dataclasses import dataclass, field from typing import Protocol, Sequence, TypeVar from engine.entity import Entity from engine.inventory import Inventory from engine.skills import default_bio @dataclass class Ailment: id: str name: str severity: float = 1.0 progress: float = 0.0 # 0-100 meter for ailments that advance over time (e.g. heatstroke) class _SlottedOverride(Protocol): slot: str removed: bool _T = TypeVar("_T", bound=_SlottedOverride) def _resolve_slot(overrides: Sequence[_T], defaults: Sequence[_T], slot: str) -> _T | None: """Shared by body parts and clothing: an override wins (or explicitly removes), else fall back to the default.""" for item in overrides: if item.slot == slot: return None if item.removed else item for item in defaults: if item.slot == slot: return item return None @dataclass class Organ: """A character's actual organ instance, living inside a specific body part (e.g. the heart lives in the torso). Independent of BodyPart's one-per-slot model - a single body part can host several organs at once. """ organ_def_id: str integrity: float = 100.0 removed: bool = False @dataclass(frozen=True) class OrganDef: id: str name: str host_slot: str # which body part slot this organ normally lives in (e.g. "torso") check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.6, "speed": 0.4} affects_organs: dict[str, float] = field(default_factory=dict) # other organ_id -> damage/tick per damage-fraction @dataclass class BodyPart: """A character's actual body part instance (as opposed to its static def).""" slot: str part_def_id: str integrity: float = 100.0 ailments: list[Ailment] = field(default_factory=list) organs: list[Organ] = field(default_factory=list) removed: bool = False @dataclass(frozen=True) class BodyPartDef: id: str slot: str texture: str | None = None color: tuple[int, int, int] = (255, 0, 255) bleeding_weight: float = 0.0 bleeding_speed: float = 0.0 skill_weights: dict[str, float] = field(default_factory=dict) speed_factor: float = 0.0 # this part's contribution to movement speed when present (e.g. a leg) @dataclass class ClothingPiece: """A worn item instance. Mirrors BodyPart's override/fallback pattern (see _resolve_slot).""" slot: str item_def_id: str removed: bool = False inventory: Inventory | None = None # lazily created if the item def grants one (e.g. a backpack) # Hand slots map to the arm body part that must be present to use them. The "_2" pair only # applies to species with 4 arm slots (e.g. left_arm_2/right_arm_2) - a plain 2-armed human # simply never has those hands available (wield_item fails the body-part check for them). HAND_TO_ARM_SLOT = { "right_hand": "right_arm", "left_hand": "left_arm", "right_hand_2": "right_arm_2", "left_hand_2": "left_arm_2", } HAND_PAIRS = { "right_hand": "left_hand", "left_hand": "right_hand", "right_hand_2": "left_hand_2", "left_hand_2": "right_hand_2", } ARM_SLOTS = ("left_arm", "right_arm", "left_arm_2", "right_arm_2") @dataclass class HeldItem: hand_slot: str item_def_id: str attachments: list[str] = field(default_factory=list) # attached mod item_def_ids (scopes, stocks, mags, ...) loaded_ammo_type: str | None = None loaded_ammo_count: int = 0 class Character(Entity): def __init__( self, *args, species_id: str | None = None, gender: str | None = None, body_parts: list[BodyPart] | None = None, default_body_parts: list[BodyPart] | None = None, clothing: list[ClothingPiece] | None = None, default_clothing: list[ClothingPiece] | None = None, held_items: list[HeldItem] | None = None, implants: list[str] | None = None, is_blocking: bool = False, strength: int = 10, blood_percentage: float = 100.0, mental_stats: dict[str, float] | None = None, bio: dict[str, int] | None = None, ability_cooldowns: dict[str, float] | None = None, **kwargs, ): super().__init__(*args, **kwargs) self.species_id = species_id self.gender = gender # open marker; valid values are species-defined, not enforced here self.body_parts: list[BodyPart] = body_parts or [] self.default_body_parts: list[BodyPart] = default_body_parts or [] self.clothing: list[ClothingPiece] = clothing or [] self.default_clothing: list[ClothingPiece] = default_clothing or [] self.held_items: list[HeldItem] = held_items or [] self.implants: list[str] = implants or [] # installed implant item_def_ids - no hand/body slot needed self.is_blocking = is_blocking self.strength = strength self.blood_percentage = blood_percentage self.mental_stats: dict[str, float] | None = mental_stats # optional: NPC/player mechanics like insanity self.bio: dict[str, int] = bio if bio is not None else default_bio() self.ability_cooldowns: dict[str, float] = ability_cooldowns or {} # ability_id -> game-clock time it's ready again def is_ability_ready(self, ability_id: str, now: float) -> bool: return now >= self.ability_cooldowns.get(ability_id, 0.0) def start_ability_cooldown(self, ability_id: str, now: float, duration: float) -> None: if duration > 0.0: self.ability_cooldowns[ability_id] = now + duration def get_body_part(self, slot: str) -> BodyPart | None: return _resolve_slot(self.body_parts, self.default_body_parts, slot) def get_or_create_body_part_override(self, slot: str) -> BodyPart | None: """The override entry for `slot`, creating one (copied from the current resolved state, including its ailments and organs) if none exists yet. Anything that needs to mutate a body part in place (damage, ailments, organs, ...) should go through this rather than get_body_part, which may return a shared default instance that must not be mutated directly. """ for part in self.body_parts: if part.slot == slot: return part current = self.get_body_part(slot) if current is None: return None override = BodyPart( slot=slot, part_def_id=current.part_def_id, integrity=current.integrity, ailments=list(current.ailments), organs=[Organ(o.organ_def_id, o.integrity, o.removed) for o in current.organs], ) self.body_parts.append(override) return override def resolved_body_parts(self) -> dict[str, BodyPart | None]: slots = {p.slot for p in self.default_body_parts} | {p.slot for p in self.body_parts} return {slot: self.get_body_part(slot) for slot in slots} def get_clothing(self, slot: str) -> ClothingPiece | None: return _resolve_slot(self.clothing, self.default_clothing, slot) def resolved_clothing(self) -> dict[str, ClothingPiece | None]: slots = {p.slot for p in self.default_clothing} | {p.slot for p in self.clothing} return {slot: self.get_clothing(slot) for slot in slots} def arm_slot_count(self) -> int: """How many arm slots this character currently has present (a missing/removed arm doesn't count) - used to check whether a sleeved garment fits, see wear_item. """ return sum(1 for slot in ARM_SLOTS if self.get_body_part(slot) is not None) def wear_item(self, slot: str, item_def_id: str, registry) -> bool: """Puts on a clothing item in the given slot, replacing whatever was already there. Fails (no-op) if the item doesn't belong in that slot, if the wearer's species' body_type isn't one the item is tailored for at all (see ItemDef.compatible_body_types - e.g. a dog can't wear a human's jacket, full stop, regardless of sleeves), or - for a sleeved garment within a compatible body type (see ItemDef.arm_slots_required) - the wearer has more arms than the garment has sleeves for. A garment tailored for more arms than the wearer has still fits fine, the extra sleeves just go unused (e.g. a 2-armed human can wear a 4-armed reptilian's jacket, both being bipeds), but the reverse doesn't work (a 4-armed wearer can't fit into a 2-sleeve jacket). """ item_def = registry.get_item_def(item_def_id) if item_def.clothing_slot != slot: return False if item_def.compatible_body_types and self.species_id is not None: species = registry.get_species_def(self.species_id) if species.body_type not in item_def.compatible_body_types: return False if item_def.arm_slots_required > 0 and self.arm_slot_count() > item_def.arm_slots_required: return False self.clothing = [c for c in self.clothing if c.slot != slot] self.clothing.append(ClothingPiece(slot, item_def_id)) return True def take_off(self, slot: str) -> None: self.clothing = [c for c in self.clothing if c.slot != slot] def get_equipped_inventory(self, slot: str, registry) -> Inventory | None: """Returns the inventory granted by a worn item (e.g. a backpack), creating it on first access.""" piece = self.get_clothing(slot) if piece is None: return None item_def = registry.get_item_def(piece.item_def_id) if item_def.grants_inventory_size is None: return None if piece.inventory is None: piece.inventory = Inventory(*item_def.grants_inventory_size) return piece.inventory def get_held_item(self, hand_slot: str) -> HeldItem | None: return next((h for h in self.held_items if h.hand_slot == hand_slot), None) def install_implant(self, item_def_id: str, registry) -> bool: """Installs a slotless implant - no hand or body slot needed, unlike wielding/clothing.""" item_def = registry.get_item_def(item_def_id) if not item_def.is_implant or item_def_id in self.implants: return False self.implants.append(item_def_id) return True def uninstall_implant(self, item_def_id: str) -> None: if item_def_id in self.implants: self.implants.remove(item_def_id) def wield_item(self, hand_slot: str, item_def_id: str, registry) -> bool: """Puts an item in the given hand; two-handed items also occupy its paired hand. Fails (no-op) if the corresponding arm is missing, or (for two-handed items) the paired hand's arm is missing too. """ item_def = registry.get_item_def(item_def_id) arm_slot = HAND_TO_ARM_SLOT.get(hand_slot) if arm_slot is None or self.get_body_part(arm_slot) is None: return False hands_needed = [hand_slot] if item_def.hands_required >= 2: paired = HAND_PAIRS.get(hand_slot) paired_arm = HAND_TO_ARM_SLOT.get(paired) if paired else None if paired is None or paired_arm is None or self.get_body_part(paired_arm) is None: return False hands_needed.append(paired) self.held_items = [h for h in self.held_items if h.hand_slot not in hands_needed] for hand in hands_needed: self.held_items.append(HeldItem(hand, item_def_id)) return True def unwield(self, hand_slot: str) -> None: self.held_items = [h for h in self.held_items if h.hand_slot != hand_slot] def wielded_shield(self, registry): """The first held item that acts as a shield (has a block bonus or blockable types), if any.""" for held in self.held_items: item_def = registry.get_item_def(held.item_def_id) if item_def.shield_block_bonus > 0 or item_def.blockable_projectile_types: return item_def return None def attach_mod(self, hand_slot: str, mod_item_def_id: str, registry) -> bool: """Attaches a mod (scope/stock/magazine/...) to the item held in `hand_slot`.""" held = self.get_held_item(hand_slot) if held is None: return False weapon_def = registry.get_item_def(held.item_def_id) mod_def = registry.get_item_def(mod_item_def_id) if mod_def.attachment_kind is None: return False if weapon_def.magazine_size <= 0 and weapon_def.aim_cone_degrees <= 0: return False # only ranged/ammo weapons take mods held.attachments.append(mod_item_def_id) return True def magazine_capacity(self, hand_slot: str, registry) -> int: """The held weapon's ammo capacity: its base magazine_size plus any attached mod bonuses.""" held = self.get_held_item(hand_slot) if held is None: return 0 weapon_def = registry.get_item_def(held.item_def_id) bonus = sum(registry.get_item_def(mod_id).magazine_capacity_bonus for mod_id in held.attachments) return weapon_def.magazine_size + bonus def reload(self, hand_slot: str, registry) -> bool: """Manually reloads the held weapon from the character's own inventory. Tops up from whichever compatible ammo type is already loaded (if any and still available), otherwise the first compatible type found. Fails as a no-op if there's no ammo-fed weapon held, no inventory, no matching ammo, or the magazine is already full. """ held = self.get_held_item(hand_slot) if held is None or self.inventory is None: return False weapon_def = registry.get_item_def(held.item_def_id) if weapon_def.magazine_size <= 0: return False capacity = self.magazine_capacity(hand_slot, registry) if held.loaded_ammo_count >= capacity: return False ammo_type = held.loaded_ammo_type if held.loaded_ammo_count > 0 else None placed = next( ( p for p in self.inventory.items() if registry.get_item_def(p.item.def_id).ammo_type in ((ammo_type,) if ammo_type else weapon_def.compatible_ammo_types) ), None, ) if placed is None: return False ammo_def = registry.get_item_def(placed.item.def_id) take = min(capacity - held.loaded_ammo_count, placed.item.quantity) if take <= 0: return False placed.item.quantity -= take if placed.item.quantity <= 0: self.inventory.remove(placed.item.item_id, ammo_def) held.loaded_ammo_type = ammo_def.ammo_type held.loaded_ammo_count += take return True def effective_speed(self, base_speed: float, registry) -> float: """Movement speed = base_speed * sum(each present body part's own speed_factor, scaled by its current integrity), further reduced by organ damage (e.g. a weakened heart or lungs). A missing leg contributes nothing; an installed cybernetic replacement (e.g. a bionic leg) contributes its own (possibly higher) factor instead of the default part's - no separate mechanism needed, it's just a slot override. """ total_factor = 0.0 for part in self.resolved_body_parts().values(): if part is None: continue part_def = registry.get_body_part_def(part.part_def_id) if part_def.speed_factor <= 0.0: continue total_factor += part_def.speed_factor * (part.integrity / 100.0) return base_speed * total_factor * (1.0 - self.organ_penalty("speed", registry)) def effective_strength(self, registry) -> float: """Raw strength reduced by organ damage (e.g. a weakened heart saps physical power).""" return self.strength * (1.0 - self.organ_penalty("strength", registry)) def can_perform_ability(self, ability_id: str, registry) -> bool: """Whether this character's species defines `ability_id` and its required body parts are all present.""" if self.species_id is None: return False species = registry.get_species_def(self.species_id) ability = next((a for a in species.abilities if a.id == ability_id), None) if ability is None: return False return all(self.get_body_part(slot) is not None for slot in ability.required_slots) def blood_status(self, species_def) -> str: if self.blood_percentage <= species_def.blood_critical_threshold: return "critical" if self.blood_percentage <= species_def.blood_danger_threshold: return "danger" return "normal" def skill_penalty(self, skill_id: str, registry) -> float: """Fraction (0..1) of `skill_id` lost to missing/damaged body parts, weighted per the species' default parts.""" total_weight = 0.0 lost_weight = 0.0 for default_part in self.default_body_parts: part_def = registry.get_body_part_def(default_part.part_def_id) weight = part_def.skill_weights.get(skill_id, 0.0) if weight == 0.0: continue total_weight += weight current = self.get_body_part(default_part.slot) integrity_fraction = 0.0 if current is None else current.integrity / 100.0 lost_weight += weight * (1.0 - integrity_fraction) if total_weight == 0.0: return 0.0 return lost_weight / total_weight def organ_penalty(self, check_id: str, registry) -> float: """Fraction (0..1) of `check_id` lost to damaged/missing organs, weighted per organ - mirrors skill_penalty exactly, but over organs instead of body parts, and over whatever named check an organ's check_weights lists (not just D&D skills - e.g. "strength", "speed", or a not-yet-implemented "constitution"/"poison_resistance"). """ total_weight = 0.0 lost_weight = 0.0 for part in self.resolved_body_parts().values(): if part is None: continue for organ in part.organs: organ_def = registry.get_organ_def(organ.organ_def_id) weight = organ_def.check_weights.get(check_id, 0.0) if weight == 0.0: continue total_weight += weight integrity_fraction = 0.0 if organ.removed else organ.integrity / 100.0 lost_weight += weight * (1.0 - integrity_fraction) if total_weight == 0.0: return 0.0 return lost_weight / total_weight def total_check_penalty(self, check_id: str, registry) -> float: """Combined penalty from both damaged body parts and damaged organs for a named check, applied multiplicatively (as independent sources of lost effectiveness) so it can never exceed 1.0 lost. Reduces to skill_penalty alone when no organ affects this check or all relevant organs are healthy - existing skill-only checks are unaffected either way. """ remaining = (1.0 - self.skill_penalty(check_id, registry)) * (1.0 - self.organ_penalty(check_id, registry)) return 1.0 - remaining