Files
devplacepy/devplacepy/static/js/GameFarm.js
T
retoorandClaude Sonnet 5 572e022584 Add thread notifications, SEO topic pages, and fix quiz auto-advance
Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.

SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).

Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.

Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
2026-09-03 08:47:57 +02:00

413 lines
23 KiB
JavaScript

// 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);
if (window.app && window.app.dialog && window.app.dialog.isOpen) return;
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 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>`;
} 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);
}
}