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_015mFzQQZYexNaSB69e2oU95main
parent
3bd2c6fa9f
commit
05f66b8e00
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue