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:
2026-06-22 16:41:53 +00:00
parent 743efbf61f
commit ebf6bf7f82
40 changed files with 3860 additions and 5 deletions
+63
View File
@@ -1245,6 +1245,69 @@ def init_db():
deepsearch_url_cache.create_column_by_example(column, example)
_index(db, "deepsearch_url_cache", "idx_deepsearch_url_cache_hash", ["url_hash"])
game_farms = get_table("game_farms")
for column, example in (
("uid", ""),
("user_uid", ""),
("coins", 0),
("xp", 0),
("level", 1),
("ci_tier", 1),
("plot_count", 0),
("total_harvests", 0),
("prestige", 0),
("streak", 0),
("last_daily_at", ""),
("perk_yield", 0),
("perk_growth", 0),
("perk_discount", 0),
("perk_xp", 0),
("created_at", ""),
("updated_at", ""),
):
if not game_farms.has_column(column):
game_farms.create_column_by_example(column, example)
_index(db, "game_farms", "idx_game_farms_user", ["user_uid"], unique=True)
_index(db, "game_farms", "idx_game_farms_rank", ["level", "xp"])
game_quests = get_table("game_quests")
for column, example in (
("uid", ""),
("farm_uid", ""),
("user_uid", ""),
("day", ""),
("slot_index", 0),
("kind", ""),
("label", ""),
("goal", 0),
("progress", 0),
("reward_coins", 0),
("reward_xp", 0),
("claimed", 0),
("created_at", ""),
("updated_at", ""),
):
if not game_quests.has_column(column):
game_quests.create_column_by_example(column, example)
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
game_plots = get_table("game_plots")
for column, example in (
("uid", ""),
("farm_uid", ""),
("user_uid", ""),
("slot_index", 0),
("crop_key", ""),
("planted_at", ""),
("ready_at", ""),
("watered_by", "[]"),
("created_at", ""),
("updated_at", ""),
):
if not game_plots.has_column(column):
game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,
+168
View File
@@ -5141,6 +5141,174 @@ four ways to sign requests.
),
],
},
{
"slug": "game",
"title": "Code Farm",
"intro": """
# Code Farm
The Code Farm is a cooperative idle game. Each member owns a farm of plots, plants software
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
client can refresh without a second request.
""",
"endpoints": [
endpoint(
id="game-home",
method="GET",
path="/game",
title="Code Farm page",
summary="The player's own farm: HUD, plot grid, shop, and leaderboard.",
auth="user",
negotiation=True,
sample_response={"ok": True, "farm": {"coins": 50, "level": 1, "plots": []}},
),
endpoint(
id="game-state",
method="GET",
path="/game/state",
title="Farm state",
summary="The signed-in player's full farm state as JSON.",
auth="user",
sample_response={
"ok": True,
"farm": {
"coins": 50,
"level": 1,
"ci_tier": 1,
"plot_count": 4,
"plots": [{"slot": 0, "state": "empty"}],
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
},
},
),
endpoint(
id="game-leaderboard",
method="GET",
path="/game/leaderboard",
title="Farm leaderboard",
summary="Top farmers ranked by level, XP, and harvests.",
auth="public",
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
),
endpoint(
id="game-view-farm",
method="GET",
path="/game/farm/{username}",
title="View a farm",
summary="Another player's farm, with water controls on growing builds.",
auth="public",
negotiation=True,
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
sample_response={"farm": {"owner_username": "alice", "is_owner": False, "plots": []}},
),
endpoint(
id="game-plant",
method="POST",
path="/game/plant",
title="Plant a crop",
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
auth="user",
params=[
field("slot", "form", "integer", True, "0", "Plot slot index."),
field("crop", "form", "string", True, "python", "Crop key."),
],
sample_response={"ok": True, "farm": {"coins": 35}},
),
endpoint(
id="game-harvest",
method="POST",
path="/game/harvest",
title="Harvest a build",
summary="Harvest a finished build for coins and XP.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 86}},
),
endpoint(
id="game-buy-plot",
method="POST",
path="/game/buy-plot",
title="Buy a plot",
summary="Unlock a new plot. Cost doubles per extra plot.",
auth="user",
sample_response={"ok": True, "farm": {"plot_count": 5}},
),
endpoint(
id="game-upgrade",
method="POST",
path="/game/upgrade",
title="Upgrade CI",
summary="Upgrade the farm CI tier for faster builds.",
auth="user",
sample_response={"ok": True, "farm": {"ci_tier": 2}},
),
endpoint(
id="game-water",
method="POST",
path="/game/farm/{username}/water",
title="Water a build",
summary="Water another player's growing build to speed it up and earn coins.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}},
),
endpoint(
id="game-fertilize",
method="POST",
path="/game/fertilize",
title="Fertilize a build",
summary="Spend coins to halve a growing build's remaining time.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 12}},
),
endpoint(
id="game-daily",
method="POST",
path="/game/daily",
title="Claim daily bonus",
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
auth="user",
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
),
endpoint(
id="game-perk",
method="POST",
path="/game/perk",
title="Upgrade a perk",
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
auth="user",
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-quests-claim",
method="POST",
path="/game/quests/claim",
title="Claim a quest",
summary="Claim a completed daily quest reward by its kind.",
auth="user",
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
sample_response={"ok": True, "farm": {"coins": 130}},
),
endpoint(
id="game-prestige",
method="POST",
path="/game/prestige",
title="Refactor (prestige)",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1}},
),
],
},
]
_PAGE_RESPONSES = {
+3 -1
View File
@@ -81,6 +81,7 @@ from devplacepy.routers import (
devrant,
dbapi,
pubsub,
game,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
@@ -431,6 +432,7 @@ app.include_router(xmlrpc.router, prefix="/xmlrpc")
app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
@app.middleware("http")
@@ -564,7 +566,7 @@ async def response_timing(request: Request, call_next):
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=6)
_home_cache = TTLCache(ttl=60, max_size=4)
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
def _landing_news():
+17
View File
@@ -530,3 +530,20 @@ class AdminSettingsForm(BaseModel):
maintenance_message: str = Field(default="", max_length=300)
docs_search_mode: str = Field(default="", max_length=20)
extra_head: str = Field(default="", max_length=50000)
class GamePlantForm(BaseModel):
slot: int = Field(ge=0, le=64)
crop: str = Field(min_length=1, max_length=40)
class GameSlotForm(BaseModel):
slot: int = Field(ge=0, le=64)
class GamePerkForm(BaseModel):
perk: str = Field(min_length=1, max_length=40)
class GameQuestForm(BaseModel):
quest: str = Field(min_length=1, max_length=40)
+6
View File
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from . import farm, index
router = index.router
router.include_router(farm.router)
+44
View File
@@ -0,0 +1,44 @@
# retoor <retoor@molodetz.nl>
from fastapi import Request
from devplacepy.database import get_table
from devplacepy.services.game import store
from devplacepy.services.pubsub import publish
def farm_topic(username: str) -> str:
return f"public.game.farm.{username}"
async def notify_farm(username: str) -> None:
if not username:
return
try:
await publish(farm_topic(username), {"kind": "update", "username": username})
except Exception:
pass
def owner_by_username(username: str) -> dict | None:
return get_table("users").find_one(username=username)
def state_payload(user: dict, viewer: dict | None = None) -> dict:
farm = store.ensure_farm(user["uid"])
return store.serialize_farm(farm, viewer=viewer or user, owner=user)
def game_seo(request: Request, title: str, description: str) -> dict:
from devplacepy.seo import base_seo_context
return base_seo_context(
request,
title=title,
description=description,
robots="noindex,follow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Code Farm", "url": "/game"},
],
)
+67
View File
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import GameSlotForm
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store
from devplacepy.utils import get_current_user, require_user, track_action
from ._shared import game_seo, notify_farm, owner_by_username
router = APIRouter()
@router.get("/farm/{username}", response_class=HTMLResponse)
async def view_farm(request: Request, username: str):
viewer = get_current_user(request)
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
farm = store.ensure_farm(owner["uid"])
data = store.serialize_farm(farm, viewer=viewer, owner=owner)
seo_ctx = game_seo(
request,
f"{owner['username']}'s Code Farm",
f"Visit {owner['username']}'s Code Farm on DevPlace and water their builds.",
)
return respond(
request,
"game_farm.html",
{
**seo_ctx,
"request": request,
"user": viewer,
"viewer": viewer,
"farm": data,
},
model=GameFarmViewOut,
)
@router.post("/farm/{username}/water")
async def water_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
try:
store.water(viewer, owner, data.slot)
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
track_action(viewer["uid"], "water")
await notify_farm(owner["username"])
if wants_json(request):
payload = store.serialize_farm(
store.ensure_farm(owner["uid"]), viewer=viewer, owner=owner
)
return JSONResponse(GameFarmViewOut(farm=payload).model_dump(mode="json"))
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
+142
View File
@@ -0,0 +1,142 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
GamePerkForm,
GamePlantForm,
GameQuestForm,
GameSlotForm,
)
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
from devplacepy.services.game import GameError, store
from devplacepy.utils import award_rewards, get_current_user, require_user, track_action
from ._shared import game_seo, notify_farm, state_payload
router = APIRouter()
@router.get("", response_class=HTMLResponse)
async def game_home(request: Request):
user = require_user(request)
farm = state_payload(user)
seo_ctx = game_seo(
request,
"Code Farm",
"Grow software projects on your Code Farm, harvest coins and XP, "
"upgrade your CI, and water your friends' builds.",
)
return respond(
request,
"game.html",
{**seo_ctx, "request": request, "user": user, "farm": farm},
model=GameStateOut,
)
@router.get("/state")
async def game_state(request: Request):
user = require_user(request)
return JSONResponse(GameStateOut(farm=state_payload(user)).model_dump(mode="json"))
@router.get("/leaderboard")
async def game_leaderboard(request: Request):
get_current_user(request)
entries = store.leaderboard(25)
return JSONResponse(
GameLeaderboardOut(entries=entries).model_dump(mode="json")
)
async def _respond_action(request: Request, user: dict, fn, on_success=None):
try:
result = fn()
except GameError as exc:
if wants_json(request):
return json_error(400, str(exc))
return RedirectResponse(url="/game", status_code=302)
if on_success:
on_success(result)
await notify_farm(user.get("username", ""))
if wants_json(request):
return JSONResponse(
GameStateOut(farm=state_payload(user)).model_dump(mode="json")
)
return RedirectResponse(url="/game", status_code=302)
@router.post("/plant")
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.plant(user, data.slot, data.crop)
)
@router.post("/harvest")
async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "harvest")
award_rewards(user["uid"], result.get("xp", 0))
return await _respond_action(
request, user, lambda: store.harvest(user, data.slot), reward
)
@router.post("/buy-plot")
async def game_buy_plot(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.buy_plot(user))
@router.post("/upgrade")
async def game_upgrade(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.upgrade_ci(user))
@router.post("/fertilize")
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
user = require_user(request)
return await _respond_action(request, user, lambda: store.fertilize(user, data.slot))
@router.post("/daily")
async def game_daily(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.claim_daily(user))
@router.post("/perk")
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.upgrade_perk(user, data.perk)
)
@router.post("/prestige")
async def game_prestige(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.prestige(user))
@router.post("/quests/claim")
async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form()]):
user = require_user(request)
def reward(result):
award_rewards(user["uid"], result.get("reward_xp", 0))
return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest), reward
)
+108
View File
@@ -1151,3 +1151,111 @@ class NlQueryOut(_Out):
row_count: int = 0
truncated: bool = False
error: Optional[str] = None
class GameCropOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
cost: int = 0
reward_coins: int = 0
reward_xp: int = 0
min_level: int = 1
grow_seconds: int = 0
locked: bool = False
class GamePlotOut(_Out):
slot: int = 0
state: str = "empty"
crop_key: str = ""
crop_name: str = ""
crop_icon: str = ""
reward_coins: int = 0
reward_xp: int = 0
ready_at: str = ""
remaining_seconds: int = 0
watered_count: int = 0
max_waters: int = 0
can_water: bool = False
fertilize_cost: int = 0
class GamePerkOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
level: int = 0
max_level: int = 0
cost: int = 0
maxed: bool = False
effect: str = ""
class GameQuestOut(_Out):
kind: str = ""
label: str = ""
goal: int = 0
progress: int = 0
reward_coins: int = 0
reward_xp: int = 0
claimed: bool = False
can_claim: bool = False
class GameFarmOut(_Out):
owner_username: str = ""
owner_uid: str = ""
is_owner: bool = False
coins: int = 0
xp: int = 0
level: int = 1
level_into: int = 0
level_span: int = 0
level_is_max: bool = False
total_harvests: int = 0
plot_count: int = 0
max_plots: int = 0
next_plot_cost: int = 0
ci_tier: int = 1
ci_label: str = ""
ci_speed: float = 1.0
ci_next_tier: int = 0
ci_next_label: str = ""
ci_next_cost: int = 0
plots: list[GamePlotOut] = []
crops: list[GameCropOut] = []
prestige: int = 0
prestige_multiplier: float = 1.0
prestige_min_level: int = 0
prestige_available: bool = False
streak: int = 0
daily_available: bool = False
daily_reward: int = 0
perks: list[GamePerkOut] = []
quests: list[GameQuestOut] = []
class GameStateOut(_Out):
ok: bool = True
farm: GameFarmOut
class GameFarmViewOut(_Out):
farm: GameFarmOut
page_title: str = ""
meta_description: str = ""
class GameLeaderboardEntryOut(_Out):
rank: int = 0
username: str = ""
level: int = 1
xp: int = 0
coins: int = 0
total_harvests: int = 0
class GameLeaderboardOut(_Out):
entries: list[GameLeaderboardEntryOut] = []
@@ -1850,6 +1850,138 @@ ACTIONS: tuple[Action, ...] = (
requires_admin=True,
params=(path("source_model", "Source model name."), confirm()),
),
Action(
name="game_state",
method="GET",
path="/game/state",
summary="Get the current user's Code Farm state",
description=(
"Returns JSON for the signed-in user's Code Farm: coins, level/XP, CI tier, "
"plot grid (each plot's state, crop, remaining build seconds), and the plantable crops."
),
handler="http",
requires_auth=True,
read_only=True,
),
Action(
name="game_leaderboard",
method="GET",
path="/game/leaderboard",
summary="List the top Code Farm players",
handler="http",
requires_auth=False,
read_only=True,
),
Action(
name="game_view_farm",
method="GET",
path="/game/farm/{username}",
summary="View another player's Code Farm",
handler="http",
requires_auth=False,
read_only=True,
params=(path("username", "The farm owner's username."),),
),
Action(
name="game_plant",
method="POST",
path="/game/plant",
summary="Plant a crop in an empty plot on your Code Farm",
handler="http",
requires_auth=True,
params=(
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
body("crop", "Crop key, e.g. shell, python, webapp, api, rust, haskell, kernel.", required=True),
),
),
Action(
name="game_harvest",
method="POST",
path="/game/harvest",
summary="Harvest a finished build from a plot on your Code Farm",
handler="http",
requires_auth=True,
params=(
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
),
),
Action(
name="game_buy_plot",
method="POST",
path="/game/buy-plot",
summary="Buy a new plot on your Code Farm",
handler="http",
requires_auth=True,
),
Action(
name="game_upgrade_ci",
method="POST",
path="/game/upgrade",
summary="Upgrade your Code Farm CI tier for faster builds",
handler="http",
requires_auth=True,
),
Action(
name="game_water",
method="POST",
path="/game/farm/{username}/water",
summary="Water a growing build on another player's Code Farm",
handler="http",
requires_auth=True,
params=(
path("username", "The farm owner's username."),
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
),
),
Action(
name="game_fertilize",
method="POST",
path="/game/fertilize",
summary="Spend coins to halve a growing build's remaining time on your Code Farm",
handler="http",
requires_auth=True,
params=(
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
),
),
Action(
name="game_daily",
method="POST",
path="/game/daily",
summary="Claim the daily Code Farm coin bonus (builds a streak)",
handler="http",
requires_auth=True,
),
Action(
name="game_upgrade_perk",
method="POST",
path="/game/perk",
summary="Upgrade a permanent Code Farm perk (yield, growth, discount, xp)",
handler="http",
requires_auth=True,
params=(
body("perk", "Perk key: yield, growth, discount, or xp.", required=True),
),
),
Action(
name="game_claim_quest",
method="POST",
path="/game/quests/claim",
summary="Claim a completed daily Code Farm quest reward",
handler="http",
requires_auth=True,
params=(
body("quest", "Quest kind: plant, harvest, water, or earn.", required=True),
),
),
Action(
name="game_prestige",
method="POST",
path="/game/prestige",
summary="Refactor (prestige) your Code Farm for a permanent coin bonus (requires level 10)",
handler="http",
requires_auth=True,
),
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
+6
View File
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from . import economy, store
from .store import GameError
__all__ = ["economy", "store", "GameError"]
+280
View File
@@ -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
+658
View File
@@ -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}
+486
View File
@@ -0,0 +1,486 @@
/* retoor <retoor@molodetz.nl> */
.game-page {
max-width: var(--max-content);
margin: 0 auto;
padding: var(--space-lg);
}
.game-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.game-title h1 {
margin: 0;
font-size: 1.8rem;
}
.game-subtitle {
margin: var(--space-xs) 0 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
.game-hud {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.hud-stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.hud-label {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hud-value {
font-size: 1.6rem;
font-weight: 700;
color: var(--text-primary);
}
.hud-sub {
color: var(--text-secondary);
font-size: 0.8rem;
}
.hud-xp-bar {
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.hud-xp-fill {
display: block;
height: 100%;
background: var(--accent);
transition: width 0.4s ease;
}
.game-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: var(--space-xl);
align-items: start;
}
.game-section-title {
font-size: 1.1rem;
margin: 0 0 var(--space-md);
}
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.game-plot {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
min-height: 140px;
padding: var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-xs);
text-align: center;
}
.game-plot-empty {
border-style: dashed;
}
.game-plot-growing {
border-color: var(--warning);
}
.game-plot-ready {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success) inset;
}
.plot-icon {
font-size: 2.4rem;
line-height: 1;
}
.plot-ready-icon {
animation: plot-bounce 1.2s ease-in-out infinite;
}
@keyframes plot-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.plot-crop-name {
font-weight: 600;
font-size: 0.9rem;
}
.plot-timer {
font-family: var(--font-mono);
color: var(--warning);
font-size: 0.85rem;
}
.plot-water-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.plot-empty-label,
.plot-ready-label {
color: var(--text-muted);
font-size: 0.85rem;
}
.plot-plant-form {
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: 100%;
}
.plot-crop-select {
width: 100%;
padding: var(--space-xs);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-input);
color: var(--text-primary);
font-size: 0.8rem;
}
.game-plot-buy {
border-style: dashed;
color: var(--text-secondary);
}
.game-shop {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.shop-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.shop-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.shop-info span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.game-side {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.game-leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.game-lb-row {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-input);
}
.game-lb-self {
background: var(--bg-muted);
}
.game-lb-rank {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.8rem;
min-width: 2.2em;
}
.game-lb-name {
flex: 1;
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
}
.game-lb-name:hover {
color: var(--accent);
}
.game-lb-level {
color: var(--text-secondary);
font-size: 0.8rem;
}
.game-lb-empty {
color: var(--text-muted);
font-size: 0.85rem;
}
@media (max-width: 900px) {
.game-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.game-page {
padding: var(--space-md);
}
.game-hud {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-sm);
}
.game-title h1 {
font-size: 1.5rem;
}
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--space-sm);
}
.perk-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
@media (max-width: 480px) {
.game-page {
padding: var(--space-sm);
}
.hud-value {
font-size: 1.3rem;
}
.game-grid {
grid-template-columns: repeat(2, 1fr);
}
.game-plot {
min-height: 120px;
padding: var(--space-sm);
}
.plot-icon {
font-size: 2rem;
}
.shop-row {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.shop-row form,
.shop-row .btn {
width: 100%;
}
.perk-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 360px) {
.game-hud {
grid-template-columns: 1fr;
}
.game-grid,
.perk-grid {
grid-template-columns: 1fr;
}
}
@media (hover: none) and (pointer: coarse) {
.game-plot .btn,
.perk-card .btn,
.quest-foot .btn,
.game-daily .btn,
.plot-crop-select {
min-height: 44px;
}
.game-plot form,
.perk-card form,
.game-daily form {
width: 100%;
}
.game-plot .btn,
.perk-card .btn,
.game-daily .btn {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.plot-ready-icon {
animation: none;
}
.hud-xp-fill,
.quest-fill {
transition: none;
}
}
.game-daily,
.game-quests,
.game-lb-section {
margin-bottom: var(--space-lg);
}
.daily-streak {
margin: 0 0 var(--space-sm);
color: var(--text-secondary);
font-size: 0.9rem;
}
.daily-claimed {
color: var(--text-muted);
font-size: 0.85rem;
}
.quest-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.quest-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-sm) var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.quest-done {
opacity: 0.6;
}
.quest-head {
display: flex;
justify-content: space-between;
gap: var(--space-sm);
font-size: 0.85rem;
font-weight: 600;
}
.quest-reward {
color: var(--success);
white-space: nowrap;
}
.quest-bar {
height: 5px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.quest-fill {
display: block;
height: 100%;
background: var(--accent);
}
.quest-foot {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
.quest-claimed {
color: var(--success);
}
.perk-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-md);
}
.perk-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
text-align: center;
}
.perk-icon {
font-size: 1.8rem;
line-height: 1;
}
.perk-name {
font-size: 0.9rem;
}
.perk-level {
color: var(--accent);
font-size: 0.8rem;
font-weight: 600;
}
.perk-effect {
color: var(--text-secondary);
font-size: 0.78rem;
min-height: 2.2em;
}
.perk-maxed {
color: var(--success);
font-size: 0.8rem;
font-weight: 600;
}
.game-perks {
margin-top: var(--space-xl);
}
+2
View File
@@ -38,6 +38,7 @@ import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
class Application {
@@ -85,6 +86,7 @@ class Application {
this.mediaGallery = new MediaGallery();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.localTime = new LocalTime();
this.gameFarm = new GameFarm();
}
}
+252
View File
@@ -0,0 +1,252 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class GameFarm {
constructor() {
this.root = document.querySelector("[data-game-root]");
if (!this.root) return;
this.mode = this.root.dataset.mode || "own";
this.username = this.root.dataset.username || "";
this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state";
this.farm = null;
this._bindActions();
this._startTicker();
this.load();
if (this.mode === "own") this._loadLeaderboard();
if (window.app && window.app.pubsub && this.username) {
window.app.pubsub.subscribe(`public.game.farm.${this.username}`, () => this.load());
}
this.poller = new Poller(() => this.load(), 20000, { pauseHidden: true, immediate: false });
}
async load() {
try {
const data = await Http.getJson(this.stateUrl);
this.render(data.farm);
} catch (error) {
return;
}
}
_bindActions() {
this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form[data-game-action]");
if (!form) return;
event.preventDefault();
this._submit(form);
});
}
async _submit(form) {
const action = form.dataset.gameAction;
const params = {};
form.querySelectorAll("[name]").forEach((field) => {
params[field.name] = field.value;
});
const button = form.querySelector("button");
if (button) button.disabled = true;
try {
const data = await Http.send(form.getAttribute("action"), params);
this.render(data.farm);
if (action === "harvest" || action === "water" || action === "prestige") {
this._loadLeaderboard();
}
} catch (error) {
if (button) button.disabled = false;
}
}
render(farm) {
if (!farm) return;
this.farm = farm;
this._renderHud(farm);
this._setHost("[data-game-grid-host]", this._gridHtml(farm));
if (this.mode === "own") {
this._setHost("[data-shop-host]", this._shopHtml(farm));
this._setHost("[data-perk-host]", this._perksHtml(farm));
this._setHost("[data-daily-host]", this._dailyHtml(farm));
this._setHost("[data-quest-host]", this._questsHtml(farm));
}
this._tick();
}
_setHost(selector, html) {
const host = this.root.querySelector(selector);
if (host) host.innerHTML = html;
}
_renderHud(farm) {
const set = (selector, value) => {
const node = this.root.querySelector(selector);
if (node) node.textContent = value;
};
set("[data-hud-coins]", farm.coins);
set("[data-hud-level]", farm.level);
set("[data-hud-ci]", farm.ci_tier);
set("[data-hud-ci-label]", `${farm.ci_label} (${farm.ci_speed}x)`);
set("[data-hud-harvests]", farm.total_harvests);
set("[data-hud-prestige]", farm.prestige);
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
const fill = this.root.querySelector("[data-hud-xp-fill]");
if (fill) {
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
fill.style.width = `${pct}%`;
}
set("[data-hud-xp]", farm.level_is_max ? "max level" : `${farm.level_into} / ${farm.level_span} XP`);
}
_shopHtml(farm) {
const ciUpgrade = farm.ci_next_tier
? `<form method="post" action="/game/upgrade" data-game-action="upgrade"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${farm.ci_next_cost}c)</button></form>`
: "";
const ciText = farm.ci_next_tier
? `Upgrade to ${farm.ci_next_label} for faster builds`
: "CI is at the top tier.";
const prestigeBtn = farm.prestige_available
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. Continue?">Refactor</button></form>`
: "";
const prestigeText = farm.prestige_available
? "Reset your farm for a permanent +25% coin bonus."
: `Reach level ${farm.prestige_min_level} to refactor.`;
return (
`<div class="shop-row"><div class="shop-info"><strong>CI upgrade</strong><span>${ciText}</span></div>${ciUpgrade}</div>` +
`<div class="shop-row"><div class="shop-info"><strong>Refactor (prestige ${farm.prestige})</strong><span>${prestigeText}</span></div>${prestigeBtn}</div>`
);
}
_perksHtml(farm) {
return (farm.perks || [])
.map((perk) => {
const action = perk.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/perk" data-game-action="perk"><input type="hidden" name="perk" value="${perk.key}"><button type="submit" class="btn btn-sm">Upgrade (${perk.cost}c)</button></form>`;
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${perk.icon}</span><strong class="perk-name">${perk.name}</strong><span class="perk-level">Lv ${perk.level}/${perk.max_level}</span><span class="perk-effect">${perk.effect || perk.description}</span>${action}</div>`;
})
.join("");
}
_dailyHtml(farm) {
const action = farm.daily_available
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`
: `<span class="daily-claimed">Claimed today. Come back tomorrow.</span>`;
return `<p class="daily-streak">Streak: <strong>${farm.streak}</strong> day(s)</p>${action}`;
}
_questsHtml(farm) {
const quests = farm.quests || [];
if (!quests.length) return `<li class="game-lb-empty">No quests today.</li>`;
return quests
.map((quest) => {
const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0;
let foot = "";
if (quest.can_claim) {
foot = `<form method="post" action="/game/quests/claim" data-game-action="claim-quest"><input type="hidden" name="quest" value="${quest.kind}"><button type="submit" class="btn btn-sm btn-primary">Claim</button></form>`;
} else if (quest.claimed) {
foot = `<span class="quest-claimed">Done</span>`;
}
return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">+${quest.reward_coins}c</span></div><div class="quest-bar"><span class="quest-fill" style="width:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${quest.progress}/${quest.goal}</span>${foot}</div></li>`;
})
.join("");
}
_gridHtml(farm) {
const cells = farm.plots.map((plot) => this._plotHtml(plot, farm)).join("");
let buy = "";
if (farm.is_owner && farm.plot_count < farm.max_plots) {
buy = `<form class="game-plot game-plot-buy" method="post" action="/game/buy-plot" data-game-action="buy-plot"><button type="submit" class="btn btn-sm">+ New plot (${farm.next_plot_cost}c)</button></form>`;
}
return `<div class="game-grid" data-game-grid>${cells}${buy}</div>`;
}
_plotHtml(plot, farm) {
const head = `<div class="game-plot game-plot-${plot.state}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
let body = "";
if (plot.state === "empty") {
if (farm.is_owner) {
const options = farm.crops
.map((crop) => {
const disabled = crop.locked || crop.cost > farm.coins ? " disabled" : "";
const lock = crop.locked ? ` (Lv ${crop.min_level})` : "";
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${crop.cost}c${lock}</option>`;
})
.join("");
body = `<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><select name="crop" class="plot-crop-select" aria-label="Choose a project to build">${options}</select><button type="submit" class="btn btn-sm btn-primary">Plant</button></form>`;
} else {
body = `<span class="plot-empty-label">Empty plot</span>`;
}
} else if (plot.state === "growing") {
body = `<span class="plot-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span><span class="plot-timer" data-plot-timer data-ready-at="${plot.ready_at}">building</span><span class="plot-water-count">${plot.watered_count}/${plot.max_waters} &#128167;</span>`;
if (plot.can_water) {
body += `<form method="post" action="/game/farm/${farm.owner_username}/water" data-game-action="water"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">&#128167; Water</button></form>`;
}
if (farm.is_owner && plot.fertilize_cost) {
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">&#9889; Fertilize (${plot.fertilize_cost}c)</button></form>`;
}
} else {
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span>`;
if (farm.is_owner) {
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">Harvest +${plot.reward_coins}c</button></form>`;
} else {
body += `<span class="plot-ready-label">Ready</span>`;
}
}
return `${head}${body}</div>`;
}
_startTicker() {
this.ticker = window.setInterval(() => this._tick(), 1000);
}
_tick() {
let flipped = false;
this.root.querySelectorAll("[data-plot-timer]").forEach((node) => {
const readyAt = node.dataset.readyAt;
const remaining = this._remaining(readyAt);
if (remaining <= 0) {
node.textContent = "ready";
flipped = true;
} else {
node.textContent = this._format(remaining);
}
});
if (flipped) this.load();
}
_remaining(iso) {
if (!iso) return 0;
const target = Date.parse(iso);
if (Number.isNaN(target)) return 0;
return Math.max(0, Math.floor((target - Date.now()) / 1000));
}
_format(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
async _loadLeaderboard() {
const list = this.root.querySelector("[data-game-leaderboard]");
if (!list) return;
try {
const data = await Http.getJson("/game/leaderboard");
if (!data.entries || !data.entries.length) {
list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`;
return;
}
list.innerHTML = data.entries
.map(
(entry) =>
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span></li>`
)
.join("");
} catch (error) {
return;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<p class="daily-streak">Streak: <strong>{{ farm.streak }}</strong> day(s)</p>
{% if farm.daily_available %}
<form method="post" action="/game/daily" data-game-action="daily">
<button type="submit" class="btn btn-sm btn-primary">Claim +{{ farm.daily_reward }}c</button>
</form>
{% else %}
<span class="daily-claimed">Claimed today. Come back tomorrow.</span>
{% endif %}
+54
View File
@@ -0,0 +1,54 @@
<div class="game-grid" data-game-grid>
{% for plot in farm.plots %}
<div class="game-plot game-plot-{{ plot.state }}" data-slot="{{ plot.slot }}" data-state="{{ plot.state }}" data-ready-at="{{ plot.ready_at }}">
{% if plot.state == 'empty' %}
{% if farm.is_owner %}
<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant">
<input type="hidden" name="slot" value="{{ plot.slot }}">
<select name="crop" class="plot-crop-select" aria-label="Choose a project to build">
{% for crop in farm.crops %}
<option value="{{ crop.key }}"{% if crop.locked or crop.cost > farm.coins %} disabled{% endif %}>{{ crop.icon }} {{ crop.name }} - {{ crop.cost }}c{% if crop.locked %} (Lv {{ crop.min_level }}){% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-primary">Plant</button>
</form>
{% else %}
<span class="plot-empty-label">Empty plot</span>
{% endif %}
{% elif plot.state == 'growing' %}
<span class="plot-icon" aria-hidden="true">{{ plot.crop_icon }}</span>
<span class="plot-crop-name">{{ plot.crop_name }}</span>
<span class="plot-timer" data-plot-timer data-ready-at="{{ plot.ready_at }}">building</span>
<span class="plot-water-count">{{ plot.watered_count }}/{{ plot.max_waters }} &#128167;</span>
{% if plot.can_water %}
<form method="post" action="/game/farm/{{ farm.owner_username }}/water" data-game-action="water">
<input type="hidden" name="slot" value="{{ plot.slot }}">
<button type="submit" class="btn btn-sm">&#128167; Water</button>
</form>
{% endif %}
{% if farm.is_owner and plot.fertilize_cost %}
<form method="post" action="/game/fertilize" data-game-action="fertilize">
<input type="hidden" name="slot" value="{{ plot.slot }}">
<button type="submit" class="btn btn-sm">&#9889; Fertilize ({{ plot.fertilize_cost }}c)</button>
</form>
{% endif %}
{% else %}
<span class="plot-icon plot-ready-icon" aria-hidden="true">{{ plot.crop_icon }}</span>
<span class="plot-crop-name">{{ plot.crop_name }}</span>
{% if farm.is_owner %}
<form method="post" action="/game/harvest" data-game-action="harvest">
<input type="hidden" name="slot" value="{{ plot.slot }}">
<button type="submit" class="btn btn-sm btn-primary">Harvest +{{ plot.reward_coins }}c</button>
</form>
{% else %}
<span class="plot-ready-label">Ready</span>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% if farm.is_owner and farm.plot_count < farm.max_plots %}
<form class="game-plot game-plot-buy" method="post" action="/game/buy-plot" data-game-action="buy-plot">
<button type="submit" class="btn btn-sm">+ New plot ({{ farm.next_plot_cost }}c)</button>
</form>
{% endif %}
</div>
+16
View File
@@ -0,0 +1,16 @@
{% for perk in farm.perks %}
<div class="perk-card">
<span class="perk-icon" aria-hidden="true">{{ perk.icon }}</span>
<strong class="perk-name">{{ perk.name }}</strong>
<span class="perk-level">Lv {{ perk.level }}/{{ perk.max_level }}</span>
<span class="perk-effect">{{ perk.effect or perk.description }}</span>
{% if perk.maxed %}
<span class="perk-maxed">Maxed</span>
{% else %}
<form method="post" action="/game/perk" data-game-action="perk">
<input type="hidden" name="perk" value="{{ perk.key }}">
<button type="submit" class="btn btn-sm">Upgrade ({{ perk.cost }}c)</button>
</form>
{% endif %}
</div>
{% endfor %}
+19
View File
@@ -0,0 +1,19 @@
{% for quest in farm.quests %}
<li class="quest-item{% if quest.claimed %} quest-done{% endif %}">
<div class="quest-head"><span>{{ quest.label }}</span><span class="quest-reward">+{{ quest.reward_coins }}c</span></div>
<div class="quest-bar"><span class="quest-fill" style="width: {% if quest.goal %}{{ (100 * quest.progress / quest.goal)|round(0, 'floor') }}{% else %}0{% endif %}%"></span></div>
<div class="quest-foot">
<span class="quest-progress">{{ quest.progress }}/{{ quest.goal }}</span>
{% if quest.can_claim %}
<form method="post" action="/game/quests/claim" data-game-action="claim-quest">
<input type="hidden" name="quest" value="{{ quest.kind }}">
<button type="submit" class="btn btn-sm btn-primary">Claim</button>
</form>
{% elif quest.claimed %}
<span class="quest-claimed">Done</span>
{% endif %}
</div>
</li>
{% else %}
<li class="game-lb-empty">No quests today.</li>
{% endfor %}
+22
View File
@@ -0,0 +1,22 @@
<div class="shop-row">
<div class="shop-info">
<strong>CI upgrade</strong>
<span>{% if farm.ci_next_tier %}Upgrade to {{ farm.ci_next_label }} for faster builds{% else %}CI is at the top tier.{% endif %}</span>
</div>
{% if farm.ci_next_tier %}
<form method="post" action="/game/upgrade" data-game-action="upgrade">
<button type="submit" class="btn btn-sm btn-primary">Upgrade ({{ farm.ci_next_cost }}c)</button>
</form>
{% endif %}
</div>
<div class="shop-row">
<div class="shop-info">
<strong>Refactor (prestige {{ farm.prestige }})</strong>
<span>{% if farm.prestige_available %}Reset your farm for a permanent +25% coin bonus.{% else %}Reach level {{ farm.prestige_min_level }} to refactor.{% endif %}</span>
</div>
{% if farm.prestige_available %}
<form method="post" action="/game/prestige" data-game-action="prestige">
<button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. Continue?">Refactor</button>
</form>
{% endif %}
</div>
+2
View File
@@ -72,6 +72,7 @@
<a href="/gists" class="topnav-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}<a href="/game" class="topnav-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
<div class="topnav-tools-dropdown {{ nav_active(request, '/tools') }}">
<button type="button" class="topnav-link topnav-tools-toggle" aria-expanded="false" aria-controls="tools-menu"><span class="icon">🧰</span> Tools <span class="topnav-caret"></span></button>
<div class="dropdown-menu" id="tools-menu">
@@ -131,6 +132,7 @@
<a href="/gists" class="topnav-mobile-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}<a href="/game" class="topnav-mobile-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-section-label">Tools</div>
<a href="/tools/seo" class="topnav-mobile-link {{ nav_active(request, '/tools/seo') }}"><span class="icon">🔍</span> SEO Diagnostics</a>
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/game.css') }}">
{% endblock %}
{% block content %}
<div class="game-page" data-game-root data-mode="own" data-username="{{ farm.owner_username }}">
<header class="game-header">
<div class="game-title">
<h1>Code Farm</h1>
<p class="game-subtitle">Plant projects, let them build, harvest coins and XP, complete quests, upgrade perks, and water your friends' builds.</p>
</div>
</header>
<section class="game-hud" data-game-hud aria-label="Farm status">
<div class="hud-stat">
<span class="hud-label">Coins</span>
<span class="hud-value" data-hud-coins>{{ farm.coins }}</span>
</div>
<div class="hud-stat">
<span class="hud-label">Level</span>
<span class="hud-value" data-hud-level>{{ farm.level }}</span>
<div class="hud-xp-bar"><span class="hud-xp-fill" data-hud-xp-fill style="width: {% if farm.level_span %}{{ (100 * farm.level_into / farm.level_span)|round(0, 'floor') }}{% else %}100{% endif %}%"></span></div>
<span class="hud-sub" data-hud-xp>{% if farm.level_is_max %}max level{% else %}{{ farm.level_into }} / {{ farm.level_span }} XP{% endif %}</span>
</div>
<div class="hud-stat">
<span class="hud-label">CI Tier</span>
<span class="hud-value" data-hud-ci>{{ farm.ci_tier }}</span>
<span class="hud-sub" data-hud-ci-label>{{ farm.ci_label }} ({{ farm.ci_speed }}x)</span>
</div>
<div class="hud-stat">
<span class="hud-label">Prestige</span>
<span class="hud-value" data-hud-prestige>{{ farm.prestige }}</span>
<span class="hud-sub" data-hud-prestige-mult>+{{ (farm.prestige_multiplier * 100 - 100)|round|int }}% coins</span>
</div>
</section>
<div class="game-layout">
<div class="game-main">
<h2 class="game-section-title">Your plots</h2>
<div data-game-grid-host>
{% include "_game_grid.html" %}
</div>
<section class="game-shop">
<h2 class="game-section-title">Infrastructure</h2>
<div data-shop-host>{% include "_game_shop.html" %}</div>
</section>
<section class="game-perks">
<h2 class="game-section-title">Perks</h2>
<div class="perk-grid" data-perk-host>{% include "_game_perks.html" %}</div>
</section>
</div>
<aside class="game-side">
<section class="game-daily">
<h2 class="game-section-title">Daily bonus</h2>
<div data-daily-host>{% include "_game_daily.html" %}</div>
</section>
<section class="game-quests">
<h2 class="game-section-title">Daily quests</h2>
<ul class="quest-list" data-quest-host>{% include "_game_quests.html" %}</ul>
</section>
<section class="game-lb-section">
<h2 class="game-section-title">Top farmers</h2>
<ol class="game-leaderboard" data-game-leaderboard>
<li class="game-lb-empty">Loading.</li>
</ol>
</section>
</aside>
</div>
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/game.css') }}">
{% endblock %}
{% block content %}
<div class="game-page" data-game-root data-mode="view" data-username="{{ farm.owner_username }}">
<header class="game-header">
<div class="game-title">
<h1>{{ farm.owner_username }}'s Code Farm</h1>
<p class="game-subtitle">Water their growing builds to speed them up and earn coins yourself.</p>
</div>
<a href="/game" class="btn btn-sm">Back to your farm</a>
</header>
<section class="game-hud" data-game-hud aria-label="Farm status">
<div class="hud-stat">
<span class="hud-label">Level</span>
<span class="hud-value" data-hud-level>{{ farm.level }}</span>
</div>
<div class="hud-stat">
<span class="hud-label">CI Tier</span>
<span class="hud-value" data-hud-ci>{{ farm.ci_tier }}</span>
<span class="hud-sub" data-hud-ci-label>{{ farm.ci_label }}</span>
</div>
<div class="hud-stat">
<span class="hud-label">Harvests</span>
<span class="hud-value" data-hud-harvests>{{ farm.total_harvests }}</span>
</div>
</section>
<div class="game-main">
<h2 class="game-section-title">Plots</h2>
<div data-game-grid-host>
{% include "_game_grid.html" %}
</div>
</div>
</div>
{% endblock %}
+15 -1
View File
@@ -11,6 +11,7 @@ import secrets
import logging
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from urllib.parse import urlsplit
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
@@ -261,7 +262,14 @@ def safe_next(value, default="/feed"):
def redirect_back(request: Request, default: str = "/feed") -> str:
return safe_next(request.headers.get("referer"), default)
referer = request.headers.get("referer") or ""
parts = urlsplit(referer)
if parts.scheme and parts.netloc:
if parts.netloc == urlsplit(str(request.base_url)).netloc:
referer = parts.path + (f"?{parts.query}" if parts.query else "")
else:
referer = ""
return safe_next(referer, default)
def cookie_secure(request: Request) -> bool:
@@ -680,6 +688,9 @@ BADGE_CATALOG = {
"Bug Reporter": {"icon": "🐛", "description": "Filed your first issue", "group": "Community"},
"Pollster": {"icon": "🗳", "description": "Voted in a poll for the first time", "group": "Engagement"},
"Profiled": {"icon": "📇", "description": "Customized your profile", "group": "First steps"},
"Green Thumb": {"icon": "🌱", "description": "Harvested your first build on the Code Farm", "group": "Code Farm"},
"Master Farmer": {"icon": "🌾", "description": "Harvested 50 builds on the Code Farm", "group": "Code Farm"},
"Good Neighbor": {"icon": "💧", "description": "Watered a neighbour's build", "group": "Code Farm"},
}
BADGE_GROUPS = [
@@ -690,6 +701,7 @@ BADGE_GROUPS = [
"Community",
"Reputation",
"Dedication",
"Code Farm",
"Levels",
"Milestones",
]
@@ -907,6 +919,8 @@ ACHIEVEMENTS = {
"issue": [(1, "Bug Reporter")],
"poll": [(1, "Pollster")],
"profile": [(1, "Profiled")],
"harvest": [(1, "Green Thumb"), (50, "Master Farmer")],
"water": [(1, "Good Neighbor")],
}
UNIQUE_ACTIONS = {"docs.read"}