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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95
main
Amir Alexander Abdelbaki 2026-07-15 14:25:02 +02:00
parent 3bd2c6fa9f
commit 05f66b8e00
11 changed files with 616 additions and 14 deletions

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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 }
}
}

View File

@ -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"
}
}

View File

@ -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",

View File

@ -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"] },

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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)