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()