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