feat: add Code Farm cooperative idle game with plot-based crop system and pub/sub notifications

Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
This commit is contained in:
2026-06-22 16:41:53 +00:00
parent 743efbf61f
commit ebf6bf7f82
40 changed files with 3860 additions and 5 deletions
+486
View File
@@ -0,0 +1,486 @@
/* retoor <retoor@molodetz.nl> */
.game-page {
max-width: var(--max-content);
margin: 0 auto;
padding: var(--space-lg);
}
.game-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.game-title h1 {
margin: 0;
font-size: 1.8rem;
}
.game-subtitle {
margin: var(--space-xs) 0 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
.game-hud {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.hud-stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.hud-label {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hud-value {
font-size: 1.6rem;
font-weight: 700;
color: var(--text-primary);
}
.hud-sub {
color: var(--text-secondary);
font-size: 0.8rem;
}
.hud-xp-bar {
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.hud-xp-fill {
display: block;
height: 100%;
background: var(--accent);
transition: width 0.4s ease;
}
.game-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: var(--space-xl);
align-items: start;
}
.game-section-title {
font-size: 1.1rem;
margin: 0 0 var(--space-md);
}
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.game-plot {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
min-height: 140px;
padding: var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-xs);
text-align: center;
}
.game-plot-empty {
border-style: dashed;
}
.game-plot-growing {
border-color: var(--warning);
}
.game-plot-ready {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success) inset;
}
.plot-icon {
font-size: 2.4rem;
line-height: 1;
}
.plot-ready-icon {
animation: plot-bounce 1.2s ease-in-out infinite;
}
@keyframes plot-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.plot-crop-name {
font-weight: 600;
font-size: 0.9rem;
}
.plot-timer {
font-family: var(--font-mono);
color: var(--warning);
font-size: 0.85rem;
}
.plot-water-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.plot-empty-label,
.plot-ready-label {
color: var(--text-muted);
font-size: 0.85rem;
}
.plot-plant-form {
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: 100%;
}
.plot-crop-select {
width: 100%;
padding: var(--space-xs);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-input);
color: var(--text-primary);
font-size: 0.8rem;
}
.game-plot-buy {
border-style: dashed;
color: var(--text-secondary);
}
.game-shop {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.shop-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.shop-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.shop-info span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.game-side {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.game-leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.game-lb-row {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-input);
}
.game-lb-self {
background: var(--bg-muted);
}
.game-lb-rank {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.8rem;
min-width: 2.2em;
}
.game-lb-name {
flex: 1;
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
}
.game-lb-name:hover {
color: var(--accent);
}
.game-lb-level {
color: var(--text-secondary);
font-size: 0.8rem;
}
.game-lb-empty {
color: var(--text-muted);
font-size: 0.85rem;
}
@media (max-width: 900px) {
.game-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.game-page {
padding: var(--space-md);
}
.game-hud {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-sm);
}
.game-title h1 {
font-size: 1.5rem;
}
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--space-sm);
}
.perk-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
@media (max-width: 480px) {
.game-page {
padding: var(--space-sm);
}
.hud-value {
font-size: 1.3rem;
}
.game-grid {
grid-template-columns: repeat(2, 1fr);
}
.game-plot {
min-height: 120px;
padding: var(--space-sm);
}
.plot-icon {
font-size: 2rem;
}
.shop-row {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.shop-row form,
.shop-row .btn {
width: 100%;
}
.perk-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 360px) {
.game-hud {
grid-template-columns: 1fr;
}
.game-grid,
.perk-grid {
grid-template-columns: 1fr;
}
}
@media (hover: none) and (pointer: coarse) {
.game-plot .btn,
.perk-card .btn,
.quest-foot .btn,
.game-daily .btn,
.plot-crop-select {
min-height: 44px;
}
.game-plot form,
.perk-card form,
.game-daily form {
width: 100%;
}
.game-plot .btn,
.perk-card .btn,
.game-daily .btn {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.plot-ready-icon {
animation: none;
}
.hud-xp-fill,
.quest-fill {
transition: none;
}
}
.game-daily,
.game-quests,
.game-lb-section {
margin-bottom: var(--space-lg);
}
.daily-streak {
margin: 0 0 var(--space-sm);
color: var(--text-secondary);
font-size: 0.9rem;
}
.daily-claimed {
color: var(--text-muted);
font-size: 0.85rem;
}
.quest-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.quest-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-sm) var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.quest-done {
opacity: 0.6;
}
.quest-head {
display: flex;
justify-content: space-between;
gap: var(--space-sm);
font-size: 0.85rem;
font-weight: 600;
}
.quest-reward {
color: var(--success);
white-space: nowrap;
}
.quest-bar {
height: 5px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.quest-fill {
display: block;
height: 100%;
background: var(--accent);
}
.quest-foot {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
.quest-claimed {
color: var(--success);
}
.perk-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-md);
}
.perk-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
text-align: center;
}
.perk-icon {
font-size: 1.8rem;
line-height: 1;
}
.perk-name {
font-size: 0.9rem;
}
.perk-level {
color: var(--accent);
font-size: 0.8rem;
font-weight: 600;
}
.perk-effect {
color: var(--text-secondary);
font-size: 0.78rem;
min-height: 2.2em;
}
.perk-maxed {
color: var(--success);
font-size: 0.8rem;
font-weight: 600;
}
.game-perks {
margin-top: var(--space-xl);
}
+2
View File
@@ -38,6 +38,7 @@ import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
class Application {
@@ -85,6 +86,7 @@ class Application {
this.mediaGallery = new MediaGallery();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.localTime = new LocalTime();
this.gameFarm = new GameFarm();
}
}
+252
View File
@@ -0,0 +1,252 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class GameFarm {
constructor() {
this.root = document.querySelector("[data-game-root]");
if (!this.root) return;
this.mode = this.root.dataset.mode || "own";
this.username = this.root.dataset.username || "";
this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state";
this.farm = null;
this._bindActions();
this._startTicker();
this.load();
if (this.mode === "own") this._loadLeaderboard();
if (window.app && window.app.pubsub && this.username) {
window.app.pubsub.subscribe(`public.game.farm.${this.username}`, () => this.load());
}
this.poller = new Poller(() => this.load(), 20000, { pauseHidden: true, immediate: false });
}
async load() {
try {
const data = await Http.getJson(this.stateUrl);
this.render(data.farm);
} catch (error) {
return;
}
}
_bindActions() {
this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form[data-game-action]");
if (!form) return;
event.preventDefault();
this._submit(form);
});
}
async _submit(form) {
const action = form.dataset.gameAction;
const params = {};
form.querySelectorAll("[name]").forEach((field) => {
params[field.name] = field.value;
});
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 === "harvest" || action === "water" || action === "prestige") {
this._loadLeaderboard();
}
} catch (error) {
if (button) button.disabled = false;
}
}
render(farm) {
if (!farm) return;
this.farm = farm;
this._renderHud(farm);
this._setHost("[data-game-grid-host]", this._gridHtml(farm));
if (this.mode === "own") {
this._setHost("[data-shop-host]", this._shopHtml(farm));
this._setHost("[data-perk-host]", this._perksHtml(farm));
this._setHost("[data-daily-host]", this._dailyHtml(farm));
this._setHost("[data-quest-host]", this._questsHtml(farm));
}
this._tick();
}
_setHost(selector, html) {
const host = this.root.querySelector(selector);
if (host) host.innerHTML = html;
}
_renderHud(farm) {
const set = (selector, value) => {
const node = this.root.querySelector(selector);
if (node) node.textContent = value;
};
set("[data-hud-coins]", farm.coins);
set("[data-hud-level]", farm.level);
set("[data-hud-ci]", farm.ci_tier);
set("[data-hud-ci-label]", `${farm.ci_label} (${farm.ci_speed}x)`);
set("[data-hud-harvests]", farm.total_harvests);
set("[data-hud-prestige]", farm.prestige);
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
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);
fill.style.width = `${pct}%`;
}
set("[data-hud-xp]", farm.level_is_max ? "max level" : `${farm.level_into} / ${farm.level_span} XP`);
}
_shopHtml(farm) {
const ciUpgrade = farm.ci_next_tier
? `<form method="post" action="/game/upgrade" data-game-action="upgrade"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${farm.ci_next_cost}c)</button></form>`
: "";
const ciText = farm.ci_next_tier
? `Upgrade to ${farm.ci_next_label} for faster builds`
: "CI is at the top tier.";
const prestigeBtn = farm.prestige_available
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. Continue?">Refactor</button></form>`
: "";
const prestigeText = farm.prestige_available
? "Reset your farm for a permanent +25% coin bonus."
: `Reach level ${farm.prestige_min_level} to refactor.`;
return (
`<div class="shop-row"><div class="shop-info"><strong>CI upgrade</strong><span>${ciText}</span></div>${ciUpgrade}</div>` +
`<div class="shop-row"><div class="shop-info"><strong>Refactor (prestige ${farm.prestige})</strong><span>${prestigeText}</span></div>${prestigeBtn}</div>`
);
}
_perksHtml(farm) {
return (farm.perks || [])
.map((perk) => {
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 (${perk.cost}c)</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>`;
})
.join("");
}
_dailyHtml(farm) {
const action = farm.daily_available
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`
: `<span class="daily-claimed">Claimed today. Come back tomorrow.</span>`;
return `<p class="daily-streak">Streak: <strong>${farm.streak}</strong> day(s)</p>${action}`;
}
_questsHtml(farm) {
const quests = farm.quests || [];
if (!quests.length) return `<li class="game-lb-empty">No quests today.</li>`;
return quests
.map((quest) => {
const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0;
let foot = "";
if (quest.can_claim) {
foot = `<form method="post" action="/game/quests/claim" data-game-action="claim-quest"><input type="hidden" name="quest" value="${quest.kind}"><button type="submit" class="btn btn-sm btn-primary">Claim</button></form>`;
} else if (quest.claimed) {
foot = `<span class="quest-claimed">Done</span>`;
}
return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">+${quest.reward_coins}c</span></div><div class="quest-bar"><span class="quest-fill" style="width:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${quest.progress}/${quest.goal}</span>${foot}</div></li>`;
})
.join("");
}
_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 (${farm.next_plot_cost}c)</button></form>`;
}
return `<div class="game-grid" data-game-grid>${cells}${buy}</div>`;
}
_plotHtml(plot, farm) {
const head = `<div class="game-plot game-plot-${plot.state}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
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 lock = crop.locked ? ` (Lv ${crop.min_level})` : "";
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${crop.cost}c${lock}</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>`;
} else {
body = `<span class="plot-empty-label">Empty plot</span>`;
}
} else if (plot.state === "growing") {
body = `<span class="plot-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span><span class="plot-timer" data-plot-timer data-ready-at="${plot.ready_at}">building</span><span class="plot-water-count">${plot.watered_count}/${plot.max_waters} &#128167;</span>`;
if (plot.can_water) {
body += `<form method="post" action="/game/farm/${farm.owner_username}/water" data-game-action="water"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">&#128167; Water</button></form>`;
}
if (farm.is_owner && plot.fertilize_cost) {
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">&#9889; Fertilize (${plot.fertilize_cost}c)</button></form>`;
}
} else {
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span>`;
if (farm.is_owner) {
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">Harvest +${plot.reward_coins}c</button></form>`;
} else {
body += `<span class="plot-ready-label">Ready</span>`;
}
}
return `${head}${body}</div>`;
}
_startTicker() {
this.ticker = window.setInterval(() => this._tick(), 1000);
}
_tick() {
let flipped = false;
this.root.querySelectorAll("[data-plot-timer]").forEach((node) => {
const readyAt = node.dataset.readyAt;
const remaining = this._remaining(readyAt);
if (remaining <= 0) {
node.textContent = "ready";
flipped = true;
} else {
node.textContent = this._format(remaining);
}
});
if (flipped) this.load();
}
_remaining(iso) {
if (!iso) return 0;
const target = Date.parse(iso);
if (Number.isNaN(target)) return 0;
return Math.max(0, Math.floor((target - Date.now()) / 1000));
}
_format(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
async _loadLeaderboard() {
const list = this.root.querySelector("[data-game-leaderboard]");
if (!list) return;
try {
const data = await Http.getJson("/game/leaderboard");
if (!data.entries || !data.entries.length) {
list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`;
return;
}
list.innerHTML = data.entries
.map(
(entry) =>
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span></li>`
)
.join("");
} catch (error) {
return;
}
}
}