Files
Flora/engine/battle.py
T

1190 lines
54 KiB
Python

from __future__ import annotations
import random
from typing import List, Optional
from .skills import (
S_ABSORB, S_ATTACKED_THIS_TURN, S_ATTACK_DOWN, S_ATTACK_UP,
S_CRIT_DOWN, S_DEFENSE_DOWN, S_DEFENSE_UP, S_DENTING, S_DOT, S_HEAL_BOOST,
S_HEAL_REDUCTION, S_HOT, S_ICICLES, S_INTOXICATE, S_LYING_WAIT, S_MADNESS, S_OBLIVION,
S_PREVENT_FIRST_DAMAGE, S_SEED, S_SHIELD, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN,
S_SPEED_UP, S_TETHER, S_VULNERABLE, S_WILT, S_ZINNIA_WEAKEN,
EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT,
TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY,
TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit,
)
from .types import type_multiplier
from .unit import ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit
from .relics import by_key
from . import runes
CRIT_MULT = 2.0
VULNERABLE_MULT = 1.5
WILT_TURNS = 3
AOE_MULT = 0.75 # area-effect skills deal less damage per target
DEF_K = 75 # defense reduces damage by defense/(defense+DEF_K), so it always matters
_DEBUFF_KEYS = (S_SPEED_DOWN, S_CRIT_DOWN, S_ATTACK_DOWN, S_DEFENSE_DOWN,
S_HEAL_REDUCTION, S_VULNERABLE, S_SLEEP,
S_INTOXICATE, S_OBLIVION, S_TETHER, S_MADNESS, S_DOT, S_WILT,
"stun")
# Random stat debuff applied by Nightshade's Dark Corrosion (value, turns=2).
_CORROSION_DEBUFFS = (
(S_SPEED_DOWN, 3),
(S_CRIT_DOWN, 10),
(S_ATTACK_DOWN, 10),
(S_DEFENSE_DOWN, 20),
(S_HEAL_REDUCTION, 30),
)
_STATUS_NAMES = {
S_SLEEP: "Sleep",
S_VULNERABLE: "Vulnerable",
S_SPEED_DOWN: "Speed Down",
S_CRIT_DOWN: "Crit Down",
S_ATTACK_DOWN: "Attack Down",
S_DEFENSE_DOWN: "Defense Down",
S_HEAL_REDUCTION: "Healing Reduction",
S_INTOXICATE: "Intoxicate",
S_OBLIVION: "Oblivion",
S_TETHER: "Tether",
S_MADNESS: "Madness",
S_DENTING: "Denting",
S_ATTACK_UP: "Attack Up",
S_DEFENSE_UP: "Defense Up",
S_SPEED_UP: "Speed Up",
S_ICICLES: "Icicle",
S_SNOWFLAKES: "Snowflakes",
"stun": "Stun",
}
class Battle:
def __init__(self, player_units: List[Unit], enemy_units: List[Unit],
relics=()) -> None:
self.players = list(player_units)
self.enemies = list(enemy_units)
self.relics = list(relics)
self.units: List[Unit] = self.players + self.enemies
for i, u in enumerate(self.units):
u.sort_order = i
self.log: List[str] = []
self.winner: Optional[int] = None
self.time: float = 0.0
self.turn: int = 0
self.current: Optional[Unit] = None
self.lotus_used: set = set() # teams whose Rebirth has already fired
self.relic_used: set = set() # teams whose one-shot relics (Second Wind) have fired
self._sweetpea_tethered: set = set() # allies tethered at Sweet Pea's turn start
self._hit_damage = {}
self._action: Optional[dict] = None
self.events: List[dict] = [] # transient UI events, cleared by the frontend
# --- helpers -------------------------------------------------------
def alive_players(self) -> List[Unit]:
return [u for u in self.players if u.alive]
def alive_enemies(self) -> List[Unit]:
return [u for u in self.enemies if u.alive]
def foes_of(self, unit: Unit) -> List[Unit]:
return self.alive_enemies() if unit.team == TEAM_PLAYER else self.alive_players()
def allies_of(self, unit: Unit) -> List[Unit]:
return self.alive_players() if unit.team == TEAM_PLAYER else self.alive_enemies()
@staticmethod
def _status_value(unit: Unit, key: str) -> int:
v = unit.status.get(key)
if v is None:
return 0
return v[0] if isinstance(v, tuple) else v
def _conditional_boost(self, unit: Unit) -> bool:
frac = unit.passive_value or 0.5
return any(e.alive and e.hp < e.max_hp * frac for e in self.foes_of(unit))
def _soporific_active(self, unit: Unit) -> bool:
return any(e.alive and e.status.get(S_SLEEP, 0) > 0 for e in self.foes_of(unit))
def _attack(self, unit: Unit) -> int:
a = unit.attack + self._status_value(unit, S_ATTACK_UP) - self._status_value(unit, S_ATTACK_DOWN)
if unit.passive in ("orchid_epiphytic", "lily_passionate") and self._conditional_boost(unit):
a += 10
if unit.passive == "lavender_soporific" and self._soporific_active(unit):
a += 10
return max(0, a)
def _speed(self, unit: Unit) -> int:
s = unit.speed + self._status_value(unit, S_SPEED_UP) - self._status_value(unit, S_SPEED_DOWN)
if unit.passive in ("orchid_epiphytic", "lily_passionate") and self._conditional_boost(unit):
s += 5
if unit.passive == "lavender_soporific" and self._soporific_active(unit):
s += 5
return max(1, s)
def _defense(self, unit: Unit, ignore_pct: float = 0.0) -> int:
d = unit.defense + self._status_value(unit, S_DEFENSE_UP)
d = int(d * (1 + 0.03 * unit.status.get("def_stack", 0)))
down = self._status_value(unit, S_DEFENSE_DOWN)
if down:
d = int(d * (1 - down / 100.0))
d = int(d * (1 - ignore_pct))
return max(0, d)
def _crit(self, unit: Unit) -> float:
pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN)
return max(0.0, pct / 100)
# --- tank protection ----------------------------------------------
def tanks_on_team(self, team: int) -> List[Unit]:
pool = self.players if team == TEAM_PLAYER else self.enemies
return [u for u in pool if u.alive and u.role == ROLE_TANK]
def protectors_of(self, unit: Unit) -> List[Unit]:
return self.tanks_on_team(unit.team)
def is_protected(self, unit: Unit) -> bool:
return unit.alive and unit.role != ROLE_TANK and bool(self.tanks_on_team(unit.team))
def _targetable(self, foes: List[Unit], bypass_tanks: bool = False) -> List[Unit]:
if bypass_tanks:
return foes
tanks = [u for u in foes if u.alive and u.role == ROLE_TANK]
return tanks if tanks else foes
def _bypass_tanks(self, user: Unit, skill: Skill) -> bool:
if user.passive != "primrose_coldhearted":
return False
return (not user.first_skill_used) or skill.name == "Frostbite"
# --- turn system (initiative timeline) ----------------------------
def _interval(self, unit: Unit) -> float:
return 100.0 / max(1, self._speed(unit))
def _next_unit(self) -> Optional[Unit]:
alive = [u for u in self.units if u.alive]
if not alive:
return None
return min(alive, key=lambda u: (u.next_at, -self._speed(u), u.sort_order))
def progress(self, unit: Unit) -> float:
"""0..1 how far a unit is from acting (drives the UI bar)."""
if not unit.alive:
return 0.0
inter = self._interval(unit)
return max(0.0, min(1.0, 1 - (unit.next_at - self.time) / inter))
def start(self) -> Unit:
self.time = 0.0
self.turn = 0
self.log.append("Battle started!")
# Relics and start-of-battle passives must apply before intervals are
# computed, so speed changes actually affect the opening turn order.
for key in self.relics:
relic = by_key(key)
if relic is not None:
relic.apply(self)
for u in self.units:
if u.passive == "snowdrop_winter":
for foe in (self.enemies if u.team == TEAM_PLAYER else self.players):
if foe.alive:
foe.status[S_SPEED_DOWN] = (3, 3)
self.log.append(f"{u.name}'s Winter Breeze slows the enemy team!")
if u.passive == "jasmine_janus":
for ally in (self.players if u.team == TEAM_PLAYER else self.enemies):
if ally.alive:
ally.status[S_PREVENT_FIRST_DAMAGE] = 1
self.log.append(f"{u.name}'s Janus Bloom protects her allies from the first hit!")
for u in self.units:
u.next_at = self._interval(u)
self.current = self._next_unit()
self.time = self.current.next_at
return self._advance()
def next_turn(self) -> Optional[Unit]:
if self.winner is not None:
return self.current
cur = self.current
if cur is not None and cur.alive:
self._end_turn_status(cur)
if cur.passive == "sweetpea_scent":
# Clear the allies that were tethered at the START of Sweet Pea's
# turn (they've now served a full turn). Tethers she applies this
# very turn survive so her next attack can heal them.
for a in self.allies_of(cur):
if id(a) in self._sweetpea_tethered:
a.status.pop(S_TETHER, None)
self._sweetpea_tethered.clear()
cur.next_at += self._interval(cur)
nxt = self._next_unit()
if nxt is None:
return None
self.current = nxt
self.time = nxt.next_at
return self._advance()
def _advance(self) -> Unit:
self.turn += 1
unit = self.current
unit.reset_turn_flags()
for sk in unit.skills:
if sk.cooldown_left > 0:
sk.cooldown_left -= 1
self._tick_dot(unit)
self._tick_wilt(unit)
self._tick_hot(unit)
self._tick_seed(unit)
if self.winner is not None:
return unit
if not unit.alive:
return self.next_turn()
if unit.passive == "sunflower_incandescent":
allies = self.allies_of(unit)
if allies:
low = min(allies, key=lambda a: a.hp)
if low.hp < low.max_hp:
amount = max(1, int(unit.attack * (unit.passive_value or 0.7)))
self._heal(unit, low, amount)
if unit.passive == "marigold_golden":
unit.status[S_ATTACKED_THIS_TURN] = 1 # first hit this turn is reduced
if unit.passive == "moonflower_night":
for e in self.foes_of(unit):
self._deal_damage(unit, e, 8 + 4 * self.turn, crit=False, flat=True)
if unit.passive == "hydrangea_ph":
unit.status["form"] = "alkaline" if unit.status.get("form", "acidic") == "acidic" else "acidic"
if unit.passive == "sweetpea_scent":
# Snapshot which allies are already tethered this turn, so they can
# be cleared when her turn ends (after her attack healed them).
self._sweetpea_tethered = {id(a) for a in self.allies_of(unit)
if isinstance(a.status.get(S_TETHER), tuple)}
if unit.status.get(S_SLEEP, 0) > 0 or unit.status.get("stun", 0) > 0:
self.log.append(f"{unit.name} is asleep/stunned and cannot take a turn")
if unit.status.get("stun", 0) > 0:
unit.status.pop("stun", None)
self.log.append(f" {unit.name} shakes off the stun!")
return self.next_turn()
return unit
def _end_turn_status(self, unit: Unit) -> None:
for key in (S_SPEED_DOWN, S_CRIT_DOWN, S_ATTACK_DOWN, S_DEFENSE_DOWN,
S_HEAL_REDUCTION, S_ATTACK_UP, S_DEFENSE_UP,
S_SPEED_UP, S_INTOXICATE, S_OBLIVION, S_ABSORB, S_LYING_WAIT):
if key in unit.status:
value, dur = unit.status[key]
dur -= 1
if dur <= 0:
del unit.status[key]
else:
unit.status[key] = (value, dur)
# --- status ticking ------------------------------------------------
def _tick_dot(self, unit: Unit) -> None:
stacks = unit.status.get(S_DOT, [])
if not stacks:
return
remaining = []
for dmg, turns in stacks:
self.log.append(f"{unit.name} suffers {dmg} from a damage-over-time effect")
self._deal_damage(None, unit, dmg, crit=False, flat=True)
if self.winner is not None:
return
if turns > 1:
remaining.append((dmg, turns - 1))
if remaining:
unit.status[S_DOT] = remaining
else:
unit.status.pop(S_DOT, None)
def _tick_wilt(self, unit: Unit) -> None:
v = unit.status.get(S_WILT)
if not v:
return
total, turns = v
tick = max(1, total // turns)
self.log.append(f"{unit.name} wilts for {tick}")
self._deal_damage(None, unit, tick, crit=False, flat=True)
if self.winner is not None:
return
remaining = total - tick
if turns <= 1 or remaining <= 0:
unit.status.pop(S_WILT, None)
else:
unit.status[S_WILT] = (remaining, turns - 1)
def _tick_hot(self, unit: Unit) -> None:
stacks = unit.status.get(S_HOT, [])
if not stacks:
return
remaining = []
for amount, turns in stacks:
self._heal(None, unit, amount)
if turns > 1:
remaining.append((amount, turns - 1))
if remaining:
unit.status[S_HOT] = remaining
else:
unit.status.pop(S_HOT, None)
def _tick_seed(self, unit: Unit) -> None:
v = unit.status.get(S_SEED)
if not v:
return
power, turns = v
if turns <= 0:
self.log.append(f"A seed erupts on {unit.name}!")
self._deal_damage(None, unit, power, crit=False, flat=True)
unit.status.pop(S_SEED, None)
else:
unit.status[S_SEED] = (power, turns - 1)
# --- heal / shield -------------------------------------------------
def _heal(self, source: Optional[Unit], target: Unit, amount: int) -> int:
if not target.alive:
return 0
if isinstance(target.status.get(S_TETHER), tuple) and target.status[S_TETHER][0] > 0:
amount = int(amount * 1.3)
if target.status.get(S_HEAL_REDUCTION, (0, 0))[0] if isinstance(target.status.get(S_HEAL_REDUCTION), tuple) else False:
amount = int(amount * (1 - target.status[S_HEAL_REDUCTION][0] / 100.0))
if target.status.get(S_HEAL_BOOST, 0):
amount = int(amount * (1 + target.status[S_HEAL_BOOST] / 100.0))
yellow = self._rune_pct(target, "healing")
if yellow:
amount = int(amount * (1 + yellow))
healed = min(target.max_hp - target.hp, amount)
if healed > 0:
target.hp += healed
if self._action is None:
src = f" from {source.name}" if source else ""
self.log.append(f"{target.name} recovers {healed} HP{src}")
if self._action is not None:
self._action["heal"] += healed
self.events.append({"type": "heal", "source": source, "target": target, "amount": healed})
if source and source.passive == "daisy_gentle":
self._daisy_gentle(source, target)
return healed
def _shield(self, target: Unit, amount: int, source: Optional[Unit] = None) -> None:
if not target.alive:
return
blue = self._rune_pct(target, "shield")
if blue:
amount = int(amount * (1 + blue))
target.status[S_SHIELD] = target.status.get(S_SHIELD, 0) + amount
if self._action is None:
src = f" from {source.name}" if source else ""
self.log.append(f"{target.name} gains {amount} shield{src}")
if self._action is not None:
self._action["shield"] += amount
def _shield_power(self, user: Unit, base: int) -> int:
"""Shield amounts scale with the caster's Attack so they keep up with
growing health pools (like the Sunflower passive)."""
return int(base * (1 + (user.attack if user else 0) / 200))
# --- damage --------------------------------------------------------
def _damage_multipliers(self, attacker: Optional[Unit], target: Unit) -> float:
mult = 1.0
if attacker is None:
return mult
mult *= type_multiplier(attacker.plant_type, target.plant_type)
if target.status.get(S_DENTING, 0):
mult *= 1 + 0.03 * target.status[S_DENTING]
# zinnia_solar: enemies Zinnia has hit deal less damage
weaken = attacker.status.get(S_ZINNIA_WEAKEN, 0)
if weaken:
mult *= 1 - weaken / 100.0
# tulip_equinox: any ally of Tulip with a shield deals +10%
if attacker.status.get(S_SHIELD, 0) > 0:
team = self.players if attacker.team == TEAM_PLAYER else self.enemies
if any(u.passive == "tulip_equinox" for u in team):
mult *= 1.1
# hellebore_toxin: madness modifiers
if attacker.status.get(S_MADNESS, 0):
mult *= 1.5 if attacker.team == TEAM_PLAYER else 1.3
if target.status.get(S_MADNESS, 0):
mult *= 1.3 if target.team == TEAM_PLAYER else 1.5
if isinstance(target.status.get(S_TETHER), tuple) and target.status[S_TETHER][0] > 0:
mult *= 0.9
if attacker.passive == "hibiscus_intensifying" and target.max_hp > attacker.max_hp:
mult *= 1.25
# runes
red = self._rune_pct(attacker, "damage")
if red:
mult *= 1 + red
white = self._rune_pct(target, "damage_taken")
if white:
mult *= max(0.0, 1 - white)
return mult
def _rune_pct(self, unit: Unit, effect: str) -> float:
"""Total effect percentage (as a fraction) from the unit's equipped
runes matching `effect`. Runes stack additively."""
if not unit.runes:
return 0.0
return sum(pct for eff, pct in unit.runes if eff == effect)
def _deal_damage(self, attacker: Optional[Unit], target: Unit, power: int,
crit: bool = False, hits: int = 1, ignore_def_pct: float = 0.0,
flat: bool = False, label: str = "",
mult: float = 1.0) -> int:
total = 0
janus_blocked = False
for _ in range(max(1, hits)):
if not target.alive:
break
if flat:
dmg = power
else:
atk = self._attack(attacker) if attacker else 0
offense = power + int(atk * 0.5)
dfn = self._defense(target, ignore_def_pct)
dmg = int(offense * (1 - dfn / (dfn + DEF_K)))
dmg = max(1, dmg)
dmg = int(dmg * self._damage_multipliers(attacker, target))
dmg += 5 * self._status_value(attacker, S_SNOWFLAKES) if attacker else 0
if target.status.get(S_VULNERABLE, 0) > 0:
dmg = int(dmg * VULNERABLE_MULT)
if crit:
dmg = int(dmg * CRIT_MULT)
dmg = self._apply_damage_taken_mods(attacker, target, dmg)
dmg = int(dmg * mult)
if dmg <= 0:
continue
if target.status.get(S_PREVENT_FIRST_DAMAGE, 0):
target.status.pop(S_PREVENT_FIRST_DAMAGE, None)
# Winter Jasmine's Janus Bloom (or the Protective Aura blessing)
# softens the first hit: it deals 80% less damage instead of
# being fully blocked.
team = self.players if target.team == TEAM_PLAYER else self.enemies
if any(u.passive == "jasmine_janus" for u in team):
self.log.append(f" {target.name}'s Janus Bloom softens the hit by 80%!")
else:
self.log.append(f" {target.name} is protected (takes 20% of the hit)!")
dmg = max(1, int(dmg * 0.2))
self._hit_damage[id(target)] = dmg
janus_blocked = True
split = 0
if attacker and self._status_value(attacker, S_INTOXICATE) > 0:
split = dmg // 2 # Intoxicate: half splashes onto the attacker's own ally
actual = self._absorb(attacker, target, dmg - split)
total += actual
if actual > 0:
if self._action is None:
suffix = f" (from {label})" if label else ""
self.log.append(f" {target.name} takes {actual} damage{suffix}")
self.events.append({"type": "damage", "source": attacker, "target": target, "amount": actual})
self._after_hit(attacker, target, actual)
if split > 0:
teammates = [u for u in self.units if u.alive and u.team == attacker.team and u is not attacker]
if teammates:
other = random.choice(teammates)
splashed = self._absorb(attacker, other, split)
total += splashed
if splashed > 0:
if self._action is None:
self.log.append(f" {other.name} takes {splashed} from Intoxicate!")
self.events.append({"type": "damage", "source": attacker, "target": other, "amount": splashed})
if self.winner is not None:
break
if attacker is not None and total > 0:
wilt_pct = self._rune_pct(attacker, "wilt")
if wilt_pct:
self._apply_wilt_damage(target, int(total * wilt_pct))
if total > 0 or not janus_blocked:
self._hit_damage[id(target)] = total
return total
def _apply_damage_taken_mods(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
if target.status.get(S_ATTACKED_THIS_TURN, 0):
dmg = int(dmg * 0.7) # marigold: first hit each turn is reduced
target.status.pop(S_ATTACKED_THIS_TURN, None)
if isinstance(target.status.get(S_ABSORB), tuple) and target.status[S_ABSORB][0] > 0:
dmg = int(dmg * 0.8)
return max(0, dmg)
def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
self._consume_hits_statuses(target)
reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0
shield = target.status.get(S_SHIELD, 0)
if shield > 0:
if shield >= dmg:
target.status[S_SHIELD] = shield - dmg
self.log.append(f" {target.name}'s shield absorbs {dmg}")
self.events.append({"type": "shield", "target": target, "amount": dmg})
if target.passive == "camelia_frigid":
target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + 2
if reflect and attacker:
self._deal_damage(None, attacker, 40, crit=False, flat=True)
if target.status.get("vigil", 0):
self._vigil_counter(attacker, target)
return 0
dmg -= shield
self.log.append(f" {target.name}'s shield shatters!")
self.events.append({"type": "shield", "target": target, "amount": shield})
target.status.pop(S_SHIELD, None)
if reflect and attacker:
self._deal_damage(None, attacker, 40, crit=False, flat=True)
target.hp -= dmg
if target.passive == "camelia_frigid":
target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + 1
if target.status.get("vigil", 0):
self._vigil_counter(attacker, target)
self._passive_on_taken(target, dmg, attacker)
if target.hp <= 0:
self._kill(target, attacker)
return dmg
def _vigil_counter(self, attacker: Optional[Unit], target: Unit) -> None:
if attacker and target.status.get("vigil_source"):
self._deal_damage(target.status["vigil_source"], attacker, 45, crit=False)
target.status.pop("vigil", None)
target.status.pop("vigil_source", None)
def _passive_on_taken(self, target: Unit, dmg: int, attacker: Optional[Unit] = None) -> None:
if target.passive == "daffodil_toxic" and attacker and attacker.alive:
if attacker.status.get(S_WILT):
remaining = attacker.status[S_WILT][0]
self._heal(target, target, int(remaining * 0.5))
self._note(f" {target.name}'s Toxic Sap drains {attacker.name}'s Wilt!")
else:
self._apply_wilt_damage(attacker, int(dmg * 0.5))
elif target.passive == "rose_briars" and attacker and attacker.alive:
self._apply_wilt_damage(attacker, int(dmg * 0.4))
if target.passive == "chrys_longevity":
target.status["def_stack"] = target.status.get("def_stack", 0) + 1
if target.passive == "dandelion_cover" and not target.status.get("cover_used", False):
if target.hp > 0 and target.hp < target.max_hp * 0.4:
target.status["cover_used"] = True
self._shield(target, int(target.max_hp * 0.5))
self.log.append(f" {target.name} is shielded by Cover!")
def _after_hit(self, attacker: Optional[Unit], target: Unit, actual: int) -> None:
if attacker is None:
return
if attacker.passive == "zinnia_solar":
stacks = min(30, target.status.get(S_ZINNIA_WEAKEN, 0) + 1)
target.status[S_ZINNIA_WEAKEN] = stacks
if attacker.passive == "flytrap_feed":
self._heal(attacker, attacker, int(actual * 0.2))
if isinstance(target.status.get(S_LYING_WAIT), tuple) and target.status[S_LYING_WAIT][0] > 0:
v = target.status[S_LYING_WAIT]
target.status[S_LYING_WAIT] = (v[0] + 10, v[1])
if attacker.passive == "poppy_hypnotic" and roll_crit(0.10):
target.status[S_INTOXICATE] = (1, 1)
target.status[S_OBLIVION] = (1, 1)
self.log.append(f" {target.name} is Intoxicated and under Oblivion!")
if attacker.passive == "sweetpea_scent":
for a in self.allies_of(attacker):
if isinstance(a.status.get(S_TETHER), tuple) and a.status[S_TETHER][0] > 0:
self._heal(attacker, a, int(actual * 0.5))
def _consume_hits_statuses(self, target: Unit) -> None:
if target.status.get(S_SLEEP, 0) > 0:
target.status[S_SLEEP] -= 1
if target.status[S_SLEEP] <= 0:
del target.status[S_SLEEP]
self.log.append(f" {target.name} wakes up!")
if target.status.get(S_VULNERABLE, 0) > 0:
target.status[S_VULNERABLE] -= 1
if target.status[S_VULNERABLE] <= 0:
del target.status[S_VULNERABLE]
self.log.append(f" {target.name} is no longer vulnerable")
def _kill(self, unit: Unit, attacker: Optional[Unit] = None) -> None:
unit.alive = False
unit.hp = 0
unit.status.pop(S_SHIELD, None) # dead units keep no shield bar
self.log.append(f"{unit.name} is defeated!")
self.events.append({"type": "kill", "target": unit})
if unit.team not in self.lotus_used:
team = self.players if unit.team == TEAM_PLAYER else self.enemies
lotus = next((p for p in team if p.passive == "lotus_rebirth" and p.alive), None)
if lotus:
self.lotus_used.add(unit.team)
unit.alive = True
unit.hp = int(unit.max_hp * 0.5)
unit.status = {}
self.log.append(f"{lotus.name}'s Rebirth revives {unit.name} at 50% HP!")
return
if unit.team not in self.relic_used and "second_wind" in self.relics:
self.relic_used.add(unit.team)
unit.alive = True
unit.hp = int(unit.max_hp * 0.5)
unit.status = {}
self.log.append(f"{unit.name} is saved by Second Wind at 50% HP!")
return
if attacker is not None:
team = self.players if unit.team == TEAM_PLAYER else self.enemies
for p in team:
if p.passive == "snowdrop_winter" and p.alive:
self._add_dot(attacker, 45, 2)
self.log.append(f"{p.name}'s Winter Breeze Chills {attacker.name}!")
if unit.status.get(S_DOT):
team = self.players if unit.team == TEAM_PLAYER else self.enemies
others = [u for u in team if u.alive and u is not unit]
if others:
spread_to = min(others, key=lambda u: u.hp)
for dmg, turns in unit.status[S_DOT]:
self._add_dot(spread_to, dmg, turns)
self._apply_corrosion_debuff(spread_to)
self.log.append(f"{unit.name}'s corruption spreads to {spread_to.name}!")
if not self.alive_enemies():
self.winner = TEAM_PLAYER
self.log.append("VICTORY! All enemies defeated.")
elif not self.alive_players():
self.winner = TEAM_ENEMY
self.log.append("DEFEAT... All allies have fallen.")
# --- status helpers ------------------------------------------------
def _apply_wilt_damage(self, target: Unit, total: int) -> None:
if total <= 0:
return
turns = WILT_TURNS - 1 if target.passive == "magnolia_nobility" else WILT_TURNS
cur = target.status.get(S_WILT)
if cur:
target.status[S_WILT] = (cur[0] + total, turns)
else:
target.status[S_WILT] = (total, turns)
self._note(f"{target.name} is afflicted with Wilt ({total} over {turns} turns)")
def _add_dot(self, target: Unit, dmg: int, turns: int) -> None:
if target.passive == "magnolia_nobility":
turns = max(1, turns - 1)
stacks = target.status.get(S_DOT, [])
stacks.append((dmg, turns))
target.status[S_DOT] = stacks
self._note(f"{target.name} is afflicted ({dmg}/turn for {turns} turns)")
def _apply_corrosion_debuff(self, target: Unit) -> None:
key, value = random.choice(_CORROSION_DEBUFFS)
target.status[key] = (value, 2)
name = _STATUS_NAMES.get(key, key.replace("_", " ").title())
self._note(f"{target.name} is {name.lower()} (-{value}) for 2 turns")
def _remove_debuffs(self, unit: Unit) -> None:
for k in _DEBUFF_KEYS:
unit.status.pop(k, None)
# --- actions -------------------------------------------------------
def valid_skill_targets(self, user: Unit, skill: Skill) -> List[Unit]:
if not user.alive or skill.cooldown_left > 0:
return []
if skill.requires_form and skill.requires_form != user.status.get("form"):
return []
if skill.target == TARGET_ENEMY:
return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill))
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
return self.foes_of(user)
if skill.target == TARGET_ALLY:
return self.allies_of(user)
if skill.target == TARGET_ALL_ALLIES:
return self.allies_of(user)
if skill.target == TARGET_SELF:
return [user]
if skill.target == TARGET_ANY:
return self.alive_players() + self.alive_enemies()
return []
def _effective_hits(self, unit: Unit, skill: Skill, target: Optional[Unit]) -> int:
hits = skill.hits
if skill.mechanic == "hits_per_effect" and target is not None:
hits += len([k for k in target.status if k != "form"])
elif skill.mechanic == "hits_per_self_effect":
hits += len([k for k in unit.status if k != "form"])
return hits
def _skill_score(self, unit: Unit, skill: Skill) -> int:
"""Selection score for a ready skill (lower = better). Skills whose hit
count scales with effects (hits_per_effect / hits_per_self_effect) fold
their effective hit count in, so the AI picks whichever attacks more;
everything else keeps its static priority."""
if skill.mechanic == "hits_per_self_effect":
hits = self._effective_hits(unit, skill, None)
return skill.priority - hits
if skill.mechanic == "hits_per_effect":
targets = self.valid_skill_targets(unit, skill)
if targets:
hits = max(self._effective_hits(unit, skill, t) for t in targets)
else:
hits = skill.hits
return skill.priority - hits
return skill.priority
def best_skill(self, unit: Unit) -> Optional[Skill]:
ready = [s for s in unit.skills
if s.cooldown_left <= 0 and (not s.requires_form or s.requires_form == unit.status.get("form"))]
if not ready:
return None
if isinstance(unit.status.get(S_OBLIVION), tuple) and unit.status[S_OBLIVION][0] > 0:
return min(ready, key=lambda s: s.power)
allies_full = all(a.hp >= a.max_hp for a in self.allies_of(unit))
def heal_only(s: Skill) -> bool:
# Only instant heals are "wasted" at full HP. HoT skills apply over
# time, so they're fine to pick even when everyone is full.
if s.kind != "heal":
return False
for eff in s.effects:
if eff.kind in (EFF_WILT, EFF_DOT):
return False
return True
def wasted(s: Skill) -> bool:
if heal_only(s) and allies_full:
return True
if s.mechanic == "puff_up":
# self heal + cleanse: pointless if full HP and nothing to cleanse
return unit.hp >= unit.max_hp and not any(k in unit.status for k in _DEBUFF_KEYS)
return False
candidates = [s for s in ready if not wasted(s)]
if not candidates:
candidates = ready
# Cleanse (puff_up) is a priority when the unit is debuffed: it heals
# AND removes the debuffs, so prefer it over an equally-ready skill.
if any(k in unit.status for k in _DEBUFF_KEYS):
cleanse = next((s for s in candidates if s.mechanic == "puff_up"), None)
if cleanse is not None:
return cleanse
return min(candidates, key=lambda s: self._skill_score(unit, s))
def use_skill(self, user: Unit, skill: Skill, target: Optional[Unit] = None) -> bool:
if user is not self.current or not user.alive:
return False
if skill.cooldown_left > 0:
return False
if skill.requires_form and skill.requires_form != user.status.get("form"):
return False
if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0:
ready = [s for s in user.skills if s.cooldown_left <= 0]
if ready and min(ready, key=lambda s: s.power) is not skill:
return False # Oblivion: only the weakest skill may be used
if skill.target == TARGET_ALL_ENEMIES:
target = None
elif skill.target == TARGET_ALL_ALLIES:
target = None
elif skill.target == TARGET_SELF:
target = user
elif skill.target == TARGET_TWO_ENEMIES:
target = None
elif target is None or target not in self.valid_skill_targets(user, skill):
return False
if not skill.extra_action:
user.acted = True
skill.cooldown_left = skill.cooldown
self._hit_damage = {}
self._action = {"heal": 0, "shield": 0, "effects": []}
log_index = len(self.log)
self._use_skill_primary(user, skill, target)
self._use_skill_effects(user, skill, target)
self._log_action(user, skill, target, log_index)
self._action = None
if skill.target == TARGET_ENEMY or skill.target == TARGET_ALLY or skill.target == TARGET_SELF:
user.first_skill_used = True
return True
def _log_action(self, user: Unit, skill: Skill, target: Optional[Unit], index: int) -> None:
dmg = sum(self._hit_damage.values())
heal = self._action["heal"] if self._action else 0
shield = self._action["shield"] if self._action else 0
effects = self._action["effects"] if self._action else []
# dedupe effects by name, keeping the longest duration
best = {}
for name, dur, hits in effects:
if name not in best or dur > best[name][1]:
best[name] = (name, dur, hits)
effects = list(best.values())
if skill.target == TARGET_ALL_ENEMIES or skill.target == TARGET_TWO_ENEMIES:
tdesc = "all enemies"
elif skill.target == TARGET_ALL_ALLIES:
tdesc = "all allies"
elif skill.target == TARGET_SELF:
tdesc = "itself"
else:
tdesc = target.name if target else "the target"
results = []
if dmg > 0:
results.append(f"for {dmg} damage")
if heal > 0:
results.append(f"for {heal} health")
if shield > 0:
results.append(f"for {shield} shield")
if results:
verb = "attacks"
if heal > 0 and dmg == 0 and shield == 0:
verb = "heals"
elif shield > 0 and dmg == 0 and heal == 0:
verb = "shields"
elif dmg > 0 and heal > 0 and shield == 0:
verb = "attacks and heals"
line = f"{user.name} {verb} {tdesc} " + " and ".join(results)
else:
line = f"{user.name} uses {skill.name} on {tdesc}!"
if effects:
parts = []
for name, dur, hits in effects:
if dur:
unit_word = "hit(s)" if hits else "turn(s)"
parts.append(f"{name} for {dur} {unit_word}")
else:
parts.append(name)
line += " (and applied " + ", ".join(parts) + ")"
self.log.insert(index, {"text": line, "team": user.team})
def _resolve_power(self, user: Unit, skill: Skill, target: Unit) -> int:
power = skill.power
if skill.mechanic == "wilt_boost" and target.status.get(S_WILT):
power = skill.power_alt
elif skill.mechanic == "low_hp_boost" and target.hp < target.max_hp * 0.4:
power = skill.power_alt
elif skill.mechanic == "high_hp_boost" and target.hp > target.max_hp * 0.8:
power = skill.power_alt
elif skill.mechanic == "thresh_boost" and (target.hp > target.max_hp * 0.8 or target.hp < target.max_hp * 0.2):
power = skill.power_alt
return power
def _use_skill_primary(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None:
ignore_def = 0.0
if user.passive == "peony_robust":
ignore_def = 0.2
if skill.mechanic == "ignore_defense":
ignore_def = min(1.0, ignore_def + (skill.power_alt or 0.3))
hits = skill.hits
if skill.mechanic == "hits_per_effect" and target is not None:
hits += len([k for k in target.status if k != "form"])
elif skill.mechanic == "hits_per_self_effect":
hits += len([k for k in user.status if k != "form"])
if skill.kind == "heal":
for t in ([target] if target else self.allies_of(user)):
if skill.mechanic == "full_heal":
amount = t.max_hp
else:
# heals keep pace with damage and growing health pools:
# base + half the caster's Attack
amount = skill.power + int(self._attack(user) * 0.5)
if skill.mechanic == "pacify" and t.hp < t.max_hp * 0.4:
t.status.pop(S_MADNESS, None)
self.log.append(f" {t.name}'s Madness is soothed away!")
self._heal(user, t, amount)
return
if skill.kind == "shield":
for t in ([target] if target else self.allies_of(user)):
self._shield(t, self._shield_power(user, skill.power), source=user)
return
if skill.kind == "utility":
self._utility_skill(user, skill, target)
return
if skill.mechanic == "ramping":
skill.power += 5
if skill.mechanic == "moon_double":
hits = 2 if roll_crit(min(0.9, 0.2 + 0.1 * self.turn)) else 1
if skill.mechanic == "dissolve":
skill.power += self._status_value(user, S_LYING_WAIT)
power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
targets = self.foes_of(user)
if skill.target == TARGET_TWO_ENEMIES:
targets = random.sample(targets, min(2, len(targets)))
for t in targets:
power = self._resolve_power(user, skill, t) + power_bonus
self._deal_damage(user, t, power, crit=roll_crit(self._crit(user)),
hits=hits, ignore_def_pct=ignore_def,
label=f"{user.name}'s {skill.name}", mult=AOE_MULT)
if skill.mechanic == "strip_buffs_aoe":
self._strip_buffs(t)
if skill.mechanic == "delay_all":
t.next_at += self._interval(t) * 0.2
if self._action is None:
self.log.append(f" {t.name} is delayed!")
return
t = target
power = self._resolve_power(user, skill, t) + power_bonus
self._deal_damage(user, t, power, crit=roll_crit(self._crit(user)),
hits=hits, ignore_def_pct=ignore_def,
label=f"{user.name}'s {skill.name}")
self._after_primary_damage(user, skill, t)
def _after_primary_damage(self, user: Unit, skill: Skill, target: Unit) -> None:
dealt = self._hit_damage.get(id(target), 0)
if skill.mechanic == "consume_wilt" and target.status.get(S_WILT):
self._heal(user, user, dealt)
elif skill.mechanic == "spore_siphon":
pct = (skill.power_alt or 25) / 100.0
self._heal(user, user, int(dealt * pct))
elif skill.mechanic == "shield_lifesteal":
pct = (skill.power_alt or 15) / 100.0
self._shield(user, int(dealt * pct), source=user)
if skill.mechanic == "ph_cloud":
if user.status.get("form") == "alkaline":
for a in self.allies_of(user):
self._heal(user, a, int(dealt * 0.3))
else:
self._apply_wilt_damage(target, int(dealt * 0.3))
if skill.mechanic == "heal_random_ally":
allies = self.allies_of(user)
if allies:
self._heal(user, random.choice(allies), 40)
if skill.mechanic == "strip_buffs":
self._strip_buffs(target)
if skill.mechanic == "tether_lowest":
allies = [a for a in self.allies_of(user) if a is not user]
if allies:
low = min(allies, key=lambda a: a.hp)
low.status[S_TETHER] = (1, 1)
self.log.append(f" {low.name} is tethered to {user.name}")
def _strip_buffs(self, unit: Unit) -> None:
for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD):
if unit.status.pop(k, None) is not None:
self.log.append(f" {unit.name} loses a buff ({k})")
break
def _use_skill_effects(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None:
if skill.mechanic == "crimson_brew":
user.status[S_ATTACK_UP] = (10, 2)
if self._action is not None:
self._action["effects"].append(("Attack up", 2, False))
if not skill.effects:
return
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
targets = self.foes_of(user)
elif skill.target == TARGET_ALL_ALLIES:
targets = self.allies_of(user)
elif target is not None:
targets = [target]
else:
targets = self.foes_of(user)
for eff in skill.effects:
for t in targets:
if not t.alive:
continue
if eff.kind == EFF_SHIELD:
self._shield(t, self._shield_power(user, eff.value), source=user)
elif eff.kind == EFF_WILT:
self._apply_wilt_damage(t, int(self._hit_damage.get(id(t), skill.power) * eff.value / 100.0))
if self._action is not None:
self._action["effects"].append(("Wilt", WILT_TURNS, False))
elif eff.kind == EFF_DOT:
self._add_dot(t, eff.value, eff.duration)
if user.passive == "nightshade_corruption":
self._apply_corrosion_debuff(t)
if self._action is not None:
self._action["effects"].append(("Damage over time", eff.duration, False))
elif eff.kind == EFF_HOT:
stacks = t.status.get(S_HOT, [])
stacks.append((eff.value, eff.duration))
t.status[S_HOT] = stacks
if self._action is not None:
self._action["effects"].append(("Heal over time", eff.duration, False))
elif eff.kind == EFF_HEAL:
self._heal(user, t, eff.value + int(self._attack(user) * 0.5))
elif eff.kind == EFF_STATUS:
self._apply_status_effect(user, t, eff)
def _apply_status_effect(self, user: Unit, target: Unit, eff) -> None:
key, value, duration = eff.key, eff.value, eff.duration
name = _STATUS_NAMES.get(key, key.replace("_", " ").title())
dur, hits = 0, False
if key in (S_SLEEP, S_VULNERABLE):
dur, hits = value, True
elif duration > 0:
dur = duration
detail = ""
if key == S_SLEEP:
target.status[S_SLEEP] = value
detail = f"{target.name} falls asleep!"
elif key == S_VULNERABLE:
target.status[S_VULNERABLE] = target.status.get(S_VULNERABLE, 0) + value
detail = f"{target.name} is vulnerable for {target.status[S_VULNERABLE]} hit(s)"
elif key == S_MADNESS:
target.status[S_MADNESS] = target.status.get(S_MADNESS, 0) + value
detail = f"{target.name} now has Madness ({target.status[S_MADNESS]})"
dur, hits = 0, False
elif key == S_DENTING:
target.status[S_DENTING] = target.status.get(S_DENTING, 0) + value
detail = f"{target.name} permanently takes {value}% more damage"
dur, hits = 0, False
elif key == S_ICICLES:
target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + value
elif key == S_SNOWFLAKES:
target.status[S_SNOWFLAKES] = target.status.get(S_SNOWFLAKES, 0) + value
detail = f"{target.name} gathers snowflakes (+{value} hit damage)"
dur, hits = 0, False
elif key == "stun":
if target.passive == "magnolia_nobility":
detail = f"{target.name} is immune to stuns!"
else:
target.status["stun"] = value
detail = f"{target.name} is stunned!"
else:
target.status[key] = (value, duration)
detail = f"{target.name} gains {key.replace('_', ' ')} ({value})"
if self._action is not None:
self._action["effects"].append((name, dur, hits))
elif detail:
self.log.append(detail)
def _utility_skill(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None:
if skill.mechanic == "reset_cooldowns":
t = target or user
for s in t.skills:
if s is not skill: # the skill being cast keeps its own cooldown
s.cooldown_left = 0
self._note(f"{t.name}'s cooldowns are reset!")
elif skill.mechanic == "extend_timers":
for u in self.allies_of(user) + self.foes_of(user):
for k, v in list(u.status.items()):
if isinstance(v, tuple):
u.status[k] = (v[0], v[1] + 1)
self._note("Alkaline Blossom extends all buffs and debuffs!")
elif skill.mechanic == "clear_debuffs_allies":
for a in self.allies_of(user):
self._remove_debuffs(a)
self._note("All allies are purified!")
elif skill.mechanic == "shatter_icicles":
n = user.status.get(S_ICICLES, 0)
user.status[S_ICICLES] = 0
for e in self.foes_of(user):
self._deal_damage(user, e, skill.power * max(1, n), crit=roll_crit(self._crit(user)),
mult=AOE_MULT)
elif skill.mechanic == "snowflakes":
for a in self.allies_of(user):
a.status[S_SNOWFLAKES] = a.status.get(S_SNOWFLAKES, 0) + 10
self._note("All allies gain +10 damage on hits!")
elif skill.mechanic == "perma_hp_def":
for a in self.allies_of(user):
bonus = int(a.max_hp * 0.15)
a.max_hp += bonus
a.hp += bonus
a.defense = int(a.defense * 1.10)
self._note("Golden Dynasty empowers all allies!")
elif skill.mechanic == "lying_wait":
user.status[S_LYING_WAIT] = (0, 1)
self._note(f"{user.name} lies in wait...")
elif skill.mechanic == "vigil":
t = target if target is not None else user
self._remove_debuffs(t)
self._shield(t, self._shield_power(user, 60))
t.status["vigil"] = 1
t.status["vigil_source"] = user
self._note(f"{t.name} is cleansed, shielded, and readies a counter!")
elif skill.mechanic == "sow":
target.status[S_SEED] = (115, 2)
self._note(f"{target.name} is seeded... it will erupt in 2 turns!")
elif skill.mechanic == "puff_up":
self._heal(user, user, int(user.max_hp * 0.3))
self._remove_debuffs(user)
self._note(f"{user.name} puffs up and cleanses itself!")
elif skill.mechanic == "absorb":
user.status[S_ABSORB] = (20, 1)
self._note(f"{user.name} hardens, taking 20% less damage!")
def _note(self, msg: str) -> None:
"""Log a detail line, but not while resolving a skill action (those go
into the one-line action summary instead)."""
if self._action is None:
self.log.append(msg)
def _daisy_gentle(self, source: Unit, target: Unit) -> None:
for k in _DEBUFF_KEYS:
if target.status.pop(k, None) is not None:
self.log.append(f" Gentle Encouragement removes a debuff from {target.name}")
break
target.status[S_SPEED_UP] = (3, 1)
# --- AI ------------------------------------------------------------
def enemy_ai(self, unit: Unit) -> bool:
"""Returns True if the enemy used a skill (extra-action skills leave
the unit unacted so the caller may let it act again)."""
if unit is not self.current or unit.team != TEAM_ENEMY or not unit.alive:
return False
foes = self.foes_of(unit)
if not foes:
return False
if isinstance(unit.status.get(S_OBLIVION), tuple) and unit.status[S_OBLIVION][0] > 0:
ready = [s for s in unit.skills if s.cooldown_left <= 0]
if not ready:
return False
skill = min(ready, key=lambda s: s.power)
target = min(foes, key=lambda t: t.hp)
return self.use_skill(unit, skill, target)
ready = sorted((s for s in unit.skills
if s.cooldown_left <= 0
and (not s.requires_form or s.requires_form == unit.status.get("form"))),
key=lambda s: self._skill_score(unit, s))
for skill in ready:
if skill.kind in ("heal", "shield"):
low = [a for a in self.allies_of(unit) if a.hp < a.max_hp * 0.7]
if skill.kind == "shield" and skill.target != TARGET_SELF:
target = self._shield_target(unit)
if target:
return self.use_skill(unit, skill, target)
continue
if low:
return self.use_skill(unit, skill, min(low, key=lambda a: a.hp))
continue
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
if len(foes) >= 2:
return self.use_skill(unit, skill, None)
continue
if skill.target in (TARGET_ALLY, TARGET_ALL_ALLIES):
allies = self.allies_of(unit)
if not allies:
continue
if skill.mechanic == "reset_cooldowns":
if not any(x.cooldown_left > 0 for a in allies for x in a.skills):
continue
target = max(allies, key=lambda a: sum(
x.cooldown_left for x in a.skills if x.cooldown_left > 0))
return self.use_skill(unit, skill, target)
return self.use_skill(unit, skill, min(allies, key=lambda a: a.hp))
if skill.target == TARGET_SELF:
return self.use_skill(unit, skill, None)
if skill.target == TARGET_ANY:
targets = self.valid_skill_targets(unit, skill)
if targets:
return self.use_skill(unit, skill, min(targets, key=lambda t: t.hp))
continue
if skill.target == TARGET_ENEMY:
targets = self.valid_skill_targets(unit, skill)
if targets:
target = self._best_target(unit, skill, targets)
return self.use_skill(unit, skill, target)
return False
def _best_target(self, unit: Unit, skill: Skill, targets: List[Unit]) -> Unit:
"""Pick the best target for `skill`: for effect-scaling hits, prefer the
enemy carrying the most effects (ties -> lowest HP); otherwise lowest HP."""
if skill.mechanic == "hits_per_effect":
return max(targets, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp))
return min(targets, key=lambda t: t.hp)
def _shield_target(self, unit: Unit) -> Optional[Unit]:
"""Pick the best ally to shield. Prefer the tank when no ally has taken
damage yet (pre-emptive shield), and also when the tank is the lowest-HP
ally while everyone else is fine."""
allies = [a for a in self.allies_of(unit) if a.alive]
if not allies:
return None
tanks = [a for a in allies if a.role == ROLE_TANK]
if tanks:
if all(a.hp >= a.max_hp for a in allies):
return tanks[0] # nobody damaged yet -> shield a tank
lowest = min(allies, key=lambda a: a.hp)
if lowest.role == ROLE_TANK:
return lowest # tank is the most injured -> shield it
return min(allies, key=lambda a: a.hp)