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 ""