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
View File
+1189
View File
File diff suppressed because it is too large Load Diff
+1212
View File
File diff suppressed because it is too large Load Diff
+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)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
MAX_LEVEL = 50
STAGE_MULT = [1.0, 1.25, 1.55, 1.9, 2.3]
GROWTH = {"attack": 0.06, "defense": 0.06, "health": 0.05, "speed": 0.04}
EVOLVE_STAGE_AT_LEVEL = {2: 10, 3: 20, 4: 30, 5: 40} # evolve to stage 2/3/4/5 at these levels
EVOLVE_COST = {2: 2, 3: 4, 4: 6, 5: 8} # duplicates consumed to evolve
def compute_stats(base_stats, level: int = 1, stage: int = 1):
"""base_stats = (attack, speed, defense, health); returns leveled stats."""
mult = STAGE_MULT[max(0, min(len(STAGE_MULT) - 1, stage - 1))]
atk, spd, dfn, hp = base_stats
def scale(v, growth):
return int(v * mult * (1 + growth * (level - 1)))
return (scale(atk, GROWTH["attack"]),
scale(spd, GROWTH["speed"]),
scale(dfn, GROWTH["defense"]),
scale(hp, GROWTH["health"]))
def xp_to_next(level: int) -> int:
return 40 * level
def stage_for_level(level: int) -> int:
stage = 1
for s in (2, 3, 4, 5):
if level >= EVOLVE_STAGE_AT_LEVEL[s]:
stage = s
return stage
def can_evolve(level: int, stage: int, duplicates: int) -> bool:
target = stage + 1
if target not in EVOLVE_STAGE_AT_LEVEL:
return False
return level >= EVOLVE_STAGE_AT_LEVEL[target] and duplicates >= EVOLVE_COST[target]
def evolve_cost(stage: int) -> int:
return EVOLVE_COST.get(stage + 1, 0) if stage + 1 in EVOLVE_COST else 0
+450
View File
@@ -0,0 +1,450 @@
from __future__ import annotations
import copy
from typing import Dict
from .growth import compute_stats
from .skills import (
S_ATTACK_DOWN, S_ATTACK_UP, S_CRIT_DOWN, S_DEFENSE_DOWN,
S_DEFENSE_UP, S_DENTING, S_HEAL_REDUCTION, S_ICICLES, S_INTOXICATE, S_MADNESS,
S_OBLIVION, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN, S_TETHER, S_VULNERABLE,
EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT, Effect, Skill,
TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY,
TARGET_SELF, TARGET_TWO_ENEMIES,
)
from .types import TYPE_GARDEN, TYPE_ICE, TYPE_NOCTURNAL, TYPE_RADIANT, TYPE_WILDFLOWER
from .unit import ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit
def _sk(name, power=0, target=TARGET_ENEMY, cd=0, hits=1, kind="damage",
effects=None, desc="", priority=4, mechanic="", power_alt=0,
extra_action=False, form=""):
return Skill(name=name, power=power, cooldown=cd, target=target, kind=kind,
hits=hits, effects=effects or [], description=desc, priority=priority,
mechanic=mechanic, power_alt=power_alt, extra_action=extra_action,
requires_form=form)
def _st(key, value, dur=0):
return Effect(EFF_STATUS, value=value, duration=dur, key=key)
def _wilt(pct):
return Effect(EFF_WILT, value=pct)
def _dot(dmg, turns):
return Effect(EFF_DOT, value=dmg, duration=turns)
def _hot(amount, turns):
return Effect(EFF_HOT, value=amount, duration=turns)
def _shield(value):
return Effect(EFF_SHIELD, value=value)
POOL: Dict[str, dict] = {
"Zinnia": dict(role=ROLE_ATTACKER, type=TYPE_RADIANT, stats=(63, 68, 51, 48),
passive="zinnia_solar", skills=[
_sk("Blinding Slash", 20, hits=2, desc="20 dmg, 2 hits"),
_sk("Glitter", 10, hits=5, cd=3, mechanic="hits_per_effect", priority=1,
desc="10 dmg, 5 hits, +1 per effect on target"),
_sk("Glimmer", 10, hits=5, cd=3, mechanic="hits_per_self_effect", priority=2,
desc="10 dmg, 5 hits, +1 per effect on self"),
]),
"Sunflower": dict(role=ROLE_SUPPORT, type=TYPE_RADIANT, stats=(57, 68, 52, 46),
passive="sunflower_incandescent", passive_value=0.7, skills=[
_sk("Beam", 50, desc="50 dmg"),
_sk("Face The Sun", kind="utility", target=TARGET_ALL_ALLIES, cd=3, priority=1,
effects=[_hot(35, 3)], desc="Heal all allies 35/turn for 3 turns"),
_sk("Daybreak", kind="utility", target=TARGET_ALLY, cd=3,
mechanic="reset_cooldowns", priority=2, desc="Reset an ally's cooldowns"),
]),
"Daisy": dict(role=ROLE_SUPPORT, type=TYPE_RADIANT, stats=(57, 72, 48, 42),
passive="daisy_gentle", skills=[
_sk("Flutter", 40, mechanic="heal_random_ally", desc="40 dmg, heals a random ally"),
_sk("Sunkissed Glow", power=50, kind="heal", target=TARGET_ALL_ALLIES, cd=2, priority=1,
desc="Heal all allies for 50"),
_sk("Motivate", kind="heal", target=TARGET_ALLY, cd=4, mechanic="full_heal",
priority=2, desc="Heal an ally to full health"),
]),
"Marigold": dict(role=ROLE_TANK, type=TYPE_RADIANT, stats=(43, 50, 68, 73),
passive="marigold_golden", skills=[
_sk("Rustle", 25, desc="25 dmg"),
_sk("Puff Up", kind="utility", target=TARGET_SELF, cd=3, mechanic="puff_up",
priority=1, desc="Heal 30% max HP, cleanse self"),
_sk("Absorb", kind="utility", target=TARGET_SELF, cd=3, mechanic="absorb",
priority=2, desc="Take 20% less damage until next turn"),
]),
"Daffodil": dict(role=ROLE_TANK, type=TYPE_RADIANT, stats=(55, 47, 64, 70),
passive="daffodil_toxic", skills=[
_sk("Consume", 45, mechanic="consume_wilt",
desc="45 dmg, heal for damage dealt if target Wilted"),
_sk("Poison Ravish", 50, target=TARGET_ALL_ENEMIES, cd=3, mechanic="wilt_boost",
power_alt=75, priority=1, desc="50/75 dmg to all, more if Wilted"),
_sk("Deteriorate", 50, target=TARGET_ALL_ENEMIES, cd=3, effects=[_wilt(50)],
priority=2, desc="50 dmg to all, Wilt for 50% of damage"),
]),
"Rose": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(74, 66, 52, 49),
passive="rose_briars", skills=[
_sk("Thorned Strike", 60, effects=[_wilt(20)], desc="60 dmg, Wilt 20%"),
_sk("Petal Kiss", 90, cd=3, mechanic="ignore_defense", power_alt=0.3, priority=1,
desc="90 dmg, ignores 30% defense"),
_sk("Brambles", 75, target=TARGET_ALL_ENEMIES, cd=3,
effects=[_wilt(15), _st(S_HEAL_REDUCTION, 40, 2)],
priority=2, desc="75 dmg to all, Wilt 15%, -40% healing 2 turns"),
]),
"Peony": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(77, 58, 61, 61),
passive="peony_robust", skills=[
_sk("Petal Punch", 55, desc="55 dmg"),
_sk("Snap", 75, cd=3, mechanic="ignore_defense", power_alt=0.15, priority=1,
effects=[_st(S_DEFENSE_DOWN, 20, 3)], desc="75 dmg, -20% defense 3 turns"),
_sk("Denting Blows", 65, target=TARGET_TWO_ENEMIES, cd=3, effects=[_st(S_DENTING, 1)],
priority=2, desc="65 dmg to 2, permanent +3% damage taken (stacks)"),
]),
"Magnolia": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(73, 64, 55, 58),
passive="magnolia_nobility", skills=[
_sk("Royal Smack", 50, effects=[_st(S_DEFENSE_DOWN, 20, 1)], desc="50 dmg, -20% defense 1 turn"),
_sk("Crown", 55, cd=3, mechanic="strip_buffs", priority=1, desc="55 dmg, removes target buffs"),
_sk("Regal Order", 90, cd=3, mechanic="shield_lifesteal", power_alt=15, priority=2,
effects=[_st("stun", 1)], desc="90 dmg, stun 1 turn, shield self 15% of damage"),
]),
"Hydrangea": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(68, 70, 52, 54),
passive="hydrangea_ph", skills=[
_sk("Cloud", 45, mechanic="ph_cloud",
desc="45 dmg; Acidic: Wilt 30%, Alkaline: heal allies 30%"),
_sk("Alkaline Blossom", kind="utility", target=TARGET_SELF, cd=3, form="alkaline",
mechanic="extend_timers", priority=1, extra_action=True,
desc="Extend buffs/debuffs by 1 turn (extra action)"),
_sk("Acidic Blossom", 80, target=TARGET_ALL_ENEMIES, cd=3, form="acidic",
effects=[_wilt(40)], priority=2, extra_action=True,
desc="80 dmg to all, Wilt 40% (extra action)"),
]),
"Sweet Pea": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(71, 71, 36, 45),
passive="sweetpea_scent", skills=[
_sk("Vine Whip", 40, mechanic="tether_lowest",
desc="40 dmg, tethers lowest-HP ally for 1 turn"),
_sk("Sugar Bloom", 70, target=TARGET_ALL_ENEMIES, cd=3, priority=1,
desc="70 dmg to all enemies"),
_sk("Fragrant Bond", kind="utility", target=TARGET_ALL_ALLIES, cd=3,
effects=[_st(S_TETHER, 1, 1)], priority=2,
desc="Tether all allies; tethered allies heal from Sweet Pea's damage"),
]),
"Chrysanthemum": dict(role=ROLE_TANK, type=TYPE_GARDEN, stats=(38, 41, 70, 76),
passive="chrys_longevity", skills=[
_sk("Imperial Smite", 45, mechanic="def_bonus", desc="45 dmg + bonus from defense"),
_sk("Autumn Shield", power=50, kind="shield", target=TARGET_ALL_ALLIES, cd=2, priority=1,
effects=[_st(S_DEFENSE_UP, 8, 1)], desc="Shield all allies (scales with Attack), +defense 1 turn"),
_sk("Golden Dynasty", kind="utility", target=TARGET_ALL_ALLIES, cd=4,
mechanic="perma_hp_def", priority=2,
desc="Permanently +15% HP and +10% defense for all allies"),
]),
"Lily": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(79, 65, 41, 36),
passive="lily_passionate", passive_value=0.5, skills=[
_sk("Flora Blast", 30, target=TARGET_ALL_ENEMIES, desc="30 dmg to all enemies"),
_sk("Fervent Blossom", 50, target=TARGET_ALL_ENEMIES, cd=2, mechanic="low_hp_boost",
power_alt=80, priority=1, desc="50/80 dmg to all, more under 40% HP"),
_sk("Whirlwind", 95, target=TARGET_ALL_ENEMIES, cd=4, mechanic="high_hp_boost",
power_alt=120, priority=2, desc="95/120 dmg to all, more above 80% HP"),
]),
"Hibiscus": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(72, 67, 49, 55),
passive="hibiscus_intensifying", skills=[
_sk("Tropical Burst", 50, desc="50 dmg"),
_sk("Crimson Strike", 70, cd=3, mechanic="thresh_boost", power_alt=90, priority=1,
desc="70/90 dmg, more if target is high or low HP"),
_sk("Crimson Brew", 65, target=TARGET_ALL_ENEMIES, cd=3, mechanic="crimson_brew", priority=2,
desc="65 dmg to all, +attack to self for 2 turns"),
]),
"Poppy": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(76, 63, 59, 55),
passive="poppy_hypnotic", skills=[
_sk("Scarlet Lash", 55, desc="55 dmg"),
_sk("Satin Smite", 70, cd=3, effects=[_st(S_INTOXICATE, 1, 2)], priority=1,
desc="70 dmg, Intoxicate 2 turns"),
_sk("Narcosis", 70, target=TARGET_TWO_ENEMIES, cd=3,
effects=[_st(S_CRIT_DOWN, 20, 2), _st(S_OBLIVION, 1, 2)], priority=2,
desc="70 dmg to 2, -20% crit chance and Oblivion 2 turns"),
]),
"Tulip": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(69, 65, 53, 57),
passive="tulip_equinox", skills=[
_sk("Bulb Blast", 40, target=TARGET_TWO_ENEMIES, desc="40 dmg to 2 enemies"),
_sk("Spring Rain", 55, target=TARGET_ALL_ENEMIES, cd=3,
effects=[_st(S_ATTACK_DOWN, 10, 2)], priority=1, desc="55 dmg to all, -attack 2 turns"),
_sk("Vibrant Bells", power=80, kind="shield", target=TARGET_ALL_ALLIES, cd=3, priority=2,
effects=[_st("bulb_reflect", 1)], desc="Shield all allies (scales with Attack); reflects Bulb Blast"),
]),
"Lotus": dict(role=ROLE_SUPPORT, type=TYPE_WILDFLOWER, stats=(59, 75, 44, 53),
passive="lotus_rebirth", skills=[
_sk("Revitalize", power=40, kind="heal", target=TARGET_ALLY, mechanic="scale_heal",
desc="Heal an ally (scales with Attack)"),
_sk("Mud Resilience", kind="utility", target=TARGET_ALL_ALLIES, cd=2,
effects=[_st(S_ATTACK_UP, 10, 2)], priority=1, desc="+attack for all allies 2 turns"),
_sk("Purify", kind="utility", target=TARGET_ALL_ALLIES, cd=4,
mechanic="clear_debuffs_allies", priority=2, extra_action=True,
desc="Remove all debuffs from all allies (extra action)"),
]),
"Dandelion": dict(role=ROLE_SUPPORT, type=TYPE_WILDFLOWER, stats=(62, 74, 45, 37),
passive="dandelion_cover", skills=[
_sk("Drifting Seed", 40, effects=[_st(S_ATTACK_DOWN, 10, 1)], desc="40 dmg, -attack 1 turn"),
_sk("Bloom", power=90, kind="shield", target=TARGET_ALLY, cd=2, priority=1, desc="Shield an ally (scales with Attack)"),
_sk("Make A Wish", power=15, kind="shield", target=TARGET_ALL_ALLIES, cd=4,
effects=[_hot(20, 3)], priority=2,
desc="Shield all allies (scales with Attack), heal 20/turn for 3 turns"),
]),
"Nightshade": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(76, 69, 45, 53),
passive="nightshade_corruption", skills=[
_sk("Astra", 60, desc="60 dmg"),
_sk("Falling Star", 25, hits=4, cd=3, priority=1, desc="25 dmg, 4 hits"),
_sk("Dark Corrosion", kind="utility", target=TARGET_ALL_ENEMIES, cd=3,
effects=[_dot(70, 3)], priority=2, desc="70 dmg/turn to all for 3 turns (stacks)"),
]),
"Moonflower": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(77, 66, 54, 43),
passive="moonflower_night", skills=[
_sk("Lunar Beam", 45, mechanic="ramping", desc="45 dmg, +5 every use"),
_sk("Night Fog", kind="utility", target=TARGET_ALL_ENEMIES, cd=3,
effects=[_st(S_HEAL_REDUCTION, 30, 2), _st(S_VULNERABLE, 1)], priority=1,
desc="-healing 2 turns, Vulnerable 1 on all"),
_sk("Glimmering Moon", 65, target=TARGET_ALL_ENEMIES, cd=3,
mechanic="moon_double", priority=2, desc="65 dmg to all, chance to hit twice"),
]),
"Orchid": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(74, 68, 47, 64),
passive="orchid_epiphytic", passive_value=0.4, skills=[
_sk("Branch", 60, desc="60 dmg"),
_sk("Sow", kind="utility", target=TARGET_ENEMY, cd=3, mechanic="sow", priority=1,
desc="Plant a seed that erupts for 115 dmg in 2 turns"),
_sk("Sweeping Elegance", 70, target=TARGET_ALL_ENEMIES, cd=3,
mechanic="strip_buffs_aoe", priority=2, desc="70 dmg to all, removes a buff each"),
]),
"Lavender": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(72, 77, 36, 43),
passive="lavender_soporific", skills=[
_sk("Tranquilize", 30, effects=[_st(S_SPEED_DOWN, 3, 1), _st(S_VULNERABLE, 1)],
desc="30 dmg, -speed 1 turn, Vulnerable 1"),
_sk("Soothing Lullaby", 50, target=TARGET_ALL_ENEMIES, cd=2,
effects=[_st(S_CRIT_DOWN, 10, 2)], priority=1, desc="50 dmg to all, -10% crit 2 turns"),
_sk("Sedate", 60, cd=4, effects=[_st(S_SLEEP, 2)], priority=2, desc="60 dmg, Sleep"),
]),
"Venus Flytrap": dict(role=ROLE_TANK, type=TYPE_NOCTURNAL, stats=(55, 44, 73, 68),
passive="flytrap_feed", skills=[
_sk("Devour", 45, desc="45 dmg"),
_sk("Lying In Wait", kind="utility", target=TARGET_SELF, cd=3,
mechanic="lying_wait", priority=1, desc="Enemy attacks charge Dissolve"),
_sk("Dissolve", 25, hits=2, cd=3, mechanic="dissolve", priority=2,
desc="25 dmg twice, +10 per charge"),
]),
"Snowdrop": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(73, 78, 52, 38),
passive="snowdrop_winter", skills=[
_sk("Snow Pierce", 50, effects=[_st(S_DEFENSE_DOWN, 20, 1)], desc="50 dmg, -20% defense 1 turn"),
_sk("Chill", kind="utility", target=TARGET_ENEMY, cd=2, effects=[_dot(45, 2)],
priority=1, desc="45 dmg/turn for 2 turns"),
_sk("Whiteout", 10, target=TARGET_ALL_ENEMIES, cd=4, mechanic="delay_all",
priority=2, desc="10 dmg to all, delays their next turns"),
]),
"Primrose": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(77, 73, 36, 42),
passive="primrose_coldhearted", skills=[
_sk("Swipe", 40, desc="40 dmg"),
_sk("Frostbite", 75, cd=3, effects=[_st(S_SPEED_DOWN, 3, 2), _st(S_HEAL_REDUCTION, 50, 2)],
priority=1, desc="75 dmg, -speed and -50% healing 2 turns"),
_sk("Icy Glare", 60, cd=3, effects=[_wilt(40)], priority=2, desc="60 dmg, Wilt 40%"),
]),
"Winter Jasmine": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(67, 73, 38, 55),
passive="jasmine_janus", skills=[
_sk("Solstice Spark", 45, desc="45 dmg"),
_sk("Thaw", kind="utility", target=TARGET_ALLY, cd=3, mechanic="vigil", priority=1,
desc="Remove debuffs, shield (scales with Attack); when hit, counter Solstice Spark"),
_sk("Catching Snowflakes", kind="utility", target=TARGET_ALL_ALLIES, cd=3,
mechanic="snowflakes", priority=2, extra_action=True,
desc="All allies' hits +10 (stacks). Extra action"),
]),
"Hellebore": dict(role=ROLE_SUPPORT, type=TYPE_ICE, stats=(76, 71, 33, 48),
passive="hellebore_toxin", skills=[
_sk("Pacify", power=45, kind="heal", target=TARGET_ALLY, mechanic="pacify",
desc="Heal an ally; removes Madness if below 40% HP"),
_sk("Madness", kind="utility", target=TARGET_ANY, cd=3, effects=[_st(S_MADNESS, 1)],
priority=1, desc="Apply Madness to a plant"),
_sk("Gradual Annihilation", 40, cd=3, effects=[_wilt(200)],
priority=2, desc="40 dmg, Wilt for 200% of damage"),
]),
"Camelia": dict(role=ROLE_TANK, type=TYPE_ICE, stats=(60, 49, 68, 71),
passive="camelia_frigid", skills=[
_sk("Hail", 55, effects=[_st(S_ICICLES, 1)], desc="55 dmg, gain 1 Icicle"),
_sk("Igloo", power=70, kind="shield", target=TARGET_SELF, cd=2, priority=1,
desc="Shield self (scales with Attack); gain 2 Icicles per hit while shielded"),
_sk("Blizzard", kind="utility", target=TARGET_ALL_ENEMIES, cd=4,
mechanic="shatter_icicles", priority=2, desc="Shatter Icicles to damage all"),
]),
# Common critters: each exists in all five types (the sprite is shared).
# They're always weaker than real plants and carry no passive.
"Pinwheel": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(30, 35, 28, 42),
skills=[
_sk("Spin Slash", 25, desc="25 dmg to one enemy"),
]),
"Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40),
skills=[
_sk("Nectar", power=20, kind="heal", target=TARGET_ALLY, cd=2,
desc="Heal an ally (scales with Attack)"),
_sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2,
desc="Shield an ally (scales with Attack)"),
]),
"Toadstool": dict(role=ROLE_TANK, type=TYPE_GARDEN, stats=(30, 30, 42, 54),
skills=[
_sk("Spore Bash", 22, mechanic="spore_siphon", power_alt=25,
desc="22 dmg, heal self for 25% of damage dealt"),
]),
# __MORE_PLANTS__
}
HP_MULT = 2.5 # balance: defense is now a % reduction, so HP needs less inflation
RARITY = {
# common critters
"Pinwheel": "common", "Butterfly": "common", "Toadstool": "common",
# super super rare (was legendary)
"Sunflower": "super_super_rare", "Hydrangea": "super_super_rare", "Poppy": "super_super_rare",
"Lavender": "super_super_rare", "Snowdrop": "super_super_rare", "Hellebore": "super_super_rare",
"Zinnia": "super_super_rare",
# super rare (was epic)
"Daisy": "super_rare", "Magnolia": "super_rare", "Sweet Pea": "super_rare",
"Chrysanthemum": "super_rare", "Lotus": "super_rare", "Nightshade": "super_rare",
"Moonflower": "super_rare", "Winter Jasmine": "super_rare", "Camelia": "super_rare", "Tulip": "super_rare",
"Rose": "super_rare", "Lily": "super_rare", "Orchid": "super_rare",
# rare (was common + rare)
"Hibiscus": "rare", "Dandelion": "rare", "Venus Flytrap": "rare",
"Marigold": "rare", "Daffodil": "rare", "Peony": "rare", "Primrose": "rare",
}
# Plant rarities. "common" (Golems) and "ultra_rare" (Ubers/Fruits) have no plants yet.
RARITY_ORDER = {
"common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4,
}
RARITY_LABELS = {
"common": "Common",
"rare": "Rare",
"super_rare": "Super Rare",
"super_super_rare": "Super Super Rare",
"ultra_rare": "Ultra Rare",
}
RARITY_COLORS = {
"common": (215, 215, 220), # White
"rare": (110, 170, 235), # Blue
"super_rare": (170, 120, 220), # Purple
"super_super_rare": (245, 180, 70), # Gold
"ultra_rare": (235, 75, 75), # Red
"epic": (170, 120, 220), # relics (legacy)
"legendary": (245, 180, 70), # relics (legacy)
}
# Critters that share one sprite across all five types; the type is assigned
# per unit (and shown with type icons in the collection).
VARIABLE_TYPE_UNITS = ("Pinwheel", "Butterfly", "Toadstool")
PASSIVES = {
"zinnia_solar": ("Solar Decay", "Enemies Zinnia attacks deal 1% less damage per hit, up to 30%."),
"sunflower_incandescent": ("Incandescent", "Heal the ally with the lowest health 70% of Attack each turn."),
"daisy_gentle": ("Gentle Encouragement", "Healing an ally removes a debuff and boosts their speed."),
"marigold_golden": ("Golden Gleam", "The first hit each turn is reduced by 30%."),
"daffodil_toxic": ("Toxic Sap", "When attacked, Wilt the attacker for 50%; drain their Wilt to heal instead."),
"rose_briars": ("Briars", "Reflect 40% of damage taken back to the attacker as Wilt."),
"peony_robust": ("Robust", "Attacks ignore 20% of defense."),
"magnolia_nobility": ("Nobility", "Immune to stuns; periodic debuffs last 1 turn less."),
"hydrangea_ph": ("pH Shift", "Switches between Acidic and Alkaline each turn."),
"sweetpea_scent": ("Sweet Scent", "Tethered allies take 10% less damage and heal 30% more."),
"chrys_longevity": ("Longevity", "Gain 3% defense when hit."),
"lily_passionate": ("Passionate", "+Attack and +Speed while enemies are below 50% health."),
"hibiscus_intensifying": ("Intensifying", "Deal more damage to enemies with more max health."),
"poppy_hypnotic": ("Hypnotic", "10% chance per attack to Intoxicate (1 turn) and Oblivion (1 turn) an enemy. Intoxicate: half the victim's damage splashes onto one of their own allies. Oblivion: they may only use their weakest skill."),
"tulip_equinox": ("Verdant Equinox", "Allies deal 10% more damage while shielded."),
"lotus_rebirth": ("Rebirth", "Revive the first fallen ally at 50% health."),
"dandelion_cover": ("Cover", "The first time an ally drops below 40% HP, shield them for 50% of max HP."),
"nightshade_corruption": ("Corruption", "When an enemy dies with Dark Corrosion, it spreads to the next most vulnerable enemy. Dark Corrosion also applies a random stat debuff to the target for 2 turns."),
"moonflower_night": ("Everlasting Night", "Damage all enemies each turn, growing stronger; Glimmering Moon's double-hit chance rises 10% each turn."),
"orchid_epiphytic": ("Epiphytic", "+Speed and +Attack while enemies are below 40% health."),
"lavender_soporific": ("Soporific", "+Speed and +Attack while an enemy is asleep."),
"flytrap_feed": ("Feed", "Heal for 20% of damage dealt."),
"snowdrop_winter": ("Winter Breeze", "Slow all enemies at start; Chill the attacker of a fallen ally."),
"primrose_coldhearted": ("Coldhearted", "The first skill bypasses tanks; afterwards only Frostbite does."),
"jasmine_janus": ("Janus Bloom", "Reduce the first damage each ally takes by 80%."),
"hellebore_toxin": ("Toxin", "Madness allies deal +50% and take +30%; enemies deal +30% and take +50%."),
"camelia_frigid": ("Frigid", "Gain an Icicle when hit."),
}
def plant_rarity(name: str) -> str:
return RARITY.get(name, "common")
def _apply_injection(name: str, team: int, skills):
"""Replace a plant's skill with its persisted injected ability, if any.
Only the player's own plants carry injections (enemies never inherit them).
Returns (injected_index, injected_name) or (-1, "")."""
if team != TEAM_PLAYER:
return -1, ""
from . import collection # lazy: collection imports plants at module level
from .skills import skill_from_dict
inj = collection.injection_of(name)
idx = inj.get("index", 1) if inj else -1
if inj and 0 < idx < len(skills):
skills[idx] = skill_from_dict(inj["skill"])
return idx, inj["skill"]["name"]
return -1, ""
def make(name: str, team: int, ptype: str = None) -> Unit:
spec = POOL[name]
atk, spd, dfn, hp = spec["stats"]
skills = [copy.deepcopy(s) for s in spec["skills"]]
inj_index, inj_name = _apply_injection(name, team, skills)
# Display order = list order (basic -> 2nd -> 3rd). Selection priority is
# position-based: the LAST skill is the strongest, so it is preselected first.
for i, s in enumerate(skills):
s.priority = len(skills) - i
unit_type = ptype if ptype is not None or name in VARIABLE_TYPE_UNITS else spec["type"]
unit = Unit(name, team, unit_type, max_hp=int(hp * HP_MULT), attack=atk, speed=spd, defense=dfn,
skills=skills,
role=spec["role"], passive=spec.get("passive", ""),
passive_value=spec.get("passive_value", 0))
unit.injected_index = inj_index
unit.injected_name = inj_name
_apply_runes(name, team, unit)
return unit
def _apply_runes(name: str, team: int, unit: Unit) -> None:
"""Load the player's equipped runes for `name` onto the unit as
(effect, pct) pairs. Only the player's own plants carry runes (enemies never
inherit them)."""
if team != TEAM_PLAYER:
return
from . import collection # lazy: collection imports plants at module level
from . import runes
equipped = collection.equipped_runes(name)
if not equipped:
return
by_id = {r["id"]: r for r in collection.rune_inventory()}
effects = []
for rune_id in equipped:
rune = by_id.get(rune_id)
if rune and rune["color"] in runes.RUNE_EFFECTS:
effects.append((runes.effect_for(rune["color"]), rune.get("pct", 30) / 100.0))
unit.runes = effects
def make_leveled(name: str, team: int, level: int = 1, stage: int = 1, ptype: str = None) -> Unit:
u = make(name, team, ptype)
if level > 1 or stage > 1:
atk, spd, dfn, hp = compute_stats(spec := POOL[name]["stats"], level, stage)
u.attack = atk
u.speed = spd
u.defense = dfn
u.max_hp = int(hp * HP_MULT)
u.hp = u.max_hp
u.level = level
u.stage = stage
return u
def build_pool() -> Dict[str, Unit]:
return {name: make(name, TEAM_PLAYER) for name in POOL}
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import random
from typing import Callable, Iterable, List, Optional
from . import runes
from .skills import (
S_ATTACK_DOWN, S_ATTACK_UP, S_DEFENSE_DOWN, S_DEFENSE_UP,
S_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN,
S_SPEED_UP, S_VULNERABLE,
)
class Relic:
"""A roguelike blessing collected during a Tower run. `apply` runs once at
the start of each battle for the rest of the run. Rune-reward relics
(`rune_reward` set) have no battle effect and instead grant runes when the
run ends; they may be picked more than once."""
__slots__ = ("key", "name", "desc", "rarity", "apply", "rune_reward")
def __init__(self, key: str, name: str, desc: str, rarity: str,
apply: Callable[["Battle"], None], rune_reward: str = "") -> None:
self.key = key
self.name = name
self.desc = desc
self.rarity = rarity
self.apply = apply
self.rune_reward = rune_reward
def __repr__(self) -> str:
return self.name
# --- apply helpers ------------------------------------------------------
def _players(battle):
return [u for u in battle.players if u.alive]
def _pct_stat(attr, pct):
def apply(battle):
for u in _players(battle):
setattr(u, attr, int(getattr(u, attr) * (1 + pct)))
return apply
def _add_stat(attr, amount):
def apply(battle):
for u in _players(battle):
setattr(u, attr, getattr(u, attr) + amount)
return apply
def _pct_hp(pct):
def apply(battle):
for u in _players(battle):
u.max_hp = int(u.max_hp * (1 + pct))
u.hp = u.max_hp
return apply
def _crit(pct):
def apply(battle):
for u in _players(battle):
u.crit = min(1.0, u.crit + pct)
return apply
def _shield(pct):
def apply(battle):
for u in _players(battle):
u.status[S_SHIELD] = u.status.get(S_SHIELD, 0) + int(u.max_hp * pct)
return apply
def _buff(key, value, duration):
def apply(battle):
for u in _players(battle):
u.status[key] = (value, duration)
return apply
def _enemy_debuff(key, value, duration):
def apply(battle):
for u in battle.enemies:
if u.alive:
u.status[key] = (value, duration)
return apply
def _heal_boost(pct):
def apply(battle):
for u in _players(battle):
u.status[S_HEAL_BOOST] = pct
return apply
def _hot(amount, turns):
def apply(battle):
for u in _players(battle):
u.status[S_HOT] = [(amount, turns)]
return apply
def _snowflakes(value):
def apply(battle):
for u in _players(battle):
u.status[S_SNOWFLAKES] = u.status.get(S_SNOWFLAKES, 0) + value
return apply
def _combine(*apps):
def apply(battle):
for fn in apps:
fn(battle)
return apply
def _prevent_first_damage():
def apply(battle):
for u in _players(battle):
u.status[S_PREVENT_FIRST_DAMAGE] = 1
return apply
def _enemy_vulnerable(hits):
def apply(battle):
for u in battle.enemies:
if u.alive:
u.status[S_VULNERABLE] = u.status.get(S_VULNERABLE, 0) + hits
return apply
# --- the relic pool -----------------------------------------------------
RELIC_POOL: List[Relic] = [
# common
Relic("iron_bark", "Iron Bark",
"Your team starts the battle with a shield for 10% of their max health.",
"common", _shield(0.10)),
Relic("vitality", "Vitality",
"+12% max health for your team.",
"common", _pct_hp(0.12)),
Relic("granite", "Granite Roots",
"+10% defense for your team.",
"common", _pct_stat("defense", 0.10)),
Relic("sunlit", "Sunlit Growth",
"+8% attack for your team.",
"common", _pct_stat("attack", 0.08)),
Relic("swift", "Swift Petals",
"+5 speed for your team.",
"common", _add_stat("speed", 5)),
Relic("sharp_eye", "Sharp Vision",
"+5% critical chance for your team.",
"common", _crit(0.05)),
Relic("morning_dew", "Morning Dew",
"Your team starts the battle with Attack Up for 1 turn.",
"common", _buff(S_ATTACK_UP, 10, 1)),
Relic("dawn_aura", "Dawn Aura",
"Your team starts the battle with Defense Up for 1 turn.",
"common", _buff(S_DEFENSE_UP, 8, 1)),
Relic("regrowth", "Regrowth",
"Your team starts the battle with a heal-over-time (20 HP/turn for 3 turns).",
"common", _hot(20, 3)),
# rare
Relic("living_spring", "Living Spring",
"Your team's heals are 15% stronger.",
"rare", _heal_boost(15)),
Relic("chilling_gust", "Chilling Gust",
"Enemies start the battle with Speed Down for 1 turn.",
"rare", _enemy_debuff(S_SPEED_DOWN, 8, 1)),
Relic("weakening_fog", "Weakening Fog",
"Enemies start the battle with Attack Down for 1 turn.",
"rare", _enemy_debuff(S_ATTACK_DOWN, 10, 1)),
Relic("frost_blades", "Frosted Blades",
"Your team starts the battle with +5 bonus hit damage.",
"rare", _snowflakes(5)),
Relic("gale_wind", "Gale Wind",
"+10 speed for your team.",
"rare", _add_stat("speed", 10)),
Relic("rune_cache", "Rune Cache",
"No battle effect. When the tower run ends, gain a random Tier 1 rune. "
"Can be picked more than once.",
"rare", lambda battle: None, rune_reward="tier1"),
# epic
Relic("thicket", "Thick Thicket",
"Your team starts the battle with a shield for 25% of their max health.",
"epic", _shield(0.25)),
Relic("rusting_rain", "Rusting Rain",
"Enemies start the battle with Defense Down for 1 turn.",
"epic", _enemy_debuff(S_DEFENSE_DOWN, 20, 1)),
Relic("overpower", "Overpower",
"+15% attack for your team.",
"epic", _pct_stat("attack", 0.15)),
Relic("apex", "Apex Growth",
"+20% max health for your team.",
"epic", _pct_hp(0.20)),
Relic("fearless", "Fearless",
"+10% critical chance for your team.",
"epic", _crit(0.10)),
Relic("expose", "Expose",
"Enemies start the battle Vulnerable for 2 hits.",
"epic", _enemy_vulnerable(2)),
Relic("rune_crypt", "Rune Crypt",
"No battle effect. When the tower run ends, gain a Tier 2 rune (brown). "
"Can be picked more than once.",
"epic", lambda battle: None, rune_reward="brown"),
# legendary
Relic("protective_aura", "Protective Aura",
"Each ally takes 80% less damage from the first hit they receive in a battle.",
"legendary", _prevent_first_damage()),
Relic("aegis", "Aegis",
"Allies start the battle with a shield for 40% of their max health.",
"legendary", _shield(0.40)),
Relic("overgrowth", "Overgrowth",
"Allies start the battle with +20% max health and +10% attack.",
"legendary", _combine(_pct_hp(0.20), _pct_stat("attack", 0.10))),
Relic("second_wind", "Second Wind",
"The first time an ally would fall in a battle, they survive at 50% HP instead.",
"legendary", lambda battle: None),
]
RELIC_BY_KEY = {r.key: r for r in RELIC_POOL}
# Relative draw weight per rarity tier; legendary relics should show up rarely.
RELIC_RARITY_WEIGHT = {"common": 100, "rare": 60, "epic": 35, "legendary": 12}
def _tier_weights(stage):
"""Draw weights for a tower stage. Better tiers get rarer at low stages and
more common at high stages, but are never impossible."""
frac = 0.0
if stage:
frac = min(1.0, max(0.0, (stage - 1) / 19.0)) # 0 at stage 1, 1 at stage 20
common = 100
rare = 60 * (0.9 + 0.2 * frac)
epic = 35 * (0.7 + 0.6 * frac)
legendary = 12 * (0.5 + 1.0 * frac)
return {"common": common, "rare": rare, "epic": epic, "legendary": legendary}
def by_key(key: str) -> Optional[Relic]:
return RELIC_BY_KEY.get(key)
def rune_reward_count(relic_keys: Iterable[str]) -> int:
"""How many runes the picked rune-reward relics will grant when the run ends."""
return sum(1 for k in relic_keys if getattr(by_key(k), "rune_reward", ""))
def grant_rune_rewards(relic_keys: Iterable[str]) -> list:
"""Grant the runes promised by any rune-reward relics collected during a
tower run (called when the run ends). Returns the granted runes."""
from . import collection # lazy: collection doesn't import relics
granted = []
for key in relic_keys:
relic = by_key(key)
reward = getattr(relic, "rune_reward", "")
if reward == "tier1":
granted.append(collection.add_rune(random.choice(runes.TIER1_COLORS)))
elif reward == "brown":
granted.append(collection.add_rune("brown"))
return granted
def roll_relics(n: int = 3, exclude: Iterable[str] = (), stage: Optional[int] = None) -> List[Relic]:
"""Roll `n` distinct relics, excluding already-owned keys. Draws are weighted
by rarity, scaled by the tower stage so better tiers appear less in the early
stages (but are never impossible). Rune-reward relics are never excluded, so
they can be picked more than once in a run."""
ex = set(exclude)
pool = [r for r in RELIC_POOL if (r.key not in ex) or r.rune_reward]
if not pool:
return []
weights_by_tier = _tier_weights(stage)
weights = [weights_by_tier.get(r.rarity, 50) for r in pool]
chosen = []
while len(chosen) < n and pool:
pick = random.choices(pool, weights=weights, k=1)[0]
chosen.append(pick)
idx = pool.index(pick)
pool.pop(idx)
weights.pop(idx)
return chosen
+333
View File
@@ -0,0 +1,333 @@
from typing import List
from . import collection
from .plants import POOL, VARIABLE_TYPE_UNITS, make_leveled, plant_rarity
from .types import ALL_TYPES, TYPE_BEATS
from .unit import ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit
DIFFICULTY_RARITIES = {
1: ("rare",),
2: ("rare", "super_rare"),
3: ("rare", "super_rare"),
4: ("super_rare", "super_super_rare"),
5: ("super_super_rare",),
}
# Low-rarity plants that join floor enemies at higher floors (deterministic pick).
FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose",
"Hibiscus", "Dandelion", "Venus Flytrap"]
# Low-rarity tank plants used as enemy tanks on later floors.
FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"]
def floor_enemy_count(floor: int) -> int:
"""Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on
10-15, then a full 4-enemy team from floor 16 on."""
if floor <= 3:
return 1
if floor <= 9:
return 2
if floor <= 15:
return 3
return 4
def floor_enemy_names(floor: int, player_type: str) -> List[str]:
"""Deterministic enemy names for a battle floor. Floors 1-3 are a single
pinwheel; the team grows from there — 3+ enemies from floor 10 and always
4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and
duplicates are allowed (e.g. two pinwheels). Tank plants join from floor
40 and low-rarity plants from floor 45, replacing critter slots so a team
never exceeds 4 enemies."""
count = floor_enemy_count(floor)
# Lead with an attacker; the rest cycle through the trio so each floor gets
# a deterministic mix of roles (with repeats) built from the critters.
names = ["Pinwheel"]
cycle = ("Butterfly", "Toadstool", "Pinwheel", "Toadstool",
"Butterfly", "Pinwheel", "Butterfly", "Toadstool")
for i in range(1, count):
names.append(cycle[(floor + i) % len(cycle)])
# High floors mix in plants, replacing critter slots (4-enemy cap kept).
if floor >= 40 and count >= 3:
names[-1] = FLOOR_TANK_PLANTS[(floor // 2) % len(FLOOR_TANK_PLANTS)]
if floor >= 45 and count >= 4:
names[-2] = FLOOR_RARE_PLANTS[(floor * 7) % len(FLOOR_RARE_PLANTS)]
return names
def roll_enemy_types(names: List[str], player_type: str, floor: int) -> List[str]:
"""Types for a floor's enemies (parallel to `names`). The floor 1-3 pinwheel is
always the type the player is strong against; other critters roll a random
type; plants keep their own fixed type."""
import random
types = []
for i, n in enumerate(names):
if n in VARIABLE_TYPE_UNITS:
if floor <= 3:
types.append(TYPE_BEATS.get(player_type, "garden"))
else:
types.append(random.choice(ALL_TYPES))
else:
types.append(POOL[n]["type"])
return types
def floor_enemy_level(floor: int) -> int:
"""Enemy level for a floor (ramps toward ~20 at floor 100)."""
return 1 + (floor - 1) // 5
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
"""Build the enemy unit team for a floor, with level/stage scaling and a
fresh roll of critter types."""
names = floor_enemy_names(floor, player_type)
types = roll_enemy_types(names, player_type, floor)
return build_floor_enemies(names, types, floor)
def build_floor_enemies(names: List[str], types: List[str], floor: int) -> List[Unit]:
"""Build enemy units from an already-rolled name/type list (so the picker and
the actual battle always show the same enemies)."""
level = floor_enemy_level(floor)
stage = max(1, min(5, 1 + (floor - 1) // 20))
return [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
for n, t in zip(names, types)]
def difficulty_pool(difficulty: int) -> List[str]:
rarities = DIFFICULTY_RARITIES.get(difficulty, DIFFICULTY_RARITIES[1])
return [n for n in POOL if plant_rarity(n) in rarities]
TOWER_STAGES = 20
TOWER_INJECT_FLOOR = 10
TOWER_INJECT_CHANCE_MIN = 0.20
TOWER_INJECT_CHANCE_MAX = 0.60
def tower_inject_chance(stage: int) -> float:
"""Chance (0..1) that a tower enemy at `stage` carries an injected ability."""
if stage < TOWER_INJECT_FLOOR:
return 0.0
frac = min(1.0, (stage - TOWER_INJECT_FLOOR) / (TOWER_STAGES - TOWER_INJECT_FLOOR))
return TOWER_INJECT_CHANCE_MIN + frac * (TOWER_INJECT_CHANCE_MAX - TOWER_INJECT_CHANCE_MIN)
def roll_enemy_injection(name: str) -> dict:
"""Roll an injected ability for an enemy plant, or None. It comes from a
same-role plant's extractable skills; legendary plants only pull from other
legendary plants so they stay strong. Never replaces a slot with the same
ability it already has."""
import random
from .skills import skill_to_dict
my_rarity = plant_rarity(name)
my_role = POOL[name]["role"]
base = POOL[name]["skills"]
candidates = []
for other, spec in POOL.items():
if other == name:
continue
if spec["role"] != my_role:
continue
if my_rarity == "super_super_rare" and plant_rarity(other) != "super_super_rare":
continue
for _, sk in collection.extractable_skills(other):
candidates.append(sk)
if not candidates:
return None
pairs = []
for sk in candidates:
for slot in (1, 2):
if slot < len(base) and base[slot].name != sk.name:
pairs.append((sk, slot))
if not pairs:
return None
sk, slot = random.choice(pairs)
return {"index": slot, "source": name, "skill": skill_to_dict(sk)}
def make_tower_enemies(names: List[str], level: int, stage: int) -> List[Unit]:
"""Build a tower enemy team; each enemy independently rolls a chance to
carry one injected ability (floors 10+) and gains runes at higher stages."""
import random
from . import runes
from .skills import skill_from_dict
units = []
estage = tower_enemy_stage(stage)
for n in names:
u = make_leveled(n, TEAM_ENEMY, level, estage)
if random.random() < tower_inject_chance(stage):
inj = roll_enemy_injection(n)
if inj and inj["index"] < len(u.skills):
u.skills[inj["index"]] = skill_from_dict(inj["skill"])
u.injected_index = inj["index"]
u.injected_name = inj["skill"]["name"]
u.runes = runes.roll_enemy_runes(stage)
units.append(u)
return units
def roll_tower_enemy_team(stage: int, level: int, exclude=()) -> tuple:
"""Roll a full tower enemy team once, returning (names, injections, runes).
The recipe is cached per stage so a retry rebuilds the exact same team
instead of rerolling it."""
import random
from . import runes
names = roll_tower_enemies(stage, exclude=exclude)
injections = []
for n in names:
inj = None
if random.random() < tower_inject_chance(stage):
inj = roll_enemy_injection(n)
injections.append(inj)
enemy_runes = [runes.roll_enemy_runes(stage) for _ in names]
return names, injections, enemy_runes
def build_tower_enemy_team(names: List[str], injections, enemy_runes,
level: int, stage: int) -> List[Unit]:
"""Rebuild tower enemy units from a previously rolled team recipe."""
from .skills import skill_from_dict
units = []
estage = tower_enemy_stage(stage)
for n, inj, rns in zip(names, injections, enemy_runes or []):
u = make_leveled(n, TEAM_ENEMY, level, estage)
if inj and inj["index"] < len(u.skills):
u.skills[inj["index"]] = skill_from_dict(inj["skill"])
u.injected_index = inj["index"]
u.injected_name = inj["skill"]["name"]
u.runes = list(rns) if rns else []
units.append(u)
return units
def tower_pool(stage: int) -> tuple:
"""Rarities available at a tower stage (stage 1 = rares only, ramping up)."""
if stage <= 1:
return ("rare",)
if stage <= 3:
return ("rare", "super_rare")
if stage <= 5:
return ("rare", "super_rare")
if stage <= 8:
return ("super_rare",)
if stage <= 12:
return ("super_rare", "super_super_rare")
return ("super_super_rare",)
def tower_enemy_stage(stage: int) -> int:
"""Evolution stage for tower enemies: they evolve as the tower ramps, which
multiplies their base stats (1.0 / 1.25 / 1.55)."""
if stage >= 11:
return 3
if stage >= 5:
return 2
return 1
def tower_enemy_level(player_level: int, stage: int) -> int:
return max(1, min(33, player_level + (stage - 1) * 3 // 4))
def roll_tower_enemies(stage: int, exclude=()) -> List[str]:
"""Roll the tower enemy team for a stage. Count grows 1 -> 4; rarity ramps.
Tanks are scarce at high stages (there are no super-super-rare tanks), so the
tank slot is filled from the super-rare pool when the stage pool has none."""
import random
pool = [n for n in POOL
if plant_rarity(n) in tower_pool(stage) and n not in exclude]
count = 1 if stage == 1 else min(4, (stage + 3) // 2)
if count == 1:
return [random.choice(pool)]
def _by(role, source):
return [n for n in source if POOL[n]["role"] == role]
tank_pool = _by(ROLE_TANK, pool)
if not tank_pool:
tank_pool = [n for n in POOL
if POOL[n]["role"] == ROLE_TANK and plant_rarity(n) == "super_rare"
and n not in exclude]
chosen = []
for role, source in ((ROLE_TANK, tank_pool), (ROLE_SUPPORT, pool),
(ROLE_ATTACKER, pool), (ROLE_ATTACKER, pool)):
if len(chosen) >= count:
break
cands = [n for n in _by(role, source) if n not in chosen]
if cands:
chosen.append(random.choice(cands))
remaining = [n for n in pool if n not in chosen]
random.shuffle(remaining)
return (chosen + remaining)[:count]
def tower_reward(stage: int):
"""Reward for defeating a tower stage: (water, tower_packs)."""
if stage <= 5:
return 1, 0
return 0, 1 + (stage - 6) // 5
def roll_enemies(difficulty: int, exclude=()) -> List[str]:
"""Roll a 4-plant enemy team for a difficulty stage, aiming for
1 tank + 1 support + 2 attackers. Falls back gracefully when a role
isn't present in the pool (e.g. no legendary tanks on stage 5)."""
import random
pool = [n for n in difficulty_pool(difficulty) if n not in exclude]
if len(pool) < 4: # safety fallback: never fewer than a full team
pool = [n for n in POOL if n not in exclude]
def _by_role(role):
return [n for n in pool if POOL[n]["role"] == role]
chosen = []
for role in (ROLE_TANK, ROLE_SUPPORT, ROLE_ATTACKER, ROLE_ATTACKER):
candidates = [n for n in _by_role(role) if n not in chosen]
if candidates:
chosen.append(random.choice(candidates))
remaining = [n for n in pool if n not in chosen]
random.shuffle(remaining)
return (chosen + remaining)[:4]
def pick_team(names: List[str], team: int) -> List[Unit]:
units = []
for n in names:
prog = collection.get_progress(n)
units.append(make_leveled(n, team, prog["level"], prog["stage"],
ptype=collection.unit_type(n)))
return units
def make_enemy_team(names: List[str], level: int = 1, stage: int = 1) -> List[Unit]:
return [make_leveled(n, TEAM_ENEMY, level, stage) for n in names]
def player_team_level(names: List[str]) -> int:
levels = [collection.get_progress(n)["level"] for n in names]
return max(1, round(sum(levels) / max(1, len(levels))))
def player_team_stage(names: List[str]) -> int:
stages = [collection.get_progress(n)["stage"] for n in names]
return max(1, round(sum(stages) / max(1, len(stages))))
DEFAULT_PLAYERS = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
DEFAULT_ENEMIES = ["Rose", "Tulip", "Camelia", "Hellebore"]
def make_roster():
"""Deterministic level-1 rosters (used by tests; independent of save data)."""
return ([make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS],
[make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES])
def all_names() -> List[str]:
return list(POOL.keys())
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import random
# Rune kinds (gems). Each rune is an item equipped into a plant's slots.
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown")
RUNE_NAMES = {
"sapphire": "Sapphire Rune",
"ruby": "Ruby Rune",
"amethyst": "Amethyst Rune",
"jade": "Jade Rune",
"brown": "Brown Rune",
}
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2.
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
TIER2_COLORS = ("brown",)
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
# Sprite file (without extension) per rune color.
RUNE_ICON_FILES = {
"sapphire": "sapphire_rune",
"ruby": "ruby_rune",
"jade": "jade_rune",
"amethyst": "amethyst_rune",
"brown": "rune_brown",
}
# Old color names kept in earlier saves, mapped to the current gem names.
RUNE_COLOR_ALIASES = {
"blue": "sapphire",
"red": "ruby",
"white": "amethyst",
"yellow": "jade",
}
def canonical_color(color: str) -> str:
"""Map an old rune color name (blue/red/white/yellow) to the current gem."""
return RUNE_COLOR_ALIASES.get(color, color)
# Primitive effects: each rune rolls its own stat and stacks additively with
# other equipped runes of the same effect. Brown is rarer and rolls lower, but
# turns part of the damage this plant deals into a Wilt.
RUNE_EFFECTS = {
"ruby": "damage", # +damage dealt
"sapphire": "shield", # +shielding
"jade": "healing", # +healing received
"amethyst": "damage_taken", # -damage taken
"brown": "wilt", # damage dealt also Wils the target
}
PCT_MIN = 25
PCT_MAX = 35
BROWN_PCT_MIN = 5
BROWN_PCT_MAX = 15
# a plant can't stack more than this many runes of the same type
MAX_RUNES_PER_TYPE = 2
# coin costs to buy a random rune of a tier in the runes shop
T1_RUNE_COST = 100
T2_RUNE_COST = 150
# brown drops less often than the other runes (relative weights)
RUNE_WEIGHTS = {"sapphire": 1.0, "ruby": 1.0, "amethyst": 1.0, "jade": 1.0, "brown": 0.75}
_EFFECT_TEXTS = {
"ruby": "+{pct}% damage dealt",
"sapphire": "+{pct}% shielding",
"jade": "+{pct}% healing",
"amethyst": "-{pct}% damage taken",
"brown": "deals {pct}% of damage as Wilt",
}
RUNE_SUMMARIES = {
"ruby": "Increases damage dealt",
"sapphire": "Increases shielding",
"jade": "Increases healing received",
"amethyst": "Reduces damage taken",
"brown": "Deals a portion of damage as Wilt",
}
MAX_RUNE_SLOTS = 3
# Drop chances (test build)
BATTLE_DROP_CHANCE = 0.15 # rare rune from a normal floor battle win
PACK_DROP_CHANCE = 0.05 # rare additional rune from a seed pack
def random_color() -> str:
"""Roll a rune color; brown is weighted rarer than the others."""
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
return random.choices(RUNE_COLORS, weights=weights, k=1)[0]
def roll_pct(color: str = None) -> int:
"""Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35."""
if color == "brown":
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
return random.randint(PCT_MIN, PCT_MAX)
def name(color: str) -> str:
return RUNE_NAMES.get(color, "Rune")
def tier(color: str) -> int:
return RUNE_TIERS.get(color, 1)
def roll_ratio(rune) -> float:
"""How close a rune's roll is to its own maximum (0..1). Brown's 5-15 range
is compared to its 15 cap, so a maxed brown ranks equally with a maxed
yellow even though 15 < 35."""
max_pct = BROWN_PCT_MAX if rune.get("color") == "brown" else PCT_MAX
return rune.get("pct", 0) / max_pct
def effect_text(color: str, pct: int) -> str:
"""Human-readable effect for a rune of `color` with rolled stat `pct`."""
return _EFFECT_TEXTS.get(color, "").format(pct=pct)
_CARD_TEXTS = {
"ruby": "+{pct}% dmg",
"sapphire": "+{pct}% shield",
"jade": "+{pct}% heal",
"amethyst": "-{pct}% taken",
"brown": "Wilt {pct}%",
}
def card_text(color: str, pct: int) -> str:
"""Compact effect label that fits a small card (e.g. 'Wilt 12%')."""
return _CARD_TEXTS.get(color, "").format(pct=pct)
def effect_for(color: str) -> str:
return RUNE_EFFECTS.get(color, "")
_EFFECT_COLORS = {v: k for k, v in RUNE_EFFECTS.items()}
def color_for_effect(effect: str) -> str:
"""The rune colour whose effect is `effect` (for showing enemy runes)."""
return _EFFECT_COLORS.get(effect, "")
def summary(color: str) -> str:
return RUNE_SUMMARIES.get(color, "")
def roll_range(color: str) -> str:
"""The stat range a rune of `color` rolls between (brown is lower)."""
if color == "brown":
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
return f"{PCT_MIN}–{PCT_MAX}%"
def slots_for_max_stage(max_stage: int) -> int:
"""Permanent rune slots unlocked by the highest evolution stage a plant has
ever reached (stage 1 -> 1 slot, stage 2 -> 2, stage 3 -> 3)."""
return min(MAX_RUNE_SLOTS, max(1, max_stage))
def enemy_rune_count(stage: int) -> int:
"""How many runes a tower enemy carries at `stage` (more as the tower ramps)."""
if stage < 3:
return 0
return min(3, 1 + (stage - 3) // 5)
def roll_enemy_runes(stage: int) -> list:
"""Roll the runes a single tower enemy carries at `stage`. Returns a list of
(effect_key, pct_fraction) pairs ready for `Unit.runes`. At most
MAX_RUNES_PER_TYPE runes of the same type are rolled (same rule as players)."""
count = enemy_rune_count(stage)
runes_list = []
counts = {}
while len(runes_list) < count:
color = random_color()
if counts.get(color, 0) >= MAX_RUNES_PER_TYPE:
continue # type already at the cap; roll another colour
counts[color] = counts.get(color, 0) + 1
runes_list.append((effect_for(color), roll_pct(color) / 100.0))
return runes_list
def per_rune_chance(source_chance: float) -> tuple:
"""Effective drop chance (low, high) for a specific rune colour from a drop
source, where brown's lower weight makes it the low end."""
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
total = sum(weights)
return source_chance * min(weights) / total, source_chance * max(weights) / total
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import random
from collections import Counter
from .plants import POOL, plant_rarity
from .types import ALL_TYPES
PACK_TYPES = ("lesser", "tower", "general") + ALL_TYPES # lesser, tower, general + one per type
REWARD_PACK_TYPES = ("general",) + ALL_TYPES # good packs only (win rewards)
RARITY_WEIGHTS = {"common": 50, "rare": 35, "super_rare": 5, "super_super_rare": 2, "ultra_rare": 1}
LESSER_RARITY_WEIGHTS = {"common": 70, "rare": 25, "super_rare": 2, "super_super_rare": 0, "ultra_rare": 0}
TOWER_RARITY_WEIGHTS = {"common": 2, "rare": 15, "super_rare": 25, "super_super_rare": 15, "ultra_rare": 5}
def _weighted(names, weights_map=None) -> str:
weights_map = weights_map or RARITY_WEIGHTS
weights = [weights_map.get(plant_rarity(n), 20) for n in names]
return random.choices(names, weights=weights, k=1)[0]
def open_pack(pack_type: str) -> str:
"""Return a random plant name for the given pack type."""
if pack_type == "lesser":
return _weighted(list(POOL.keys()), LESSER_RARITY_WEIGHTS)
if pack_type == "tower":
return _weighted(list(POOL.keys()), TOWER_RARITY_WEIGHTS)
if pack_type == "general":
return _weighted(list(POOL.keys()))
if pack_type in ALL_TYPES:
# type pack: roll the rarity first (with the general ladder), then pick a
# plant of that rarity from the type; fall back to the whole pool only if
# that rarity isn't present in the type.
dist = _rarity_odds(list(POOL.keys()), RARITY_WEIGHTS)
rarity = random.choices(list(dist.keys()),
weights=[dist[r] for r in dist], k=1)[0]
candidates = [n for n in POOL
if POOL[n]["type"] == pack_type and plant_rarity(n) == rarity]
if not candidates:
candidates = [n for n in POOL if plant_rarity(n) == rarity]
return random.choice(candidates)
return _weighted(list(POOL.keys()))
def _rarity_odds(pool, weights_map) -> dict:
"""Chance (0..1) of each rarity when rolling from `pool` with `weights_map`."""
counts = Counter(plant_rarity(n) for n in pool)
total = sum(counts.get(r, 0) * w for r, w in weights_map.items())
if total <= 0:
return {r: 0.0 for r in weights_map}
return {r: counts.get(r, 0) * weights_map[r] / total for r in weights_map}
def pack_odds(pack_type: str) -> dict:
"""Chance (0..1) of each rarity for the given pack type."""
whole = list(POOL.keys())
if pack_type == "lesser":
return _rarity_odds(whole, LESSER_RARITY_WEIGHTS)
if pack_type == "tower":
return _rarity_odds(whole, TOWER_RARITY_WEIGHTS)
if pack_type == "general":
return _rarity_odds(whole, RARITY_WEIGHTS)
# type packs roll the rarity first with the general ladder, so their rarity
# odds match the general pack (the plant then comes from the type when possible).
return _rarity_odds(whole, RARITY_WEIGHTS)
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List
# Target kinds
TARGET_ENEMY = "enemy"
TARGET_ALL_ENEMIES = "all_enemies"
TARGET_TWO_ENEMIES = "two_enemies"
TARGET_ALLY = "ally"
TARGET_ALL_ALLIES = "all_allies"
TARGET_SELF = "self"
TARGET_ANY = "any"
# Effect kinds
EFF_SHIELD = "shield"
EFF_WILT = "wilt" # value = % of the skill's damage dealt, over 3 turns
EFF_DOT = "dot" # value = damage per turn, duration = turns
EFF_HOT = "hot" # value = heal per turn, duration = turns
EFF_STATUS = "status" # applies status[key] with value/duration
EFF_HEAL = "heal" # immediate heal
@dataclass
class Effect:
kind: str
value: int = 0
duration: int = 0
key: str = ""
@dataclass
class Skill:
name: str
power: int = 0
cooldown: int = 0
target: str = TARGET_ENEMY
kind: str = "damage" # damage | heal | shield | utility
hits: int = 1
effects: List[Effect] = field(default_factory=list)
description: str = ""
priority: int = 100 # lower = "best" (preselected / AI preference)
mechanic: str = "" # special behaviour id handled by the engine
extra_action: bool = False
requires_form: str = "" # hydrangea: "acidic" | "alkaline"
power_alt: int = 0 # conditional power (e.g. [50/75])
cooldown_left: int = field(init=False, default=0)
def roll_crit(chance: float) -> bool:
return random.random() < chance
def skill_to_dict(sk: "Skill") -> dict:
"""Serialize a Skill (minus runtime cooldown) for persistence."""
return {
"name": sk.name,
"power": sk.power,
"cooldown": sk.cooldown,
"target": sk.target,
"kind": sk.kind,
"hits": sk.hits,
"effects": [{"kind": e.kind, "value": e.value, "duration": e.duration, "key": e.key}
for e in sk.effects],
"description": sk.description,
"mechanic": sk.mechanic,
"extra_action": sk.extra_action,
"requires_form": sk.requires_form,
"power_alt": sk.power_alt,
}
def skill_from_dict(d: dict) -> "Skill":
"""Recreate a Skill from a skill_to_dict payload."""
return Skill(
name=d["name"],
power=d.get("power", 0),
cooldown=d.get("cooldown", 0),
target=d.get("target", TARGET_ENEMY),
kind=d.get("kind", "damage"),
hits=d.get("hits", 1),
effects=[Effect(**e) for e in d.get("effects", [])],
description=d.get("description", ""),
mechanic=d.get("mechanic", ""),
extra_action=d.get("extra_action", False),
requires_form=d.get("requires_form", ""),
power_alt=d.get("power_alt", 0),
)
# --- shared status keys -------------------------------------------------
S_SPEED_DOWN = "speed_down"
S_CRIT_DOWN = "crit_down"
S_ATTACK_DOWN = "attack_down"
S_DEFENSE_DOWN = "defense_down"
S_HEAL_REDUCTION = "healing_reduction"
S_HEAL_BOOST = "healing_boost"
S_SLEEP = "sleep"
S_VULNERABLE = "vulnerable"
S_INTOXICATE = "intoxicate"
S_OBLIVION = "oblivion"
S_TETHER = "tether"
S_MADNESS = "madness"
S_DENTING = "denting"
S_ATTACK_UP = "attack_up"
S_DEFENSE_UP = "defense_up"
S_SPEED_UP = "speed_up"
S_WILT = "wilt"
S_DOT = "dot"
S_HOT = "hot"
S_SHIELD = "shield"
S_ICICLES = "icicles"
S_SNOWFLAKES = "snowflakes"
S_ZINNIA_WEAKEN = "zinnia_weaken"
S_ABSORB = "absorb"
S_PREVENT_FIRST_DAMAGE = "prevent_first_damage"
S_LYING_WAIT = "lying_wait"
S_SEED = "seed" # orchid: erupts later
S_ATTACKED_THIS_TURN = "attacked_this_turn" # marigold: first-hit guard
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
TYPE_RADIANT = "radiant"
TYPE_NOCTURNAL = "nocturnal"
TYPE_ICE = "ice"
TYPE_GARDEN = "garden"
TYPE_WILDFLOWER = "wildflower"
ALL_TYPES = (TYPE_RADIANT, TYPE_NOCTURNAL, TYPE_ICE, TYPE_GARDEN, TYPE_WILDFLOWER)
# type_beats[X] = the type that X is strong against
TYPE_BEATS = {
TYPE_RADIANT: TYPE_NOCTURNAL,
TYPE_NOCTURNAL: TYPE_ICE,
TYPE_ICE: TYPE_GARDEN,
TYPE_GARDEN: TYPE_WILDFLOWER,
TYPE_WILDFLOWER: TYPE_RADIANT,
}
TYPE_ADVANTAGE_MULT = 1.4 # hitting a type you're strong against
TYPE_DISADVANTAGE_MULT = 0.7 # hitting a type that's strong against you
def type_multiplier(attacker_type: str, defender_type: str) -> float:
"""Damage multiplier between two plant types. Only affects damage."""
if TYPE_BEATS.get(attacker_type) == defender_type:
return TYPE_ADVANTAGE_MULT
if TYPE_BEATS.get(defender_type) == attacker_type:
return TYPE_DISADVANTAGE_MULT
return 1.0
def type_relations(ptype: str):
"""Return (strong_against, weak_against) for a type."""
strong = TYPE_BEATS.get(ptype)
weak = next((k for k, v in TYPE_BEATS.items() if v == ptype), None)
return strong, weak
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
from .skills import S_ATTACKED_THIS_TURN, Skill
TEAM_PLAYER = 0
TEAM_ENEMY = 1
ROLE_ATTACKER = "attacker"
ROLE_SUPPORT = "support"
ROLE_TANK = "tank"
ROLES = (ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK)
# Shared presentation for roles: human name, accent color, and icon file.
ROLE_NAMES = {
ROLE_ATTACKER: "Attacker",
ROLE_SUPPORT: "Support",
ROLE_TANK: "Tank",
}
ROLE_ICONS = {
ROLE_ATTACKER: "attacker_icon",
ROLE_SUPPORT: "supporter_icon",
ROLE_TANK: "tank_icon",
}
ROLE_COLORS = {
ROLE_ATTACKER: (235, 130, 90),
ROLE_SUPPORT: (120, 155, 235),
ROLE_TANK: (235, 200, 90),
}
@dataclass
class Unit:
name: str
team: int
plant_type: str
max_hp: int
attack: int
speed: int
defense: int
skills: List[Skill]
role: str = ""
crit: float = 0.10
passive: str = ""
passive_value: int = 0
sort_order: int = 0
runes: List = field(default_factory=list) # (effect_key, pct_fraction) per equipped rune
hp: int = field(init=False)
alive: bool = field(init=False)
status: dict = field(default_factory=dict)
acted: bool = field(default=False)
next_at: float = field(default=0.0)
level: int = field(default=1)
stage: int = field(default=1)
first_skill_used: bool = field(default=False) # primrose
used_once: bool = field(default=False) # generic "first time" flags
injected_index: int = field(default=-1, init=False)
injected_name: str = field(default="", init=False)
def __post_init__(self) -> None:
self.hp = self.max_hp
self.alive = True
self.status = {}
def reset_turn_flags(self) -> None:
self.acted = False
self.status.pop(S_ATTACKED_THIS_TURN, None)
def __repr__(self) -> str:
return self.name