forked from retoor/devplacepy
feat: add Code Farm cooperative idle game with plot-based crop system and pub/sub notifications
Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import economy, store
|
||||
from .store import GameError
|
||||
|
||||
__all__ = ["economy", "store", "GameError"]
|
||||
@@ -0,0 +1,280 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
STARTING_COINS = 50
|
||||
STARTING_PLOTS = 4
|
||||
MAX_PLOTS = 12
|
||||
MAX_LEVEL = 20
|
||||
|
||||
PLOT_BASE_COST = 100
|
||||
WATER_BONUS_PCT = 0.08
|
||||
MAX_WATERS_PER_PLOT = 3
|
||||
WATER_REWARD_COINS = 6
|
||||
WATER_REWARD_XP = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Crop:
|
||||
key: str
|
||||
name: str
|
||||
icon: str
|
||||
cost: int
|
||||
grow_seconds: int
|
||||
reward_coins: int
|
||||
reward_xp: int
|
||||
min_level: int
|
||||
|
||||
|
||||
CROPS: tuple[Crop, ...] = (
|
||||
Crop("shell", "Shell Script", "\U0001f41a", 5, 30, 11, 2, 1),
|
||||
Crop("python", "Python Script", "\U0001f40d", 15, 120, 36, 5, 1),
|
||||
Crop("webapp", "Web App", "\U0001f4dc", 40, 300, 98, 12, 2),
|
||||
Crop("api", "Go Service", "\U0001f439", 90, 600, 224, 25, 3),
|
||||
Crop("rust", "Rust Engine", "\U0001f980", 200, 1800, 520, 60, 4),
|
||||
Crop("haskell", "Compiler", "λ", 500, 3600, 1380, 150, 6),
|
||||
Crop("kernel", "Kernel", "⚙️", 1200, 7200, 3600, 400, 8),
|
||||
)
|
||||
|
||||
CROP_BY_KEY = {crop.key: crop for crop in CROPS}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CiTier:
|
||||
tier: int
|
||||
label: str
|
||||
speed: float
|
||||
upgrade_cost: int
|
||||
|
||||
|
||||
CI_TIERS: tuple[CiTier, ...] = (
|
||||
CiTier(1, "Local Build", 1.0, 0),
|
||||
CiTier(2, "Shared Runner", 1.25, 150),
|
||||
CiTier(3, "Fast Runner", 1.6, 400),
|
||||
CiTier(4, "Parallel Matrix", 2.0, 1000),
|
||||
CiTier(5, "Distributed Cache", 2.5, 2600),
|
||||
)
|
||||
|
||||
CI_BY_TIER = {tier.tier: tier for tier in CI_TIERS}
|
||||
MAX_CI_TIER = CI_TIERS[-1].tier
|
||||
|
||||
|
||||
def crop_for(key: str) -> Crop | None:
|
||||
return CROP_BY_KEY.get(key)
|
||||
|
||||
|
||||
def ci_speed(tier: int) -> float:
|
||||
entry = CI_BY_TIER.get(tier)
|
||||
return entry.speed if entry else 1.0
|
||||
|
||||
|
||||
def next_ci_tier(tier: int) -> CiTier | None:
|
||||
return CI_BY_TIER.get(tier + 1)
|
||||
|
||||
|
||||
def farm_speed(ci_tier: int, growth_level: int = 0) -> float:
|
||||
return ci_speed(ci_tier) * (1 + PERK_BY_KEY["growth"].step * growth_level)
|
||||
|
||||
|
||||
def grow_seconds_for(crop: Crop, ci_tier: int, growth_level: int = 0) -> int:
|
||||
return max(1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level)))
|
||||
|
||||
|
||||
def water_bonus_seconds(crop: Crop, ci_tier: int, growth_level: int = 0) -> int:
|
||||
return max(1, round(grow_seconds_for(crop, ci_tier, growth_level) * WATER_BONUS_PCT))
|
||||
|
||||
|
||||
def plot_cost(current_plot_count: int) -> int:
|
||||
extra = current_plot_count - STARTING_PLOTS
|
||||
return PLOT_BASE_COST * (2 ** max(0, extra))
|
||||
|
||||
|
||||
def xp_threshold(level: int) -> int:
|
||||
return 50 * (level - 1) * (level - 1)
|
||||
|
||||
|
||||
def level_for_xp(xp: int) -> int:
|
||||
level = 1
|
||||
while level < MAX_LEVEL and xp >= xp_threshold(level + 1):
|
||||
level += 1
|
||||
return level
|
||||
|
||||
|
||||
def level_progress(xp: int) -> dict:
|
||||
level = level_for_xp(xp)
|
||||
floor_xp = xp_threshold(level)
|
||||
if level >= MAX_LEVEL:
|
||||
return {
|
||||
"level": level,
|
||||
"xp": xp,
|
||||
"into_level": xp - floor_xp,
|
||||
"span": 0,
|
||||
"next_level_xp": floor_xp,
|
||||
"is_max": True,
|
||||
}
|
||||
ceil_xp = xp_threshold(level + 1)
|
||||
return {
|
||||
"level": level,
|
||||
"xp": xp,
|
||||
"into_level": xp - floor_xp,
|
||||
"span": ceil_xp - floor_xp,
|
||||
"next_level_xp": ceil_xp,
|
||||
"is_max": False,
|
||||
}
|
||||
|
||||
|
||||
def unlocked_crops(level: int) -> list[Crop]:
|
||||
return [crop for crop in CROPS if crop.min_level <= level]
|
||||
|
||||
|
||||
def crop_payload(
|
||||
crop: Crop,
|
||||
ci_tier: int,
|
||||
level: int,
|
||||
growth_level: int = 0,
|
||||
discount_level: int = 0,
|
||||
yield_level: int = 0,
|
||||
prestige: int = 0,
|
||||
) -> dict:
|
||||
return {
|
||||
"key": crop.key,
|
||||
"name": crop.name,
|
||||
"icon": crop.icon,
|
||||
"cost": effective_plant_cost(crop, discount_level),
|
||||
"reward_coins": effective_reward_coins(crop, yield_level, prestige),
|
||||
"reward_xp": crop.reward_xp,
|
||||
"min_level": crop.min_level,
|
||||
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level),
|
||||
"locked": crop.min_level > level,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Perk:
|
||||
key: str
|
||||
name: str
|
||||
icon: str
|
||||
description: str
|
||||
max_level: int
|
||||
base_cost: int
|
||||
cost_growth: float
|
||||
step: float
|
||||
|
||||
|
||||
PERKS: tuple[Perk, ...] = (
|
||||
Perk("yield", "Optimizer", "📈", "+5% harvest coins per level", 10, 120, 1.6, 0.05),
|
||||
Perk("growth", "Build Cache", "⚡", "+4% build speed per level", 10, 150, 1.7, 0.04),
|
||||
Perk("discount", "Bulk Licenses", "🏷️", "-3% planting cost per level", 8, 100, 1.7, 0.03),
|
||||
Perk("xp", "Mentorship", "🎓", "+5% harvest XP per level", 10, 140, 1.6, 0.05),
|
||||
)
|
||||
|
||||
PERK_BY_KEY = {perk.key: perk for perk in PERKS}
|
||||
|
||||
PRESTIGE_MIN_LEVEL = 10
|
||||
PRESTIGE_BONUS = 0.25
|
||||
|
||||
DAILY_BASE = 20
|
||||
DAILY_STREAK_STEP = 12
|
||||
DAILY_STREAK_CAP = 7
|
||||
|
||||
FERTILIZE_FRACTION = 0.5
|
||||
FERTILIZE_COIN_PER_SECOND = 0.3
|
||||
FERTILIZE_MIN_COST = 5
|
||||
|
||||
|
||||
def perk_for(key: str) -> Perk | None:
|
||||
return PERK_BY_KEY.get(key)
|
||||
|
||||
|
||||
def perk_cost(perk: Perk, current_level: int) -> int:
|
||||
return round(perk.base_cost * (perk.cost_growth ** current_level))
|
||||
|
||||
|
||||
def perk_value_text(perk: Perk, level: int) -> str:
|
||||
if perk.key == "discount":
|
||||
return f"-{round(perk.step * level * 100)}% planting cost"
|
||||
if perk.key == "growth":
|
||||
return f"+{round(perk.step * level * 100)}% build speed"
|
||||
if perk.key == "yield":
|
||||
return f"+{round(perk.step * level * 100)}% harvest coins"
|
||||
return f"+{round(perk.step * level * 100)}% harvest XP"
|
||||
|
||||
|
||||
def prestige_multiplier(prestige: int) -> float:
|
||||
return 1 + PRESTIGE_BONUS * max(0, prestige)
|
||||
|
||||
|
||||
def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int:
|
||||
factor = max(0.0, 1 - PERK_BY_KEY["discount"].step * discount_level)
|
||||
return max(1, round(crop.cost * factor))
|
||||
|
||||
|
||||
def effective_reward_coins(crop: Crop, yield_level: int = 0, prestige: int = 0) -> int:
|
||||
factor = (1 + PERK_BY_KEY["yield"].step * yield_level) * prestige_multiplier(prestige)
|
||||
return round(crop.reward_coins * factor)
|
||||
|
||||
|
||||
def effective_reward_xp(crop: Crop, xp_level: int = 0) -> int:
|
||||
return round(crop.reward_xp * (1 + PERK_BY_KEY["xp"].step * xp_level))
|
||||
|
||||
|
||||
def daily_reward(streak: int) -> int:
|
||||
effective = min(max(streak, 1), DAILY_STREAK_CAP)
|
||||
return DAILY_BASE + DAILY_STREAK_STEP * (effective - 1)
|
||||
|
||||
|
||||
def fertilize_cost(remaining_seconds: int) -> int:
|
||||
return max(FERTILIZE_MIN_COST, round(remaining_seconds * FERTILIZE_COIN_PER_SECOND))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestDef:
|
||||
kind: str
|
||||
label: str
|
||||
goal_min: int
|
||||
goal_max: int
|
||||
coin: int
|
||||
xp: int
|
||||
|
||||
|
||||
QUEST_DEFS: dict[str, QuestDef] = {
|
||||
"plant": QuestDef("plant", "Plant {goal} crops", 3, 6, 8, 2),
|
||||
"harvest": QuestDef("harvest", "Harvest {goal} builds", 3, 5, 14, 4),
|
||||
"water": QuestDef("water", "Water {goal} neighbour builds", 2, 4, 16, 5),
|
||||
"earn": QuestDef("earn", "Earn {goal} coins harvesting", 150, 400, 0, 0),
|
||||
}
|
||||
|
||||
QUEST_KINDS = tuple(QUEST_DEFS.keys())
|
||||
DAILY_QUEST_COUNT = 3
|
||||
|
||||
|
||||
def daily_quests(user_uid: str, day: str) -> list[dict]:
|
||||
import hashlib
|
||||
|
||||
seed = int(hashlib.sha256(f"{user_uid}:{day}".encode()).hexdigest(), 16)
|
||||
start = seed % len(QUEST_KINDS)
|
||||
quests = []
|
||||
for index in range(DAILY_QUEST_COUNT):
|
||||
kind = QUEST_KINDS[(start + index) % len(QUEST_KINDS)]
|
||||
definition = QUEST_DEFS[kind]
|
||||
span = definition.goal_max - definition.goal_min + 1
|
||||
goal = definition.goal_min + ((seed >> (index * 7)) % span)
|
||||
if kind == "earn":
|
||||
goal = max(definition.goal_min, (goal // 10) * 10)
|
||||
reward_coins = round(goal * 0.15)
|
||||
reward_xp = max(1, round(goal / 30))
|
||||
else:
|
||||
reward_coins = goal * definition.coin
|
||||
reward_xp = goal * definition.xp
|
||||
quests.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"label": definition.label.format(goal=goal),
|
||||
"goal": goal,
|
||||
"reward_coins": reward_coins,
|
||||
"reward_xp": reward_xp,
|
||||
}
|
||||
)
|
||||
return quests
|
||||
@@ -0,0 +1,658 @@
|
||||
# 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) -> 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
|
||||
)
|
||||
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,
|
||||
"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"]
|
||||
plots = get_plots(farm["uid"])
|
||||
serialized_plots = [
|
||||
serialize_plot(plot, viewer_uid=viewer_uid, owner_uid=owner_uid, now=now)
|
||||
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
|
||||
prestige = _lvl(farm, "prestige")
|
||||
growth_level = _lvl(farm, "perk_growth")
|
||||
discount_level = _lvl(farm, "perk_discount")
|
||||
yield_level = _lvl(farm, "perk_yield")
|
||||
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 leaderboard(limit: int = 25) -> list[dict]:
|
||||
farms = sorted(
|
||||
_farms().find(),
|
||||
key=lambda row: (int(row.get("level", 1)), int(row.get("xp", 0)), int(row.get("total_harvests", 0))),
|
||||
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)),
|
||||
}
|
||||
)
|
||||
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}
|
||||
Reference in New Issue
Block a user