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
+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
)