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
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import copy
from typing import Iterable, List, Optional
from .battle import Battle
from .unit import TEAM_ENEMY, TEAM_PLAYER, Unit
def _ai_act(battle: Battle, unit: Unit) -> bool:
"""Let `unit` take an action using the engine's AI (mirrors the player's
auto-battle behaviour). Returns True if it used a skill."""
if unit.team == TEAM_ENEMY:
return battle.enemy_ai(unit)
s = battle.best_skill(unit)
if s is None:
return False
foes = battle.foes_of(unit)
if s.kind == "heal":
low = [a for a in battle.allies_of(unit) if a.hp < a.max_hp]
return battle.use_skill(unit, s, min(low, key=lambda a: a.hp) if low else None)
if s.kind == "shield":
t = unit if s.target == "self" else min(battle.allies_of(unit), key=lambda a: a.hp)
return battle.use_skill(unit, s, t)
if s.target in ("all_enemies", "two_enemies"):
return battle.use_skill(unit, s, None)
if s.target == "any":
tg = battle.valid_skill_targets(unit, s)
return battle.use_skill(unit, s, min(tg, key=lambda t: t.hp) if tg else None)
if s.kind == "utility" and s.mechanic == "reset_cooldowns":
al = battle.allies_of(unit)
if al:
target = max(al, key=lambda a: sum(x.cooldown_left for x in a.skills))
return battle.use_skill(unit, s, target)
return False
if s.target in ("ally", "all_allies"):
al = battle.allies_of(unit)
return battle.use_skill(unit, s, min(al, key=lambda a: a.hp) if al else None)
tg = battle.valid_skill_targets(unit, s)
if not tg:
return False
if s.mechanic == "hits_per_effect":
t = max(tg, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp))
else:
t = min(tg, key=lambda t: t.hp)
return battle.use_skill(unit, s, t)
def play_to_end(battle: Battle) -> Optional[int]:
"""Play `battle` to completion with the AI controlling both sides.
Returns the winning team (TEAM_PLAYER / TEAM_ENEMY)."""
battle.start()
guard = 0
while battle.winner is None and battle.current is not None and guard < 600:
guard += 1
unit = battle.current
if not unit.acted:
used = _ai_act(battle, unit)
if not used:
battle.next_turn()
continue
if battle.current is unit and unit.acted:
battle.next_turn()
return battle.winner
def _surviving_hp_fraction(battle: Battle) -> float:
if not battle.players:
return 0.0
return sum(u.hp / u.max_hp for u in battle.players) / len(battle.players)
def estimate_dominance(player_units: List[Unit], enemy_units: List[Unit],
relics: Iterable[str] = (), runs: int = 25,
win_thresh: float = 0.99, margin_thresh: float = 0.6) -> bool:
"""Monte-Carlo estimate of whether the player team crushes the enemy team.
Returns True only for confident blowouts: the player wins almost every run
AND ends with most of the team at high health. Used to decide whether a
"Finish battle" button is safe to show (it's hidden the moment the matchup
is remotely close)."""
wins = 0
margin = 0.0
for i in range(runs):
b = Battle(copy.deepcopy(player_units), copy.deepcopy(enemy_units),
relics=list(relics))
winner = play_to_end(b)
if winner == TEAM_PLAYER:
wins += 1
margin += _surviving_hp_fraction(b)
# early exit: even if we win every remaining run we can't hit the threshold
if wins + (runs - i - 1) < runs * win_thresh:
return False
if runs == 0:
return False
return wins / runs >= win_thresh and margin / max(1, wins) >= margin_thresh
def resolve_instantly(player_units: List[Unit], enemy_units: List[Unit],
relics: Iterable[str] = ()) -> Optional[int]:
"""Run a single real simulation to completion and return its outcome. This
is what the 'Finish battle' button applies, so the result is genuine, not
an assumed win."""
b = Battle(copy.deepcopy(player_units), copy.deepcopy(enemy_units),
relics=list(relics))
return play_to_end(b)