365 lines
15 KiB
Python
365 lines
15 KiB
Python
import atexit
|
|
import os
|
|
import pathlib
|
|
import random
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
# make()/pick_team() read the save file, so point the engine at a fresh empty
|
|
# save to keep relic tests hermetic (same trick as test_battle.py).
|
|
import engine.collection as collection
|
|
|
|
_fd, _tmp_path = tempfile.mkstemp(suffix=".json")
|
|
os.close(_fd)
|
|
_TMP = pathlib.Path(_tmp_path)
|
|
_ORIG_DATA_FILE = collection._DATA_FILE
|
|
collection._DATA_FILE = _TMP
|
|
|
|
|
|
@atexit.register
|
|
def _restore_collection():
|
|
collection._DATA_FILE = _ORIG_DATA_FILE
|
|
if _TMP.exists():
|
|
_TMP.unlink()
|
|
|
|
|
|
from engine.battle import Battle
|
|
from engine.plants import make, make_leveled
|
|
from engine import runes
|
|
from engine.relics import RELIC_POOL, by_key, grant_rune_rewards, roll_relics, rune_reward_count
|
|
from engine.roster import DEFAULT_ENEMIES, DEFAULT_PLAYERS
|
|
from engine.skills import S_ATTACK_UP, S_DEFENSE_UP, S_HOT, S_SHIELD, S_SNOWFLAKES, S_VULNERABLE
|
|
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
|
|
|
|
|
|
def _battle(*relic_keys):
|
|
players = [make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS]
|
|
enemies = [make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES]
|
|
b = Battle(players, enemies, relics=relic_keys)
|
|
b.start()
|
|
return b
|
|
|
|
|
|
class RelicRollTest(unittest.TestCase):
|
|
def test_roll_returns_distinct_relics(self):
|
|
for _ in range(20):
|
|
rolled = roll_relics(3)
|
|
self.assertLessEqual(len(rolled), 3)
|
|
self.assertEqual(len({r.key for r in rolled}), len(rolled))
|
|
|
|
def test_roll_excludes_owned(self):
|
|
rolled = roll_relics(3, exclude=["iron_bark", "vitality", "granite"])
|
|
self.assertTrue(all(r.key not in ("iron_bark", "vitality", "granite") for r in rolled))
|
|
|
|
def test_by_key_roundtrip(self):
|
|
for r in RELIC_POOL:
|
|
self.assertIs(by_key(r.key), r)
|
|
|
|
def test_roll_with_all_excluded_keeps_rune_rewards(self):
|
|
rolled = roll_relics(3, exclude=[r.key for r in RELIC_POOL])
|
|
self.assertTrue(rolled)
|
|
self.assertTrue(all(r.rune_reward for r in rolled)) # rune rewards always available
|
|
|
|
def test_rune_reward_relics_are_rare_and_epic(self):
|
|
self.assertEqual(by_key("rune_cache").rarity, "rare")
|
|
self.assertEqual(by_key("rune_crypt").rarity, "epic")
|
|
|
|
def test_rune_reward_relics_ignore_exclusion(self):
|
|
# a picked rune-reward relic can be rolled again in the same run
|
|
|
|
def fake_choices(pool, weights=None, k=1):
|
|
pick = by_key("rune_cache")
|
|
return [pick if pick in pool else pool[0]]
|
|
|
|
with mock.patch.object(random, "choices", side_effect=fake_choices):
|
|
rolled = roll_relics(3, exclude=["rune_cache"])
|
|
self.assertIn("rune_cache", [r.key for r in rolled])
|
|
|
|
def test_grant_rune_rewards_tier1(self):
|
|
granted = grant_rune_rewards(["rune_cache"])
|
|
self.assertEqual(len(granted), 1)
|
|
self.assertIn(granted[0]["color"], runes.TIER1_COLORS)
|
|
|
|
def test_grant_rune_rewards_brown(self):
|
|
granted = grant_rune_rewards(["rune_crypt"])
|
|
self.assertEqual(len(granted), 1)
|
|
self.assertEqual(granted[0]["color"], "brown")
|
|
|
|
def test_grant_rune_rewards_counts(self):
|
|
granted = grant_rune_rewards(["rune_cache", "rune_crypt", "rune_cache"])
|
|
self.assertEqual(len(granted), 3)
|
|
colors = [g["color"] for g in granted]
|
|
self.assertEqual(colors.count("brown"), 1)
|
|
self.assertEqual(sum(1 for c in colors if c in runes.TIER1_COLORS), 2)
|
|
|
|
def test_rune_reward_count(self):
|
|
self.assertEqual(rune_reward_count(["rune_cache", "iron_bark", "rune_crypt"]), 2)
|
|
self.assertEqual(rune_reward_count(["iron_bark", "vitality"]), 0)
|
|
self.assertEqual(rune_reward_count([]), 0)
|
|
|
|
|
|
class RelicApplyTest(unittest.TestCase):
|
|
def test_shield_relic(self):
|
|
b = _battle("iron_bark")
|
|
for u in b.players:
|
|
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), int(u.max_hp * 0.09))
|
|
|
|
def test_pct_hp_relic(self):
|
|
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
|
b = _battle("vitality")
|
|
u = next(p for p in b.players if p.name == "Zinnia")
|
|
self.assertGreaterEqual(u.max_hp, int(base.max_hp * 1.12) - 1)
|
|
self.assertEqual(u.hp, u.max_hp) # healed to full
|
|
|
|
def test_attack_pct_relic(self):
|
|
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
|
b = _battle("sunlit")
|
|
u = next(p for p in b.players if p.name == "Zinnia")
|
|
self.assertEqual(u.attack, int(base.attack * 1.08))
|
|
|
|
def test_start_buff_relic(self):
|
|
b = _battle("morning_dew")
|
|
for u in b.players:
|
|
self.assertEqual(u.status[S_ATTACK_UP], (10, 1)) # lasts 1 turn
|
|
|
|
def test_heal_boost_relic(self):
|
|
b = _battle("living_spring")
|
|
target = b.players[0]
|
|
target.hp = target.max_hp - 100 # room for a full heal
|
|
self.assertEqual(target.status["healing_boost"], 15)
|
|
healed = b._heal(None, target, 50)
|
|
self.assertEqual(healed, int(50 * 1.15))
|
|
self.assertEqual(target.hp, target.max_hp - 100 + int(50 * 1.15))
|
|
|
|
def test_hot_relic(self):
|
|
b = _battle("regrowth")
|
|
for u in b.players:
|
|
self.assertIn(S_HOT, u.status)
|
|
self.assertEqual(u.status[S_HOT][0][0], 20) # 20 HP/turn (may have ticked once)
|
|
|
|
def test_snowflakes_relic(self):
|
|
b = _battle("frost_blades")
|
|
for u in b.players:
|
|
self.assertEqual(u.status.get(S_SNOWFLAKES, 0), 5)
|
|
|
|
def test_enemy_debuff_relic(self):
|
|
b = _battle("chilling_gust")
|
|
for u in b.enemies:
|
|
self.assertEqual(u.status["speed_down"], (8, 1)) # lasts 1 turn
|
|
|
|
def test_enemy_vulnerable_relic(self):
|
|
b = _battle("expose")
|
|
for u in b.enemies:
|
|
self.assertEqual(u.status.get(S_VULNERABLE, 0), 2)
|
|
|
|
def test_multiple_relics_stack(self):
|
|
b = _battle("iron_bark", "morning_dew", "chilling_gust")
|
|
for u in b.players:
|
|
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), 1)
|
|
self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
|
|
for u in b.enemies:
|
|
self.assertEqual(u.status["speed_down"], (8, 1))
|
|
|
|
def test_janus_bloom_reduces_first_hit_by_80_percent(self):
|
|
from engine.plants import make
|
|
from engine.skills import S_PREVENT_FIRST_DAMAGE
|
|
# baseline: full damage with protection status removed (no crits, so the
|
|
# two battles below are directly comparable)
|
|
target = make("Winter Jasmine", TEAM_PLAYER)
|
|
attacker = make("Rose", TEAM_ENEMY)
|
|
attacker.crit = 0.0
|
|
b = Battle([target], [attacker])
|
|
b.start()
|
|
while b.current is not attacker:
|
|
b.next_turn()
|
|
target.status.pop(S_PREVENT_FIRST_DAMAGE, None)
|
|
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
|
hp0 = target.hp
|
|
b.use_skill(attacker, thorn, target)
|
|
full = hp0 - target.hp
|
|
self.assertGreater(full, 0)
|
|
# protected: same matchup, keep the Janus protection on the first hit
|
|
target2 = make("Winter Jasmine", TEAM_PLAYER)
|
|
attacker2 = make("Rose", TEAM_ENEMY)
|
|
attacker2.crit = 0.0
|
|
b2 = Battle([target2], [attacker2])
|
|
b2.start()
|
|
while b2.current is not attacker2:
|
|
b2.next_turn()
|
|
hp0 = target2.hp
|
|
b2.use_skill(attacker2, next(s for s in attacker2.skills if s.kind == "damage"),
|
|
target2)
|
|
softened = hp0 - target2.hp
|
|
self.assertLess(softened, full) # reduced
|
|
self.assertAlmostEqual(softened / full, 0.2, delta=0.06) # ~20% remains
|
|
|
|
def test_protective_aura_log_is_not_janus(self):
|
|
# The blessing grants the same protection as Winter Jasmine's passive,
|
|
# but must not claim Janus Bloom in the log when no jasmine is present.
|
|
from engine.plants import make
|
|
target = make("Lavender", TEAM_PLAYER) # no tank on this team
|
|
attacker = make("Rose", TEAM_ENEMY)
|
|
b = Battle([target], [attacker], relics=["protective_aura"])
|
|
b.start()
|
|
guard = 0
|
|
while b.current is not attacker and guard < 50:
|
|
b.next_turn()
|
|
guard += 1
|
|
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
|
b.use_skill(attacker, thorn, target)
|
|
self.assertFalse(any("Janus Bloom" in (e if isinstance(e, str) else e["text"])
|
|
for e in b.log))
|
|
# first hit is reduced by 80%, not fully blocked
|
|
self.assertLess(target.hp, target.max_hp)
|
|
self.assertGreater(target.hp, target.max_hp * 0.5)
|
|
|
|
def test_second_wind_revives_once(self):
|
|
from engine.plants import make
|
|
target = make("Lavender", TEAM_PLAYER)
|
|
attacker = make("Rose", TEAM_ENEMY)
|
|
b = Battle([target], [attacker], relics=["second_wind"])
|
|
b.start()
|
|
guard = 0
|
|
while b.current is not attacker and guard < 50:
|
|
b.next_turn()
|
|
guard += 1
|
|
thorn = next(s for s in attacker.skills if s.kind == "damage")
|
|
target.hp = 1
|
|
b.use_skill(attacker, thorn, target)
|
|
self.assertTrue(target.alive) # saved by Second Wind
|
|
self.assertLessEqual(target.hp, int(target.max_hp * 0.5))
|
|
# only once: killing it again keeps it dead
|
|
target.hp = 1
|
|
b._kill(target, attacker)
|
|
self.assertFalse(target.alive)
|
|
|
|
def test_legendary_relics(self):
|
|
from engine.relics import RELIC_POOL
|
|
legendary = [r for r in RELIC_POOL if r.rarity == "legendary"]
|
|
self.assertGreaterEqual(len(legendary), 4)
|
|
self.assertEqual(by_key("protective_aura").rarity, "legendary")
|
|
self.assertIsNotNone(by_key("aegis"))
|
|
self.assertIsNotNone(by_key("overgrowth"))
|
|
self.assertIsNotNone(by_key("second_wind"))
|
|
|
|
def test_roll_weights_toward_common(self):
|
|
import random
|
|
random.seed(1234)
|
|
counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0}
|
|
for _ in range(600):
|
|
for r in roll_relics(3):
|
|
counts[r.rarity] += 1
|
|
self.assertGreater(counts["common"], counts["legendary"])
|
|
self.assertGreater(counts["rare"], counts["legendary"])
|
|
self.assertGreater(counts["epic"], counts["legendary"])
|
|
|
|
def test_low_stage_suppresses_high_tiers(self):
|
|
from engine.relics import _tier_weights
|
|
low = _tier_weights(1)
|
|
high = _tier_weights(20)
|
|
self.assertGreater(low["common"] / low["legendary"],
|
|
high["common"] / high["legendary"])
|
|
self.assertGreater(low["common"] / low["epic"],
|
|
high["common"] / high["epic"])
|
|
# high tiers are never impossible
|
|
self.assertGreater(low["legendary"], 0)
|
|
self.assertGreater(low["epic"], 0)
|
|
|
|
def test_roll_honors_stage(self):
|
|
from engine.relics import _tier_weights
|
|
import random
|
|
random.seed(7)
|
|
def rarity_counts(stage):
|
|
counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0}
|
|
for _ in range(400):
|
|
for r in roll_relics(3, stage=stage):
|
|
counts[r.rarity] += 1
|
|
return counts
|
|
c1 = rarity_counts(1)
|
|
c20 = rarity_counts(20)
|
|
# higher stages draw notably more legendary/epic relics
|
|
self.assertGreater(c20["legendary"], c1["legendary"])
|
|
self.assertGreater(c20["epic"], c1["epic"])
|
|
|
|
def test_battle_with_relics_completes(self):
|
|
keys = ["iron_bark", "sunlit", "chilling_gust", "living_spring", "protective_aura"]
|
|
b = _battle(*keys)
|
|
guard = 0
|
|
while b.winner is None and b.current is not None and guard < 500:
|
|
guard += 1
|
|
u = b.current
|
|
if u.team == TEAM_ENEMY:
|
|
b.enemy_ai(u)
|
|
else:
|
|
s = b.best_skill(u)
|
|
if s:
|
|
foes = b.foes_of(u)
|
|
if s.kind == "heal":
|
|
low = [a for a in b.allies_of(u) if a.hp < a.max_hp]
|
|
if low:
|
|
b.use_skill(u, s, min(low, key=lambda a: a.hp))
|
|
elif s.kind == "shield":
|
|
b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp))
|
|
elif s.target in ("all_enemies", "two_enemies"):
|
|
b.use_skill(u, s, None)
|
|
elif s.target == "any" and foes:
|
|
b.use_skill(u, s, foes[0])
|
|
elif s.target in ("ally", "all_allies"):
|
|
allies = b.allies_of(u)
|
|
if allies:
|
|
b.use_skill(u, s, min(allies, key=lambda a: a.hp))
|
|
elif foes:
|
|
b.use_skill(u, s, min(foes, key=lambda t: t.hp))
|
|
b.next_turn()
|
|
self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY))
|
|
|
|
|
|
class TowerMaxStageTest(unittest.TestCase):
|
|
def _set(self, owned, levels=None, stages=None, injected=None):
|
|
"""Set owned plants + optional level/stage/injected per plant."""
|
|
levels = levels or {}
|
|
stages = stages or {}
|
|
injected = injected or {}
|
|
data = collection.load()
|
|
data["owned"] = list(owned)
|
|
for n in data["progress"]:
|
|
data["progress"][n].pop("injected", None)
|
|
for n in owned:
|
|
prog = data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0,
|
|
"duplicates": 0, "watered_at": 0})
|
|
prog["level"] = levels.get(n, 1)
|
|
prog["stage"] = stages.get(n, 1)
|
|
if n in injected:
|
|
prog["injected"] = injected[n]
|
|
collection.save(data)
|
|
|
|
def test_fresh_account_caps_at_5(self):
|
|
self._set(["Lavender", "Marigold"])
|
|
from engine.collection import tower_max_stage
|
|
self.assertEqual(tower_max_stage(), 5)
|
|
|
|
def test_evolved_plant_unlocks_10(self):
|
|
self._set(["Lavender", "Marigold"], stages={"Lavender": 2})
|
|
from engine.collection import tower_max_stage
|
|
self.assertEqual(tower_max_stage(), 10)
|
|
|
|
def test_max_level_unlocks_15(self):
|
|
self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3})
|
|
from engine.collection import tower_max_stage
|
|
self.assertEqual(tower_max_stage(), 15)
|
|
|
|
def test_max_level_plus_injection_unlocks_20(self):
|
|
self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3},
|
|
injected={"Lavender": {"index": 1, "skill": {}}})
|
|
from engine.collection import tower_max_stage
|
|
self.assertEqual(tower_max_stage(), 20)
|
|
|
|
def test_injection_alone_does_not_unlock(self):
|
|
self._set(["Lavender"], injected={"Lavender": {"index": 1, "skill": {}}})
|
|
from engine.collection import tower_max_stage
|
|
self.assertEqual(tower_max_stage(), 5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |