From 05f66b8e007f214033f468cec0e567a1bb5c0fe7 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 15 Jul 2026 14:25:02 +0200 Subject: [PATCH] Add launch_lightning ability with bot-specific shortcircuit effect - Sample launch_lightning ability (storm_rod item): guaranteed hit on the first Character within a configurable range along the aim direction, no skill check - burns roughly half the target's body parts and inflicts heart_palpitations, which both weighs down heart-dependent checks directly and damages the target's heart-equivalent organ (new OrganDef.role tag + Character.find_organ _by_role, so this works species-agnostically) so its dependents degrade too via the existing organ-interaction tick. - Bot targets (new SpeciesDef.is_synthetic flag) also short-circuit: 120% organ damage randomly split across every organ they have, plus a heavy insight/perception/sleight_of_hand debuff - the intellect hit falls out of the existing organ-penalty math since bot_cpu/ bot_npu are among the randomly-damaged organs. - ABILITY_CASTERS/_cast_ability now thread aim_direction uniformly so directional abilities work through the hand/implant/ability-bar casting paths alike. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95 --- engine/character.py | 26 +++ engine/defs.py | 2 + engine/species.py | 2 + resources/defs/ailments.json | 12 ++ resources/defs/items.json | 9 + resources/defs/organs.json | 15 +- resources/defs/species.json | 1 + resources/logic/abilities.py | 148 +++++++++++++- resources/logic/actions.py | 23 ++- tests/test_launch_lightning.py | 350 +++++++++++++++++++++++++++++++++ tests/test_organs.py | 42 ++++ 11 files changed, 616 insertions(+), 14 deletions(-) create mode 100644 tests/test_launch_lightning.py diff --git a/engine/character.py b/engine/character.py index 5a5c8d3..c612f27 100644 --- a/engine/character.py +++ b/engine/character.py @@ -68,6 +68,9 @@ class OrganDef: 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 @@ -433,6 +436,29 @@ class Character(Entity): 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 diff --git a/engine/defs.py b/engine/defs.py index c4a80a1..e2a5c45 100644 --- a/engine/defs.py +++ b/engine/defs.py @@ -126,6 +126,7 @@ class DefRegistry: host_slot=fields["host_slot"], check_weights=dict(fields.get("check_weights", {})), affects_organs=dict(fields.get("affects_organs", {})), + role=fields.get("role"), ) def _load_ailments(self, path: Path) -> None: @@ -161,6 +162,7 @@ class DefRegistry: body_type=fields.get("body_type", "biped"), body_type_options=list(fields.get("body_type_options", [])), hair_colors=list(fields.get("hair_colors", [])), + is_synthetic=fields.get("is_synthetic", False), abilities=abilities, blood_danger_threshold=fields.get("blood_danger_threshold", 50.0), blood_critical_threshold=fields.get("blood_critical_threshold", 25.0), diff --git a/engine/species.py b/engine/species.py index e57d1f8..a4c26c9 100644 --- a/engine/species.py +++ b/engine/species.py @@ -30,6 +30,8 @@ class SpeciesDef: # above, which is a fixed anatomical trait, not a player choice hair_colors: list[str] = field(default_factory=list) # valid hair color choices for character # creation; empty for species with no hair (e.g. reptilian, bot) + is_synthetic: bool = False # mechanical/electronic organs (currently just "bot") - lets game + # logic gate effects like a lightning-induced short circuit without hardcoding species ids abilities: list[AbilityDef] = field(default_factory=list) blood_danger_threshold: float = 50.0 # % blood remaining blood_critical_threshold: float = 25.0 diff --git a/resources/defs/ailments.json b/resources/defs/ailments.json index 11597dd..2344e30 100644 --- a/resources/defs/ailments.json +++ b/resources/defs/ailments.json @@ -10,5 +10,17 @@ "brain_trauma": { "name": "Brain Trauma", "check_weights": { "insight": 0.6, "perception": 0.5, "sleight_of_hand": 0.2 } + }, + "burn": { + "name": "Burn", + "check_weights": { "sleight_of_hand": 0.3, "athletics": 0.2 } + }, + "heart_palpitations": { + "name": "Heart Palpitations", + "check_weights": { "strength": 0.6, "speed": 0.5 } + }, + "shortcircuit": { + "name": "Short Circuit", + "check_weights": { "insight": 0.7, "perception": 0.6, "sleight_of_hand": 0.5 } } } diff --git a/resources/defs/items.json b/resources/defs/items.json index 2deb3a4..0149c2c 100644 --- a/resources/defs/items.json +++ b/resources/defs/items.json @@ -280,5 +280,14 @@ "color": [150, 110, 220], "is_implant": true, "grants_ability": "time_warp_sphere" + }, + "storm_rod": { + "name": "Storm Rod", + "width": 1, + "height": 3, + "texture": "items/storm_rod.png", + "color": [90, 160, 220], + "hands_required": 1, + "grants_ability": "launch_lightning" } } diff --git a/resources/defs/organs.json b/resources/defs/organs.json index c5b2278..d175d1a 100644 --- a/resources/defs/organs.json +++ b/resources/defs/organs.json @@ -3,7 +3,8 @@ "name": "Heart", "host_slot": "torso", "check_weights": { "strength": 0.6, "speed": 0.5 }, - "affects_organs": { "human_kidneys": 0.05 } + "affects_organs": { "human_kidneys": 0.05 }, + "role": "heart" }, "human_lungs": { "name": "Lungs", @@ -30,7 +31,8 @@ "name": "Heart", "host_slot": "torso", "check_weights": { "strength": 0.6, "speed": 0.5 }, - "affects_organs": { "reptilian_venom_gland": 0.05 } + "affects_organs": { "reptilian_venom_gland": 0.05 }, + "role": "heart" }, "reptilian_lung": { "name": "Lung", @@ -57,7 +59,8 @@ "name": "Cardial Node", "host_slot": "torso", "check_weights": { "strength": 0.6, "speed": 0.5 }, - "affects_organs": { "zenari_filtration_organ": 0.05 } + "affects_organs": { "zenari_filtration_organ": 0.05 }, + "role": "heart" }, "zenari_respirator": { "name": "Respirator", @@ -94,7 +97,8 @@ "name": "Power Cell", "host_slot": "torso", "check_weights": { "strength": 0.6, "speed": 0.5 }, - "affects_organs": { "bot_cpu": 0.05, "bot_npu": 0.05, "bot_hydraulic_fluid_bladder": 0.03 } + "affects_organs": { "bot_cpu": 0.05, "bot_npu": 0.05, "bot_hydraulic_fluid_bladder": 0.03 }, + "role": "heart" }, "bot_hydraulic_fluid_bladder": { "name": "Hydraulic Fluid Bladder", @@ -111,7 +115,8 @@ "name": "Heart", "host_slot": "torso", "check_weights": { "strength": 0.6, "speed": 0.5 }, - "affects_organs": { "dog_kidneys": 0.05 } + "affects_organs": { "dog_kidneys": 0.05 }, + "role": "heart" }, "dog_lungs": { "name": "Lungs", diff --git a/resources/defs/species.json b/resources/defs/species.json index 06ccf81..ff5d4b7 100644 --- a/resources/defs/species.json +++ b/resources/defs/species.json @@ -108,6 +108,7 @@ "body_type": "biped", "body_type_options": ["compact", "standard", "heavy"], "hair_colors": [], + "is_synthetic": true, "abilities": [ { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, diff --git a/resources/logic/abilities.py b/resources/logic/abilities.py index 65d880b..9e6e4ef 100644 --- a/resources/logic/abilities.py +++ b/resources/logic/abilities.py @@ -1,7 +1,10 @@ from __future__ import annotations -from engine.character import Character +import random + +from engine.character import Ailment, Character from engine.world import TimeWarpZone, World +from resources.logic.combat import find_ranged_target DEFAULT_RADIUS = 3.0 DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere @@ -17,6 +20,7 @@ def cast_time_warp_sphere( speed_factor: float = DEFAULT_SPEED_FACTOR, duration: float = DEFAULT_DURATION, whitelist: set[str] | None = None, + aim_direction: tuple[int, int, int] | None = None, ) -> TimeWarpZone: """Sample ability: everything inside the sphere (except the caster and `whitelist`, e.g. allies) moves at `speed_factor` of normal for `duration` seconds. Size, speed factor, @@ -25,7 +29,9 @@ def cast_time_warp_sphere( actions.py::activate_hand for the item attachment point, and Character.can_perform_ability for gating a direct character-ability trigger the same way leap/melee/ranged already are. Note: only entity *movement* speed is actually slowed (see World.speed_multiplier_at) - - this engine resolves shots as instant hit-scans with no travel-time to slow. + this engine resolves shots as instant hit-scans with no travel-time to slow. `aim_direction` + is accepted (and ignored) only so every ABILITY_CASTERS entry shares one calling + convention with directional abilities like cast_launch_lightning below. """ assert caster.position is not None if center is None: @@ -42,3 +48,141 @@ def cast_time_warp_sphere( ) world.time_warp_zones.append(zone) return zone + + +DEFAULT_LAUNCH_LIGHTNING_RANGE = 8 # tiles + +LIGHTNING_BOLT_BURN_FRACTION = 0.5 # roughly half the body parts, same flavor as the weather strike +LIGHTNING_BOLT_BURN_SEVERITY = 0.9 +LIGHTNING_BOLT_INTEGRITY_DAMAGE = 40.0 # per burned body part + +HEART_PALPITATIONS_SEVERITY = 0.85 # near-total loss of the check_weights this ailment covers +HEART_ORGAN_DAMAGE = 60.0 # taken directly off the heart-equivalent organ's own integrity + +SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE = 120.0 # "120%" - more than one organ's full integrity, +# spread randomly across every organ a synthetic (see SpeciesDef.is_synthetic) target has +SHORTCIRCUIT_SEVERITY = 0.9 # huge debuff to insight/perception/sleight_of_hand (ailments.json) + + +def cast_launch_lightning( + caster: Character, + world: World, + now: float, + aim_direction: tuple[int, int, int] = (0, 1, 0), + max_range: int = DEFAULT_LAUNCH_LIGHTNING_RANGE, + rng: random.Random | None = None, +) -> Character | None: + """Sample ability: a lightning bolt along aim_direction that always hits the first Character + it reaches within max_range - no skill check, lightning doesn't miss once something's in its + path (same straight-line/wall-stopping traversal as a ranged weapon shot, see + find_ranged_target; the walls-block-it part is the only "miss" condition, via range/line of + sight, not chance). On a hit it burns roughly half the target's body parts (integrity damage + + a burn ailment each) and inflicts heart_palpitations: a severe ailment that directly + weighs down heart-dependent checks on its own (see resources/defs/ailments.json) *and* + damages the target's heart-equivalent organ directly (see OrganDef.role and + Character.find_organ_by_role) - so whatever that organ itself affects (e.g. kidneys via + OrganDef.affects_organs) degrades too over subsequent organ-interaction ticks, a real + physiological cascade rather than a flat stat penalty. A species with no heart-equivalent + organ still gets burned, just skips the organ-damage half. On a synthetic target (see + SpeciesDef.is_synthetic, currently just bots) it also short-circuits - see + _apply_shortcircuit - on top of everything else, since electronics are especially + vulnerable to a lightning strike. + """ + assert caster.position is not None + rng = rng or random.Random() + dx, dy, dz = aim_direction + target, _impact, _steps = find_ranged_target( + world, caster.position.map_id, caster.position.x, caster.position.y, caster.position.z, + dx, dy, dz, max_range, + ) + if target is None: + return None + + _burn_random_body_parts(target, rng) + _apply_heart_palpitations(target, world.registry) + _apply_shortcircuit(target, world.registry, rng) + return target + + +def _burn_random_body_parts(character: Character, rng: random.Random) -> None: + slots = [p.slot for p in character.default_body_parts] + if not slots: + return + count = max(1, round(len(slots) * LIGHTNING_BOLT_BURN_FRACTION)) + for slot in rng.sample(slots, min(count, len(slots))): + part = character.get_or_create_body_part_override(slot) + if part is None: + continue + part.integrity = max(0.0, part.integrity - LIGHTNING_BOLT_INTEGRITY_DAMAGE) + if part.integrity <= 0.0: + part.removed = True + part.ailments.append(Ailment(id="burn", name="Burn", severity=LIGHTNING_BOLT_BURN_SEVERITY, progress=100.0)) + + +def _apply_heart_palpitations(character: Character, registry) -> None: + found = character.find_organ_by_role("heart", registry) + if found is None: + return + body_part, heart = found + heart.integrity = max(0.0, heart.integrity - HEART_ORGAN_DAMAGE) + existing = next((a for a in body_part.ailments if a.id == "heart_palpitations"), None) + if existing is not None: + existing.severity = max(existing.severity, HEART_PALPITATIONS_SEVERITY) + else: + body_part.ailments.append( + Ailment(id="heart_palpitations", name="Heart Palpitations", severity=HEART_PALPITATIONS_SEVERITY) + ) + + +def _apply_shortcircuit(character: Character, registry, rng: random.Random) -> None: + """Bot-specific (see SpeciesDef.is_synthetic - a no-op for anyone else, including a + speciesless character): splits SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE randomly across every organ + the target has - a short circuit doesn't discriminate which components it fries - then + layers a shortcircuit ailment onto every body part that hosts at least one of them. The + ailment alone already weighs heavily on insight/perception/sleight_of_hand (see + resources/defs/ailments.json), and since bot_cpu/bot_npu (the organs those same checks + depend on, see organs.json) are themselves among the randomly-damaged organs, the "huge + debuff to intellect" falls straight out of the existing organ_penalty math - no separate + intellect-specific mechanic needed. + """ + if character.species_id is None: + return + species = registry.get_species_def(character.species_id) + if not species.is_synthetic: + return + + organ_refs = [ + (slot, organ.organ_def_id) + for slot, part in character.resolved_body_parts().items() + if part is not None + for organ in part.organs + ] + if not organ_refs: + return + + weights = [rng.random() for _ in organ_refs] + weight_total = sum(weights) or 1.0 + + affected_slots: set[str] = set() + for (slot, organ_def_id), weight in zip(organ_refs, weights): + damage = SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE * weight / weight_total + override = character.get_or_create_body_part_override(slot) + if override is None: + continue + organ = next((o for o in override.organs if o.organ_def_id == organ_def_id), None) + if organ is None: + continue + organ.integrity = max(0.0, organ.integrity - damage) + if organ.integrity <= 0.0: + organ.removed = True + affected_slots.add(slot) + + for slot in affected_slots: + part = character.get_or_create_body_part_override(slot) + if part is None: + continue + existing = next((a for a in part.ailments if a.id == "shortcircuit"), None) + if existing is not None: + existing.severity = max(existing.severity, SHORTCIRCUIT_SEVERITY) + else: + part.ailments.append(Ailment(id="shortcircuit", name="Short Circuit", severity=SHORTCIRCUIT_SEVERITY)) diff --git a/resources/logic/actions.py b/resources/logic/actions.py index 5d96678..42300ec 100644 --- a/resources/logic/actions.py +++ b/resources/logic/actions.py @@ -4,7 +4,7 @@ from engine.character import Character from engine.defs import DefRegistry from engine.ui import AbilityBarSlot from engine.world import World -from resources.logic.abilities import cast_time_warp_sphere +from resources.logic.abilities import cast_launch_lightning, cast_time_warp_sphere from resources.logic.combat import DiceRoller, perform_attack, perform_shoot from resources.logic.construction import interact_with_tile from resources.logic.knockback import resolve_melee_knockback @@ -12,22 +12,30 @@ from resources.logic.ranged_combat import fire_held_weapon ABILITY_CASTERS = { "time_warp_sphere": cast_time_warp_sphere, + "launch_lightning": cast_launch_lightning, } ABILITY_COOLDOWNS = { "time_warp_sphere": 8.0, # seconds; longer than the zone's own default 5s duration + "launch_lightning": 10.0, # a hefty cooldown given how severe a hit it lands } +DEFAULT_AIM_DIRECTION = (0, 1, 0) -def _cast_ability(character: Character, world: World, ability_id: str, now: float): + +def _cast_ability( + character: Character, world: World, ability_id: str, now: float, aim_direction: tuple[int, int, int] = DEFAULT_AIM_DIRECTION +): """Shared by the item-in-hand and implant ability paths: casts `ability_id` if it's off cooldown, then starts its cooldown (see ABILITY_COOLDOWNS) - a no-op returning None while - still on cooldown, or if no caster is registered for it. + still on cooldown, or if no caster is registered for it. Every ABILITY_CASTERS entry + accepts aim_direction (directional abilities like launch_lightning use it; omnidirectional + ones like time_warp_sphere just ignore it) so this dispatch stays uniform either way. """ caster = ABILITY_CASTERS.get(ability_id) if caster is None or not character.is_ability_ready(ability_id, now): return None - result = caster(character, world, now) + result = caster(character, world, now, aim_direction=aim_direction) character.start_ability_cooldown(ability_id, now, ABILITY_COOLDOWNS.get(ability_id, 0.0)) return result @@ -75,7 +83,7 @@ def activate_hand( item_def = registry.get_item_def(held.item_def_id) if item_def.grants_ability is not None: - return _cast_ability(character, world, item_def.grants_ability, now) + return _cast_ability(character, world, item_def.grants_ability, now, aim_direction=aim_direction) if item_def.tool_kind is not None: map_ = world.maps[character.position.map_id] @@ -111,6 +119,7 @@ def activate_implant( world: World, registry: DefRegistry, implant_item_def_id: str, + aim_direction: tuple[int, int, int] = DEFAULT_AIM_DIRECTION, now: float = 0.0, ): """Casts the ability granted by an installed slotless implant (e.g. a chronoimplant @@ -122,7 +131,7 @@ def activate_implant( item_def = registry.get_item_def(implant_item_def_id) if item_def.grants_ability is None: return None - return _cast_ability(character, world, item_def.grants_ability, now) + return _cast_ability(character, world, item_def.grants_ability, now, aim_direction=aim_direction) def activate_ability_bar_slot( @@ -141,5 +150,5 @@ def activate_ability_bar_slot( if slot.kind == "hand": return activate_hand(character, world, registry, slot.source_id, aim_direction, rng=rng, now=now) if slot.kind == "implant": - return activate_implant(character, world, registry, slot.source_id, now=now) + return activate_implant(character, world, registry, slot.source_id, aim_direction=aim_direction, now=now) return None diff --git a/tests/test_launch_lightning.py b/tests/test_launch_lightning.py new file mode 100644 index 0000000..6b10c01 --- /dev/null +++ b/tests/test_launch_lightning.py @@ -0,0 +1,350 @@ +import random +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.abilities import ( + DEFAULT_LAUNCH_LIGHTNING_RANGE, + HEART_ORGAN_DAMAGE, + HEART_PALPITATIONS_SEVERITY, + SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE, + cast_launch_lightning, +) +from resources.logic.actions import ABILITY_COOLDOWNS, activate_hand, activate_implant +from resources.logic.organs import apply_organ_interactions_tick + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry, width=15, height=15): + field = Map("field", width, height, 1) + for x in range(width): + for y in range(height): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +# --- cast_launch_lightning: targeting / range ------------------------------------------------ + + +def test_hits_a_target_within_range(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 8, 0)) + world.add_entity(caster) + world.add_entity(target) + + hit = cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + assert hit is target + + +def test_default_range_matches_the_documented_constant(registry): + assert DEFAULT_LAUNCH_LIGHTNING_RANGE == 8 + + +def test_misses_a_target_beyond_range(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 0, 0, 0)) + target = make_human(registry, position=EntityPosition("field", 0, 10, 0)) # 10 tiles away + world.add_entity(caster) + world.add_entity(target) + + hit = cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), max_range=8) + assert hit is None + assert target.get_body_part("torso").ailments == [] + + +def test_range_is_configurable_per_cast(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 0, 0, 0)) + target = make_human(registry, position=EntityPosition("field", 0, 10, 0)) + world.add_entity(caster) + world.add_entity(target) + + hit = cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), max_range=12) + assert hit is target + + +def test_no_target_in_the_aimed_direction_is_a_no_op(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + world.add_entity(caster) + assert cast_launch_lightning(caster, world, now=0.0, aim_direction=(1, 0, 0)) is None + + +def test_a_wall_blocks_the_bolt(registry): + world = make_world(registry) + field = world.maps["field"] + field.get_tile(5, 6, 0).room = TileLayerInstance("wood_wall") + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 8, 0)) + world.add_entity(caster) + world.add_entity(target) + + assert cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) is None + assert target.get_body_part("torso").ailments == [] + + +def test_never_misses_regardless_of_skill_or_bio(registry): + # no skill check at all is performed - a bare, unskilled character still always hits + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + assert cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) is target + + +# --- burn ailments ----------------------------------------------------------------------------- + + +def test_burns_roughly_half_the_body_parts(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + burned = [p for p in target.body_parts if any(a.id == "burn" for a in p.ailments)] + assert 2 <= len(burned) <= 4 # ~half of the human's 6 default parts + for part in burned: + assert part.integrity < 100.0 + + +# --- heart_palpitations + organ damage ---------------------------------------------------------- + + +def test_inflicts_heart_palpitations_on_the_torso(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + torso = target.get_body_part("torso") + palpitations = next(a for a in torso.ailments if a.id == "heart_palpitations") + assert palpitations.severity == pytest.approx(HEART_PALPITATIONS_SEVERITY) + + +def test_damages_the_heart_organ_directly(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + torso = target.get_body_part("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + assert heart.integrity == pytest.approx(100.0 - HEART_ORGAN_DAMAGE) + + +def test_heart_palpitations_weakens_strength_and_speed_checks(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + before = target.total_check_penalty("strength", registry) + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + after = target.total_check_penalty("strength", registry) + assert after > before + + +def test_heart_damage_cascades_to_dependent_organs_via_organ_tick(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + apply_organ_interactions_tick(target, registry, ticks=10) + + kidneys = next(o for o in target.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") + assert kidneys.integrity < 100.0 # the damaged heart affects_organs its way into the kidneys + + +def test_species_with_no_heart_equivalent_still_gets_burned(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = Character( + position=EntityPosition("field", 5, 6, 0), + default_body_parts=[BodyPart("torso", "human_torso"), BodyPart("head", "human_head")], + ) + world.add_entity(caster) + world.add_entity(target) + + hit = cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + assert hit is target + assert any(any(a.id == "burn" for a in p.ailments) for p in target.body_parts) + assert not any(a.id == "heart_palpitations" for p in target.body_parts for a in p.ailments) + + +def test_recasting_refreshes_rather_than_stacks_heart_palpitations(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + cast_launch_lightning(caster, world, now=100.0, aim_direction=(0, 1, 0)) + torso = target.get_body_part("torso") + assert len([a for a in torso.ailments if a.id == "heart_palpitations"]) == 1 + + +# --- item attachment + actions.py plumbing ------------------------------------------------------ + + +def test_storm_rod_casts_launch_lightning_via_activate_hand(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + caster.wield_item("right_hand", "storm_rod", registry) + world.add_entity(caster) + world.add_entity(target) + + result = activate_hand(caster, world, registry, "right_hand", (0, 1, 0), now=0.0) + assert result is target + assert any(a.id == "burn" for p in target.body_parts for a in p.ailments) + + +def test_launch_lightning_respects_its_cooldown(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + caster.wield_item("right_hand", "storm_rod", registry) + world.add_entity(caster) + world.add_entity(target) + + activate_hand(caster, world, registry, "right_hand", (0, 1, 0), now=0.0) + assert caster.is_ability_ready("launch_lightning", now=1.0) is False + cooldown = ABILITY_COOLDOWNS["launch_lightning"] + assert caster.is_ability_ready("launch_lightning", now=cooldown + 0.1) is True + + +def test_time_warp_sphere_via_implant_still_works_after_aim_direction_plumbing(registry): + # regression check: activate_implant gained an aim_direction param for launch_lightning- + # style abilities, but a non-directional ability (time_warp_sphere) must be unaffected + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + + result = activate_implant(caster, world, registry, "chronoimplant", aim_direction=(1, 0, 0), now=0.0) + assert result is not None + assert len(world.time_warp_zones) == 1 + + +# --- shortcircuit: bot-specific, randomized organ damage spread ------------------------------- + + +def make_bot(registry, **kwargs) -> Character: + return Character(species_id="bot", default_body_parts=registry.new_default_body_parts("bot"), **kwargs) + + +def test_shortcircuit_only_applies_to_synthetic_species(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0)) + assert not any(a.id == "shortcircuit" for p in target.body_parts for a in p.ailments) + + +def test_shortcircuit_hits_a_bot(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_bot(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(1)) + assert any(a.id == "shortcircuit" for p in target.body_parts for a in p.ailments) + + +def test_shortcircuit_damages_every_organ_the_bot_has(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_bot(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(7)) + + all_organ_ids = {"bot_cpu", "bot_npu", "bot_power_cell", "bot_hydraulic_fluid_bladder", "bot_cooling_fan"} + damaged_ids = set() + total_damage = 0.0 + for part in target.body_parts: + for organ in part.organs: + if organ.integrity < 100.0 or organ.removed: + damaged_ids.add(organ.organ_def_id) + total_damage += 100.0 - organ.integrity + assert damaged_ids == all_organ_ids # every organ took at least some damage + # shortcircuit's 120 spread across all 5 organs, plus heart_palpitations' own separate hit + # to bot_power_cell (the bot's heart-equivalent organ) on top of its shortcircuit share + assert total_damage <= SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE + HEART_ORGAN_DAMAGE + 1e-6 + + +def test_shortcircuit_ailment_lands_on_every_body_part_hosting_a_damaged_organ(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_bot(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(3)) + head = target.get_body_part("head") + torso = target.get_body_part("torso") + assert any(a.id == "shortcircuit" for a in head.ailments) # hosts cpu/npu + assert any(a.id == "shortcircuit" for a in torso.ailments) # hosts the other three + + +def test_shortcircuit_gives_a_huge_intellect_debuff(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_bot(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + before = target.total_check_penalty("insight", registry) + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(1)) + after = target.total_check_penalty("insight", registry) + assert after > before + assert after > 0.5 # a "huge" debuff, not a marginal one + + +def test_shortcircuit_recast_refreshes_rather_than_stacks(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + target = make_bot(registry, position=EntityPosition("field", 5, 6, 0)) + world.add_entity(caster) + world.add_entity(target) + + cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(1)) + cast_launch_lightning(caster, world, now=100.0, aim_direction=(0, 1, 0), rng=random.Random(2)) + head = target.get_body_part("head") + assert len([a for a in head.ailments if a.id == "shortcircuit"]) == 1 diff --git a/tests/test_organs.py b/tests/test_organs.py index 5a4d7f7..0fd0665 100644 --- a/tests/test_organs.py +++ b/tests/test_organs.py @@ -200,3 +200,45 @@ def test_removed_organ_no_longer_contributes_to_further_interactions(registry): apply_organ_interactions_tick(character, registry, ticks=100) kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") assert kidneys.integrity == 100.0 # removed heart is skipped as a source entirely + + +# --- find_organ_by_role ----------------------------------------------------------------------- + + +def test_find_organ_by_role_finds_the_heart(registry): + character = make_human(registry) + found = character.find_organ_by_role("heart", registry) + assert found is not None + body_part, organ = found + assert body_part.slot == "torso" + assert organ.organ_def_id == "human_heart" + + +def test_find_organ_by_role_works_across_bespoke_species_organs(registry): + bot = Character(species_id="bot", default_body_parts=registry.new_default_body_parts("bot")) + found = bot.find_organ_by_role("heart", registry) + assert found is not None + _body_part, organ = found + assert organ.organ_def_id == "bot_power_cell" # the robot's functional heart-equivalent + + +def test_find_organ_by_role_none_when_no_organs_at_all(registry): + character = Character(default_body_parts=[BodyPart("torso", "human_torso")]) + assert character.find_organ_by_role("heart", registry) is None + + +def test_find_organ_by_role_none_for_unknown_role(registry): + character = make_human(registry) + assert character.find_organ_by_role("gizzard", registry) is None + + +def test_find_organ_by_role_returns_the_mutable_override(registry): + character = make_human(registry) + found = character.find_organ_by_role("heart", registry) + assert found is not None + _body_part, organ = found + organ.integrity = 10.0 + # re-fetching sees the same mutated instance, not a fresh default copy + torso = character.get_body_part("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + assert heart.integrity == pytest.approx(10.0)