2026-06-22 18:41:53 +02:00
|
|
|
# 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"
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 19:33:03 +02:00
|
|
|
def serialize_plot(
|
|
|
|
|
plot: dict,
|
|
|
|
|
*,
|
|
|
|
|
viewer_uid: str,
|
|
|
|
|
owner_uid: str,
|
|
|
|
|
now: datetime,
|
|
|
|
|
yield_level: int = 0,
|
|
|
|
|
prestige: int = 0,
|
|
|
|
|
) -> dict:
|
2026-06-22 18:41:53 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-06-22 19:33:03 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-06-22 18:41:53 +02:00
|
|
|
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,
|
2026-06-22 19:33:03 +02:00
|
|
|
"can_steal": can_steal,
|
|
|
|
|
"steal_coins": steal_coins,
|
2026-06-22 18:41:53 +02:00
|
|
|
"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"]
|
2026-06-22 19:33:03 +02:00
|
|
|
prestige = _lvl(farm, "prestige")
|
|
|
|
|
growth_level = _lvl(farm, "perk_growth")
|
|
|
|
|
discount_level = _lvl(farm, "perk_discount")
|
|
|
|
|
yield_level = _lvl(farm, "perk_yield")
|
2026-06-22 18:41:53 +02:00
|
|
|
plots = get_plots(farm["uid"])
|
|
|
|
|
serialized_plots = [
|
2026-06-22 19:33:03 +02:00
|
|
|
serialize_plot(
|
|
|
|
|
plot,
|
|
|
|
|
viewer_uid=viewer_uid,
|
|
|
|
|
owner_uid=owner_uid,
|
|
|
|
|
now=now,
|
|
|
|
|
yield_level=yield_level,
|
|
|
|
|
prestige=prestige,
|
|
|
|
|
)
|
2026-06-22 18:41:53 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 19:33:03 +02:00
|
|
|
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"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 18:41:53 +02:00
|
|
|
def leaderboard(limit: int = 25) -> list[dict]:
|
|
|
|
|
farms = sorted(
|
|
|
|
|
_farms().find(),
|
feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
2026-06-22 22:52:02 +02:00
|
|
|
key=economy.farm_score,
|
2026-06-22 18:41:53 +02:00
|
|
|
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)),
|
feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
2026-06-22 22:52:02 +02:00
|
|
|
"prestige": int(farm.get("prestige") or 0),
|
|
|
|
|
"score": economy.farm_score(farm),
|
2026-06-22 18:41:53 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
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}
|