|
// 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)} ★)</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)} ★` : `+${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} 💧</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">💧 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">⚡ Fertilize (${Format.coins(plot.fertilize_cost)})</button></form>`;
|
|
}
|
|
} else {
|
|
const goldenMark = plot.is_golden ? " ✨" : "";
|
|
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);
|
|
}
|
|
}
|