# retoor <retoor@molodetz.nl>
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}