// retoor <retoor@molodetz.nl>
import { Format } from "./Format.js";
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.board = "score";
this._bindActions();
this._bindLeaderboardBoard();
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);
});
}
_bindLeaderboardBoard() {
const select = this.root.querySelector("[data-lb-board]");
if (!select) return;
select.addEventListener("change", () => {
this.board = select.value;
this._loadLeaderboard();
});
}
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 === "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();
}
} 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-defense-host]", this._defenseHtml(farm));
this._setHost("[data-infra-host]", this._infraHtml(farm));
this._setHost("[data-perk-host]", this._perksHtml(farm));
this._setHost("[data-legacy-host]", this._legacyHtml(farm));
this._setHost("[data-mastery-host]", this._masteryHtml(farm));
this._setHost("[data-analytics-host]", this._analyticsHtml(farm));
this._setHost("[data-cosmetics-host]", this._cosmeticsHtml(farm));
this._setHost("[data-daily-host]", this._dailyHtml(farm));
this._setHost("[data-grant-host]", this._grantHtml(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;
};
this._setCoins("[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} (${Format.speed(farm.ci_speed)})`);
set("[data-hud-harvests]", Format.exact(farm.total_harvests));
set("[data-hud-prestige]", farm.prestige);
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));
set("[data-era-coins]", Format.coins(farm.era_coins));
set("[data-era-harvests]", Format.exact(farm.era_harvests));
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.setProperty("--hud-xp", `${pct}%`);
}
set("[data-hud-xp]", farm.level_is_max ? "max level" : `${Format.exact(farm.level_into)} / ${Format.exact(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 (${Format.coins(farm.ci_next_cost)})</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.refactor_affordable
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactoring costs ${Format.exact(farm.refactor_cost)} coins and resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You carry over ${Format.exact(farm.refactor_carryover_preview)} coins and keep your Stars and Legacy upgrades. Continue?">Refactor (${Format.coins(farm.refactor_cost)})</button></form>`
: "";
let prestigeText;
if (!farm.prestige_available) {
prestigeText = `Reach level ${farm.prestige_min_level} to refactor.`;
} else if (farm.refactor_affordable) {
prestigeText = `Pay ${Format.coins(farm.refactor_cost)} to reset your farm for a permanent +25% coin bonus. You keep ${farm.refactor_carryover_pct}% of what remains (${Format.coins(farm.refactor_carryover_preview)}) plus your Stars and Legacy upgrades.`;
} else {
prestigeText = `Refactoring costs ${Format.coins(farm.refactor_cost)} right now - keep farming to afford it.`;
}
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 (${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>`;
})
.join("");
}
_legacyHtml(farm) {
return (farm.legacy || [])
.map((up) => {
const action = up.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/legacy" data-game-action="legacy"><input type="hidden" name="key" value="${up.key}"><button type="submit" class="btn btn-sm">Buy (${Format.exact(up.cost)} &#9733;)</button></form>`;
return `<div class="perk-card legacy-card"><span class="perk-icon" aria-hidden="true">${up.icon}</span><strong class="perk-name">${up.name}</strong><span class="perk-level">Lv ${up.level}/${up.max_level}</span><span class="perk-effect">${up.effect || up.description}</span>${action}</div>`;
})
.join("");
}
_masteryHtml(farm) {
return (farm.mastery || [])
.map((m) => {
const action = m.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/mastery" data-game-action="mastery"><input type="hidden" name="key" value="${m.key}"><button type="submit" class="btn btn-sm">Upgrade (${Format.exact(m.cost)} Mastery)</button></form>`;
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${m.icon}</span><strong class="perk-name">${m.name}</strong><span class="perk-level">Lv ${m.level}/${m.max_level}</span><span class="perk-effect">${m.effect || m.description}</span>${action}</div>`;
})
.join("");
}
_analyticsHtml(farm) {
if (!farm.mastery_analytics_unlocked) return "";
return (
`<div class="shop-row"><div class="shop-info"><strong>Lifetime coins earned</strong><span>${Format.coins(farm.lifetime_coins_earned)} since Farm Analytics was unlocked.</span></div></div>` +
`<div class="shop-row"><div class="shop-info"><strong>Lifetime harvests</strong><span>${Format.exact(farm.lifetime_harvests)} builds harvested since Farm Analytics was unlocked.</span></div></div>`
);
}
_infraHtml(farm) {
return (farm.infrastructure || [])
.map((infra) => {
let action;
if (infra.owned) {
action = `<span class="perk-maxed">Owned</span>`;
} else if (farm.prestige < infra.min_prestige) {
action = `<span class="perk-maxed">Requires prestige ${infra.min_prestige}</span>`;
} 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>`;
})
.join("");
}
_defenseHtml(farm) {
const upkeep = farm.defense_upkeep_daily
? `Costs ${Format.coins(farm.defense_upkeep_daily)}/day upkeep. If you cannot pay you lose one level - never your whole balance.`
: "No upkeep at level 0.";
const action = farm.defense_next_cost
? `<form method="post" action="/game/defense/upgrade" data-game-action="defense"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${Format.coins(farm.defense_next_cost)})</button></form>`
: `<span class="perk-maxed">Maxed</span>`;
const downgrade = farm.defense_downgrade_available
? `<form method="post" action="/game/defense/downgrade" data-game-action="defense-downgrade"><button type="submit" class="btn btn-sm" data-confirm="Drop to the next defense tier down? Lower upkeep, less protection.">Downgrade</button></form>`
: "";
return `<div class="shop-row"><div class="shop-info"><strong>Defense: ${farm.defense_tier_name} (level ${farm.defense_level})</strong><span>${upkeep}</span></div><div class="shop-actions">${action}${downgrade}</div></div>`;
}
_cosmeticsHtml(farm) {
return (farm.cosmetics || [])
.map((c) => {
let action;
if (c.owned && c.kind === "title") {
action =
farm.active_title === c.key
? `<span class="perk-maxed">Equipped</span>`
: `<form method="post" action="/game/cosmetics/equip" data-game-action="cosmetic-equip"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm">Equip</button></form>`;
} else if (c.owned) {
action = `<span class="perk-maxed">Owned</span>`;
} else {
action = `<form method="post" action="/game/cosmetics/buy" data-game-action="cosmetic-buy"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${c.name} for ${Format.exact(c.cost_coins)} coins?">Buy (${Format.coins(c.cost_coins)})</button></form>`;
}
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${c.icon}</span><strong class="perk-name">${c.name}</strong><span class="perk-effect">${c.description}</span>${action}</div>`;
})
.join("");
}
_grantHtml(farm) {
const action = farm.grant_available
? `<form method="post" action="/game/grant" data-game-action="grant"><button type="submit" class="btn btn-sm btn-primary">Claim +${Format.coins(farm.grant_amount)}</button></form>`
: `<span class="daily-claimed">${farm.grant_reason}</span>`;
return `<p class="daily-streak">Treasury: <strong>${Format.coins(farm.treasury_balance)}</strong> collected from refactor fees</p>${action}`;
}
_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 +${Format.coins(farm.daily_reward)}</button></form>`
: `<span class="daily-claimed">Claimed today. Come back tomorrow.</span>`;
return `<p class="daily-streak">Streak: <strong>${Format.exact(farm.streak)}</strong> day(s)${farm.daily_streak_reset ? " - your streak lapsed, claiming today starts a new one" : ""}</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;
const reward = quest.reward_stars ? `+${Format.exact(quest.reward_stars)} &#9733;` : `+${Format.coins(quest.reward_coins)}`;
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}"><input type="hidden" name="scope" value="${quest.scope}"><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">${reward}</span></div><div class="quest-bar"><span class="quest-fill" style="--quest-pct:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${Format.exact(quest.progress)}/${Format.exact(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 (${Format.coins(farm.next_plot_cost)})</button></form>`;
}
return `<div class="game-grid" data-game-grid>${cells}${buy}</div>`;
}
_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}">`;
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})` : "";
const market =
crop.market_state === "saturated"
? " (saturated)"
: crop.market_state === "boosted"
? " (boosted)"
: "";
return `<option value="${crop.key}"${disabled}>${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>`;
} 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 (${Format.coins(plot.fertilize_cost)})</button></form>`;
}
} else {
const goldenMark = plot.is_golden ? " &#10024;" : "";
const raided = plot.raided_pct ? `<span class="plot-raided">Raided ${plot.raided_pct}%</span>` : "";
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}${goldenMark}</span>${raided}`;
if (farm.is_owner) {
const label = plot.is_golden ? "Harvest golden" : "Harvest";
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">${label}</button></form>`;
} else if (plot.can_steal) {
body += `<form method="post" action="/game/farm/${farm.owner_username}/steal" data-game-action="steal"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +${Format.coins(plot.steal_coins)}</button></form>`;
} else if (plot.steal_reason === "stripped") {
body += `<span class="plot-ready-label plot-steal-locked">Already stripped</span>`;
} else if (plot.steal_reason === "cooldown") {
body += `<span class="plot-ready-label plot-steal-locked">Raid again in ${this._format(plot.steal_cooldown_seconds)}</span>`;
} else {
body += `<span class="plot-ready-label">Ready</span>`;
}
}
return `${head}${body}</div>`;
}
_startTicker() {
this.ticker = window.setInterval(() => this._tick(), 1000);
}
_tick() {
this._tickBoosts();
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) {
return Format.duration(seconds);
}
_setCoins(selector, value) {
const node = this.root.querySelector(selector);
if (!node) return;
node.textContent = Format.number(value);
node.title = Format.exact(value);
}
_tickBoosts() {
this.root.querySelectorAll("[data-seconds]").forEach((node) => {
const remaining = Math.max(0, Number(node.dataset.seconds || 0) - 1);
node.dataset.seconds = String(remaining);
node.textContent = Format.duration(remaining);
});
}
async _loadLeaderboard() {
const list = this.root.querySelector("[data-game-leaderboard]");
if (!list) return;
try {
const data = await Http.getJson(`/game/leaderboard?board=${encodeURIComponent(this.board || "score")}`);
if (!data.entries || !data.entries.length) {
list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`;
return;
}
const board = this.board || "score";
list.innerHTML = data.entries
.map((entry) => {
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
const value = this._leaderboardValue(board, entry);
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><div class="game-lb-name-group"><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}</div><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
})
.join("");
} catch (error) {
return;
}
}
_leaderboardValue(board, entry) {
if (board === "raids") return `${Format.coins(entry.raid_avg)}/raid`;
if (board === "time_to_kernel") return Format.duration(entry.time_to_kernel_seconds);
return Format.exact(entry.score);
}
}