from __future__ import annotations 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 DEFAULT_DURATION = 5.0 # seconds def cast_time_warp_sphere( caster: Character, world: World, now: float, center: tuple[float, float, float] | None = None, radius: float = DEFAULT_RADIUS, 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, whitelist, and time are all just arguments - this is deliberately not tied to being cast from a character ability vs. an item; see grants_ability on ItemDef and resources/logic/ 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. `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: center = (caster.position.x, caster.position.y, caster.position.z) immune = set(whitelist) if whitelist is not None else set() immune.add(caster.entity_id) zone = TimeWarpZone( map_id=caster.position.map_id, center=center, radius=radius, speed_factor=speed_factor, expires_at=now + duration, immune_entity_ids=immune, ) 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))