Code Farm: add front-view server rack and API-backed terminal

This commit is contained in:
2026-10-19 21:32:45 +02:00
parent 7274cc1840
commit 8155f394bd
18 changed files with 1375 additions and 98 deletions
+4
View File
@@ -201,6 +201,10 @@ old eRepublik battles: settle tabs-versus-spaces by showing up daily and fightin
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
The game presents plots as individual servers in one front-view rack. Select a server to spin up a workload from the right-hand card picker, watch its build progress, and harvest the result. Buying a plot installs the next server, up to the existing twelve-server limit. Cooling reflects the Build Cache perk, networking reflects the CI tier, and power reflects the Optimizer perk; installed infrastructure also lights up the rack. These are visual representations of the existing upgrades and rules. Keyboard navigation, mobile controls, and the server-rendered no-JavaScript forms are supported.
The rack's **control terminal** supports every current game API route. Try `help`, `servers`, `plant 3 python`, `harvest all`, or `ci upgrade`; server numbers match the rack (1–12). Up/Down recalls commands and Tab completes commands and upgrade keys. `api` lists the route coverage, while `state` exposes the full JSON snapshot. Terminal commands and page controls share the same action handler and backend permissions. Refactoring, raids, infrastructure/cosmetic purchases, and defense downgrades retain confirmation dialogs. The terminal keeps drafts and history through live refreshes and reports actual control actions and completed builds.
- **Plant.** Plant a software project (a shell script, Python script, web app, Go service, Rust engine, compiler, or kernel) in an empty plot for a coin cost. Higher-tier crops unlock as your farm level rises.
- **Build and harvest.** A planted crop builds over real time; when the build finishes, harvest it for coins and XP. Harvesting also awards site XP and the **Green Thumb** / **Master Farmer** badges.
- **Upgrade CI.** Spend coins to raise your CI tier (Local Build through Distributed Cache); each tier makes every build faster.
+2 -2
View File
@@ -53,7 +53,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="GET",
path="/game",
title="Code Farm page",
summary="The player's own farm: HUD, plot grid, shop, and leaderboard.",
summary="The player's own farm: server rack, workload picker, control terminal, HUD, shop, and leaderboard.",
auth="user",
negotiation=True,
sample_response={"ok": True, "farm": {"coins": 50, "level": 1, "plots": []}},
@@ -275,7 +275,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/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, up to 60% with the Golden Parachute Legacy upgrade at level 10) 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 60% with the Golden Parachute Legacy upgrade at level 10) carries over. Reaching prestige 50 earns a permanent Mastery point, then every 5 more prestige earns another.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
+3 -1
View File
@@ -24,10 +24,12 @@ A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, me
## Live + frontend
After any mutation the handler `await`s `_shared.notify_farm(username)` which publishes a nudge to the `public.game.farm.{username}` pub/sub topic; subscribed clients re-fetch their viewer-specific state (keeps `can_water` correct without broadcasting per-viewer payloads). `static/js/GameFarm.js` (`app.gameFarm`, auto-detects `[data-game-root]`) renders the grid/HUD/leaderboard, ticks plot countdowns client-side every second, delegates the `data-game-action` forms through `Http.send`, subscribes to the farm topic, and keeps a 20s `Poller` fallback. The server renders a full no-JS fallback grid (`templates/_game_grid.html`, shared by `game.html` and `game_farm.html` with progressive-enhancement POST forms).
After any mutation the handler `await`s `_shared.notify_farm(username)` which publishes a nudge to the `public.game.farm.{username}` pub/sub topic; subscribed clients re-fetch their viewer-specific state (keeps `can_water` correct without broadcasting per-viewer payloads). `static/js/GameFarm.js` (`app.gameFarm`, auto-detects `[data-game-root]`) renders the HUD/leaderboard, ticks plot countdowns and progress client-side every second, delegates the `data-game-action` forms through `Http.send` using successful form controls, subscribes to the farm topic, and keeps a 20s `Poller` fallback. `GameDatacenter.js` renders one front-view rack with a real button per purchased plot, a purchase bay until the twelve-plot cap, and a right-hand workload/selected-server panel. Server/crop/filter selection survives live refreshes; buying a plot selects its new server. Arrow keys, Home/End, and previous/next controls navigate servers. Cooling, networking, and power visuals read the existing `growth` perk, CI tier, and `yield` perk; infrastructure ownership controls additional lights. No game rules or endpoints are added. `game-datacenter.css` scopes the rack palette and responsive layout. The decorative transparent artwork is `static/images/game/rack-front.png` (provenance and alignment in the adjacent README). Both page templates embed their serialized farm with Jinja's `tojson` for immediate enhancement and retain the full no-JS fallback grid (`templates/_game_grid.html`, shared by `game.html` and `game_farm.html` with progressive-enhancement POST forms).
## Fan-out
The rack also mounts the persistent `_game_terminal.html` partial through `GameTerminal.js`. Keep its DOM node when `GameDatacenter.refresh` or `GameFarm.render` rebuilds the rack: remount it in `[data-rack-terminal-slot]` so output, draft, caret focus, history and collapse state survive. `GameCommands.js` contains a JSON-compatible declarative catalog for every member/public game route; `tests/unit/services/game/terminal.py` compares its method/path and form fields against the actual routers/models. Commands use 1-based server numbers and translate to 0-based API slots. `GameFarm.performAction` is shared by forms and terminal commands, and only renders a response farm when its owner matches the displayed rack; social responses for another owner trigger a refresh of the current rack instead. Use shared `Http` and `app.dialog`, never eval, shell execution or a second request/confirmation implementation. Live API responses toggle Mastery, boosts, Era and CI Bot banners, and ownership of `skin_neon` applies its cosmetic rack treatment. The terminal's API listing/help are generated from the command catalog.
Devii plays via the `game_*` `http` actions in `actions/catalog/game.py` (state/leaderboard/view public, the rest `requires_auth`); the API reference has a **Code Farm** group in `docs_api.py`; pages are `noindex,follow` (interactive, user-specific) so they are intentionally not in the sitemap; badges live in `utils.BADGE_CATALOG` under the **Code Farm** group with `harvest`/`water`/`harvest_stolen`/`got_stolen_from` `ACHIEVEMENTS` (the steal pair awards **Cat Burglar** to the thief and **Robbed** to the victim, both threshold 1).
## Stealing (competitive loop, backwards compatible)
+359
View File
@@ -0,0 +1,359 @@
/* retoor <retoor@molodetz.nl> */
/* Code Farm's front-view rack palette; artwork is decorative, controls are HTML. */
.game-page {
--rack-bg: #101017;
--rack-panel: #181720;
--rack-card: #201f29;
--rack-line: #373541;
--rack-ink: #f2eff7;
--rack-muted: #aaa6ba;
--rack-accent: #ff982f;
--rack-accent-rgb: 255, 152, 47;
--rack-green: #87df83;
--rack-green-rgb: 135, 223, 131;
--rack-gold: #f1c76f;
--rack-blue: #6bb4ee;
--rack-purple: #c2a2ef;
--rack-metal-top: #656570;
--rack-metal: #33333e;
--rack-metal-dark: #181923;
--rack-metal-edge: #777782;
--rack-black: #07080d;
--rack-handle: #87868b;
color: var(--rack-ink);
max-width: 1280px;
padding-top: var(--space-xl);
}
.game-page [hidden] { display: none; }
.game-page .game-header { align-items: center; margin-bottom: var(--space-lg); }
.farm-eyebrow, .rack-eyebrow { color: var(--rack-muted); font: 0.6rem var(--font-mono); letter-spacing: 0.14em; }
.game-page .game-title h1 { font-size: 1.65rem; letter-spacing: -0.045em; margin: var(--space-xs) 0; }
.game-page .game-subtitle { color: var(--rack-muted); font-size: 0.8rem; }
.farm-guide { color: var(--rack-muted); text-decoration: none; font-size: 0.75rem; border: 1px solid var(--rack-line); border-radius: var(--radius); padding: var(--space-sm) var(--space-md); }
.farm-guide span { margin-left: var(--space-md); color: var(--rack-accent); }
.game-page .game-hud { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; background: var(--rack-panel); border: 1px solid var(--rack-line); border-radius: var(--radius-lg); margin-bottom: var(--space-md); }
.game-page .game-hud:has([data-hud-mastery-stat]:not([hidden])) { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.game-page[data-mode="view"] .game-hud { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.game-page .hud-stat { position: relative; background: transparent; border: 0; border-right: 1px solid var(--rack-line); border-radius: 0; padding: var(--space-md) var(--space-lg); min-width: 0; gap: 2px; }
.game-page .hud-stat:last-child { border-right: 0; }
.game-page .hud-label { display: flex; align-items: center; gap: var(--space-xs); font-size: 0.59rem; color: var(--rack-muted); letter-spacing: 0.07em; }
.hud-symbol { font-size: 0.8rem; color: var(--rack-accent); }
.game-page .hud-value { font-size: 1.25rem; color: var(--rack-ink); font-variant-numeric: tabular-nums; }
.game-page .hud-stat:first-child .hud-value, .game-page .hud-stat:nth-child(5) .hud-value { color: var(--rack-gold); }
.game-page .hud-sub { color: var(--rack-muted); font-size: 0.61rem; }
.game-page .hud-xp-bar { height: 2px; background: var(--rack-line); }
.game-page .hud-xp-fill { background: var(--rack-accent); }
.game-page > [data-era-banner] { font-size: 0.68rem; color: var(--rack-muted); margin-bottom: var(--space-md); }
.game-page > [data-era-banner]::before { content: "✦"; color: var(--rack-accent); margin-right: var(--space-sm); }
.farm-navigation { display: flex; flex-wrap: wrap; gap: var(--space-xl); border-bottom: 1px solid var(--rack-line); margin-bottom: var(--space-xl); }
.farm-navigation a { color: var(--rack-muted); text-decoration: none; font-size: 0.73rem; padding: var(--space-sm) 0 var(--space-md); border-bottom: 2px solid transparent; }
.farm-navigation .farm-nav-current { color: var(--rack-accent); border-bottom-color: var(--rack-accent); }
.farm-navigation a:hover, .farm-guide:hover { color: var(--rack-ink); }
#datacenter, #rack-console, #game-terminal, #infrastructure, #perks, #legacy, [id^="perk-"], [id^="infra-"] { scroll-margin-top: calc(var(--nav-height) + var(--space-xl)); }
.rack-workspace { display: grid; grid-template-columns: minmax(0, 0.88fr) minmax(0, 1fr); gap: var(--space-xl); align-items: start; margin-bottom: var(--space-2xl); }
.rack-stage, .rack-control-column { min-width: 0; }
.rack-heading { display: flex; justify-content: space-between; gap: var(--space-sm); align-items: center; margin: 0 var(--space-md) var(--space-sm); font: 0.58rem var(--font-mono); color: var(--rack-muted); letter-spacing: 0.07em; }
.rack-heading > span:last-child { display: inline-flex; align-items: center; gap: var(--space-xs); letter-spacing: 0; }
.rack-heading b { margin: 0 var(--space-xs); font-weight: 400; }
.rack-live-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--rack-green); }
.rack-cabinet { position: relative; width: 100%; max-width: 540px; margin: 0 auto; isolation: isolate; }
.rack-frame { display: block; width: 100%; height: auto; }
.rack-servers { position: absolute; left: 20.6%; right: 20.6%; top: 17.3%; bottom: 27.1%; display: grid; grid-template-rows: repeat(12, minmax(0, 1fr)); gap: 3px; padding: 2px; background: var(--rack-black); }
.rack-server, .rack-expansion button { position: relative; display: grid; grid-template-columns: 23px 25px minmax(0, 1fr) 23px; align-items: center; gap: 5px; min-width: 0; width: 100%; height: 100%; padding: 5px 9px 5px 5px; border: 1px solid var(--rack-metal-edge); border-radius: 3px; color: var(--rack-ink); background: linear-gradient(180deg, var(--rack-metal-top) 0, var(--rack-metal) 8%, var(--rack-metal-dark) 45%, var(--rack-metal-dark) 90%, var(--rack-metal) 100%); box-shadow: inset 0 0 0 2px var(--rack-black); text-align: left; cursor: pointer; font-family: var(--font-sans); }
.rack-server:hover, .rack-expansion button:hover { border-color: var(--rack-accent); }
.rack-server.is-selected { border: 2px solid var(--rack-accent); padding: 4px 8px 4px 4px; box-shadow: 0 0 8px rgba(var(--rack-accent-rgb), 0.22), inset 0 0 0 1px var(--rack-black); }
.rack-server.is-selected .server-number { color: var(--rack-green); }
.has-neon-skin .rack-frame { filter: drop-shadow(0 0 5px var(--rack-purple)); }
.has-neon-skin .rack-server { border-color: var(--rack-purple); }
.has-neon-skin .rack-server.is-selected { border-color: var(--rack-accent); }
.has-neon-skin .server-progress > i { background: linear-gradient(90deg, var(--rack-purple), var(--rack-blue), var(--rack-green)); }
.rack-server:focus-visible, .game-page a:focus-visible, .game-page button:focus-visible { outline: 2px solid var(--rack-accent); outline-offset: 3px; }
.server-number { display: grid; place-items: center; align-self: stretch; background: var(--rack-black); border: 1px solid var(--rack-line); border-radius: 2px; font: 0.65rem var(--font-mono); color: var(--rack-muted); }
.workload-symbol { display: inline-grid; place-items: center; flex-shrink: 0; font: 700 1.75rem var(--font-mono); letter-spacing: -0.09em; color: var(--rack-ink); width: 43px; height: 46px; line-height: 1; }
.symbol-python { color: var(--rack-blue); }
.symbol-webapp, .symbol-api { color: var(--rack-blue); }
.symbol-rust { color: var(--rack-accent); }
.symbol-haskell, .symbol-distsys { color: var(--rack-purple); }
.symbol-kernel { color: var(--rack-gold); }
.symbol-mlpipe { color: var(--rack-green); }
.symbol-secfort { color: var(--rack-blue); }
.symbol-idle { color: var(--rack-muted); }
.rack-server .workload-symbol { width: 25px; height: 30px; font-size: 1.05rem; }
.server-workload { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.server-workload > strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 0.7rem; font-weight: 500; line-height: 1.2; }
.server-build-line { display: flex; align-items: center; gap: 5px; min-width: 0; font: 0.49rem var(--font-mono); color: var(--rack-muted); }
.server-progress { flex: 1; min-width: 0; background: var(--rack-black); height: 4px; border-radius: 2px; border: 1px solid var(--rack-line); overflow: hidden; }
.server-progress > i { display: block; width: var(--build-progress, 0%); height: 100%; background: var(--rack-green); border-radius: inherit; }
.server-build-line [data-plot-timer] { flex-shrink: 0; }
.server-state-tag { font-size: 0.51rem; letter-spacing: 0.06em; color: var(--rack-muted); }
.game-plot-ready .server-state-tag { color: var(--rack-green); background: rgba(var(--rack-green-rgb), 0.12); border-radius: 2px; padding: 3px 5px; }
.server-golden-star, .is-golden .server-state-tag { color: var(--rack-gold); }
.server-drive-bay { align-self: stretch; display: grid; grid-template-columns: repeat(2, 3px); gap: 5px; align-content: center; justify-content: center; border: 1px solid var(--rack-black); background: radial-gradient(circle, var(--rack-black) 1px, transparent 1.5px) 0 0 / 4px 4px; border-radius: 2px; }
.server-drive-bay > i { width: 3px; height: 3px; border-radius: 50%; background: var(--rack-green); box-shadow: 0 0 4px rgba(var(--rack-green-rgb), 0.4); }
.game-plot-growing .server-drive-bay > i:last-child { animation: rack-activity 2.4s ease-in-out infinite; }
.game-plot-empty .server-drive-bay > i { background: var(--rack-line); box-shadow: none; }
.is-golden .server-drive-bay > i { background: var(--rack-gold); }
.server-handle { position: absolute; right: 3px; height: 70%; width: 3px; border: 1px solid var(--rack-metal-edge); border-radius: 2px; }
.rack-expansion { margin: 0; min-width: 0; }
.rack-expansion button { grid-template-columns: 23px 25px minmax(0, 1fr); background: var(--rack-metal-dark); border-color: var(--rack-line); font-size: 0.61rem; }
.rack-expansion button > span:last-child { display: flex; flex-wrap: wrap; gap: var(--space-xs); align-items: center; }
.rack-expansion strong { color: var(--rack-gold); font: 0.6rem var(--font-mono); }
.server-buy-icon { text-align: center; font-size: 1.1rem; color: var(--rack-muted); }
.rack-expansion button:disabled { opacity: 0.55; cursor: not-allowed; }
.rack-empty-bay { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xs); border-top: 1px solid var(--rack-metal-dark); border-bottom: 1px solid var(--rack-metal-dark); padding: var(--space-xs); font: 0.45rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--rack-muted); opacity: 0.4; }
.rack-empty-bay i { width: 5px; height: 5px; border: 1px solid var(--rack-line); }
.rack-hardware-state { pointer-events: none; position: absolute; inset: 0; }
.rack-cooling-lights { position: absolute; top: 7.6%; left: 29.4%; width: 40%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.rack-cooling-lights i { width: 14px; height: 14px; border-radius: 50%; box-shadow: 0 0 18px 6px rgba(var(--rack-green-rgb), 0.5); }
.rack-network-lights { position: absolute; left: 46%; top: 12.4%; display: flex; gap: 3px; }
.rack-network-lights i { width: 4px; height: 3px; background: var(--rack-green); box-shadow: 0 0 5px var(--rack-green); }
.rack-registry-light { position: absolute; top: 75.4%; left: 34%; font: 0.4rem var(--font-mono); background: var(--rack-black); color: var(--rack-green); padding: 2px; }
.rack-canary-light { position: absolute; top: 15.7%; left: 61%; color: var(--rack-gold); background: var(--rack-black); font: 0.4rem var(--font-mono); padding: 2px; }
.rack-monitor-label { position: absolute; top: 80.6%; left: 40%; font: 0.42rem var(--font-mono); color: var(--rack-blue); background: var(--rack-black); padding: 2px; }
.rack-monitor-label b { font-weight: 400; }
.rack-power-lights { display: flex; gap: 2px; position: absolute; left: 60.5%; top: 86.4%; }
.rack-power-lights i { width: 3px; height: 12px; background: var(--rack-green); box-shadow: 0 0 8px var(--rack-green); }
.rack-instructions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-md); color: var(--rack-muted); font-size: 0.64rem; margin: var(--space-sm) 0 0; text-align: center; }
.rack-instructions span { font: 0.57rem var(--font-mono); opacity: 0.7; }
@keyframes rack-activity { 0%, 45%, 100% { opacity: 1; } 50%, 85% { opacity: 0.25; } }
.rack-control-column { display: flex; flex-direction: column; gap: var(--space-md); }
.rack-console, .rack-hardware-panel, .rack-infrastructure-panel { background: linear-gradient(135deg, var(--rack-panel), var(--rack-bg)); border: 1px solid var(--rack-line); border-radius: var(--radius-lg); padding: var(--space-xl); }
.rack-console-heading { border-bottom: 1px solid var(--rack-line); margin-bottom: var(--space-xl); padding-bottom: var(--space-lg); }
.rack-console-heading h2 { font-size: clamp(1.8rem, 3.3vw, 2.5rem); letter-spacing: 0.01em; line-height: 1.1; margin: 0; font-weight: 700; }
.rack-console-title { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.rack-server-navigation { display: flex; gap: var(--space-xs); }
.rack-server-navigation button { width: 30px; height: 30px; background: var(--rack-card); border: 1px solid var(--rack-line); border-radius: var(--radius); color: var(--rack-muted); font: 0.9rem var(--font-mono); cursor: pointer; }
.rack-server-navigation button:disabled { opacity: 0.3; cursor: not-allowed; }
.rack-console-status { display: flex; align-items: center; gap: var(--space-sm); color: var(--rack-muted); font-size: 0.9rem; margin: var(--space-sm) 0 0; }
.rack-console-status i { width: 5px; height: 5px; border-radius: 50%; background: var(--rack-muted); }
.rack-console-status.state-growing i, .rack-console-status.state-ready i { background: var(--rack-green); }
.plot-inspector:has(.plot-plant-form) { display: block; }
.plot-inspector { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md); }
.plot-inspector > .plot-icon, .plot-inspector > .plot-crop-name { display: none; }
.plot-inspector .plot-timer { flex-basis: 100%; color: var(--rack-ink); font-size: 2.2rem; line-height: 1.2; }
.plot-inspector .plot-water-count { color: var(--rack-muted); font-size: 0.75rem; flex-basis: 100%; }
.plot-inspector .plot-ready-label { color: var(--rack-green); }
.game-page .btn { background: var(--rack-card); color: var(--rack-ink); border: 1px solid var(--rack-line); border-radius: var(--radius); font-size: 0.74rem; font-weight: 600; box-shadow: none; }
.game-page .btn:hover { border-color: var(--rack-accent); background: var(--rack-metal); }
.game-page .btn-primary { background: var(--rack-accent); color: var(--rack-black); border-color: var(--rack-accent); }
.game-page .btn-primary:hover { background: var(--rack-gold); color: var(--rack-black); }
.game-page .btn-danger { border-color: var(--danger); color: var(--danger); }
.game-page .btn:disabled { opacity: 0.45; cursor: not-allowed; }
.build-catalog-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); margin-bottom: var(--space-md); }
.build-catalog-heading h3 { font-size: 1.08rem; font-weight: 600; color: var(--rack-ink); margin: 0; letter-spacing: -0.025em; }
.build-filters { display: flex; gap: var(--space-xs); }
.build-filters button { padding: var(--space-xs) var(--space-sm); border: 1px solid transparent; border-radius: var(--radius); background: transparent; color: var(--rack-muted); font: 0.6rem var(--font-sans); cursor: pointer; }
.build-filters button[aria-pressed="true"] { border-color: var(--rack-line); color: var(--rack-accent); background: var(--rack-card); }
.build-catalog { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); border: 0; padding: 0; margin: 0; min-width: 0; }
.build-card { position: relative; display: grid; grid-template-columns: 43px minmax(0, 1fr); gap: var(--space-sm); align-items: center; padding: var(--space-md); min-height: 94px; min-width: 0; border: 1px solid var(--rack-line); border-radius: var(--radius); background: linear-gradient(135deg, var(--rack-card), var(--rack-panel)); cursor: pointer; transition: border-color 0.15s; }
.build-card:hover { border-color: var(--rack-metal-edge); }
.build-card:has(input:checked) { border-color: var(--rack-accent); box-shadow: inset 0 0 0 1px var(--rack-accent), 0 0 10px rgba(var(--rack-accent-rgb), 0.1); }
.build-card:has(input:focus-visible) { outline: 2px solid var(--rack-accent); outline-offset: 3px; }
.build-card input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.build-card-content { display: flex; flex-direction: column; min-width: 0; gap: var(--space-xs); }
.build-card-content > strong { font-size: 0.79rem; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
.build-card-facts { display: flex; flex-wrap: wrap; gap: var(--space-xs) var(--space-sm); font-size: 0.61rem; color: var(--rack-muted); }
.build-card-cost { color: var(--rack-gold); }
.build-card-reward { color: var(--rack-green); }
.build-card-xp, .build-card-lock { color: var(--rack-muted); font-size: 0.57rem; line-height: 1.4; }
.build-card-market { color: var(--rack-gold); }
.build-card.is-unavailable { cursor: not-allowed; }
.build-card.is-unavailable .workload-symbol { color: var(--rack-muted); opacity: 0.55; }
.build-card-check { position: absolute; top: var(--space-sm); right: var(--space-sm); width: 15px; height: 15px; border-radius: 50%; background: var(--rack-accent); color: var(--rack-black); display: grid; place-items: center; font-size: 0.6rem; opacity: 0; }
.build-card:has(input:checked) .build-card-check { opacity: 1; }
.advanced-catalog { padding: var(--space-md); border: 1px solid var(--rack-line); border-radius: var(--radius); margin: var(--space-lg) 0 0; min-width: 0; }
.advanced-catalog > legend { font-size: 0.67rem; color: var(--rack-muted); padding: 0 var(--space-xs); }
.advanced-catalog > div { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); }
.advanced-catalog .build-card { grid-template-columns: 22px minmax(0, 1fr); gap: var(--space-xs); padding: var(--space-sm); min-height: 66px; }
.advanced-catalog .workload-symbol { font-size: 1.3rem; width: 22px; }
.advanced-catalog .build-card-content > strong { font-size: 0.6rem; color: var(--rack-muted); font-weight: 500; }
.advanced-catalog .build-card-lock { font-size: 0.49rem; }
.build-deploy-bar { margin-top: var(--space-lg); display: flex; flex-direction: column; align-items: center; gap: var(--space-sm); }
.build-deploy-bar .btn { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--space-sm); width: 100%; min-height: 54px; font-size: 0.9rem; box-shadow: 0 0 14px rgba(var(--rack-accent-rgb), 0.11); }
.build-deploy-bar > span { font-size: 0.59rem; color: var(--rack-muted); }
.deploy-server-icon { font-size: 1.5rem; margin-right: var(--space-sm); }
.running-workload { display: flex; align-items: center; gap: var(--space-lg); margin: 0 0 var(--space-lg); }
.running-workload .workload-symbol { font-size: 2.5rem; width: 58px; }
.running-workload h3 { font-size: 1.1rem; margin: var(--space-xs) 0; }
.running-workload p { color: var(--rack-muted); font-size: 0.74rem; line-height: 1.6; margin: 0; }
.running-workload .server-golden-star { font-size: 0.67rem; white-space: nowrap; }
.running-monitor { border: 1px solid var(--rack-line); background: var(--rack-bg); border-radius: var(--radius); padding: var(--space-lg); margin-bottom: var(--space-xl); }
.running-monitor-top { display: flex; justify-content: space-between; font: 0.58rem var(--font-mono); color: var(--rack-muted); }
.running-monitor-top > span:last-child { color: var(--rack-green); }
.running-monitor-track { height: 5px; border-radius: 2px; background: var(--rack-line); overflow: hidden; margin: var(--space-md) 0 var(--space-lg); }
.running-monitor-track > i { display: block; width: var(--build-progress, 0%); height: 100%; background: var(--rack-green); }
.running-monitor-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); }
.running-monitor-stats > span { display: flex; flex-direction: column; gap: var(--space-xs); font-size: 0.6rem; color: var(--rack-muted); }
.running-monitor-stats strong { color: var(--rack-ink); font: 0.75rem var(--font-mono); }
.rack-hardware-panel, .rack-infrastructure-panel { padding: var(--space-lg); }
.rack-hardware-panel h3, .rack-infrastructure-panel h3 { margin: 0 0 var(--space-md); color: var(--rack-accent); font-size: 0.7rem; letter-spacing: 0.06em; }
.rack-upgrade-grid, .rack-infrastructure-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); }
.rack-upgrade-card { display: grid; grid-template-columns: 38px minmax(0, 1fr) 8px; gap: var(--space-xs); align-items: center; min-width: 0; padding: var(--space-sm); background: var(--rack-card); border: 1px solid var(--rack-line); border-radius: var(--radius); color: var(--rack-ink); text-decoration: none; }
.rack-upgrade-card:hover, .rack-infra-card:hover { border-color: var(--rack-accent); }
.rack-upgrade-card > span:nth-child(2) { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; }
.rack-upgrade-card strong { font-size: 0.65rem; }
.rack-upgrade-card small { font-size: 0.55rem; color: var(--rack-muted); }
.rack-upgrade-card > span > span { font-size: 0.48rem; color: var(--rack-muted); overflow-wrap: anywhere; }
.rack-upgrade-card > b { color: var(--rack-gold); font-size: 1.3rem; font-weight: 400; }
.hardware-art { display: block; width: 38px; height: 44px; background-image: url("../images/game/rack-front.png"); background-repeat: no-repeat; background-size: 132px auto; border: 1px solid var(--rack-black); border-radius: 4px; }
.hardware-art-cooling { background-position: 48% 6%; }
.hardware-art-network { background-position: 48% 13%; }
.hardware-art-power { background-position: 64% 89%; }
.rack-infra-card { display: grid; grid-template-columns: 23px minmax(0, 1fr); gap: var(--space-sm); align-items: center; min-width: 0; text-decoration: none; border: 1px solid var(--rack-line); border-radius: var(--radius); background: var(--rack-card); padding: var(--space-md) var(--space-sm); color: var(--rack-ink); }
.rack-infra-icon { grid-row: span 2; font-size: 1.2rem; }
.rack-infra-card strong { font-size: 0.64rem; font-weight: 500; overflow-wrap: anywhere; }
.rack-infra-card > span:last-child { color: var(--rack-muted); font-size: 0.55rem; line-height: 1.4; }
.rack-infra-card.is-owned > span:last-child { color: var(--rack-green); }
.rack-visitor-note { display: flex; gap: var(--space-md); background: var(--rack-panel); border: 1px solid var(--rack-line); border-radius: var(--radius); padding: var(--space-lg); }
.rack-visitor-note p { color: var(--rack-muted); font-size: 0.78rem; line-height: 1.7; margin: 0; }
.rack-visitor-note > span { color: var(--rack-accent); font-size: 1.5rem; }
.game-terminal { min-width: 0; border: 1px solid var(--rack-line); border-radius: var(--radius-lg); overflow: hidden; background: var(--rack-black); }
.game-terminal-header { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--rack-line); background: var(--rack-panel); }
.terminal-window-lights { display: flex; gap: 4px; }
.terminal-window-lights i { display: block; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
.terminal-window-lights i:nth-child(2) { background: var(--rack-gold); }
.terminal-window-lights i:nth-child(3) { background: var(--rack-green); }
.game-terminal-header h3 { flex: 1; min-width: 0; margin: 0; font: 0.67rem var(--font-mono); color: var(--rack-ink); }
.game-terminal-header h3 span { color: var(--rack-muted); font-size: 0.59rem; }
.game-terminal-header button { width: 26px; height: 26px; border: 1px solid var(--rack-line); background: transparent; color: var(--rack-muted); border-radius: var(--radius); cursor: pointer; }
.game-terminal-output { min-height: 180px; max-height: 270px; overflow: auto; overscroll-behavior: contain; padding: var(--space-md) var(--space-lg); scrollbar-color: var(--rack-line) var(--rack-black); }
.terminal-line { margin: 0 0 var(--space-sm); padding: 0; border: 0; background: transparent; white-space: pre-wrap; overflow-wrap: anywhere; font: 0.67rem/1.7 var(--font-mono); color: var(--rack-muted); tab-size: 2; }
.terminal-welcome, .terminal-hint { color: var(--rack-muted); }
.terminal-command { color: var(--rack-accent); margin-top: var(--space-md); }
.terminal-success { color: var(--rack-green); }
.terminal-error { color: var(--danger); }
.terminal-activity { color: var(--rack-blue); }
.game-terminal-form { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); margin: 0; border-top: 1px solid var(--rack-line); }
.game-terminal-prompt { color: var(--rack-green); font: 0.72rem var(--font-mono); white-space: nowrap; }
.game-terminal-prompt > span { color: var(--rack-muted); margin: 0 3px; }
.game-terminal-form input { flex: 1; min-width: 0; width: 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; color: var(--rack-ink); font: 0.73rem var(--font-mono); padding: var(--space-sm) 0; caret-color: var(--rack-accent); }
.game-terminal-form input:focus { outline: none; box-shadow: none; }
.game-terminal-form:focus-within { box-shadow: inset 2px 0 var(--rack-accent); }
.game-terminal-form input::placeholder { color: var(--rack-muted); opacity: 0.6; }
.game-terminal-form button { width: 30px; height: 30px; border: 1px solid var(--rack-line); border-radius: var(--radius); background: var(--rack-card); color: var(--rack-accent); cursor: pointer; }
.game-terminal-form button:disabled { opacity: 0.4; cursor: wait; }
.game-terminal-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm); align-items: center; padding: var(--space-xs) var(--space-md) var(--space-sm); color: var(--rack-muted); font: 0.52rem var(--font-mono); }
.game-terminal-footer b { color: var(--rack-line); font-weight: 400; }
.game-terminal-footer > div { display: flex; gap: var(--space-xs); }
.game-terminal-footer button { background: transparent; border: 1px solid var(--rack-line); border-radius: var(--radius); color: var(--rack-muted); font: inherit; padding: 3px var(--space-sm); cursor: pointer; }
.game-terminal-footer button:hover { color: var(--rack-accent); border-color: var(--rack-accent); }
.game-page .game-layout { grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-lg); }
.game-page .game-shop, .game-page .perk-card, .game-side > section { background: var(--rack-panel); border: 1px solid var(--rack-line); border-radius: var(--radius-lg); }
.game-page .game-side { background: transparent; padding: 0; border: 0; display: flex; flex-direction: column; gap: var(--space-md); }
.game-side > section { padding: var(--space-lg); min-width: 0; margin: 0; }
.game-page .game-section-title { font-size: 0.97rem; letter-spacing: -0.02em; }
.game-side .game-section-title { font-size: 0.82rem; }
.game-side .game-quests { order: -1; }
.game-page .daily-streak, .game-page .daily-claimed, .game-page .game-lb-empty { color: var(--rack-muted); font-size: 0.72rem; line-height: 1.7; }
.game-page .shop-row { padding: var(--space-md) 0; }
.game-page .shop-info span, .game-page .perk-effect, .game-page .game-legacy-note { color: var(--rack-muted); font-size: 0.73rem; line-height: 1.6; }
.game-page .perk-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--space-sm); }
.game-page .perk-card { padding: var(--space-md); }
.game-page .perk-level { color: var(--rack-accent); }
.game-page .perk-maxed { color: var(--rack-green); }
.game-page .legacy-card { border-color: var(--rack-line); }
.game-page .game-mastery, .game-page .game-cosmetics { margin-top: var(--space-xl); }
.game-page .quest-item { background: var(--rack-bg); border-color: var(--rack-line); padding: var(--space-md); gap: var(--space-sm); }
.game-page .quest-head { font-size: 0.7rem; line-height: 1.5; }
.game-page .quest-reward, .game-page .quest-claimed { color: var(--rack-green); }
.game-page .quest-bar { height: 3px; background: var(--rack-line); }
.game-page .quest-fill { background: var(--rack-accent); }
.game-page .quest-foot { color: var(--rack-muted); font-size: 0.63rem; }
.game-page .game-lb-row { gap: var(--space-xs); padding: var(--space-sm) 0; }
.game-page .game-lb-name { color: var(--rack-ink); font-size: 0.75rem; }
.game-page .game-lb-score, .game-page .game-lb-level, .game-page .game-lb-rank { color: var(--rack-muted); font-size: 0.62rem; }
.game-page .game-lb-title { color: var(--rack-accent); font-size: 0.6rem; }
.game-page .game-lb-self { background: var(--rack-card); }
.game-page [data-lb-board] { width: 100%; background: var(--rack-bg); color: var(--rack-muted); border: 1px solid var(--rack-line); border-radius: var(--radius); padding: var(--space-sm); font-size: 0.73rem; margin-bottom: var(--space-md); }
@media (max-width: 1024px) {
.rack-workspace { gap: var(--space-md); grid-template-columns: minmax(0, 0.95fr) minmax(0, 1fr); }
.rack-console { padding: var(--space-lg); }
.build-card { grid-template-columns: 30px minmax(0, 1fr); padding: var(--space-sm); gap: var(--space-xs); }
.build-card .workload-symbol { width: 30px; font-size: 1.4rem; }
.build-card-content > strong { font-size: 0.73rem; }
.build-card-facts { font-size: 0.57rem; }
.rack-upgrade-grid { grid-template-columns: minmax(0, 1fr); }
.rack-upgrade-card { grid-template-columns: 38px minmax(0, 1fr) 12px; }
.rack-upgrade-card > span:nth-child(2) { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
.rack-infrastructure-grid { grid-template-columns: minmax(0, 1fr); }
.rack-infra-card { grid-template-columns: 23px minmax(0, 1fr) minmax(0, 1fr); }
.rack-infra-icon { grid-row: auto; }
.game-page .game-layout { grid-template-columns: minmax(0, 1fr); }
.game-page .game-side { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
}
@media (max-width: 768px) {
.rack-workspace { grid-template-columns: minmax(0, 1fr); gap: var(--space-xl); }
.rack-cabinet { max-width: 440px; }
.rack-heading { max-width: 420px; margin-left: auto; margin-right: auto; }
.rack-control-column { width: 100%; }
.rack-upgrade-grid, .rack-infrastructure-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.rack-upgrade-card > span:nth-child(2) { flex-direction: column; align-items: flex-start; gap: var(--space-xs); }
.rack-infra-card { grid-template-columns: 23px minmax(0, 1fr); }
.rack-infra-icon { grid-row: span 2; }
.game-page .hud-stat { padding: var(--space-md); }
.game-page .hud-sub { font-size: 0.55rem; }
.build-card { grid-template-columns: 43px minmax(0, 1fr); padding: var(--space-md); }
.build-card .workload-symbol { width: 43px; font-size: 1.75rem; }
}
@media (max-width: 480px) {
.rack-stage { overflow: hidden; }
.rack-cabinet { width: 118%; max-width: none; margin-left: -9%; margin-right: -9%; }
.game-page .game-hud, .game-page .game-hud:has([data-hud-mastery-stat]:not([hidden])) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.game-page .hud-stat { border: 0; }
.game-page .hud-stat:first-child { grid-column: span 2; }
.game-page[data-mode="view"] .hud-stat:first-child { grid-column: auto; }
.game-page .game-title h1 { font-size: 1.45rem; }
.game-page .game-header { gap: var(--space-sm); }
.farm-guide { padding: var(--space-xs) var(--space-sm); font-size: 0.65rem; }
.farm-navigation { gap: var(--space-lg); }
.farm-navigation a { font-size: 0.65rem; }
.rack-heading { margin-left: var(--space-sm); margin-right: var(--space-sm); font-size: 0.5rem; }
.rack-servers { gap: 2px; padding: 1px; }
.rack-server, .rack-expansion button { grid-template-columns: 18px 20px minmax(0, 1fr) 15px; gap: 3px; padding: 4px; }
.rack-server.is-selected { padding: 3px; }
.rack-server .workload-symbol { width: 20px; font-size: 0.85rem; }
.server-number { font-size: 0.53rem; }
.server-workload > strong { font-size: 0.58rem; }
.server-build-line, .server-state-tag { font-size: 0.42rem; }
.server-build-line { gap: 3px; }
.server-drive-bay { grid-template-columns: 3px; gap: 3px; }
.server-drive-bay > i:nth-child(2n) { display: none; }
.server-handle { display: none; }
.rack-expansion button { grid-template-columns: 18px 20px minmax(0, 1fr); font-size: 0.53rem; }
.rack-expansion strong { font-size: 0.53rem; }
.rack-empty-bay { font-size: 0.35rem; }
.rack-console { padding: var(--space-lg); }
.rack-console-heading h2 { font-size: 1.8rem; }
.rack-console-status { font-size: 0.75rem; }
.build-catalog-heading { flex-wrap: wrap; }
.build-catalog-heading h3 { font-size: 0.95rem; }
.build-card { grid-template-columns: 30px minmax(0, 1fr); padding: var(--space-sm); }
.build-card .workload-symbol { width: 30px; font-size: 1.4rem; }
.build-card-content > strong { font-size: 0.7rem; }
.build-card-facts { font-size: 0.53rem; gap: var(--space-xs); }
.advanced-catalog { padding: var(--space-sm); }
.advanced-catalog > div { grid-template-columns: minmax(0, 1fr); }
.advanced-catalog .build-card { grid-template-columns: 25px minmax(0, 1fr); min-height: 44px; }
.advanced-catalog .build-card-content { flex-direction: row; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.advanced-catalog .workload-symbol { height: 25px; }
.advanced-catalog .build-card-lock { font-size: 0.55rem; flex-shrink: 0; }
.build-deploy-bar .btn { font-size: 0.8rem; }
.rack-upgrade-grid, .rack-infrastructure-grid { grid-template-columns: minmax(0, 1fr); }
.rack-upgrade-card > span:nth-child(2) { flex-direction: row; align-items: center; gap: var(--space-sm); }
.rack-infra-card { grid-template-columns: 23px minmax(0, 1fr) minmax(0, 1fr); }
.rack-infra-icon { grid-row: auto; }
.game-page .game-side { grid-template-columns: minmax(0, 1fr); }
.game-page .perk-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (hover: none) and (pointer: coarse) {
.build-filters button, .plot-inspector .btn, .rack-upgrade-card { min-height: 44px; }
.rack-server-navigation button { width: 44px; height: 44px; }
.game-terminal-form button, .game-terminal-header button, .game-terminal-footer button { min-width: 44px; min-height: 44px; }
}
+19
View File
@@ -0,0 +1,19 @@
# Code Farm rack artwork
`rack-front.png` is a transparent 793 × 1983 PNG generated with the built-in ImageGen tool from the approved Code Farm rack concept, then revised to the user's requested straight-on front view. It is decorative: the server bays, names, progress, selection, actions, and upgrade indicators are real HTML controlled by `GameDatacenter.js`.
The empty installation cavity occupies approximately 20.6%–79.4% of the image width and 17.3%–72.9% of its height. The `.rack-servers` overlay in `game-datacenter.css` uses these bounds. Keep the image's proportions and empty cavity when replacing it.
Final ImageGen edit prompt:
> Revise this game asset to meet the user's correction: SIMPLE STRAIGHT-ON FRONT VIEW, NOT A 3D PERSPECTIVE RACK. Keep only the tall slim black rack cabinet, on a true transparent background. No visible right side, left side, top plane, depth extrusion, perspective, open door, surrounding scene or dramatic lighting. Symmetrical flat frontal elevation like a carefully drawn technical game illustration. Crisp restrained 2D hardware illustration with subtle shading, clear simple shapes; recognizable fans, screws, vents, network ports and blue/orange patch cables. Avoid photorealistic noisy textures and shiny 3D rendering. Black/graphite metal and green LEDs as before. No text, logos or numbers.
>
> Use a tall portrait canvas, front cabinet height about 2.7 times its width, whole cabinet from roof to feet, centered, fills canvas with 2 percent transparent margin. Outer frame is a perfectly rectangular straight-on front, left and right mounting rails equally visible.
>
> At top 4 to 21 percent of height: three symmetric cooling fans, below them one simple network switch and neat blue and orange patch cables.
>
> Between 23 and 76 percent height: one COMPLETELY EMPTY DARK RECTANGULAR INSTALLATION CAVITY, about 12 to 88 percent of canvas width, framed by mounting rails. No servers or horizontal shelves or dividers inside it. Actual interactive server rows will be overlaid here by the game. Keep this cavity exactly straight and uncluttered.
>
> At bottom 78 to 96 percent height: three clean hardware modules: slim storage drive panel; small monitoring display and a UPS battery/power module; and a row of three cooling fans. Simplify compared with the input, keep useful readable silhouettes.
>
> No interface, no buttons, no white background; transparent background. This is a front-view rack frame asset to integrate into a real website.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+127
View File
@@ -0,0 +1,127 @@
// retoor <retoor@molodetz.nl>
/** Declarative coverage of the existing game API; no command runs system code. */
export class GameCommands {
static API = Object.freeze([
{"command":"home","method":"GET","path":"/game","args":[],"description":"Read your own farm overview."},
{"command":"state","method":"GET","path":"/game/state","args":[],"description":"Read every farm field as JSON."},
{"command":"leaderboard","method":"GET","path":"/game/leaderboard","args":["board"],"defaults":{"board":"score"},"params":{"board":"board"},"description":"Show a leaderboard; defaults to score."},
{"command":"view","method":"GET","path":"/game/farm/{username}","args":["username"],"description":"Inspect another player's rack."},
{"command":"plant","method":"POST","path":"/game/plant","args":["server","crop"],"params":{"slot":"server","crop":"crop"},"action":"plant","description":"Spin up a workload on an idle server."},
{"command":"harvest","method":"POST","path":"/game/harvest","args":["server"],"params":{"slot":"server"},"action":"harvest","bulk":true,"description":"Harvest a server, or harvest all ready servers."},
{"command":"stop","method":"POST","path":"/game/plot/stop","args":["server"],"params":{"slot":"server"},"fixed":{"stop":"true"},"action":"plot-stop","description":"Stop auto-replant after this server's next harvest."},
{"command":"resume","method":"POST","path":"/game/plot/stop","args":["server"],"params":{"slot":"server"},"fixed":{"stop":"false"},"action":"plot-stop","description":"Resume this server's auto-replant."},
{"command":"buy server","method":"POST","path":"/game/buy-plot","args":[],"action":"buy-plot","description":"Install the next server at its current price."},
{"command":"ci upgrade","method":"POST","path":"/game/upgrade","args":[],"action":"upgrade","description":"Upgrade your CI tier."},
{"command":"fertilize","method":"POST","path":"/game/fertilize","args":["server"],"params":{"slot":"server"},"action":"fertilize","description":"Pay to speed up a growing build."},
{"command":"daily","method":"POST","path":"/game/daily","args":[],"action":"daily","description":"Claim the daily bonus."},
{"command":"grant","method":"POST","path":"/game/grant","args":[],"action":"grant","description":"Claim your community grant if eligible."},
{"command":"perk","method":"POST","path":"/game/perk","args":["key"],"catalog":"perks","params":{"perk":"key"},"action":"perk","description":"Upgrade a perk. Run perks to see keys and costs."},
{"command":"prestige","method":"POST","path":"/game/prestige","args":[],"action":"prestige","confirm":true,"description":"Refactor your farm after confirming the live fee and reset."},
{"command":"legacy","method":"POST","path":"/game/legacy","args":["key"],"catalog":"legacy","params":{"key":"key"},"action":"legacy","description":"Buy a permanent Legacy upgrade with Stars."},
{"command":"mastery","method":"POST","path":"/game/mastery","args":["key"],"catalog":"mastery","params":{"key":"key"},"action":"mastery","description":"Spend Mastery points on an upgrade."},
{"command":"claim","method":"POST","path":"/game/quests/claim","args":["quest","scope"],"defaults":{"scope":"daily"},"params":{"quest":"quest","scope":"scope"},"action":"claim-quest","description":"Claim a daily quest or weekly contract."},
{"command":"defense upgrade","method":"POST","path":"/game/defense/upgrade","args":[],"action":"defense","description":"Upgrade defense and its daily upkeep."},
{"command":"defense downgrade","method":"POST","path":"/game/defense/downgrade","args":[],"action":"defense-downgrade","confirm":true,"description":"Reduce defense and upkeep after confirmation."},
{"command":"infra","method":"POST","path":"/game/infrastructure/buy","args":["key"],"catalog":"infrastructure","params":{"key":"key"},"action":"infrastructure","confirm":true,"description":"Buy infrastructure at its current coin price."},
{"command":"cosmetic buy","method":"POST","path":"/game/cosmetics/buy","args":["key"],"catalog":"cosmetics","params":{"key":"key"},"action":"cosmetic-buy","confirm":true,"description":"Buy a cosmetic after confirming its price."},
{"command":"cosmetic equip","method":"POST","path":"/game/cosmetics/equip","args":["key"],"catalog":"cosmetics","params":{"key":"key"},"action":"cosmetic-equip","description":"Equip an owned title."},
{"command":"water","method":"POST","path":"/game/farm/{username}/water","args":["username","server"],"params":{"slot":"server"},"action":"water","social":true,"description":"Water another player's growing server."},
{"command":"steal","method":"POST","path":"/game/farm/{username}/steal","args":["username","server"],"params":{"slot":"server"},"action":"steal","social":true,"confirm":true,"description":"Raid another player's ready server after confirmation."}
]);
static BOARDS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"];
static ALIASES = { spinup: "plant", boost: "fertilize", refactor: "prestige", ls: "servers" };
static LOCAL = {
help: "Show commands and examples.", status: "Show the current rack's status.", servers: "List current servers and countdowns.",
server: "Select a server: server 3.", crops: "List workloads, prices, rewards, locks and market state.",
perks: "List perk keys, levels and costs.", legacy: "List permanent Legacy upgrades.", mastery: "List Mastery upgrades.",
infra: "List infrastructure and ownership.", defense: "Show defense and upkeep.", cosmetics: "List cosmetics and titles.",
quests: "List daily quests and weekly contracts.", ci: "Show your CI tier.", stats: "Show lifetime, weekly and Era statistics.",
api: "Show every game API route and its command.", clear: "Clear the terminal output."
};
static usage(spec) {
return [spec.command, ...spec.args.map((arg) => Object.hasOwn(spec.defaults || {}, arg) ? `[${arg}]` : `<${arg}>`)].join(" ");
}
static server(value, farm, remote = false) {
if (!/^\d+$/.test(value || "")) throw new Error("Use a server number, for example 1.");
const slot = Number(value) - 1;
if (slot < 0 || slot >= farm.max_plots || (!remote && !farm.plots.some((plot) => plot.slot === slot))) {
throw new Error(`Server ${value} is not available. Run servers to see your installed servers.`);
}
return slot;
}
static parse(line, farm) {
if (!farm) throw new Error("The rack is still loading. Try again in a moment.");
if (line.length > 256) throw new Error("Commands must be 256 characters or fewer.");
const tokens = line.trim().split(/\s+/);
tokens[0] = tokens[0].toLowerCase();
if (Object.hasOwn(GameCommands.ALIASES, tokens[0])) tokens[0] = GameCommands.ALIASES[tokens[0]];
const matching = GameCommands.API.filter((spec) => spec.command.split(" ").every((word, index) => tokens[index]?.toLowerCase() === word));
const spec = matching.find((item) => {
const count = tokens.length - item.command.split(" ").length;
return count >= item.args.filter((arg) => !Object.hasOwn(item.defaults || {}, arg)).length && count <= item.args.length;
});
if (!spec) {
if (Object.hasOwn(GameCommands.LOCAL, tokens[0]) && (tokens.length === 1 || tokens[0] === "server" && tokens.length === 2)) {
return { local: tokens[0], slot: tokens[0] === "server" ? GameCommands.server(tokens[1], farm) : undefined };
}
if (matching.length) throw new Error(`Usage: ${GameCommands.usage(matching[0])}`);
throw new Error("Unknown command. Type help for commands and examples.");
}
if (spec.method === "POST" && !spec.social && !farm.is_owner) throw new Error("Open your own rack at /game to use this command.");
const raw = tokens.slice(spec.command.split(" ").length);
const values = { ...spec.defaults };
spec.args.forEach((arg, index) => { if (raw[index] !== undefined) values[arg] = raw[index]; });
for (const [arg, value] of Object.entries(values)) {
if (arg === "server") {
values[arg] = spec.bulk && value.toLowerCase() === "all" ? "all" : GameCommands.server(value, farm, spec.social);
} else if (arg === "username") {
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(value)) throw new Error("Use a valid username (3–32 letters, numbers, hyphens or underscores).");
} else {
values[arg] = value.toLowerCase();
if (!/^[a-z][a-z0-9_]{0,39}$/.test(values[arg])) throw new Error(`Invalid ${arg}. Type help for the accepted syntax.`);
if (arg === "board" && !GameCommands.BOARDS.includes(values[arg])) throw new Error(`Boards: ${GameCommands.BOARDS.join(", ")}`);
if (arg === "scope" && !["daily", "weekly"].includes(values[arg])) throw new Error("Quest scope must be daily or weekly.");
const catalog = arg === "crop" ? farm.crops : arg === "key" ? farm[spec.catalog] : null;
if (catalog && !catalog.some((entry) => entry.key === values[arg])) throw new Error(`Unknown ${arg}: ${value}. Run ${arg === "crop" ? "crops" : spec.catalog} to see available keys.`);
}
}
const params = { ...spec.fixed };
for (const [field, arg] of Object.entries(spec.params || {})) params[field] = values[arg];
let url = spec.path.replace("{username}", encodeURIComponent(values.username || ""));
if (spec.method === "GET" && Object.keys(params).length) url += `?${new URLSearchParams(params)}`;
return { spec, url, params, values, bulk: values.server === "all" };
}
static completions(line, farm) {
const tokens = line.trimStart().split(/\s+/);
const first = tokens[0]?.toLowerCase();
const command = Object.hasOwn(GameCommands.ALIASES, first) ? GameCommands.ALIASES[first] : first;
const index = tokens.length - 1;
const prefix = tokens[index].toLowerCase();
let choices = [];
if (index === 0) choices = [...Object.keys(GameCommands.LOCAL), ...GameCommands.API.map((spec) => spec.command.split(" ")[0]), ...Object.keys(GameCommands.ALIASES)];
else if (index === 1) {
if (["plant", "harvest", "fertilize", "stop", "resume", "server"].includes(command)) choices = (farm.plots || []).map((plot) => String(plot.slot + 1));
if (command === "harvest") choices.push("all");
const collections = { perk: "perks", legacy: "legacy", mastery: "mastery", infra: "infrastructure" };
if (collections[command]) choices = (farm[collections[command]] || []).map((entry) => entry.key);
if (command === "leaderboard") choices = GameCommands.BOARDS;
if (command === "buy") choices = ["server"];
if (command === "ci") choices = ["upgrade"];
if (command === "defense") choices = ["upgrade", "downgrade"];
if (command === "cosmetic") choices = ["buy", "equip"];
if (command === "claim") choices = [...new Set((farm.quests || []).map((quest) => quest.kind))];
} else if (index === 2) {
if (command === "plant") choices = (farm.crops || []).map((crop) => crop.key);
if (command === "cosmetic") choices = (farm.cosmetics || []).map((entry) => entry.key);
if (["water", "steal"].includes(command)) choices = Array.from({ length: farm.max_plots }, (_, slot) => String(slot + 1));
if (command === "claim") choices = ["daily", "weekly"];
}
return [...new Set(choices)].filter((choice) => choice.startsWith(prefix)).sort();
}
}
+188
View File
@@ -0,0 +1,188 @@
// retoor <retoor@molodetz.nl>
import { Format } from "./Format.js";
import { assetUrl } from "./assetVersion.js";
/** Front-view rack presentation. GameFarm owns all requests and game rules. */
export class GameDatacenter {
constructor(game) {
this.game = game;
this.slot = null;
this.crop = null;
this.filter = "all";
game.root.addEventListener("click", (event) => {
const server = event.target.closest("[data-select-slot]");
if (server) {
this.select(Number(server.dataset.selectSlot));
if (window.matchMedia("(max-width: 768px)").matches) {
this.game.root.querySelector("#rack-console")?.scrollIntoView({ block: "start", behavior: "instant" });
}
}
const navigate = event.target.closest("[data-server-step]");
if (navigate && game.farm) {
const plots = game.farm.plots;
const index = plots.findIndex((plot) => plot.slot === this.slot);
const next = plots[index + Number(navigate.dataset.serverStep)];
if (next) {
this.slot = next.slot;
this.refresh(`[data-server-step="${navigate.dataset.serverStep}"]`);
}
}
const filter = event.target.closest("[data-build-filter]");
if (filter) {
this.filter = filter.dataset.buildFilter;
this.refresh(`[data-build-filter="${this.filter}"]`);
}
});
game.root.addEventListener("keydown", (event) => {
const server = event.target.closest("[data-select-slot]");
if (!server || !game.farm) return;
const slots = game.farm.plots.map((plot) => plot.slot);
const index = slots.indexOf(Number(server.dataset.selectSlot));
let next;
if (["ArrowUp", "ArrowLeft"].includes(event.key)) next = slots[index - 1];
if (["ArrowDown", "ArrowRight"].includes(event.key)) next = slots[index + 1];
if (event.key === "Home") next = slots[0];
if (event.key === "End") next = slots[slots.length - 1];
if (next !== undefined) {
event.preventDefault();
this.select(next);
}
});
game.root.addEventListener("change", (event) => {
if (!event.target.matches("[data-build-choice]")) return;
this.crop = event.target.value;
this.refresh(`[data-build-choice][value="${this.crop}"]`);
});
}
escape(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[char]);
}
select(slot) {
if (!this.game.farm?.plots.some((plot) => plot.slot === slot)) return;
this.slot = slot;
this.refresh(`[data-select-slot="${slot}"]`);
}
refresh(focusSelector) {
if (!this.game.farm) return;
this.game._setHost("[data-game-grid-host]", this.html(this.game.farm));
this.game.terminal?.mount();
this.game._tick();
let target = this.game.root.querySelector(focusSelector);
if (target?.disabled) target = this.game.root.querySelector("[data-server-step]:not(:disabled)");
target?.focus({ preventScroll: true });
}
levels(farm) {
return {
cooling: (farm.perks || []).find((perk) => perk.key === "growth")?.level || 0,
network: farm.ci_tier || 1,
power: (farm.perks || []).find((perk) => perk.key === "yield")?.level || 0,
};
}
html(farm) {
if (!farm.plots.some((plot) => plot.slot === this.slot)) {
this.slot = (farm.plots.find((plot) => plot.state === "ready") || farm.plots[0])?.slot;
}
const selected = farm.plots.find((plot) => plot.slot === this.slot);
const levels = this.levels(farm);
const owned = (key) => (farm.infrastructure || []).some((infra) => infra.key === key && infra.owned);
const neon = (farm.cosmetics || []).some((cosmetic) => cosmetic.key === "skin_neon" && cosmetic.owned);
const ready = farm.plots.filter((plot) => plot.state === "ready").length;
const servers = Array.from({ length: farm.max_plots }, (_, index) => {
const plot = farm.plots.find((item) => item.slot === index);
if (plot) return this.serverHtml(plot, farm);
if (farm.is_owner && index === farm.plot_count) {
return `<form class="rack-expansion" method="post" action="/game/buy-plot" data-game-action="buy-plot"><button type="submit"${farm.coins < farm.next_plot_cost ? " disabled" : ""}><span class="server-number">${this.number(index)}</span><span class="server-buy-icon" aria-hidden="true">+</span><span>Buy server <strong>${Format.coins(farm.next_plot_cost)}</strong></span></button></form>`;
}
return `<div class="rack-empty-bay" aria-hidden="true"><span>${this.number(index)}</span><span>Expansion bay</span><i></i></div>`;
}).join("");
const status = selected?.state === "empty" ? "Idle · Ready to deploy" : selected?.state === "ready" ? "Build complete · Ready to harvest" : "Online · Build in progress";
const selectedIndex = farm.plots.findIndex((plot) => plot.slot === this.slot);
return `<section class="rack-workspace" data-game-grid aria-label="Server rack">
<div class="rack-stage"><div class="rack-heading"><span>${farm.is_owner ? "YOUR RACK" : `${this.escape(farm.owner_username)}'S RACK`}</span><span><i class="rack-live-dot"></i>${farm.plot_count} / ${farm.max_plots} servers <b>·</b> ${ready} ready</span></div>
<div class="rack-cabinet${levels.cooling ? " has-cooling" : ""}${levels.power ? " has-power" : ""}${owned("registry") ? " has-registry" : ""}${owned("observability") ? " has-monitoring" : ""}${neon ? " has-neon-skin" : ""}" data-cooling-level="${levels.cooling}" data-network-tier="${levels.network}" data-power-level="${levels.power}">
<img class="rack-frame" src="${assetUrl("/static/images/game/rack-front.png")}" alt="" width="793" height="1983" fetchpriority="high" aria-hidden="true">
<div class="rack-servers" role="group" aria-label="Servers. Use up and down arrow keys to select a server.">${servers}</div>
<div class="rack-hardware-state" aria-hidden="true"><div class="rack-cooling-lights">${"<i></i>".repeat(Math.min(3, Math.ceil(levels.cooling / 4)))}</div><div class="rack-network-lights">${"<i></i>".repeat(levels.network)}</div>${owned("registry") ? '<span class="rack-registry-light">REGISTRY ONLINE</span>' : ""}${owned("canary") ? '<span class="rack-canary-light">● CANARY</span>' : ""}<span class="rack-monitor-label">${owned("observability") ? "MONITORING ON" : `CI ${levels.network} <b>·</b> ${farm.plot_count}/${farm.max_plots}`}</span><div class="rack-power-lights">${"<i></i>".repeat(Math.min(5, Math.ceil(levels.power / 2)))}</div></div>
</div>
<p class="rack-instructions">Select a server to manage its workload. <span>↑ ↓ to navigate</span></p>
</div>
<div class="rack-control-column">
${selected ? `<section class="rack-console" id="rack-console" aria-labelledby="rack-server-title"><header class="rack-console-heading"><div class="rack-console-title"><h2 id="rack-server-title">SERVER ${this.number(selected.slot)}</h2><div class="rack-server-navigation" role="group" aria-label="Select server"><button type="button" data-server-step="-1" aria-label="Previous server"${selectedIndex <= 0 ? " disabled" : ""}>↑</button><button type="button" data-server-step="1" aria-label="Next server"${selectedIndex >= farm.plots.length - 1 ? " disabled" : ""}>↓</button></div></div><p class="rack-console-status state-${selected.state}"><i></i>${status}</p></header>${selected.state !== "empty" ? this.runningHtml(selected, farm) : ""}${this.game._plotHtml(selected, farm)}</section>` : ""}
<div data-rack-terminal-slot></div>
${farm.is_owner ? this.hardwareHtml(farm, levels) + this.infrastructureHtml(farm) : '<div class="rack-visitor-note"><span aria-hidden="true">⌁</span><p>Water a growing build to help your neighbour and earn coins. Ready builds can be raided after their protection window.</p></div>'}
</div>
</section>`;
}
number(slot) {
return String(slot + 1).padStart(2, "0");
}
symbol(key, icon) {
const symbols = { shell: ">_", python: "Py", webapp: "◎", api: "Go", rust: "R", haskell: "λ", kernel: "K", distsys: "⋈", mlpipe: "✳", secfort: "◇" };
return `<span class="workload-symbol symbol-${this.escape(key || "idle")}" aria-hidden="true">${this.escape(symbols[key] || icon || "—")}</span>`;
}
progress(plot, farm) {
const crop = farm.crops.find((item) => item.key === plot.crop_key);
if (plot.state === "ready") return 100;
if (plot.state !== "growing" || !crop?.grow_seconds) return 0;
return Math.max(0, Math.min(100, 100 * (1 - this.game._remaining(plot.ready_at) / crop.grow_seconds)));
}
serverHtml(plot, farm) {
const selected = plot.slot === this.slot;
const status = plot.state === "empty" ? "IDLE" : plot.state === "ready" ? "READY" : Format.duration(plot.remaining_seconds);
return `<button type="button" class="rack-server game-plot-${plot.state}${selected ? " is-selected" : ""}${plot.is_golden ? " is-golden" : ""}" data-slot="${plot.slot}" data-select-slot="${plot.slot}" data-state="${plot.state}" aria-pressed="${selected}" aria-controls="rack-console" aria-label="Server ${this.number(plot.slot)}: ${this.escape(plot.crop_name || "empty")}, ${plot.state}${plot.is_golden ? ", golden" : ""}" tabindex="${selected ? 0 : -1}">
<span class="server-number">${this.number(plot.slot)}</span>${this.symbol(plot.crop_key)}
<span class="server-workload"><strong>${this.escape(plot.crop_name || "Empty server")}${plot.is_golden ? ' <span class="server-golden-star">✦</span>' : ""}</strong><span class="server-build-line">${plot.state === "growing" ? `<span class="server-progress"><i data-build-progress data-ready-at="${this.escape(plot.ready_at)}" data-grow-seconds="${farm.crops.find((item) => item.key === plot.crop_key)?.grow_seconds || 1}" style="--build-progress:${this.progress(plot, farm)}%"></i></span><span data-plot-timer data-ready-at="${this.escape(plot.ready_at)}">${status}</span>` : `<span class="server-state-tag">${status}</span>`}</span></span>
<span class="server-drive-bay" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span class="server-handle" aria-hidden="true"></span>
</button>`;
}
runningHtml(plot, farm) {
const crop = farm.crops.find((item) => item.key === plot.crop_key);
return `<div class="running-workload">${this.symbol(plot.crop_key, plot.crop_icon)}<div><span class="rack-eyebrow">CURRENT WORKLOAD</span><h3>${this.escape(plot.crop_name)}${plot.is_golden ? ' <span class="server-golden-star">✦ Golden build</span>' : ""}</h3><p>${plot.state === "ready" ? "Your build is ready. Collect its coins and XP." : "Your server is working. Harvest its rewards when the build finishes."}</p></div></div>
<div class="running-monitor"><div class="running-monitor-top"><span>${plot.state === "ready" ? "BUILD COMPLETE" : "BUILD PROGRESS"}</span><span>${plot.state === "ready" ? "100%" : "RUNNING"}</span></div><div class="running-monitor-track"><i data-build-progress data-ready-at="${this.escape(plot.ready_at)}" data-grow-seconds="${crop?.grow_seconds || 1}" style="--build-progress:${this.progress(plot, farm)}%"></i></div><div class="running-monitor-stats"><span>Build time <strong>${Format.duration(crop?.grow_seconds || 0)}</strong></span><span>Project yield <strong>${Format.coins(crop?.reward_coins || plot.reward_coins || 0)}</strong></span><span>Project XP <strong>${Format.exact(crop?.reward_xp || plot.reward_xp || 0)}</strong></span></div></div>`;
}
hardwareHtml(farm, levels) {
const cards = [
{ type: "cooling", name: "Cooling", source: "Build Cache", level: levels.cooling, href: "#perk-growth" },
{ type: "network", name: "Networking", source: farm.ci_label, level: levels.network, href: "#infrastructure" },
{ type: "power", name: "Power", source: "Optimizer", level: levels.power, href: "#perk-yield" },
];
return `<section class="rack-hardware-panel" aria-label="Hardware upgrades"><h3>HARDWARE UPGRADES</h3><div class="rack-upgrade-grid">${cards.map((card) => `<a class="rack-upgrade-card" href="${card.href}" title="${this.escape(card.source)}"><span class="hardware-art hardware-art-${card.type}" aria-hidden="true"></span><span><strong>${card.name}</strong><small>LEVEL ${card.level}</small><span>${this.escape(card.source)}</span></span><b aria-hidden="true">›</b></a>`).join("")}</div></section>`;
}
infrastructureHtml(farm) {
if (!farm.infrastructure?.length) return "";
return `<section class="rack-infrastructure-panel" aria-label="Installed infrastructure"><h3>INFRASTRUCTURE</h3><div class="rack-infrastructure-grid">${farm.infrastructure.map((infra) => `<a href="#infra-${this.escape(infra.key)}" class="rack-infra-card${infra.owned ? " is-owned" : ""}"><span class="rack-infra-icon" aria-hidden="true">${this.escape(infra.icon)}</span><strong>${this.escape(infra.name)}</strong><span>${infra.owned ? "Installed" : farm.prestige < infra.min_prestige ? `Requires prestige ${infra.min_prestige}` : `Buy ${Format.coins(infra.cost)}`}</span></a>`).join("")}</div></section>`;
}
cropCardHtml(crop, farm, chosen) {
const disabled = crop.locked || crop.cost > farm.coins;
const lock = crop.locked ? (crop.locked_reason === "mastery" ? "Mastery required" : crop.locked_reason === "era" ? "Era unavailable" : `Unlocks at level ${crop.min_level}`) : crop.cost > farm.coins ? "Not enough coins" : "";
const advanced = crop.locked && crop.locked_reason === "mastery";
return `<label class="build-card${disabled ? " is-unavailable" : ""}${advanced ? " is-advanced" : ""}" title="${this.escape(crop.locked_text || lock)}"><input type="radio" name="crop" value="${this.escape(crop.key)}" data-build-choice${chosen?.key === crop.key ? " checked" : ""}${disabled ? " disabled" : ""}>${this.symbol(crop.key, crop.icon)}<span class="build-card-content"><strong>${this.escape(crop.name)}</strong>${advanced ? `<span class="build-card-lock">${lock}</span>` : `<span class="build-card-facts"><span>◷ ${Format.duration(crop.grow_seconds)}</span><span class="build-card-cost">◉ ${Format.coins(crop.cost)}</span><span class="build-card-reward">↑ ${Format.coins(crop.reward_coins)}</span></span><span class="build-card-xp">+${Format.exact(crop.reward_xp)} XP${crop.market_state && crop.market_state !== "normal" ? ` · <span class="build-card-market">${this.escape(crop.market_state)}</span>` : ""}</span>${lock ? `<span class="build-card-lock">${this.escape(lock)}</span>` : ""}`}</span><span class="build-card-check" aria-hidden="true">✓</span></label>`;
}
plantHtml(plot, farm) {
const chosen = farm.crops.find((crop) => crop.key === this.crop && !crop.locked && crop.cost <= farm.coins)
|| farm.crops.find((crop) => !crop.locked && crop.cost <= farm.coins);
this.crop = chosen?.key || null;
const visible = farm.crops.filter((crop) => this.filter !== "available" || !crop.locked);
const main = visible.filter((crop) => !(crop.locked && crop.locked_reason === "mastery"));
const advanced = visible.filter((crop) => crop.locked && crop.locked_reason === "mastery");
return `<div class="build-catalog-heading"><h3>What should this server run?</h3><div class="build-filters" role="group" aria-label="Project filter"><button type="button" data-build-filter="all" aria-pressed="${this.filter === "all"}">All</button><button type="button" data-build-filter="available" aria-pressed="${this.filter === "available"}">Unlocked</button></div></div>
<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><fieldset class="build-catalog"><legend class="sr-only">Choose a workload for Server ${this.number(plot.slot)}</legend>${main.map((crop) => this.cropCardHtml(crop, farm, chosen)).join("")}</fieldset>${advanced.length ? `<fieldset class="advanced-catalog"><legend>⌑ Advanced workloads · Mastery required</legend><div>${advanced.map((crop) => this.cropCardHtml(crop, farm, chosen)).join("")}</div></fieldset>` : ""}<div class="build-deploy-bar"><button type="submit" class="btn btn-primary"${chosen ? "" : " disabled"}><span class="deploy-server-icon" aria-hidden="true">▤</span>${chosen ? `Spin up ${this.escape(chosen.name)} <span>· ${Format.coins(chosen.cost)}</span>` : "Not enough coins to spin up a project"}</button><span>Deploying to <strong>Server ${this.number(plot.slot)}</strong></span></div></form>`;
}
}
+83 -44
View File
@@ -3,6 +3,8 @@
import { Format } from "./Format.js";
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { GameDatacenter } from "./GameDatacenter.js";
import { GameTerminal } from "./GameTerminal.js";
export class GameFarm {
constructor() {
@@ -13,8 +15,18 @@ export class GameFarm {
this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state";
this.farm = null;
this.board = "score";
this.datacenter = new GameDatacenter(this);
this.terminal = new GameTerminal(this);
this._bindActions();
this._bindLeaderboardBoard();
const initial = this.root.querySelector("[data-game-initial-state]");
if (initial) {
try {
this.render(JSON.parse(initial.textContent));
} catch (error) {
// The server-rendered forms remain usable if enhancement fails.
}
}
this._startTicker();
this.load();
if (this.mode === "own") this._loadLeaderboard();
@@ -29,6 +41,7 @@ export class GameFarm {
const data = await Http.getJson(this.stateUrl);
if (window.app && window.app.dialog && window.app.dialog.isOpen) return;
this.render(data.farm);
return data.farm;
} catch (error) {
return;
}
@@ -54,31 +67,47 @@ export class GameFarm {
async _submit(form) {
const action = form.dataset.gameAction;
const params = {};
form.querySelectorAll("[name]").forEach((field) => {
params[field.name] = field.value;
});
const params = Object.fromEntries(new FormData(form));
const button = form.querySelector("button");
if (button) button.disabled = true;
try {
const data = await Http.send(form.getAttribute("action"), params);
this.render(data.farm);
if (action === "steal" && window.app && window.app.toast) {
window.app.toast.show(`You stole ${Format.coins(data.stole_coins)}!`, { type: "success" });
}
if (action === "harvest" || action === "water" || action === "steal" || action === "prestige") {
this._loadLeaderboard();
}
await this.performAction(form.getAttribute("action"), params, { action });
} catch (error) {
if (button) button.disabled = false;
}
}
async performAction(url, params = {}, options = {}) {
const { action, origin = "controls", silent = false } = options;
const data = await Http.send(url, params, { silent });
const currentRack = data.farm?.owner_username === this.username;
if (action === "buy-plot" && currentRack) {
const previous = new Set((this.farm?.plots || []).map((plot) => plot.slot));
const added = data.farm.plots.find((plot) => !previous.has(plot.slot));
if (added) this.datacenter.slot = added.slot;
}
if (currentRack) this.render(data.farm);
else await this.load(); // Social actions return the neighbour, not the player's farm.
if (action === "steal" && origin === "controls" && window.app?.toast) {
window.app.toast.show(`You stole ${Format.coins(data.stole_coins)}!`, { type: "success" });
}
if (["harvest", "water", "steal", "prestige"].includes(action)) this._loadLeaderboard();
if (origin === "controls") this.terminal?.recordAction(action, params, data, data.farm?.owner_username);
return data;
}
render(farm) {
if (!farm) return;
const focused = document.activeElement;
const focusSlot = focused?.dataset.selectSlot;
const focusCrop = focused?.matches("[data-build-choice]") ? focused.value : null;
const focusFilter = focused?.dataset.buildFilter;
const terminalFocus = this.terminal?.element?.contains(focused);
const previous = this.farm;
this.farm = farm;
this._renderHud(farm);
this._setHost("[data-game-grid-host]", this._gridHtml(farm));
this.terminal?.mount();
if (this.mode === "own") {
this._setHost("[data-shop-host]", this._shopHtml(farm));
this._setHost("[data-defense-host]", this._defenseHtml(farm));
@@ -93,6 +122,11 @@ export class GameFarm {
this._setHost("[data-quest-host]", this._questsHtml(farm));
}
this._tick();
if (focusSlot !== undefined) this.root.querySelector(`[data-select-slot="${focusSlot}"]`)?.focus({ preventScroll: true });
if (focusCrop) this.root.querySelector(`[data-build-choice][value="${focusCrop}"]`)?.focus({ preventScroll: true });
if (focusFilter) this.root.querySelector(`[data-build-filter="${focusFilter}"]`)?.focus({ preventScroll: true });
if (terminalFocus && focused.isConnected && !focused.disabled) focused.focus({ preventScroll: true });
this.terminal?.observe(previous, farm);
}
_setHost(selector, html) {
@@ -114,8 +148,30 @@ export class GameFarm {
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
set("[data-hud-stars]", Format.exact(farm.stars));
set("[data-hud-mastery]", Format.exact(farm.mastery_points));
this.root.querySelectorAll("[data-mastery-section], [data-hud-mastery-stat]").forEach((node) => {
node.hidden = !farm.mastery_points_earned_total;
});
set("[data-era-coins]", Format.coins(farm.era_coins));
set("[data-era-harvests]", Format.exact(farm.era_harvests));
set("[data-era-name]", farm.era_name || "");
set("[data-auto-harvest-count]", Format.exact(farm.auto_harvested));
set("[data-auto-harvest-coins]", Format.coins(farm.auto_harvest_coins));
for (const [selector, visible] of [["[data-era-banner]", farm.era_active], ["[data-autoharvest-banner]", farm.auto_harvested]]) {
const node = this.root.querySelector(selector);
if (node) node.hidden = !visible;
}
for (const [bannerSelector, countdownSelector, seconds] of [
["[data-underdog-banner]", "[data-underdog-countdown]", farm.underdog_boost_seconds_remaining],
["[data-contract-banner]", "[data-contract-countdown]", farm.contract_boost_seconds_remaining],
]) {
const banner = this.root.querySelector(bannerSelector);
const countdown = this.root.querySelector(countdownSelector);
if (banner) banner.hidden = !seconds;
if (countdown) {
countdown.dataset.seconds = String(seconds || 0);
countdown.dataset.expiresAt = String(Date.now() + (seconds || 0) * 1000);
}
}
const fill = this.root.querySelector("[data-hud-xp-fill]");
if (fill) {
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
@@ -154,7 +210,7 @@ export class GameFarm {
const action = perk.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/perk" data-game-action="perk"><input type="hidden" name="perk" value="${perk.key}"><button type="submit" class="btn btn-sm">Upgrade (${Format.coins(perk.cost)})</button></form>`;
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${perk.icon}</span><strong class="perk-name">${perk.name}</strong><span class="perk-level">Lv ${perk.level}/${perk.max_level}</span><span class="perk-effect">${perk.effect || perk.description}</span>${action}</div>`;
return `<div class="perk-card" id="perk-${perk.key}"><span class="perk-icon" aria-hidden="true">${perk.icon}</span><strong class="perk-name">${perk.name}</strong><span class="perk-level">Lv ${perk.level}/${perk.max_level}</span><span class="perk-effect">${perk.effect || perk.description}</span>${action}</div>`;
})
.join("");
}
@@ -200,7 +256,7 @@ export class GameFarm {
} else {
action = `<form method="post" action="/game/infrastructure/buy" data-game-action="infrastructure"><input type="hidden" name="key" value="${infra.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${infra.name} for ${Format.exact(infra.cost)} coins?">Buy (${Format.coins(infra.cost)})</button></form>`;
}
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${infra.icon}</span><strong class="perk-name">${infra.name}</strong><span class="perk-effect">${infra.description}</span>${action}</div>`;
return `<div class="perk-card" id="infra-${infra.key}"><span class="perk-icon" aria-hidden="true">${infra.icon}</span><strong class="perk-name">${infra.name}</strong><span class="perk-effect">${infra.description}</span>${action}</div>`;
})
.join("");
}
@@ -270,12 +326,7 @@ export class GameFarm {
}
_gridHtml(farm) {
const cells = farm.plots.map((plot) => this._plotHtml(plot, farm)).join("");
let buy = "";
if (farm.is_owner && farm.plot_count < farm.max_plots) {
buy = `<form class="game-plot game-plot-buy" method="post" action="/game/buy-plot" data-game-action="buy-plot"><button type="submit" class="btn btn-sm">+ New plot (${Format.coins(farm.next_plot_cost)})</button></form>`;
}
return `<div class="game-grid" data-game-grid>${cells}${buy}</div>`;
return this.datacenter.html(farm);
}
_plotStopHtml(plot) {
@@ -286,32 +337,11 @@ export class GameFarm {
}
_plotHtml(plot, farm) {
const golden = plot.is_golden ? " game-plot-golden" : "";
const head = `<div class="game-plot game-plot-${plot.state}${golden}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
const head = `<div class="plot-inspector" data-inspector-slot="${plot.slot}">`;
let body = "";
if (plot.state === "empty") {
if (farm.is_owner) {
const options = farm.crops
.map((crop) => {
const disabled = crop.locked || crop.cost > farm.coins ? " disabled" : "";
const title = crop.locked ? ` title="${crop.locked_text}"` : "";
const lock = crop.locked_reason === "mastery"
? " (Mastery)"
: crop.locked_reason === "era"
? " (Era)"
: crop.locked
? ` (Lv ${crop.min_level})`
: "";
const market =
crop.market_state === "saturated"
? " (saturated)"
: crop.market_state === "boosted"
? " (boosted)"
: "";
return `<option value="${crop.key}"${disabled}${title}>${crop.icon} ${crop.name} - ${Format.coins(crop.cost)}${lock}${market}</option>`;
})
.join("");
body = `<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><select name="crop" class="plot-crop-select" aria-label="Choose a project to build">${options}</select><button type="submit" class="btn btn-sm btn-primary">Plant</button></form>`;
body = this.datacenter.plantHtml(plot, farm);
} else {
body = `<span class="plot-empty-label">Empty plot</span>`;
}
@@ -362,6 +392,11 @@ export class GameFarm {
node.textContent = this._format(remaining);
}
});
this.root.querySelectorAll("[data-build-progress]").forEach((node) => {
const remaining = this._remaining(node.dataset.readyAt);
const duration = Math.max(1, Number(node.dataset.growSeconds));
node.style.setProperty("--build-progress", `${Math.max(0, Math.min(100, 100 * (1 - remaining / duration)))}%`);
});
if (flipped) this.load();
}
@@ -385,9 +420,13 @@ export class GameFarm {
_tickBoosts() {
this.root.querySelectorAll("[data-seconds]").forEach((node) => {
const remaining = Math.max(0, Number(node.dataset.seconds || 0) - 1);
const expiresAt = Number(node.dataset.expiresAt) || Date.now() + Number(node.dataset.seconds || 0) * 1000;
node.dataset.expiresAt = String(expiresAt);
const remaining = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
node.dataset.seconds = String(remaining);
node.textContent = Format.duration(remaining);
const banner = node.closest("[data-underdog-banner], [data-contract-banner]");
if (banner && !remaining) banner.hidden = true;
});
}
+235
View File
@@ -0,0 +1,235 @@
// retoor <retoor@molodetz.nl>
import { Format } from "./Format.js";
import { Http } from "./Http.js";
import { GameCommands } from "./GameCommands.js";
/** A game command console, backed exclusively by the existing game API. */
export class GameTerminal {
constructor(game) {
this.game = game;
this.element = game.root.querySelector("[data-game-terminal]");
if (!this.element) return;
this.output = this.element.querySelector("[data-terminal-output]");
this.input = this.element.querySelector("[data-terminal-input]");
this.form = this.element.querySelector("[data-terminal-form]");
this.history = [];
this.historyIndex = 0;
this.draft = "";
this.busy = false;
this.element.hidden = false;
game.root.querySelectorAll("[data-terminal-link]").forEach((link) => { link.hidden = false; });
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.run(this.input.value);
});
this.element.addEventListener("click", (event) => {
const quick = event.target.closest("[data-terminal-command]");
if (quick) this.run(quick.dataset.terminalCommand);
const toggle = event.target.closest("[data-terminal-toggle]");
if (toggle) {
const body = this.element.querySelector(".game-terminal-body");
body.hidden = !body.hidden;
toggle.setAttribute("aria-expanded", String(!body.hidden));
toggle.setAttribute("aria-label", body.hidden ? "Expand terminal" : "Minimize terminal");
toggle.textContent = body.hidden ? "+" : "−";
}
});
this.input.addEventListener("keydown", (event) => this.keydown(event));
this.write("Code Farm control terminal\nType help to get started. Server numbers match the rack: 01–12.", "welcome");
}
mount() {
const slot = this.game.root.querySelector("[data-rack-terminal-slot]");
if (slot && this.element) slot.appendChild(this.element);
}
write(message, type = "output", reveal = true) {
if (!this.output) return;
const atBottom = this.output.scrollHeight - this.output.scrollTop - this.output.clientHeight < 32;
const line = document.createElement("pre");
line.className = `terminal-line terminal-${type}`;
line.textContent = message;
this.output.appendChild(line);
while (this.output.children.length > 120) this.output.firstElementChild.remove();
if (reveal || atBottom) this.output.scrollTop = this.output.scrollHeight;
}
keydown(event) {
if (event.isComposing) return;
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault();
if (this.historyIndex === this.history.length) this.draft = this.input.value;
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + (event.key === "ArrowUp" ? -1 : 1)));
this.input.value = this.history[this.historyIndex] ?? this.draft;
this.input.setSelectionRange(this.input.value.length, this.input.value.length);
} else if (event.key === "Tab" && !event.shiftKey && this.game.farm) {
const matches = GameCommands.completions(this.input.value, this.game.farm);
if (!matches.length) return;
event.preventDefault();
const tokens = this.input.value.trimStart().split(/\s+/);
let prefix = matches[0];
while (!matches.every((match) => match.startsWith(prefix))) prefix = prefix.slice(0, -1);
tokens[tokens.length - 1] = prefix;
this.input.value = tokens.join(" ") + (matches.length === 1 ? " " : "");
if (matches.length > 1) this.write(matches.join(" "), "hint");
}
}
async run(raw) {
const line = String(raw || "").trim();
if (!line || this.busy || !this.element) return;
this.busy = true;
this.input.value = "";
this.input.disabled = true;
this.form.querySelector("button").disabled = true;
this.element.setAttribute("aria-busy", "true");
if (this.history[this.history.length - 1] !== line) this.history.push(line);
this.history = this.history.slice(-100);
this.historyIndex = this.history.length;
this.draft = "";
this.write(`farm~$ ${line}`, "command");
let focusAfterSelection = false;
try {
let plan = GameCommands.parse(line, this.game.farm);
if (plan.local) {
this.local(plan);
focusAfterSelection = plan.local === "server";
}
else if (plan.spec.method === "GET") await this.read(plan);
else {
if (plan.spec.confirm) {
// Price-sensitive confirmations use the latest server snapshot.
if (!await this.game.load()) throw new Error("Could not refresh the action's current price. Try again.");
plan = GameCommands.parse(line, this.game.farm);
const message = this.confirmation(plan);
const dialog = window.app?.dialog;
const accepted = dialog
? await dialog.confirm({ title: "Confirm game action", message, danger: plan.spec.action === "prestige" })
: window.confirm(message);
if (!accepted) {
this.write("Cancelled. No action was sent.", "hint");
return;
}
}
if (plan.bulk) {
const ready = this.game.farm.plots.filter((plot) => plot.state === "ready").map((plot) => plot.slot);
if (!ready.length) this.write("No ready servers to harvest.", "hint");
for (const slot of ready) {
if (!this.game.farm.plots.some((plot) => plot.slot === slot && plot.state === "ready")) continue;
await this.game.performAction(plan.url, { slot }, { action: plan.spec.action, silent: true, origin: "terminal" });
this.write(`Harvested Server ${String(slot + 1).padStart(2, "0")}.`, "success");
}
} else {
const data = await this.game.performAction(plan.url, plan.params, { action: plan.spec.action, silent: true, origin: "terminal" });
this.write(this.actionMessage(plan.spec.action, plan.params, data, plan.values.username), "success");
}
}
} catch (error) {
this.write(error.message || "Command failed. Please try again.", "error");
} finally {
this.busy = false;
this.input.disabled = false;
this.form.querySelector("button").disabled = false;
this.element.setAttribute("aria-busy", "false");
if (focusAfterSelection || this.element.contains(document.activeElement) || document.activeElement === document.body) {
this.input.focus({ preventScroll: true });
}
}
}
async read(plan) {
const data = await Http.getJson(plan.url);
if (data.farm?.owner_username === this.game.username) this.game.render(data.farm);
if (plan.spec.command === "state") this.write(JSON.stringify(data.farm, null, 2));
else if (plan.spec.command === "leaderboard") {
this.write(`LEADERBOARD / ${plan.values.board}\n` + ((data.entries || []).map((entry) =>
`#${entry.rank} ${entry.username} Lv ${entry.level} ${this.game._leaderboardValue(plan.values.board, entry)}`
).join("\n") || "No entries yet."));
} else {
this.write(this.status(data.farm));
this.write(this.servers(data.farm));
if (plan.spec.command === "view") this.write(`Open this rack: /game/farm/${data.farm.owner_username}`, "hint");
}
}
local(plan) {
const farm = this.game.farm;
if (plan.local === "clear") return this.output.replaceChildren();
if (plan.local === "help") {
this.write("Server numbers are 1-based, matching the rack.\nExamples: plant 3 python · harvest all · ci upgrade · water alice 2\n↑/↓ command history · Tab completion\n");
this.write(Object.entries(GameCommands.LOCAL).map(([name, description]) => `${name.padEnd(13)} ${description}`).join("\n"));
this.write(GameCommands.API.map((spec) => `${GameCommands.usage(spec).padEnd(31)} ${spec.description}`).join("\n"));
return this.write("Aliases: spinup = plant · boost = fertilize · refactor = prestige · ls = servers", "hint");
}
if (plan.local === "api") {
return this.write(GameCommands.API.map((spec) => `${spec.method.padEnd(4)} ${spec.path.padEnd(32)} ${GameCommands.usage(spec)}`).join("\n"));
}
if (plan.local === "status") return this.write(this.status(farm));
if (plan.local === "servers") return this.write(this.servers(farm));
if (plan.local === "server") {
this.game.datacenter.select(plan.slot);
return this.write(`Selected Server ${String(plan.slot + 1).padStart(2, "0")}.`, "success");
}
if (plan.local === "crops") {
return this.write(farm.crops.map((crop) => `${crop.key.padEnd(10)} ${crop.name} · ${Format.coins(crop.cost)} · ${Format.duration(crop.grow_seconds)} · yield ${Format.coins(crop.reward_coins)} / ${Format.exact(crop.reward_xp)} XP${crop.locked ? ` · ${crop.locked_text || "locked"}` : ""}${crop.market_state !== "normal" ? ` · ${crop.market_state}` : ""}`).join("\n"));
}
if (plan.local === "quests") {
return this.write((farm.quests || []).map((quest) => `${quest.scope}/${quest.kind} ${quest.label} ${quest.progress}/${quest.goal} ${quest.claimed ? "claimed" : quest.can_claim ? "ready to claim" : "in progress"} ${quest.reward_stars ? `${quest.reward_stars} Stars` : Format.coins(quest.reward_coins)}\n claim ${quest.kind} ${quest.scope}`).join("\n") || "No quests available.");
}
if (plan.local === "defense") return this.write(`${farm.defense_tier_name} · level ${farm.defense_level}\nUpkeep: ${Format.coins(farm.defense_upkeep_daily)}/day\nNext upgrade: ${farm.defense_next_cost ? Format.coins(farm.defense_next_cost) : "maxed"}`);
if (plan.local === "ci") return this.write(`CI tier ${farm.ci_tier}: ${farm.ci_label} (${Format.speed(farm.ci_speed)})\n${farm.ci_next_tier ? `Next: ${farm.ci_next_label} · ${Format.coins(farm.ci_next_cost)} · ci upgrade` : "Top tier reached."}`);
if (plan.local === "stats") {
return this.write(`Lifetime coins: ${Format.coins(farm.lifetime_coins_earned)}\nLifetime harvests: ${Format.exact(farm.lifetime_harvests)}\nThis week's harvests: ${Format.exact(farm.harvests_week)}\nEra: ${farm.era_active ? farm.era_name : "none"}\nEra coins: ${Format.coins(farm.era_coins)} · harvests ${Format.exact(farm.era_harvests)}\nMastery: ${Format.exact(farm.mastery_points)} points · ${Format.exact(farm.mastery_points_earned_total)} earned`);
}
const catalogs = { perks: "perks", legacy: "legacy", mastery: "mastery", infra: "infrastructure", cosmetics: "cosmetics" };
const list = farm[catalogs[plan.local]] || [];
const currency = plan.local === "legacy" ? " Stars" : plan.local === "mastery" ? " Mastery" : "c";
this.write(list.map((entry) => `${entry.key} · ${entry.name}${entry.level !== undefined ? ` · Lv ${entry.level}/${entry.max_level}` : ""}\n ${entry.effect || entry.description}${entry.owned ? " · owned" : entry.maxed ? " · maxed" : ` · cost ${Format.number(entry.cost ?? entry.cost_coins)}${currency}`}${entry.min_prestige !== undefined ? ` · prestige ${entry.min_prestige}` : ""}${farm.active_title === entry.key ? " · equipped" : ""}`).join("\n") || "No upgrades available in this view.");
}
status(farm) {
return `${farm.owner_username}'s rack · ${farm.plot_count}/${farm.max_plots} servers\n${Format.coins(farm.coins)} · level ${farm.level} · CI ${farm.ci_tier} (${Format.speed(farm.ci_speed)})\nPrestige ${farm.prestige} · ${Format.exact(farm.stars)} Stars · ${Format.exact(farm.total_harvests)} harvests`;
}
servers(farm) {
return farm.plots.map((plot) => `${String(plot.slot + 1).padStart(2, "0")} ${(plot.crop_name || "Empty server").padEnd(20)} ${plot.state === "growing" ? Format.duration(this.game._remaining(plot.ready_at)) : plot.state}${plot.is_golden ? " · GOLDEN" : ""}${plot.raided_pct ? ` · raided ${plot.raided_pct}%` : ""}${plot.stop_after_harvest ? " · auto-replant paused" : ""}`).join("\n");
}
confirmation(plan) {
const farm = this.game.farm;
const action = plan.spec.action;
if (action === "prestige") return `Refactor for ${Format.coins(farm.refactor_cost)}? Coins, level, CI, extra servers and perks reset for a permanent +25% coin bonus. You carry over ${Format.coins(farm.refactor_carryover_preview)} and keep Stars and Legacy upgrades.`;
if (action === "defense-downgrade") return "Drop defense by one tier? This reduces daily upkeep and raid protection.";
if (action === "steal") return `Raid ${plan.values.username}'s Server ${String(plan.params.slot + 1).padStart(2, "0")}?`;
const item = (farm[plan.spec.catalog] || []).find((entry) => entry.key === plan.params.key);
return `Buy ${item?.name || plan.params.key} for ${Format.coins(item?.cost ?? item?.cost_coins)}?`;
}
actionMessage(action, params, data, username) {
const server = params.slot !== undefined ? `Server ${String(Number(params.slot) + 1).padStart(2, "0")}` : "";
if (action === "plant") return `${server} is running ${params.crop}.`;
if (action === "harvest") return `${server} harvested. Rewards credited by the server.`;
if (action === "water") return `Watered ${username ? `${username}'s ` : ""}${server}.`;
if (action === "steal") return `Raided ${username ? `${username}'s ` : ""}${server} for ${Format.coins(data.stole_coins)}.`;
if (action === "fertilize") return `${server} build accelerated.`;
if (action === "plot-stop") return `${server}: auto-replant ${params.stop === "true" ? "will pause after harvest" : "resumed"}.`;
if (action === "buy-plot") return `Server installed. ${data.farm.plot_count}/${data.farm.max_plots} bays in use.`;
if (action === "upgrade") return `CI upgraded to tier ${data.farm.ci_tier}: ${data.farm.ci_label}.`;
if (action === "prestige") return `Refactor complete. Prestige ${data.farm.prestige}.`;
return `${action}${params.key || params.perk || params.quest ? ` / ${params.key || params.perk || params.quest}` : ""}: complete.`;
}
recordAction(action, params, data, username) {
this.write(this.actionMessage(action, params, data, username), "activity", false);
}
observe(previous, farm) {
if (!previous || previous.owner_username !== farm.owner_username) return;
if (farm.auto_harvested) this.write(`CI Bot collected ${Format.exact(farm.auto_harvested)} builds for ${Format.coins(farm.auto_harvest_coins)}.`, "activity", false);
for (const plot of farm.plots) {
const before = previous.plots.find((item) => item.slot === plot.slot);
if (before?.state === "growing" && plot.state === "ready") this.write(`Server ${String(plot.slot + 1).padStart(2, "0")} · ${plot.crop_name} ready to harvest.`, "success", false);
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{% for infra in farm.infrastructure %}
<div class="perk-card">
<div class="perk-card" id="infra-{{ infra.key }}">
<span class="perk-icon" aria-hidden="true">{{ infra.icon }}</span>
<strong class="perk-name">{{ infra.name }}</strong>
<span class="perk-effect">{{ infra.description }}</span>
+1 -1
View File
@@ -1,5 +1,5 @@
{% for perk in farm.perks %}
<div class="perk-card">
<div class="perk-card" id="perk-{{ perk.key }}">
<span class="perk-icon" aria-hidden="true">{{ perk.icon }}</span>
<strong class="perk-name">{{ perk.name }}</strong>
<span class="perk-level">Lv {{ perk.level }}/{{ perk.max_level }}</span>
+17
View File
@@ -0,0 +1,17 @@
<section class="game-terminal" id="game-terminal" data-game-terminal hidden aria-label="Code Farm terminal">
<header class="game-terminal-header">
<span class="terminal-window-lights" aria-hidden="true"><i></i><i></i><i></i></span>
<h3>codefarm <span>/ control terminal</span></h3>
<button type="button" data-terminal-toggle aria-expanded="true" aria-controls="game-terminal-body" aria-label="Minimize terminal">−</button>
</header>
<div id="game-terminal-body" class="game-terminal-body">
<div class="game-terminal-output" data-terminal-output role="log" aria-live="polite" aria-relevant="additions" tabindex="0" aria-label="Terminal output"></div>
<form class="game-terminal-form" data-terminal-form autocomplete="off">
<label class="sr-only" for="game-terminal-command">Code Farm command</label>
<span class="game-terminal-prompt" aria-hidden="true">farm<span>~</span>$</span>
<input id="game-terminal-command" data-terminal-input type="text" maxlength="256" placeholder="help" autocomplete="off" autocapitalize="none" spellcheck="false" enterkeyhint="send">
<button type="submit" aria-label="Run command">↵</button>
</form>
<footer class="game-terminal-footer"><span>↑ ↓ history <b>·</b> Tab complete</span><div><button type="button" data-terminal-command="status">status</button><button type="button" data-terminal-command="servers">servers</button><button type="button" data-terminal-command="help">help</button></div></footer>
</div>
</section>
+28
View File
@@ -28,6 +28,34 @@ them, then documents every field a client sees and shows a complete automated cl
A farm starts with **50 coins** and **4 plots**. Nothing is lost while you are away: builds keep
progressing and wait for you, so the game is safe to check once an hour or once a day.
## Rack control terminal
The rack includes a command window that calls the same game API as the buttons. It uses the
server numbers shown on the rack (**1–12**); the terminal translates these to the API's 0-based
slot indexes. Type `help` for all commands or `api` for the complete route mapping.
| Command example | Action |
|---|---|
| `status`, `servers`, `server 3` | Inspect the rack or select a server. |
| `crops`, `plant 3 python` | Compare workloads and spin one up. `spinup` is an alias for `plant`. |
| `harvest 3`, `harvest all` | Collect one build or all currently ready builds. |
| `fertilize 3`, `stop 3`, `resume 3` | Accelerate a build or manage Continuous Delivery's auto-replant. |
| `ci upgrade`, `perk growth`, `legacy speed`, `mastery analytics` | Buy the corresponding upgrade. |
| `infra registry`, `defense upgrade`, `defense downgrade` | Manage infrastructure and defense. |
| `quests`, `claim harvest daily`, `claim harvest weekly` | Inspect and claim daily quests or weekly contracts. |
| `daily`, `grant`, `refactor` | Claim rewards or refactor; `refactor` is an alias for `prestige`. |
| `cosmetics`, `cosmetic buy skin_neon`, `cosmetic equip title_architect` | Buy cosmetics or equip an owned title. |
| `view alice`, `water alice 2`, `steal alice 2` | Inspect and interact with a neighbour. |
| `leaderboard raids`, `state`, `home`, `stats` | Read rankings, the full farm JSON, your overview, or lifetime/Era stats. |
Run `perks`, `legacy`, `mastery`, `infra`, or `cosmetics` without arguments to list keys,
effects and current costs. Up/Down recalls command history; Tab completes commands and keys.
The terminal retains drafts and output through live refreshes. Price-sensitive confirmations
fetch the latest state before asking; server-side affordability, ownership, cooldowns and
unlock checks still apply. Bulk harvesting sends one normal harvest request per ready server
and stops if a request fails. `view` prints a neighbour's snapshot; it does not change the rack
you are controlling. The Neon Terminal skin changes the rack's lighting and trim once owned.
## Crops
Each crop has a coin cost to plant, a base build time, a coin and experience reward on harvest,
+27 -34
View File
@@ -1,14 +1,17 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/game.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/game-datacenter.css') }}">
{% endblock %}
{% block content %}
<div class="game-page" data-game-root data-mode="own" data-username="{{ farm.owner_username }}">
<header class="game-header">
<div class="game-title">
<span class="farm-eyebrow">SERVER CONTROL</span>
<h1>Code Farm</h1>
<p class="game-subtitle">Plant projects, let them build, harvest coins and XP, complete quests, upgrade perks, and water your friends' builds.</p>
<p class="game-subtitle">Spin up projects, harvest their rewards, and build out your server rack.</p>
</div>
<a class="farm-guide" href="/docs/code-farm.html">How to play <span aria-hidden="true">↗</span></a>
</header>
{% if game_error %}
@@ -17,85 +20,75 @@
<section class="game-hud" data-game-hud aria-label="Farm status">
<div class="hud-stat">
<span class="hud-label">Coins</span>
<span class="hud-label"><span class="hud-symbol" aria-hidden="true">◈</span> Coins</span>
<span class="hud-value" data-hud-coins title="{{ format_exact(farm.coins) }}">{{ format_number(farm.coins) }}</span>
</div>
<div class="hud-stat">
<span class="hud-label">Level</span>
<span class="hud-label"><span class="hud-symbol" aria-hidden="true">⬡</span> Level</span>
<span class="hud-value" data-hud-level>{{ farm.level }}</span>
<div class="hud-xp-bar"><span class="hud-xp-fill" data-hud-xp-fill style="--hud-xp: {% if farm.level_span %}{{ (100 * farm.level_into / farm.level_span)|round(0, 'floor') }}{% else %}100{% endif %}%"></span></div>
<span class="hud-sub" data-hud-xp>{% if farm.level_is_max %}max level{% else %}{{ farm.level_into }} / {{ farm.level_span }} XP{% endif %}</span>
</div>
<div class="hud-stat">
<span class="hud-label">CI Tier</span>
<span class="hud-label"><span class="hud-symbol" aria-hidden="true">ϟ</span> CI Tier</span>
<span class="hud-value" data-hud-ci>{{ farm.ci_tier }}</span>
<span class="hud-sub" data-hud-ci-label>{{ farm.ci_label }} ({{ format_speed(farm.ci_speed) }})</span>
</div>
<div class="hud-stat">
<span class="hud-label">Prestige</span>
<span class="hud-label"><span class="hud-symbol" aria-hidden="true">◇</span> Prestige</span>
<span class="hud-value" data-hud-prestige>{{ farm.prestige }}</span>
<span class="hud-sub" data-hud-prestige-mult>+{{ (farm.prestige_multiplier * 100 - 100)|round|int }}% coins</span>
</div>
<div class="hud-stat">
<span class="hud-label">Stars</span>
<span class="hud-label"><span class="hud-symbol" aria-hidden="true">✦</span> Stars</span>
<span class="hud-value" data-hud-stars>{{ format_exact(farm.stars) }}</span>
<span class="hud-sub">spend on Legacy</span>
<span class="hud-sub">Legacy currency</span>
</div>
{% if farm.mastery_points_earned_total %}
<div class="hud-stat">
<div class="hud-stat" data-hud-mastery-stat{% if not farm.mastery_points_earned_total %} hidden{% endif %}>
<span class="hud-label">Mastery</span>
<span class="hud-value" data-hud-mastery>{{ format_exact(farm.mastery_points) }}</span>
<span class="hud-sub">spend on Mastery upgrades</span>
</div>
{% endif %}
</section>
{% if farm.underdog_boost_seconds_remaining %}
<p class="daily-claimed" data-underdog-banner>Underdog boost active: +25% coins for another <strong data-underdog-countdown data-seconds="{{ farm.underdog_boost_seconds_remaining }}">{{ format_duration(farm.underdog_boost_seconds_remaining) }}</strong>.</p>
{% endif %}
{% if farm.contract_boost_seconds_remaining %}
<p class="daily-claimed" data-contract-banner>Contract boost active: +20% harvest coins for another <strong data-contract-countdown data-seconds="{{ farm.contract_boost_seconds_remaining }}">{{ format_duration(farm.contract_boost_seconds_remaining) }}</strong>.</p>
{% endif %}
{% if farm.auto_harvested %}
<p class="daily-claimed" data-autoharvest-banner>CI Bot collected {{ format_exact(farm.auto_harvested) }} ready build(s) for {{ format_coins(farm.auto_harvest_coins) }} while you were away.</p>
{% endif %}
{% if farm.era_active %}
<p class="daily-claimed" data-era-banner>Era "{{ farm.era_name }}" is running - you have earned <strong data-era-coins>{{ format_coins(farm.era_coins) }}</strong> Era coins and <strong data-era-harvests>{{ format_exact(farm.era_harvests) }}</strong> Era harvests so far.</p>
{% endif %}
<p class="daily-claimed" data-underdog-banner{% if not farm.underdog_boost_seconds_remaining %} hidden{% endif %}>Underdog boost active: +25% coins for another <strong data-underdog-countdown data-seconds="{{ farm.underdog_boost_seconds_remaining }}">{{ format_duration(farm.underdog_boost_seconds_remaining) }}</strong>.</p>
<p class="daily-claimed" data-contract-banner{% if not farm.contract_boost_seconds_remaining %} hidden{% endif %}>Contract boost active: +20% harvest coins for another <strong data-contract-countdown data-seconds="{{ farm.contract_boost_seconds_remaining }}">{{ format_duration(farm.contract_boost_seconds_remaining) }}</strong>.</p>
<p class="daily-claimed" data-autoharvest-banner{% if not farm.auto_harvested %} hidden{% endif %}>CI Bot collected <strong data-auto-harvest-count>{{ format_exact(farm.auto_harvested) }}</strong> ready build(s) for <strong data-auto-harvest-coins>{{ format_coins(farm.auto_harvest_coins) }}</strong> while you were away.</p>
<p class="daily-claimed" data-era-banner{% if not farm.era_active %} hidden{% endif %}>Era "<span data-era-name>{{ farm.era_name }}</span>" is running - you have earned <strong data-era-coins>{{ format_coins(farm.era_coins) }}</strong> Era coins and <strong data-era-harvests>{{ format_exact(farm.era_harvests) }}</strong> Era harvests so far.</p>
<nav class="farm-navigation" aria-label="Farm sections"><a class="farm-nav-current" href="#datacenter">▤ Server rack</a><a href="#game-terminal" data-terminal-link hidden>&gt;_ Terminal</a><a href="#infrastructure">ϟ Infrastructure</a><a href="#perks">⌘ Perks</a><a href="#legacy">✦ Legacy</a></nav>
<div id="datacenter" data-game-grid-host>
{% include "_game_grid.html" %}
</div>
<script type="application/json" data-game-initial-state>{{ farm|tojson }}</script>
{% include "_game_terminal.html" %}
<div class="game-layout">
<div class="game-main">
<h2 class="game-section-title">Your plots</h2>
<div data-game-grid-host>
{% include "_game_grid.html" %}
</div>
<section class="game-shop">
<section class="game-shop" id="infrastructure">
<h2 class="game-section-title">Infrastructure</h2>
<div data-shop-host>{% include "_game_shop.html" %}</div>
<div data-defense-host>{% include "_game_defense.html" %}</div>
<div class="perk-grid" data-infra-host>{% include "_game_infra.html" %}</div>
</section>
<section class="game-perks">
<section class="game-perks" id="perks">
<h2 class="game-section-title">Perks</h2>
<div class="perk-grid" data-perk-host>{% include "_game_perks.html" %}</div>
</section>
<section class="game-legacy">
<section class="game-legacy" id="legacy">
<h2 class="game-section-title">Legacy (Stars)</h2>
<p class="game-legacy-note">Earn Stars every Refactor. Legacy upgrades are permanent and survive every refactor.</p>
<div class="perk-grid" data-legacy-host>{% include "_game_legacy.html" %}</div>
</section>
{% if farm.mastery_points_earned_total %}
<section class="game-mastery">
<section class="game-mastery" data-mastery-section{% if not farm.mastery_points_earned_total %} hidden{% endif %}>
<h2 class="game-section-title">Mastery</h2>
<p class="game-legacy-note">Earned every 10 prestige past 50. Mastery upgrades open new gameplay rather than bigger numbers, and survive every refactor.</p>
<p class="game-legacy-note">Earned at prestige 50 and every 5 prestige after that. Mastery upgrades open new gameplay rather than bigger numbers, and survive every refactor.</p>
<div class="perk-grid" data-mastery-host>{% include "_game_mastery.html" %}</div>
<div data-analytics-host>{% include "_game_analytics.html" %}</div>
</section>
{% endif %}
<section class="game-cosmetics">
<h2 class="game-section-title">Cosmetics</h2>
+4 -1
View File
@@ -1,11 +1,13 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/game.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/game-datacenter.css') }}">
{% endblock %}
{% block content %}
<div class="game-page" data-game-root data-mode="view" data-username="{{ farm.owner_username }}">
<header class="game-header">
<div class="game-title">
<span class="farm-eyebrow">NEIGHBOURHOOD / CODE FARM</span>
<h1>{{ farm.owner_username }}'s Code Farm</h1>
<p class="game-subtitle">Water their growing builds to speed them up and earn coins yourself.</p>
</div>
@@ -33,10 +35,11 @@
</section>
<div class="game-main">
<h2 class="game-section-title">Plots</h2>
<div data-game-grid-host>
{% include "_game_grid.html" %}
</div>
<script type="application/json" data-game-initial-state>{{ farm|tojson }}</script>
{% include "_game_terminal.html" %}
</div>
</div>
{% endblock %}
+157 -14
View File
@@ -111,8 +111,8 @@ def test_plant_crop(alice):
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("shell")
form.locator("button").click()
form.locator("input[name='crop'][value='shell']").check()
form.locator("button[type='submit']").click()
expect(page.locator(".game-plot-growing")).to_have_count(1)
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99995))
@@ -122,8 +122,8 @@ def test_harvest_crop(alice):
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("shell")
form.locator("button").click()
form.locator("input[name='crop'][value='shell']").check()
form.locator("button[type='submit']").click()
expect(page.locator(".game-plot-growing")).to_have_count(1)
warp_ready("alice_test")
open_game(page)
@@ -138,8 +138,8 @@ def test_fertilize_spends_coins(alice):
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("python")
form.locator("button").click()
form.locator("input[name='crop'][value='python']").check()
form.locator("button[type='submit']").click()
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99985))
fert = page.locator("form[data-game-action='fertilize'] button").first
fert.wait_for(state="visible")
@@ -154,6 +154,8 @@ def test_buy_plot(alice):
open_game(page)
page.locator("form[data-game-action='buy-plot'] button").click()
expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(5)
expect(page.locator("[data-select-slot='4']")).to_have_attribute("aria-pressed", "true")
expect(page.locator("[data-inspector-slot='4']")).to_be_visible()
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99900))
@@ -474,7 +476,7 @@ def test_crop_shows_saturated_label_when_overfarmed(alice):
}
)
open_game(page)
option = page.locator("form[data-game-action='plant'] select option[value='shell']").first
option = page.locator(".build-card:has(input[value='shell'])").first
option.wait_for(state="attached")
deadline = datetime.now(timezone.utc) + timedelta(seconds=10)
while "saturated" not in option.inner_text() and datetime.now(timezone.utc) < deadline:
@@ -482,7 +484,7 @@ def test_crop_shows_saturated_label_when_overfarmed(alice):
page.reload(wait_until="domcontentloaded")
page.locator("[data-game-grid]").first.wait_for(state="visible")
option = page.locator(
"form[data-game-action='plant'] select option[value='shell']"
".build-card:has(input[value='shell'])"
).first
option.wait_for(state="attached")
assert "saturated" in option.inner_text()
@@ -502,8 +504,8 @@ def test_plot_stop_toggle_hidden_without_continuous_delivery(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.select_option("[data-slot='0'] select[name='crop']", "shell")
page.click("[data-slot='0'] button:has-text('Plant')")
page.locator("input[data-build-choice][value='shell']").check()
page.locator("form[data-game-action='plant'] button[type='submit']").click()
page.locator("[data-slot='0'].game-plot-growing").wait_for(state="visible")
assert page.locator("form[data-game-action='plot-stop']").count() == 0
@@ -513,14 +515,155 @@ def test_plot_stop_toggle_flips_and_leaves_plot_empty_after_harvest(alice):
reset_farm("alice_test")
set_farm("alice_test", mastery_autoreplant=1)
open_game(page)
page.select_option("[data-slot='0'] select[name='crop']", "shell")
page.click("[data-slot='0'] button:has-text('Plant')")
toggle = page.locator("[data-slot='0'] form[data-game-action='plot-stop'] button")
page.locator("input[data-build-choice][value='shell']").check()
page.locator("form[data-game-action='plant'] button[type='submit']").click()
toggle = page.locator("[data-inspector-slot='0'] form[data-game-action='plot-stop'] button")
toggle.wait_for(state="visible")
expect(toggle).to_contain_text("Stop after harvest")
toggle.click()
expect(toggle).to_contain_text("Resume auto-replant")
warp_ready("alice_test")
open_game(page)
page.locator("[data-slot='0'] form[data-game-action='harvest'] button").click()
page.locator("[data-inspector-slot='0'] form[data-game-action='harvest'] button").click()
page.locator("[data-slot='0'].game-plot-empty").wait_for(state="visible")
def test_datacenter_select_plot_and_deploy_chosen_project(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator("[data-select-slot='2']").click()
expect(page.locator("[data-inspector-slot='2']")).to_be_visible()
expect(page.locator("[data-select-slot='2']")).to_have_attribute("aria-pressed", "true")
page.locator("input[data-build-choice][value='python']").check()
page.locator("form[data-game-action='plant'] button[type='submit']").click()
expect(page.locator("[data-slot='2']")).to_have_attribute("data-state", "growing")
expect(page.locator("[data-slot='0']")).to_have_attribute("data-state", "empty")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99985))
def test_datacenter_keyboard_and_refresh_preserve_selection(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator("[data-select-slot='0']").focus()
page.keyboard.press("ArrowRight")
expect(page.locator("[data-inspector-slot='1']")).to_be_visible()
page.locator("input[data-build-choice][value='python']").check()
page.evaluate("window.app.gameFarm.load()")
expect(page.locator("[data-select-slot='1']")).to_have_attribute("aria-pressed", "true")
expect(page.locator("input[data-build-choice][value='python']")).to_be_checked()
def test_build_catalog_explains_locks_and_filters_projects(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
kernel = page.locator(".build-card:has(input[value='kernel'])")
expect(kernel).to_contain_text("Unlocks at level 8")
expect(kernel.locator("input")).to_be_disabled()
page.locator("[data-build-filter='available']").click()
expect(page.locator("input[data-build-choice]")).to_have_count(2)
page.locator("[data-build-filter='all']").click()
expect(page.locator("input[data-build-choice][value='kernel']")).to_be_disabled()
def test_rack_hardware_reflects_existing_upgrades(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
expect(page.locator(".rack-cabinet")).to_have_attribute("data-cooling-level", "0")
page.locator("form[data-game-action='perk']:has(input[value='growth']) button").click()
expect(page.locator(".rack-cabinet")).to_have_attribute("data-cooling-level", "1")
page.locator("form[data-game-action='upgrade'] button").click()
expect(page.locator(".rack-cabinet")).to_have_attribute("data-network-tier", "2")
def test_full_rack_has_no_extra_purchase_bay(alice):
page, _ = alice
reset_farm("alice_test")
from devplacepy.services.game import store
from devplacepy.database import get_table
user = get_table("users").find_one(username="alice_test")
for _ in range(8):
store.buy_plot({"uid": user["uid"], "username": "alice_test"})
open_game(page)
expect(page.locator(".rack-server")).to_have_count(12)
expect(page.locator("form[data-game-action='buy-plot']")).to_have_count(0)
page.locator("[data-select-slot='11']").click()
expect(page.locator("#rack-server-title")).to_have_text("SERVER 12")
def test_terminal_commands_deploy_to_visible_server_number(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
command = page.locator("[data-terminal-input]")
command.fill("server 2")
command.press("Enter")
expect(page.locator("[data-select-slot='1']")).to_have_attribute("aria-pressed", "true")
expect(command).to_be_visible()
expect(command).to_be_focused()
command.fill("plant 2 py")
command.press("Tab")
expect(command).to_have_value("plant 2 python ")
command.press("Enter")
expect(page.locator("[data-slot='1']")).to_have_attribute("data-state", "growing")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99985))
expect(page.locator(".terminal-line").last).to_contain_text("Server 02 is running python")
def test_terminal_draft_history_and_output_survive_refresh(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
command = page.locator("[data-terminal-input]")
command.fill("status")
command.press("Enter")
expect(page.locator(".terminal-line").last).to_contain_text("alice_test's rack")
command.fill("unfinished draft")
page.evaluate("window.app.gameFarm.load()")
expect(command).to_have_value("unfinished draft")
expect(command).to_be_focused()
command.press("ArrowUp")
expect(command).to_have_value("status")
command.press("ArrowDown")
expect(command).to_have_value("unfinished draft")
page.locator("[data-select-slot='2']").click()
expect(command).to_have_value("unfinished draft")
expect(page.locator("[data-game-terminal]")).to_have_count(1)
expect(page.locator("[data-terminal-output]")).to_contain_text("alice_test's rack")
def test_terminal_neighbour_action_keeps_own_rack(bob):
page, _ = bob
from tests.e2e.game.farm import _plant_growing
reset_farm("alice_test")
reset_farm("bob_test", coins=0)
_plant_growing("alice_test")
open_game(page)
command = page.locator("[data-terminal-input]")
command.fill("water alice_test 1")
command.press("Enter")
expect(page.locator(".terminal-line").last).to_contain_text("Watered alice_test's Server 01")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(6))
assert page.evaluate("window.app.gameFarm.farm.owner_username") == "bob_test"
expect(page.locator(".game-plot-growing")).to_have_count(0)
def test_mastery_and_boosts_appear_when_unlocked_without_reload(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
expect(page.locator("[data-mastery-section]")).to_be_hidden()
expect(page.locator("[data-hud-mastery-stat]")).to_be_hidden()
expect(page.locator("[data-contract-banner]")).to_be_hidden()
set_farm("alice_test", mastery_points_earned_total=1, mastery_points=1,
contract_boost_until=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat())
page.evaluate("window.app.gameFarm.load()")
expect(page.locator("[data-mastery-section]")).to_be_visible()
expect(page.locator("[data-hud-mastery-stat]")).to_be_visible()
expect(page.locator("[data-hud-mastery]")).to_have_text("1")
expect(page.locator("[data-contract-banner]")).to_be_visible()
+120
View File
@@ -0,0 +1,120 @@
"""The terminal must cover the real router contract, including form parameters."""
import ast
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[4]
COMMANDS = ROOT / "devplacepy/static/js/GameCommands.js"
def command_specs():
source = COMMANDS.read_text(encoding="utf-8")
body = source.split("static API = Object.freeze(", 1)[1].split(");", 1)[0]
return json.loads(body)
def game_routes():
routes = {}
for filename in ("index.py", "farm.py"):
tree = ast.parse((ROOT / "devplacepy/routers/game" / filename).read_text(encoding="utf-8"))
for node in tree.body:
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
continue
if decorator.func.attr in ("get", "post"):
routes[(decorator.func.attr.upper(), "/game" + ast.literal_eval(decorator.args[0]))] = node
return routes
def test_terminal_covers_every_current_game_route():
exposed = {(spec["method"], spec["path"]) for spec in command_specs()}
assert exposed == set(game_routes())
def test_terminal_parameters_match_game_form_models():
models = ast.parse((ROOT / "devplacepy/models/game.py").read_text(encoding="utf-8"))
fields = {
node.name: {field.target.id for field in node.body if isinstance(field, ast.AnnAssign)}
for node in models.body if isinstance(node, ast.ClassDef)
}
for spec in command_specs():
route = game_routes()[(spec["method"], spec["path"])]
for argument in route.args.args:
if argument.arg != "data":
continue
model = argument.annotation.slice.elts[0].id
assert set(spec.get("params", {})) | set(spec.get("fixed", {})) == fields[model], spec
for variable in re.findall(r"\{(\w+)\}", spec["path"]):
assert variable in spec["args"]
def test_terminal_parser_slots_permissions_and_completion(tmp_path):
node = shutil.which("node")
if not node:
pytest.skip("Node is unavailable; command interactions are also covered in browser tests.")
# Browsers treat this file as an ES module; Node 18 needs an explicit suffix.
module = tmp_path / "GameCommands.mjs"
module.write_bytes(COMMANDS.read_bytes())
script = r'''
import assert from "node:assert/strict";
const { GameCommands: C } = await import(MODULE_URL);
const farm = {
is_owner: true, max_plots: 12,
plots: Array.from({length:12},(_,slot)=>({slot,state:slot%2 ? "ready":"empty"})),
crops:[{key:"python"},{key:"shell"}], perks:[{key:"growth"}],
legacy:[{key:"speed"}], mastery:[{key:"analytics"}],
infrastructure:[{key:"registry"}], cosmetics:[{key:"skin_neon"}],
quests:[{kind:"plant",scope:"daily"}]
};
const cases = [
["plant 1 python","/game/plant",{slot:0,crop:"python"}],
["spinup 12 shell","/game/plant",{slot:11,crop:"shell"}],
["harvest 12","/game/harvest",{slot:11}],
["stop 2","/game/plot/stop",{slot:1,stop:"true"}],
["resume 2","/game/plot/stop",{slot:1,stop:"false"}],
["buy server","/game/buy-plot",{}], ["ci upgrade","/game/upgrade",{}],
["boost 3","/game/fertilize",{slot:2}], ["daily","/game/daily",{}],
["grant","/game/grant",{}], ["perk growth","/game/perk",{perk:"growth"}],
["refactor","/game/prestige",{}], ["legacy speed","/game/legacy",{key:"speed"}],
["mastery analytics","/game/mastery",{key:"analytics"}],
["claim plant","/game/quests/claim",{quest:"plant",scope:"daily"}],
["claim plant weekly","/game/quests/claim",{quest:"plant",scope:"weekly"}],
["defense upgrade","/game/defense/upgrade",{}],
["defense downgrade","/game/defense/downgrade",{}],
["infra registry","/game/infrastructure/buy",{key:"registry"}],
["cosmetic buy skin_neon","/game/cosmetics/buy",{key:"skin_neon"}],
["cosmetic equip skin_neon","/game/cosmetics/equip",{key:"skin_neon"}],
["water alice 1","/game/farm/alice/water",{slot:0}],
["steal alice 12","/game/farm/alice/steal",{slot:11}]
];
for (const [line,url,params] of cases) {
const parsed=C.parse(line,farm);
assert.equal(parsed.url,url,line);
assert.deepEqual(parsed.params,params,line);
}
assert.equal(C.parse("leaderboard",farm).url,"/game/leaderboard?board=score");
assert.equal(C.parse("leaderboard raids",farm).url,"/game/leaderboard?board=raids");
assert.equal(C.parse("view alice",farm).url,"/game/farm/alice");
assert.equal(C.parse("state",farm).url,"/game/state");
assert.equal(C.parse("home",farm).url,"/game");
assert.equal(C.parse("harvest all",farm).bulk,true);
assert.equal(C.parse("server 12",farm).slot,11);
assert.equal(C.parse("legacy",farm).local,"legacy");
assert.deepEqual(C.completions("plant 1 py",farm),["python"]);
assert.deepEqual(C.completions("claim plant w",farm),["weekly"]);
for (const line of ["plant 0 python","harvest 13","harvest 1.5","water ../alice 1","water alice 0","claim plant monthly","leaderboard invalid","perk nonexistent","constructor","__proto__","plant 1 python; alert(1)","harvest 1 extra"]) {
assert.throws(()=>C.parse(line,farm),undefined,line);
}
assert.throws(()=>C.parse("plant 1 python",{...farm,is_owner:false}),/own rack/);
assert.equal(C.parse("water alice 1",{...farm,is_owner:false}).url,"/game/farm/alice/water");
console.log("Parser, API bindings, 1-based slots, permissions, invalid commands and completion passed.");
'''.replace("MODULE_URL", json.dumps(module.as_uri()))
result = subprocess.run([node, "--input-type=module"], input=script, text=True, capture_output=True, timeout=15)
assert result.returncode == 0, result.stdout + result.stderr