Mi2dRPGamEng/engine/character.py

447 lines
19 KiB
Python

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