Initial commit: Plants turn-based battle game

This commit is contained in:
2026-08-28 14:26:07 +02:00
commit 6840b715de
158 changed files with 13352 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
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()