ipdatepppdate

This commit is contained in:
retoor 2026-07-23 19:09:49 +02:00
parent 2620ecc0f1
commit ac04cf6817
30 changed files with 915 additions and 10309 deletions

File diff suppressed because one or more lines are too long

View File

@ -70,6 +70,10 @@ devplace deepsearch clear # delete every DeepSearch session + job row + collec
devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists) devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists)
devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist) devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist)
devplace isslop clear # delete every AI usage analysis, its report and job rows devplace isslop clear # delete every AI usage analysis, its report and job rows
devplace game market prune # delete Code Farm market tick buckets older than the tracking window
devplace game era status # show the current Code Farm Era
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
devplace backups list # list recorded backups devplace backups list # list recorded backups
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server) devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
devplace backups prune # remove backup records whose archive file is missing devplace backups prune # remove backup records whose archive file is missing

View File

@ -131,15 +131,15 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins. - **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative. - **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming. - **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops get a relief buff (up to +15%) while the market is saturated - printing one crop nonstop is throttled, diversity is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop. - **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (raises the minimum you keep when raided). - **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (fixes the raid payout floor at 30% of a build's value).
- **Defense.** An upgradeable building that reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth); if unpaid, the tier decays automatically. - **Defense.** An upgradeable building that reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth); if unpaid, the tier decays automatically.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard. - **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 10 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids). - **Mastery (endgame beyond prestige).** From prestige 50 onward, every 10 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board. - **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start a Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results. - **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**. The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.
## Engagement ## Engagement

View File

@ -1,580 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "containers",
"title": "Container Manager",
"admin": True,
"intro": """
# Container Manager
Run supervised container instances for a project. There is no in-app image building: every instance
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page",
method="GET",
path="/projects/{project_slug}/containers",
title="Container manager page",
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
auth="admin",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
),
endpoint(
id="containers-admin-index",
method="GET",
path="/admin/containers",
title="Admin containers list",
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
auth="admin",
interactive=True,
),
endpoint(
id="containers-admin-data",
method="GET",
path="/admin/containers/data",
title="Admin containers list data",
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={
"instances": [
{
"uid": "INSTANCE_UID",
"name": "staging",
"status": "running",
"project_slug": "PROJECT_SLUG",
"project_title": "My Project",
"ingress_slug": "my-service",
"restart_policy": "always",
}
]
},
),
endpoint(
id="containers-admin-instance",
method="GET",
path="/admin/containers/{uid}",
title="Instance detail page",
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-admin-edit-page",
method="GET",
path="/admin/containers/{uid}/edit",
title="Edit instance page",
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-create-instance",
method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance",
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("name", "form", "string", True, "staging", "Instance name."),
field(
"boot_command",
"form",
"string",
False,
"python app.py",
"Optional boot command.",
),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"env",
"form",
"textarea",
False,
"KEY=VALUE",
"Env vars, one KEY=VALUE per line.",
),
field(
"ports",
"form",
"string",
False,
"80",
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field(
"mem_limit", "form", "string", False, "512m", "Memory limit."
),
field(
"restart_policy",
"form",
"enum",
False,
"never",
"Restart policy.",
["never", "always", "on-failure", "unless-stopped"],
),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field(
"ingress_slug",
"form",
"string",
False,
"my-service",
"Publish at /p/<slug> (optional).",
),
field(
"ingress_port",
"form",
"integer",
False,
"8899",
"Container port to publish (must be a mapped port).",
),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[
field(
"slug",
"path",
"string",
True,
"my-service",
"The instance's ingress_slug.",
)
],
),
endpoint(
id="containers-instance-action",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle",
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"path",
"enum",
True,
"start",
"Lifecycle action.",
["start", "stop", "restart", "pause", "resume"],
),
],
),
endpoint(
id="containers-instance-logs",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs",
summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-instance-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/delete",
title="Delete instance",
summary="Remove a container instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
),
endpoint(
id="containers-instance-exec",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/exec",
title="Exec a command",
summary="Run a one-shot command inside a running instance and return its output.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"command",
"form",
"string",
True,
"ls -la /app",
"Shell command to run (via /bin/sh -c).",
),
],
),
endpoint(
id="containers-instance-data",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}",
title="Instance detail data",
summary="Return the full instance row plus runtime info as JSON.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
),
endpoint(
id="containers-instance-metrics",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
title="Instance metrics",
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"metrics": [], "stats": {}},
),
endpoint(
id="containers-instance-schedules",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
title="Create a schedule",
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"form",
"string",
True,
"start",
"Lifecycle action to run on schedule (start, stop, restart).",
),
field(
"kind",
"form",
"string",
True,
"cron",
"Schedule kind: cron, once, interval, or delay.",
),
field(
"cron",
"form",
"string",
False,
"0 * * * *",
"Cron expression (when kind is cron).",
),
field(
"run_at",
"form",
"string",
False,
"2026-01-01T00:00:00",
"ISO timestamp for a one-time run (when kind is once).",
),
field(
"delay_seconds",
"form",
"integer",
False,
"60",
"Seconds to wait before a single run (when kind is delay).",
),
field(
"every_seconds",
"form",
"integer",
False,
"300",
"Interval in seconds between runs (when kind is interval).",
),
field(
"max_runs",
"form",
"integer",
False,
"10",
"Optional cap on the number of runs.",
),
],
),
endpoint(
id="containers-instance-schedule-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
title="Delete a schedule",
summary="Remove a schedule from an instance.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
),
],
),
endpoint(
id="containers-admin-create",
method="POST",
path="/admin/containers/create",
title="Admin create instance",
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
field("name", "form", "string", True, "staging", "Instance name."),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
],
),
endpoint(
id="containers-admin-edit",
method="POST",
path="/admin/containers/{uid}/edit",
title="Admin edit instance",
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
],
),
endpoint(
id="containers-admin-action",
method="POST",
path="/admin/containers/{uid}/{action}",
title="Admin instance lifecycle",
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-admin-sync",
method="POST",
path="/admin/containers/{uid}/sync",
title="Admin bidirectional sync",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-admin-delete",
method="POST",
path="/admin/containers/{uid}/delete",
title="Admin delete instance",
summary="Soft-delete an instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
endpoint(
id="containers-admin-project-search",
method="GET",
path="/admin/containers/projects/search",
title="Admin project search",
summary="Search projects by title for the admin create form (returns uid, slug, title).",
auth="admin",
params=[
field("q", "query", "string", False, "api", "Title fragment."),
],
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
),
endpoint(
id="containers-admin-user-search",
method="GET",
path="/admin/containers/users/search",
title="Admin run-as user search",
summary="Search users by username for the run-as-user select (returns uid, username).",
auth="admin",
params=[
field("q", "query", "string", False, "alice", "Username fragment."),
],
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
),
],
}

View File

@ -2,6 +2,27 @@
from .._shared import endpoint, field from .._shared import endpoint, field
CROP_KEYS = [
"shell",
"python",
"webapp",
"api",
"rust",
"haskell",
"kernel",
"distsys",
"mlpipe",
"secfort",
]
PERK_KEYS = ["yield", "growth", "discount", "xp"]
QUEST_KINDS = ["plant", "harvest", "water", "earn"]
QUEST_SCOPES = ["daily", "weekly"]
LEGACY_KEYS = ["autoharvest", "multiplier", "speed", "plots", "defense", "carryover"]
MASTERY_KEYS = ["autoreplant", "analytics", "contracts"]
INFRA_KEYS = ["registry", "canary", "observability"]
COSMETIC_KEYS = ["title_architect", "title_refactorer", "title_kernel_hacker", "skin_neon"]
BOARD_KEYS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"]
GROUP = { GROUP = {
"slug": "game", "slug": "game",
"title": "Code Farm", "title": "Code Farm",
@ -15,8 +36,16 @@ faster builds, and waters other members' growing builds to speed them up and ear
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth; Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
the fees fill a community treasury from which active low-balance farms can claim a weekly grant. the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a All endpoints negotiate HTML or JSON. POST bodies are form encoded
client can refresh without a second request. (`application/x-www-form-urlencoded`). Every own-farm action returns `{"ok": true, "farm": {...}}`
- the full updated farm state - so a client can refresh without a second request; the two
neighbour actions (water, steal) return the neighbour's farm as `{"farm": {...}}`, and a
successful steal adds `stole_coins`. An invalid action (not enough coins, wrong plot state, a
protected harvest, an active cooldown) returns HTTP 400 as
`{"error": {"status": 400, "message": "..."}}`; an unknown farm username is 404. Reading your
own farm state also runs lazy owner effects: the CI Bot legacy upgrade auto-harvests ready
builds, and any due Defense upkeep is charged. The complete rules, formulas, and an automated
client are on the [Code Farm guide](/docs/code-farm.html).
""", """,
"endpoints": [ "endpoints": [
endpoint( endpoint(
@ -34,7 +63,7 @@ client can refresh without a second request.
method="GET", method="GET",
path="/game/state", path="/game/state",
title="Farm state", title="Farm state",
summary="The signed-in player's full farm state as JSON.", summary="The signed-in player's full farm state as JSON. Reading it auto-collects ready builds (with the CI Bot legacy upgrade) and charges any due Defense upkeep.",
auth="user", auth="user",
sample_response={ sample_response={
"ok": True, "ok": True,
@ -43,8 +72,21 @@ client can refresh without a second request.
"level": 1, "level": 1,
"ci_tier": 1, "ci_tier": 1,
"plot_count": 4, "plot_count": 4,
"prestige": 0,
"stars": 0,
"refactor_cost": 20000,
"plots": [{"slot": 0, "state": "empty"}], "plots": [{"slot": 0, "state": "empty"}],
"crops": [{"key": "python", "name": "Python Script", "cost": 15}], "crops": [
{
"key": "python",
"name": "Python Script",
"cost": 15,
"reward_coins": 36,
"grow_seconds": 120,
"locked": False,
"market_state": "normal",
}
],
}, },
}, },
), ),
@ -53,17 +95,41 @@ client can refresh without a second request.
method="GET", method="GET",
path="/game/leaderboard", path="/game/leaderboard",
title="Farm leaderboard", title="Farm leaderboard",
summary="Top farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running).", summary="Top 25 farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid over 30 days, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running). Cached about 15 seconds.",
auth="public", auth="public",
params=[field("board", "query", "string", False, "score", "Leaderboard board key.")], params=[
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4, "score": 1000}]}, field(
"board",
"query",
"string",
False,
"score",
"Leaderboard board key.",
options=BOARD_KEYS,
)
],
sample_response={
"entries": [
{
"rank": 1,
"username": "alice",
"level": 4,
"xp": 600,
"coins": 240,
"total_harvests": 52,
"prestige": 1,
"score": 6120,
"title": "The Architect",
}
]
},
), ),
endpoint( endpoint(
id="game-view-farm", id="game-view-farm",
method="GET", method="GET",
path="/game/farm/{username}", path="/game/farm/{username}",
title="View a farm", title="View a farm",
summary="Another player's farm, with water controls on growing builds.", summary="Another player's farm, with per-plot can_water/can_steal flags computed for the viewer.",
auth="public", auth="public",
negotiation=True, negotiation=True,
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")], params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
@ -74,11 +140,11 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/plant", path="/game/plant",
title="Plant a crop", title="Plant a crop",
summary="Plant a crop in an empty plot. Costs the crop's coin price.", summary="Plant a crop in an empty plot. Costs the crop's live coin price (the cost field in the farm state's crops list).",
auth="user", auth="user",
params=[ params=[
field("slot", "form", "integer", True, "0", "Plot slot index."), field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
field("crop", "form", "string", True, "python", "Crop key."), field("crop", "form", "string", True, "python", "Crop key.", options=CROP_KEYS),
], ],
sample_response={"ok": True, "farm": {"coins": 35}}, sample_response={"ok": True, "farm": {"coins": 35}},
), ),
@ -87,9 +153,9 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/harvest", path="/game/harvest",
title="Harvest a build", title="Harvest a build",
summary="Harvest a finished build for coins and XP.", summary="Harvest a finished (state ready) build for coins and XP.",
auth="user", auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")], params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
sample_response={"ok": True, "farm": {"coins": 86}}, sample_response={"ok": True, "farm": {"coins": 86}},
), ),
endpoint( endpoint(
@ -97,7 +163,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/buy-plot", path="/game/buy-plot",
title="Buy a plot", title="Buy a plot",
summary="Unlock a new plot. Cost doubles per extra plot.", summary="Unlock a new plot (up to 12). Cost starts at 100 coins and doubles per extra plot; the exact price is the farm state's next_plot_cost.",
auth="user", auth="user",
sample_response={"ok": True, "farm": {"plot_count": 5}}, sample_response={"ok": True, "farm": {"plot_count": 5}},
), ),
@ -106,7 +172,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/upgrade", path="/game/upgrade",
title="Upgrade CI", title="Upgrade CI",
summary="Upgrade the farm CI tier for faster builds.", summary="Upgrade the farm CI tier for faster builds (up to tier 5); the exact price is the farm state's ci_next_cost.",
auth="user", auth="user",
sample_response={"ok": True, "farm": {"ci_tier": 2}}, sample_response={"ok": True, "farm": {"ci_tier": 2}},
), ),
@ -115,11 +181,11 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/farm/{username}/water", path="/game/farm/{username}/water",
title="Water a build", title="Water a build",
summary="Water another player's growing build to speed it up and earn coins.", summary="Water another player's growing build to cut 8% off its build time; pays the visitor 6 coins and 3 XP. Once per visitor per build, 3 waterings per build total.",
auth="user", auth="user",
params=[ params=[
field("username", "path", "string", True, "alice", "Farm owner's username."), field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."), field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
], ],
sample_response={"farm": {"owner_username": "alice"}}, sample_response={"farm": {"owner_username": "alice"}},
), ),
@ -128,11 +194,11 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/farm/{username}/steal", path="/game/farm/{username}/steal",
title="Steal a build", title="Steal a build",
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.", summary="Steal another player's ready build once its protection window has passed; you receive a fraction of the build's coin value (half by default, less against defended owners - the plot's steal_coins field is the exact payout). Limited to once per hour per neighbour; Security Fortress builds are immune.",
auth="user", auth="user",
params=[ params=[
field("username", "path", "string", True, "alice", "Farm owner's username."), field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."), field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
], ],
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18}, sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
), ),
@ -141,9 +207,9 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/fertilize", path="/game/fertilize",
title="Fertilize a build", title="Fertilize a build",
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.", summary="Spend coins to halve a growing build's remaining time (the plot's fertilize_cost field is the exact price). The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
auth="user", auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")], params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
sample_response={"ok": True, "farm": {"coins": 12}}, sample_response={"ok": True, "farm": {"coins": 12}},
), ),
endpoint( endpoint(
@ -151,7 +217,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/daily", path="/game/daily",
title="Claim daily bonus", title="Claim daily bonus",
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.", summary="Claim the once-per-UTC-day coin bonus; consecutive days grow a streak (20 coins on day one up to 92 from day seven on).",
auth="user", auth="user",
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}}, sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
), ),
@ -160,9 +226,9 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/perk", path="/game/perk",
title="Upgrade a perk", title="Upgrade a perk",
summary="Upgrade a permanent perk: yield, growth, discount, or xp.", summary="Upgrade a permanent perk with coins: yield (+5% harvest coins), growth (+4% build speed), discount (-3% planting cost), or xp (+5% harvest XP) per level. Perks reset on refactor.",
auth="user", auth="user",
params=[field("perk", "form", "string", True, "growth", "Perk key.")], params=[field("perk", "form", "string", True, "growth", "Perk key.", options=PERK_KEYS)],
sample_response={"ok": True, "farm": {"coins": 0}}, sample_response={"ok": True, "farm": {"coins": 0}},
), ),
endpoint( endpoint(
@ -170,11 +236,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/quests/claim", path="/game/quests/claim",
title="Claim a quest", title="Claim a quest",
summary="Claim a completed daily quest, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a temporary coin boost instead of coins/XP.", summary="Claim a completed daily quest by its kind, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a 48-hour +20% coin boost instead of coins.",
auth="user", auth="user",
params=[ params=[
field("quest", "form", "string", True, "harvest", "Quest kind."), field("quest", "form", "string", True, "harvest", "Quest kind.", options=QUEST_KINDS),
field("scope", "form", "string", False, "daily", "daily (default) or weekly."), field(
"scope",
"form",
"string",
False,
"daily",
"daily (default) or weekly.",
options=QUEST_SCOPES,
),
], ],
sample_response={"ok": True, "farm": {"coins": 130}}, sample_response={"ok": True, "farm": {"coins": 130}},
), ),
@ -183,7 +257,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/prestige", path="/game/prestige",
title="Refactor (prestige)", title="Refactor (prestige)",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, more with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.", summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, up to 35% with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
auth="user", auth="user",
destructive=True, destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}}, sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
@ -193,7 +267,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/grant", path="/game/grant",
title="Claim the community grant", title="Claim the community grant",
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees. Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.", summary="Claim the weekly community grant (up to 2500 coins), paid from the treasury filled by refactor fees. Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
auth="user", auth="user",
sample_response={"ok": True, "farm": {"coins": 2550}}, sample_response={"ok": True, "farm": {"coins": 2550}},
), ),
@ -202,9 +276,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/legacy", path="/game/legacy",
title="Buy a Legacy upgrade", title="Buy a Legacy upgrade",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, defense, or carryover (Golden Parachute, raises the refactor coin carry-over).", summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest (CI Bot), multiplier (+10% coins/level), speed (+5% build speed/level), plots (+1 starting plot/level), defense (+30s grace, -5% steal loss/level), or carryover (Golden Parachute, +5% refactor carry-over/level).",
auth="user", auth="user",
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")], params=[
field(
"key",
"form",
"string",
True,
"multiplier",
"Legacy upgrade key.",
options=LEGACY_KEYS,
)
],
sample_response={"ok": True, "farm": {"stars": 1}}, sample_response={"ok": True, "farm": {"stars": 1}},
), ),
endpoint( endpoint(
@ -212,9 +296,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/mastery", path="/game/mastery",
title="Buy a Mastery upgrade", title="Buy a Mastery upgrade",
summary="Spend Mastery points (earned every 10 prestige past 50) on a permanent Mastery upgrade: autoreplant, analytics, or contracts.", summary="Spend Mastery points (earned at prestige 50 and every 10 prestige after) on a permanent Mastery upgrade: autoreplant (Continuous Delivery, 3 points), analytics (Farm Analytics, 2 points), or contracts (Legacy Contracts, 4 points).",
auth="user", auth="user",
params=[field("key", "form", "string", True, "autoreplant", "Mastery upgrade key.")], params=[
field(
"key",
"form",
"string",
True,
"autoreplant",
"Mastery upgrade key.",
options=MASTERY_KEYS,
)
],
sample_response={"ok": True, "farm": {"mastery_points": 0}}, sample_response={"ok": True, "farm": {"mastery_points": 0}},
), ),
endpoint( endpoint(
@ -222,9 +316,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/infrastructure/buy", path="/game/infrastructure/buy",
title="Buy Infrastructure", title="Buy Infrastructure",
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (faster rare crops), canary (double/refund harvest chance), or observability (raises the minimum you keep when raided).", summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (Rust/Compiler/Kernel build 15% faster; 25M coins, prestige 3), canary (12% chance to double a harvest, 6% to only refund its planting cost; 75M, prestige 8), or observability (fixes the raid payout floor at 30%; 150M, prestige 15).",
auth="user", auth="user",
params=[field("key", "form", "string", True, "registry", "Infrastructure key.")], params=[
field(
"key",
"form",
"string",
True,
"registry",
"Infrastructure key.",
options=INFRA_KEYS,
)
],
sample_response={"ok": True, "farm": {"coins": 0}}, sample_response={"ok": True, "farm": {"coins": 0}},
), ),
endpoint( endpoint(
@ -232,7 +336,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/defense/upgrade", path="/game/defense/upgrade",
title="Upgrade Defense", title="Upgrade Defense",
summary="Buy the next Defense tier. Reduces raid losses and adds steal grace, but adds an ongoing daily coin upkeep (proportional to your coin balance) - if unpaid, the tier decays.", summary="Buy the next Defense tier (Firewall through Zero Trust Mesh; the farm state's defense_next_cost is the exact price). Each tier lowers the raid payout floor and adds steal grace, but adds an ongoing daily coin upkeep of max(tier minimum, 0.2% of your balance) - if unpaid past 2 days, the tier decays by one level.",
auth="user", auth="user",
sample_response={"ok": True, "farm": {"defense_level": 1}}, sample_response={"ok": True, "farm": {"defense_level": 1}},
), ),
@ -241,9 +345,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/cosmetics/buy", path="/game/cosmetics/buy",
title="Buy a cosmetic", title="Buy a cosmetic",
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect.", summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect. The farm state's cosmetics list carries each key, cost, and an owned flag.",
auth="user", auth="user",
params=[field("key", "form", "string", True, "title_architect", "Cosmetic key.")], params=[
field(
"key",
"form",
"string",
True,
"title_architect",
"Cosmetic key.",
options=COSMETIC_KEYS,
)
],
sample_response={"ok": True, "farm": {"coins": 0}}, sample_response={"ok": True, "farm": {"coins": 0}},
), ),
endpoint( endpoint(
@ -251,9 +365,19 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/cosmetics/equip", path="/game/cosmetics/equip",
title="Equip a title", title="Equip a title",
summary="Equip an owned title cosmetic so it shows next to your name on the leaderboard.", summary="Equip an owned title cosmetic so its display name shows next to your name on the leaderboard.",
auth="user", auth="user",
params=[field("key", "form", "string", True, "title_architect", "An owned title cosmetic key.")], params=[
field(
"key",
"form",
"string",
True,
"title_architect",
"An owned title cosmetic key.",
options=COSMETIC_KEYS,
)
],
sample_response={"ok": True, "farm": {"active_title": "title_architect"}}, sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
), ),
], ],

View File

@ -1,637 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import re
import socket
from pathlib import Path
from devplacepy import config, project_files, stealth
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
Mount,
PortMapping,
RunSpec,
)
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import (
Schedule,
now_utc,
to_iso,
)
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
INSTANCE_LABEL = "devplace.instance"
PROJECT_LABEL = "devplace.project"
HOST_PORT_MIN = 20001
HOST_PORT_MAX = 65535
BOOT_LANGUAGES = ("none", "python", "bash")
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
MAX_BOOT_SCRIPT_CHARS = 100_000
class ContainerError(ValueError):
pass
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
if mem_limit and not MEM_RE.match(str(mem_limit)):
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
def validate_run_as(run_as_uid) -> str:
uid = str(run_as_uid or "").strip()
if not uid:
return ""
from devplacepy import database
user = database.get_users_by_uids([uid]).get(uid)
if not user:
raise ContainerError(f"run-as user not found: {uid}")
return uid
def validate_boot(boot_language, boot_script) -> tuple:
language = str(boot_language or "none").strip().lower() or "none"
if language not in BOOT_LANGUAGES:
raise ContainerError(
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
)
script = str(boot_script or "")
if language == "none":
script = ""
if len(script) > MAX_BOOT_SCRIPT_CHARS:
raise ContainerError(
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
)
if language != "none" and not script.strip():
raise ContainerError("boot script is required when a boot language is set")
return language, script
def parse_ports(value) -> list:
ports = []
if not value:
return ports
items = (
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
)
for item in items:
item = str(item).strip()
if not item:
continue
proto = "tcp"
if "/" in item:
item, proto = item.split("/", 1)
if ":" in item:
host, container = item.split(":", 1)
else:
host, container = "0", item
if not host.isdigit() or not container.isdigit():
raise ContainerError(
f"port '{item}' must be numeric host:container or a bare container port"
)
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
return ports
def used_host_ports() -> set:
ports = set()
for instance in store.all_instances():
for mapping in json.loads(instance.get("ports_json") or "[]"):
host = int(mapping.get("host") or 0)
if host:
ports.add(host)
return ports
def _host_port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", port))
return True
except OSError:
return False
def allocate_host_port(reserved: set) -> int:
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
if port in reserved:
continue
if _host_port_free(port):
return port
raise ContainerError(
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
)
def assign_host_ports(port_list: list) -> list:
reserved = used_host_ports()
assigned = []
for mapping in port_list:
host = mapping.host
if not host:
host = allocate_host_port(reserved)
elif host in reserved:
raise ContainerError(
f"host port {host} is already published by another instance"
)
reserved.add(host)
assigned.append(PortMapping(host, mapping.container, mapping.proto))
return assigned
def parse_env(value) -> dict:
env = {}
if not value:
return env
if isinstance(value, dict):
items = value.items()
else:
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
for key, val in items:
key = str(key).strip()
if not ENV_KEY_RE.match(key):
raise ContainerError(f"invalid environment variable name: {key}")
env[key] = str(val)
return env
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple:
slug = (slug or "").strip().lower()
if not slug:
return "", 0
if not INGRESS_SLUG_RE.match(slug):
raise ContainerError(
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
)
for other in store.all_instances():
if other.get("ingress_slug") == slug:
raise ContainerError(f"ingress slug '{slug}' is already in use")
ingress_port = int(port) if port else 0
container_ports = {p.container for p in port_list}
if ingress_port and ingress_port not in container_ports:
raise ContainerError(
f"ingress_port {ingress_port} must be one of the container ports you mapped"
)
if not ingress_port and len(container_ports) != 1:
raise ContainerError(
"set ingress_port to choose which mapped container port to publish"
)
return slug, ingress_port
async def create_instance(
project: dict,
*,
name: str,
boot_command: str = "",
boot_language: str = "none",
boot_script: str = "",
run_as_uid: str = "",
start_on_boot: bool = False,
env="",
cpu_limit: str = "",
mem_limit: str = "",
ports="",
volumes="",
restart_policy: str = "never",
autostart: bool = True,
ingress_slug: str = "",
ingress_port=None,
actor=("system", "system"),
) -> dict:
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
raise ContainerError(
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
)
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
_validate_limits(cpu_limit, mem_limit)
run_as_uid = validate_run_as(run_as_uid)
boot_language, boot_script = validate_boot(boot_language, boot_script)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
name = (name or "").strip()
if not name:
raise ContainerError("instance name is required")
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
await asyncio.to_thread(
project_files.export_to_dir, project["uid"], "", str(workspace)
)
row = {
"project_uid": project["uid"],
"created_by": actor[1] if actor and actor[0] == "user" else "",
"owner_uid": project.get("user_uid", ""),
"run_as_uid": run_as_uid,
"name": name,
"boot_command": boot_command or "",
"boot_language": boot_language,
"boot_script": boot_script,
"start_on_boot": 1 if start_on_boot else 0,
"env_json": json.dumps(env_map),
"cpu_limit": str(cpu_limit or ""),
"mem_limit": str(mem_limit or ""),
"ports_json": json.dumps(
[
{"host": p.host, "container": p.container, "proto": p.proto}
for p in port_list
]
),
"volumes_json": volumes
if isinstance(volumes, str)
else json.dumps(volumes or []),
"restart_policy": restart_policy,
"ingress_slug": ingress_slug,
"ingress_port": ingress_port,
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
"status": store.ST_CREATED,
"workspace_dir": str(workspace),
}
instance = store.create_instance(row)
store.record_event(
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
)
if actor and actor[0] == "user":
from devplacepy.utils import track_action
track_action(actor[1], "container")
return instance
def set_desired_state(
instance: dict, desired: str, *, actor=("system", "system")
) -> dict:
if desired not in (
store.DESIRED_RUNNING,
store.DESIRED_STOPPED,
store.DESIRED_PAUSED,
):
raise ContainerError("desired state must be running, stopped, or paused")
store.update_instance(instance["uid"], {"desired_state": desired})
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
return store.get_instance(instance["uid"])
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
)
store.record_event(instance, "restart", actor[0], actor[1])
return store.get_instance(instance["uid"])
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
)
store.record_event(instance, "remove", actor[0], actor[1])
def update_instance_config(
instance: dict,
*,
run_as_uid=None,
boot_language=None,
boot_script=None,
boot_command=None,
restart_policy=None,
start_on_boot=None,
cpu_limit=None,
mem_limit=None,
actor=("system", "system"),
) -> dict:
changes: dict = {}
if run_as_uid is not None:
changes["run_as_uid"] = validate_run_as(run_as_uid)
if boot_language is not None or boot_script is not None:
language = (
boot_language
if boot_language is not None
else instance.get("boot_language", "none")
)
script = (
boot_script if boot_script is not None else instance.get("boot_script", "")
)
language, script = validate_boot(language, script)
changes["boot_language"] = language
changes["boot_script"] = script
if boot_command is not None:
changes["boot_command"] = str(boot_command or "")[:500]
if restart_policy is not None:
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
changes["restart_policy"] = restart_policy
if start_on_boot is not None:
changes["start_on_boot"] = 1 if start_on_boot else 0
if cpu_limit is not None or mem_limit is not None:
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
_validate_limits(cpu, mem)
changes["cpu_limit"] = str(cpu or "")
changes["mem_limit"] = str(mem or "")
if not changes:
return store.get_instance(instance["uid"])
store.update_instance(instance["uid"], changes)
store.record_event(
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
)
return store.get_instance(instance["uid"])
def set_start_on_boot(
instance: dict, enabled: bool, *, actor=("system", "system")
) -> dict:
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
store.record_event(
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
)
return store.get_instance(instance["uid"])
def materialize_boot_script(instance: dict) -> None:
language = (instance.get("boot_language") or "none").strip().lower()
workspace = instance.get("workspace_dir")
if not workspace:
return
for filename in BOOT_SCRIPT_FILES.values():
stale = Path(workspace) / filename
if stale.is_file():
try:
stale.unlink()
except OSError:
pass
if language not in BOOT_SCRIPT_FILES:
return
script = instance.get("boot_script") or ""
if not script.strip():
return
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
try:
Path(workspace).mkdir(parents=True, exist_ok=True)
target.write_text(script, encoding="utf-8")
except OSError:
pass
def pravda_env(instance: dict) -> dict:
from devplacepy import database, seo
base_url = seo.public_base_url()
api_key = ""
user_uid = instance.get("owner_uid") or ""
for uid in (
instance.get("run_as_uid"),
instance.get("created_by"),
instance.get("owner_uid"),
):
if not uid:
continue
user = database.get_users_by_uids([uid]).get(uid)
if user and user.get("api_key"):
api_key = user["api_key"]
break
slug = instance.get("ingress_slug") or ""
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
return {
"PRAVDA_BASE_URL": base_url,
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"PRAVDA_API_KEY": api_key,
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
"PRAVDA_INGRESS_URL": ingress_url,
}
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
for p in json.loads(instance.get("ports_json") or "[]")
]
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
for extra in json.loads(instance.get("volumes_json") or "[]"):
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
mounts.append(
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
elif boot:
command = ["/bin/sh", "-c", boot]
else:
command = ["sleep", "infinity"]
return RunSpec(
image=image_tag,
name=instance["slug"],
labels={
INSTANCE_LABEL: instance["uid"],
PROJECT_LABEL: instance["project_uid"],
},
env=env,
cpu_limit=instance.get("cpu_limit", ""),
mem_limit=instance.get("mem_limit", ""),
ports=ports,
mounts=mounts,
restart_policy=instance.get("restart_policy", "never"),
command=command,
)
async def sync_workspace(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
raise ContainerError("instance has no workspace")
counts = await asyncio.to_thread(
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
)
store.record_event(
instance,
"sync",
"user",
user["uid"],
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
return {"exported": 0, "imported": 0}
counts = project_files.sync_dir_bidirectional(
instance["project_uid"], workspace, user
)
if counts["exported"] or counts["imported"]:
store.record_event(
instance,
"sync",
"service",
"system",
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
if action not in ("start", "stop"):
raise ContainerError("schedule action must be start or stop")
first = schedule.first_run(now_utc())
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
# ---------------- aggregation ----------------
def _percentile(values: list, pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
return float(ordered[index])
def instance_stats(instance_uid: str) -> dict:
metrics = store.recent_metrics(instance_uid, limit=720)
cpu = [m.get("cpu_pct", 0) for m in metrics]
mem = [m.get("mem_bytes", 0) for m in metrics]
return {
"samples": len(metrics),
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
"cpu_p95": round(_percentile(cpu, 95), 2),
"mem_max": max(mem) if mem else 0,
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
}
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try:
with stealth.stealth_sync_client(timeout=timeout) as client:
response = client.get(f"http://{host}:{port}/")
return f"HTTP {response.status_code}"
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
return f"unreachable: {type(exc).__name__}"
def _net_entry(data: dict) -> dict:
net = (data or {}).get("NetworkSettings") or {}
if net.get("IPAddress") or net.get("Gateway"):
return net
for entry in (net.get("Networks") or {}).values():
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
return entry
return {}
def container_ip_from_inspect(data: dict) -> str:
return (_net_entry(data).get("IPAddress") or "").strip()
def container_gateway_from_inspect(data: dict) -> str:
return (_net_entry(data).get("Gateway") or "").strip()
def _ingress_container_port(instance: dict, port_maps: list) -> int:
ingress_port = int(instance.get("ingress_port") or 0)
if ingress_port:
for mapping in port_maps:
if int(mapping.get("container") or 0) == ingress_port:
return ingress_port
return 0
return int(port_maps[0].get("container") or 0) if port_maps else 0
def _host_port_for(port_maps: list, container_port: int) -> int:
for mapping in port_maps:
if int(mapping.get("container") or 0) == container_port:
return int(mapping.get("host") or 0)
return 0
def proxy_target(instance: dict) -> tuple:
port_maps = json.loads(instance.get("ports_json") or "[]")
container_port = _ingress_container_port(instance, port_maps)
if not container_port:
return None, None
host_port = _host_port_for(port_maps, container_port)
if config.CONTAINER_PROXY_HOST:
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
if not host_port:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return (gateway or "127.0.0.1", host_port)
def instance_runtime(instance: dict) -> dict:
boot = (instance.get("boot_command") or "").strip()
port_maps = json.loads(instance.get("ports_json") or "[]")
container_ip = (instance.get("container_ip") or "").strip()
container_gateway = (instance.get("container_gateway") or "").strip()
target_host, target_port = proxy_target(instance)
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
ports = []
for mapping in port_maps:
host_port = int(mapping.get("host") or 0)
ports.append(
{
"container": int(mapping.get("container") or 0),
"host": host_port,
"proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(probe_host, host_port)
if host_port
else False,
}
)
ingress_serving = (
_http_probe(target_host, target_port)
if target_host and target_port
else "no ingress port mapped"
)
return {
"command": boot or "image CMD (no boot_command set)",
"ports": ports,
"container_ip": container_ip,
"container_gateway": container_gateway,
"ingress_target": (
f"{target_host}:{target_port}" if target_host and target_port else ""
),
"ingress_port": int(instance.get("ingress_port") or 0),
"ingress_serving": ingress_serving,
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
"restart_count": int(instance.get("restart_count") or 0),
"exit_code": instance.get("exit_code"),
"container_id": (instance.get("container_id") or "")[:12],
}

View File

@ -1,119 +0,0 @@
" retoor <retoor@molodetz.nl>
" Self-contained config for the ppy container. No external plugins or managers:
" it works out of the box with the stock vim, and the AI edit feature uses only
" curl and the PRAVDA_* gateway env that every instance is launched with.
set nocompatible
filetype plugin indent on
syntax on
set encoding=utf-8
set fileencoding=utf-8
set termencoding=utf-8
set mouse=a
set backspace=indent,eol,start
set autoindent
set smartindent
set tabstop=4
set shiftwidth=4
set expandtab
set number
set showmatch
set showtabline=2
set laststatus=2
set hidden
set incsearch
set hlsearch
set wildmenu
set ttimeoutlen=50
if has('clipboard')
set clipboard=unnamedplus
endif
if !isdirectory(expand('~/.vim/undo'))
call mkdir(expand('~/.vim/undo'), 'p')
endif
set undofile
set undodir=~/.vim/undo
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
let mapleader = ","
inoremap <C-n> <ESC>:tabnext<CR>
inoremap <C-p> <ESC>:tabprevious<CR>
nnoremap <C-n> :tabnext<CR>
nnoremap <C-p> :tabprevious<CR>
nnoremap <Tab> :tabnext<CR>
nnoremap <C-Tab> :tabprevious<CR>
nnoremap <C-e> :tabnew<Space>
inoremap <C-e> <ESC>:tabnew<Space>
if has('autocmd')
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
endif
function! s:GetVisualSelection() abort
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
let l:lines = getline(l:line_start, l:line_end)
if empty(l:lines)
return ''
endif
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
let l:lines[0] = l:lines[0][l:col_start - 1 :]
return join(l:lines, "\n")
endfunction
function! s:GatewayUrl() abort
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
if empty(l:base)
return 'https://openai.app.molodetz.nl/v1/chat/completions'
elseif l:base =~# '/chat/completions$'
return l:base
endif
return l:base . '/chat/completions'
endfunction
function! AiEditSelection() abort
let l:instruction = input('AI instruction: ')
if empty(l:instruction)
echo 'Cancelled.'
return
endif
let l:orig = s:GetVisualSelection()
if empty(l:orig)
echo 'No selection.'
return
endif
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
\ . ' -H ' . shellescape('Content-Type: application/json')
\ . ' -d ' . shellescape(l:json)
let l:reply = system(l:cmd)
if v:shell_error
echohl ErrorMsg | echom 'AI request failed' | echohl None
return
endif
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
if empty(l:text)
echohl ErrorMsg | echom 'No content in AI response' | echohl None
return
endif
let l:text = substitute(l:text, '\\n', "\n", 'g')
let l:text = substitute(l:text, '\\"', '"', 'g')
let l:text = substitute(l:text, '\\t', "\t", 'g')
normal! gv
normal! c
call feedkeys(l:text, 'n')
endfunction
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -56,7 +56,12 @@ GAME_ACTIONS: tuple[Action, ...] = (
requires_auth=True, requires_auth=True,
params=( params=(
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"), 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), body(
"crop",
"Crop key: shell, python, webapp, api, rust, haskell, kernel, or "
"(with Mastery) distsys, mlpipe, secfort.",
required=True,
),
), ),
), ),
Action( Action(

View File

@ -1,200 +0,0 @@
# retoor <retoor@molodetz.nl>
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SLUG = arg(
"project_slug",
"Project slug or uid that owns the container resources.",
required=True,
)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_instances",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="List a project's container instances and their status",
params=(SLUG,),
),
Action(
name="container_create_instance",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("name", "Instance name.", required=True),
arg(
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
),
arg(
"boot_language",
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
),
arg(
"boot_script",
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
),
arg(
"run_as_uid",
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
),
arg(
"start_on_boot",
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg(
"ports",
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
),
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
arg("autostart", "Start immediately ('true' or 'false', default true)."),
arg(
"ingress_slug",
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
),
arg(
"ingress_port",
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
kind="integer",
),
),
),
Action(
name="container_instance_action",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
description="sync imports the container /app workspace back into the project files.",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"action",
"start, stop, restart, pause, resume, delete, or sync.",
required=True,
),
arg(
"confirm",
"Required only for action=delete: set true ONLY after the user has explicitly "
"confirmed destroying the instance. Leave unset otherwise; the delete is refused "
"until you pass confirm=true.",
kind="boolean",
),
),
),
Action(
name="container_configure_instance",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"run_as_uid",
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
),
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
arg("boot_script", "Boot source code body run on launch."),
arg("boot_command", "Fallback boot command used when no boot_script is set."),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg(
"start_on_boot",
"Force running on container-service start ('true' or 'false').",
),
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
),
),
Action(
name="container_logs",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Read the recent logs of a running instance",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("tail", "Number of log lines (default 200).", kind="integer"),
),
),
Action(
name="container_exec",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"command",
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
required=True,
),
arg(
"confirm",
"Required only when the command is destructive (rm, dd, truncate, drop, etc.): set "
"true ONLY after the user has explicitly confirmed. Leave unset otherwise; a "
"destructive command is refused until you pass confirm=true.",
kind="boolean",
),
),
),
Action(
name="container_stats",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Get aggregated resource and runtime statistics for an instance",
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
),
Action(
name="container_schedule",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start or stop.", required=True),
arg("kind", "once, interval, or cron.", required=True),
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
),
),
)

View File

@ -4,21 +4,21 @@ This file documents the Code Farm idle game. Claude Code auto-loads it when a fi
## Overview ## Overview
`game/` package (routers, mounted at `/game`) is 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,grant,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. `game/` package (routers, mounted at `/game`) is 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,grant,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}`. Customer-facing documentation is the `docs_api.py` **Code Farm** group (`docs_api/groups/game.py`, enum `options` on every key param) and the prose guide `templates/docs/code-farm.html` (`/docs/code-farm.html` - the complete rules, every exact formula and catalog table, the full farm/plot field reference, the JSON error shape, and a stdlib automation client); keep BOTH in lockstep with `economy.py` whenever a constant, formula, catalog entry, field, or endpoint changes.
A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, member-only to play, public to view another farm. Cooperative loop = watering a neighbour's growing build; competitive loop = stealing a neighbour's ready build. A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, member-only to play, public to view another farm. Cooperative loop = watering a neighbour's growing build; competitive loop = stealing a neighbour's ready build.
## Data layer ## Data layer
**Data layer is pure + timestamp-driven (no background tick).** `services/game/economy.py` holds every constant and formula as frozen dataclasses/functions: the `CROPS` tuple (key/name/icon/cost/grow_seconds/reward_coins/reward_xp/min_level), `CI_TIERS` (speed multiplier + upgrade cost), level thresholds (`xp_threshold`/`level_for_xp`/`level_progress`), `plot_cost` (doubles per extra plot), and watering bonus. `services/game/store.py` is the only DB access (`game_farms`, `game_plots`). **A plot's state is derived from `ready_at` vs now, never stored** - growing crops finish purely by the clock, so there is no reconciler/service. `serialize_farm(farm, viewer=, owner=)` computes plot states, remaining seconds, `can_water` (viewer is not owner, build growing, viewer not already in the per-cycle `watered_by` JSON list, under `MAX_WATERS_PER_PLOT`), `can_steal`/`steal_coins` (viewer is not owner, build ready, and `now >= ready_at + STEAL_GRACE_SECONDS` so the owner gets a protection window), level progress, and the plantable crop list. `serialize_plot` takes the owner farm's `yield_level`/`prestige` (threaded from `serialize_farm`) only to compute the steal payout. Mutations (`plant`/`harvest`/`buy_plot`/`upgrade_ci`/`water`/`steal`) raise `GameError` on any invalid op (insufficient coins, locked crop, wrong state, still-protected harvest); the routers translate that to a `400` JSON error or a redirect. **Data layer is pure + timestamp-driven (no background tick).** `services/game/economy.py` holds every constant and formula as frozen dataclasses/functions: the `CROPS` tuple (key/name/icon/cost/grow_seconds/reward_coins/reward_xp/min_level, plus the trailing defaulted `min_mastery`/`steal_immune`/`era_key`), `CI_TIERS` (speed multiplier + upgrade cost), level thresholds (`xp_threshold`/`level_for_xp`/`level_progress`), `plot_cost` (doubles per extra plot), and watering bonus. The `services/game/store/` package is the only DB access (`common.py` shared helpers/`credit_farm`/`conditional_update_farm`, `farm.py` farm+plots+leaderboards, `actions.py` plant/harvest/water/steal/quests/daily/perks/legacy/prestige/fertilize, `serialize.py`, `market.py`, `quests.py`, `treasury.py`, `era.py`, `infrastructure.py`, `defense.py`, `cosmetics.py`, `mastery.py`; `store/__init__.py` re-exports the public surface). **A plot's state is derived from `ready_at` vs now, never stored** - growing crops finish purely by the clock, so there is no reconciler/service. `serialize_farm(farm, viewer=, owner=)` computes plot states, remaining seconds, `can_water` (viewer is not owner, build growing, viewer not already in the per-cycle `watered_by` JSON list, under `MAX_WATERS_PER_PLOT`), `can_steal`/`steal_coins` (viewer is not owner, build ready, and `now >= ready_at + STEAL_GRACE_SECONDS` so the owner gets a protection window), level progress, and the plantable crop list. `serialize_plot` takes the owner farm's `yield_level`/`prestige` (threaded from `serialize_farm`) only to compute the steal payout. Mutations (`plant`/`harvest`/`buy_plot`/`upgrade_ci`/`water`/`steal`) raise `GameError` on any invalid op (insufficient coins, locked crop, wrong state, still-protected harvest); the routers translate that to a `400` JSON error or a redirect.
## Tables ## Tables
`game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`/`last_grant_week`, the four `perk_*` columns, and the endgame `stars` + six `legacy_*` columns including `legacy_carryover`), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger), and `game_treasury` (a single row `uid="treasury-main"` holding `balance`/`collected_total`/`granted_total`, the refactor-fee redistribution ledger) are **not** soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in `database.init_db` (unique `idx_game_farms_user`, `idx_game_farms_rank`, `idx_game_plots_farm`, `idx_game_steals_pair` on `(thief_uid, owner_uid, stolen_at)`); the `_uid_index` loop adds the uid index. `ensure_farm(user_uid)` lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook. `game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`/`last_grant_week`, the four `perk_*` columns, the endgame `stars` + six `legacy_*` columns including `legacy_carryover`, the three `mastery_*` upgrade columns + `mastery_points`/`mastery_points_earned_total`, the three `infra_*` booleans, `defense_level`/`defense_last_upkeep_at`, `active_title`, the boost stamps `underdog_boost_until`/`contract_boost_until`, the lifetime/weekly counters `lifetime_coins_earned`/`lifetime_harvests`/`harvests_week`/`harvests_week_start`, the kernel-race columns `prestiged_at`/`last_kernel_harvest_prestige`/`time_to_kernel_seconds`, and the Era shadow counters `era_coins`/`era_harvests`/`era_joined_at`), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), `game_quests` (per-day daily quests + per-ISO-week contracts, `scope`/`kind`/`goal`/`progress`/`reward_*`/`claimed`), `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger), `game_treasury` (a single row `uid="treasury-main"` holding `balance`/`collected_total`/`granted_total`, the refactor-fee redistribution ledger), `game_market_ticks` (per-crop hourly harvest counts, unique on `(crop_key, hour_bucket)`), `game_cosmetics` (owned cosmetics per user), `game_eras` (at most one `active=1` row), and `game_era_results` (the permanent per-user Era history) are **not** soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in `database.init_db` (unique `idx_game_farms_user`, `idx_game_farms_rank`, `idx_game_plots_farm`, `idx_game_steals_pair` on `(thief_uid, owner_uid, stolen_at)`); the `_uid_index` loop adds the uid index. `ensure_farm(user_uid)` lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook.
## Routes (`routers/game/`) ## Routes (`routers/game/`)
`index.py` is the base router - `GET /game` (own farm page), `GET /game/state` (own farm JSON), `GET /game/leaderboard`, and the action POSTs `plant`/`harvest`/`buy-plot`/`upgrade`. `farm.py` adds `GET /game/farm/{username}` (view), `POST /game/farm/{username}/water`, and `POST /game/farm/{username}/steal`. Every action returns the **full updated farm** as JSON (so the client refreshes in one round trip) or redirects for no-JS, via the shared `_respond_action` choke (the `farm.py` water/steal handlers inline the same shape). Harvest awards site XP (`award_rewards`) and the `harvest`/`water` achievements (`track_action`). All action handlers are `require_user`; reads of other farms/the leaderboard are public. `index.py` is the base router - `GET /game` (own farm page), `GET /game/state` (own farm JSON), `GET /game/leaderboard`, and every own-farm action POST (`plant`/`harvest`/`buy-plot`/`upgrade`/`fertilize`/`daily`/`grant`/`perk`/`prestige`/`legacy`/`mastery`/`quests/claim`/`defense/upgrade`/`infrastructure/buy`/`cosmetics/buy`/`cosmetics/equip`). `farm.py` adds `GET /game/farm/{username}` (view), `POST /game/farm/{username}/water`, and `POST /game/farm/{username}/steal`. Every action returns the **full updated farm** as JSON (so the client refreshes in one round trip) or redirects for no-JS, via the shared `_respond_action` choke (the `farm.py` water/steal handlers inline the same shape). Harvest awards site XP (`award_rewards`) and the `harvest`/`water` achievements (`track_action`). All action handlers are `require_user`; reads of other farms/the leaderboard are public.
**Leaderboard ranking is a composite `economy.farm_score(farm)`** (one integer per row, computed in memory over the already-loaded `_farms().find()` set, so it stays fast): it sums weighted contributions from every tracked factor - `xp`, `prestige` * `SCORE_PRESTIGE` (5000, dominant since a refactor is a full completed cycle), `total_harvests` * `SCORE_HARVEST`, `coins` // `SCORE_COIN_DIVISOR`, `(ci_tier-1)` * `SCORE_CI`, `(plot_count-STARTING_PLOTS)` * `SCORE_PLOT`, the summed perk levels * `SCORE_PERK`, and `min(streak, SCORE_STREAK_CAP)` * `SCORE_STREAK` - so a player who refactored (which resets xp/level/coins/ci/plots/perks) is no longer buried below a never-refactored higher-level player. The weights are module-level constants in `economy.py` for tuning; the leaderboard entry carries `score` and `prestige` (surfaced on `GameLeaderboardEntryOut`) and `GameFarm.js` renders the score next to `Lv X`. **Leaderboard ranking is a composite `economy.farm_score(farm)`** (one integer per row, computed in memory over the already-loaded `_farms().find()` set, so it stays fast): it sums weighted contributions from every tracked factor - `xp`, `prestige` * `SCORE_PRESTIGE` (5000, dominant since a refactor is a full completed cycle), `total_harvests` * `SCORE_HARVEST`, `coins` // `SCORE_COIN_DIVISOR`, `(ci_tier-1)` * `SCORE_CI`, `(plot_count-STARTING_PLOTS)` * `SCORE_PLOT`, the summed perk levels * `SCORE_PERK`, and `min(streak, SCORE_STREAK_CAP)` * `SCORE_STREAK` - so a player who refactored (which resets xp/level/coins/ci/plots/perks) is no longer buried below a never-refactored higher-level player. The weights are module-level constants in `economy.py` for tuning; the leaderboard entry carries `score` and `prestige` (surfaced on `GameLeaderboardEntryOut`) and `GameFarm.js` renders the score next to `Lv X`.
@ -32,7 +32,7 @@ Devii plays via the `game_*` `http` actions in `actions/catalog.py` (state/leade
## Stealing (competitive loop, backwards compatible) ## Stealing (competitive loop, backwards compatible)
`store.steal(thief, owner, slot)` mirrors `water`: it requires the owner plot to be `ready` AND past the protection window `economy.effective_steal_grace(owner_defense_level)` (base `STEAL_GRACE_SECONDS` 60s, +30s per owner Branch Protection level) measured from `ready_at`, AND that the thief is **off cooldown for this victim** (`steal_cooldown_remaining(thief, owner, now) == 0`, i.e. no row in `game_steals` for the pair within `STEAL_COOLDOWN_SECONDS` = 3600 - you can raid a given neighbour only once per hour); it clears the plot exactly like a harvest (owner gets nothing), credits the **thief's** farm `economy.steal_reward_coins` (the owner's yield/prestige/legacy-multiplier realized value times `effective_steal_fraction(owner_defense_level)`, base `STEAL_FRACTION` 0.5, -5% per defense level, floored at 0.1) and **coins only** - no XP, no `total_harvests`, so the leaderboard stays earned by real farming - then inserts a `game_steals` row stamping the cooldown. The route `POST /game/farm/{username}/steal` (reusing `GameSlotForm`) then `track_action`s both sides, fires `create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game")` (the message never names the thief; `related_uid` is internal), and `await notify_farm`s **both** the owner and the thief usernames so both farms refresh live. The new `harvest_stolen` notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing `ready_at`/perk/legacy columns, the `game_steals` table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new `GamePlotOut.can_steal`/`steal_coins`/`steal_cooldown_seconds`/`steal_reason` + `GameFarmOut.steal_cooldown_seconds` + `GameFarmViewOut.stole_coins` schema fields default safe. The per-pair cooldown is computed **once per farm** in `serialize_farm` (when the viewer is not the owner) and threaded into every `serialize_plot` as `steal_locked_until`, so `can_steal` is false and `steal_reason` is `"cooldown"`/`"protected"` accordingly. Frontend: the ready/not-owner branch of `_game_grid.html` and `GameFarm.js._plotHtml` render a `btn-danger` Steal button (`data-game-action="steal"`, `data-confirm` gated like prestige) carrying `steal_coins`, or a disabled "Raid again in <countdown>" label when on cooldown; on success `GameFarm.js._submit` toasts the payout from `data.stole_coins`. `store.steal(thief, owner, slot)` mirrors `water`: it requires the owner plot to be `ready` AND past the protection window `economy.effective_steal_grace(owner_defense_level)` (base `STEAL_GRACE_SECONDS` 60s, +30s per owner Branch Protection level) measured from `ready_at`, AND that the thief is **off cooldown for this victim** (`steal_cooldown_remaining(thief, owner, now) == 0`, i.e. no row in `game_steals` for the pair within `STEAL_COOLDOWN_SECONDS` = 3600 - you can raid a given neighbour only once per hour); it clears the plot exactly like a harvest (owner gets nothing), credits the **thief's** farm `economy.steal_reward_coins` (the owner's yield/prestige/legacy-multiplier realized value times `effective_steal_fraction(owner_defense_level)`, base `STEAL_FRACTION` 0.5, -5% per defense level, floored at 0.1) and **coins only** - no XP, no `total_harvests`, so the leaderboard stays earned by real farming - then inserts a `game_steals` row stamping the cooldown. The route `POST /game/farm/{username}/steal` (reusing `GameSlotForm`) then `track_action`s both sides, fires `create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game")` (the message never names the thief; `related_uid` is internal), and `await notify_farm`s **both** the owner and the thief usernames so both farms refresh live. The new `harvest_stolen` notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing `ready_at`/perk/legacy columns, the `game_steals` table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new `GamePlotOut.can_steal`/`steal_coins`/`steal_cooldown_seconds`/`steal_reason` + `GameFarmOut.steal_cooldown_seconds` + `GameFarmViewOut.stole_coins` schema fields default safe. The per-pair cooldown is computed **once per farm** in `serialize_farm` (when the viewer is not the owner) and threaded into every `serialize_plot` as `steal_locked_until`, so `can_steal` is false and `steal_reason` is `"cooldown"`/`"protected"` accordingly. **Display must equal enforcement:** `serialize_farm` also threads the owner's Defense building into every `serialize_plot` (`steal_grace_bonus` = the tier's `grace_bonus`, `steal_floor` = the tier's `steal_fraction_floor` raised to `OBSERVABILITY_STEAL_FLOOR` when the owner owns Observability), so the serialized `can_steal`/`steal_coins` match exactly what `store/actions.py::steal()` will enforce and pay - a client acting on `can_steal=true` can never get a "still protected" 400, and `steal_coins` is the exact payout. Never let `serialize_plot` and `steal()` compute grace or payout from different inputs. Frontend: the ready/not-owner branch of `_game_grid.html` and `GameFarm.js._plotHtml` render a `btn-danger` Steal button (`data-game-action="steal"`, `data-confirm` gated like prestige) carrying `steal_coins`, or a disabled "Raid again in <countdown>" label when on cooldown; on success `GameFarm.js._submit` toasts the payout from `data.stole_coins`.
## Extended mechanics (all backwards compatible) ## Extended mechanics (all backwards compatible)
@ -68,11 +68,23 @@ A second large layer added on top of everything above, aimed at flattening runaw
**Load-bearing gotcha: every precondition column must be `COALESCE`d.** None of `prestige`, `perk_*`, `legacy_*`, `stars`, `mastery_*`, `infra_*`, or `defense_level` are set in `ensure_farm`'s insert - a real, never-yet-touched farm has them as SQL `NULL`, not `0`, and `NULL = 0` evaluates to `NULL` (false) in a `WHERE` clause. A first version of this fix compared bare `column = :current_level` and was verified "safe" only because the verification script had incorrectly pre-seeded those columns to `0` - against a genuinely fresh farm it silently rejected every legitimate first purchase. Every precondition and every arithmetic `SET` on a column that is not set at farm creation must read `COALESCE(column, 0)`; `coins`, `plot_count`, and `ci_tier` are the only farm columns set in `ensure_farm` and are the only ones safe to compare bare. **Load-bearing gotcha: every precondition column must be `COALESCE`d.** None of `prestige`, `perk_*`, `legacy_*`, `stars`, `mastery_*`, `infra_*`, or `defense_level` are set in `ensure_farm`'s insert - a real, never-yet-touched farm has them as SQL `NULL`, not `0`, and `NULL = 0` evaluates to `NULL` (false) in a `WHERE` clause. A first version of this fix compared bare `column = :current_level` and was verified "safe" only because the verification script had incorrectly pre-seeded those columns to `0` - against a genuinely fresh farm it silently rejected every legitimate first purchase. Every precondition and every arithmetic `SET` on a column that is not set at farm creation must read `COALESCE(column, 0)`; `coins`, `plot_count`, and `ci_tier` are the only farm columns set in `ensure_farm` and are the only ones safe to compare bare.
`buy_plot` additionally reserves the plot slot atomically (`WHERE plot_count = :current_count AND coins >= :cost`) **before** inserting the `game_plots` row - `idx_game_plots_farm (farm_uid, slot_index)` is not a unique index, so without the reservation two concurrent buys could insert two rows at the same `slot_index` (silent data corruption, not just a rejected purchase). `prestige` reserves the whole refactor transition atomically first (`WHERE COALESCE(prestige, 0) = :old_prestige`, in the same statement as every other reset field) and only mutates `game_plots` after that reservation wins, so a losing concurrent `prestige` call touches no plot data at all. Verified by forcing real concurrent OS processes (not threads - `dataset` gives each thread its own pooled connection, and enough of them exhausts the pool and produces `database is locked` noise that has nothing to do with the game logic) against genuinely fresh, un-seeded farm state; every purchase's final coins/stars/level exactly matches hand-computed expected totals under contention, never a partial or double charge. `buy_plot` additionally reserves the plot slot atomically (`WHERE plot_count = :current_count AND coins >= :cost`) **before** inserting the `game_plots` row - `idx_game_plots_farm (farm_uid, slot_index)` is not a unique index, so without the reservation two concurrent buys could insert two rows at the same `slot_index` (silent data corruption, not just a rejected purchase). `prestige` reserves the whole refactor transition atomically first (`WHERE COALESCE(prestige, 0) = :old_prestige`, in the same statement as every other reset field) and only mutates `game_plots` after that reservation wins, so a losing concurrent `prestige` call touches no plot data at all.
**The core loop is atomic too, not just purchases.** The original atomicity pass covered only spend-precondition mutations; the read-then-write core actions were still lost-update/double-credit races under `--workers N` (a 4-way concurrent `steal` of one plot really did pay 4x and insert 4 cooldown rows before this was closed - caught by the layer-3 race verification, real OS processes). The closed set, all via the `store/common.py` primitives (`conditional_update_row` - the generic form of `conditional_update_farm` - plus `clear_plot` and `refund_farm`):
- **`credit_farm` is a single atomic increment UPDATE** (`SET coins = MAX(0, COALESCE(coins,0) + :delta), xp = COALESCE(xp,0) + :delta, ...` for every counter incl. `stars`, `era_*`, `lifetime_*`, and the `harvests_week` CASE), never a read-modify-write of absolute values - two concurrent credits can no longer overwrite each other. `level` is derived afterwards by `_refresh_level` (bounded retry: read row, compute `level_for_xp`, stamp `WHERE COALESCE(xp,0) = :xp_seen` so only a writer that saw the final xp stamps). `extra` keys remain absolute assignments (timestamps/stamps, last-writer-wins by nature). `stars` is a first-class increment param (`claim_quest` uses it; never pass a computed absolute star total through `extra` - that was a lost-update).
- **`harvest`/`steal`/`_auto_harvest` reserve the plot via `clear_plot(plot_uid, expected_planted_at)`** (one conditional UPDATE, `WHERE crop_key != '' AND planted_at = :expected`) BEFORE crediting anything; a `rowcount` of 0 means someone else already took it (raise / skip). The `planted_at` match also protects an autoreplanted fresh crop from a stale clear.
- **`plant`/`_try_autoreplant` charge-then-claim with compensation:** atomic coin charge (`WHERE coins >= :cost`), then atomic plot claim (`WHERE crop_key = '' OR crop_key IS NULL`); a lost claim refunds via `refund_farm` and errors. Two concurrent plants can never double-plant a slot or get a lost coin deduction.
- **`water` CASes the plot row** (`WHERE COALESCE(watered_by,'[]') = :expected AND crop_key = :crop`) so the same visitor double-clicking cannot be paid twice and two visitors' waterings cannot lose one; the visitor reward goes through `credit_farm`.
- **`fertilize`** charges atomically then CASes `ready_at` (`WHERE ready_at = :expected`), refunding on a lost CAS.
- **`claim_daily` is one conditional UPDATE** (`WHERE COALESCE(last_daily_at,'') = '' OR substr(last_daily_at,1,10) != :today`) folding the credit, streak, and stamp into the guard - use `substr(...,1,10)` for the date compare, NOT SQLite `date()`, which chokes on the full-microsecond ISO strings stored here.
- **`claim_quest` CASes the claimed flag** (`WHERE COALESCE(claimed,0) = 0`) before paying; `advance_quests` increments progress atomically (`SET progress = MIN(COALESCE(goal,0), COALESCE(progress,0) + :amount)`).
Known residual (accepted, tiny): two steals of two *different* ready plots of the same victim fired in the same instant can both pass the per-pair cooldown check (each plot still pays exactly once; only the once-per-hour rule is softened by that one overlap). Closing it would need a uniqueness constraint on the cooldown ledger for no real-world gain. Verified by forcing real concurrent OS processes (not threads - `dataset` gives each thread its own pooled connection, and enough of them exhausts the pool and produces `database is locked` noise that has nothing to do with the game logic) against genuinely fresh, un-seeded farm state; every purchase's final coins/stars/level exactly matches hand-computed expected totals under contention, never a partial or double charge.
### 1. Market Saturation (`economy.py` + `store/market.py`) ### 1. Market Saturation (`economy.py` + `store/market.py`)
A new, genuinely new table `game_market_ticks` (`crop_key`, `hour_bucket` = UTC `"YYYY-MM-DDTHH"`, `harvests` count, unique on `(crop_key, hour_bucket)`) tracks the last `MARKET_WINDOW_HOURS` (48h) of production **per crop, globally** - recorded only for owner harvests/auto-harvests (`store/actions.py` calls `market.record_harvest_tick` after a successful clear), deliberately **not** for steals (a raid moves already-produced value, it does not print new supply, so counting it would double-count). `market.recent_harvests(crop_key, window_hours)` sums the trailing buckets via a raw `SELECT SUM` (cached 30s in a plain `TTLCache`, cosmetic/economic staleness only, no `cache_state` wiring needed). `economy.market_saturation_factor(recent_harvests)` steps the payout down through `MARKET_SATURATION_TIERS` (100% -> 40% past 600 recent harvests); `economy.market_buff_factor(crop_key, saturation_factor)` gives the four starter crops (`MARKET_BUFFED_CROPS`) up to `MARKET_BUFF_CAP` (+15%) relief while the market is saturated. `market.market_factor_for(crop_key)` composes both into one multiplier, threaded as the new `market_factor` parameter on `economy.effective_reward_coins`/`steal_reward_coins`/`crop_payload` (default `1.0`, so any caller that omits it is unaffected). `GameCropOut.market_state` (`"normal"`/`"saturated"`/`"boosted"`) surfaces the live signal in the shop list before planting - both the server-rendered `_game_grid.html` plant `<option>` and its `GameFarm.js` JS mirror append a `" (saturated)"`/`" (boosted)"` text suffix to the crop label (an `<option>` cannot hold markup, so this is plain text, not a styled badge). `market.prune_ticks(older_than_hours=96)` keeps the table small; CLI `devplace game market prune`. A new, genuinely new table `game_market_ticks` (`crop_key`, `hour_bucket` = UTC `"YYYY-MM-DDTHH"`, `harvests` count, unique on `(crop_key, hour_bucket)`) tracks the last `MARKET_WINDOW_HOURS` (48h) of production **per crop, globally** - recorded only for owner harvests/auto-harvests (`store/actions.py` calls `market.record_harvest_tick` after a successful clear), deliberately **not** for steals (a raid moves already-produced value, it does not print new supply, so counting it would double-count). `market.recent_harvests(crop_key, window_hours)` sums the trailing buckets via a raw `SELECT SUM` (cached 30s in a plain `TTLCache`, cosmetic/economic staleness only, no `cache_state` wiring needed). **Supply is velocity-normalized:** `economy.supply_days(crop, recent_harvests)` = `recent * grow_seconds / 86400` converts raw counts into base-rate plot-days, so a 30s `shell` and a 7200s `kernel` saturate on the same real-terms scale (the original absolute-count tiers let a single active player permanently floor every fast crop). `economy.market_saturation_factor(supply_days)` steps the payout down through `MARKET_SATURATION_TIERS` (plot-day thresholds: 100% -> 40% past 96 plot-days, calibrated so the first tier is one dedicated 4-plot starter farm mono-cropping the full window and the floor is community-scale monocropping). **Penalty XOR boost, never multiplied:** `market.market_factor_for(crop_key)` returns the crop's own saturation factor when it is below 1.0; only an unsaturated crop in `MARKET_BUFFED_CROPS` gets `economy.market_buff_factor(crop_key, market.market_pressure())`, where pressure = `1 - min(saturation over MARKET_PRESSURE_CROPS)` (`rust`/`haskell`/`kernel`) and the buff ramps linearly to `MARKET_BUFF_CAP` (+15%) at total high-tier collapse (slope derived from existing constants, no magic number). This makes the `"boosted"` state actually reachable (the original composition `saturation * buff` was capped at exactly 1.0, so "boosted" was dead code) and creates a self-balancing rotation loop: whales flooding kernel boost starter crops until those saturate themselves. The composed factor lies in `[0.40, 1.15]` and threads as the `market_factor` parameter on `economy.effective_reward_coins`/`steal_reward_coins`/`crop_payload` (default `1.0`, so any caller that omits it is unaffected). `GameCropOut.market_state` (`"normal"`/`"saturated"`/`"boosted"`) surfaces the live signal in the shop list before planting - both the server-rendered `_game_grid.html` plant `<option>` and its `GameFarm.js` JS mirror append a `" (saturated)"`/`" (boosted)"` text suffix to the crop label (an `<option>` cannot hold markup, so this is plain text, not a styled badge). `market.prune_ticks(older_than_hours=96)` keeps the table small; CLI `devplace game market prune`.
### 2. Coin sinks: Infrastructure, Defense, Cosmetics (`economy.py` + `store/infrastructure.py`, `store/defense.py`, `store/cosmetics.py`) ### 2. Coin sinks: Infrastructure, Defense, Cosmetics (`economy.py` + `store/infrastructure.py`, `store/defense.py`, `store/cosmetics.py`)
@ -88,7 +100,7 @@ Three new high-tier crops (`distsys`/`mlpipe`/`secfort` in `economy.CROPS`) exte
### 4. Secondary leaderboards (`economy.py` scoring + `store/farm.py`) ### 4. Secondary leaderboards (`economy.py` scoring + `store/farm.py`)
`GET /game/leaderboard` gained an optional `board` query param (default `"score"`, so every existing caller/Devii/docs entry that omits it sees byte-identical output to before this shipped). `store/farm.py::leaderboard_for(board, limit)` dispatches through `LEADERBOARD_BOARDS` (`score` = the original `farm_score` board, `prestige`, `harvests` = this week's `harvests_week` counter, `raids` = average coins per **successful** raid with a minimum of `MIN_RAIDS_FOR_EFFICIENCY_BOARD` (3) qualifying raids - deliberately not "attempts," since failed steals are never persisted today and adding that write would be a spam vector for no real value, `time_to_kernel` = seconds between a farm's last `prestiged_at` and its next Kernel harvest, `fair_play` = `economy.fair_play_score(harvests_week, coins)` which rewards recent activity and penalizes hoarding) plus `era` (dispatches to `store/era.py::leaderboard_era`, always empty when no Era is running). `GameFarm.js._loadLeaderboard` renders a board-appropriate value per row instead of always showing the raw composite score - `raid_avg` (formatted `"Nc/raid"`) for the `raids` board and a formatted duration for `time_to_kernel`, falling back to `score` for every other board. New `game_farms` columns backing these: `harvests_week`/`harvests_week_start` (reset lazily in `serialize_farm` via `_reset_harvests_week` whenever the current ISO week - `common._iso_week` - differs from the stored one, same lazy-no-tick idiom as everything else), `prestiged_at` (stamped every `prestige()`), `last_kernel_harvest_prestige`/`time_to_kernel_seconds` (updated in `harvest`/`_auto_harvest` the first time a Kernel is harvested since the last refactor). `GET /game/leaderboard` gained an optional `board` query param (default `"score"`, so every existing caller/Devii/docs entry that omits it sees byte-identical output to before this shipped). `store/farm.py::leaderboard_for(board, limit)` dispatches through `LEADERBOARD_BOARDS` (`score` = the original `farm_score` board, `prestige`, `harvests` = this week's `harvests_week` counter, `raids` = average coins per **successful** raid with a minimum of `MIN_RAIDS_FOR_EFFICIENCY_BOARD` (3) qualifying raids - deliberately not "attempts," since failed steals are never persisted today and adding that write would be a spam vector for no real value, `time_to_kernel` = seconds between a farm's last `prestiged_at` and its next Kernel harvest, `fair_play` = `economy.fair_play_score(harvests_week, coins)` which rewards recent activity and penalizes hoarding) plus `era` (dispatches to `store/era.py::leaderboard_era`, always empty when no Era is running). `GameFarm.js._loadLeaderboard` renders a board-appropriate value per row instead of always showing the raw composite score - `raid_avg` (formatted `"Nc/raid"`) for the `raids` board and a formatted duration for `time_to_kernel`, falling back to `score` for every other board. New `game_farms` columns backing these: `harvests_week`/`harvests_week_start` (**incremented inside `credit_farm`'s atomic UPDATE** whenever `harvests > 0` - a CASE that rolls the counter over to the new ISO week when `harvests_week_start` differs, else adds; also reset lazily in `serialize_farm` via `_reset_harvests_week` for idle weeks, same lazy-no-tick idiom as everything else. The increment is load-bearing: it feeds grant eligibility, the `harvests` board, and `fair_play` - it was originally missing entirely, leaving the community grant permanently unclaimable and the weekly board all zeros, a liveness bug invisible to pure-safety fuzzing and caught only by the layer-3 grant race setup), `prestiged_at` (stamped every `prestige()`), `last_kernel_harvest_prestige`/`time_to_kernel_seconds` (updated in `harvest`/`_auto_harvest` the first time a Kernel is harvested since the last refactor).
### 5. Eras / seasons (`economy.py` era scoring + `store/era.py`, `routers/admin/game.py`) ### 5. Eras / seasons (`economy.py` era scoring + `store/era.py`, `routers/admin/game.py`)

View File

@ -596,30 +596,36 @@ def weekly_contract(user_uid: str, iso_week: str) -> dict:
MARKET_WINDOW_HOURS = 48 MARKET_WINDOW_HOURS = 48
MARKET_SATURATION_TIERS: tuple[tuple[int, float], ...] = ( MARKET_SATURATION_TIERS: tuple[tuple[float, float], ...] = (
(0, 1.00), (0.0, 1.00),
(40, 0.85), (8.0, 0.85),
(120, 0.70), (24.0, 0.70),
(300, 0.55), (48.0, 0.55),
(600, 0.40), (96.0, 0.40),
) )
MARKET_BUFFED_CROPS = ("shell", "python", "webapp", "api") MARKET_BUFFED_CROPS = ("shell", "python", "webapp", "api")
MARKET_PRESSURE_CROPS = ("rust", "haskell", "kernel")
MARKET_BUFF_CAP = 1.15 MARKET_BUFF_CAP = 1.15
def market_saturation_factor(recent_harvests: int) -> float: def supply_days(crop: Crop, recent_harvests: int) -> float:
return recent_harvests * crop.grow_seconds / 86400.0
def market_saturation_factor(supply: float) -> float:
factor = MARKET_SATURATION_TIERS[0][1] factor = MARKET_SATURATION_TIERS[0][1]
for threshold, tier_factor in MARKET_SATURATION_TIERS: for threshold, tier_factor in MARKET_SATURATION_TIERS:
if recent_harvests >= threshold: if supply >= threshold:
factor = tier_factor factor = tier_factor
return factor return factor
def market_buff_factor(crop_key: str, saturation_factor: float) -> float: def market_buff_factor(crop_key: str, pressure: float) -> float:
if crop_key not in MARKET_BUFFED_CROPS: if crop_key not in MARKET_BUFFED_CROPS:
return 1.0 return 1.0
relief = 1.0 - saturation_factor max_pressure = 1.0 - MARKET_SATURATION_TIERS[-1][1]
return min(MARKET_BUFF_CAP, 1.0 + relief) scale = (MARKET_BUFF_CAP - 1.0) / max_pressure
return min(MARKET_BUFF_CAP, 1.0 + pressure * scale)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -653,7 +659,7 @@ INFRASTRUCTURE: tuple[Infrastructure, ...] = (
"observability", "observability",
"Observability Suite", "Observability Suite",
"\U0001f52d", "\U0001f52d",
"Raises the minimum coins you keep when raided from 10% to 30%", "Fixes the raid payout floor at 30% of a build's value",
150_000_000, 150_000_000,
15, 15,
), ),

View File

@ -37,8 +37,12 @@ from .common import (
_steals, _steals,
_today, _today,
_update_farm, _update_farm,
clear_plot,
conditional_update_farm,
conditional_update_row,
credit_farm, credit_farm,
last_steal_at, last_steal_at,
refund_farm,
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys

View File

@ -11,8 +11,11 @@ from .. import economy
from .common import ( from .common import (
GameError, GameError,
PERK_COLUMN, PERK_COLUMN,
clear_plot,
conditional_update_farm, conditional_update_farm,
conditional_update_row,
credit_farm, credit_farm,
refund_farm,
_iso, _iso,
_iso_week, _iso_week,
_lvl, _lvl,
@ -23,7 +26,6 @@ from .common import (
_quests, _quests,
_steals, _steals,
_today, _today,
_update_farm,
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .era import active_era_name from .era import active_era_name
@ -66,18 +68,21 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
owns_infrastructure(farm, "registry"), owns_infrastructure(farm, "registry"),
) )
ready_at = now + timedelta(seconds=grow) ready_at = now + timedelta(seconds=grow)
_plots().update( charged = conditional_update_farm(
{ farm["uid"], "coins = coins - :cost", "coins >= :cost", {"cost": cost}
"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}) if charged == 0:
raise GameError("Not enough coins to plant that.")
claimed = conditional_update_row(
"game_plots",
plot["uid"],
"crop_key = :crop, planted_at = :planted, ready_at = :ready, watered_by = '[]'",
"crop_key = '' OR crop_key IS NULL",
{"crop": crop.key, "planted": _iso(now), "ready": _iso(ready_at)},
)
if claimed == 0:
refund_farm(farm["uid"], cost)
raise GameError("That plot is already in use.")
advance_quests(user["uid"], "plant", 1) advance_quests(user["uid"], "plant", 1)
return {"slot": slot, "crop": crop.key, "spent": cost} return {"slot": slot, "crop": crop.key, "spent": cost}
@ -122,17 +127,8 @@ def harvest(user: dict, slot: int) -> dict:
if not crop: if not crop:
raise GameError("Unknown crop type.") raise GameError("Unknown crop type.")
planted_at = plot.get("planted_at", "") planted_at = plot.get("planted_at", "")
_plots().update( if clear_plot(plot["uid"], planted_at) == 0:
{ raise GameError("That plot is empty.")
"uid": plot["uid"],
"crop_key": "",
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
result = _harvest_crop(farm, crop, plot.get("uid", ""), planted_at, now) result = _harvest_crop(farm, crop, plot.get("uid", ""), planted_at, now)
coins_gain, xp_gain, golden = result["coins"], result["xp"], result["golden"] coins_gain, xp_gain, golden = result["coins"], result["xp"], result["golden"]
extra = {} extra = {}
@ -177,18 +173,20 @@ def _try_autoreplant(farm: dict, slot: int, crop, now) -> None:
owns_infrastructure(farm, "registry"), owns_infrastructure(farm, "registry"),
) )
ready_at = now + timedelta(seconds=grow) ready_at = now + timedelta(seconds=grow)
_plots().update( charged = conditional_update_farm(
{ farm["uid"], "coins = coins - :cost", "coins >= :cost", {"cost": cost}
"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": int(farm.get("coins", 0)) - cost}) if charged == 0:
return
claimed = conditional_update_row(
"game_plots",
plot["uid"],
"crop_key = :crop, planted_at = :planted, ready_at = :ready, watered_by = '[]'",
"crop_key = '' OR crop_key IS NULL",
{"crop": crop.key, "planted": _iso(now), "ready": _iso(ready_at)},
)
if claimed == 0:
refund_farm(farm["uid"], cost)
def water(visitor: dict, owner: dict, slot: int) -> dict: def water(visitor: dict, owner: dict, slot: int) -> dict:
@ -214,25 +212,25 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
if owns_infrastructure(farm, "registry") and crop.key in economy.REGISTRY_BOOST_CROPS: if owns_infrastructure(farm, "registry") and crop.key in economy.REGISTRY_BOOST_CROPS:
bonus = round(bonus * economy.REGISTRY_BOOST_FACTOR) bonus = round(bonus * economy.REGISTRY_BOOST_FACTOR)
new_ready = max(now, ready_at - timedelta(seconds=bonus)) new_ready = max(now, ready_at - timedelta(seconds=bonus))
expected_watered = plot.get("watered_by") or "[]"
watered.append(visitor["uid"]) watered.append(visitor["uid"])
_plots().update( updated = conditional_update_row(
"game_plots",
plot["uid"],
"ready_at = :ready, watered_by = :watered",
"COALESCE(watered_by, '[]') = :expected AND crop_key = :crop",
{ {
"uid": plot["uid"], "ready": _iso(new_ready),
"ready_at": _iso(new_ready), "watered": json.dumps(watered),
"watered_by": json.dumps(watered), "expected": expected_watered,
"updated_at": _iso(now), "crop": plot.get("crop_key", ""),
}, },
["uid"],
) )
if updated == 0:
raise GameError("This build was just watered - refresh and try again.")
visitor_farm = ensure_farm(visitor["uid"]) visitor_farm = ensure_farm(visitor["uid"])
new_xp = int(visitor_farm.get("xp", 0)) + economy.WATER_REWARD_XP credit_farm(
_update_farm( visitor_farm, coins=economy.WATER_REWARD_COINS, xp=economy.WATER_REWARD_XP
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) advance_quests(visitor["uid"], "water", 1)
return { return {
@ -275,17 +273,8 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
f"You can only raid {owner.get('username', 'this farmer')} " f"You can only raid {owner.get('username', 'this farmer')} "
f"once an hour. Try again in {minutes} min." f"once an hour. Try again in {minutes} min."
) )
_plots().update( if clear_plot(plot["uid"], plot.get("planted_at", "")) == 0:
{ raise GameError("That build is already gone.")
"uid": plot["uid"],
"crop_key": "",
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
market_factor = market_factor_for(crop.key) market_factor = market_factor_for(crop.key)
coins_gain = economy.steal_reward_coins( coins_gain = economy.steal_reward_coins(
crop, crop,
@ -341,18 +330,9 @@ def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
crop = economy.crop_for(plot.get("crop_key", "")) crop = economy.crop_for(plot.get("crop_key", ""))
if not crop: if not crop:
continue continue
if clear_plot(plot["uid"], plot.get("planted_at", "")) == 0:
continue
result = _harvest_crop(farm, crop, plot.get("uid", ""), plot.get("planted_at", ""), now) result = _harvest_crop(farm, crop, plot.get("uid", ""), plot.get("planted_at", ""), now)
_plots().update(
{
"uid": plot["uid"],
"crop_key": "",
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
coins_gain += result["coins"] coins_gain += result["coins"]
xp_gain += result["xp"] xp_gain += result["xp"]
harvested += 1 harvested += 1
@ -404,16 +384,19 @@ def claim_quest(user: dict, kind: str, scope: str = "daily") -> dict:
reward_coins = int(row.get("reward_coins") or 0) reward_coins = int(row.get("reward_coins") or 0)
reward_xp = int(row.get("reward_xp") or 0) reward_xp = int(row.get("reward_xp") or 0)
reward_stars = int(row.get("reward_stars") or 0) reward_stars = int(row.get("reward_stars") or 0)
_quests().update( marked = conditional_update_row(
{"uid": row["uid"], "claimed": 1, "updated_at": _iso(now)}, ["uid"] "game_quests", row["uid"], "claimed = 1", "COALESCE(claimed, 0) = 0", {}
) )
extra = {"stars": _lvl(farm, "stars") + reward_stars} if reward_stars else None if marked == 0:
raise GameError("Quest already claimed.")
extra = None
if scope == "weekly": if scope == "weekly":
extra = extra or {} extra = {
extra["contract_boost_until"] = _iso( "contract_boost_until": _iso(
now + timedelta(hours=economy.WEEKLY_CONTRACT_BOOST_HOURS) now + timedelta(hours=economy.WEEKLY_CONTRACT_BOOST_HOURS)
) )
credit_farm(farm, coins=reward_coins, xp=reward_xp, extra=extra) }
credit_farm(farm, coins=reward_coins, xp=reward_xp, stars=reward_stars, extra=extra)
return { return {
"kind": kind, "kind": kind,
"reward_coins": reward_coins, "reward_coins": reward_coins,
@ -430,12 +413,28 @@ def claim_daily(user: dict) -> dict:
last = _parse_date(farm.get("last_daily_at") or "") last = _parse_date(farm.get("last_daily_at") or "")
streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1 streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1
reward = economy.daily_reward(streak) reward = economy.daily_reward(streak)
credit_farm( set_parts = [
farm, "coins = COALESCE(coins, 0) + :reward",
coins=reward, "lifetime_coins_earned = COALESCE(lifetime_coins_earned, 0) + :reward",
era_active=bool(active_era_name()), "streak = :streak",
extra={"streak": streak, "last_daily_at": _iso(now)}, "last_daily_at = :now_iso",
]
params = {
"reward": reward,
"streak": streak,
"now_iso": _iso(now),
"today": now.date().isoformat(),
}
if active_era_name():
set_parts.append("era_coins = COALESCE(era_coins, 0) + :reward")
rows = conditional_update_farm(
farm["uid"],
", ".join(set_parts),
"COALESCE(last_daily_at, '') = '' OR substr(last_daily_at, 1, 10) != :today",
params,
) )
if rows == 0:
raise GameError("Daily bonus already claimed today.")
return {"reward": reward, "streak": streak} return {"reward": reward, "streak": streak}
@ -614,12 +613,24 @@ def fertilize(user: dict, slot: int) -> dict:
market_factor_for(crop.key), market_factor_for(crop.key),
) )
cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow) cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
if int(farm.get("coins", 0)) < cost: charged = conditional_update_farm(
farm["uid"], "coins = coins - :cost", "coins >= :cost", {"cost": cost}
)
if charged == 0:
raise GameError("Not enough coins to fertilize.") raise GameError("Not enough coins to fertilize.")
new_ready = max(now, ready_at - timedelta(seconds=reduce_by)) new_ready = max(now, ready_at - timedelta(seconds=reduce_by))
_plots().update( moved = conditional_update_row(
{"uid": plot["uid"], "ready_at": _iso(new_ready), "updated_at": _iso(now)}, "game_plots",
["uid"], plot["uid"],
"ready_at = :new_ready",
"ready_at = :expected AND crop_key = :crop",
{
"new_ready": _iso(new_ready),
"expected": plot.get("ready_at", ""),
"crop": plot.get("crop_key", ""),
},
) )
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost}) if moved == 0:
refund_farm(farm["uid"], cost)
raise GameError("That build just changed - refresh and try again.")
return {"slot": slot, "spent": cost, "reduced_seconds": reduce_by} return {"slot": slot, "spent": cost, "reduced_seconds": reduce_by}

View File

@ -90,43 +90,104 @@ def _update_farm(farm_uid: str, fields: dict) -> None:
_farms().update(fields, ["uid"]) _farms().update(fields, ["uid"])
def conditional_update_farm(farm_uid: str, set_clause: str, where_clause: str, params: dict) -> int: def conditional_update_row(
table_name: str, row_uid: str, set_clause: str, where_clause: str, params: dict
) -> int:
from sqlalchemy import text from sqlalchemy import text
from devplacepy.database import db from devplacepy.database import db
sql = ( sql = (
f"UPDATE game_farms SET {set_clause}, updated_at = :updated_at " f"UPDATE {table_name} SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :farm_uid AND ({where_clause})" f"WHERE uid = :row_uid AND ({where_clause})"
) )
bind = {**params, "updated_at": _iso(_now()), "farm_uid": farm_uid} bind = {**params, "updated_at": _iso(_now()), "row_uid": row_uid}
with db: with db:
result = db.executable.execute(text(sql), bind) result = db.executable.execute(text(sql), bind)
return result.rowcount return result.rowcount
def conditional_update_farm(farm_uid: str, set_clause: str, where_clause: str, params: dict) -> int:
return conditional_update_row("game_farms", farm_uid, set_clause, where_clause, params)
def clear_plot(plot_uid: str, expected_planted_at: str) -> int:
return conditional_update_row(
"game_plots",
plot_uid,
"crop_key = '', planted_at = '', ready_at = '', watered_by = '[]'",
"crop_key != '' AND planted_at = :expected_planted",
{"expected_planted": expected_planted_at},
)
def refund_farm(farm_uid: str, coins: int) -> None:
if coins <= 0:
return
conditional_update_farm(
farm_uid, "coins = COALESCE(coins, 0) + :refund", "1 = 1", {"refund": coins}
)
def _refresh_level(farm_uid: str) -> dict:
for _ in range(4):
row = _farms().find_one(uid=farm_uid)
if not row:
return {}
xp_seen = int(row.get("xp") or 0)
level = economy.level_for_xp(xp_seen)
if int(row.get("level") or 1) == level:
return row
rows = conditional_update_farm(
farm_uid,
"level = :level",
"COALESCE(xp, 0) = :xp_seen",
{"level": level, "xp_seen": xp_seen},
)
if rows:
return {**row, "level": level}
return _farms().find_one(uid=farm_uid) or {}
def credit_farm( def credit_farm(
farm: dict, farm: dict,
*, *,
coins: int = 0, coins: int = 0,
xp: int = 0, xp: int = 0,
harvests: int = 0, harvests: int = 0,
stars: int = 0,
era_active: bool = False, era_active: bool = False,
extra: dict | None = None, extra: dict | None = None,
) -> dict: ) -> dict:
new_xp = int(farm.get("xp", 0)) + xp set_parts = [
fields = { "coins = MAX(0, COALESCE(coins, 0) + :coins_delta)",
"coins": max(0, int(farm.get("coins", 0)) + coins), "xp = COALESCE(xp, 0) + :xp_delta",
"xp": new_xp, "total_harvests = COALESCE(total_harvests, 0) + :harvests_delta",
"level": economy.level_for_xp(new_xp), "lifetime_coins_earned = COALESCE(lifetime_coins_earned, 0) + :earned_delta",
"total_harvests": int(farm.get("total_harvests", 0)) + harvests, "lifetime_harvests = COALESCE(lifetime_harvests, 0) + :harvests_delta",
"lifetime_coins_earned": max(0, _lvl(farm, "lifetime_coins_earned") + max(0, coins)), ]
"lifetime_harvests": _lvl(farm, "lifetime_harvests") + harvests, params = {
"coins_delta": coins,
"xp_delta": xp,
"harvests_delta": harvests,
"earned_delta": max(0, coins),
} }
if stars:
set_parts.append("stars = COALESCE(stars, 0) + :stars_delta")
params["stars_delta"] = stars
if harvests:
set_parts.append(
"harvests_week = CASE WHEN harvests_week_start = :week "
"THEN COALESCE(harvests_week, 0) + :harvests_delta ELSE :harvests_delta END"
)
set_parts.append("harvests_week_start = :week")
params["week"] = _iso_week()
if era_active: if era_active:
fields["era_coins"] = _lvl(farm, "era_coins") + max(0, coins) set_parts.append("era_coins = COALESCE(era_coins, 0) + :earned_delta")
fields["era_harvests"] = _lvl(farm, "era_harvests") + harvests set_parts.append("era_harvests = COALESCE(era_harvests, 0) + :harvests_delta")
if extra: if extra:
fields.update(extra) for index, (key, value) in enumerate(extra.items()):
_update_farm(farm["uid"], fields) set_parts.append(f"{key} = :extra_{index}")
return {**farm, **fields} params[f"extra_{index}"] = value
conditional_update_farm(farm["uid"], ", ".join(set_parts), "1 = 1", params)
return _refresh_level(farm["uid"])

View File

@ -60,10 +60,24 @@ def recent_harvests(crop_key: str, window_hours: int) -> int:
return total return total
def market_pressure() -> float:
worst = 1.0
for key in economy.MARKET_PRESSURE_CROPS:
crop = economy.crop_for(key)
recent = recent_harvests(key, economy.MARKET_WINDOW_HOURS)
worst = min(worst, economy.market_saturation_factor(economy.supply_days(crop, recent)))
return 1.0 - worst
def market_factor_for(crop_key: str) -> float: def market_factor_for(crop_key: str) -> float:
crop = economy.crop_for(crop_key)
if crop is None:
return 1.0
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS) recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
saturation = economy.market_saturation_factor(recent) saturation = economy.market_saturation_factor(economy.supply_days(crop, recent))
return saturation * economy.market_buff_factor(crop_key, saturation) if saturation < 1.0:
return saturation
return economy.market_buff_factor(crop_key, market_pressure())
def prune_ticks(older_than_hours: int = 96) -> int: def prune_ticks(older_than_hours: int = 96) -> int:

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
from .. import economy from .. import economy
from .common import _iso, _iso_week, _lvl, _now, _quests, _today from .common import _iso, _iso_week, _lvl, _now, _quests, _today, conditional_update_row
from .farm import get_farm from .farm import get_farm
@ -87,11 +87,12 @@ def advance_quests(user_uid: str, kind: str, amount: int) -> None:
for row in rows: for row in rows:
if row.get("claimed"): if row.get("claimed"):
continue continue
goal = int(row.get("goal") or 0) conditional_update_row(
progress = min(goal, int(row.get("progress") or 0) + amount) "game_quests",
_quests().update( row["uid"],
{"uid": row["uid"], "progress": progress, "updated_at": _iso(now)}, "progress = MIN(COALESCE(goal, 0), COALESCE(progress, 0) + :amount)",
["uid"], "COALESCE(claimed, 0) = 0",
{"amount": amount},
) )
except Exception: except Exception:
return return

View File

@ -56,6 +56,8 @@ def serialize_plot(
legacy_speed_level: int = 0, legacy_speed_level: int = 0,
defense_level: int = 0, defense_level: int = 0,
steal_locked_until: int = 0, steal_locked_until: int = 0,
steal_grace_bonus: int = 0,
steal_floor: float = 0.1,
) -> dict: ) -> dict:
state = _plot_state(plot, now) state = _plot_state(plot, now)
crop = economy.crop_for(plot.get("crop_key", "")) crop = economy.crop_for(plot.get("crop_key", ""))
@ -73,7 +75,7 @@ def serialize_plot(
and viewer_uid not in watered and viewer_uid not in watered
and len(watered) < economy.MAX_WATERS_PER_PLOT and len(watered) < economy.MAX_WATERS_PER_PLOT
) )
grace = economy.effective_steal_grace(defense_level) grace = economy.effective_steal_grace(defense_level, steal_grace_bonus)
protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace) protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace)
immune = bool(crop and crop.steal_immune) immune = bool(crop and crop.steal_immune)
eligible = ( eligible = (
@ -95,7 +97,13 @@ def serialize_plot(
market_factor = market_factor_for(crop.key) if crop else 1.0 market_factor = market_factor_for(crop.key) if crop else 1.0
steal_coins = ( steal_coins = (
economy.steal_reward_coins( economy.steal_reward_coins(
crop, yield_level, prestige, legacy_mult_level, defense_level, market_factor crop,
yield_level,
prestige,
legacy_mult_level,
defense_level,
market_factor,
steal_floor,
) )
if can_steal if can_steal
else 0 else 0
@ -161,6 +169,11 @@ def serialize_farm(
if viewer_uid and not is_owner if viewer_uid and not is_owner
else 0 else 0
) )
building_defense_level = _lvl(farm, "defense_level")
defense_tier_info = economy.defense_tier(building_defense_level)
steal_floor = defense_tier_info.steal_fraction_floor
if owns_infrastructure(farm, "observability"):
steal_floor = max(steal_floor, economy.OBSERVABILITY_STEAL_FLOOR)
plots = get_plots(farm["uid"]) plots = get_plots(farm["uid"])
serialized_plots = [ serialized_plots = [
serialize_plot( serialize_plot(
@ -176,6 +189,8 @@ def serialize_farm(
legacy_speed_level=legacy_speed_level, legacy_speed_level=legacy_speed_level,
defense_level=defense_level, defense_level=defense_level,
steal_locked_until=steal_locked_until, steal_locked_until=steal_locked_until,
steal_grace_bonus=defense_tier_info.grace_bonus,
steal_floor=steal_floor,
) )
for plot in plots for plot in plots
] ]
@ -192,8 +207,6 @@ def serialize_farm(
underdog_seconds = ( underdog_seconds = (
max(0, int((underdog_until - now).total_seconds())) if underdog_until else 0 max(0, int((underdog_until - now).total_seconds())) if underdog_until else 0
) )
building_defense_level = _lvl(farm, "defense_level")
defense_tier_info = economy.defense_tier(building_defense_level)
coins = int(farm.get("coins", 0)) coins = int(farm.get("coins", 0))
refactor_fee = economy.refactor_cost(prestige, coins) refactor_fee = economy.refactor_cost(prestige, coins)
carryover_level = _lvl(farm, "legacy_carryover") carryover_level = _lvl(farm, "legacy_carryover")

View File

@ -14,13 +14,14 @@ play. Visiting another farm at `{{ base }}/game/farm/{username}` is public.
Everything on this page also runs over the JSON API, so the game can be driven by a script as Everything on this page also runs over the JSON API, so the game can be driven by a script as
well as by hand. The full request and response reference is the well as by hand. The full request and response reference is the
[Code Farm API group](/docs/game.html); this page explains the rules and the numbers behind [Code Farm API group](/docs/game.html); this page explains the rules and the exact numbers behind
them, then shows a complete automated client at the end. them, then documents every field a client sees and shows a complete automated client at the end.
## The core loop ## The core loop
1. **Plant** a crop in an empty plot. Planting costs coins. 1. **Plant** a crop in an empty plot. Planting costs coins.
2. The crop **builds** over real time. The build finishes at a fixed wall-clock moment. 2. The crop **builds** over real time. The build finishes at a fixed wall-clock moment
(`ready_at`); there is no server tick, the clock alone decides.
3. **Harvest** the finished build for coins and experience, which empties the plot again. 3. **Harvest** the finished build for coins and experience, which empties the plot again.
4. Spend the coins on faster CI, more plots, and perks so the next loop pays more. 4. Spend the coins on faster CI, more plots, and perks so the next loop pays more.
@ -32,26 +33,29 @@ progressing and wait for you, so the game is safe to check once an hour or once
Each crop has a coin cost to plant, a base build time, a coin and experience reward on harvest, Each crop has a coin cost to plant, a base build time, a coin and experience reward on harvest,
and a level at which it unlocks. Slower crops pay far more per build. and a level at which it unlocks. Slower crops pay far more per build.
| Crop | Cost | Base build time | Coins | XP | Unlocks at level | | Crop | Key | Cost | Base build time | Coins | XP | Unlocks at |
|------|-----:|----------------:|------:|---:|:----------------:| |------|-----|-----:|----------------:|------:|---:|:----------:|
| 🐚 Shell Script | 5 | 30s | 11 | 2 | 1 | | 🐚 Shell Script | `shell` | 5 | 30s | 11 | 2 | level 1 |
| 🐍 Python Script | 15 | 2m | 36 | 5 | 1 | | 🐍 Python Script | `python` | 15 | 2m | 36 | 5 | level 1 |
| 📜 Web App | 40 | 5m | 98 | 12 | 2 | | 📜 Web App | `webapp` | 40 | 5m | 98 | 12 | level 2 |
| 🐹 Go Service | 90 | 10m | 224 | 25 | 3 | | 🐹 Go Service | `api` | 90 | 10m | 224 | 25 | level 3 |
| 🦀 Rust Engine | 200 | 30m | 520 | 60 | 4 | | 🦀 Rust Engine | `rust` | 200 | 30m | 520 | 60 | level 4 |
| λ Compiler | 500 | 1h | 1380 | 150 | 6 | | λ Compiler | `haskell` | 500 | 1h | 1380 | 150 | level 6 |
| ⚙️ Kernel | 1200 | 2h | 3600 | 400 | 8 | | ⚙️ Kernel | `kernel` | 1200 | 2h | 3600 | 400 | level 8 |
| 🕸️ Distributed System | 5000 | 4h | 9500 | 900 | Mastery | | 🕸️ Distributed System | `distsys` | 5000 | 4h | 9500 | 900 | level 20 + Mastery |
| 🧠 ML Pipeline | 12000 | 6h | 21000 | 1800 | Mastery | | 🧠 ML Pipeline | `mlpipe` | 12000 | 6h | 21000 | 1800 | level 20 + Mastery |
| 🔐 Security Fortress | 30000 | 8h | 48000 | 3200 | Mastery, raid-immune | | 🔐 Security Fortress | `secfort` | 30000 | 8h | 48000 | 3200 | level 20 + Mastery |
The three Mastery-tier crops unlock once you have earned at least one Mastery point (see The three Mastery-tier crops require **level 20** and at least **one earned Mastery point** (see
Mastery, below), on top of the usual level requirement. Security Fortress can never be raided. Mastery, below). The Mastery unlock is permanent - it checks points ever earned, not points held,
so spending your points never re-locks the crops. Security Fortress is **raid-immune**: it can
never be stolen.
Build time is the base time divided by the farm build speed (CI tier and the growth perk, below), Build time is the base time divided by the farm build speed (CI tier, the Build Cache perk, the
so a higher tier finishes everything proportionally faster. Coin and experience rewards shown in Bare-Metal Legacy upgrade, and the Private Registry for its three crops), so upgrades finish
the API already include your perks, refactor bonus, and the current Market Saturation factor, so everything proportionally faster. Coin and experience rewards shown in the API already include
read them from the live state rather than from this table. Each crop in the state also carries a your perks, refactor bonus, Legacy multiplier, and the current Market Saturation factor, so read
them from the live state rather than from this table. Each crop in the state also carries a
`market_state` of `normal`, `saturated`, or `boosted`. `market_state` of `normal`, `saturated`, or `boosted`.
## Plots ## Plots
@ -74,7 +78,8 @@ More plots is more builds in parallel, which is the main way to scale output.
## CI tier (build speed) ## CI tier (build speed)
Upgrading your CI tier multiplies build speed for the whole farm. The upgrade is permanent. Upgrading your CI tier multiplies build speed for the whole farm. The upgrade is permanent (until
a refactor resets it).
| Tier | Name | Build speed | Upgrade cost | | Tier | Name | Build speed | Upgrade cost |
|:----:|------|:-----------:|-------------:| |:----:|------|:-----------:|-------------:|
@ -87,151 +92,260 @@ Upgrading your CI tier multiplies build speed for the whole farm. The upgrade is
## Levels and experience ## Levels and experience
Harvesting grants experience. Experience raises your level, and higher levels unlock the more Harvesting grants experience. Experience raises your level, and higher levels unlock the more
valuable crops. The maximum level is **20**. Each level needs `50 * (level - 1)^2` total valuable crops. The maximum level is **20**. Reaching a level needs `50 * (level - 1)^2` total
experience, so level 2 is at 50 XP, level 5 at 800, level 10 at 4050, and level 20 at 18050. experience, so level 2 is at 50 XP, level 5 at 800, level 10 at 4050, and level 20 at 18050. The
state reports `level`, `level_into` (XP into the current level), `level_span` (XP from this level
to the next), and `level_is_max`.
## Perks ## Perks
Perks are permanent upgrades bought with coins. Each level of a perk costs more than the last. Perks are permanent upgrades bought with coins (until a refactor resets them). The cost of level
There are four: `n+1` is `round(base * growth^n)`, so each level costs more than the last. There are four:
| Perk | Effect per level | Max level | | Perk | Key | Effect per level | Max level | First cost | Cost growth |
|------|------------------|:---------:| |------|-----|------------------|:---------:|-----------:|:-----------:|
| 📈 Optimizer | +5% harvest coins | 10 | | 📈 Optimizer | `yield` | +5% harvest coins | 10 | 120 | 1.6x |
| ⚡ Build Cache | +4% build speed | 10 | | ⚡ Build Cache | `growth` | +4% build speed | 10 | 150 | 1.7x |
| 🏷️ Bulk Licenses | -3% planting cost | 8 | | 🏷️ Bulk Licenses | `discount` | -3% planting cost | 8 | 100 | 1.7x |
| 🎓 Mentorship | +5% harvest experience | 10 | | 🎓 Mentorship | `xp` | +5% harvest experience | 10 | 140 | 1.6x |
The live state reports each perk's current level and the exact cost of its next level. The live state reports each perk's current level and the exact cost of its next level - read
`cost` from the state rather than recomputing it.
## Daily bonus and streak ## Daily bonus and streak
Once per day you can claim a coin bonus. Claiming on consecutive days builds a streak that Once per UTC day you can claim a coin bonus of `20 + 12 * (streak_day - 1)`, where the streak day
increases the bonus, from **20 coins** on day one up to **92 coins** at a seven-day streak. caps at seven: **20 coins** on day one up to **92 coins** from the seventh consecutive day on.
Missing a day resets the streak. The state field `daily_available` tells you when a claim is Missing a day resets the streak to one. The state field `daily_available` tells you when a claim
ready, and `daily_reward` is the amount you would receive. is ready, and `daily_reward` is the exact amount the next claim pays.
## Daily quests ## Daily quests
Each day you are given three small goals, such as planting a number of crops, harvesting a number Each day you are given three small goals, deterministically chosen per user and day, such as
of builds, watering neighbour builds, or earning a number of coins from harvests. Progress is planting a number of crops, harvesting a number of builds, watering neighbour builds, or earning a
tracked automatically as you play. A completed quest is claimed by its **kind** (`plant`, number of coins from harvests. Progress is tracked automatically as you play. A completed quest is
`harvest`, `water`, or `earn`) for a coin and experience reward. The state field `can_claim` on claimed by its **kind** (`plant`, `harvest`, `water`, or `earn`) for a coin and experience reward.
each quest marks the ones that are ready. The state field `can_claim` on each quest marks the ones that are ready; claiming an incomplete or
already-claimed quest returns an error.
## Fertilizer ## Fertilizer
Fertilizing a growing build spends coins to **halve its remaining time**. The cost scales with the Fertilizing a growing build spends coins to **halve its remaining time**. The exact cost is
build's coin value and with how much time you skip, so a build with a long way to go costs more and `ceil(effective_harvest_coins * skipped_seconds / full_build_seconds * 1.05)`, where
fertilizing is cheapest near the end. Each plot in the state reports its current `fertilize_cost`. `effective_harvest_coins` already includes all your multipliers - so fully fertilizing a build
always costs at least 105% of its harvest value. Fertilizer is a pure time-skip, never a profit,
at any prestige. Each growing plot in the state reports its current `fertilize_cost`; a build
with less than two seconds remaining cannot be fertilized.
## Golden builds
Every planting has a **5% chance** to come out **golden**, decided the moment it is planted and
fixed for that build's lifetime. A golden harvest pays **5x coins** (experience is unchanged).
The plot field `is_golden` marks it - the owner always sees it, a visitor only when the build is
currently stealable (a golden build is a 5x raid target too, so watch your grace window).
## Watering a neighbour (cooperation) ## Watering a neighbour (cooperation)
Visit another member's farm and water one of their **growing** builds. Watering reduces that Visit another member's farm and water one of their **growing** builds. Watering reduces that
build's remaining time by 8% of its total build time and rewards **you**, the visitor, with build's remaining time by **8% of its full (speed-adjusted) build time** and rewards **you**, the
**6 coins and 3 experience**. A single plot can be watered up to **3 times** total across all visitor, with **6 coins and 3 experience**. Each visitor can water a given build **once per
visitors, so popular farms fill up. Each plot reports `can_water`, `watered_count`, and growth cycle**, and a single build takes at most **3 waterings** total across all visitors, so
`max_waters`. popular farms fill up. Each plot reports `can_water` (true only for signed-in non-owners who have
not watered it yet), `watered_count`, and `max_waters`. Watering also advances your own `water`
quest.
## Raiding a ready build (competition) ## Raiding a ready build (competition)
If an owner leaves a build **ready** without harvesting it, another member can raid it, but only If an owner leaves a build **ready** without harvesting it, another member can raid ("steal") it,
after a protection window (60 seconds by default, longer with Branch Protection or a Defense but only after a protection window has passed since the build became ready. The window is **60
building) has passed since the build became ready. The raider receives a fraction of the build's seconds** base, **+30s** per level of the owner's Branch Protection Legacy upgrade, plus the
coin value (half by default, less with the owner's defenses, floored so a raid never pays nothing) owner's Defense tier grace bonus (table below).
and the owner loses the build entirely - except a Security Fortress build, which can never be
raided. The owner is told their farm was raided but the raider's identity is never revealed. You The raider receives a fraction of the build's realized coin value:
can raid a given neighbour only once per hour. Raiding a farm with **10x** your own coins grants you `max(floor, 50% - 5% x Branch Protection level)`. The floor comes from the owner's Defense tier
a 24-hour **Underdog** boost (+25% harvest coins). Each plot reports `can_steal` and `steal_coins` (10% undefended, down to 4% at Zero Trust Mesh); when the owner owns the Observability Suite the
(the payout a raid would give), and `ready_at` lets you compute when the protection window ends. floor is 30% instead. The owner loses the build entirely either way. A Security Fortress build
can never be raided. The owner is notified their farm was raided but the raider's identity is
never revealed.
You can raid any given neighbour only **once per hour** (per victim, so raiding several different
farms in one hour is fine). Raiding a farm holding more than **10x** your own coins grants you a
24-hour **Underdog** boost (+25% harvest coins, visible as
`underdog_boost_seconds_remaining` in your state).
Each plot on a farm you view reports:
- `can_steal` - true when everything below is satisfied and you can raid right now.
- `steal_coins` - the exact payout a raid would give.
- `steal_reason` - why a ready build is not stealable: `protected` (grace window still running),
`cooldown` (you raided this owner within the hour), or `immune` (Security Fortress). Empty when
stealable or not applicable.
- `steal_cooldown_seconds` - seconds until your per-owner cooldown ends (only set with
`steal_reason` of `cooldown`; also mirrored farm-wide as `steal_cooldown_seconds` on the farm).
- `ready_at` - add the protection window to compute when a raid becomes possible.
Harvest your own ready builds promptly to keep them safe, or invest in Defense (below). Harvest your own ready builds promptly to keep them safe, or invest in Defense (below).
## Market Saturation ## Market Saturation
The game tracks the last 48 hours of league-wide harvests of each crop. When a crop is being The game tracks the last **48 hours** of league-wide harvests of each crop and converts them into
farmed heavily, its payout drops in steps (down to 40% of normal); while that is happening, the supply measured in build time ("plot-days" at base rate), so a 30-second Shell Script and a
four starter crops (Shell Script, Python Script, Web App, Go Service) get a relief buff of up to 2-hour Kernel saturate on the same real-terms scale - quick crops are not punished for being
+15%. This rewards diversifying what you plant instead of printing one crop nonstop. Nothing else quick. As supply piles up, the crop's payout steps down:
about the crop changes - cost, build time, and unlock level are unaffected.
| Supply (plot-days in 48h) | Payout |
|:-------------------------:|:------:|
| under 8 | 100% |
| 8 or more | 85% |
| 24 or more | 70% |
| 48 or more | 55% |
| 96 or more | 40% |
Separately, while the high-tier market (Rust Engine, Compiler, Kernel) is saturated, the four
starter crops (Shell Script, Python Script, Web App, Go Service) pay a boost of up to **+15%**
(scaling with how collapsed the high tier is) as long as they are not overfarmed themselves - a
crop is either penalized or boosted, never both. This rewards planting what the market is short
on instead of printing one crop nonstop. Nothing else about the crop changes - cost, build time,
and unlock level are unaffected. The live factor is folded into each crop's `reward_coins` and
summarized as `market_state` (`normal`, `saturated`, or `boosted`); the server refreshes market
factors about every 30 seconds.
## Infrastructure, Defense, and Cosmetics (coin sinks) ## Infrastructure, Defense, and Cosmetics (coin sinks)
Once you are earning more coins than you can spend on plots and perks, three permanent systems Once you are earning more coins than you can spend on plots and perks, three permanent systems
give large coins a purpose: give large coins a purpose.
- **Infrastructure** - one-time, prestige-gated buildings bought with `POST /game/infrastructure/buy`: ### Infrastructure
**Private Registry** (Rust, Compiler, and Kernel crops grow 15% faster, prestige 3+), **Canary
Deployments** (every harvest has a 12% chance to double and a 6% chance to only refund its One-time, prestige-gated buildings bought with `POST /game/infrastructure/buy`:
planting cost, prestige 8+), and **Observability Suite** (raises the minimum coins you keep when
raided from 10% to 30%, prestige 15+). | Building | Key | Effect | Cost | Requires |
- **Defense** - an upgradeable building (`POST /game/defense/upgrade`) that lowers your raid losses |----------|-----|--------|-----:|:--------:|
and lengthens your protection window with every tier. Unlike a one-time purchase, it costs a | 📦 Private Registry | `registry` | Rust, Compiler, and Kernel builds finish 15% faster (watering them is 15% more effective too) | 25,000,000 | prestige 3 |
**daily coin upkeep** proportional to your balance; if you do not have enough coins to cover it, | 🐤 Canary Deployments | `canary` | Every harvest has a 12% chance to double and a 6% chance to only refund its planting cost | 75,000,000 | prestige 8 |
the tier decays by one level automatically. This is the sink that scales with wealth. | 🔭 Observability Suite | `observability` | Fixes the raid payout floor at 30% of a build's value (see Raiding and Defense) | 150,000,000 | prestige 15 |
- **Cosmetics** - purely cosmetic titles and plot skins bought with coins (`POST /game/cosmetics/buy`)
and equipped (`POST /game/cosmetics/equip`). Zero gameplay effect; an equipped title shows next ### Defense
to your name on the leaderboard.
An upgradeable building (`POST /game/defense/upgrade`) that lowers your raid losses and lengthens
your protection window per tier:
| Tier | Name | Upgrade cost | Daily upkeep (min) | Raid payout floor | Extra grace |
|:----:|------|-------------:|-------------------:|:-----------------:|:-----------:|
| 0 | Undefended | - | 0 | 10% | +0s |
| 1 | Firewall | 5,000 | 500 | 10% | +15s |
| 2 | WAF | 40,000 | 2,500 | 8% | +30s |
| 3 | SOC Monitoring | 300,000 | 15,000 | 6% | +60s |
| 4 | Zero Trust Mesh | 2,000,000 | 100,000 | 4% | +120s |
The payout floor is the minimum fraction of a build's value a raider takes from you (see Raiding
above); the Observability Suite replaces any tier floor with 30%. Grace stacks on top of the base
60 seconds and any Branch Protection levels.
Unlike a one-time purchase, Defense costs a **daily coin upkeep** of
`max(tier_minimum, 0.2% of your coin balance)` - a large balance pays real money, not a flat fee.
Upkeep is charged lazily whenever you load your own farm, for at most **2 days** at a time; if
your balance cannot cover what is due, your coins go to zero and the tier **decays by one
level**. The state reports `defense_level`, `defense_tier_name`, `defense_upkeep_daily` (today's
exact charge), and `defense_next_cost`.
### Cosmetics
Purely cosmetic titles and plot skins bought with coins (`POST /game/cosmetics/buy`) and, for
titles, equipped with `POST /game/cosmetics/equip`. Zero gameplay effect; an equipped title shows
next to your name on the leaderboard.
| Cosmetic | Key | Kind | Cost |
|----------|-----|:----:|-----:|
| 🏛️ The Architect | `title_architect` | title | 500,000 |
| ♻ Serial Refactorer | `title_refactorer` | title | 250,000 |
| ⚙ Kernel Hacker | `title_kernel_hacker` | title | 1,000,000 |
| 🌈 Neon Terminal | `skin_neon` | skin | 750,000 |
Era-exclusive cosmetics (when an Era awards one) appear in your owned list but are never sold in
the shop. The state's `cosmetics` list carries each purchasable cosmetic with an `owned` flag,
and `active_title` is your equipped title key.
## Mastery (beyond prestige) ## Mastery (beyond prestige)
Refactoring past **prestige 50** starts earning a second currency: one Mastery point immediately, Refactoring past **prestige 50** starts earning a second currency: one Mastery point the moment
then one more every 10 further prestige. Mastery points never expire, and once you have earned at you cross 50, then one more every 10 further prestige (`total earned = 1 + (prestige - 50) / 10`,
least one, the three Mastery-tier crops above stay unlocked even if you later spend the point. rounded down). Points never expire, and crop unlocks check points **ever earned**
Spend Mastery points with `POST /game/mastery` on three permanent upgrades: (`mastery_points_earned_total`), so spending never re-locks content. Spend points with
`POST /game/mastery` on three permanent upgrades:
| Mastery upgrade | Effect | Max level | | Mastery upgrade | Key | Effect | Cost (points) |
|------------------|--------|:---------:| |------------------|-----|--------|:-------------:|
| 🔁 Continuous Delivery | Auto-replant the same crop right after harvest, if affordable | 1 | | 🔁 Continuous Delivery | `autoreplant` | Auto-replant the same crop right after every harvest, if still affordable and unlocked | 3 |
| 📊 Farm Analytics | Unlocks lifetime stats on your farm | 1 | | 📊 Farm Analytics | `analytics` | Unlocks lifetime stats: `lifetime_coins_earned` and `lifetime_harvests` | 2 |
| 📜 Legacy Contracts | Unlocks a weekly contract slot paying Stars and a temporary coin boost | 1 | | 📜 Legacy Contracts | `contracts` | Unlocks a weekly contract slot paying Stars and a temporary coin boost | 4 |
The state reports `mastery_points`, `mastery_points_earned_total`, and each upgrade's level and cost. Each has a single level. The state reports `mastery_points` (spendable),
`mastery_points_earned_total`, and each upgrade's level and cost.
### Weekly contracts
With Legacy Contracts owned, one **weekly contract** appears in your `quests` list with
`scope: "weekly"` - a larger goal of the same four kinds, deterministic per user and ISO week.
Claiming it (`POST /game/quests/claim` with the contract's `quest` kind and `scope=weekly`) pays
**Stars** (`reward_stars`) plus experience, and grants a **+20% harvest-coin boost for 48
hours**.
## Refactor (prestige) ## Refactor (prestige)
At level 10 or above you can **refactor**: the farm resets to its starting coins, level, CI tier, At level 10 or above you can **refactor**: coins, XP, level, CI tier, perks, and extra plots
perks, and plots, but you gain a permanent **+25% coin bonus** that stacks with every refactor and reset, but you gain a permanent **+25% coin bonus per refactor** that multiplies with your perks.
multiplies with the Optimizer perk. Refactoring is the long-term progression and the single Refactoring is the long-term progression and the single largest factor in the leaderboard. The
largest factor in the leaderboard. The state reports `prestige`, `prestige_multiplier`, and state reports `prestige`, `prestige_multiplier`, `prestige_min_level`, and `prestige_available`.
`prestige_available`.
Refactoring is not free: it costs a **coin fee** that grows with both your prestige count and your Refactoring is not free. The fee is:
current coin balance (a wealth tax), so each refactor takes longer to afford than the last. The
live state reports the exact `refactor_cost` and whether you can pay it (`refactor_affordable`).
After the fee, **10%** of your remaining coins carry over into the new run - more with the Golden
Parachute Legacy upgrade (up to 35%), previewed as `refactor_carryover_preview`.
Each refactor also awards **stars**, a separate prestige currency (more at higher levels and higher ```
prestige). Stars are never reset and are spent on permanent **Legacy upgrades**. fee = round(20000 * (1 + prestige) * (1 + 0.25 * prestige) + 0.15 * coins)
```
so it grows with both your refactor count and your current balance (a wealth tax), and each
refactor takes longer to afford than the last. The live state reports the exact `refactor_cost`
and whether you can pay it right now (`refactor_affordable`). After the fee, **10%** of your
remaining coins carry over into the new run - up to **35%** with the Golden Parachute Legacy
upgrade - previewed exactly as `refactor_carryover_preview` (and `refactor_carryover_pct`). The
new run starts with the 50 starting coins plus that carry-over.
Each refactor also awards **Stars**: `1 + level / 5 + prestige` (rounded down, using the level
and prestige you refactor from). Stars are never reset and are spent on permanent **Legacy
upgrades**. What survives a refactor: prestige, Stars, Legacy upgrades, Mastery points and
upgrades, Infrastructure, Defense, cosmetics, lifetime stats, and your Era counters.
## Community treasury and weekly grant ## Community treasury and weekly grant
Every refactor fee flows into a shared **community treasury**. Once per week, an active farm that Every refactor fee flows into a shared **community treasury**. Once per ISO week, an active farm
is still building up - at least five harvests this week, fewer than 10,000 coins, and at most that is still building up - at least **5 harvests this week**, fewer than **10,000 coins**, and
prestige 5 - can claim a grant of up to 2,500 coins from it with `POST /game/grant`. The state at most **prestige 5** - can claim a grant of up to **2,500 coins** (capped by the treasury
reports `grant_available`, `grant_amount`, `grant_reason`, and the current `treasury_balance`. balance) with `POST /game/grant`. The state reports `grant_available`, `grant_amount`,
`grant_reason` (a human-readable explanation when unavailable), and the current
`treasury_balance`.
## Legacy upgrades ## Legacy upgrades
Stars buy Legacy upgrades, permanent boosts that persist through every future refactor. There are Stars buy Legacy upgrades, permanent boosts that persist through every future refactor. The cost
six: of level `n+1` is `round(base * growth^n)` Stars. There are six:
| Legacy upgrade | Effect | Max level | | Legacy upgrade | Key | Effect | Max level | First cost | Cost growth |
|----------------|--------|:---------:| |----------------|-----|--------|:---------:|-----------:|:-----------:|
| 🤖 CI Bot | Auto-collect ready builds when you open your farm | 1 | | 🤖 CI Bot | `autoharvest` | Auto-collect ready builds whenever you load your farm | 1 | 3 | - |
| 💎 Tech Debt Payoff | +10% harvest coins per level, stacks with refactor | 10 | | 💎 Tech Debt Payoff | `multiplier` | +10% harvest coins per level, stacks with refactor | 10 | 1 | 1.6x |
| 🏎️ Bare-Metal | +5% base build speed per level | 8 | | 🏎️ Bare-Metal | `speed` | +5% base build speed per level | 8 | 1 | 1.7x |
| 🗂️ Monorepo | +1 starting plot after each refactor per level | 4 | | 🗂️ Monorepo | `plots` | +1 starting plot after each refactor per level | 4 | 3 | 2.0x |
| 🛡️ Branch Protection | +30s steal grace and -5% steal loss per level | 5 | | 🛡️ Branch Protection | `defense` | +30s steal grace and -5% steal loss per level | 5 | 2 | 1.8x |
| 🪂 Golden Parachute | +5% refactor coin carry-over per level | 5 | | 🪂 Golden Parachute | `carryover` | +5% refactor coin carry-over per level | 5 | 2 | 1.8x |
The live state reports your `stars` balance and each legacy upgrade's current level and the exact The live state reports your `stars` balance and each legacy upgrade's current level and the exact
star cost of its next level. Star cost of its next level.
## Leaderboards and scoring ## Leaderboards and scoring
The default leaderboard ranks the top farms by a composite score. Refactors dominate the formula, The default leaderboard ranks the top 25 farms by a composite score. Refactors dominate the
then lifetime harvests, level, and everything else: formula, then lifetime harvests, level, and everything else:
``` ```
score = xp score = xp
@ -244,19 +358,32 @@ score = xp
+ min(streak, 30) * 15 + min(streak, 30) * 15
``` ```
Read the top farms with `GET {{ base }}/game/leaderboard` (public, no account needed). Add Read the top farms with `GET {{ base }}/game/leaderboard` (public, no account needed; results are
`?board=` to switch boards - `score` (default), `prestige`, `harvests` (this week only), `raids` cached server-side for about 15 seconds). Add `?board=` to switch boards:
(average coins per successful raid over the last 30 days, minimum 3 qualifying raids), `time_to_kernel`
(fastest to harvest a Kernel after your last refactor), `fair_play` (rewards recent activity over | Board | Ranked by |
hoarding coins), and `era` (the current Era's board, empty when no Era is running - see below). |-------|-----------|
| `score` | The composite score above (default). |
| `prestige` | Prestige count, Stars as tiebreak. |
| `harvests` | Harvests this ISO week (`harvests_week`). |
| `raids` | Average coins per successful raid over the last 30 days, minimum 3 qualifying raids; each entry carries `raid_avg`. |
| `time_to_kernel` | Fastest Kernel harvest since the player's last refactor; each entry carries `time_to_kernel_seconds`. |
| `fair_play` | `harvests_week * 50 - coins / 200000` - rewards recent activity over hoarding. |
| `era` | The current Era's score (below); empty when no Era is running. |
Every entry carries `rank`, `username`, `level`, `xp`, `coins`, `total_harvests`, `prestige`,
`score`, and `title` (the display name of the player's equipped cosmetic title, empty when none).
## Eras (seasons) ## Eras (seasons)
Administrators can occasionally start an **Era**: a fresh, visible "this season" leaderboard Administrators can occasionally start an **Era**: a fresh, visible "this season" leaderboard
(`board=era`) that everyone starts at zero on, while their real coins, prestige, Stars, Legacy, and (`board=era`) that everyone starts at zero on, while their real coins, prestige, Stars, Legacy,
Mastery are completely untouched. When an Era ends, the top 10 by Era score earn permanent Stars and Mastery are completely untouched. Era score is `era_coins / 20 + era_harvests * 10 + prestige
(and sometimes an Era-exclusive cosmetic). The state reports `era_active`, `era_name`, `era_coins`, * 2000`, so veterans keep an edge without it being insurmountable. When an Era ends, the top 10
and `era_harvests` when one is running. by Era score earn permanent Stars (50, 30, 20, 15, 10, then 5 each for ranks 6-10) and sometimes
an Era-exclusive cosmetic. Your own state reports `era_active`, `era_name`, `era_coins`, and
`era_harvests` while one is running; an Era can also make Era-exclusive crops plantable for its
duration.
## Live updates ## Live updates
@ -276,6 +403,7 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/game` | Your farm page (HTML, or the same JSON as `/game/state` when asked). |
| `GET` | `/game/state` | Your full farm state (always JSON). | | `GET` | `/game/state` | Your full farm state (always JSON). |
| `GET` | `/game/leaderboard` | Top farms (public). Accepts `?board=`. | | `GET` | `/game/leaderboard` | Top farms (public). Accepts `?board=`. |
| `GET` | `/game/farm/{username}` | Another member's farm. | | `GET` | `/game/farm/{username}` | Another member's farm. |
@ -289,7 +417,7 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind, optional `scope` (`daily` or `weekly`). | | `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind, optional `scope` (`daily` or `weekly`). |
| `POST` | `/game/prestige` | Refactor at level 10 or above; costs the current `refactor_cost` in coins. | | `POST` | `/game/prestige` | Refactor at level 10 or above; costs the current `refactor_cost` in coins. |
| `POST` | `/game/grant` | Claim the weekly community grant from the treasury. | | `POST` | `/game/grant` | Claim the weekly community grant from the treasury. |
| `POST` | `/game/legacy` | Buy a legacy upgrade (`key`) with stars. | | `POST` | `/game/legacy` | Buy a Legacy upgrade (`key`) with Stars. |
| `POST` | `/game/mastery` | Buy a Mastery upgrade (`key`) with Mastery points. | | `POST` | `/game/mastery` | Buy a Mastery upgrade (`key`) with Mastery points. |
| `POST` | `/game/infrastructure/buy` | Buy an Infrastructure building (`key`) with coins. | | `POST` | `/game/infrastructure/buy` | Buy an Infrastructure building (`key`) with coins. |
| `POST` | `/game/defense/upgrade` | Buy the next Defense tier with coins. | | `POST` | `/game/defense/upgrade` | Buy the next Defense tier with coins. |
@ -299,9 +427,36 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. | | `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. |
POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an
integer plot index), `crop`, `perk`, `quest`, `scope`, and `key` (a legacy/Mastery/Infrastructure/cosmetic integer plot index, 0-based), `crop`, `perk`, `quest`, `scope`, and `key` (a
key) where the table notes them. Every POST returns the full farm under `farm`, so one call both Legacy/Mastery/Infrastructure/cosmetic key) where the table notes them. Every own-farm POST
performs the action and gives you the new state. returns `{"ok": true, "farm": {...}}` - the full updated farm - so one call both performs the
action and gives you the new state. The two neighbour actions return the **neighbour's** farm as
you see it (`{"farm": {...}}`), and a successful steal adds `"stole_coins"` with your payout.
### Errors
An invalid action (not enough coins, wrong plot state, a still-protected harvest, an active
cooldown, an unmet requirement) returns **HTTP 400** with a human-readable reason:
```json
{"error": {"status": 400, "message": "Not enough coins to plant that."}}
```
An unknown farm username is **404**. Invalid credentials are **401**; a request with no
credentials at all is redirected (303) to the login page, so always send your API key. Mutating
requests count against the sitewide rate limit (per client IP, 60 per minute by default) - reads
do not.
### Two things that happen on state reads
Loading your own farm (`GET /game`, `GET /game/state`, or the farm returned by any action) is
when lazy owner-side effects run, so a scripted client should expect them:
- With the **CI Bot** Legacy upgrade, every ready build is auto-harvested (and with Continuous
Delivery, auto-replanted) during the read - the state you get back is post-collection.
- With a **Defense** building, any daily upkeep due is charged during the read, so your `coins`
can be lower than the previous response predicted (and the tier one lower, if you could not
pay).
### Reading the state ### Reading the state
@ -320,10 +475,10 @@ for plot in farm["plots"]:
print(plot["slot"], plot["state"], plot.get("crop_name"), plot.get("remaining_seconds")) print(plot["slot"], plot["state"], plot.get("crop_name"), plot.get("remaining_seconds"))
``` ```
### The farm state shape ### The plot shape
`GET /game/state` returns `{"ok": true, "farm": {...}}`. The farm carries your totals plus three `GET /game/state` returns `{"ok": true, "farm": {...}}`. The farm carries your totals plus the
lists you act on: `plots`, `crops`, and `quests`. A plot looks like this: lists you act on. A plot looks like this:
```json ```json
{ {
@ -331,7 +486,8 @@ lists you act on: `plots`, `crops`, and `quests`. A plot looks like this:
"state": "growing", "state": "growing",
"crop_key": "python", "crop_key": "python",
"crop_name": "Python Script", "crop_name": "Python Script",
"reward_coins": 45, "crop_icon": "🐍",
"reward_coins": 36,
"reward_xp": 5, "reward_xp": 5,
"ready_at": "2026-06-23T12:34:56+00:00", "ready_at": "2026-06-23T12:34:56+00:00",
"remaining_seconds": 73, "remaining_seconds": 73,
@ -340,13 +496,58 @@ lists you act on: `plots`, `crops`, and `quests`. A plot looks like this:
"can_water": false, "can_water": false,
"can_steal": false, "can_steal": false,
"steal_coins": 0, "steal_coins": 0,
"steal_cooldown_seconds": 0,
"steal_reason": "",
"is_golden": false,
"fertilize_cost": 22 "fertilize_cost": 22
} }
``` ```
A plot's `state` is `empty`, `growing`, or `ready`. An entry in `crops` carries the live `cost`, A plot's `state` is `empty`, `growing`, or `ready`. `reward_coins`/`reward_xp` on a plot are the
`reward_coins`, `grow_seconds`, `min_level`, and a `locked` flag, so a client can decide what is crop's **base** rewards; the multiplied live values are on the matching entry in `crops`. An
both unlocked and affordable without hard-coding the table above. entry in `crops` carries the live `cost`, `reward_coins`, `reward_xp`, `grow_seconds`,
`min_level`, `market_state`, and a `locked` flag, so a client can decide what is both unlocked
and affordable without hard-coding the tables above.
### The farm state reference
Every field on the `farm` object, grouped. Fields marked *(owner)* are only populated when you
read your **own** farm - on someone else's farm the lists are empty and the flags false/zero.
**Identity:** `owner_username`, `owner_uid`, `is_owner`.
**Progress and currency:** `coins`, `xp`, `level`, `level_into`, `level_span`, `level_is_max`,
`total_harvests`, `plot_count`, `max_plots`, `next_plot_cost` (0 when maxed).
**CI:** `ci_tier`, `ci_label`, `ci_speed`, `ci_next_tier`, `ci_next_label`, `ci_next_cost` (all
`next` fields 0/empty at the top tier).
**Lists:** `plots`, `crops`, plus *(owner)* `perks`, `quests` (daily entries, and the weekly
contract when unlocked), `legacy`, `mastery`, `infrastructure`, `cosmetics`. Each purchasable
entry carries `key`, `name`, `icon`, `description`, its current `level`/`owned` state, the exact
next `cost`, and a `maxed` flag where applicable.
**Refactor:** `prestige`, `prestige_multiplier`, `prestige_min_level`, `prestige_available`
*(owner)*, `refactor_cost`, `refactor_affordable` *(owner)*, `refactor_carryover_pct`,
`refactor_carryover_preview`, `stars`.
**Grant and treasury** *(owner)*: `grant_available`, `grant_amount`, `grant_reason`,
`treasury_balance`.
**Daily:** `streak`, `daily_available` *(owner)*, `daily_reward`.
**Raiding:** `steal_cooldown_seconds` (your remaining cooldown against this farm's owner; 0 on
your own farm).
**Mastery and lifetime stats:** `mastery_points`, `mastery_points_earned_total`,
`mastery_analytics_unlocked`, `lifetime_coins_earned`, `lifetime_harvests`, `harvests_week`.
**Defense:** `defense_level`, `defense_tier_name`, `defense_upkeep_daily`, `defense_next_cost`
(0 at the top tier).
**Cosmetics and boosts:** `active_title`, `underdog_boost_seconds_remaining`.
**Era:** `era_active`, `era_name`, `era_coins`, `era_harvests`.
### A complete automated farmer ### A complete automated farmer
@ -395,9 +596,11 @@ def best_affordable_crop(farm):
def claim_free_rewards(farm): def claim_free_rewards(farm):
if farm.get("daily_available"): if farm.get("daily_available"):
call("POST", "/game/daily") call("POST", "/game/daily")
if farm.get("grant_available"):
call("POST", "/game/grant")
for quest in farm.get("quests", []): for quest in farm.get("quests", []):
if quest.get("can_claim"): if quest.get("can_claim"):
call("POST", "/game/quests/claim", {"quest": quest["kind"]}) call("POST", "/game/quests/claim", {"quest": quest["kind"], "scope": quest["scope"]})
def harvest_and_replant(farm): def harvest_and_replant(farm):
@ -438,9 +641,10 @@ if __name__ == "__main__":
From here the natural extensions are easy: spend surplus coins with `POST /game/buy-plot` and From here the natural extensions are easy: spend surplus coins with `POST /game/buy-plot` and
`POST /game/upgrade` when you can afford them, raise perks with `POST /game/perk`, water `POST /game/upgrade` when you can afford them, raise perks with `POST /game/perk`, water
neighbours by walking `GET /game/farm/{username}` and posting to its `/water` path for plots where neighbours by walking `GET /game/farm/{username}` and posting to its `/water` path for plots where
`can_water` is true, and refactor with `POST /game/prestige` once `prestige_available` is set. Be `can_water` is true, raid where `can_steal` is true, and refactor with `POST /game/prestige` once
polite to the rate limiter: mutating calls are limited per account, so a poll interval of a minute `refactor_affordable` is set. Be polite to the rate limiter: mutating calls are limited per
or more with one action per finished build stays well within limits. client, so a poll interval of a minute or more with one action per finished build stays well
within limits.
## Ask Devii ## Ask Devii

View File

@ -1,363 +0,0 @@
<div class="docs-content" data-render>
# Get started with vibing
> **Alpha, admin-only.** Vibe coding is a preview feature. The runtime is still
> changing and access is currently limited to administrators. This guide is public
> so anyone can read how it works, but the create, start, terminal, and ingress
> actions described below only succeed for an administrator account.
**Vibing** is building software by talking to an AI agent instead of typing every
line yourself. On DevPlace you get a real Linux container in the cloud, a coding
agent that works like Claude Code, and a one-line way to put the result online.
You describe what you want, the agent writes and runs the code, and you ship it
under your own slug.
You can do every step from the admin **Containers** screens, but the friendliest
path is to simply ask **Devii**, the built-in assistant. This guide focuses on the
Devii way: each instruction below is plain English you can type into Devii, and the
note next to it names the tool Devii runs for you.
## What you get
- A **project** to hold your files (the persistent storage for your work).
- A **container** built from the shared `ppy` image, with your project files
mounted at `/app` and a broad toolchain preinstalled.
- Three AI agents baked into every container, all running on **your own API key**:
- **DevPlace Code (`dpc`)** - a coding agent in the same class as Claude Code.
- **`botje.py`** - a plug-and-play DevPlace bot you can copy and customise.
- **`pagent`** - a minimal, zero-dependency agent for small scripted tasks.
- **Ingress**: publish a port from your container to a public URL at `/p/<slug>`.
All AI usage from inside the container is metered to the account whose API key the
container carries, so your spend rolls up under your own profile, exactly like
direct API calls.
## The four steps, the Devii way
Open Devii from the user menu (the **Devii** item) and type these in order. Devii
asks for confirmation on anything destructive, so you stay in control.
**1. Create a project (storage).**
> "Create a project called Vibe Lab with the description: my first vibe-coded app."
Devii calls `create_project`. The project is the home for every file your container
produces.
**2. Attach a container to it.**
> "In Vibe Lab, create a container named lab that maps port 8000."
Devii calls `container_create_instance`. The instance runs the shared `ppy` image
with your project mounted at `/app`. A bare port like `8000` auto-assigns a unique
host port; use `host:container` only if you must pin one.
**3. Start the container.**
> "Start the lab container in Vibe Lab."
Devii calls `container_instance_action` with `action=start`. (If you set
`autostart` when creating it, it is already running and you can skip this.)
**4. Open a terminal.**
> "Open a terminal in the lab container."
Devii calls the `open_terminal` action, which opens a floating xterm.js window in
your browser attached to the container's interactive shell. From here you run
`dpc`, `botje.py`, or anything else.
You can also do everything without the terminal: ask Devii to run one-shot commands
with `container_exec` ("run `pip list` in the lab container"), read output with
`container_logs`, check resource use with `container_stats`, and import the
container's files back into the project with `container_instance_action`
`action=sync`.
## Inside the container
- The OS user is always **`pravda`** (uid 1000). This is deliberate: `/app` is
bind-mounted from the host, so writing as uid 1000 keeps file ownership correct.
- Your working directory is **`/app`**, which is your project's files. Anything you
create there can be synced back into the project.
- **`apt` and `sudo` work without real root.** `apt install <package>` installs
system packages through a fakeroot wrapper, and `sudo` runs the command as
`pravda` rather than switching to root. You cannot bind a port below 1024 (use a
high port plus ingress instead), but otherwise the environment behaves like a
normal box you own.
- Preinstalled tooling includes `git`, `curl`, `wget`, `vim`, `tmux`, `htop`, `nc`,
`zip`, the Apache benchmark tool `ab`, Playwright with Chromium, and a wide
Python stack (Flask, Django, FastAPI, uvicorn, pandas, numpy, requests, httpx,
beautifulsoup4, sqlalchemy, pytest, ruff, black, and more).
## DevPlace Code (dpc)
`dpc` is **DevPlace Code**, a terminal coding agent that provides the same kind of
experience as Claude Code: you give it a task, it reads and writes files, runs
commands, fixes what it broke, and iterates until the job is done. It is installed
at `/usr/bin/dpc` and ready to use the moment your container starts.
```bash
dpc "build a small FastAPI app in app.py that serves a JSON health check at /"
```
`dpc` reads your API key from the container environment (`PRAVDA_API_KEY`) and talks
to the platform AI gateway, so **all of its AI usage is metered through your own
account**. There is no separate key to manage and nothing to configure: it is plug
and play.
## Container environment keys
Every container is launched with these variables already set. Scripts and agents
inside the container read them to reach the platform and to attribute AI spend.
| Variable | What it contains |
|----------|------------------|
| `PRAVDA_BASE_URL` | The public base URL of this DevPlace instance. |
| `PRAVDA_OPENAI_URL` | The AI gateway endpoint, `PRAVDA_BASE_URL` + `/openai/v1`. |
| `PRAVDA_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
| `PRAVDA_USER_UID` | The DevPlace user id whose identity the container carries. |
| `PRAVDA_CONTAINER_NAME` | The instance's name. |
| `PRAVDA_CONTAINER_UID` | The instance's unique id. |
| `PRAVDA_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |
The key that lands in `PRAVDA_API_KEY` is resolved in order from the instance's
**run-as user**, then its creator, then the project owner. You can point a container
at a specific account by asking Devii to set `run_as_uid` when creating or
configuring it. The OS user stays `pravda`; only the identity and key change.
## botje.py - the plug-and-play bot
`botje.py` (installed at `/usr/bin/botje.py`) is a complete, ready-to-run DevPlace
bot. Start it with no arguments and it logs in with your `PRAVDA_API_KEY`, then
polls DevPlace for `@mentions` and direct messages and answers each one with a full
agent toolset. Give it a task on the command line and it runs that single task and
exits.
```bash
python /usr/bin/botje.py # run as a DevPlace bot (polling loop)
python /usr/bin/botje.py "summarise the latest news" # run one task and exit
```
**What it can do.** Behind both modes is a complete agent: read, write, edit, and
patch files; search with grep, glob, and symbol lookup; search the web and do deep
research; fetch and download URLs; describe images; run shell commands; and plan,
reflect, verify, and delegate to sub-agents for larger jobs.
**How it is configured.** Everything comes from the environment, so it is plug and
play inside a container:
| Variable | Effect |
|----------|--------|
| `PRAVDA_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
| `PRAVDA_BASE_URL` | Which DevPlace instance to talk to (already set). |
| `BOT_USERNAME` | The bot's own username, so it ignores its own posts. |
| `MENTION_POLL_SECONDS` | How often it checks for mentions (default 30). |
| `DM_POLL_SECONDS` | How often it checks for direct messages (default 10). |
| `DEVPLACE_MAX_ITERATIONS` | Upper bound on agent steps per task. |
**Make it your own.** `botje.py` is the reference bot, and it is meant to be
forked. Copy it into your project and vibe the changes with `dpc`:
```bash
cp /usr/bin/botje.py /app/mybot.py
dpc "in mybot.py, make the bot also reply 'pong' whenever a message contains the word ping"
python /app/mybot.py
```
Because the copy lives in `/app`, a `sync` saves it into your project so it
persists. You can run it as the container's boot command (ask Devii to set
`boot_command` to `python /app/mybot.py`) and add a `restart_policy` so it stays up.
## Ingress: host your app at /p/&lt;slug&gt;
Ingress publishes one container port to a public URL on the platform. Once set, your
app is reachable at `/p/<slug>` over both HTTP and WebSocket. The target host and
port are derived from the instance, never from user input, so there is no way to
point ingress at something you do not own.
Two values control it:
- **`ingress_slug`** - the public name. Lowercase letters, digits, and hyphens,
up to 63 characters, and unique across the whole platform.
- **`ingress_port`** - the container port to publish. It must be one of the ports
you mapped on the instance. If the instance maps exactly one port you can omit
this and it is chosen for you.
**Set it through Devii** at create time:
> "Create a container named web in Vibe Lab, map port 8000, and expose it publicly
> as vibe-lab on port 8000."
or on an existing instance by recreating it with the ingress fields, or by asking
Devii to configure the ports and ingress. The resulting URL is
`PRAVDA_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
`PRAVDA_INGRESS_URL` so your app can self-reference its own public address.
## Tutorial: vibe a web app and put it online
This is the full loop, start to finish, entirely through Devii and `dpc`.
**1. Create the project and an exposed container.** In Devii:
> "Create a project called Quote Wall. Then create a container named web in it, map
> port 8000, expose it publicly as quote-wall on port 8000, and start it."
Devii runs `create_project`, then `container_create_instance` with
`ports=8000`, `ingress_slug=quote-wall`, `ingress_port=8000`, `autostart=true`.
**2. Open a terminal.**
> "Open a terminal in the web container."
**3. Vibe the app with dpc.** In the terminal:
```bash
dpc "create app.py: a Flask app that serves an HTML page listing inspirational
quotes, with a form to add a new quote stored in quotes.json. Bind to
0.0.0.0 port 8000. Then run it."
```
`dpc` writes `app.py` and `quotes.json`, installs anything it needs, and starts the
server on port 8000 inside the container.
**4. Visit your live app.** Open `PRAVDA_BASE_URL` + `/p/quote-wall` in your browser.
The platform proxies the request straight to port 8000 in your container. Add a
quote in the form and watch it persist.
**5. Keep it running and save the work.** Back in Devii:
> "Set the web container's boot command to `python /app/app.py`, set its restart
> policy to unless-stopped, then sync it."
Devii configures the boot command and policy with `container_configure_instance`,
and `sync` (via `container_instance_action`) imports `app.py` and `quotes.json` back
into the Quote Wall project so they are saved. Your app now restarts on its own and
its source lives in your project.
That is the whole vibe loop: describe, run, expose, save. From here you iterate by
asking `dpc` for the next feature and refreshing `/p/quote-wall`.
## Tutorial: vibe a custom bot by changing botje
`botje.py` is the reference bot, and it is built to be changed. In this tutorial you
turn the stock bot into a **personal helpdesk bot** that recognises its own commands,
adds a brand-new agent tool, and remembers state between restarts - all by chaining
small `dpc` edits. You never edit the file by hand; you describe each change and let
`dpc` make it.
The pattern is the same every time:
1. Copy `botje.py` once into your project.
2. Ask `dpc` for one focused change.
3. Run the bot and try it from another account.
4. Ask `dpc` for the next change.
5. When it behaves, set it as the boot command and `sync` to save it.
**1. Start from a copy.** In a project's running container (the Vibe Lab or Quote
Wall from the steps above both work), open a terminal and copy the bot into `/app`
so it persists with the project:
```bash
cp /usr/bin/botje.py /app/helpdesk.py
```
**2. Add a custom command.** A command is just a phrase the bot recognises in a
mention or DM. Ask `dpc` to add one:
```bash
dpc "in /app/helpdesk.py, add a custom command: when a direct message starts with
'!help', reply with a short list of the commands this bot supports. Keep the
existing mention and DM behavior intact."
```
`dpc` reads the file, finds where incoming messages are handled, and inserts the
command without disturbing the rest. Run it and test from a second account:
```bash
python /app/helpdesk.py
```
DM the bot `!help` from another user and you should get the command list back.
**3. Give it a brand-new tool (the special functionality).** The bot answers with an
agent that has a fixed toolset. You extend that toolset the same way the built-in
tools are defined: a function decorated with `@tool`. Describe the tool you want and
let `dpc` wire it in:
```bash
dpc "in /app/helpdesk.py, add a new @tool called open_ticket(summary, priority) that
appends a ticket as one JSON line to /app/tickets.jsonl with an id, the summary,
the priority, and the current ISO timestamp, and returns the new ticket id.
Register it so the agent can call it, then teach the bot: when a DM starts with
'!ticket ', open a ticket from the rest of the message and reply with the id."
```
Now the bot can file tickets on request, and because the agent sees the tool in its
list it can also decide to open one on its own when a conversation clearly describes
a problem. Restart and test:
```bash
python /app/helpdesk.py
```
DM `!ticket the login page is slow` and confirm a line lands in
`/app/tickets.jsonl`.
**4. Add memory so it survives restarts.** State lives in plain files under `/app`,
which is exactly what persists and syncs:
```bash
dpc "in /app/helpdesk.py, add a !tickets command that reads /app/tickets.jsonl and
replies with the count of open tickets and the three most recent summaries.
Make the file read tolerant of it not existing yet."
```
**5. Refine the voice.** Chaining keeps working as long as you ask for one change at
a time:
```bash
dpc "in /app/helpdesk.py, make every reply start with 'Helpdesk:' and stay under two
sentences unless the user asked for a list."
```
**6. Run it on boot and save it.** Once the bot behaves, hand it to the container
service. In Devii:
> "Set this container's boot command to `python /app/helpdesk.py`, set its restart
> policy to unless-stopped, then sync it."
Devii configures the boot command and policy with `container_configure_instance`, and
`sync` imports `helpdesk.py` and `tickets.jsonl` back into the project so the whole
bot is saved. It now starts on its own, restarts if it stops, and answers on your own
API key.
**Where to take it next.** Because the bot already has file, web-search, deep-research,
fetch, vision, and shell tools, a single `dpc` prompt can teach it almost any new
behavior: summarise a URL someone sends, run a quick check and report the result,
post a daily digest, or escalate a ticket by mentioning an admin. Add one tool or one
command per prompt, test, and `sync`. That is how you vibe a bot with genuinely
special functionality without writing it from scratch.
## Limits and safety
- The feature is in **Alpha** and **admin-only**. Behaviour and limits may change.
- Devii **confirms before anything destructive**: deleting an instance and
destructive shell commands (`rm`, `dd`, `truncate`, dropping a database, and the
like) are refused until you explicitly confirm.
- You cannot bind ports below 1024 inside the container. Use a high port and
ingress to serve on the public web.
- AI usage from `dpc`, `botje.py`, and `pagent` is metered to the API key the
container carries. Keep an eye on your usage on your profile.
## Read next
- [Devii Assistant](/docs/devii.html) - everything the assistant can do for you.
- [DeepSearch](/docs/tools-deepsearch.html) and [SEO Diagnostics](/docs/tools-seo.html) -
the other tools you can drive conversationally.
{% if is_admin(user) %}
- [Container Manager](/docs/services-containers.html) - the full container runtime
reference: backends, reconciler, ingress internals, and schedules.
- [BotsService](/docs/services-bots.html) and [Bots internals](/docs/bots-internals.html) -
how the autonomous bot fleet is built on the same agent.
{% endif %}
</div>

View File

@ -1,113 +0,0 @@
<div class="docs-content" data-render>
# Container Manager
The Container Manager runs supervised container instances with full lifecycle control from the web UI,
the HTTP API, and Devii. Every instance runs ONE shared, prebuilt image; there is no per-project image
building inside the app. A reconciling `BaseService` supervises the instances.
> Audience: administrators. All run, exec, lifecycle, and schedule operations require an administrator,
> because running containers with bind mounts and access to the docker socket is root-equivalent on the
> host.
> Visibility scoping: container access inherits the project's visibility through
> `content.can_view_project`. A container attached to a project hidden by a **member** is visible to
> every administrator, but a container attached to a project hidden by an **administrator** is visible
> only to that owner administrator - every other administrator is blocked from listing it, opening a
> terminal on it, exec-ing in it, or managing it, on both the web UI and the REST API. The opt-in public
> ingress at `/p/{slug}` and the generic admin raw DB API `/dbapi` are deliberate exceptions and are not
> scoped this way.
## Pieces
- **Backend abstraction** (`services/containers/backend/`): a `Backend` ABC with a `DockerCliBackend`
that drives the `docker` CLI via `asyncio.create_subprocess_exec`, streaming stdout line by line. A
`FakeBackend` implements the same interface for tests with no daemon. The active backend is resolved
by `runtime.get_backend()` and is the pluggable seam for a future Kubernetes or remote backend.
- **Data model** (indexed in `database.py`): `instances`, `instance_events` (audit), `instance_metrics`
(ring-buffered), `instance_schedules`. There are no dockerfile or build tables.
- **ContainerService** (`service.py`): a reconciler. Each tick it snapshots `docker ps` (filtered by
the `devplace.instance` label), converges every instance to its `desired_state`, applies restart
policies, reaps orphan containers whose DB row is gone, fires due schedules, and samples metrics. The
`devplace.instance=<uid>` label is the join key, guaranteeing no orphans and no lost state. Only the
service lock owner reconciles; HTTP handlers only edit `desired_state`.
## The shared `ppy` image
Every instance runs `ppy:latest` (override with the `DEVPLACE_CONTAINER_IMAGE` env var), built **once**
with `make ppy` from `ppy.Dockerfile`. The image is a Python + Playwright base with a broad set of
common Python libraries preinstalled, and it bakes in the security model: the `pravda` user at uid/gid
`1000:1000`, a `sudo` superclone that never escalates (it runs commands as `pravda`, so a container can
never write a root-owned file into the bind-mounted workspace), and the stdlib `pagent` agent at
`/usr/bin/pagent.py`. Creating an instance is then an instant `docker run` with no build wait; it fails
fast with a clear error if the `ppy` image has not been built yet.
Projects that need a library not in the image install it at runtime with `pip install` (pravda owns the
site-packages, so no `sudo` is needed), or add it to `ppy.Dockerfile` and rerun `make ppy`.
## Instances and lifecycle
An instance is created with a name, optional boot command, env vars, CPU/memory limits, port maps, and
restart policy. The project's files are materialized to a persistent host workspace and bind-mounted
read-write as `/app`; the sync action imports container-side changes back into the project filesystem.
Lifecycle operations (start, stop, restart, pause, resume, delete) flip the instance's `desired_state`
and the reconciler executes them. Schedules use the shared cron/interval/one-time scheduler to start or
stop instances. One-shot exec runs over HTTP; an interactive shell runs over a PTY-backed WebSocket on
the lock owner.
Each launch also injects per-instance `PRAVDA_*` env vars (`PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`,
`PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`) so code inside the
container - and `pagent` - can call back into the platform and the AI gateway authenticated as the user.
## Runtime data and operations
All generated data lives OUTSIDE the `devplacepy/` package and is never served via `/static`:
persistent workspaces under `config.DATA_DIR/container_workspaces` (default `data/`, configurable with
the `DEVPLACE_DATA_DIR` env var - point it at a volume in production). The docker daemon must be able to
bind-mount `DATA_DIR` for the `/app` mount. The service is disabled by default (it needs the docker
socket); an administrator enables **Containers** on `/admin/services`, and the `ppy` image must be built
once with `make ppy`.
## Publishing a service (ingress)
A running instance can be exposed by setting an `ingress_slug` plus an `ingress_port` (one of its
mapped container ports). The `/p/<slug>` route (`routers/proxy.py`) then reverse-proxies both HTTP and
WebSocket traffic to that container's published host port, stripping the `/p/<slug>` prefix. It is
public but SSRF-safe: the target host and port are derived from the instance row, never from the
request. A container WebSocket server on port 8899 becomes reachable at `wss://<your-host>/p/<slug>/ws`.
Set the **Public URL** in admin settings (`/admin/settings`) to your production origin; the container
tools then return an absolute `ingress_url` (for example `https://your-host/p/<slug>`) so Devii and API
clients use the real public address rather than localhost.
## Production
The manager drives the host docker daemon, so production needs the opt-in overlay
`docker-compose.containers.yml`. The `make docker-build` and `make docker-up` targets apply it
automatically and self-derive its inputs, so it works with no `sudo`, no `/srv` dir, and no manual
`.env` editing; a plain `docker compose up -d` drops the overlay and re-breaks it. The overlay mounts
the docker socket (root on the host - trusted admins only), installs the docker CLI via the
`INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read from the socket via
`stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter. First, the data dir must be
bind-mounted at the **same absolute path** on host and in the app container (make sets
`DEVPLACE_DATA_DIR` to the project's own `./data`) because `docker run -v` resolves the source against
the host. Second, the ingress proxy reaches a published port through the container's own docker bridge
gateway plus the published host port: the reconciler records each instance's `container_ip` and
`container_gateway` from `docker inspect`, and the `/p/<slug>` proxy dials `gateway:host_port` by
default. This avoids the loopback path, which fails on hosts where docker's `nat OUTPUT` rule excludes
`127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port. It also
avoids the container's own bridge IP, which docker's bridge-isolation rule
(`DOCKER ! -i docker0 -o docker0 -j DROP`) can drop from the host. Set
`DEVPLACE_CONTAINER_PROXY_HOST` to override this (a containerized app via the docker
socket uses `host.docker.internal`); the proxy then dials that host with the published port
instead of the gateway. Before the reconciler has recorded a gateway, the proxy falls back to
`127.0.0.1`. Run `make ppy` on the production host too. Full steps are in the README
"Container Manager wiring".
## Observability, CLI, and Devii
The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95
runtime) are computed on read over the ring buffer. The CLI exposes
`devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a
one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's
admin `container_*` tools cover the same instance operations in natural language.
</div>

View File

@ -32,7 +32,7 @@ services:
dockerfile: nginx/Dockerfile dockerfile: nginx/Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-10500}:80" - "127.0.0.1:${PORT:-10500}:80"
environment: environment:
NGINX_CACHE_ENABLED: "${NGINX_CACHE_ENABLED:-false}" NGINX_CACHE_ENABLED: "${NGINX_CACHE_ENABLED:-false}"
NGINX_CACHE_MAX_SIZE: "${NGINX_CACHE_MAX_SIZE:-1g}" NGINX_CACHE_MAX_SIZE: "${NGINX_CACHE_MAX_SIZE:-1g}"

View File

@ -118,7 +118,8 @@ def test_harvest_crop(alice):
warp_ready("alice_test") warp_ready("alice_test")
open_game(page) open_game(page)
page.locator("form[data-game-action='harvest'] button").first.click() page.locator("form[data-game-action='harvest'] button").first.click()
expect(page.locator("[data-hud-coins]")).to_have_text("100006") expect(page.locator("[data-hud-coins]")).not_to_have_text("99995")
assert coins(page) > 99995
expect(page.locator(".game-plot-growing")).to_have_count(0) expect(page.locator(".game-plot-growing")).to_have_count(0)
@ -292,7 +293,15 @@ def test_leaderboard_panel_populates(alice):
reset_farm("alice_test") reset_farm("alice_test")
open_game(page) open_game(page)
page.locator(".game-lb-row").first.wait_for(state="visible") page.locator(".game-lb-row").first.wait_for(state="visible")
assert page.is_visible("a.game-lb-name:has-text('alice_test')") listed = False
for _ in range(8):
if page.is_visible("a.game-lb-name:has-text('alice_test')"):
listed = True
break
page.wait_for_timeout(3000)
page.reload(wait_until="domcontentloaded")
page.locator(".game-lb-row").first.wait_for(state="visible")
assert listed, "alice_test never appeared on the farm leaderboard within the 15s board cache TTL"
def test_infrastructure_hidden_below_prestige_requirement(alice): def test_infrastructure_hidden_below_prestige_requirement(alice):
@ -437,13 +446,17 @@ def test_crop_shows_saturated_label_when_overfarmed(alice):
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
reset_farm("alice_test") reset_farm("alice_test")
from devplacepy.services.game import economy
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
crop = economy.crop_for("shell")
worst_days = economy.MARKET_SATURATION_TIERS[-1][0]
get_table("game_market_ticks").insert( get_table("game_market_ticks").insert(
{ {
"uid": generate_uid(), "uid": generate_uid(),
"crop_key": "shell", "crop_key": "shell",
"hour_bucket": market_store._hour_bucket(now), "hour_bucket": market_store._hour_bucket(now),
"harvests": 600, "harvests": int(worst_days * 86400 / crop.grow_seconds) + 1,
"updated_at": now.isoformat(), "updated_at": now.isoformat(),
} }
) )

View File

@ -44,10 +44,16 @@ def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
vote_btn.click() vote_btn.click()
pa.wait_for_timeout(1500) pa.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded") ranked = False
body = pa.locator("body").text_content() for _ in range(15):
assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}" pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
assert pa.is_visible("a.leaderboard-name:has-text('bob_test')") body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}"
if pa.is_visible("a.leaderboard-name:has-text('bob_test')"):
ranked = True
break
pa.wait_for_timeout(5000)
assert ranked, "bob_test never appeared on the leaderboard within the 60s ranking cache TTL"
finally: finally:
ctx_a.close() ctx_a.close()
ctx_b.close() ctx_b.close()

View File

@ -311,18 +311,19 @@ def test_optimistic_send_pending_then_reconciles_no_double_render(alice, bob):
textarea.fill(msg) textarea.fill(msg)
page_a.locator(".messages-send-btn").click() page_a.locator(".messages-send-btn").click()
pending = page_a.locator(f".message-bubble.mine.pending:has-text('{msg}')") bubble = page_a.locator(f".message-bubble.mine:has-text('{msg}')").first
pending.wait_for(state="visible", timeout=5000) bubble.wait_for(state="visible", timeout=10000)
client_id = pending.get_attribute("data-client-id") client_id = bubble.get_attribute("data-client-id")
assert client_id assert client_id
reconciled = page_a.locator( reconciled = page_a.locator(
f".message-bubble.mine[data-client-id='{client_id}'][data-msg-uid]:not(.pending)" f".message-bubble.mine[data-client-id='{client_id}'][data-msg-uid]:not(.pending)"
) )
reconciled.wait_for(state="visible", timeout=6000) reconciled.wait_for(state="visible", timeout=10000)
assert page_a.locator(f".message-bubble.mine:has-text('{msg}')").count() == 1
received = page_b.locator(f".message-bubble.theirs:has-text('{msg}')") received = page_b.locator(f".message-bubble.theirs:has-text('{msg}')")
received.wait_for(state="visible", timeout=6000) received.wait_for(state="visible", timeout=10000)
assert received.count() == 1 assert received.count() == 1

View File

@ -397,10 +397,16 @@ def test_feed_vote_voted_state_persists(alice):
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click() page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-action-btn.vote-up").first.click() card = page.locator(
expect(page.locator(".post-vote-count").first).to_have_text("1") ".post-card", has_text="Feed vote persistence test content"
).first
card.locator(".post-action-btn.vote-up").click()
expect(card.locator(".post-vote-count")).to_have_text("1")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class( card = page.locator(
".post-card", has_text="Feed vote persistence test content"
).first
expect(card.locator(".post-action-btn.vote-up")).to_have_class(
re.compile(r"\bvoted\b") re.compile(r"\bvoted\b")
) )

View File

@ -231,20 +231,56 @@ def test_is_golden_requires_both_args():
def test_market_saturation_factor_is_full_when_fresh(): def test_market_saturation_factor_is_full_when_fresh():
assert economy.market_saturation_factor(0) == 1.0 assert economy.market_saturation_factor(0.0) == 1.0
def test_market_saturation_factor_steps_down(): def test_market_saturation_factor_steps_down():
assert economy.market_saturation_factor(40) < 1.0 assert economy.market_saturation_factor(8.0) < 1.0
assert economy.market_saturation_factor(600) == 0.40 assert economy.market_saturation_factor(96.0) == 0.40
assert economy.market_saturation_factor(40) > economy.market_saturation_factor(600) assert economy.market_saturation_factor(8.0) > economy.market_saturation_factor(96.0)
def test_market_saturation_factor_is_monotone_non_increasing():
previous = 1.0
step = 0
while step <= 400:
supply = step * 0.5
factor = economy.market_saturation_factor(supply)
assert factor <= previous
previous = factor
step += 1
def test_supply_days_normalizes_by_grow_time():
shell = economy.crop_for("shell")
kernel = economy.crop_for("kernel")
assert economy.supply_days(shell, 0) == 0.0
assert economy.supply_days(shell, 5760) == economy.supply_days(kernel, 24)
assert economy.supply_days(shell, 2880) == 1.0
assert economy.supply_days(kernel, 12) == 1.0
def test_market_buff_factor_only_applies_to_starter_crops(): def test_market_buff_factor_only_applies_to_starter_crops():
assert economy.market_buff_factor("kernel", 0.40) == 1.0 assert economy.market_buff_factor("kernel", 0.60) == 1.0
assert economy.market_buff_factor("shell", 1.0) == 1.0 assert economy.market_buff_factor("shell", 0.0) == 1.0
assert economy.market_buff_factor("shell", 0.40) > 1.0 assert economy.market_buff_factor("shell", 0.15) == 1.0375
assert economy.market_buff_factor("shell", 0.0) <= economy.MARKET_BUFF_CAP assert economy.market_buff_factor("shell", 0.60) == economy.MARKET_BUFF_CAP
assert economy.market_buff_factor("shell", 0.90) == economy.MARKET_BUFF_CAP
def test_market_factor_bounds_across_domain():
floor = economy.MARKET_SATURATION_TIERS[-1][1]
for key in economy.MARKET_TRACKED_CROPS:
crop = economy.crop_for(key)
for harvests in range(0, 300000, 977):
saturation = economy.market_saturation_factor(economy.supply_days(crop, harvests))
for pressure in (0.0, 0.15, 0.30, 0.45, 0.60):
factor = saturation if saturation < 1.0 else economy.market_buff_factor(key, pressure)
assert floor <= factor <= economy.MARKET_BUFF_CAP
if saturation < 1.0:
assert factor == saturation
else:
assert factor >= 1.0
def test_effective_reward_coins_applies_market_factor(): def test_effective_reward_coins_applies_market_factor():

View File

@ -371,6 +371,7 @@ def _insert_quest(user, kind, goal, progress=0, claimed=0, reward_coins=50, rewa
"user_uid": user["uid"], "user_uid": user["uid"],
"day": store._today(), "day": store._today(),
"slot_index": 0, "slot_index": 0,
"scope": "daily",
"kind": kind, "kind": kind,
"label": f"{kind} {goal}", "label": f"{kind} {goal}",
"goal": goal, "goal": goal,
@ -723,7 +724,9 @@ def test_harvest_records_market_tick_and_saturates_reward(local_db):
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
worst_tier_count = economy.MARKET_SATURATION_TIERS[-1][0] crop = economy.crop_for("shell")
worst_days = economy.MARKET_SATURATION_TIERS[-1][0]
worst_tier_count = int(worst_days * 86400 / crop.grow_seconds) + 1
get_table("game_market_ticks").insert( get_table("game_market_ticks").insert(
{ {
"uid": generate_uid(), "uid": generate_uid(),
@ -735,11 +738,52 @@ def test_harvest_records_market_tick_and_saturates_reward(local_db):
) )
store.plant(user, 0, "shell") store.plant(user, 0, "shell")
_warp(user) _warp(user)
crop = economy.crop_for("shell")
result = store.harvest(user, 0) result = store.harvest(user, 0)
assert result["coins"] < crop.reward_coins assert result["coins"] < crop.reward_coins
def _seed_market_ticks(crop_key, harvests):
from devplacepy.services.game.store import market as market_store
from devplacepy.utils import generate_uid
now = datetime.now(timezone.utc)
get_table("game_market_ticks").insert(
{
"uid": generate_uid(),
"crop_key": crop_key,
"hour_bucket": market_store._hour_bucket(now),
"harvests": harvests,
"updated_at": now.isoformat(),
}
)
market_store._saturation_cache.clear()
def _worst_tier_harvests(crop_key):
crop = economy.crop_for(crop_key)
worst_days = economy.MARKET_SATURATION_TIERS[-1][0]
return int(worst_days * 86400 / crop.grow_seconds) + 1
def test_starter_crop_boosted_when_market_saturated(local_db):
_reset("unit_a")
from devplacepy.services.game.store import market as market_store
_seed_market_ticks("kernel", _worst_tier_harvests("kernel"))
assert market_store.market_factor_for("kernel") == economy.MARKET_SATURATION_TIERS[-1][1]
assert market_store.market_factor_for("shell") == economy.MARKET_BUFF_CAP
assert market_store.market_factor_for("rust") == 1.0
def test_own_saturation_beats_boost(local_db):
_reset("unit_a")
from devplacepy.services.game.store import market as market_store
_seed_market_ticks("kernel", _worst_tier_harvests("kernel"))
_seed_market_ticks("shell", _worst_tier_harvests("shell"))
assert market_store.market_factor_for("shell") == economy.MARKET_SATURATION_TIERS[-1][1]
def test_harvest_records_a_market_tick_for_its_own_crop(local_db): def test_harvest_records_a_market_tick_for_its_own_crop(local_db):
user = _reset("unit_a") user = _reset("unit_a")
store.plant(user, 0, "shell") store.plant(user, 0, "shell")
@ -952,11 +996,11 @@ def test_end_era_awards_stars_to_top_participant(local_db):
user = _reset("unit_era_top") user = _reset("unit_era_top")
store.start_era("TestEra", 7) store.start_era("TestEra", 7)
_set(user, era_coins=50000, era_harvests=10) _set(user, era_coins=50000, era_harvests=10)
before_stars = int(store.get_farm(user["uid"])["stars"]) before_stars = int(store.get_farm(user["uid"])["stars"] or 0)
store.end_era() store.end_era()
farm = store.get_farm(user["uid"]) farm = store.get_farm(user["uid"])
assert int(farm["stars"]) > before_stars assert int(farm["stars"] or 0) > before_stars
assert int(farm["era_coins"]) == 0 assert int(farm["era_coins"] or 0) == 0
def test_era_gates_new_crops_and_leaderboard(local_db): def test_era_gates_new_crops_and_leaderboard(local_db):