Initial commit: Plants turn-based battle game
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
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_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)
|
||||
|
||||
|
||||
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 # 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"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import engine.collection as collection
|
||||
|
||||
|
||||
class CoinTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._orig = collection._DATA_FILE
|
||||
fd, cls._path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
collection._DATA_FILE = pathlib.Path(cls._path)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
collection._DATA_FILE = cls._orig
|
||||
if os.path.exists(cls._path):
|
||||
os.unlink(cls._path)
|
||||
|
||||
def setUp(self):
|
||||
collection.init_starter_pinwheel("radiant")
|
||||
|
||||
def _backdate(self, seconds, coins=0):
|
||||
data = collection.load()
|
||||
data["coins"] = coins
|
||||
data["coins_accrued_at"] = time.time() - seconds
|
||||
collection.save(data)
|
||||
|
||||
def test_accrue_never_exceeds_cap(self):
|
||||
self._backdate(600, coins=195)
|
||||
self.assertEqual(collection.accrue_coins(), 5) # only up to the cap
|
||||
self.assertEqual(collection.available_coins(), collection.COIN_CAP)
|
||||
|
||||
def test_no_generation_while_at_cap(self):
|
||||
self._backdate(120, coins=collection.COIN_CAP)
|
||||
self.assertEqual(collection.accrue_coins(), 0)
|
||||
self.assertEqual(collection.available_coins(), collection.COIN_CAP)
|
||||
|
||||
def test_generation_resumes_after_spending(self):
|
||||
self._backdate(61, coins=150)
|
||||
self.assertEqual(collection.accrue_coins(), 1)
|
||||
self.assertEqual(collection.available_coins(), 151)
|
||||
|
||||
def test_add_coins_respects_cap(self):
|
||||
data = collection.load()
|
||||
data["coins"] = collection.COIN_CAP - 1
|
||||
collection.save(data)
|
||||
collection.add_coins(10)
|
||||
self.assertEqual(collection.available_coins(), collection.COIN_CAP)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
import atexit
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Point the engine at a fresh empty save to keep these 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.dominance import estimate_dominance, play_to_end, resolve_instantly
|
||||
from engine.plants import make_leveled
|
||||
from engine.roster import DEFAULT_ENEMIES, DEFAULT_PLAYERS
|
||||
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
|
||||
|
||||
|
||||
def _team(names, team, level=1, stage=1):
|
||||
return [make_leveled(n, team, level, stage) for n in names]
|
||||
|
||||
|
||||
class PlayToEndTest(unittest.TestCase):
|
||||
def test_returns_winner(self):
|
||||
b = Battle(_team(DEFAULT_PLAYERS, TEAM_PLAYER),
|
||||
_team(DEFAULT_ENEMIES, TEAM_ENEMY))
|
||||
winner = play_to_end(b)
|
||||
self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY))
|
||||
|
||||
def test_returns_winner_with_relics(self):
|
||||
b = Battle(_team(DEFAULT_PLAYERS, TEAM_PLAYER),
|
||||
_team(DEFAULT_ENEMIES, TEAM_ENEMY),
|
||||
relics=["iron_bark", "living_spring", "chilling_gust"])
|
||||
winner = play_to_end(b)
|
||||
self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY))
|
||||
|
||||
def test_resolve_instantly_matches_a_played_outcome(self):
|
||||
players = _team(DEFAULT_PLAYERS, TEAM_PLAYER)
|
||||
enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY)
|
||||
winner = resolve_instantly(players, enemies)
|
||||
# re-running fresh units gives the same winner (deterministic roll-free path)
|
||||
self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY))
|
||||
|
||||
|
||||
class DominanceTest(unittest.TestCase):
|
||||
def test_overwhelming_team_is_dominant(self):
|
||||
players = _team(DEFAULT_PLAYERS, TEAM_PLAYER, level=20, stage=3)
|
||||
enemies = _team(["Rose"], TEAM_ENEMY, level=1, stage=1)
|
||||
self.assertTrue(estimate_dominance(players, enemies, runs=15))
|
||||
|
||||
def test_even_match_is_not_dominant(self):
|
||||
players = _team(DEFAULT_PLAYERS, TEAM_PLAYER, level=1, stage=1)
|
||||
enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY, level=1, stage=1)
|
||||
self.assertFalse(estimate_dominance(players, enemies, runs=15))
|
||||
|
||||
def test_much_weaker_team_is_not_dominant(self):
|
||||
players = _team(["Marigold"], TEAM_PLAYER, level=1, stage=1)
|
||||
enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY, level=10, stage=3)
|
||||
self.assertFalse(estimate_dominance(players, enemies, runs=15))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,230 @@
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
||||
|
||||
import engine.collection as collection
|
||||
from engine.plants import POOL, make
|
||||
from engine.unit import TEAM_PLAYER
|
||||
|
||||
|
||||
class ExtractionTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._orig = collection._DATA_FILE
|
||||
fd, cls._path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
collection._DATA_FILE = pathlib.Path(cls._path)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
collection._DATA_FILE = cls._orig
|
||||
if os.path.exists(cls._path):
|
||||
os.unlink(cls._path)
|
||||
|
||||
def setUp(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Lavender", "Zinnia", "Marigold", "Sunflower", "Hydrangea", "Hellebore"]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
data["extracted"] = []
|
||||
for n in data["owned"]:
|
||||
data["progress"][n].pop("injected", None)
|
||||
collection.save(data)
|
||||
|
||||
def _maxed(self, name):
|
||||
data = collection.load()
|
||||
data["progress"][name]["level"] = 50
|
||||
data["progress"][name]["stage"] = 3
|
||||
collection.save(data)
|
||||
|
||||
def test_extraction_requires_maxed(self):
|
||||
self.assertNotEqual(collection.can_extract("Lavender"), "")
|
||||
self._maxed("Lavender")
|
||||
self.assertEqual(collection.can_extract("Lavender"), "")
|
||||
|
||||
def test_extract_excludes_basic_and_blocks_inject_of_self_type(self):
|
||||
self._maxed("Lavender")
|
||||
idxs = [i for i, _ in collection.extractable_skills("Lavender")]
|
||||
self.assertTrue(all(i > 0 for i in idxs))
|
||||
|
||||
def test_hydrangea_has_no_extractable_abilities(self):
|
||||
self._maxed("Hydrangea")
|
||||
self.assertEqual(collection.extractable_skills("Hydrangea"), [])
|
||||
self.assertNotEqual(collection.can_extract("Hydrangea"), "")
|
||||
|
||||
def test_extract_resets_plant_and_stores_ability(self):
|
||||
self._maxed("Lavender")
|
||||
idx, sk = collection.extractable_skills("Lavender")[0]
|
||||
self.assertTrue(collection.extract_skill("Lavender", idx))
|
||||
prog = collection.load()["progress"]["Lavender"]
|
||||
self.assertEqual(prog["level"], 1)
|
||||
self.assertEqual(prog["stage"], 1)
|
||||
ext = collection.extracted_skills()
|
||||
self.assertEqual(len(ext), 1)
|
||||
self.assertEqual(ext[0]["skill_name"], sk.name)
|
||||
self.assertEqual(ext[0]["role"], POOL["Lavender"]["role"])
|
||||
# extraction again is blocked (no longer maxed)
|
||||
self.assertNotEqual(collection.can_extract("Lavender"), "")
|
||||
|
||||
def test_inject_requires_same_role_and_is_single_use(self):
|
||||
self._maxed("Lavender")
|
||||
idx, _ = collection.extractable_skills("Lavender")[0]
|
||||
collection.extract_skill("Lavender", idx)
|
||||
rec = collection.extracted_skills()[0]
|
||||
self.assertNotEqual(collection.can_inject("Marigold", rec), "") # tank
|
||||
self.assertEqual(collection.can_inject("Zinnia", rec), "") # attacker
|
||||
self.assertTrue(collection.inject_skill(0, "Zinnia", 1))
|
||||
self.assertEqual(collection.extracted_skills(), []) # consumed
|
||||
# one injected ability per plant
|
||||
self.assertNotEqual(collection.can_inject("Zinnia", rec), "")
|
||||
self.assertFalse(collection.inject_skill(0, "Zinnia", 2))
|
||||
|
||||
def test_unit_uses_injected_skill_and_remove_reverts(self):
|
||||
self._maxed("Lavender")
|
||||
idx, sk = collection.extractable_skills("Lavender")[0]
|
||||
collection.extract_skill("Lavender", idx)
|
||||
collection.inject_skill(0, "Zinnia", 1)
|
||||
unit = make("Zinnia", TEAM_PLAYER)
|
||||
self.assertEqual(unit.skills[1].name, sk.name)
|
||||
self.assertEqual(unit.skills[0].name, POOL["Zinnia"]["skills"][0].name)
|
||||
self.assertTrue(collection.remove_injection("Zinnia"))
|
||||
unit = make("Zinnia", TEAM_PLAYER)
|
||||
self.assertEqual(unit.skills[1].name, POOL["Zinnia"]["skills"][1].name)
|
||||
|
||||
def test_madness_is_not_extractable(self):
|
||||
self._maxed("Hellebore")
|
||||
names = [sk.name for _, sk in collection.extractable_skills("Hellebore")]
|
||||
self.assertNotIn("Madness", names)
|
||||
idx = POOL["Hellebore"]["skills"].index(next(s for s in POOL["Hellebore"]["skills"]
|
||||
if s.name == "Madness"))
|
||||
self.assertFalse(collection.extract_skill("Hellebore", idx))
|
||||
|
||||
def test_inject_cannot_replace_same_ability(self):
|
||||
from engine.skills import skill_to_dict
|
||||
data = collection.load()
|
||||
data["extracted"] = [{
|
||||
"source": "Lavender", "role": "attacker",
|
||||
"skill_name": "Glitter", "skill": skill_to_dict(POOL["Zinnia"]["skills"][1]),
|
||||
}]
|
||||
collection.save(data)
|
||||
rec = collection.extracted_skills()[0]
|
||||
# Zinnia's 2nd skill IS Glitter -> cannot replace it with Glitter
|
||||
self.assertNotEqual(collection.injection_slot_reason("Zinnia", rec, 1), "")
|
||||
self.assertFalse(collection.inject_skill(0, "Zinnia", 1))
|
||||
# its 3rd skill (Glimmer) is fine
|
||||
self.assertEqual(collection.injection_slot_reason("Zinnia", rec, 2), "")
|
||||
self.assertTrue(collection.inject_skill(0, "Zinnia", 2))
|
||||
|
||||
|
||||
class FertilizerRewardTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._orig = collection._DATA_FILE
|
||||
fd, cls._path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
collection._DATA_FILE = pathlib.Path(cls._path)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
collection._DATA_FILE = cls._orig
|
||||
if os.path.exists(cls._path):
|
||||
os.unlink(cls._path)
|
||||
|
||||
def test_win_grants_fertilizer_not_water(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
data["water"] = 0
|
||||
data["fertilizer"] = 0
|
||||
collection.save(data)
|
||||
collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=3)
|
||||
self.assertEqual(collection.available_fertilizer(), 3)
|
||||
self.assertEqual(collection.available_water(), 0)
|
||||
|
||||
def test_win_fertilizer_scales_with_floor(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
data["fertilizer"] = 0
|
||||
collection.save(data)
|
||||
# floor 1 -> 1 fertilizer, floor 5 -> 5
|
||||
collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=1)
|
||||
collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=5)
|
||||
self.assertEqual(collection.available_fertilizer(), 6)
|
||||
|
||||
def test_water_still_levels_plants(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
data["water"] = 3
|
||||
collection.save(data)
|
||||
collection.water("Lavender")
|
||||
self.assertEqual(collection.get_progress("Lavender")["level"], 2)
|
||||
self.assertEqual(collection.available_water(), 2)
|
||||
|
||||
def test_smart_water_candidate(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
data["progress"]["Marigold"]["duplicates"] = 5 # >= 2 needed for stage 2
|
||||
collection.save(data)
|
||||
self.assertTrue(collection.smart_water_candidate("Marigold"))
|
||||
data = collection.load()
|
||||
data["progress"]["Marigold"]["level"] = 10 # already at the milestone
|
||||
collection.save(data)
|
||||
self.assertFalse(collection.smart_water_candidate("Marigold"))
|
||||
|
||||
|
||||
class StarterPackTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._orig = collection._DATA_FILE
|
||||
fd, cls._path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
collection._DATA_FILE = pathlib.Path(cls._path)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
collection._DATA_FILE = cls._orig
|
||||
if os.path.exists(cls._path):
|
||||
os.unlink(cls._path)
|
||||
|
||||
def setUp(self):
|
||||
if collection._DATA_FILE.exists():
|
||||
collection._DATA_FILE.unlink()
|
||||
|
||||
def test_init_starter_pinwheel(self):
|
||||
self.assertFalse(collection.save_exists())
|
||||
self.assertFalse(collection.init_starter_pinwheel("nope"))
|
||||
self.assertTrue(collection.init_starter_pinwheel("radiant"))
|
||||
self.assertTrue(collection.save_exists())
|
||||
data = collection.load()
|
||||
self.assertEqual(set(data["owned"]), {"Pinwheel"})
|
||||
self.assertEqual(data["progress"]["Pinwheel"]["type"], "radiant")
|
||||
self.assertEqual(data["progress"]["Pinwheel"]["level"], 1)
|
||||
self.assertEqual(data["progress"]["Pinwheel"]["stage"], 1)
|
||||
self.assertEqual(data["progress"]["Pinwheel"]["duplicates"], 0)
|
||||
|
||||
def test_every_type_can_start(self):
|
||||
from engine.types import ALL_TYPES
|
||||
for pt in ALL_TYPES:
|
||||
if collection._DATA_FILE.exists():
|
||||
collection._DATA_FILE.unlink()
|
||||
self.assertTrue(collection.init_starter_pinwheel(pt))
|
||||
self.assertEqual(collection.load()["progress"]["Pinwheel"]["type"], pt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,365 @@
|
||||
import atexit
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# make()/pick_team() read the save file, so point the engine at a fresh empty
|
||||
# save to keep relic tests hermetic (same trick as test_battle.py).
|
||||
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 import runes
|
||||
from engine.relics import RELIC_POOL, by_key, grant_rune_rewards, roll_relics, rune_reward_count
|
||||
from engine.roster import DEFAULT_ENEMIES, DEFAULT_PLAYERS
|
||||
from engine.skills import S_ATTACK_UP, S_DEFENSE_UP, S_HOT, S_SHIELD, S_SNOWFLAKES, S_VULNERABLE
|
||||
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
|
||||
|
||||
|
||||
def _battle(*relic_keys):
|
||||
players = [make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS]
|
||||
enemies = [make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES]
|
||||
b = Battle(players, enemies, relics=relic_keys)
|
||||
b.start()
|
||||
return b
|
||||
|
||||
|
||||
class RelicRollTest(unittest.TestCase):
|
||||
def test_roll_returns_distinct_relics(self):
|
||||
for _ in range(20):
|
||||
rolled = roll_relics(3)
|
||||
self.assertLessEqual(len(rolled), 3)
|
||||
self.assertEqual(len({r.key for r in rolled}), len(rolled))
|
||||
|
||||
def test_roll_excludes_owned(self):
|
||||
rolled = roll_relics(3, exclude=["iron_bark", "vitality", "granite"])
|
||||
self.assertTrue(all(r.key not in ("iron_bark", "vitality", "granite") for r in rolled))
|
||||
|
||||
def test_by_key_roundtrip(self):
|
||||
for r in RELIC_POOL:
|
||||
self.assertIs(by_key(r.key), r)
|
||||
|
||||
def test_roll_with_all_excluded_keeps_rune_rewards(self):
|
||||
rolled = roll_relics(3, exclude=[r.key for r in RELIC_POOL])
|
||||
self.assertTrue(rolled)
|
||||
self.assertTrue(all(r.rune_reward for r in rolled)) # rune rewards always available
|
||||
|
||||
def test_rune_reward_relics_are_rare_and_epic(self):
|
||||
self.assertEqual(by_key("rune_cache").rarity, "rare")
|
||||
self.assertEqual(by_key("rune_crypt").rarity, "epic")
|
||||
|
||||
def test_rune_reward_relics_ignore_exclusion(self):
|
||||
# a picked rune-reward relic can be rolled again in the same run
|
||||
|
||||
def fake_choices(pool, weights=None, k=1):
|
||||
pick = by_key("rune_cache")
|
||||
return [pick if pick in pool else pool[0]]
|
||||
|
||||
with mock.patch.object(random, "choices", side_effect=fake_choices):
|
||||
rolled = roll_relics(3, exclude=["rune_cache"])
|
||||
self.assertIn("rune_cache", [r.key for r in rolled])
|
||||
|
||||
def test_grant_rune_rewards_tier1(self):
|
||||
granted = grant_rune_rewards(["rune_cache"])
|
||||
self.assertEqual(len(granted), 1)
|
||||
self.assertIn(granted[0]["color"], runes.TIER1_COLORS)
|
||||
|
||||
def test_grant_rune_rewards_brown(self):
|
||||
granted = grant_rune_rewards(["rune_crypt"])
|
||||
self.assertEqual(len(granted), 1)
|
||||
self.assertEqual(granted[0]["color"], "brown")
|
||||
|
||||
def test_grant_rune_rewards_counts(self):
|
||||
granted = grant_rune_rewards(["rune_cache", "rune_crypt", "rune_cache"])
|
||||
self.assertEqual(len(granted), 3)
|
||||
colors = [g["color"] for g in granted]
|
||||
self.assertEqual(colors.count("brown"), 1)
|
||||
self.assertEqual(sum(1 for c in colors if c in runes.TIER1_COLORS), 2)
|
||||
|
||||
def test_rune_reward_count(self):
|
||||
self.assertEqual(rune_reward_count(["rune_cache", "iron_bark", "rune_crypt"]), 2)
|
||||
self.assertEqual(rune_reward_count(["iron_bark", "vitality"]), 0)
|
||||
self.assertEqual(rune_reward_count([]), 0)
|
||||
|
||||
|
||||
class RelicApplyTest(unittest.TestCase):
|
||||
def test_shield_relic(self):
|
||||
b = _battle("iron_bark")
|
||||
for u in b.players:
|
||||
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), int(u.max_hp * 0.09))
|
||||
|
||||
def test_pct_hp_relic(self):
|
||||
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
||||
b = _battle("vitality")
|
||||
u = next(p for p in b.players if p.name == "Zinnia")
|
||||
self.assertGreaterEqual(u.max_hp, int(base.max_hp * 1.12) - 1)
|
||||
self.assertEqual(u.hp, u.max_hp) # healed to full
|
||||
|
||||
def test_attack_pct_relic(self):
|
||||
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
||||
b = _battle("sunlit")
|
||||
u = next(p for p in b.players if p.name == "Zinnia")
|
||||
self.assertEqual(u.attack, int(base.attack * 1.08))
|
||||
|
||||
def test_start_buff_relic(self):
|
||||
b = _battle("morning_dew")
|
||||
for u in b.players:
|
||||
self.assertEqual(u.status[S_ATTACK_UP], (10, 1)) # lasts 1 turn
|
||||
|
||||
def test_heal_boost_relic(self):
|
||||
b = _battle("living_spring")
|
||||
target = b.players[0]
|
||||
target.hp = target.max_hp - 100 # room for a full heal
|
||||
self.assertEqual(target.status["healing_boost"], 15)
|
||||
healed = b._heal(None, target, 50)
|
||||
self.assertEqual(healed, int(50 * 1.15))
|
||||
self.assertEqual(target.hp, target.max_hp - 100 + int(50 * 1.15))
|
||||
|
||||
def test_hot_relic(self):
|
||||
b = _battle("regrowth")
|
||||
for u in b.players:
|
||||
self.assertIn(S_HOT, u.status)
|
||||
self.assertEqual(u.status[S_HOT][0][0], 20) # 20 HP/turn (may have ticked once)
|
||||
|
||||
def test_snowflakes_relic(self):
|
||||
b = _battle("frost_blades")
|
||||
for u in b.players:
|
||||
self.assertEqual(u.status.get(S_SNOWFLAKES, 0), 5)
|
||||
|
||||
def test_enemy_debuff_relic(self):
|
||||
b = _battle("chilling_gust")
|
||||
for u in b.enemies:
|
||||
self.assertEqual(u.status["speed_down"], (8, 1)) # lasts 1 turn
|
||||
|
||||
def test_enemy_vulnerable_relic(self):
|
||||
b = _battle("expose")
|
||||
for u in b.enemies:
|
||||
self.assertEqual(u.status.get(S_VULNERABLE, 0), 2)
|
||||
|
||||
def test_multiple_relics_stack(self):
|
||||
b = _battle("iron_bark", "morning_dew", "chilling_gust")
|
||||
for u in b.players:
|
||||
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), 1)
|
||||
self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
|
||||
for u in b.enemies:
|
||||
self.assertEqual(u.status["speed_down"], (8, 1))
|
||||
|
||||
def test_janus_bloom_reduces_first_hit_by_80_percent(self):
|
||||
from engine.plants import make
|
||||
from engine.skills import S_PREVENT_FIRST_DAMAGE
|
||||
# baseline: full damage with protection status removed (no crits, so the
|
||||
# two battles below are directly comparable)
|
||||
target = make("Winter Jasmine", TEAM_PLAYER)
|
||||
attacker = make("Rose", TEAM_ENEMY)
|
||||
attacker.crit = 0.0
|
||||
b = Battle([target], [attacker])
|
||||
b.start()
|
||||
while b.current is not attacker:
|
||||
b.next_turn()
|
||||
target.status.pop(S_PREVENT_FIRST_DAMAGE, None)
|
||||
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
||||
hp0 = target.hp
|
||||
b.use_skill(attacker, thorn, target)
|
||||
full = hp0 - target.hp
|
||||
self.assertGreater(full, 0)
|
||||
# protected: same matchup, keep the Janus protection on the first hit
|
||||
target2 = make("Winter Jasmine", TEAM_PLAYER)
|
||||
attacker2 = make("Rose", TEAM_ENEMY)
|
||||
attacker2.crit = 0.0
|
||||
b2 = Battle([target2], [attacker2])
|
||||
b2.start()
|
||||
while b2.current is not attacker2:
|
||||
b2.next_turn()
|
||||
hp0 = target2.hp
|
||||
b2.use_skill(attacker2, next(s for s in attacker2.skills if s.kind == "damage"),
|
||||
target2)
|
||||
softened = hp0 - target2.hp
|
||||
self.assertLess(softened, full) # reduced
|
||||
self.assertAlmostEqual(softened / full, 0.2, delta=0.06) # ~20% remains
|
||||
|
||||
def test_protective_aura_log_is_not_janus(self):
|
||||
# The blessing grants the same protection as Winter Jasmine's passive,
|
||||
# but must not claim Janus Bloom in the log when no jasmine is present.
|
||||
from engine.plants import make
|
||||
target = make("Lavender", TEAM_PLAYER) # no tank on this team
|
||||
attacker = make("Rose", TEAM_ENEMY)
|
||||
b = Battle([target], [attacker], relics=["protective_aura"])
|
||||
b.start()
|
||||
guard = 0
|
||||
while b.current is not attacker and guard < 50:
|
||||
b.next_turn()
|
||||
guard += 1
|
||||
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
||||
b.use_skill(attacker, thorn, target)
|
||||
self.assertFalse(any("Janus Bloom" in (e if isinstance(e, str) else e["text"])
|
||||
for e in b.log))
|
||||
# first hit is reduced by 80%, not fully blocked
|
||||
self.assertLess(target.hp, target.max_hp)
|
||||
self.assertGreater(target.hp, target.max_hp * 0.5)
|
||||
|
||||
def test_second_wind_revives_once(self):
|
||||
from engine.plants import make
|
||||
target = make("Lavender", TEAM_PLAYER)
|
||||
attacker = make("Rose", TEAM_ENEMY)
|
||||
b = Battle([target], [attacker], relics=["second_wind"])
|
||||
b.start()
|
||||
guard = 0
|
||||
while b.current is not attacker and guard < 50:
|
||||
b.next_turn()
|
||||
guard += 1
|
||||
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
||||
target.hp = 1
|
||||
b.use_skill(attacker, thorn, target)
|
||||
self.assertTrue(target.alive) # saved by Second Wind
|
||||
self.assertLessEqual(target.hp, int(target.max_hp * 0.5))
|
||||
# only once: killing it again keeps it dead
|
||||
target.hp = 1
|
||||
b._kill(target, attacker)
|
||||
self.assertFalse(target.alive)
|
||||
|
||||
def test_legendary_relics(self):
|
||||
from engine.relics import RELIC_POOL
|
||||
legendary = [r for r in RELIC_POOL if r.rarity == "legendary"]
|
||||
self.assertGreaterEqual(len(legendary), 4)
|
||||
self.assertEqual(by_key("protective_aura").rarity, "legendary")
|
||||
self.assertIsNotNone(by_key("aegis"))
|
||||
self.assertIsNotNone(by_key("overgrowth"))
|
||||
self.assertIsNotNone(by_key("second_wind"))
|
||||
|
||||
def test_roll_weights_toward_common(self):
|
||||
import random
|
||||
random.seed(1234)
|
||||
counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0}
|
||||
for _ in range(600):
|
||||
for r in roll_relics(3):
|
||||
counts[r.rarity] += 1
|
||||
self.assertGreater(counts["common"], counts["legendary"])
|
||||
self.assertGreater(counts["rare"], counts["legendary"])
|
||||
self.assertGreater(counts["epic"], counts["legendary"])
|
||||
|
||||
def test_low_stage_suppresses_high_tiers(self):
|
||||
from engine.relics import _tier_weights
|
||||
low = _tier_weights(1)
|
||||
high = _tier_weights(20)
|
||||
self.assertGreater(low["common"] / low["legendary"],
|
||||
high["common"] / high["legendary"])
|
||||
self.assertGreater(low["common"] / low["epic"],
|
||||
high["common"] / high["epic"])
|
||||
# high tiers are never impossible
|
||||
self.assertGreater(low["legendary"], 0)
|
||||
self.assertGreater(low["epic"], 0)
|
||||
|
||||
def test_roll_honors_stage(self):
|
||||
from engine.relics import _tier_weights
|
||||
import random
|
||||
random.seed(7)
|
||||
def rarity_counts(stage):
|
||||
counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0}
|
||||
for _ in range(400):
|
||||
for r in roll_relics(3, stage=stage):
|
||||
counts[r.rarity] += 1
|
||||
return counts
|
||||
c1 = rarity_counts(1)
|
||||
c20 = rarity_counts(20)
|
||||
# higher stages draw notably more legendary/epic relics
|
||||
self.assertGreater(c20["legendary"], c1["legendary"])
|
||||
self.assertGreater(c20["epic"], c1["epic"])
|
||||
|
||||
def test_battle_with_relics_completes(self):
|
||||
keys = ["iron_bark", "sunlit", "chilling_gust", "living_spring", "protective_aura"]
|
||||
b = _battle(*keys)
|
||||
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))
|
||||
|
||||
|
||||
class TowerMaxStageTest(unittest.TestCase):
|
||||
def _set(self, owned, levels=None, stages=None, injected=None):
|
||||
"""Set owned plants + optional level/stage/injected per plant."""
|
||||
levels = levels or {}
|
||||
stages = stages or {}
|
||||
injected = injected or {}
|
||||
data = collection.load()
|
||||
data["owned"] = list(owned)
|
||||
for n in data["progress"]:
|
||||
data["progress"][n].pop("injected", None)
|
||||
for n in owned:
|
||||
prog = data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
prog["level"] = levels.get(n, 1)
|
||||
prog["stage"] = stages.get(n, 1)
|
||||
if n in injected:
|
||||
prog["injected"] = injected[n]
|
||||
collection.save(data)
|
||||
|
||||
def test_fresh_account_caps_at_5(self):
|
||||
self._set(["Lavender", "Marigold"])
|
||||
from engine.collection import tower_max_stage
|
||||
self.assertEqual(tower_max_stage(), 5)
|
||||
|
||||
def test_evolved_plant_unlocks_10(self):
|
||||
self._set(["Lavender", "Marigold"], stages={"Lavender": 2})
|
||||
from engine.collection import tower_max_stage
|
||||
self.assertEqual(tower_max_stage(), 10)
|
||||
|
||||
def test_max_level_unlocks_15(self):
|
||||
self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3})
|
||||
from engine.collection import tower_max_stage
|
||||
self.assertEqual(tower_max_stage(), 15)
|
||||
|
||||
def test_max_level_plus_injection_unlocks_20(self):
|
||||
self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3},
|
||||
injected={"Lavender": {"index": 1, "skill": {}}})
|
||||
from engine.collection import tower_max_stage
|
||||
self.assertEqual(tower_max_stage(), 20)
|
||||
|
||||
def test_injection_alone_does_not_unlock(self):
|
||||
self._set(["Lavender"], injected={"Lavender": {"index": 1, "skill": {}}})
|
||||
from engine.collection import tower_max_stage
|
||||
self.assertEqual(tower_max_stage(), 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,417 @@
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import engine.collection as collection
|
||||
from engine import runes
|
||||
from engine.battle import Battle
|
||||
from engine.skills import Skill, TARGET_ENEMY
|
||||
from engine.unit import TEAM_ENEMY, TEAM_PLAYER, Unit
|
||||
|
||||
|
||||
def _force_random(value):
|
||||
"""Force the random.random() used by the collection module to return `value`."""
|
||||
return mock.patch.object(collection, "random",
|
||||
mock.Mock(random=mock.Mock(return_value=value)))
|
||||
|
||||
|
||||
class RuneTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._orig = collection._DATA_FILE
|
||||
fd, cls._path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
collection._DATA_FILE = pathlib.Path(cls._path)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
collection._DATA_FILE = cls._orig
|
||||
if os.path.exists(cls._path):
|
||||
os.unlink(cls._path)
|
||||
|
||||
def setUp(self):
|
||||
collection.init_starter_pinwheel("radiant")
|
||||
data = collection.load()
|
||||
data["runes"] = []
|
||||
for n in ("Winter Jasmine", "Primrose"):
|
||||
if n not in data["owned"]:
|
||||
data["owned"].append(n)
|
||||
data["owned"].sort()
|
||||
for n in data["owned"]:
|
||||
data["progress"][n] = {"level": 1, "stage": 1, "xp": 0, "duplicates": 0,
|
||||
"watered_at": 0, "max_stage": 1, "equipped_runes": []}
|
||||
collection.save(data)
|
||||
self.plant = "Winter Jasmine"
|
||||
|
||||
def _evolve_to(self, name, stage, level, duplicates):
|
||||
data = collection.load()
|
||||
prog = data["progress"][name]
|
||||
prog["level"] = level
|
||||
prog["stage"] = stage
|
||||
prog["duplicates"] = duplicates
|
||||
collection.save(data)
|
||||
return collection.evolve(name)
|
||||
|
||||
# --- slots ------------------------------------------------------
|
||||
def test_starts_with_one_slot(self):
|
||||
self.assertEqual(collection.rune_slots(self.plant), 1)
|
||||
|
||||
def test_evolve_unlocks_second_slot(self):
|
||||
self.assertTrue(self._evolve_to(self.plant, 1, 10, 2))
|
||||
self.assertEqual(collection.rune_slots(self.plant), 2)
|
||||
|
||||
def test_evolve_unlocks_third_slot(self):
|
||||
self.assertTrue(self._evolve_to(self.plant, 2, 20, 4))
|
||||
self.assertEqual(collection.rune_slots(self.plant), 3)
|
||||
|
||||
def test_slots_persist_through_extraction_reset(self):
|
||||
# evolve to stage 2 (2 slots), then reset like extract_skill does
|
||||
self.assertTrue(self._evolve_to(self.plant, 1, 10, 2))
|
||||
data = collection.load()
|
||||
prog = data["progress"][self.plant]
|
||||
prog["level"] = 1
|
||||
prog["stage"] = 1
|
||||
prog["xp"] = 0
|
||||
prog["duplicates"] = 0
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.rune_slots(self.plant), 2)
|
||||
|
||||
def test_slots_cap_at_three(self):
|
||||
self.assertTrue(self._evolve_to(self.plant, 2, 20, 4))
|
||||
self.assertEqual(collection.rune_slots(self.plant), 3)
|
||||
|
||||
# --- inventory & equip ------------------------------------------
|
||||
def test_add_rune_and_inventory(self):
|
||||
r = collection.add_rune("sapphire")
|
||||
self.assertEqual(r["color"], "sapphire")
|
||||
self.assertEqual(r["name"], "Sapphire Rune")
|
||||
self.assertGreaterEqual(r["pct"], 25)
|
||||
self.assertLessEqual(r["pct"], 35)
|
||||
self.assertEqual(collection.rune_inventory(), [r])
|
||||
|
||||
def test_add_rune_random_color(self):
|
||||
r = collection.add_rune()
|
||||
self.assertIn(r["color"], ("sapphire", "ruby", "amethyst", "jade", "brown"))
|
||||
if r["color"] == "brown":
|
||||
self.assertGreaterEqual(r["pct"], 5)
|
||||
self.assertLessEqual(r["pct"], 15)
|
||||
else:
|
||||
self.assertGreaterEqual(r["pct"], 25)
|
||||
self.assertLessEqual(r["pct"], 35)
|
||||
|
||||
def test_brown_rune_rolls_low_pct(self):
|
||||
r = collection.add_rune("brown")
|
||||
self.assertGreaterEqual(r["pct"], 5)
|
||||
self.assertLessEqual(r["pct"], 15)
|
||||
|
||||
def test_tiers(self):
|
||||
self.assertEqual(runes.tier("brown"), 2)
|
||||
for c in ("sapphire", "ruby", "amethyst", "jade"):
|
||||
self.assertEqual(runes.tier(c), 1)
|
||||
self.assertIn(c, runes.TIER1_COLORS)
|
||||
|
||||
def test_roll_ratio_is_proportional(self):
|
||||
# a maxed brown equals a maxed yellow despite 15 < 35
|
||||
self.assertEqual(runes.roll_ratio({"color": "brown", "pct": 15}),
|
||||
runes.roll_ratio({"color": "jade", "pct": 35}))
|
||||
# mid rolls compare against their own cap
|
||||
self.assertGreater(runes.roll_ratio({"color": "jade", "pct": 30}),
|
||||
runes.roll_ratio({"color": "brown", "pct": 10}))
|
||||
self.assertLess(runes.roll_ratio({"color": "brown", "pct": 5}),
|
||||
runes.roll_ratio({"color": "jade", "pct": 35}))
|
||||
|
||||
def test_free_runes_excludes_equipped(self):
|
||||
r1 = collection.add_rune("ruby")
|
||||
r2 = collection.add_rune("sapphire")
|
||||
self._evolve_to(self.plant, 1, 10, 2)
|
||||
collection.equip_rune(self.plant, 0, r1["id"])
|
||||
free = [r["id"] for r in collection.free_runes()]
|
||||
self.assertIn(r2["id"], free)
|
||||
self.assertNotIn(r1["id"], free)
|
||||
|
||||
def _ten_runes(self, data):
|
||||
ids = []
|
||||
for _ in range(10):
|
||||
ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"])
|
||||
return ids
|
||||
|
||||
def test_reroll_rune_improves_pct(self):
|
||||
r = collection.add_rune("ruby")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 25
|
||||
sac = self._ten_runes(data)
|
||||
collection.save(data)
|
||||
err = collection.reroll_rune(r["id"], sac)
|
||||
self.assertEqual(err, "")
|
||||
new_pct = collection.load()["runes"][0]["pct"]
|
||||
self.assertGreaterEqual(new_pct, 26) # at least 1% better
|
||||
self.assertLessEqual(new_pct, 35)
|
||||
self.assertEqual(len(collection.load()["runes"]), 1) # 10 consumed
|
||||
|
||||
def test_reroll_brown_caps_at_15(self):
|
||||
r = collection.add_rune("brown")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 5
|
||||
sac = self._ten_runes(data)
|
||||
collection.save(data)
|
||||
err = collection.reroll_rune(r["id"], sac)
|
||||
self.assertEqual(err, "")
|
||||
new_pct = collection.load()["runes"][0]["pct"]
|
||||
self.assertGreaterEqual(new_pct, 6)
|
||||
self.assertLessEqual(new_pct, 15)
|
||||
|
||||
def test_reroll_max_rune_rejected(self):
|
||||
r = collection.add_rune("ruby")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 35
|
||||
sac = self._ten_runes(data)
|
||||
collection.save(data)
|
||||
err = collection.reroll_rune(r["id"], sac)
|
||||
self.assertEqual(err, "Already max roll")
|
||||
self.assertEqual(len(collection.load()["runes"]), 11) # nothing consumed
|
||||
|
||||
def test_reroll_requires_exactly_ten(self):
|
||||
r = collection.add_rune("ruby")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 25
|
||||
sac = self._ten_runes(data)
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.reroll_rune(r["id"], sac[:9]),
|
||||
"Pick exactly 10 runes to sacrifice")
|
||||
|
||||
def test_reroll_rejects_target_in_sacrifices(self):
|
||||
r = collection.add_rune("ruby")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 25
|
||||
sac = self._ten_runes(data)
|
||||
sac[0] = r["id"] # 10 unique runes, but includes the upgrade target
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.reroll_rune(r["id"], sac),
|
||||
"Cannot sacrifice the rune being upgraded")
|
||||
|
||||
def test_reroll_unequips_sacrificed_runes(self):
|
||||
r1 = collection.add_rune("ruby")
|
||||
r2 = collection.add_rune("sapphire")
|
||||
data = collection.load()
|
||||
data["runes"][0]["pct"] = 25
|
||||
sac = self._ten_runes(data)
|
||||
sac[0] = r2["id"] # sacrifice the equipped rune
|
||||
collection.save(data)
|
||||
self._evolve_to(self.plant, 1, 10, 2)
|
||||
collection.equip_rune(self.plant, 0, r2["id"])
|
||||
err = collection.reroll_rune(r1["id"], sac)
|
||||
self.assertEqual(err, "")
|
||||
self.assertEqual(collection.equipped_runes(self.plant), [None, None])
|
||||
self.assertNotIn(r2["id"], [x["id"] for x in collection.load()["runes"]])
|
||||
|
||||
def test_equip_and_unequip(self):
|
||||
self._evolve_to(self.plant, 1, 10, 2) # 2 slots
|
||||
r1 = collection.add_rune("sapphire")
|
||||
r2 = collection.add_rune("ruby")
|
||||
self.assertEqual(collection.equip_rune(self.plant, 0, r1["id"]), "")
|
||||
self.assertEqual(collection.equipped_runes(self.plant), [r1["id"], None])
|
||||
# a rune can only occupy one slot; equipping elsewhere moves it
|
||||
self.assertEqual(collection.equip_rune(self.plant, 1, r1["id"]), "")
|
||||
self.assertEqual(collection.equipped_runes(self.plant), [None, r1["id"]])
|
||||
# fill the other slot too
|
||||
self.assertEqual(collection.equip_rune(self.plant, 0, r2["id"]), "")
|
||||
self.assertEqual(sorted(collection.equipped_runes(self.plant)),
|
||||
sorted([r1["id"], r2["id"]]))
|
||||
# bad slot / unknown rune are rejected
|
||||
self.assertNotEqual(collection.equip_rune(self.plant, 5, r1["id"]), "")
|
||||
self.assertNotEqual(collection.equip_rune(self.plant, 0, 9999), "")
|
||||
# unequip
|
||||
self.assertTrue(collection.unequip_rune(self.plant, 0))
|
||||
self.assertEqual(collection.equipped_runes(self.plant), [None, r1["id"]])
|
||||
self.assertFalse(collection.unequip_rune(self.plant, 5))
|
||||
|
||||
def test_equip_respects_slot_count(self):
|
||||
r = collection.add_rune("sapphire")
|
||||
# only 1 slot until evolved
|
||||
self.assertNotEqual(collection.equip_rune(self.plant, 1, r["id"]), "")
|
||||
self.assertEqual(collection.equip_rune(self.plant, 0, r["id"]), "")
|
||||
|
||||
def test_rune_can_only_be_equipped_on_one_plant(self):
|
||||
other = "Primrose"
|
||||
r = collection.add_rune("ruby")
|
||||
self._evolve_to(self.plant, 1, 10, 2)
|
||||
self._evolve_to(other, 1, 10, 2)
|
||||
self.assertEqual(collection.equip_rune(self.plant, 0, r["id"]), "")
|
||||
self.assertEqual(collection.rune_location(r["id"]), self.plant)
|
||||
# equipping the same rune on another plant moves it there
|
||||
self.assertEqual(collection.equip_rune(other, 0, r["id"]), "")
|
||||
self.assertEqual(collection.rune_location(r["id"]), other)
|
||||
self.assertEqual(collection.equipped_runes(self.plant), [None, None])
|
||||
|
||||
def test_max_same_type_runes_per_plant(self):
|
||||
self._evolve_to(self.plant, 2, 20, 4) # 3 slots
|
||||
ws = [collection.add_rune("amethyst") for _ in range(3)]
|
||||
self.assertEqual(collection.equip_rune(self.plant, 0, ws[0]["id"]), "")
|
||||
self.assertEqual(collection.equip_rune(self.plant, 1, ws[1]["id"]), "")
|
||||
# a third white rune is blocked
|
||||
self.assertNotEqual(collection.equip_rune(self.plant, 2, ws[2]["id"]), "")
|
||||
# a different colour still fits
|
||||
red = collection.add_rune("ruby")
|
||||
self.assertEqual(collection.equip_rune(self.plant, 2, red["id"]), "")
|
||||
|
||||
def test_enemy_runes_cap_same_type(self):
|
||||
# three white rolls: the 3rd is rejected in favour of another colour
|
||||
with mock.patch.object(runes, "random_color",
|
||||
side_effect=["amethyst", "amethyst", "amethyst", "ruby"]):
|
||||
rolled = runes.roll_enemy_runes(15)
|
||||
self.assertEqual(len(rolled), 3)
|
||||
effects = [e for e, _ in rolled]
|
||||
self.assertEqual(effects.count("damage_taken"), 2) # at most 2 white
|
||||
self.assertEqual(effects.count("damage"), 1)
|
||||
|
||||
# --- drops ------------------------------------------------------
|
||||
def test_battle_drop_chance(self):
|
||||
with _force_random(0.0):
|
||||
self.assertIsNotNone(collection.roll_battle_rune())
|
||||
with _force_random(0.99):
|
||||
self.assertIsNone(collection.roll_battle_rune())
|
||||
|
||||
def test_pack_drop_chance(self):
|
||||
data = collection.load()
|
||||
data["packs"]["general"] = 2
|
||||
collection.save(data)
|
||||
with _force_random(0.0):
|
||||
plant, is_new, bonus = collection.open_pack("general")
|
||||
self.assertIsNotNone(bonus)
|
||||
self.assertIn("color", bonus)
|
||||
with _force_random(0.99):
|
||||
plant, is_new, bonus = collection.open_pack("general")
|
||||
self.assertIsNone(bonus)
|
||||
|
||||
def test_pack_still_returns_plant_without_rune(self):
|
||||
data = collection.load()
|
||||
data["packs"]["general"] = 1
|
||||
collection.save(data)
|
||||
with _force_random(0.99):
|
||||
result = collection.open_pack("general")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_open_pack_history_keeps_last_ten(self):
|
||||
data = collection.load()
|
||||
data["packs"]["general"] = 12
|
||||
collection.save(data)
|
||||
for _ in range(12):
|
||||
collection.open_pack("general")
|
||||
history = collection.open_history()
|
||||
self.assertEqual(len(history), 10)
|
||||
for rec in history:
|
||||
self.assertIn("plant", rec)
|
||||
self.assertIn("is_new", rec)
|
||||
self.assertIn("rune", rec)
|
||||
|
||||
def test_buy_rune_t1(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 200
|
||||
collection.save(data)
|
||||
before = len(collection.rune_inventory())
|
||||
r = collection.buy_rune(1)
|
||||
self.assertIsInstance(r, dict)
|
||||
self.assertEqual(runes.tier(r["color"]), 1)
|
||||
self.assertEqual(collection.available_coins(), 100)
|
||||
self.assertEqual(len(collection.rune_inventory()), before + 1)
|
||||
|
||||
def test_buy_rune_t2(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 200
|
||||
collection.save(data)
|
||||
before = len(collection.rune_inventory())
|
||||
r = collection.buy_rune(2)
|
||||
self.assertIsInstance(r, dict)
|
||||
self.assertEqual(r["color"], "brown")
|
||||
self.assertEqual(collection.available_coins(), 50)
|
||||
self.assertEqual(len(collection.rune_inventory()), before + 1)
|
||||
|
||||
def test_buy_rune_insufficient_coins(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 50
|
||||
collection.save(data)
|
||||
before = len(collection.rune_inventory())
|
||||
self.assertEqual(collection.buy_rune(1), "Not enough coins")
|
||||
self.assertEqual(len(collection.rune_inventory()), before)
|
||||
self.assertEqual(collection.available_coins(), 50)
|
||||
|
||||
|
||||
class RuneBattleEffectTest(unittest.TestCase):
|
||||
def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5,
|
||||
dfn=20, hp=100, skills=None, runes=None):
|
||||
return Unit(name, team, ptype, max_hp=hp, attack=atk, speed=spd, defense=dfn,
|
||||
skills=skills or [Skill("Hit", 10, target=TARGET_ENEMY)],
|
||||
crit=0.0, runes=runes or [])
|
||||
|
||||
def _damage_test(self, attacker_runes=(), target_runes=()):
|
||||
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
|
||||
a = self._unit(atk=50, skills=[hit], runes=list(attacker_runes))
|
||||
t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, runes=list(target_runes))
|
||||
b = Battle([a], [t])
|
||||
b.start()
|
||||
b.use_skill(a, hit, t)
|
||||
base = int((10 + 25) * (1 - 20 / 95))
|
||||
return 500 - t.hp, base
|
||||
|
||||
def test_red_rune_boosts_damage(self):
|
||||
dealt, base = self._damage_test(attacker_runes=(("damage", 0.30),))
|
||||
self.assertEqual(dealt, int(base * 1.3))
|
||||
|
||||
def test_red_runes_stack_by_rolled_pct(self):
|
||||
dealt, base = self._damage_test(attacker_runes=(("damage", 0.25), ("damage", 0.35)))
|
||||
self.assertEqual(dealt, int(base * 1.6))
|
||||
|
||||
def test_white_rune_reduces_damage_taken(self):
|
||||
dealt, base = self._damage_test(target_runes=(("damage_taken", 0.30),))
|
||||
self.assertEqual(dealt, int(base * 0.7))
|
||||
|
||||
def test_red_and_white_combine(self):
|
||||
dealt, base = self._damage_test(attacker_runes=(("damage", 0.30),),
|
||||
target_runes=(("damage_taken", 0.30),))
|
||||
self.assertEqual(dealt, int(base * 1.3 * 0.7))
|
||||
|
||||
def test_blue_rune_boosts_shields(self):
|
||||
t = self._unit("T", TEAM_ENEMY, "garden", hp=500, runes=[("shield", 0.30)])
|
||||
b = Battle([self._unit()], [t])
|
||||
b.start()
|
||||
b._shield(t, 100)
|
||||
self.assertEqual(t.status["shield"], 130)
|
||||
|
||||
def test_yellow_rune_boosts_healing(self):
|
||||
heal = Skill("Mend", power=40, kind="heal", target="ally")
|
||||
a = self._unit(atk=50, skills=[heal])
|
||||
ally = self._unit("Ally", TEAM_PLAYER, "radiant", hp=1000, runes=[("healing", 0.30)])
|
||||
ally.hp = 100
|
||||
enemy = self._unit("E", TEAM_ENEMY, "garden")
|
||||
b = Battle([a, ally], [enemy])
|
||||
b.start()
|
||||
b.use_skill(a, heal, ally)
|
||||
self.assertEqual(ally.hp, 100 + int((40 + 25) * 1.3))
|
||||
|
||||
def test_brown_rune_applies_wilt_from_damage(self):
|
||||
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
|
||||
a = self._unit(atk=50, skills=[hit], runes=[("wilt", 0.06)])
|
||||
t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500)
|
||||
b = Battle([a], [t])
|
||||
b.start()
|
||||
b.use_skill(a, hit, t)
|
||||
base = int((10 + 25) * (1 - 20 / 95))
|
||||
self.assertEqual(500 - t.hp, base)
|
||||
# 6% of the 100 damage dealt lands as Wilt
|
||||
self.assertEqual(t.status["wilt"][0], int(base * 0.06))
|
||||
|
||||
def test_brown_rune_wilt_ticks_over_three_turns(self):
|
||||
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
|
||||
a = self._unit(atk=50, spd=9, skills=[hit], runes=[("wilt", 0.06)])
|
||||
t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=1)
|
||||
b = Battle([a], [t])
|
||||
b.start()
|
||||
b.use_skill(a, hit, t)
|
||||
self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,313 @@
|
||||
import atexit
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
# Point the engine at a fresh empty save to keep these 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()
|
||||
|
||||
|
||||
class SoilTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Rose", "Lavender", "Zinnia"]
|
||||
data["soil"] = {"seed_cd_until": 0.0, "seeds": [], "plots": []}
|
||||
data["water"] = 10
|
||||
data["fertilizer"] = 10
|
||||
collection.save(data)
|
||||
|
||||
def test_retrieve_seed(self):
|
||||
self.assertEqual(collection.retrieve_seed("Rose"), "")
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(len(state["seeds"]), 1)
|
||||
self.assertEqual(state["seeds"][0]["plant"], "Rose")
|
||||
self.assertGreater(state["cd_left"], 0) # cooldown set
|
||||
|
||||
def test_retrieve_respects_cooldown(self):
|
||||
collection.retrieve_seed("Rose")
|
||||
self.assertNotEqual(collection.retrieve_seed("Lavender"), "")
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(len(state["seeds"]), 1)
|
||||
|
||||
def test_retrieve_requires_owned(self):
|
||||
self.assertNotEqual(collection.retrieve_seed("NotAPlant"), "")
|
||||
data = collection.load()
|
||||
data["discovered"] = ["Marigold"]
|
||||
collection.save(data)
|
||||
self.assertNotEqual(collection.retrieve_seed("Marigold"), "")
|
||||
|
||||
def test_plant_creates_stage_zero_plot(self):
|
||||
collection.retrieve_seed("Rose")
|
||||
self.assertEqual(collection.plant_seed(0), "")
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(len(state["plots"]), 1)
|
||||
self.assertEqual(state["seeds"], [])
|
||||
plot = state["plots"][0]
|
||||
self.assertEqual(plot["stage"], 0)
|
||||
self.assertFalse(plot["grown"])
|
||||
self.assertFalse(plot["stalled"]) # stage not yet full
|
||||
|
||||
def test_stage_fills_over_time(self):
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
state = collection.soil_state()
|
||||
plot = state["plots"][0]
|
||||
self.assertEqual(plot["stage_progress"], 1.0)
|
||||
self.assertTrue(plot["stalled"]) # stage full -> needs water
|
||||
self.assertFalse(plot["grown"]) # but not the last stage
|
||||
|
||||
def test_water_requires_stalled(self):
|
||||
collection.retrieve_seed("Rose")
|
||||
collection.plant_seed(0)
|
||||
self.assertNotEqual(collection.water_plot(0), "") # not stalled yet
|
||||
|
||||
def test_water_advances_stage(self):
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.water_plot(0), "")
|
||||
self.assertEqual(collection.available_fertilizer(), 9)
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(state["plots"][0]["stage"], 1)
|
||||
self.assertEqual(state["plots"][0]["water"], 1)
|
||||
self.assertFalse(state["plots"][0]["stalled"]) # new stage just started
|
||||
|
||||
def test_water_requires_fertilizer_pool(self):
|
||||
data = collection.load()
|
||||
data["fertilizer"] = 0
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
self.assertNotEqual(collection.water_plot(0), "")
|
||||
|
||||
def test_harvest_requires_grown(self):
|
||||
collection.retrieve_seed("Rose")
|
||||
collection.plant_seed(0)
|
||||
self.assertNotEqual(collection.harvest_plot(0), "") # not grown
|
||||
|
||||
def test_grow_all_stages_then_harvest(self):
|
||||
# Rose is super-rare -> 4 stages. Plant with an old stage_started_at so each
|
||||
# stage is instantly full, then water through stages 0..2.
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
total = collection.GROWTH_STAGES["super_rare"]
|
||||
for expected in range(total - 1):
|
||||
self.assertNotEqual(collection.harvest_plot(0), "") # not grown mid-way
|
||||
self.assertEqual(collection.water_plot(0), "", f"water to advance to {expected + 1}")
|
||||
# each watered stage is instantly full again (stage_started_at=now)
|
||||
data = collection.load()
|
||||
data["soil"]["plots"][0]["stage_started_at"] = 0
|
||||
collection.save(data)
|
||||
state = collection.soil_state()
|
||||
self.assertTrue(state["plots"][0]["grown"])
|
||||
dup_before = collection.get_progress("Rose")["duplicates"]
|
||||
self.assertEqual(collection.harvest_plot(0), "")
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(state["plots"], [])
|
||||
self.assertEqual(collection.get_progress("Rose")["duplicates"], dup_before + 1)
|
||||
|
||||
def test_higher_rarity_more_stages(self):
|
||||
self.assertGreater(collection.GROWTH_STAGES["super_super_rare"],
|
||||
collection.GROWTH_STAGES["super_rare"])
|
||||
self.assertGreater(collection.GROWTH_STAGES["super_rare"],
|
||||
collection.GROWTH_STAGES["rare"])
|
||||
self.assertGreater(collection.GROWTH_STAGES["rare"],
|
||||
collection.GROWTH_STAGES["common"])
|
||||
|
||||
def test_default_one_plot_unlocked(self):
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(state["plots_unlocked"], 1)
|
||||
self.assertEqual(state["plots_max"], collection.SOIL_PLOTS_MAX)
|
||||
|
||||
def test_soil_gated_by_owned_plants(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Rose", "Lavender"]
|
||||
collection.save(data)
|
||||
self.assertFalse(collection.soil_unlocked())
|
||||
data = collection.load()
|
||||
data["owned"] = [f"Plant{i}" for i in range(collection.SOIL_GATE_PLANTS)]
|
||||
for n in data["owned"]:
|
||||
data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
||||
"duplicates": 0, "watered_at": 0})
|
||||
collection.save(data)
|
||||
self.assertTrue(collection.soil_unlocked())
|
||||
|
||||
def test_plot_unlock_costs_escalate(self):
|
||||
self.assertEqual(collection.plot_unlock_cost(2), (10, 5))
|
||||
self.assertEqual(collection.plot_unlock_cost(3), (50, 20))
|
||||
self.assertEqual(collection.plot_unlock_cost(4), (100, 50))
|
||||
self.assertEqual(collection.plot_unlock_cost(5), (0, 0))
|
||||
|
||||
def test_plant_limited_to_unlocked_plots(self):
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
# 1 unlocked, 1 planted -> planting another seed fails
|
||||
collection.retrieve_seed("Lavender")
|
||||
self.assertEqual(len(collection.soil_state()["seeds"]), 1)
|
||||
self.assertNotEqual(collection.plant_seed(0), "")
|
||||
|
||||
def test_unlock_plot_spends_coins_and_fertilizer(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 10
|
||||
data["fertilizer"] = 5
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.unlock_plot(), "")
|
||||
self.assertEqual(collection.soil_state()["plots_unlocked"], 2)
|
||||
self.assertEqual(collection.available_coins(), 0)
|
||||
self.assertEqual(collection.available_fertilizer(), 0)
|
||||
# too little fertilizer now
|
||||
self.assertNotEqual(collection.unlock_plot(), "")
|
||||
|
||||
def test_unlock_plot_requires_coins(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 0
|
||||
data["fertilizer"] = 50
|
||||
collection.save(data)
|
||||
self.assertNotEqual(collection.unlock_plot(), "")
|
||||
|
||||
def test_unlock_plot_requires_fertilizer(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 50
|
||||
data["fertilizer"] = 4
|
||||
collection.save(data)
|
||||
self.assertNotEqual(collection.unlock_plot(), "")
|
||||
|
||||
def test_unlock_plot_max_four(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 500
|
||||
data["fertilizer"] = 500
|
||||
collection.save(data)
|
||||
for _ in range(3):
|
||||
self.assertEqual(collection.unlock_plot(), "")
|
||||
self.assertEqual(collection.soil_state()["plots_unlocked"], collection.SOIL_PLOTS_MAX)
|
||||
self.assertNotEqual(collection.unlock_plot(), "") # capped
|
||||
|
||||
def test_after_unlock_extra_plot_usable(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 10
|
||||
data["fertilizer"] = 5
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
collection.unlock_plot() # now 2 unlocked, 1 used
|
||||
collection.retrieve_seed("Lavender")
|
||||
self.assertEqual(collection.plant_seed(0), "")
|
||||
self.assertEqual(len(collection.soil_state()["plots"]), 2)
|
||||
|
||||
|
||||
class AutoFarmerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
data = collection.load()
|
||||
data["owned"] = ["Rose", "Lavender", "Zinnia"]
|
||||
data["soil"] = {"seed_cd_until": 0.0, "seeds": [], "plots": []}
|
||||
data["fertilizer"] = 10
|
||||
data["coins"] = 100
|
||||
data["auto_farmer"] = False
|
||||
collection.save(data)
|
||||
|
||||
def _stalled_plot(self):
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||
collection.save(data)
|
||||
|
||||
def test_accrue_fertilizer_grants_one_per_hour(self):
|
||||
data = collection.load()
|
||||
data["fertilizer_accrued_at"] = time.time() - 7200 # 2 hours ago
|
||||
data["fertilizer"] = 0
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.accrue_fertilizer(), 2)
|
||||
self.assertEqual(collection.available_fertilizer(), 2)
|
||||
|
||||
def test_accrue_fertilizer_caps(self):
|
||||
data = collection.load()
|
||||
data["fertilizer_accrued_at"] = time.time() - 7200
|
||||
data["fertilizer"] = 99
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.accrue_fertilizer(), 1)
|
||||
self.assertEqual(collection.available_fertilizer(), 100)
|
||||
|
||||
def test_add_fertilizer_caps(self):
|
||||
data = collection.load()
|
||||
data["fertilizer"] = 99
|
||||
collection.save(data)
|
||||
collection.add_fertilizer(10)
|
||||
self.assertEqual(collection.available_fertilizer(), 100)
|
||||
|
||||
def test_buy_auto_farmer(self):
|
||||
self.assertEqual(collection.buy_auto_farmer(), "")
|
||||
self.assertTrue(collection.auto_farmer_owned())
|
||||
self.assertEqual(collection.available_coins(), 0)
|
||||
self.assertNotEqual(collection.buy_auto_farmer(), "") # already owned
|
||||
|
||||
def test_buy_auto_farmer_requires_coins(self):
|
||||
data = collection.load()
|
||||
data["coins"] = 50
|
||||
collection.save(data)
|
||||
self.assertNotEqual(collection.buy_auto_farmer(), "")
|
||||
self.assertFalse(collection.auto_farmer_owned())
|
||||
|
||||
def test_buy_activates_farmer(self):
|
||||
self.assertEqual(collection.buy_auto_farmer(), "")
|
||||
self.assertTrue(collection.auto_farmer_active())
|
||||
|
||||
def test_toggle_auto_farmer(self):
|
||||
collection.buy_auto_farmer()
|
||||
self.assertTrue(collection.auto_farmer_active())
|
||||
self.assertFalse(collection.toggle_auto_farmer()) # turned off
|
||||
self.assertFalse(collection.auto_farmer_active())
|
||||
self.assertTrue(collection.toggle_auto_farmer()) # back on
|
||||
self.assertTrue(collection.auto_farmer_owned()) # still owns it
|
||||
|
||||
def test_auto_farm_respects_toggle(self):
|
||||
self._stalled_plot()
|
||||
collection.buy_auto_farmer()
|
||||
collection.toggle_auto_farmer() # turn it off
|
||||
self.assertEqual(collection.auto_farm(), "Auto farmer off")
|
||||
self.assertEqual(collection.available_fertilizer(), 10) # nothing spent
|
||||
self.assertEqual(collection.soil_state()["plots"][0]["stage"], 0)
|
||||
|
||||
def test_auto_farm_fertilizes_stalled_plot(self):
|
||||
self._stalled_plot()
|
||||
self.assertEqual(collection.buy_auto_farmer(), "")
|
||||
self.assertEqual(collection.auto_farm(), "")
|
||||
self.assertEqual(collection.available_fertilizer(), 9)
|
||||
self.assertEqual(collection.soil_state()["plots"][0]["stage"], 1)
|
||||
|
||||
def test_auto_farm_requires_farmer(self):
|
||||
self._stalled_plot()
|
||||
self.assertEqual(collection.auto_farm(), "No auto farmer")
|
||||
|
||||
def test_auto_farm_does_not_plant_or_harvest(self):
|
||||
data = collection.load()
|
||||
data["soil"]["plots"] = [
|
||||
{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}, # stalled
|
||||
{"plant": "Rose", "stage_started_at": 0, "stage": 3, "water": 0}, # grown (epic)
|
||||
]
|
||||
data["soil"]["seeds"] = [{"plant": "Lavender"}]
|
||||
collection.save(data)
|
||||
self.assertEqual(collection.buy_auto_farmer(), "")
|
||||
self.assertEqual(collection.auto_farm(), "")
|
||||
state = collection.soil_state()
|
||||
self.assertEqual(len(state["plots"]), 2) # grown plot not harvested
|
||||
self.assertEqual(len(state["seeds"]), 1) # seed not planted
|
||||
self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user