Initial commit: Plants turn-based battle game
This commit is contained in:
+1189
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user