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