447 lines
19 KiB
Python
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
|