1190 lines
54 KiB
Python
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)
|