Mi2dRPGamEng/engine/character.py

589 lines
27 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, Sequence, TypeVar
from engine.ai import AIController
from engine.entity import Entity
from engine.inventory import Inventory
from engine.skills import default_bio
MAX_MEDICAL_LOG_ENTRIES = 20
@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)
@dataclass(frozen=True)
class AilmentDef:
"""How much an ailment (at its current severity) degrades relevant checks - mirrors
OrganDef.check_weights exactly, just keyed by ailment id instead of organ id. Not every
Ailment instance needs a matching AilmentDef; one with no def (or a def with no weight for
the check in question) simply contributes nothing, same as an organ with no relevant
check_weights entry.
"""
id: str
name: str
check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.5}
causes_bleeding: bool = False # e.g. a synthetic's popped_fluid_hose - see
# resources/logic/health_ticks.py::apply_bleeding_tick and BodyPart.bandaged
bleed_rate: float = 4.0 # blood_percentage lost per tick at severity 1.0, only meaningful
# when causes_bleeding is set - deliberately independent of the host BodyPartDef's own
# bleeding_speed (which is 0.0 on every synthetic part - bots don't bleed *normally*, this
# ailment is the one exception, so it needs its own rate rather than borrowing that field)
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
role: str | None = None # e.g. "heart" - the physiologically-equivalent organ across every
# species that has one, so game logic can target "the heart" (or similar) without
# hardcoding species-specific organ ids - see resources/logic/abilities.py::cast_launch_lightning
@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
bandaged: bool = False # a bandage (organic species) or sealant (synthetic) was applied -
# see resources/logic/medical.py::treat_bleeding - stops this part's contribution to
# apply_bleeding_tick, whether that's from being severed or from a bleeding ailment
@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_type: str | None = None,
hair_color: 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,
ai: AIController | None = None,
factions: list[str] | 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_type = body_type # cosmetic build/archetype (e.g. "average"/"thin"/"fat"/"hulk");
# valid values are species-defined via SpeciesDef.body_type_options, not enforced here -
# NOT the same as SpeciesDef.body_type, which is a fixed anatomical trait
self.hair_color = hair_color # valid values are species-defined via SpeciesDef.hair_colors
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
self.ai: AIController | None = ai # the "AI slot" - None for the player and any inert
# NPC; see resources/logic/ai.py::run_ai_tick for what drives a filled slot each tick
self.factions: list[str] = factions or [] # open-ended tags (e.g. "player", "hostile")
# for other systems to categorize this character by, rather than by entity_id - see
# resources/logic/turrets.py::is_hostile for the first consumer
self.active_boosts: dict[str, float] = {} # stat id ("strength"/"speed") -> multiplier,
# e.g. a consumed stimulant (see resources/logic/abilities.py::cast_adrenablend) -
# applied in effective_strength/effective_speed, expired via boost_expirations
self.boost_expirations: dict[str, float] = {} # stat id -> game-clock time it wears off,
# cleared by resources/logic/health_ticks.py::apply_boost_expiry_tick
self.medical_log: list[str] = [] # recent injuries/treatments, newest last - see
# log_medical_event; shown in the character card's medical tab (engine/render/menu_draw.py)
def log_medical_event(self, message: str) -> None:
"""Appends a short human-readable entry to medical_log (e.g. "Sprained left_leg from a
fall"), trimming the oldest entries past MAX_MEDICAL_LOG_ENTRIES so it can't grow
unbounded over a long play session.
"""
self.medical_log.append(message)
if len(self.medical_log) > MAX_MEDICAL_LOG_ENTRIES:
del self.medical_log[: len(self.medical_log) - MAX_MEDICAL_LOG_ENTRIES]
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)) * self.active_boosts.get(
"speed", 1.0
)
def effective_strength(self, registry) -> float:
"""Raw strength reduced by organ damage (e.g. a weakened heart saps physical power),
then scaled by any active stimulant boost (see active_boosts/resources/logic/
abilities.py::cast_adrenablend).
"""
return self.strength * (1.0 - self.organ_penalty("strength", registry)) * self.active_boosts.get("strength", 1.0)
def base_tech_skill(self) -> float:
"""The "tech" skill (see resources/logic/hacking.py) isn't bought/rolled/assigned
directly like the DND_SKILLS bio entries - it's derived, averaging intelligence and
perception the same way those two are themselves plain bio values. total_check_penalty
still applies on top of this the same way it would for any other skill's base value.
"""
return (self.bio.get("intelligence", 0) + self.bio.get("perception", 0)) / 2.0
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 find_organ_by_role(self, role: str, registry) -> tuple[BodyPart, Organ] | None:
"""The (body part override, organ) pair for this character's organ with the given
OrganDef.role (e.g. "heart") - lets game logic target a physiologically-equivalent
organ across species without hardcoding species-specific organ ids (see
resources/logic/abilities.py::cast_launch_lightning). Always returns the *override*
body part (creating one via get_or_create_body_part_override if needed) so callers can
safely mutate the organ's integrity and attach ailments to the same body part in one
place. None if this character has no organ with that role at all (e.g. a species with
no heart-equivalent).
"""
for slot, part in self.resolved_body_parts().items():
if part is None:
continue
for organ in part.organs:
if registry.get_organ_def(organ.organ_def_id).role != role:
continue
override = self.get_or_create_body_part_override(slot)
if override is None:
return None
override_organ = next((o for o in override.organs if o.organ_def_id == organ.organ_def_id), None)
return (override, override_organ) if override_organ is not None else None
return None
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 ailment_penalty(self, check_id: str, registry) -> float:
"""Fraction (0..1) of `check_id` lost to active ailments (e.g. a combat-inflicted
fracture or brain_trauma - see resources/logic/combat.py) - mirrors organ_penalty, but
weighted by each ailment's own severity (already a 0..1 fraction) instead of an organ's
integrity. An ailment id with no matching AilmentDef (or no weight for this check)
simply contributes nothing.
"""
total_weight = 0.0
lost_weight = 0.0
for part in self.resolved_body_parts().values():
if part is None:
continue
for ailment in part.ailments:
ailment_def = registry.get_ailment_def(ailment.id)
if ailment_def is None:
continue
weight = ailment_def.check_weights.get(check_id, 0.0)
if weight == 0.0:
continue
total_weight += weight
lost_weight += weight * min(1.0, max(0.0, ailment.severity))
if total_weight == 0.0:
return 0.0
return lost_weight / total_weight
def species_penalty(self, check_id: str, registry) -> float:
"""Fraction (0..1) of `check_id` permanently lost regardless of damage state - e.g.
motorbot's clumsy manipulators (see SpeciesDef.innate_check_penalties). Unlike
skill/organ/ailment_penalty this never heals; it's just an inherent trait of the species.
"""
if self.species_id is None:
return 0.0
return registry.get_species_def(self.species_id).innate_check_penalties.get(check_id, 0.0)
def total_check_penalty(self, check_id: str, registry) -> float:
"""Combined penalty from damaged body parts, damaged organs, active ailments, and any
innate species trait 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 nothing else affects this check or everything else is healthy
- existing skill-only checks are unaffected.
"""
remaining = (
(1.0 - self.skill_penalty(check_id, registry))
* (1.0 - self.organ_penalty(check_id, registry))
* (1.0 - self.ailment_penalty(check_id, registry))
* (1.0 - self.species_penalty(check_id, registry))
)
return 1.0 - remaining
def blood_label(self, registry) -> str:
"""The species-appropriate display term for blood_percentage - e.g. hydraulibot calls
it "Synthmuscle-Fluid" (see SpeciesDef.blood_label) instead of "Blood".
"""
if self.species_id is None:
return "Blood"
return registry.get_species_def(self.species_id).blood_label