Files
Flora/tests/test_battle.py
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

851 lines
37 KiB
Python

import atexit
import os
import pathlib
import tempfile
import unittest
# make()/pick_team() read the save file (e.g. injected abilities, levels), so
# point the engine at a fresh empty save to keep battle tests hermetic.
import engine.collection as collection
_fd, _tmp_path = tempfile.mkstemp(suffix=".json")
os.close(_fd)
_TMP = pathlib.Path(_tmp_path)
_ORIG_DATA_FILE = collection._DATA_FILE
collection._DATA_FILE = _TMP
@atexit.register
def _restore_collection():
collection._DATA_FILE = _ORIG_DATA_FILE
if _TMP.exists():
_TMP.unlink()
from engine.battle import Battle
from engine.plants import make, make_leveled
from engine.roster import all_names, pick_team
from engine.skills import (
S_DOT, S_INTOXICATE, S_SHIELD, S_SLEEP, S_VULNERABLE, S_WILT,
EFF_STATUS, TARGET_ENEMY,
Effect, Skill, roll_crit,
)
from engine.types import TYPE_ADVANTAGE_MULT, TYPE_DISADVANTAGE_MULT, type_multiplier
from engine.unit import ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit
def make_unit(name, team, ptype, atk=50, spd=5, dfn=20, hp=100, skills=None, crit=0.0, role="", passive=""):
return Unit(name, team, ptype, max_hp=hp, attack=atk, speed=spd, defense=dfn,
skills=skills or [], crit=crit, role=role, passive=passive)
class TypeAdvantageTest(unittest.TestCase):
def test_advantage_cycle(self):
self.assertEqual(type_multiplier("radiant", "nocturnal"), TYPE_ADVANTAGE_MULT)
self.assertEqual(type_multiplier("nocturnal", "ice"), TYPE_ADVANTAGE_MULT)
self.assertEqual(type_multiplier("ice", "garden"), TYPE_ADVANTAGE_MULT)
self.assertEqual(type_multiplier("garden", "wildflower"), TYPE_ADVANTAGE_MULT)
self.assertEqual(type_multiplier("wildflower", "radiant"), TYPE_ADVANTAGE_MULT)
self.assertEqual(type_multiplier("nocturnal", "radiant"), TYPE_DISADVANTAGE_MULT)
self.assertEqual(type_multiplier("radiant", "radiant"), 1.0)
def test_damage_gets_type_bonus(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit])
t = make_unit("T", TEAM_ENEMY, "nocturnal", dfn=20, hp=100)
b = Battle([a], [t])
b.start()
b.use_skill(a, hit, t)
base = int((10 + 25) * (1 - 20 / (20 + 75)))
self.assertEqual(t.hp, 100 - int(base * TYPE_ADVANTAGE_MULT))
def test_damage_gets_type_penalty(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=50, skills=[hit])
t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20, hp=100)
b = Battle([a], [t])
b.start()
b.use_skill(a, hit, t)
base = int((10 + 25) * (1 - 20 / (20 + 75)))
self.assertEqual(t.hp, 100 - int(base * TYPE_DISADVANTAGE_MULT))
def test_heal_unaffected_by_type(self):
mend = Skill("Mend", power=40, kind="heal", target="ally")
a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=50, skills=[mend])
ally = make_unit("Ally", TEAM_PLAYER, "radiant", hp=100)
ally.hp = 30
t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20)
b = Battle([a, ally], [t])
b.start()
b.use_skill(a, mend, ally)
# heals scale with half the caster's Attack, but are unaffected by type
self.assertEqual(ally.hp, 30 + 40 + int(50 * 0.5))
class DamageTest(unittest.TestCase):
def test_attack_defense_formula(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100)
b = Battle([a], [t])
b.start()
b.use_skill(a, hit, t)
self.assertEqual(t.hp, 100 - int(35 * (1 - 20 / 95)))
def test_higher_defense_means_less_damage(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit])
low = make_unit("Low", TEAM_ENEMY, "garden", dfn=20, hp=500)
high = make_unit("High", TEAM_ENEMY, "garden", dfn=70, hp=500)
b1 = Battle([a], [low])
b1.start()
b1.use_skill(a, hit, low)
b2 = Battle([a], [high])
b2.start()
b2.use_skill(a, hit, high)
self.assertGreater(500 - low.hp, 500 - high.hp)
def test_min_one_damage(self):
hit = Skill("Hit", power=5, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=10, skills=[hit])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=100, hp=100)
b = Battle([a], [t])
b.start()
b.use_skill(a, hit, t)
self.assertLess(t.hp, 100)
def test_crit_doubles(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, crit=1.0, skills=[hit])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100)
b = Battle([a], [t])
b.start()
b.use_skill(a, hit, t)
base = int(35 * (1 - 20 / 95))
self.assertEqual(t.hp, 100 - base * 2)
def test_vulnerable_boosts(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
mark = Skill("Mark", power=10, target=TARGET_ENEMY,
effects=[Effect(EFF_STATUS, value=1, key=S_VULNERABLE)])
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[mark, hit])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500)
b = Battle([a], [t])
b.start()
base = int(35 * (1 - 20 / 95))
b.use_skill(a, mark, t)
self.assertEqual(t.hp, 500 - base)
b.next_turn()
b.next_turn()
b.use_skill(a, hit, t)
self.assertEqual(t.hp, 500 - base - int(base * 1.5))
self.assertNotIn(S_VULNERABLE, t.status)
class ShieldWiltDotTest(unittest.TestCase):
def test_shield_absorbs_and_ignores_heal(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 60, target=TARGET_ENEMY)])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100)
b = Battle([a], [t])
b.start()
b._shield(t, 30)
b.use_skill(a, a.skills[0], t)
base = int((60 + 25) * (1 - 20 / 95))
self.assertEqual(t.hp, 100 - (base - 30))
b._heal(None, t, 50)
self.assertEqual(t.hp, 100)
self.assertNotIn(S_SHIELD, t.status)
def test_heal_random_ally_prefers_injured_ally(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
full = make_unit("Full", TEAM_PLAYER, "radiant", hp=100)
hurt = make_unit("Hurt", TEAM_PLAYER, "radiant", hp=100)
hurt.hp = 50
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy, full, hurt], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertEqual(hurt.hp, 90) # the injured ally got healed
self.assertEqual(full.hp, 100) # full-HP ally untouched
def test_heal_random_ally_noop_when_all_full(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertFalse(any(isinstance(l, dict) and "recovers" in l["text"] for l in b.log)) # nothing healed
def test_wilt_ticks_over_three_turns(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 10, target=TARGET_ENEMY)])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
b = Battle([a], [t])
b.start()
b._apply_wilt_damage(t, 30)
self.assertEqual(t.status[S_WILT][0], 30)
b.next_turn() # t turn -> tick 10
self.assertEqual(t.hp, 490)
b.next_turn() # a round 2
b.next_turn() # t round 2 -> tick 10
self.assertEqual(t.hp, 480)
b.next_turn()
b.next_turn() # t round 3 -> tick 10, wilt ends
self.assertEqual(t.hp, 470)
self.assertNotIn(S_WILT, t.status)
def test_wilt_reapply_resets_duration(self):
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=1, skills=[Skill("H", 1, target=TARGET_ENEMY)])
b = Battle([a], [t])
b.start()
b._apply_wilt_damage(t, 30)
b._apply_wilt_damage(t, 30)
self.assertEqual(t.status[S_WILT], (60, 3))
def test_dot_deals_listed_damage_per_turn(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=1, skills=[Skill("H", 1, target=TARGET_ENEMY)])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
b = Battle([a], [t])
b.start()
b._add_dot(t, 45, 2)
b.next_turn() # t turn -> 45
self.assertEqual(t.hp, 455)
b.next_turn()
b.next_turn() # t turn -> 45, dot ends
self.assertEqual(t.hp, 410)
self.assertNotIn(S_DOT, t.status)
def test_shield_decays_after_3_turns(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
self.assertEqual(b._shield_total(t), 30)
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 3 -> 2
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 2 -> 1
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 0) # gone after the 3rd turn
self.assertNotIn(S_SHIELD, t.status)
def test_stacked_shields_decay_independently(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._end_turn_status(t) # first: 3 -> 2
b._shield(t, 40) # stack a new one (doesn't refresh the old)
b._end_turn_status(t) # first: 2 -> 1, second: 3 -> 2
b._end_turn_status(t) # first expires, second: 2 -> 1
self.assertEqual(b._shield_total(t), 40) # only the newer 40 remains
b._end_turn_status(t) # second expires too
self.assertEqual(b._shield_total(t), 0)
def test_shield_absorption_consumes_applications_in_order(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._shield(t, 40)
b._absorb(None, t, 50) # 30 from the old shield, 20 from the new one
self.assertEqual(b._shield_total(t), 20)
b._absorb(None, t, 20) # finishes the new shield
self.assertEqual(b._shield_total(t), 0)
self.assertEqual(t.hp, 500)
class MechanicsTest(unittest.TestCase):
def test_sleep_skips_until_two_hits(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
sedate = Skill("Sedate", power=60, target=TARGET_ENEMY,
effects=[Effect(EFF_STATUS, value=2, key=S_SLEEP)])
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[sedate, hit])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
b = Battle([a], [t])
b.start()
b.use_skill(a, sedate, t)
b.next_turn()
self.assertIs(b.current, a)
b.use_skill(a, hit, t)
self.assertEqual(t.status[S_SLEEP], 1)
b.next_turn()
self.assertIs(b.current, a)
b.use_skill(a, hit, t)
self.assertNotIn(S_SLEEP, t.status)
b.next_turn()
self.assertIs(b.current, t)
def test_intoxicate_splashes_to_attackers_own_ally(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, spd=5, skills=[hit])
p2 = make_unit("P2", TEAM_PLAYER, "garden", hp=200, spd=4)
e = make_unit("E", TEAM_ENEMY, "garden", hp=500, spd=3, dfn=0)
b = Battle([a, p2], [e])
b.start()
a.status[S_INTOXICATE] = (1, 1)
before = p2.hp
b.use_skill(a, a.skills[0], e)
self.assertLess(p2.hp, before)
def test_lotus_revitalize_scales_with_attack(self):
lotus = make("Lotus", TEAM_PLAYER)
ally = make_unit("Ally", TEAM_PLAYER, "garden", hp=500, spd=5)
e = make_unit("E", TEAM_ENEMY, "garden", hp=500, spd=5)
b = Battle([lotus, ally], [e])
b.start()
ally.hp = 100
lotus.attack = 300
heal = lotus.skills[0]
b.use_skill(lotus, heal, ally)
self.assertGreater(ally.hp, 100 + heal.power)
def test_flat_heal_now_scales_with_attack(self):
mend = Skill("Mend", power=40, kind="heal", target="ally")
a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=80, skills=[mend])
ally = make_unit("Ally", TEAM_PLAYER, "radiant", hp=1000)
ally.hp = 100
t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20)
b = Battle([a, ally], [t])
b.start()
b.use_skill(a, mend, ally)
self.assertEqual(ally.hp, 100 + 40 + int(80 * 0.5))
def test_tank_protection(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
cactus = make_unit("Cactus", TEAM_ENEMY, "garden", dfn=30, hp=100, role=ROLE_TANK)
fungus = make_unit("Fungus", TEAM_ENEMY, "garden", dfn=10, hp=100)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit])
b = Battle([a], [cactus, fungus])
b.start()
self.assertTrue(b.is_protected(fungus))
self.assertNotIn(fungus, b.valid_skill_targets(a, hit))
self.assertIn(cactus, b.valid_skill_targets(a, hit))
def test_cooldown(self):
boom = Skill("Boom", power=10, cooldown=2, target=TARGET_ENEMY)
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[boom])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
b = Battle([a], [t])
b.start()
b.use_skill(a, boom, t)
self.assertEqual(boom.cooldown_left, 2)
b.next_turn()
b.next_turn()
self.assertEqual(boom.cooldown_left, 1)
b.next_turn()
b.next_turn()
self.assertEqual(boom.cooldown_left, 0)
def test_enemy_aoe_hits_players_not_own_allies(self):
aoe = Skill("Burst", power=30, target="all_enemies")
e1 = make_unit("E1", TEAM_ENEMY, "ice", atk=50, skills=[aoe])
e2 = make_unit("E2", TEAM_ENEMY, "ice", atk=50, hp=200, spd=1)
p1 = make_unit("P1", TEAM_PLAYER, "garden", dfn=20, hp=200, spd=9)
p2 = make_unit("P2", TEAM_PLAYER, "garden", dfn=20, hp=200, spd=8)
b = Battle([p1, p2], [e1, e2])
b.start()
while b.current is not e1:
b.next_turn()
b.use_skill(e1, aoe, None)
self.assertLess(p1.hp, 200) # players damaged
self.assertLess(p2.hp, 200)
self.assertEqual(e2.hp, 200) # own ally untouched
def test_daffodil_toxic_sap_wilts_attacker(self):
from engine.plants import make
from engine.unit import ROLE_TANK
daff = make("Daffodil", TEAM_PLAYER)
attacker = make("Rose", TEAM_ENEMY)
hit = Skill("Hit", power=30, target=TARGET_ENEMY)
attacker.skills = [hit]
b = Battle([daff], [attacker])
b.start()
while b.current is not attacker:
b.next_turn()
self.assertNotIn(S_WILT, daff.status) # Daffodil itself must not be wilted
b.use_skill(attacker, hit, daff)
self.assertIn(S_WILT, attacker.status) # the ATTACKER gets the Wilt
self.assertGreater(attacker.status[S_WILT][0], 0)
def test_daffodil_toxic_sap_drains_existing_wilt(self):
from engine.plants import make
daff = make("Daffodil", TEAM_PLAYER)
attacker = make_unit("Weak", TEAM_ENEMY, "garden", atk=0, spd=9, dfn=20,
skills=[Skill("Hit", power=5, target=TARGET_ENEMY)])
b = Battle([daff], [attacker])
b.start()
while b.current is not attacker:
b.next_turn()
b._apply_wilt_damage(attacker, 30)
daff.hp = 50
b.use_skill(attacker, attacker.skills[0], daff)
self.assertGreater(daff.hp, 50) # healed by draining the attacker's Wilt
def test_rose_briars_wilts_attacker(self):
from engine.plants import make
rose = make("Rose", TEAM_PLAYER)
attacker = make("Zinnia", TEAM_ENEMY)
hit = Skill("Hit", power=30, target=TARGET_ENEMY)
attacker.skills = [hit]
b = Battle([rose], [attacker])
b.start()
while b.current is not attacker:
b.next_turn()
self.assertNotIn(S_WILT, rose.status)
b.use_skill(attacker, hit, rose)
self.assertIn(S_WILT, attacker.status)
def test_zinnia_solar_reduces_weakened_enemy_damage(self):
target = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5,
skills=[Skill("Hit", power=10, target=TARGET_ENEMY)])
victim = make_unit("V", TEAM_PLAYER, "garden", dfn=20, hp=500, spd=9)
b = Battle([victim], [target])
b.start()
while b.current is not target:
b.next_turn()
target.status["zinnia_weaken"] = 30
before = victim.hp
b.use_skill(target, target.skills[0], victim)
base = int((10 + 25) * (1 - 20 / 95))
self.assertEqual(before - victim.hp, int(base * 0.7))
def test_marigold_reduces_first_hit_each_turn(self):
from engine.plants import make
marigold = make("Marigold", TEAM_PLAYER)
attacker = make_unit("A", TEAM_ENEMY, "garden", atk=0, spd=1, dfn=20,
skills=[Skill("Hit", power=40, target=TARGET_ENEMY)])
b = Battle([marigold], [attacker])
b.start()
while b.current is not attacker:
b.next_turn()
base = int((40 + 0) * (1 - marigold.defense / (marigold.defense + 75)))
first = marigold.hp
b.use_skill(attacker, attacker.skills[0], marigold)
self.assertEqual(first - marigold.hp, int(base * 0.7)) # first hit reduced
first = marigold.hp
b.next_turn() # marigold turn resets flag
while b.current is not attacker:
b.next_turn()
b.use_skill(attacker, attacker.skills[0], marigold)
self.assertEqual(first - marigold.hp, int(base * 0.7)) # still reduced next turn
def test_magnolia_reduces_periodic_debuff_duration(self):
from engine.plants import make
magnolia = make("Magnolia", TEAM_PLAYER)
b = Battle([magnolia], [make_unit("E", TEAM_ENEMY, "garden", spd=1, dfn=20)])
b.start()
b._apply_wilt_damage(magnolia, 30)
self.assertEqual(magnolia.status[S_WILT][1], 2) # 3 -> 2 turns
b._add_dot(magnolia, 45, 2)
self.assertEqual(magnolia.status[S_DOT][0][1], 1) # 2 -> 1 turn
def test_hydrangea_cloud_wilts_only_when_acidic(self):
from engine.plants import make
hyd = make("Hydrangea", TEAM_PLAYER)
cloud = next(s for s in hyd.skills if s.name == "Cloud")
enemy = make_unit("E", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=1)
b = Battle([hyd], [enemy])
b.start()
while b.current is not hyd:
b.next_turn()
hyd.status["form"] = "acidic"
b.use_skill(hyd, cloud, enemy)
self.assertIn(S_WILT, enemy.status)
b.next_turn()
b.next_turn()
while b.current is not hyd:
b.next_turn()
hyd.status["form"] = "alkaline"
enemy.status.pop(S_WILT, None)
b.use_skill(hyd, cloud, enemy)
self.assertNotIn(S_WILT, enemy.status)
def test_tulip_equinox_boosts_allies_with_shield(self):
tulip = make_unit("Tulip", TEAM_PLAYER, "garden", atk=1, spd=1, dfn=20, passive="tulip_equinox")
ally = make_unit("Ally", TEAM_PLAYER, "radiant", atk=50, spd=9, dfn=20,
skills=[Skill("Hit", power=10, target=TARGET_ENEMY)])
enemy = make_unit("E", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
b = Battle([tulip, ally], [enemy])
b.start()
while b.current is not ally:
b.next_turn()
b._shield(ally, 20)
before = enemy.hp
b.use_skill(ally, ally.skills[0], enemy)
base = int((10 + 25) * (1 - 20 / 95))
self.assertEqual(before - enemy.hp, int(base * 1.1))
def test_nightshade_corruption_spreads_dot_on_death(self):
from engine.plants import make
from engine.unit import TEAM_ENEMY
e1 = make_unit("E1", TEAM_ENEMY, "nocturnal", dfn=10, hp=100, spd=1)
e2 = make_unit("E2", TEAM_ENEMY, "nocturnal", dfn=10, hp=100, spd=1)
killer = make_unit("K", TEAM_PLAYER, "radiant", atk=99, spd=9,
skills=[Skill("Hit", power=200, target=TARGET_ENEMY)])
b = Battle([killer], [e1, e2])
b.start()
while b.current is not killer:
b.next_turn()
b._add_dot(e1, 40, 3)
b.use_skill(killer, killer.skills[0], e1)
self.assertFalse(e1.alive)
self.assertIn(S_DOT, e2.status) # corrosion spread to the other enemy
def test_orchid_sow_seed_deals_full_delayed_damage(self):
from engine.plants import make
orchid = make("Orchid", TEAM_ENEMY)
orchid.speed = 12
target = make_unit("T", TEAM_PLAYER, "garden", dfn=50, hp=500, spd=10)
sow = next(s for s in orchid.skills if s.name == "Sow")
b = Battle([target], [orchid])
b.start()
while b.current is not orchid:
b.next_turn()
b.use_skill(orchid, sow, target)
self.assertEqual(target.status["seed"][0], 115)
hp0 = target.hp
self.assertEqual(target.hp, hp0) # no immediate damage
lav_turns = 0
erupted = False
for _ in range(40):
b.next_turn()
if b.current is target:
lav_turns += 1
if target.hp < hp0:
erupted = True
break
self.assertTrue(erupted)
self.assertEqual(lav_turns, 3) # two turns pass, erupts on the third
self.assertEqual(hp0 - target.hp, 115) # full flat damage, ignores defense
self.assertNotIn("seed", target.status)
def test_zinnia_picks_skill_with_more_effects(self):
from engine.plants import make
zinnia = make("Zinnia", TEAM_PLAYER)
enemy = make_unit("E", TEAM_ENEMY, "nocturnal", dfn=20, hp=500, spd=1)
b = Battle([zinnia], [enemy])
b.start()
glitter = next(s for s in zinnia.skills if s.name == "Glitter")
glimmer = next(s for s in zinnia.skills if s.name == "Glimmer")
self.assertEqual(glitter.hits, 5)
self.assertEqual(glimmer.hits, 5)
# enemy carries more effects -> target-scaling Glitter wins
enemy.status["wilt"] = (30, 3)
enemy.status["dot"] = [(10, 2)]
zinnia.status.clear()
self.assertIs(b.best_skill(zinnia), glitter)
# Zinnia carries more effects on herself -> Glimmer wins
enemy.status.clear()
zinnia.status["attack_up"] = (10, 2)
zinnia.status["speed_up"] = (5, 2)
self.assertIs(b.best_skill(zinnia), glimmer)
# no effects anywhere -> Glimmer (lower static priority) wins the tie
zinnia.status.clear()
self.assertIs(b.best_skill(zinnia), glimmer)
# a heavily-buffed enemy still out-hits Zinnia -> Glitter again
enemy.status["wilt"] = (30, 3)
enemy.status["dot"] = [(10, 2)]
enemy.status["vulnerable"] = 1
zinnia.status["shield"] = [(10, 3)] # one self effect only
self.assertIs(b.best_skill(zinnia), glitter)
def test_zinnia_targets_most_effect_rich_enemy(self):
from engine.plants import make
zinnia = make("Zinnia", TEAM_ENEMY)
zinnia.speed = 80
e1 = make_unit("Plain", TEAM_PLAYER, "garden", dfn=10, hp=400, spd=1)
e2 = make_unit("Buffed", TEAM_PLAYER, "garden", dfn=10, hp=400, spd=1)
e2.status["wilt"] = (30, 3)
e2.status["dot"] = [(10, 2)]
b = Battle([e1, e2], [zinnia])
b.start()
while b.current is not zinnia:
b.next_turn()
# enemy_ai picks Glitter (most hits) and targets the effect-rich enemy
self.assertTrue(b.enemy_ai(zinnia))
self.assertLess(e2.hp, 400)
self.assertEqual(e1.hp, 400)
def test_shield_prefers_tank_when_nobody_damaged(self):
from engine.plants import make
from engine.unit import ROLE_TANK
shield = Skill("Shield", power=40, kind="shield", target="ally")
tank = make_leveled("Marigold", TEAM_PLAYER, 1, 1) # role tank
squishy = make("Lavender", TEAM_PLAYER)
ally = make("Rose", TEAM_PLAYER)
ally.skills = [shield]
enemy = make_leveled("Camelia", TEAM_ENEMY, 1, 1)
b = Battle([tank, squishy, ally], [enemy])
b.start()
# nobody damaged yet -> tank is preferred
self.assertIs(b._shield_target(ally), tank)
# tank is lowest HP -> tank still preferred
tank.hp = 1
squishy.hp = int(squishy.max_hp * 0.8)
self.assertIs(b._shield_target(ally), tank)
# squishy lower than a healthy tank -> squishy
tank.hp = tank.max_hp
squishy.hp = int(squishy.max_hp * 0.3)
self.assertIs(b._shield_target(ally), squishy)
def test_best_skill_priority(self):
lav = make("Lavender", TEAM_PLAYER)
by_name = {s.name: s for s in lav.skills}
from engine.battle import Battle as B
from engine.plants import make as mk
b = B([lav], [mk("Camelia", TEAM_ENEMY)])
self.assertIs(b.best_skill(lav), by_name["Sedate"])
by_name["Sedate"].cooldown_left = 4
self.assertIs(b.best_skill(lav), by_name["Soothing Lullaby"])
by_name["Soothing Lullaby"].cooldown_left = 2
self.assertIs(b.best_skill(lav), by_name["Tranquilize"])
def test_pure_heal_not_preselected_when_allies_full(self):
from engine.roster import pick_team
sun = make("Sunflower", TEAM_PLAYER)
b = Battle([sun], pick_team(["Rose"], TEAM_ENEMY))
b.start()
by = {s.name: s for s in sun.skills}
self.assertIsNot(b.best_skill(sun), by["Face The Sun"])
by["Beam"].cooldown_left = 1
by["Daybreak"].cooldown_left = 1
self.assertIs(b.best_skill(sun), by["Face The Sun"]) # only option left -> fallback
sun.hp = 20
self.assertIs(b.best_skill(sun), by["Face The Sun"]) # ally hurt -> candidate again
def test_pool_consistency(self):
from engine.plants import HP_MULT, POOL
for name in all_names():
spec = POOL[name]
self.assertIn(spec["type"], ("radiant", "nocturnal", "ice", "garden", "wildflower"))
self.assertIn(spec["role"], ("attacker", "support", "tank"))
self.assertTrue(spec["skills"])
u = make(name, TEAM_PLAYER)
self.assertEqual(u.max_hp, int(spec["stats"][3] * HP_MULT))
class SpeedTimelineTest(unittest.TestCase):
def test_fast_unit_acts_more_often(self):
fast = make_unit("Fast", TEAM_ENEMY, "garden", spd=110, skills=[Skill("H", 1, target=TARGET_ENEMY)])
mid = make_unit("Mid", TEAM_ENEMY, "garden", spd=75, skills=[Skill("H", 1, target=TARGET_ENEMY)])
slows = [make_unit(f"S{i}", TEAM_ENEMY, "garden", spd=50, skills=[Skill("H", 1, target=TARGET_ENEMY)]) for i in range(3)]
b = Battle([], [fast, mid] + slows)
b.start()
order = []
for _ in range(6):
order.append(b.current.name)
b.next_turn()
self.assertEqual(order, ["Fast", "Mid", "Fast", "S0", "S1", "S2"])
def test_progress_bar_is_full_for_current(self):
fast = make_unit("Fast", TEAM_ENEMY, "garden", spd=110, skills=[Skill("H", 1, target=TARGET_ENEMY)])
slow = make_unit("Slow", TEAM_ENEMY, "garden", spd=50, skills=[Skill("H", 1, target=TARGET_ENEMY)])
b = Battle([], [fast, slow])
b.start()
self.assertIs(b.current, fast)
self.assertAlmostEqual(b.progress(fast), 1.0)
self.assertLess(b.progress(slow), 1.0)
def test_delay_pushes_turns_back(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, spd=100,
skills=[Skill("Delay", 5, target="all_enemies", mechanic="delay_all")])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=50)
b = Battle([a], [t])
b.start()
before = t.next_at
b.use_skill(a, a.skills[0], None)
self.assertGreater(t.next_at, before)
class BattleCompleteTest(unittest.TestCase):
def test_default_battle_completes(self):
from engine.roster import make_roster
players, enemies = make_roster()
b = Battle(players, enemies)
b.start()
guard = 0
while b.winner is None and b.current is not None and guard < 500:
guard += 1
u = b.current
if u.team == TEAM_ENEMY:
b.enemy_ai(u)
else:
s = b.best_skill(u)
if s:
foes = b.foes_of(u)
if s.kind == "heal":
low = [a for a in b.allies_of(u) if a.hp < a.max_hp]
if low:
b.use_skill(u, s, min(low, key=lambda a: a.hp))
elif s.kind == "shield":
b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp))
elif s.target in ("all_enemies", "two_enemies"):
b.use_skill(u, s, None)
elif s.target == "any" and foes:
b.use_skill(u, s, foes[0])
elif s.target in ("ally", "all_allies"):
allies = b.allies_of(u)
if allies:
b.use_skill(u, s, min(allies, key=lambda a: a.hp))
elif foes:
b.use_skill(u, s, min(foes, key=lambda t: t.hp))
b.next_turn()
self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY))
def test_random_teams_battle_completes(self):
import random
names = list(all_names())
for _ in range(5):
random.seed(_)
allies = random.sample(names, 4)
enemies = random.sample(names, 4)
b = Battle([make_leveled(n, TEAM_PLAYER, 1, 1) for n in allies],
[make_leveled(n, TEAM_ENEMY, 1, 1) for n in enemies])
b.start()
guard = 0
while b.winner is None and b.current is not None and guard < 400:
guard += 1
u = b.current
if u.team == TEAM_ENEMY:
b.enemy_ai(u)
else:
s = b.best_skill(u)
if s:
foes = b.foes_of(u)
if s.kind == "heal":
low = [a for a in b.allies_of(u) if a.hp < a.max_hp]
if low:
b.use_skill(u, s, min(low, key=lambda a: a.hp))
elif s.kind == "shield":
b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp))
elif s.target in ("all_enemies", "two_enemies"):
b.use_skill(u, s, None)
elif s.target == "any" and foes:
b.use_skill(u, s, foes[0])
elif s.target in ("ally", "all_allies"):
allies = b.allies_of(u)
if allies:
b.use_skill(u, s, min(allies, key=lambda a: a.hp))
elif foes:
b.use_skill(u, s, min(foes, key=lambda t: t.hp))
b.next_turn()
self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY))
class TowerInjectionTest(unittest.TestCase):
def _skill_sources(self):
from engine.plants import POOL, RARITY
src = {}
for n in POOL:
for sk in POOL[n]["skills"]:
src.setdefault(sk.name, set()).add((POOL[n]["role"], RARITY[n]))
return src
def test_tower_retry_rebuilds_same_team(self):
from engine.roster import build_tower_enemy_team, roll_tower_enemy_team
for stage, level in ((5, 8), (12, 10), (20, 15)):
names, injections, runes = roll_tower_enemy_team(stage, level)
team_a = build_tower_enemy_team(names, injections, runes, level, stage)
team_b = build_tower_enemy_team(names, injections, runes, level, stage)
self.assertEqual([u.name for u in team_a], names)
self.assertEqual([u.name for u in team_b], names)
for a, b in zip(team_a, team_b):
self.assertEqual(a.injected_name, b.injected_name)
self.assertEqual(a.runes, b.runes)
self.assertEqual([s.name for s in a.skills],
[s.name for s in b.skills])
def test_tower_enemies_gain_runes_at_higher_stages(self):
from engine.roster import make_tower_enemies
early = make_tower_enemies(["Zinnia"], 10, 2)
late = make_tower_enemies(["Zinnia"], 20, 15)
self.assertEqual(early[0].runes, []) # stage 2: no runes yet
self.assertTrue(late[0].runes) # stage 15: runes equipped
def test_tower_inject_chance(self):
from engine.roster import tower_inject_chance
self.assertEqual(tower_inject_chance(5), 0.0)
self.assertGreater(tower_inject_chance(10), 0.0)
self.assertLessEqual(tower_inject_chance(20), 0.6)
def test_legendary_enemies_pull_only_from_legendaries(self):
from engine.roster import roll_enemy_injection
src = self._skill_sources()
for _ in range(40):
inj = roll_enemy_injection("Zinnia") # super-super-rare attacker
if inj:
for role, rar in src[inj["skill"]["name"]]:
self.assertEqual((role, rar), ("attacker", "super_super_rare"))
def test_epic_tank_pulls_from_any_tank(self):
from engine.roster import roll_enemy_injection
src = self._skill_sources()
for _ in range(40):
inj = roll_enemy_injection("Camelia") # epic tank
if inj:
roles = {role for role, _ in src[inj["skill"]["name"]]}
self.assertEqual(roles, {"tank"})
def test_tower_enemies_have_no_injection_below_floor(self):
from engine.roster import make_tower_enemies
units = make_tower_enemies(["Zinnia", "Rose"], 1, 5)
self.assertTrue(all(u.injected_index == -1 for u in units))
def test_enemy_injection_never_replaces_same_ability(self):
from engine.roster import roll_enemy_injection
from engine.plants import POOL
for name in ["Zinnia", "Camelia", "Sunflower", "Hellebore", "Marigold"]:
for _ in range(40):
inj = roll_enemy_injection(name)
if inj:
base = POOL[name]["skills"][inj["index"]]
self.assertNotEqual(base.name, inj["skill"]["name"])
class TurnActionLimitTest(unittest.TestCase):
"""A unit must act only once per turn (spam-clicking must not multi-cast)."""
def _battle(self, skills):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=skills)
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=1000)
b = Battle([a], [t])
b.start()
return b, a, t
def test_unit_cannot_use_two_skills_in_one_turn(self):
hit1 = Skill("Hit1", power=10, target=TARGET_ENEMY)
hit2 = Skill("Hit2", power=10, target=TARGET_ENEMY)
b, a, t = self._battle([hit1, hit2])
self.assertTrue(b.use_skill(a, hit1, t))
self.assertFalse(b.use_skill(a, hit2, t)) # already acted
self.assertTrue(a.acted)
def test_same_skill_cannot_be_spammed(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY, cooldown=0)
b, a, t = self._battle([hit])
self.assertTrue(b.use_skill(a, hit, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted flag blocks the re-cast
def test_extra_action_skill_still_usable_after_acting(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t)) # extra action allowed after acting
self.assertTrue(a.acted)
def test_no_extra_actions_after_act_limit_exhausted(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
extra2 = Skill("Extra2", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra, extra2])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t))
self.assertTrue(b.use_skill(a, extra2, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted + cooldown: still blocked
if __name__ == "__main__":
unittest.main()