446 lines
19 KiB
Python
446 lines
19 KiB
Python
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() |