Code Farm economy rebalance: market saturation, infrastructure/defense/cosmetics, mastery track, secondary leaderboards, admin eras, underdog bonus and weekly contracts

Every purchase/upgrade path (new and pre-existing) is now race-safe against concurrent requests via atomic conditional SQL updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:55:46 +02:00
co-authored by Claude Sonnet 5
parent 024edb5291
commit 34f76aad65
54 changed files with 3886 additions and 164 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ Prefixes are wired in `main.py`:
| `/follow` | follow.py |
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
| `/admin/services` | admin/services.py |
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py |
@@ -43,7 +43,7 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
+2
View File
@@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
game,
gateway_configs,
issues,
media,
@@ -36,5 +37,6 @@ router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(gateway_configs.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
+97
View File
@@ -0,0 +1,97 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from devplacepy.models import GameEraStartForm
from devplacepy.responses import respond, action_result, json_error, wants_json
from devplacepy.schemas import AdminGameOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.game import GameError, store
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _era_context() -> dict:
era = store.active_era()
return {
"era_active": bool(era),
"era_name": era["name"] if era else "",
"era_number": int(era["era_number"]) if era else 0,
"era_started_at": era["started_at"] if era else "",
"era_ends_at": era["ends_at"] if era else "",
}
@router.get("/game", response_class=HTMLResponse)
async def admin_game(request: Request):
admin = require_admin(request)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Code Farm - Admin",
description="Manage Code Farm Eras.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Code Farm", "url": "/admin/game"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_game.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "game",
**_era_context(),
},
model=AdminGameOut,
)
@router.post("/game/era/start")
async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]):
admin = require_admin(request)
try:
era = store.start_era(data.name, data.duration_days)
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to start Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_start",
user=admin,
metadata={"era_number": era["era_number"], "name": era["name"]},
summary=f"admin {admin['username']} started Era {era['name']}",
)
return action_result(request, "/admin/game")
@router.post("/game/era/end")
async def admin_game_era_end(request: Request):
admin = require_admin(request)
try:
result = store.end_era()
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to end Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_end",
user=admin,
metadata=result,
summary=f"admin {admin['username']} ended Era {result['era_number']}",
)
return action_result(request, "/admin/game")
+2
View File
@@ -88,6 +88,8 @@ async def steal_farm(
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
track_action(viewer["uid"], "harvest_stolen")
track_action(owner["uid"], "got_stolen_from")
if result.get("underdog_triggered"):
track_action(viewer["uid"], "underdog_raid")
create_notification(
owner["uid"],
"harvest_stolen",
+56 -3
View File
@@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
GameCosmeticForm,
GameInfraForm,
GameLegacyForm,
GameMasteryForm,
GamePerkForm,
GamePlantForm,
GameQuestForm,
@@ -49,9 +52,9 @@ async def game_state(request: Request):
@router.get("/leaderboard")
async def game_leaderboard(request: Request):
async def game_leaderboard(request: Request, board: str = "score"):
get_current_user(request)
entries = store.leaderboard(25)
entries = store.leaderboard_for(board, 25)
return JSONResponse(
GameLeaderboardOut(entries=entries).model_dump(mode="json")
)
@@ -149,5 +152,55 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
award_rewards(user["uid"], result.get("reward_xp", 0))
return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest), reward
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
)
@router.post("/defense/upgrade")
async def game_upgrade_defense(request: Request):
user = require_user(request)
def reward(result):
track_action(user["uid"], "defense_upgraded")
return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward)
@router.post("/infrastructure/buy")
async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "infra_bought")
return await _respond_action(
request, user, lambda: store.buy_infrastructure(user, data.key), reward
)
@router.post("/mastery")
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.upgrade_mastery(user, data.key)
)
@router.post("/cosmetics/buy")
async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "cosmetic_bought")
return await _respond_action(
request, user, lambda: store.buy_cosmetic(user, data.key), reward
)
@router.post("/cosmetics/equip")
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.equip_title(user, data.key)
)