Initial commit: Plants turn-based battle game

This commit is contained in:
2026-08-28 14:26:07 +02:00
commit 6840b715de
158 changed files with 13352 additions and 0 deletions
+747
View File
@@ -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()
+57
View File
@@ -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()
+75
View File
@@ -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()
+230
View File
@@ -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()
+365
View File
@@ -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()
+417
View File
@@ -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()
+313
View File
@@ -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()