Files
Flora/engine/collection.py
T
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

1308 lines
43 KiB
Python

from __future__ import annotations
import json
import pathlib
import random
from .growth import MAX_LEVEL, EVOLVE_COST, EVOLVE_STAGE_AT_LEVEL, can_evolve, evolve_cost, xp_to_next
from .plants import POOL, VARIABLE_TYPE_UNITS, plant_rarity
from .types import ALL_TYPES
from . import runes
from .seeds import PACK_TYPES, REWARD_PACK_TYPES, open_pack as roll_pack
from .skills import skill_from_dict, skill_to_dict
_ROOT = pathlib.Path(__file__).resolve().parent.parent
_DATA_DIR = _ROOT / "data"
_DATA_FILE = _DATA_DIR / "collection.json"
DEFAULT_OWNED = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
CATCH_UP_PER_GAP = 20 # bonus XP the lowest-level plant earns per level of gap
WIN_FERTILIZER = 5 # fertilizer granted into the pool for a won battle
STARTER_TYPES = ALL_TYPES
# The very first lesser packs the player opens are scripted (in this order) so
# every save starts the critter trio off the same way; later packs are random.
LESSER_STARTER_SEQUENCE = ["Pinwheel", "Toadstool", "Pinwheel", "Butterfly"]
def save_exists() -> bool:
"""True once a starter pinwheel (or any save) has been created."""
return _DATA_FILE.exists()
def init_starter_pinwheel(ptype: str) -> bool:
"""Create a fresh save starting with a single Pinwheel of the chosen type."""
if ptype not in ALL_TYPES:
return False
data = {
"owned": ["Pinwheel"],
"discovered": ["Pinwheel"],
"progress": {"Pinwheel": {"level": 1, "stage": 1, "xp": 0, "duplicates": 0,
"watered_at": 0, "max_stage": 1, "equipped_runes": [],
"type": ptype}},
"packs": {t: 0 for t in PACK_TYPES},
"battles": 0,
"wins": 0,
"water": 0,
"fertilizer": 0,
"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,
"favorites": [],
"presets": [],
"tower_best": 0,
"extracted": [],
"open_history": [],
"lesser_opened": 0,
"floor": 1,
"bosses_fought": [],
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
}
save(data)
return True
def _default() -> dict:
return {
"owned": list(DEFAULT_OWNED),
"discovered": list(DEFAULT_OWNED),
"progress": {n: {"level": 1, "stage": 1, "xp": 0, "duplicates": 2, "watered_at": 0,
"max_stage": 1, "equipped_runes": []}
for n in DEFAULT_OWNED},
"packs": {t: (2 if t == "general" else 0) for t in PACK_TYPES},
"battles": 0,
"wins": 0,
"water": 0,
"fertilizer": 0,
"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,
"favorites": [],
"presets": [],
"tower_best": 0,
"extracted": [],
"open_history": [],
"lesser_opened": 0,
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
"floor": 1,
"bosses_fought": [],
}
def _fresh_progress() -> dict:
return {"level": 1, "stage": 1, "xp": 0, "duplicates": 0, "watered_at": 0,
"max_stage": 1, "equipped_runes": []}
def load() -> dict:
if _DATA_FILE.exists():
try:
data = json.loads(_DATA_FILE.read_text(encoding="utf-8"))
if isinstance(data, list): # legacy: discovered only
data = {"owned": list(DEFAULT_OWNED), "discovered": data, "progress": {}}
data.setdefault("owned", list(DEFAULT_OWNED))
data.setdefault("discovered", [])
data.setdefault("progress", {})
data.setdefault("packs", {t: 0 for t in PACK_TYPES})
data["packs"] = {t: data["packs"].get(t, 0) for t in PACK_TYPES}
data.setdefault("battles", 0)
data.setdefault("water", 0)
data.setdefault("fertilizer", 0)
data.setdefault("fertilizer_accrued_at", 0.0)
data.setdefault("auto_farmer", False)
data.setdefault("auto_farmer_active", False)
data.setdefault("coins", 0)
data.setdefault("coins_accrued_at", 0.0)
data.setdefault("water_used", 0)
data.setdefault("favorites", [])
data.setdefault("presets", [])
data.setdefault("tower_best", 0)
data.setdefault("extracted", [])
data.setdefault("open_history", [])
data.setdefault("lesser_opened", 0)
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", []):
r["color"] = runes.canonical_color(r.get("color", ""))
if r["color"] not in runes.RUNE_EFFECTS:
r["color"] = runes.random_color()
r["name"] = runes.name(r["color"])
r.setdefault("pct", runes.roll_pct(r["color"]))
return data
except (ValueError, OSError):
pass
return _default()
def save(data: dict) -> None:
_DATA_DIR.mkdir(exist_ok=True)
_DATA_FILE.write_text(json.dumps(data), encoding="utf-8")
def owned() -> set:
return set(load()["owned"])
MAX_FLOOR = 100
def floor_progress() -> int:
"""The player's current battle floor (1..100 for now)."""
return max(1, min(MAX_FLOOR, load().get("floor", 1)))
def advance_floor() -> int:
"""Advance the player's floor by one (capped at MAX_FLOOR)."""
data = load()
data["floor"] = min(MAX_FLOOR, data.get("floor", 1) + 1)
save(data)
return data["floor"]
def unit_type(name: str, data=None):
"""The elemental type of a unit. For variable-type critters (Pinwheel/
Butterfly/Toadstool) this is the stored per-unit type (None until the
player picks one after their first evolution); otherwise the spec's fixed type."""
if name in VARIABLE_TYPE_UNITS:
data = data or load()
stored = data["progress"].get(name, {}).get("type")
return stored if stored else None
return POOL[name]["type"]
def set_unit_type(name: str, ptype: str) -> str:
"""Set the elemental type of a variable-type critter once it has evolved at
least once (stage 2). Returns '' or a reason."""
if name not in VARIABLE_TYPE_UNITS:
return "Not a variable-type unit"
if ptype not in ALL_TYPES:
return "Unknown type"
data = load()
prog = data["progress"].get(name)
if not prog:
return "Not owned"
if prog.get("stage", 1) < 2:
return "Evolve once to choose a type"
prog["type"] = ptype
save(data)
return ""
def discovered() -> set:
return set(load()["discovered"])
def get_progress(name: str, data=None) -> dict:
data = data or load()
prog = data["progress"].get(name)
if not prog:
return dict(_fresh_progress())
return dict(prog)
def _put_progress(data, name, prog) -> None:
data.setdefault("progress", {})[name] = prog
def mark_found(names) -> set:
data = load()
data["discovered"] = sorted(set(data["discovered"]) | set(names))
save(data)
return set(data["discovered"])
def mark_owned(names) -> set:
data = load()
data["owned"] = sorted(set(data["owned"]) | set(names))
data["discovered"] = sorted(set(data["discovered"]) | set(names))
for n in names:
data["progress"].setdefault(n, _fresh_progress())
save(data)
return set(data["owned"])
def grant_xp(names, xp: int, count_battle: bool = True, fertilizer: int = None) -> dict:
"""Add battle XP; auto-levels (may level several times). The lowest-level
plant on the team earns bonus XP based on the level gap (catch-up).
`count_battle` controls whether this battle counts toward the fertilizer
allowance (only wins should); `fertilizer` overrides the win amount."""
data = load()
if count_battle:
data["battles"] = data.get("battles", 0) + 1
amount = WIN_FERTILIZER if fertilizer is None else fertilizer
data["fertilizer"] = min(FERTILIZER_CAP, data.get("fertilizer", 0) + amount)
levels = {}
for n in names:
prog = data["progress"].get(n)
if not prog:
prog = _fresh_progress()
data["progress"][n] = prog
levels[n] = prog["level"]
low = min(levels.values()) if levels else 1
high = max(levels.values()) if levels else 1
gap = high - low
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 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"])
prog["level"] += 1
save(data)
return {n: dict(data["progress"][n]) for n in names if n in data["progress"]}
def can_water(name, data=None) -> str:
"""Why this plant can't be watered, or '' if it can (water pool available)."""
data = data or load()
prog = data["progress"].get(name)
if not prog:
return "Not owned"
if prog["level"] >= MAX_LEVEL:
return "Max level"
if data.get("water", 0) > 0:
return ""
return "No water"
def water(name) -> bool:
"""Spend 1 water from the shared pool to level a plant."""
data = load()
prog = data["progress"].get(name)
if not prog:
return False
if prog["level"] >= MAX_LEVEL:
return False
if data.get("water", 0) <= 0:
return False
data["water"] -= 1
prog["level"] += 1
save(data)
return True
def available_water() -> int:
"""How much water the player has in their pool."""
return load().get("water", 0)
def smart_water_candidate(name: str, data=None) -> bool:
"""True if the plant already has the duplicates for its next evolution and
is only missing the level milestone — watering it up is a smart move."""
data = data or load()
prog = data["progress"].get(name)
if not prog:
return False
if prog["level"] >= MAX_LEVEL:
return False
target = prog["stage"] + 1
if target not in EVOLVE_STAGE_AT_LEVEL:
return False
if prog["level"] >= EVOLVE_STAGE_AT_LEVEL[target]:
return False
return prog["duplicates"] >= EVOLVE_COST[target]
def redeem_tower_pack() -> bool:
"""Redeem one Tower pack for 10 water."""
data = load()
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
if packs.get("tower", 0) <= 0:
return False
packs["tower"] -= 1
data["water"] = data.get("water", 0) + 10
save(data)
return True
def add_water(amount: int) -> None:
data = load()
data["water"] = data.get("water", 0) + amount
save(data)
def available_fertilizer() -> int:
return load().get("fertilizer", 0)
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:
data = load()
data["fertilizer"] = min(FERTILIZER_CAP, data.get("fertilizer", 0) + amount)
save(data)
def accrue_fertilizer() -> int:
"""Grant the fertilizer earned since the last check (1 per hour, test build),
never exceeding FERTILIZER_CAP. Partial hours carry over. Returns how many
fertilizer were granted."""
import time as _time
data = load()
now = _time.time()
last = data.get("fertilizer_accrued_at", 0.0)
if last <= 0:
data["fertilizer_accrued_at"] = now
save(data)
return 0
gained = int((now - last) // 3600) * FERTILIZER_PER_HOUR
if gained > 0:
current = data.get("fertilizer", 0)
new_total = min(FERTILIZER_CAP, current + gained)
granted = new_total - current
data["fertilizer"] = new_total
if granted > 0:
data["fertilizer_accrued_at"] = last + granted * 3600
else:
data["fertilizer_accrued_at"] = now
save(data)
return granted
return 0
def auto_farmer_owned() -> bool:
return bool(load().get("auto_farmer", False))
def auto_farmer_active() -> bool:
"""Whether the owned auto farmer is currently toggled on."""
return bool(load().get("auto_farmer_active", False))
def toggle_auto_farmer() -> bool:
"""Turn the owned auto farmer on/off. Returns the new active state."""
data = load()
data["auto_farmer_active"] = not data.get("auto_farmer_active", False)
save(data)
return data["auto_farmer_active"]
def buy_auto_farmer() -> str:
"""Buy the auto farmer for AUTO_FARMER_COST coins. Returns '' or a reason."""
data = load()
if data.get("auto_farmer", False):
return "Already owned"
if data.get("coins", 0) < AUTO_FARMER_COST:
return "Not enough coins"
data["coins"] -= AUTO_FARMER_COST
data["auto_farmer"] = True
data["auto_farmer_active"] = True
save(data)
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."""
cost = runes.T1_RUNE_COST if tier == 1 else runes.T2_RUNE_COST
data = load()
if data.get("coins", 0) < cost:
return "Not enough coins"
data["coins"] -= cost
color = runes.random_color()
while runes.tier(color) != tier:
color = runes.random_color()
rune = add_rune(color=color, data=data, save_now=False)
save(data)
return rune
# --- coins --------------------------------------------------------
COINS_PER_MINUTE = 1 # test build: earn 1 coin every real minute
COIN_CAP = 200 # coins stop accruing once this cap is reached
def available_coins() -> int:
return load().get("coins", 0)
def add_coins(amount: int) -> None:
data = load()
data["coins"] = min(COIN_CAP, data.get("coins", 0) + amount)
save(data)
def accrue_coins() -> int:
"""Grant the coins earned since the last check (1 per minute, test build),
never exceeding COIN_CAP. Partial minutes carry over. Returns how many
coins were granted."""
import time as _time
data = load()
now = _time.time()
last = data.get("coins_accrued_at", 0.0)
if last <= 0:
data["coins_accrued_at"] = now
save(data)
return 0
gained = int((now - last) // 60) * COINS_PER_MINUTE
if gained > 0:
current = data.get("coins", 0)
new_total = min(COIN_CAP, current + gained)
granted = new_total - current
data["coins"] = new_total
if granted > 0:
data["coins_accrued_at"] = last + granted * 60
else:
# at the cap: forfeit the elapsed time so it isn't banked
data["coins_accrued_at"] = now
save(data)
return granted
return 0
# --- runes ---------------------------------------------------------
def rune_inventory() -> list:
"""Every rune the player owns (not yet equipped)."""
return list(load().get("runes", []))
def add_rune(color=None, data=None, save_now=True) -> dict:
"""Add a rune to the inventory; returns the rune dict. When `data` is given
(already loaded save), it is modified in place; `save_now` controls whether
it is persisted (callers inside a larger save flow pass save_now=False)."""
if data is None:
data = load()
color = runes.canonical_color(color or runes.random_color())
rune_list = data.setdefault("runes", [])
rune_id = max([r["id"] for r in rune_list], default=0) + 1
rune = {"id": rune_id, "color": color, "name": runes.name(color),
"pct": runes.roll_pct(color)}
rune_list.append(rune)
if save_now:
save(data)
return rune
def roll_battle_rune():
"""Rare rune drop from a normal floor battle win. Returns a rune dict or
None if it didn't drop this time."""
if random.random() >= runes.BATTLE_DROP_CHANCE:
return None
return add_rune()
def roll_pack_rune(data=None):
"""Rare additional rune drop when opening a seed pack. Returns a rune dict
or None. Modifies `data` in place without saving (the caller saves)."""
if random.random() >= runes.PACK_DROP_CHANCE:
return 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()
prog = data["progress"].get(name, {})
return runes.slots_for_max_stage(prog.get("max_stage", 1))
def equipped_runes(name, data=None) -> list:
"""The equipped rune ids for `name`, one entry per unlocked slot (None = empty)."""
data = data or load()
prog = data["progress"].get(name, {})
return list(prog.get("equipped_runes", []))
def equipped_rune_colors(name, data=None) -> list:
"""Colors of the runes equipped on `name`, in slot order."""
data = data or load()
by_id = {r["id"]: r for r in data.get("runes", [])}
return [by_id[e]["color"] for e in equipped_runes(name, data) if e in by_id]
def rune_location(rune_id: int, data=None) -> str:
"""The plant name that currently has `rune_id` equipped, or None. A rune can
only be equipped on one plant at a time."""
data = data or load()
for name, prog in data["progress"].items():
if rune_id in prog.get("equipped_runes", []):
return name
return None
def free_runes(data=None) -> list:
"""Runes the player owns that aren't equipped on any plant."""
data = data or load()
equipped = set()
for prog in data["progress"].values():
equipped.update(prog.get("equipped_runes", []))
return [r for r in data.get("runes", []) if r["id"] not in equipped]
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 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.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) != 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}
if any(i not in by_id for i in sac_set):
return "No such rune"
target["pct"] = random.randint(current + 1, max_pct)
data["runes"] = [r for r in rune_list if r["id"] not in sac_set]
# unequip the sacrificed runes from any plant
for prog in data["progress"].values():
equipped = prog.get("equipped_runes", [])
if any(r in sac_set for r in equipped):
prog["equipped_runes"] = [None if r in sac_set else r for r in equipped]
save(data)
return ""
def equip_rune(name: str, slot: int, rune_id: int) -> str:
"""Equip rune `rune_id` into `slot` (0-based) on `name`. Returns '' on
success or a reason string. A rune can only occupy one slot anywhere, and a
plant can equip at most runes.MAX_RUNES_PER_TYPE runes of the same type."""
data = load()
prog = data["progress"].get(name)
if not prog:
return "Not owned"
slots = runes.slots_for_max_stage(prog.get("max_stage", 1))
if not (0 <= slot < slots):
return "Slot locked"
rune = next((r for r in data.get("runes", []) if r["id"] == rune_id), None)
if not rune:
return "No such rune"
# free the rune from every plant's slots (global uniqueness)
for other_name, other_prog in data["progress"].items():
equipped = other_prog.get("equipped_runes", [])
if rune_id in equipped:
other_prog["equipped_runes"] = [None if r == rune_id else r for r in equipped]
equipped = list(prog.get("equipped_runes", []))
while len(equipped) < slots:
equipped.append(None)
by_id = {r["id"]: r for r in data.get("runes", [])}
same = sum(1 for e in equipped if e is not None and e in by_id
and by_id[e]["color"] == rune["color"])
if same >= runes.MAX_RUNES_PER_TYPE:
return f"Max {runes.MAX_RUNES_PER_TYPE} {rune['color']} runes per plant"
equipped[slot] = rune_id
prog["equipped_runes"] = equipped
save(data)
return ""
def unequip_rune(name: str, slot: int) -> bool:
"""Clear the rune equipped in `slot` on `name`."""
data = load()
prog = data["progress"].get(name)
if not prog:
return False
equipped = list(prog.get("equipped_runes", []))
if not (0 <= slot < len(equipped)):
return False
equipped[slot] = None
prog["equipped_runes"] = equipped
save(data)
return True
def add_tower_packs(count: int) -> None:
data = load()
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
packs["tower"] = packs.get("tower", 0) + count
save(data)
def evolve(name) -> bool:
"""Evolve at the milestone level by consuming duplicates."""
data = load()
prog = data["progress"].get(name)
if not prog:
return False
if not can_evolve(prog["level"], prog["stage"], prog["duplicates"]):
return False
prog["stage"] += 1
prog["max_stage"] = max(prog.get("max_stage", 1), prog["stage"])
prog["duplicates"] -= evolve_cost(prog["stage"] - 1)
save(data)
return True
# --- favorites ------------------------------------------------------
def favorites() -> set:
return set(load().get("favorites", []))
def is_favorite(name) -> bool:
return name in load().get("favorites", [])
def toggle_favorite(name) -> bool:
"""Toggle a plant's favorite state; returns its new state."""
data = load()
favs = data.setdefault("favorites", [])
if name in favs:
favs.remove(name)
state = False
else:
favs.append(name)
state = True
save(data)
return state
# --- team presets ----------------------------------------------------
def get_presets() -> list:
presets = load().get("presets", [])
while len(presets) < 4:
presets.append(None)
return presets
def save_preset(slot: int, names) -> None:
data = load()
presets = list(data.get("presets", []))
while len(presets) < 4:
presets.append(None)
presets[slot] = list(names)
data["presets"] = presets
save(data)
def clear_preset(slot: int) -> None:
data = load()
presets = list(data.get("presets", []))
while len(presets) < 4:
presets.append(None)
presets[slot] = None
data["presets"] = presets
save(data)
def get_tower_best() -> int:
return load().get("tower_best", 0)
def set_tower_best(stage: int) -> None:
data = load()
if stage > data.get("tower_best", 0):
data["tower_best"] = stage
save(data)
# --- seed packs ----------------------------------------------------
def pack_counts() -> dict:
data = load()
return dict(data.get("packs", {}))
def add_random_packs(count: int = 3) -> list:
"""Grant `count` random seed packs (the reward for winning a battle)."""
import random as _random
data = load()
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
granted = []
for _ in range(count):
pt = _random.choice(REWARD_PACK_TYPES)
packs[pt] = packs.get(pt, 0) + 1
granted.append(pt)
save(data)
return granted
def add_lesser_pack() -> list:
"""Grant one lesser seed pack."""
data = load()
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
packs["lesser"] = packs.get("lesser", 0) + 1
save(data)
return ["lesser"]
def add_pack(pack_type: str, count: int = 1) -> 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
save(data)
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)
def record_win() -> int:
"""Increment and return the battle-win counter."""
data = load()
data["wins"] = data.get("wins", 0) + 1
save(data)
return data["wins"]
def grant_fifth_win_bonus() -> list:
"""Grant the every-5th-win bonus: one general or type-boosted pack."""
import random as _random
pt = _random.choice(["general"] + list(ALL_TYPES))
return add_pack(pt)
def _lesser_pack_plant(data: dict) -> str:
"""Plant for a lesser pack. The first few lesser packs a save opens follow a
fixed starter sequence so every run starts with the same critters; after
that the pack rolls normally."""
opened = data.get("lesser_opened", 0)
if opened < len(LESSER_STARTER_SEQUENCE):
plant = LESSER_STARTER_SEQUENCE[opened]
else:
plant = roll_pack("lesser")
data["lesser_opened"] = opened + 1
return plant
def open_pack(pack_type: str):
"""Open one pack; returns (plant_name, is_new, bonus) or None if none left.
`bonus` is a rune dict on a rare additional drop, otherwise None. The result
is recorded in the last-10 open history."""
data = load()
packs = data.get("packs", {})
if packs.get(pack_type, 0) <= 0:
return None
packs[pack_type] -= 1
plant = _lesser_pack_plant(data) if pack_type == "lesser" else roll_pack(pack_type)
if plant in data["owned"]:
prog = data["progress"].setdefault(plant, _fresh_progress())
prog["duplicates"] = prog.get("duplicates", 0) + 1
is_new = False
else:
data["owned"].append(plant)
data["owned"].sort()
data["discovered"].append(plant)
data["discovered"] = sorted(set(data["discovered"]))
prog = data["progress"].setdefault(plant, _fresh_progress())
is_new = True
bonus = roll_pack_rune(data)
history = data.setdefault("open_history", [])
history.append({
"plant": plant,
"is_new": is_new,
"rune": bonus["name"] if bonus else None,
})
del history[:-10]
save(data)
return plant, is_new, bonus
def open_history() -> list:
"""The last 10 opened packs (each a {'plant','is_new','rune'} record)."""
return list(load().get("open_history", []))
def open_packs(pack_type: str, count: int) -> list:
"""Open up to `count` packs of one type; returns a list of
(plant, is_new, bonus) tuples for each pack actually opened."""
results = []
for _ in range(count):
r = open_pack(pack_type)
if not r:
break
results.append(r)
return results
# --- ability extraction / injection -------------------------------------
EXTRACT_GATE = 10 # tower stages cleared (best) to unlock extraction
FULL_LEVEL = MAX_LEVEL # 20
FULL_STAGE = 3 # 2 evolutions
# abilities whose effects are tied to a specific plant identity/passive
NON_EXTRACTABLE_MECHANICS = {
"extend_timers", "lying_wait", "sow", "shatter_icicles",
"snowflakes", "perma_hp_def", "vigil",
}
# name-based blocklist for skills whose effect depends on the plant's passive
NON_EXTRACTABLE_SKILL_NAMES = {"Madness"} # Hellebore: relies on the Toxin passive
def extracted_skills() -> list:
"""Available extracted abilities (each is single-use)."""
return list(load().get("extracted", []))
def injection_of(name: str):
"""The injected-ability record for a plant, or None."""
prog = load()["progress"].get(name)
if not prog:
return None
return prog.get("injected") or None
def tower_max_stage() -> int:
"""Highest tower stage the player currently qualifies for, gated by account
milestones (any owned plant, whether or not it's brought along):
stages 1-5 free, 6-10 need an evolved plant, 11-15 need a max-level plant,
16-20 need a max-level plant plus any injected plant."""
data = load()
owned = data.get("owned", [])
progress = data.get("progress", {})
has_evolved = any(progress.get(n, {}).get("stage", 1) >= 2 for n in owned)
has_max = any(progress.get(n, {}).get("level", 1) >= MAX_LEVEL for n in owned)
has_injected = any(injection_of(n) for n in owned)
if has_max and has_injected:
return 20
if has_max:
return 15
if has_evolved:
return 10
return 5
def extraction_blocked_reason(name: str, index: int, sk) -> str:
"""Why this skill of `name` can't be extracted, or '' if it can."""
if index == 0:
return "basic ability"
if sk.mechanic in NON_EXTRACTABLE_MECHANICS or sk.name in NON_EXTRACTABLE_SKILL_NAMES:
return "tied to the plant's passive"
if sk.requires_form:
return "depends on the plant's form"
return ""
def extractable_skills(name: str) -> list:
"""[(index, Skill)] of a plant's abilities that can be extracted."""
spec = POOL[name]
out = []
for i, sk in enumerate(spec["skills"]):
if extraction_blocked_reason(name, i, sk) == "":
out.append((i, sk))
return out
def can_extract(name: str) -> str:
"""Why extraction is blocked for `name`, or '' if it can be extracted."""
prog = load()["progress"].get(name)
if not prog:
return "Not owned"
if prog["level"] < FULL_LEVEL:
return f"Needs Lv {FULL_LEVEL}"
if prog["stage"] < FULL_STAGE:
return "Needs 2 evolutions"
if not extractable_skills(name):
return "No extractable abilities"
return ""
def extract_skill(name: str, index: int) -> bool:
"""Extract ability `index` from a fully leveled/evolved plant. The plant
resets to Lv 1 / Stage 1; the ability is stored for one injection."""
data = load()
prog = data["progress"].get(name)
if not prog or prog["level"] < FULL_LEVEL or prog["stage"] < FULL_STAGE:
return False
spec = POOL[name]
if not (0 <= index < len(spec["skills"])):
return False
sk = spec["skills"][index]
if index == 0 or sk.mechanic in NON_EXTRACTABLE_MECHANICS \
or sk.name in NON_EXTRACTABLE_SKILL_NAMES or sk.requires_form:
return False
data.setdefault("extracted", []).append({
"source": name,
"role": spec["role"],
"skill_name": sk.name,
"skill": skill_to_dict(sk),
})
prog["level"] = 1
prog["stage"] = 1
prog["xp"] = 0
prog["duplicates"] = 0
prog.pop("injected", None)
save(data)
return True
def can_inject(target: str, record: dict) -> str:
"""Why injecting `record` into `target` is blocked, or '' if allowed."""
if not record:
return "No extracted ability"
prog = load()["progress"].get(target)
if not prog:
return "Not owned"
if prog.get("injected"):
return "Already has an injected ability"
if POOL[target]["role"] != record["role"]:
return "Wrong role"
return ""
def injection_slot_reason(target: str, record: dict, slot: int) -> str:
"""Why injecting `record` into `target` at `slot` is blocked, or '' if ok."""
if slot >= len(POOL[target]["skills"]):
return "No skill in that slot"
if POOL[target]["skills"][slot].name == record["skill"]["name"]:
return "same ability"
return ""
def inject_skill(extracted_index: int, target: str, slot: int) -> bool:
"""Inject extracted[extracted_index] into `target`, replacing its slot
(1 or 2 = 2nd/3rd skill). Consumes the extracted ability. Never replaces
a skill with the same ability."""
data = load()
extracted = data.get("extracted", [])
if not (0 <= extracted_index < len(extracted)):
return False
rec = extracted[extracted_index]
prog = data["progress"].get(target)
if not prog or prog.get("injected"):
return False
if POOL[target]["role"] != rec["role"]:
return False
if slot not in (1, 2) or slot >= len(POOL[target]["skills"]):
return False
if injection_slot_reason(target, rec, slot):
return False
prog["injected"] = {"index": slot, "source": rec["source"], "skill": rec["skill"]}
del extracted[extracted_index]
save(data)
return True
def remove_injection(name: str) -> bool:
"""Destroy a plant's injected ability (reverts it to base skills)."""
data = load()
prog = data["progress"].get(name)
if not prog or not prog.get("injected"):
return False
prog.pop("injected", None)
save(data)
return True
# --- Soil / seed collection -----------------------------------------
import time as _time
# Retrieve a seed no more often than this (test build: 1 minute; real: 24h).
SEED_CD_SECONDS = 60
SEED_BAG_MAX = 8
SOIL_PLOTS_MAX = 4
SOIL_GATE_PLANTS = 8 # distinct plants owned to unlock the Soil feature
# (coins, fertilizer) needed to unlock each plot slot beyond the first.
PLOT_UNLOCK_COSTS = {
2: (10, 5),
3: (50, 20),
4: (100, 50),
}
def soil_unlocked() -> bool:
"""Whether the Soil feature is available (own at least SOIL_GATE_PLANTS)."""
return len(owned()) >= SOIL_GATE_PLANTS
# growth stages per rarity (higher rarity = more stages)
GROWTH_STAGES = {"common": 2, "rare": 3, "super_rare": 4, "super_super_rare": 5, "ultra_rare": 6}
# seconds to fill a single stage
STAGE_SECONDS = 30
def _plot_view(p: dict, now: float) -> dict:
total = GROWTH_STAGES.get(plant_rarity(p["plant"]), 2)
stage = p.get("stage", 0)
started = p.get("stage_started_at", p.get("planted_at", now))
elapsed = now - started
stage_progress = min(1.0, elapsed / STAGE_SECONDS)
full = elapsed >= STAGE_SECONDS
grown = full and stage >= total - 1
return {
"plant": p["plant"],
"stage": stage,
"total_stages": total,
"stage_progress": stage_progress,
"grown": grown,
"stalled": full and not grown, # stage full, needs water to advance
"water": p.get("water", 0),
"seconds_left": max(0, STAGE_SECONDS - elapsed),
}
def soil_state() -> dict:
"""Snapshot of the soil: seed cooldown, retrieved seeds, and planted plots
with their current stage / growth progress."""
data = load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
now = _time.time()
cd_left = max(0.0, soil.get("seed_cd_until", 0.0) - now)
seeds = list(soil.get("seeds", []))
plots = [_plot_view(p, now) for p in soil.get("plots", [])]
unlocked = unlocked_plots(data, soil)
return {
"cd_left": cd_left,
"seeds": seeds,
"plots": plots,
"plots_unlocked": unlocked,
"plots_max": SOIL_PLOTS_MAX,
"next_plot_cost": plot_unlock_cost(unlocked + 1) if unlocked < SOIL_PLOTS_MAX else (0, 0),
}
def retrieve_seed(name: str) -> str:
"""Retrieve a seed of `name`. Returns "" on success or a reason string.
Critters (pinwheels/butterflies/toadstools) don't drop seeds."""
if name in VARIABLE_TYPE_UNITS:
return "Critters don't drop seeds"
data = load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
if name not in data["owned"]:
return "Not owned"
if _time.time() < soil.get("seed_cd_until", 0.0):
return "On cooldown"
if len(soil.get("seeds", [])) >= SEED_BAG_MAX:
return "Seed bag full"
soil.setdefault("seeds", []).append({"plant": name})
soil["seed_cd_until"] = _time.time() + SEED_CD_SECONDS
save(data)
return ""
def plant_seed(index: int) -> str:
"""Plant the retrieved seed at `index` in the soil. Returns "" or a reason."""
data = load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
seeds = soil.get("seeds", [])
if not (0 <= index < len(seeds)):
return "No such seed"
if len(soil.get("plots", [])) >= unlocked_plots(data, soil):
return "No free plots"
seed = seeds.pop(index)
soil.setdefault("plots", []).append({
"plant": seed["plant"], "stage_started_at": _time.time(), "stage": 0, "water": 0})
save(data)
return ""
def unlocked_plots(data: dict = None, soil: dict = None) -> int:
"""How many plot slots are currently unlocked (starts at 1, max 5)."""
if soil is None:
data = data or load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
return max(1, min(SOIL_PLOTS_MAX, soil.get("plots_unlocked", 1)))
def plot_unlock_cost(next_slot: int):
"""(coins, fertilizer) needed to unlock the Nth plot slot (slot 1 is free)."""
return PLOT_UNLOCK_COSTS.get(next_slot, (0, 0))
def unlock_plot() -> str:
"""Spend coins + fertilizer to unlock the next plot slot. Returns "" or a reason."""
data = load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
unlocked = unlocked_plots(data, soil)
if unlocked >= SOIL_PLOTS_MAX:
return "Max plots"
coins, fert = plot_unlock_cost(unlocked + 1)
if data.get("coins", 0) < coins:
return "Not enough coins"
if data.get("fertilizer", 0) < fert:
return "Not enough fertilizer"
data["coins"] -= coins
data["fertilizer"] -= fert
soil["plots_unlocked"] = unlocked + 1
save(data)
return ""
def water_plot(index: int) -> str:
"""Fertilize a stalled plot, spending 1 fertilizer to advance to the next
growth stage. The current stage must be full before it can be fertilized."""
data = load()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
plots = soil.get("plots", [])
if not (0 <= index < len(plots)):
return "No such plot"
if data.get("fertilizer", 0) <= 0:
return "No fertilizer"
now = _time.time()
view = _plot_view(plots[index], now)
if view["grown"]:
return "Already grown"
if not view["stalled"]:
return "Not ready yet"
p = plots[index]
p["stage"] = view["stage"] + 1
p["stage_started_at"] = now
p["water"] = p.get("water", 0) + 1
data["fertilizer"] -= 1
save(data)
return ""
def auto_farm() -> str:
"""If the player owns an auto farmer that's toggled on and a plot is
stalled, spend 1 fertilizer to advance it. Returns '' on success or when
idle, else a reason. The auto farmer never plants seeds or harvests."""
data = load()
if not data.get("auto_farmer", False):
return "No auto farmer"
if not data.get("auto_farmer_active", False):
return "Auto farmer off"
if data.get("fertilizer", 0) <= 0:
return "No fertilizer"
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
plots = soil.get("plots", [])
now = _time.time()
for i, p in enumerate(plots):
view = _plot_view(p, now)
if view["stalled"] and not view["grown"]:
return water_plot(i)
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()
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
plots = soil.get("plots", [])
if not (0 <= index < len(plots)):
return "No such plot"
if not _plot_view(plots[index], _time.time())["grown"]:
return "Not grown yet"
plant = plots[index]["plant"]
if plant in data["owned"]:
prog = data["progress"].setdefault(plant, _fresh_progress())
prog["duplicates"] = prog.get("duplicates", 0) + 1
else:
data["owned"].append(plant)
data["owned"].sort()
data["discovered"] = sorted(set(data["discovered"]) | {plant})
data["progress"].setdefault(plant, _fresh_progress())
plots.pop(index)
save(data)
return ""