small update
boss prototype, auto planter, visual reworks, bug fixes
This commit is contained in:
+57
-7
@@ -9,6 +9,7 @@ from .skills import (
|
||||
S_HEAL_REDUCTION, S_HOT, S_ICICLES, S_INTOXICATE, S_LYING_WAIT, S_MADNESS, S_OBLIVION,
|
||||
S_PREVENT_FIRST_DAMAGE, S_SEED, S_SHIELD, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN,
|
||||
S_SPEED_UP, S_TETHER, S_VULNERABLE, S_WILT, S_ZINNIA_WEAKEN,
|
||||
SHIELD_DURATION,
|
||||
EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT,
|
||||
TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY,
|
||||
TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit,
|
||||
@@ -135,6 +136,7 @@ class Battle:
|
||||
|
||||
def _crit(self, unit: Unit) -> float:
|
||||
pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN)
|
||||
pct += self._rune_pct(unit, "crit") * 100
|
||||
return max(0.0, pct / 100)
|
||||
|
||||
# --- tank protection ----------------------------------------------
|
||||
@@ -278,6 +280,15 @@ class Battle:
|
||||
del unit.status[key]
|
||||
else:
|
||||
unit.status[key] = (value, dur)
|
||||
# Shields decay like any buff: each application counts down SHIELD_DURATION
|
||||
# turns from when it was applied, independently of newer shields.
|
||||
shields = unit.status.get(S_SHIELD)
|
||||
if shields:
|
||||
remaining = [(amount, turns - 1) for amount, turns in shields if turns > 1]
|
||||
if remaining:
|
||||
unit.status[S_SHIELD] = remaining
|
||||
else:
|
||||
unit.status.pop(S_SHIELD, None)
|
||||
|
||||
# --- status ticking ------------------------------------------------
|
||||
def _tick_dot(self, unit: Unit) -> None:
|
||||
@@ -371,13 +382,20 @@ class Battle:
|
||||
blue = self._rune_pct(target, "shield")
|
||||
if blue:
|
||||
amount = int(amount * (1 + blue))
|
||||
target.status[S_SHIELD] = target.status.get(S_SHIELD, 0) + amount
|
||||
# Each application has its own decay timer; stacking does NOT refresh
|
||||
# shields already up (they still decay SHIELD_DURATION turns after being
|
||||
# applied).
|
||||
target.status.setdefault(S_SHIELD, []).append((amount, SHIELD_DURATION))
|
||||
if self._action is None:
|
||||
src = f" from {source.name}" if source else ""
|
||||
self.log.append(f"{target.name} gains {amount} shield{src}")
|
||||
if self._action is not None:
|
||||
self._action["shield"] += amount
|
||||
|
||||
def _shield_total(self, unit: Unit) -> int:
|
||||
"""Total shield remaining across all applications."""
|
||||
return sum(amount for amount, _ in unit.status.get(S_SHIELD, []))
|
||||
|
||||
def _shield_power(self, user: Unit, base: int) -> int:
|
||||
"""Shield amounts scale with the caster's Attack so they keep up with
|
||||
growing health pools (like the Sunflower passive)."""
|
||||
@@ -396,7 +414,7 @@ class Battle:
|
||||
if weaken:
|
||||
mult *= 1 - weaken / 100.0
|
||||
# tulip_equinox: any ally of Tulip with a shield deals +10%
|
||||
if attacker.status.get(S_SHIELD, 0) > 0:
|
||||
if self._shield_total(attacker) > 0:
|
||||
team = self.players if attacker.team == TEAM_PLAYER else self.enemies
|
||||
if any(u.passive == "tulip_equinox" for u in team):
|
||||
mult *= 1.1
|
||||
@@ -507,10 +525,25 @@ class Battle:
|
||||
def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
|
||||
self._consume_hits_statuses(target)
|
||||
reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0
|
||||
shield = target.status.get(S_SHIELD, 0)
|
||||
shield_list = target.status.get(S_SHIELD, [])
|
||||
shield = sum(amount for amount, _ in shield_list)
|
||||
if shield > 0:
|
||||
if shield >= dmg:
|
||||
target.status[S_SHIELD] = shield - dmg
|
||||
# Consume the damage across applications (oldest first).
|
||||
remaining = dmg
|
||||
new_shields = []
|
||||
for amount, turns in shield_list:
|
||||
if remaining <= 0:
|
||||
new_shields.append((amount, turns))
|
||||
elif amount > remaining:
|
||||
new_shields.append((amount - remaining, turns))
|
||||
remaining = 0
|
||||
else:
|
||||
remaining -= amount
|
||||
if new_shields:
|
||||
target.status[S_SHIELD] = new_shields
|
||||
else:
|
||||
target.status.pop(S_SHIELD, None)
|
||||
self.log.append(f" {target.name}'s shield absorbs {dmg}")
|
||||
self.events.append({"type": "shield", "target": target, "amount": dmg})
|
||||
if target.passive == "camelia_frigid":
|
||||
@@ -674,6 +707,8 @@ class Battle:
|
||||
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
||||
return []
|
||||
if skill.target == TARGET_ENEMY:
|
||||
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
|
||||
return [] # Landslide only becomes available at 3 stacks
|
||||
return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill))
|
||||
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
||||
return self.foes_of(user)
|
||||
@@ -755,6 +790,10 @@ class Battle:
|
||||
return False
|
||||
if skill.cooldown_left > 0:
|
||||
return False
|
||||
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
|
||||
return False
|
||||
if user.acted and not skill.extra_action:
|
||||
return False # a unit acts once per turn (extra-action skills still allowed)
|
||||
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
||||
return False
|
||||
if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0:
|
||||
@@ -891,6 +930,8 @@ class Battle:
|
||||
if skill.mechanic == "dissolve":
|
||||
skill.power += self._status_value(user, S_LYING_WAIT)
|
||||
power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0
|
||||
if skill.mechanic == "boss_landslide":
|
||||
power_bonus += 55 * user.status.get("boss_stacks", 0)
|
||||
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
||||
targets = self.foes_of(user)
|
||||
if skill.target == TARGET_TWO_ENEMIES:
|
||||
@@ -931,9 +972,12 @@ class Battle:
|
||||
else:
|
||||
self._apply_wilt_damage(target, int(dealt * 0.3))
|
||||
if skill.mechanic == "heal_random_ally":
|
||||
allies = self.allies_of(user)
|
||||
if allies:
|
||||
self._heal(user, random.choice(allies), 40)
|
||||
# Only pick from allies that are missing HP, so the heal always
|
||||
# lands on someone it can actually help (a random full-HP ally
|
||||
# would silently heal for 0).
|
||||
injured = [a for a in self.allies_of(user) if a.hp < a.max_hp]
|
||||
if injured:
|
||||
self._heal(user, random.choice(injured), skill.power)
|
||||
if skill.mechanic == "strip_buffs":
|
||||
self._strip_buffs(target)
|
||||
if skill.mechanic == "tether_lowest":
|
||||
@@ -942,6 +986,12 @@ class Battle:
|
||||
low = min(allies, key=lambda a: a.hp)
|
||||
low.status[S_TETHER] = (1, 1)
|
||||
self.log.append(f" {low.name} is tethered to {user.name}")
|
||||
if skill.mechanic == "boss_stack":
|
||||
user.status["boss_stacks"] = min(3, user.status.get("boss_stacks", 0) + 1)
|
||||
self.log.append(f" {user.name}'s power stacks ({user.status['boss_stacks']}/3).")
|
||||
if skill.mechanic == "boss_landslide":
|
||||
user.status["boss_stacks"] = 0
|
||||
self.log.append(f" {user.name} unleashes Landslide and releases all stacks!")
|
||||
|
||||
def _strip_buffs(self, unit: Unit) -> None:
|
||||
for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD):
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .plants import HP_MULT
|
||||
from .skills import Skill, TARGET_ENEMY
|
||||
from .types import TYPE_GARDEN
|
||||
from .unit import TEAM_ENEMY, Unit
|
||||
|
||||
# A boss appears every BOSS_FLOOR_STEP floors (20, 40, 60, ...). Bosses are
|
||||
# fought alone (1 vs the player's team) and have far bigger stats than normal
|
||||
# floor enemies.
|
||||
BOSS_FLOOR_STEP = 20
|
||||
|
||||
# Chance (0..1) a defeated boss drops one of its signature runes — much higher
|
||||
# than the ~5-15% rune drop chance anywhere else.
|
||||
BOSS_RUNE_DROP_CHANCE = 0.80
|
||||
|
||||
BOSS_SPECS = {
|
||||
"Stones": {
|
||||
"type": TYPE_GARDEN,
|
||||
# (attack, speed, defense, health) — medium attack, very low speed,
|
||||
# medium defense, high health (before HP_MULT).
|
||||
"stats": (110, 30, 90, 380),
|
||||
"sprite": "stones",
|
||||
"drop_runes": ("ruby", "amethyst"),
|
||||
"desc": "A slow, rocky guardian. Each Pebble Throw stacks its power "
|
||||
"(up to 3); at 3 stacks it unleashes Landslide for huge damage.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_boss(name: str) -> bool:
|
||||
return name in BOSS_SPECS
|
||||
|
||||
|
||||
def boss_for_floor(floor: int):
|
||||
"""The boss that appears on `floor`, or None if it's a normal floor."""
|
||||
if floor > 0 and floor % BOSS_FLOOR_STEP == 0:
|
||||
return "Stones"
|
||||
return None
|
||||
|
||||
|
||||
def boss_type(name: str) -> str:
|
||||
return BOSS_SPECS[name]["type"]
|
||||
|
||||
|
||||
def boss_stats(name: str) -> tuple:
|
||||
return BOSS_SPECS[name]["stats"]
|
||||
|
||||
|
||||
def boss_sprite(name: str) -> str:
|
||||
return BOSS_SPECS[name]["sprite"]
|
||||
|
||||
|
||||
def boss_drop_runes(name: str) -> tuple:
|
||||
return BOSS_SPECS[name]["drop_runes"]
|
||||
|
||||
|
||||
def boss_floor_level(floor: int) -> int:
|
||||
"""Display level for a boss at `floor` (scales with each boss appearance)."""
|
||||
return 1 + floor // BOSS_FLOOR_STEP
|
||||
|
||||
|
||||
def make_boss(name: str, floor: int) -> Unit:
|
||||
"""Build the boss unit for `floor`. Boss stats are already far above normal
|
||||
enemies and grow a little with each appearance so they stay threatening."""
|
||||
spec = BOSS_SPECS[name]
|
||||
atk, spd, dfn, hp = spec["stats"]
|
||||
lvl = boss_floor_level(floor)
|
||||
mult = 1 + 0.06 * (lvl - 1)
|
||||
atk = int(atk * mult)
|
||||
dfn = int(dfn * mult)
|
||||
hp = int(hp * mult)
|
||||
pebble = Skill("Pebble Throw", power=60, target=TARGET_ENEMY, cooldown=0,
|
||||
mechanic="boss_stack", priority=2,
|
||||
description="Medium damage; stacks the boss's power (up to 3).")
|
||||
landslide = Skill("Landslide", power=140, target=TARGET_ENEMY, cooldown=0,
|
||||
mechanic="boss_landslide", priority=1,
|
||||
description="Huge damage, releases all stacks. Needs 3 stacks.")
|
||||
u = Unit(name, TEAM_ENEMY, spec["type"], max_hp=int(hp * HP_MULT),
|
||||
attack=atk, speed=spd, defense=dfn, skills=[pebble, landslide],
|
||||
role="tank", crit=0.05)
|
||||
u.hp = u.max_hp
|
||||
return u
|
||||
+118
-22
@@ -49,6 +49,8 @@ def init_starter_pinwheel(ptype: str) -> bool:
|
||||
"fertilizer_accrued_at": 0.0,
|
||||
"auto_farmer": False,
|
||||
"auto_farmer_active": False,
|
||||
"auto_planter": False,
|
||||
"auto_planter_active": False,
|
||||
"coins": 0,
|
||||
"coins_accrued_at": 0.0,
|
||||
"water_used": 0,
|
||||
@@ -59,6 +61,7 @@ def init_starter_pinwheel(ptype: str) -> bool:
|
||||
"open_history": [],
|
||||
"lesser_opened": 0,
|
||||
"floor": 1,
|
||||
"bosses_fought": [],
|
||||
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
||||
}
|
||||
save(data)
|
||||
@@ -80,6 +83,8 @@ def _default() -> dict:
|
||||
"fertilizer_accrued_at": 0.0,
|
||||
"auto_farmer": False,
|
||||
"auto_farmer_active": False,
|
||||
"auto_planter": False,
|
||||
"auto_planter_active": False,
|
||||
"coins": 0,
|
||||
"coins_accrued_at": 0.0,
|
||||
"water_used": 0,
|
||||
@@ -91,6 +96,7 @@ def _default() -> dict:
|
||||
"lesser_opened": 0,
|
||||
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
||||
"floor": 1,
|
||||
"bosses_fought": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +134,9 @@ def load() -> dict:
|
||||
data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
|
||||
data.setdefault("floor", 1)
|
||||
data.setdefault("wins", 0)
|
||||
data.setdefault("auto_planter", False)
|
||||
data.setdefault("auto_planter_active", False)
|
||||
data.setdefault("bosses_fought", [])
|
||||
# normalize runes: older saves lack the per-rune stat roll, and use the old
|
||||
# color names (blue/red/white/yellow) instead of the current gems.
|
||||
for r in data.setdefault("runes", []):
|
||||
@@ -252,8 +261,9 @@ def grant_xp(names, xp: int, count_battle: bool = True, fertilizer: int = None)
|
||||
for n in names:
|
||||
prog = data["progress"][n]
|
||||
bonus = gap * CATCH_UP_PER_GAP if prog["level"] == low else 0
|
||||
# all XP gains are cut by 2/3 (only a third is awarded)
|
||||
gain = (xp + bonus) // 3 if (xp + bonus) > 0 else 0
|
||||
# all XP gains are cut to a sixth (half of the old third) so leveling
|
||||
# takes twice as long; water (a direct +1 level) is unaffected
|
||||
gain = (xp + bonus) // 6 if (xp + bonus) > 0 else 0
|
||||
prog["xp"] += gain
|
||||
while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]):
|
||||
prog["xp"] -= xp_to_next(prog["level"])
|
||||
@@ -338,6 +348,7 @@ def available_fertilizer() -> int:
|
||||
FERTILIZER_CAP = 100 # fertilizer stops accruing once this cap is reached
|
||||
FERTILIZER_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour
|
||||
AUTO_FARMER_COST = 100 # coins (WIP value)
|
||||
AUTO_PLANTER_COST = 200 # coins: plants retrieved seeds automatically
|
||||
|
||||
|
||||
def add_fertilizer(amount: int) -> None:
|
||||
@@ -404,6 +415,37 @@ def buy_auto_farmer() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def auto_planter_owned() -> bool:
|
||||
return bool(load().get("auto_planter", False))
|
||||
|
||||
|
||||
def auto_planter_active() -> bool:
|
||||
"""Whether the owned auto planter is currently toggled on."""
|
||||
return bool(load().get("auto_planter_active", False))
|
||||
|
||||
|
||||
def toggle_auto_planter() -> bool:
|
||||
"""Turn the owned auto planter on/off. Returns the new active state."""
|
||||
data = load()
|
||||
data["auto_planter_active"] = not data.get("auto_planter_active", False)
|
||||
save(data)
|
||||
return data["auto_planter_active"]
|
||||
|
||||
|
||||
def buy_auto_planter() -> str:
|
||||
"""Buy the auto planter for AUTO_PLANTER_COST coins. Returns '' or a reason."""
|
||||
data = load()
|
||||
if data.get("auto_planter", False):
|
||||
return "Already owned"
|
||||
if data.get("coins", 0) < AUTO_PLANTER_COST:
|
||||
return "Not enough coins"
|
||||
data["coins"] -= AUTO_PLANTER_COST
|
||||
data["auto_planter"] = True
|
||||
data["auto_planter_active"] = True
|
||||
save(data)
|
||||
return ""
|
||||
|
||||
|
||||
def buy_rune(tier: int):
|
||||
"""Buy a random rune of `tier` (1 = common types, 2 = brown) for coins.
|
||||
Returns the rune dict on success or a reason string."""
|
||||
@@ -502,6 +544,30 @@ def roll_pack_rune(data=None):
|
||||
return add_rune(data=data, save_now=False)
|
||||
|
||||
|
||||
# --- bosses ---------------------------------------------------------
|
||||
def bosses_fought() -> set:
|
||||
"""Boss names the player has defeated at least once."""
|
||||
return set(load().get("bosses_fought", []))
|
||||
|
||||
|
||||
def mark_boss_fought(name: str) -> None:
|
||||
data = load()
|
||||
fought = data.setdefault("bosses_fought", [])
|
||||
if name not in fought:
|
||||
fought.append(name)
|
||||
save(data)
|
||||
|
||||
|
||||
def boss_rune_drop(name: str):
|
||||
"""Roll a boss's signature rune drop. Much higher chance than normal rune
|
||||
drops. Returns a rune dict or None."""
|
||||
from . import bosses
|
||||
if random.random() >= bosses.BOSS_RUNE_DROP_CHANCE:
|
||||
return None
|
||||
color = random.choice(bosses.boss_drop_runes(name))
|
||||
return add_rune(color)
|
||||
|
||||
|
||||
def rune_slots(name, data=None) -> int:
|
||||
"""Permanent rune slots for `name` (1 + evolutions ever achieved)."""
|
||||
data = data or load()
|
||||
@@ -544,20 +610,20 @@ def free_runes(data=None) -> list:
|
||||
|
||||
def reroll_rune(rune_id: int, sacrificed_ids) -> str:
|
||||
"""Reroll `rune_id`'s stat to a higher value (at least 1% better, never
|
||||
above its max roll), sacrificing the given 10 runes. Equipped sacrifices are
|
||||
removed from their plants. Returns '' on success or a reason string."""
|
||||
above its max roll), sacrificing runes.RUNE_SACRIFICE_COST runes. Equipped
|
||||
sacrifices are removed from their plants. Returns '' on success or a reason."""
|
||||
data = load()
|
||||
rune_list = data.get("runes", [])
|
||||
target = next((r for r in rune_list if r["id"] == rune_id), None)
|
||||
if not target:
|
||||
return "No such rune"
|
||||
max_pct = runes.BROWN_PCT_MAX if target["color"] == "brown" else runes.PCT_MAX
|
||||
max_pct = runes.max_pct(target["color"])
|
||||
current = target.get("pct", 0)
|
||||
if current >= max_pct:
|
||||
return "Already max roll"
|
||||
sac_set = set(sacrificed_ids)
|
||||
if len(sac_set) != 10:
|
||||
return "Pick exactly 10 runes to sacrifice"
|
||||
if len(sac_set) != runes.RUNE_SACRIFICE_COST:
|
||||
return f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice"
|
||||
if rune_id in sac_set:
|
||||
return "Cannot sacrifice the rune being upgraded"
|
||||
by_id = {r["id"]: r for r in rune_list}
|
||||
@@ -629,20 +695,6 @@ def add_tower_packs(count: int) -> None:
|
||||
save(data)
|
||||
|
||||
|
||||
def convert_water_to_pack(pack_type: str) -> bool:
|
||||
"""Spend 5 water from the pool to gain one pack of the chosen type."""
|
||||
data = load()
|
||||
if pack_type not in REWARD_PACK_TYPES:
|
||||
return False
|
||||
if data.get("water", 0) < 5:
|
||||
return False
|
||||
data["water"] -= 5
|
||||
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
||||
packs[pack_type] = packs.get(pack_type, 0) + 1
|
||||
save(data)
|
||||
return True
|
||||
|
||||
|
||||
def evolve(name) -> bool:
|
||||
"""Evolve at the milestone level by consuming duplicates."""
|
||||
data = load()
|
||||
@@ -750,7 +802,7 @@ def add_lesser_pack() -> list:
|
||||
|
||||
|
||||
def add_pack(pack_type: str, count: int = 1) -> list:
|
||||
"""Grant `count` packs of a specific type. Returns the granted type list."""
|
||||
"""Add `count` seed packs of `pack_type`."""
|
||||
data = load()
|
||||
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
||||
packs[pack_type] = packs.get(pack_type, 0) + count
|
||||
@@ -758,6 +810,33 @@ def add_pack(pack_type: str, count: int = 1) -> list:
|
||||
return [pack_type] * count
|
||||
|
||||
|
||||
# Pack shop (seed packs page): coins buy packs. Tower packs can't be bought.
|
||||
PACK_COSTS = {
|
||||
"lesser": 50,
|
||||
"general": 100,
|
||||
"radiant": 100,
|
||||
"nocturnal": 100,
|
||||
"ice": 100,
|
||||
"garden": 100,
|
||||
"wildflower": 100,
|
||||
}
|
||||
|
||||
|
||||
def buy_pack(pack_type: str) -> str:
|
||||
"""Buy a pack of `pack_type` with coins. Returns '' on success or a reason."""
|
||||
cost = PACK_COSTS.get(pack_type)
|
||||
if cost is None:
|
||||
return "That pack can't be bought"
|
||||
data = load()
|
||||
if data.get("coins", 0) < cost:
|
||||
return "Not enough coins"
|
||||
data["coins"] -= cost
|
||||
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
||||
packs[pack_type] = packs.get(pack_type, 0) + 1
|
||||
save(data)
|
||||
return ""
|
||||
|
||||
|
||||
def battle_wins() -> int:
|
||||
"""Total number of battle wins the player has recorded."""
|
||||
return load().get("wins", 0)
|
||||
@@ -1189,6 +1268,23 @@ def auto_farm() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def auto_plant() -> str:
|
||||
"""If the player owns an auto planter that's toggled on, automatically plant
|
||||
a retrieved seed into the first empty plot (if any seeds are available).
|
||||
Returns '' on success or when idle, else a reason."""
|
||||
data = load()
|
||||
if not data.get("auto_planter", False):
|
||||
return "No auto planter"
|
||||
if not data.get("auto_planter_active", False):
|
||||
return "Auto planter off"
|
||||
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
|
||||
if not soil.get("seeds", []):
|
||||
return "" # nothing to plant
|
||||
if len(soil.get("plots", [])) >= unlocked_plots(data, soil):
|
||||
return "" # no empty plot
|
||||
return plant_seed(0)
|
||||
|
||||
|
||||
def harvest_plot(index: int) -> str:
|
||||
"""Harvest a grown plot, gaining a copy (duplicate) of the plant."""
|
||||
data = load()
|
||||
|
||||
+1
-1
@@ -281,7 +281,7 @@ POOL: Dict[str, dict] = {
|
||||
]),
|
||||
"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,
|
||||
_sk("Nectar", power=15, 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)"),
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ 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_ATTACK_DOWN, S_ATTACK_UP, S_CRIT_DOWN, 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,
|
||||
S_SPEED_UP, S_VULNERABLE, SHIELD_DURATION,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def _crit(pct):
|
||||
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)
|
||||
u.status.setdefault(S_SHIELD, []).append((int(u.max_hp * pct), SHIELD_DURATION))
|
||||
return apply
|
||||
|
||||
|
||||
|
||||
+36
-11
@@ -20,6 +20,12 @@ FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose",
|
||||
# Low-rarity tank plants used as enemy tanks on later floors.
|
||||
FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"]
|
||||
|
||||
# Floor enemies scale up faster and hit harder / are beefier than player units
|
||||
# of the same level (a flat boost on top of the leveled stats).
|
||||
FLOOR_ENEMY_LEVEL_STEP = 4 # +1 enemy level every 4 floors (was every 5)
|
||||
FLOOR_ENEMY_ATK_MULT = 1.20 # +20% attack (more damage)
|
||||
FLOOR_ENEMY_HP_MULT = 1.25 # +25% max health (tankier)
|
||||
|
||||
|
||||
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
|
||||
@@ -39,7 +45,11 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
|
||||
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."""
|
||||
never exceeds 4 enemies. Boss floors field the boss alone."""
|
||||
from . import bosses
|
||||
boss = bosses.boss_for_floor(floor)
|
||||
if boss:
|
||||
return [boss]
|
||||
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.
|
||||
@@ -58,24 +68,29 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
|
||||
|
||||
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
|
||||
always the type the player is strong against; other critters get a fixed type
|
||||
derived from the floor (no rerolling — the same floor always fields the same
|
||||
team, and duplicate types within a team are allowed); plants keep their own
|
||||
fixed type."""
|
||||
from . import bosses
|
||||
types = []
|
||||
for i, n in enumerate(names):
|
||||
if n in VARIABLE_TYPE_UNITS:
|
||||
if bosses.is_boss(n):
|
||||
types.append(bosses.boss_type(n))
|
||||
elif n in VARIABLE_TYPE_UNITS:
|
||||
if floor <= 3:
|
||||
types.append(TYPE_BEATS.get(player_type, "garden"))
|
||||
else:
|
||||
types.append(random.choice(ALL_TYPES))
|
||||
types.append(ALL_TYPES[(floor * 7 + i * i) % len(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
|
||||
"""Enemy level for a floor (ramps one level every FLOOR_ENEMY_LEVEL_STEP
|
||||
floors, steeper than the old every-5 curve)."""
|
||||
return 1 + (floor - 1) // FLOOR_ENEMY_LEVEL_STEP
|
||||
|
||||
|
||||
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
|
||||
@@ -88,11 +103,21 @@ def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
|
||||
|
||||
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)."""
|
||||
the actual battle always show the same enemies). Floor enemies also get a
|
||||
flat attack/health boost so they out-stat player units of the same level.
|
||||
Boss floors build the boss instead."""
|
||||
from . import bosses
|
||||
if names and bosses.is_boss(names[0]):
|
||||
return [bosses.make_boss(names[0], floor)]
|
||||
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)]
|
||||
units = [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
|
||||
for n, t in zip(names, types)]
|
||||
for u in units:
|
||||
u.attack = int(u.attack * FLOOR_ENEMY_ATK_MULT)
|
||||
u.max_hp = int(u.max_hp * FLOOR_ENEMY_HP_MULT)
|
||||
u.hp = u.max_hp
|
||||
return units
|
||||
|
||||
|
||||
def difficulty_pool(difficulty: int) -> List[str]:
|
||||
|
||||
+38
-13
@@ -3,18 +3,19 @@ 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_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown", "pearl")
|
||||
RUNE_NAMES = {
|
||||
"sapphire": "Sapphire Rune",
|
||||
"ruby": "Ruby Rune",
|
||||
"amethyst": "Amethyst Rune",
|
||||
"jade": "Jade Rune",
|
||||
"brown": "Brown Rune",
|
||||
"pearl": "Pearl Rune",
|
||||
}
|
||||
|
||||
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2.
|
||||
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown & pearl Tier 2.
|
||||
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
|
||||
TIER2_COLORS = ("brown",)
|
||||
TIER2_COLORS = ("brown", "pearl")
|
||||
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
|
||||
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
|
||||
|
||||
@@ -25,6 +26,7 @@ RUNE_ICON_FILES = {
|
||||
"jade": "jade_rune",
|
||||
"amethyst": "amethyst_rune",
|
||||
"brown": "rune_brown",
|
||||
"pearl": "pearl_rune",
|
||||
}
|
||||
|
||||
# Old color names kept in earlier saves, mapped to the current gem names.
|
||||
@@ -43,31 +45,39 @@ def canonical_color(color: str) -> str:
|
||||
|
||||
# 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.
|
||||
# turns part of the damage this plant deals into a Wilt. Pearl rolls a chunk of
|
||||
# critical hit chance.
|
||||
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
|
||||
"pearl": "crit", # +critical hit chance
|
||||
}
|
||||
PCT_MIN = 25
|
||||
PCT_MAX = 35
|
||||
BROWN_PCT_MIN = 5
|
||||
BROWN_PCT_MAX = 15
|
||||
PEARL_PCT_MIN = 20
|
||||
PEARL_PCT_MAX = 40
|
||||
# a plant can't stack more than this many runes of the same type
|
||||
MAX_RUNES_PER_TYPE = 2
|
||||
# runes sacrificed to reroll one rune to a higher stat
|
||||
RUNE_SACRIFICE_COST = 3
|
||||
# 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}
|
||||
# brown & pearl drop 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, "pearl": 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",
|
||||
"pearl": "+{pct}% critical chance",
|
||||
}
|
||||
RUNE_SUMMARIES = {
|
||||
"ruby": "Increases damage dealt",
|
||||
@@ -75,6 +85,7 @@ RUNE_SUMMARIES = {
|
||||
"jade": "Increases healing received",
|
||||
"amethyst": "Reduces damage taken",
|
||||
"brown": "Deals a portion of damage as Wilt",
|
||||
"pearl": "Increases critical hit chance",
|
||||
}
|
||||
|
||||
MAX_RUNE_SLOTS = 3
|
||||
@@ -91,12 +102,23 @@ def random_color() -> str:
|
||||
|
||||
|
||||
def roll_pct(color: str = None) -> int:
|
||||
"""Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35."""
|
||||
"""Roll a rune's stat percentage. Brown rolls 5-15, pearl 20-40, the rest 25-35."""
|
||||
if color == "brown":
|
||||
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
|
||||
if color == "pearl":
|
||||
return random.randint(PEARL_PCT_MIN, PEARL_PCT_MAX)
|
||||
return random.randint(PCT_MIN, PCT_MAX)
|
||||
|
||||
|
||||
def max_pct(color: str) -> int:
|
||||
"""The highest stat a rune of `color` can roll (brown/pearl have their own caps)."""
|
||||
if color == "brown":
|
||||
return BROWN_PCT_MAX
|
||||
if color == "pearl":
|
||||
return PEARL_PCT_MAX
|
||||
return PCT_MAX
|
||||
|
||||
|
||||
def name(color: str) -> str:
|
||||
return RUNE_NAMES.get(color, "Rune")
|
||||
|
||||
@@ -106,11 +128,11 @@ def tier(color: str) -> int:
|
||||
|
||||
|
||||
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
|
||||
"""How close a rune's roll is to its own maximum (0..1). Each color is
|
||||
compared to its own cap, so a maxed brown or pearl ranks equally with a
|
||||
maxed ruby even though 15 < 35."""
|
||||
cap = max_pct(rune.get("color"))
|
||||
return rune.get("pct", 0) / cap
|
||||
|
||||
|
||||
def effect_text(color: str, pct: int) -> str:
|
||||
@@ -124,6 +146,7 @@ _CARD_TEXTS = {
|
||||
"jade": "+{pct}% heal",
|
||||
"amethyst": "-{pct}% taken",
|
||||
"brown": "Wilt {pct}%",
|
||||
"pearl": "+{pct}% crit",
|
||||
}
|
||||
|
||||
|
||||
@@ -149,9 +172,11 @@ def summary(color: str) -> str:
|
||||
|
||||
|
||||
def roll_range(color: str) -> str:
|
||||
"""The stat range a rune of `color` rolls between (brown is lower)."""
|
||||
"""The stat range a rune of `color` rolls between (brown/pearl are lower)."""
|
||||
if color == "brown":
|
||||
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
|
||||
if color == "pearl":
|
||||
return f"{PEARL_PCT_MIN}–{PEARL_PCT_MAX}%"
|
||||
return f"{PCT_MIN}–{PCT_MAX}%"
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ S_WILT = "wilt"
|
||||
S_DOT = "dot"
|
||||
S_HOT = "hot"
|
||||
S_SHIELD = "shield"
|
||||
SHIELD_DURATION = 3 # turns a shield lasts before decaying (per application, doesn't refresh)
|
||||
S_ICICLES = "icicles"
|
||||
S_SNOWFLAKES = "snowflakes"
|
||||
S_ZINNIA_WEAKEN = "zinnia_weaken"
|
||||
|
||||
Reference in New Issue
Block a user