1212 lines
40 KiB
Python
1212 lines
40 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,
|
|
"coins": 0,
|
|
"coins_accrued_at": 0.0,
|
|
"water_used": 0,
|
|
"favorites": [],
|
|
"presets": [],
|
|
"tower_best": 0,
|
|
"extracted": [],
|
|
"open_history": [],
|
|
"lesser_opened": 0,
|
|
"floor": 1,
|
|
"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,
|
|
"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,
|
|
}
|
|
|
|
|
|
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)
|
|
# 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 by 2/3 (only a third is awarded)
|
|
gain = (xp + bonus) // 3 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)
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
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 the given 10 runes. Equipped sacrifices are
|
|
removed from their plants. Returns '' on success or a reason string."""
|
|
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
|
|
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 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 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()
|
|
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:
|
|
"""Grant `count` packs of a specific type. Returns the granted type list."""
|
|
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
|
|
|
|
|
|
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 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 "" |