feat: add Code Farm cooperative idle game with plot-based crop system and pub/sub notifications
Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import farm, index
|
||||
|
||||
router = index.router
|
||||
router.include_router(farm.router)
|
||||
@@ -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"},
|
||||
],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user