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,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
|
||||
Reference in New Issue
Block a user