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