small update

boss prototype, auto planter, visual reworks, bug fixes
This commit is contained in:
2026-08-29 10:45:34 +02:00
parent 6840b715de
commit feb4500c6c
31 changed files with 1293 additions and 213 deletions
+105 -1
View File
@@ -155,6 +155,28 @@ class ShieldWiltDotTest(unittest.TestCase):
self.assertEqual(t.hp, 100)
self.assertNotIn(S_SHIELD, t.status)
def test_heal_random_ally_prefers_injured_ally(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
full = make_unit("Full", TEAM_PLAYER, "radiant", hp=100)
hurt = make_unit("Hurt", TEAM_PLAYER, "radiant", hp=100)
hurt.hp = 50
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy, full, hurt], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertEqual(hurt.hp, 90) # the injured ally got healed
self.assertEqual(full.hp, 100) # full-HP ally untouched
def test_heal_random_ally_noop_when_all_full(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertFalse(any(isinstance(l, dict) and "recovers" in l["text"] for l in b.log)) # nothing healed
def test_wilt_ticks_over_three_turns(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 10, target=TARGET_ENEMY)])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
@@ -194,6 +216,45 @@ class ShieldWiltDotTest(unittest.TestCase):
self.assertEqual(t.hp, 410)
self.assertNotIn(S_DOT, t.status)
def test_shield_decays_after_3_turns(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
self.assertEqual(b._shield_total(t), 30)
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 3 -> 2
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 2 -> 1
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 0) # gone after the 3rd turn
self.assertNotIn(S_SHIELD, t.status)
def test_stacked_shields_decay_independently(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._end_turn_status(t) # first: 3 -> 2
b._shield(t, 40) # stack a new one (doesn't refresh the old)
b._end_turn_status(t) # first: 2 -> 1, second: 3 -> 2
b._end_turn_status(t) # first expires, second: 2 -> 1
self.assertEqual(b._shield_total(t), 40) # only the newer 40 remains
b._end_turn_status(t) # second expires too
self.assertEqual(b._shield_total(t), 0)
def test_shield_absorption_consumes_applications_in_order(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._shield(t, 40)
b._absorb(None, t, 50) # 30 from the old shield, 20 from the new one
self.assertEqual(b._shield_total(t), 20)
b._absorb(None, t, 20) # finishes the new shield
self.assertEqual(b._shield_total(t), 0)
self.assertEqual(t.hp, 500)
class MechanicsTest(unittest.TestCase):
def test_sleep_skips_until_two_hits(self):
@@ -487,7 +548,7 @@ class MechanicsTest(unittest.TestCase):
enemy.status["wilt"] = (30, 3)
enemy.status["dot"] = [(10, 2)]
enemy.status["vulnerable"] = 1
zinnia.status["shield"] = 10 # one self effect only
zinnia.status["shield"] = [(10, 3)] # one self effect only
self.assertIs(b.best_skill(zinnia), glitter)
def test_zinnia_targets_most_effect_rich_enemy(self):
@@ -743,5 +804,48 @@ class TowerInjectionTest(unittest.TestCase):
self.assertNotEqual(base.name, inj["skill"]["name"])
class TurnActionLimitTest(unittest.TestCase):
"""A unit must act only once per turn (spam-clicking must not multi-cast)."""
def _battle(self, skills):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=skills)
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=1000)
b = Battle([a], [t])
b.start()
return b, a, t
def test_unit_cannot_use_two_skills_in_one_turn(self):
hit1 = Skill("Hit1", power=10, target=TARGET_ENEMY)
hit2 = Skill("Hit2", power=10, target=TARGET_ENEMY)
b, a, t = self._battle([hit1, hit2])
self.assertTrue(b.use_skill(a, hit1, t))
self.assertFalse(b.use_skill(a, hit2, t)) # already acted
self.assertTrue(a.acted)
def test_same_skill_cannot_be_spammed(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY, cooldown=0)
b, a, t = self._battle([hit])
self.assertTrue(b.use_skill(a, hit, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted flag blocks the re-cast
def test_extra_action_skill_still_usable_after_acting(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t)) # extra action allowed after acting
self.assertTrue(a.acted)
def test_no_extra_actions_after_act_limit_exhausted(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
extra2 = Skill("Extra2", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra, extra2])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t))
self.assertTrue(b.use_skill(a, extra2, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted + cooldown: still blocked
if __name__ == "__main__":
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
import atexit
import os
import pathlib
import tempfile
import unittest
# Point the engine at a fresh empty save to keep collection-dependent tests hermetic.
import engine.collection as collection
from engine import bosses
from engine.battle import Battle
from engine.plants import make_leveled
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
_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 BossSchedulingTest(unittest.TestCase):
def test_boss_appears_on_multiples_of_20(self):
self.assertEqual(bosses.boss_for_floor(20), "Stones")
self.assertEqual(bosses.boss_for_floor(40), "Stones")
self.assertEqual(bosses.boss_for_floor(100), "Stones")
self.assertIsNone(bosses.boss_for_floor(19))
self.assertIsNone(bosses.boss_for_floor(1))
def test_boss_stats_are_extreme(self):
u = bosses.make_boss("Stones", 20)
# very low speed, medium defense, high health, medium attack
self.assertLessEqual(u.speed, 40)
self.assertGreaterEqual(u.max_hp, 900)
self.assertGreaterEqual(u.defense, 80)
self.assertTrue(60 <= u.attack <= 200)
class BossMechanicTest(unittest.TestCase):
def _battle(self):
a = make_leveled("Rose", TEAM_PLAYER, 20, 1)
a.max_hp = 5000
a.hp = 5000
u = bosses.make_boss("Stones", 20)
b = Battle([a], [u])
b.start()
return b, a, u
def test_landslide_blocked_until_three_stacks(self):
b, a, u = self._battle()
b.current = u
pebble, landslide = u.skills
u.acted = False
self.assertFalse(b.use_skill(u, landslide, a)) # 0 stacks: blocked
self.assertEqual(b.valid_skill_targets(u, landslide), [])
for _ in range(3):
u.acted = False
self.assertTrue(b.use_skill(u, pebble, a))
self.assertEqual(u.status["boss_stacks"], 3)
u.acted = False
self.assertTrue(b.use_skill(u, landslide, a)) # now available
self.assertEqual(u.status["boss_stacks"], 0) # released
def test_boss_stacks_cap_at_three(self):
b, a, u = self._battle()
b.current = u
pebble, _ = u.skills
for _ in range(6):
u.acted = False
self.assertTrue(b.use_skill(u, pebble, a))
self.assertEqual(u.status["boss_stacks"], 3) # never exceeds 3
def test_boss_ai_pebbles_then_landslides_then_repeats(self):
b, a, u = self._battle()
moves = []
guard = 0
while len(moves) < 7 and guard < 120 and b.winner is None:
guard += 1
if b.current is u and not u.acted:
before = u.status.get("boss_stacks", 0)
used = b.enemy_ai(u)
after = u.status.get("boss_stacks", 0)
if used:
moves.append("Pebble" if after > before else
("Landslide" if before >= 3 and after == 0 else "?"))
b.next_turn()
self.assertEqual(moves[:4], ["Pebble", "Pebble", "Pebble", "Landslide"])
self.assertEqual(moves[4:], ["Pebble", "Pebble", "Pebble"])
def test_landslide_hits_harder_than_pebble(self):
b, a, u = self._battle()
b.current = u
pebble, landslide = u.skills
for _ in range(3):
u.acted = False
b.use_skill(u, pebble, a)
u.acted = False
pebble_dmg = b._hit_damage.get(id(a), 0)
a.hp = a.max_hp # reset before landslide
u.status["boss_stacks"] = 3
b.use_skill(u, landslide, a)
landslide_dmg = b._hit_damage.get(id(a), 0)
self.assertGreater(landslide_dmg, pebble_dmg * 2)
class BossRewardTest(unittest.TestCase):
def setUp(self):
data = collection.load()
data["owned"] = ["Rose"]
data["bosses_fought"] = []
data["runes"] = []
collection.save(data)
def test_mark_boss_fought(self):
self.assertEqual(collection.bosses_fought(), set())
collection.mark_boss_fought("Stones")
self.assertEqual(collection.bosses_fought(), {"Stones"})
def test_boss_rune_drop_is_high_chance_and_select(self):
colors = set()
total = 0
for _ in range(300):
r = collection.boss_rune_drop("Stones")
if r:
total += 1
self.assertIn(r["color"], ("ruby", "amethyst"))
colors.add(r["color"])
self.assertGreaterEqual(total, 150) # ~80% chance
self.assertEqual(colors, {"ruby", "amethyst"})
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -103,7 +103,8 @@ 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))
total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
self.assertGreaterEqual(total, int(u.max_hp * 0.09))
def test_pct_hp_relic(self):
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
@@ -156,7 +157,8 @@ class RelicApplyTest(unittest.TestCase):
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)
total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
self.assertGreaterEqual(total, 1)
self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
for u in b.enemies:
self.assertEqual(u.status["speed_down"], (8, 1))
+44 -15
View File
@@ -131,9 +131,9 @@ class RuneTest(unittest.TestCase):
self.assertIn(r2["id"], free)
self.assertNotIn(r1["id"], free)
def _ten_runes(self, data):
def _sac_runes(self, data):
ids = []
for _ in range(10):
for _ in range(runes.RUNE_SACRIFICE_COST):
ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"])
return ids
@@ -141,20 +141,20 @@ class RuneTest(unittest.TestCase):
r = collection.add_rune("ruby")
data = collection.load()
data["runes"][0]["pct"] = 25
sac = self._ten_runes(data)
sac = self._sac_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
self.assertEqual(len(collection.load()["runes"]), 1) # cost 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)
sac = self._sac_runes(data)
collection.save(data)
err = collection.reroll_rune(r["id"], sac)
self.assertEqual(err, "")
@@ -166,27 +166,27 @@ class RuneTest(unittest.TestCase):
r = collection.add_rune("ruby")
data = collection.load()
data["runes"][0]["pct"] = 35
sac = self._ten_runes(data)
sac = self._sac_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
self.assertEqual(len(collection.load()["runes"]), 1 + runes.RUNE_SACRIFICE_COST) # nothing consumed
def test_reroll_requires_exactly_ten(self):
def test_reroll_requires_exact_sacrifice_count(self):
r = collection.add_rune("ruby")
data = collection.load()
data["runes"][0]["pct"] = 25
sac = self._ten_runes(data)
sac = self._sac_runes(data)
collection.save(data)
self.assertEqual(collection.reroll_rune(r["id"], sac[:9]),
"Pick exactly 10 runes to sacrifice")
self.assertEqual(collection.reroll_rune(r["id"], sac[:runes.RUNE_SACRIFICE_COST - 1]),
f"Pick exactly {runes.RUNE_SACRIFICE_COST} 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
sac = self._sac_runes(data)
sac[0] = r["id"] # unique runes, but includes the upgrade target
collection.save(data)
self.assertEqual(collection.reroll_rune(r["id"], sac),
"Cannot sacrifice the rune being upgraded")
@@ -196,7 +196,7 @@ class RuneTest(unittest.TestCase):
r2 = collection.add_rune("sapphire")
data = collection.load()
data["runes"][0]["pct"] = 25
sac = self._ten_runes(data)
sac = self._sac_runes(data)
sac[0] = r2["id"] # sacrifice the equipped rune
collection.save(data)
self._evolve_to(self.plant, 1, 10, 2)
@@ -338,6 +338,16 @@ class RuneTest(unittest.TestCase):
self.assertEqual(len(collection.rune_inventory()), before)
self.assertEqual(collection.available_coins(), 50)
def test_pearl_is_t2_crit_rune(self):
self.assertEqual(runes.tier("pearl"), 2)
self.assertEqual(runes.effect_for("pearl"), "crit")
self.assertEqual(runes.max_pct("pearl"), 40)
self.assertEqual(runes.roll_range("pearl"), "20–40%")
for _ in range(50):
self.assertGreaterEqual(runes.roll_pct("pearl"), 20)
self.assertLessEqual(runes.roll_pct("pearl"), 40)
self.assertEqual(runes.name("pearl"), "Pearl Rune")
class RuneBattleEffectTest(unittest.TestCase):
def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5,
@@ -378,7 +388,7 @@ class RuneBattleEffectTest(unittest.TestCase):
b = Battle([self._unit()], [t])
b.start()
b._shield(t, 100)
self.assertEqual(t.status["shield"], 130)
self.assertEqual(sum(a for a, _ in t.status["shield"]), 130)
def test_yellow_rune_boosts_healing(self):
heal = Skill("Mend", power=40, kind="heal", target="ally")
@@ -412,6 +422,25 @@ class RuneBattleEffectTest(unittest.TestCase):
b.use_skill(a, hit, t)
self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns
def test_pearl_rune_raises_crit_chance(self):
a = self._unit(runes=[("crit", 0.30)])
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.30)
def test_pearl_runes_stack_additively(self):
a = self._unit(runes=[("crit", 0.20), ("crit", 0.20)])
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.40)
def test_pearl_rune_adds_on_top_of_base_crit(self):
a = self._unit(runes=[("crit", 0.25)])
a.crit = 0.10
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.35)
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -219,6 +219,8 @@ class AutoFarmerTest(unittest.TestCase):
data["fertilizer"] = 10
data["coins"] = 100
data["auto_farmer"] = False
data["auto_planter"] = False
data["auto_planter_active"] = False
collection.save(data)
def _stalled_plot(self):
@@ -308,6 +310,100 @@ class AutoFarmerTest(unittest.TestCase):
self.assertEqual(len(state["seeds"]), 1) # seed not planted
self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced
def test_buy_auto_planter(self):
data = collection.load()
data["coins"] = 300
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertTrue(collection.auto_planter_owned())
self.assertTrue(collection.auto_planter_active())
self.assertEqual(collection.available_coins(), 100)
self.assertNotEqual(collection.buy_auto_planter(), "") # already owned
def test_buy_auto_planter_requires_coins(self):
data = collection.load()
data["coins"] = 50
collection.save(data)
self.assertNotEqual(collection.buy_auto_planter(), "")
self.assertFalse(collection.auto_planter_owned())
def test_toggle_auto_planter(self):
data = collection.load()
data["coins"] = 300
collection.save(data)
collection.buy_auto_planter()
self.assertFalse(collection.toggle_auto_planter()) # turned off
self.assertFalse(collection.auto_planter_active())
self.assertTrue(collection.toggle_auto_planter()) # back on
def test_auto_plant_plants_seed_into_empty_plot(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertEqual(collection.auto_plant(), "")
state = collection.soil_state()
self.assertEqual(len(state["plots"]), 1)
self.assertEqual(state["plots"][0]["plant"], "Rose") # first seed planted
self.assertEqual(len(state["seeds"]), 1)
def test_auto_plant_respects_toggle_and_requirements(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}]
collection.save(data)
self.assertEqual(collection.auto_plant(), "No auto planter") # not owned
collection.buy_auto_planter()
collection.toggle_auto_planter() # turn off
self.assertEqual(collection.auto_plant(), "Auto planter off")
collection.toggle_auto_planter()
data = collection.load()
data["soil"]["seeds"] = []
collection.save(data)
self.assertEqual(collection.auto_plant(), "") # nothing to plant -> idle
self.assertEqual(len(collection.soil_state()["plots"]), 0)
def test_auto_plant_wont_overfill_plots(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertEqual(collection.auto_plant(), "") # no free plot -> idle
self.assertEqual(len(collection.soil_state()["plots"]), 1)
class PackShopTest(unittest.TestCase):
def setUp(self):
data = collection.load()
data["coins"] = 500
data["packs"] = {t: 0 for t in collection.PACK_TYPES}
collection.save(data)
def test_buy_lesser_and_general(self):
self.assertEqual(collection.buy_pack("lesser"), "")
self.assertEqual(collection.buy_pack("general"), "")
self.assertEqual(collection.buy_pack("garden"), "")
self.assertEqual(collection.available_coins(), 500 - 50 - 100 - 100)
packs = collection.pack_counts()
self.assertEqual(packs["lesser"], 1)
self.assertEqual(packs["general"], 1)
self.assertEqual(packs["garden"], 1)
def test_tower_pack_cannot_be_bought(self):
self.assertEqual(collection.buy_pack("tower"), "That pack can't be bought")
self.assertEqual(collection.pack_counts().get("tower", 0), 0)
self.assertEqual(collection.available_coins(), 500)
def test_buy_pack_requires_coins(self):
data = collection.load()
data["coins"] = 30
collection.save(data)
self.assertEqual(collection.buy_pack("lesser"), "Not enough coins")
self.assertEqual(collection.pack_counts().get("lesser", 0), 0)
if __name__ == "__main__":
unittest.main()