Files
Flora/tests/test_runes.py
T
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

446 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 _sac_runes(self, data):
ids = []
for _ in range(runes.RUNE_SACRIFICE_COST):
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._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) # 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._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, 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._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"]), 1 + runes.RUNE_SACRIFICE_COST) # nothing consumed
def test_reroll_requires_exact_sacrifice_count(self):
r = collection.add_rune("ruby")
data = collection.load()
data["runes"][0]["pct"] = 25
sac = self._sac_runes(data)
collection.save(data)
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._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")
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._sac_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)
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,
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(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")
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
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()