# retoor from __future__ import annotations import json from datetime import datetime, timedelta, timezone from devplacepy.database import get_table from devplacepy.utils import generate_uid from . import economy class GameError(Exception): pass def _now() -> datetime: return datetime.now(timezone.utc) def _iso(value: datetime) -> str: return value.isoformat() def _parse(value: str) -> datetime | None: if not value: return None try: return datetime.fromisoformat(value) except ValueError: return None def _today() -> str: return _now().date().isoformat() def _parse_date(value: str): parsed = _parse(value) return parsed.date() if parsed else None def _farms(): return get_table("game_farms") def _plots(): return get_table("game_plots") def _quests(): return get_table("game_quests") def _lvl(farm: dict, key: str) -> int: return int(farm.get(key) or 0) PERK_COLUMN = {perk.key: f"perk_{perk.key}" for perk in economy.PERKS} def get_farm(user_uid: str) -> dict | None: return _farms().find_one(user_uid=user_uid) def ensure_farm(user_uid: str) -> dict: farm = get_farm(user_uid) if farm: return farm now = _iso(_now()) uid = generate_uid() _farms().insert( { "uid": uid, "user_uid": user_uid, "coins": economy.STARTING_COINS, "xp": 0, "level": 1, "ci_tier": 1, "plot_count": economy.STARTING_PLOTS, "total_harvests": 0, "created_at": now, "updated_at": now, } ) for slot_index in range(economy.STARTING_PLOTS): _create_plot(uid, user_uid, slot_index, now) return get_farm(user_uid) def _create_plot(farm_uid: str, user_uid: str, slot_index: int, now: str) -> None: _plots().insert( { "uid": generate_uid(), "farm_uid": farm_uid, "user_uid": user_uid, "slot_index": slot_index, "crop_key": "", "planted_at": "", "ready_at": "", "watered_by": "[]", "created_at": now, "updated_at": now, } ) def get_plots(farm_uid: str) -> list[dict]: rows = list(_plots().find(farm_uid=farm_uid)) rows.sort(key=lambda row: row.get("slot_index", 0)) return rows def _plot_at(farm_uid: str, slot_index: int) -> dict | None: return _plots().find_one(farm_uid=farm_uid, slot_index=slot_index) def _watered_by(plot: dict) -> list[str]: try: value = json.loads(plot.get("watered_by") or "[]") return value if isinstance(value, list) else [] except (ValueError, TypeError): return [] def _plot_state(plot: dict, now: datetime) -> str: if not plot.get("crop_key"): return "empty" ready_at = _parse(plot.get("ready_at", "")) if ready_at and now >= ready_at: return "ready" return "growing" def serialize_plot( plot: dict, *, viewer_uid: str, owner_uid: str, now: datetime, yield_level: int = 0, prestige: int = 0, ) -> dict: state = _plot_state(plot, now) crop = economy.crop_for(plot.get("crop_key", "")) ready_at = _parse(plot.get("ready_at", "")) remaining = 0 if state == "growing" and ready_at: remaining = max(0, int((ready_at - now).total_seconds())) watered = _watered_by(plot) is_owner = viewer_uid == owner_uid can_water = ( state == "growing" and not is_owner and bool(viewer_uid) and viewer_uid not in watered and len(watered) < economy.MAX_WATERS_PER_PLOT ) can_steal = ( state == "ready" and not is_owner and bool(viewer_uid) and bool(crop) and ready_at is not None and now >= ready_at + timedelta(seconds=economy.STEAL_GRACE_SECONDS) ) steal_coins = ( economy.steal_reward_coins(crop, yield_level, prestige) if can_steal else 0 ) return { "slot": plot.get("slot_index", 0), "state": state, "crop_key": plot.get("crop_key", ""), "crop_name": crop.name if crop else "", "crop_icon": crop.icon if crop else "", "reward_coins": crop.reward_coins if crop else 0, "reward_xp": crop.reward_xp if crop else 0, "ready_at": plot.get("ready_at", ""), "remaining_seconds": remaining, "watered_count": len(watered), "max_waters": economy.MAX_WATERS_PER_PLOT, "can_water": can_water, "can_steal": can_steal, "steal_coins": steal_coins, "fertilize_cost": ( economy.fertilize_cost(remaining) if state == "growing" and is_owner else 0 ), } def serialize_farm( farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None ) -> dict: now = now or _now() viewer_uid = viewer["uid"] if viewer else "" owner_uid = owner["uid"] prestige = _lvl(farm, "prestige") growth_level = _lvl(farm, "perk_growth") discount_level = _lvl(farm, "perk_discount") yield_level = _lvl(farm, "perk_yield") plots = get_plots(farm["uid"]) serialized_plots = [ serialize_plot( plot, viewer_uid=viewer_uid, owner_uid=owner_uid, now=now, yield_level=yield_level, prestige=prestige, ) for plot in plots ] progress = economy.level_progress(int(farm.get("xp", 0))) level = progress["level"] ci_tier = int(farm.get("ci_tier", 1)) next_tier = economy.next_ci_tier(ci_tier) ci_entry = economy.CI_BY_TIER.get(ci_tier) is_owner = viewer_uid == owner_uid streak = _lvl(farm, "streak") daily_available = is_owner and _daily_available(farm, now) return { "owner_username": owner.get("username", ""), "owner_uid": owner_uid, "is_owner": is_owner, "coins": int(farm.get("coins", 0)), "xp": int(farm.get("xp", 0)), "level": level, "level_into": progress["into_level"], "level_span": progress["span"], "level_is_max": progress["is_max"], "total_harvests": int(farm.get("total_harvests", 0)), "plot_count": int(farm.get("plot_count", economy.STARTING_PLOTS)), "max_plots": economy.MAX_PLOTS, "next_plot_cost": ( economy.plot_cost(int(farm.get("plot_count", economy.STARTING_PLOTS))) if int(farm.get("plot_count", economy.STARTING_PLOTS)) < economy.MAX_PLOTS else 0 ), "ci_tier": ci_tier, "ci_label": ci_entry.label if ci_entry else "", "ci_speed": economy.ci_speed(ci_tier), "ci_next_tier": next_tier.tier if next_tier else 0, "ci_next_label": next_tier.label if next_tier else "", "ci_next_cost": next_tier.upgrade_cost if next_tier else 0, "plots": serialized_plots, "crops": [ economy.crop_payload( crop, ci_tier, level, growth_level, discount_level, yield_level, prestige ) for crop in economy.CROPS ], "prestige": prestige, "prestige_multiplier": economy.prestige_multiplier(prestige), "prestige_min_level": economy.PRESTIGE_MIN_LEVEL, "prestige_available": is_owner and level >= economy.PRESTIGE_MIN_LEVEL, "streak": streak, "daily_available": daily_available, "daily_reward": economy.daily_reward(streak + 1 if daily_available else streak), "perks": _serialize_perks(farm) if is_owner else [], "quests": _serialize_quests(owner_uid, now) if is_owner else [], } def _update_farm(farm_uid: str, fields: dict) -> None: fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())} _farms().update(fields, ["uid"]) def plant(user: dict, slot: int, crop_key: str) -> dict: farm = ensure_farm(user["uid"]) crop = economy.crop_for(crop_key) if not crop: raise GameError("Unknown crop type.") progress = economy.level_progress(int(farm.get("xp", 0))) if crop.min_level > progress["level"]: raise GameError(f"{crop.name} unlocks at level {crop.min_level}.") plot = _plot_at(farm["uid"], slot) if not plot: raise GameError("That plot does not exist.") if plot.get("crop_key"): raise GameError("That plot is already in use.") coins = int(farm.get("coins", 0)) cost = economy.effective_plant_cost(crop, _lvl(farm, "perk_discount")) if coins < cost: raise GameError("Not enough coins to plant that.") now = _now() ci_tier = int(farm.get("ci_tier", 1)) grow = economy.grow_seconds_for(crop, ci_tier, _lvl(farm, "perk_growth")) ready_at = now + timedelta(seconds=grow) _plots().update( { "uid": plot["uid"], "crop_key": crop.key, "planted_at": _iso(now), "ready_at": _iso(ready_at), "watered_by": "[]", "updated_at": _iso(now), }, ["uid"], ) _update_farm(farm["uid"], {"coins": coins - cost}) advance_quests(user["uid"], "plant", 1) return {"slot": slot, "crop": crop.key, "spent": cost} def harvest(user: dict, slot: int) -> dict: farm = ensure_farm(user["uid"]) plot = _plot_at(farm["uid"], slot) if not plot or not plot.get("crop_key"): raise GameError("That plot is empty.") now = _now() if _plot_state(plot, now) != "ready": raise GameError("That build is still running.") crop = economy.crop_for(plot.get("crop_key", "")) if not crop: raise GameError("Unknown crop type.") _plots().update( { "uid": plot["uid"], "crop_key": "", "planted_at": "", "ready_at": "", "watered_by": "[]", "updated_at": _iso(now), }, ["uid"], ) prestige = _lvl(farm, "prestige") coins_gain = economy.effective_reward_coins(crop, _lvl(farm, "perk_yield"), prestige) xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp")) new_xp = int(farm.get("xp", 0)) + xp_gain _update_farm( farm["uid"], { "coins": int(farm.get("coins", 0)) + coins_gain, "xp": new_xp, "level": economy.level_for_xp(new_xp), "total_harvests": int(farm.get("total_harvests", 0)) + 1, }, ) advance_quests(user["uid"], "harvest", 1) advance_quests(user["uid"], "earn", coins_gain) return { "slot": slot, "crop": crop.key, "coins": coins_gain, "xp": xp_gain, } def buy_plot(user: dict) -> dict: farm = ensure_farm(user["uid"]) plot_count = int(farm.get("plot_count", economy.STARTING_PLOTS)) if plot_count >= economy.MAX_PLOTS: raise GameError("All plots are already unlocked.") cost = economy.plot_cost(plot_count) coins = int(farm.get("coins", 0)) if coins < cost: raise GameError("Not enough coins for a new plot.") _create_plot(farm["uid"], user["uid"], plot_count, _iso(_now())) _update_farm(farm["uid"], {"coins": coins - cost, "plot_count": plot_count + 1}) return {"plot_count": plot_count + 1, "spent": cost} def upgrade_ci(user: dict) -> dict: farm = ensure_farm(user["uid"]) ci_tier = int(farm.get("ci_tier", 1)) next_tier = economy.next_ci_tier(ci_tier) if not next_tier: raise GameError("CI is already at the top tier.") coins = int(farm.get("coins", 0)) if coins < next_tier.upgrade_cost: raise GameError("Not enough coins to upgrade CI.") _update_farm( farm["uid"], {"coins": coins - next_tier.upgrade_cost, "ci_tier": next_tier.tier}, ) return {"ci_tier": next_tier.tier, "spent": next_tier.upgrade_cost} def water(visitor: dict, owner: dict, slot: int) -> dict: if visitor["uid"] == owner["uid"]: raise GameError("You cannot water your own build.") farm = ensure_farm(owner["uid"]) plot = _plot_at(farm["uid"], slot) if not plot or not plot.get("crop_key"): raise GameError("That plot is empty.") now = _now() if _plot_state(plot, now) != "growing": raise GameError("That build is not running.") watered = _watered_by(plot) if visitor["uid"] in watered: raise GameError("You already watered this build.") if len(watered) >= economy.MAX_WATERS_PER_PLOT: raise GameError("This build has been watered enough.") crop = economy.crop_for(plot.get("crop_key", "")) ready_at = _parse(plot.get("ready_at", "")) or now bonus = economy.water_bonus_seconds( crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth") ) new_ready = max(now, ready_at - timedelta(seconds=bonus)) watered.append(visitor["uid"]) _plots().update( { "uid": plot["uid"], "ready_at": _iso(new_ready), "watered_by": json.dumps(watered), "updated_at": _iso(now), }, ["uid"], ) visitor_farm = ensure_farm(visitor["uid"]) new_xp = int(visitor_farm.get("xp", 0)) + economy.WATER_REWARD_XP _update_farm( visitor_farm["uid"], { "coins": int(visitor_farm.get("coins", 0)) + economy.WATER_REWARD_COINS, "xp": new_xp, "level": economy.level_for_xp(new_xp), }, ) advance_quests(visitor["uid"], "water", 1) return { "slot": slot, "bonus_seconds": bonus, "reward_coins": economy.WATER_REWARD_COINS, "reward_xp": economy.WATER_REWARD_XP, } def steal(thief: dict, owner: dict, slot: int) -> dict: if thief["uid"] == owner["uid"]: raise GameError("You cannot steal from your own farm.") farm = ensure_farm(owner["uid"]) plot = _plot_at(farm["uid"], slot) if not plot or not plot.get("crop_key"): raise GameError("That plot is empty.") now = _now() if _plot_state(plot, now) != "ready": raise GameError("That build is not ready.") crop = economy.crop_for(plot.get("crop_key", "")) if not crop: raise GameError("Unknown crop type.") ready_at = _parse(plot.get("ready_at", "")) or now if now < ready_at + timedelta(seconds=economy.STEAL_GRACE_SECONDS): raise GameError("That harvest is still protected.") _plots().update( { "uid": plot["uid"], "crop_key": "", "planted_at": "", "ready_at": "", "watered_by": "[]", "updated_at": _iso(now), }, ["uid"], ) coins_gain = economy.steal_reward_coins( crop, _lvl(farm, "perk_yield"), _lvl(farm, "prestige") ) thief_farm = ensure_farm(thief["uid"]) _update_farm( thief_farm["uid"], {"coins": int(thief_farm.get("coins", 0)) + coins_gain}, ) return { "slot": slot, "crop": crop.key, "coins": coins_gain, "owner_uid": owner["uid"], } def leaderboard(limit: int = 25) -> list[dict]: farms = sorted( _farms().find(), key=economy.farm_score, reverse=True, )[:limit] if not farms: return [] from devplacepy.database import get_users_by_uids users = get_users_by_uids([farm["user_uid"] for farm in farms]) entries = [] for rank, farm in enumerate(farms, start=1): owner = users.get(farm["user_uid"]) if not owner: continue entries.append( { "rank": rank, "username": owner.get("username", ""), "level": int(farm.get("level", 1)), "xp": int(farm.get("xp", 0)), "coins": int(farm.get("coins", 0)), "total_harvests": int(farm.get("total_harvests", 0)), "prestige": int(farm.get("prestige") or 0), "score": economy.farm_score(farm), } ) return entries def _daily_available(farm: dict, now: datetime) -> bool: last = _parse_date(farm.get("last_daily_at") or "") return last != now.date() def _serialize_perks(farm: dict) -> list[dict]: perks = [] for perk in economy.PERKS: level = _lvl(farm, PERK_COLUMN[perk.key]) maxed = level >= perk.max_level perks.append( { "key": perk.key, "name": perk.name, "icon": perk.icon, "description": perk.description, "level": level, "max_level": perk.max_level, "cost": 0 if maxed else economy.perk_cost(perk, level), "maxed": maxed, "effect": economy.perk_value_text(perk, level), } ) return perks def ensure_quests(farm: dict, day: str) -> None: if _quests().find_one(farm_uid=farm["uid"], day=day): return now = _iso(_now()) for index, quest in enumerate(economy.daily_quests(farm["user_uid"], day)): _quests().insert( { "uid": generate_uid(), "farm_uid": farm["uid"], "user_uid": farm["user_uid"], "day": day, "slot_index": index, "kind": quest["kind"], "label": quest["label"], "goal": quest["goal"], "progress": 0, "reward_coins": quest["reward_coins"], "reward_xp": quest["reward_xp"], "claimed": 0, "created_at": now, "updated_at": now, } ) def _serialize_quests(user_uid: str, now: datetime) -> list[dict]: farm = get_farm(user_uid) if not farm: return [] day = now.date().isoformat() ensure_quests(farm, day) rows = sorted( _quests().find(farm_uid=farm["uid"], day=day), key=lambda row: row.get("slot_index", 0), ) quests = [] for row in rows: goal = int(row.get("goal") or 0) progress = min(goal, int(row.get("progress") or 0)) claimed = bool(row.get("claimed")) quests.append( { "kind": row.get("kind", ""), "label": row.get("label", ""), "goal": goal, "progress": progress, "reward_coins": int(row.get("reward_coins") or 0), "reward_xp": int(row.get("reward_xp") or 0), "claimed": claimed, "can_claim": (not claimed) and goal > 0 and progress >= goal, } ) return quests def advance_quests(user_uid: str, kind: str, amount: int) -> None: if amount <= 0: return try: farm = get_farm(user_uid) if not farm: return day = _today() ensure_quests(farm, day) for row in _quests().find(farm_uid=farm["uid"], day=day, kind=kind): if row.get("claimed"): continue goal = int(row.get("goal") or 0) progress = min(goal, int(row.get("progress") or 0) + amount) _quests().update( {"uid": row["uid"], "progress": progress, "updated_at": _iso(_now())}, ["uid"], ) except Exception: return def claim_quest(user: dict, kind: str) -> dict: farm = ensure_farm(user["uid"]) day = _today() ensure_quests(farm, day) row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind) if not row: raise GameError("No such quest today.") if row.get("claimed"): raise GameError("Quest already claimed.") goal = int(row.get("goal") or 0) if int(row.get("progress") or 0) < goal: raise GameError("Quest not complete yet.") reward_coins = int(row.get("reward_coins") or 0) reward_xp = int(row.get("reward_xp") or 0) _quests().update( {"uid": row["uid"], "claimed": 1, "updated_at": _iso(_now())}, ["uid"] ) new_xp = int(farm.get("xp", 0)) + reward_xp _update_farm( farm["uid"], { "coins": int(farm.get("coins", 0)) + reward_coins, "xp": new_xp, "level": economy.level_for_xp(new_xp), }, ) return {"kind": kind, "reward_coins": reward_coins, "reward_xp": reward_xp} def claim_daily(user: dict) -> dict: farm = ensure_farm(user["uid"]) now = _now() if not _daily_available(farm, now): raise GameError("Daily bonus already claimed today.") last = _parse_date(farm.get("last_daily_at") or "") streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1 reward = economy.daily_reward(streak) _update_farm( farm["uid"], { "coins": int(farm.get("coins", 0)) + reward, "streak": streak, "last_daily_at": _iso(now), }, ) return {"reward": reward, "streak": streak} def upgrade_perk(user: dict, perk_key: str) -> dict: perk = economy.perk_for(perk_key) if not perk: raise GameError("Unknown perk.") farm = ensure_farm(user["uid"]) column = PERK_COLUMN[perk.key] level = _lvl(farm, column) if level >= perk.max_level: raise GameError("That perk is maxed out.") cost = economy.perk_cost(perk, level) if int(farm.get("coins", 0)) < cost: raise GameError("Not enough coins for that upgrade.") _update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost, column: level + 1}) return {"perk": perk.key, "level": level + 1, "spent": cost} def prestige(user: dict) -> dict: farm = ensure_farm(user["uid"]) level = economy.level_for_xp(int(farm.get("xp", 0))) if level < economy.PRESTIGE_MIN_LEVEL: raise GameError( f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)." ) new_prestige = _lvl(farm, "prestige") + 1 now = _iso(_now()) for plot in get_plots(farm["uid"]): if int(plot.get("slot_index", 0)) >= economy.STARTING_PLOTS: _plots().delete(uid=plot["uid"]) else: _plots().update( { "uid": plot["uid"], "crop_key": "", "planted_at": "", "ready_at": "", "watered_by": "[]", "updated_at": now, }, ["uid"], ) reset = { "coins": economy.STARTING_COINS, "xp": 0, "level": 1, "ci_tier": 1, "plot_count": economy.STARTING_PLOTS, "prestige": new_prestige, } for perk in economy.PERKS: reset[PERK_COLUMN[perk.key]] = 0 _update_farm(farm["uid"], reset) return {"prestige": new_prestige} def fertilize(user: dict, slot: int) -> dict: farm = ensure_farm(user["uid"]) plot = _plot_at(farm["uid"], slot) if not plot or not plot.get("crop_key"): raise GameError("That plot is empty.") now = _now() if _plot_state(plot, now) != "growing": raise GameError("That build is not running.") ready_at = _parse(plot.get("ready_at", "")) or now remaining = max(1, int((ready_at - now).total_seconds())) cost = economy.fertilize_cost(remaining) if int(farm.get("coins", 0)) < cost: raise GameError("Not enough coins to fertilize.") reduce_by = int(remaining * economy.FERTILIZE_FRACTION) new_ready = max(now, ready_at - timedelta(seconds=reduce_by)) _plots().update( {"uid": plot["uid"], "ready_at": _iso(new_ready), "updated_at": _iso(now)}, ["uid"], ) _update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost}) return {"slot": slot, "spent": cost, "reduced_seconds": reduce_by}