851 lines
37 KiB
Python
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() |