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, SHIELD_DURATION, 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) pct += self._rune_pct(unit, "crit") * 100 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) # Shields decay like any buff: each application counts down SHIELD_DURATION # turns from when it was applied, independently of newer shields. shields = unit.status.get(S_SHIELD) if shields: remaining = [(amount, turns - 1) for amount, turns in shields if turns > 1] if remaining: unit.status[S_SHIELD] = remaining else: unit.status.pop(S_SHIELD, None) # --- 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)) # Each application has its own decay timer; stacking does NOT refresh # shields already up (they still decay SHIELD_DURATION turns after being # applied). target.status.setdefault(S_SHIELD, []).append((amount, SHIELD_DURATION)) 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_total(self, unit: Unit) -> int: """Total shield remaining across all applications.""" return sum(amount for amount, _ in unit.status.get(S_SHIELD, [])) 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 self._shield_total(attacker) > 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_list = target.status.get(S_SHIELD, []) shield = sum(amount for amount, _ in shield_list) if shield > 0: if shield >= dmg: # Consume the damage across applications (oldest first). remaining = dmg new_shields = [] for amount, turns in shield_list: if remaining <= 0: new_shields.append((amount, turns)) elif amount > remaining: new_shields.append((amount - remaining, turns)) remaining = 0 else: remaining -= amount if new_shields: target.status[S_SHIELD] = new_shields else: target.status.pop(S_SHIELD, None) 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: if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3: return [] # Landslide only becomes available at 3 stacks 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.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3: return False if user.acted and not skill.extra_action: return False # a unit acts once per turn (extra-action skills still allowed) 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.mechanic == "boss_landslide": power_bonus += 55 * user.status.get("boss_stacks", 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": # Only pick from allies that are missing HP, so the heal always # lands on someone it can actually help (a random full-HP ally # would silently heal for 0). injured = [a for a in self.allies_of(user) if a.hp < a.max_hp] if injured: self._heal(user, random.choice(injured), skill.power) 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}") if skill.mechanic == "boss_stack": user.status["boss_stacks"] = min(3, user.status.get("boss_stacks", 0) + 1) self.log.append(f" {user.name}'s power stacks ({user.status['boss_stacks']}/3).") if skill.mechanic == "boss_landslide": user.status["boss_stacks"] = 0 self.log.append(f" {user.name} unleashes Landslide and releases all stacks!") 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)