Mi2dRPGamEng/resources/logic/abilities.py

229 lines
10 KiB
Python

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
ADRENABLEND_STRENGTH_MULTIPLIER = 1.6
ADRENABLEND_SPEED_MULTIPLIER = 1.6
ADRENABLEND_DURATION = 20.0 # seconds
ADRENABLEND_BRAIN_FOG_SEVERITY = 0.7 # same ailment/severity a flu infection causes (see
# resources/logic/contagion.py) - reused directly rather than a near-identical duplicate, since
# mechanically "your head's not all there" is the same regardless of cause
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
def cast_adrenablend(
caster: Character,
world: World,
now: float,
aim_direction: tuple[int, int, int] | None = None,
duration: float = ADRENABLEND_DURATION,
) -> Character:
"""A shot of stimulant: greatly boosts strength and speed (see Character.active_boosts,
consumed by effective_strength/effective_speed) for `duration` seconds, at the cost of the
same brain_fog ailment a flu infection causes - reused directly (see
ADRENABLEND_BRAIN_FOG_SEVERITY above). Meant for a consumable item (ItemDef.consumable) via
resources/logic/actions.py::activate_hand, which unwields it after this succeeds - unlike
time_warp_sphere/launch_lightning this isn't a reusable wand-like ability, just item
plumbing reused for the "activate to trigger an effect" part. `aim_direction` is accepted
(and ignored) only to match every other ABILITY_CASTERS entry's calling convention.
"""
caster.active_boosts["strength"] = ADRENABLEND_STRENGTH_MULTIPLIER
caster.active_boosts["speed"] = ADRENABLEND_SPEED_MULTIPLIER
caster.boost_expirations["strength"] = now + duration
caster.boost_expirations["speed"] = now + duration
head = caster.get_or_create_body_part_override("head")
if head is not None:
existing = next((a for a in head.ailments if a.id == "brain_fog"), None)
if existing is not None:
existing.severity = max(existing.severity, ADRENABLEND_BRAIN_FOG_SEVERITY)
else:
head.ailments.append(Ailment(id="brain_fog", name="Brain Fog", severity=ADRENABLEND_BRAIN_FOG_SEVERITY))
caster.log_medical_event("Took Adrenablend: strength/speed boosted, brain fog")
return caster
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 hydraulibot/motorbot) 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:
"""Synthetic-only (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 every synthetic species' own CPU/NPU-equivalent
organs (whichever checks those 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, and no per-species
organ-id branching either.
"""
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))