Initial commit: Plants turn-based battle game
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user