Firekeeping: shared guild fires

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-21 12:16:22 +01:00
parent c64e28fc38
commit 54ae3ae641
6 changed files with 657 additions and 34 deletions

View file

@ -149,7 +149,7 @@ function toast(text, kind = "") {
}
function showSummary(sum) {
if (!sum.done && !sum.stopped) return;
if (!sum.done && !sum.stopped && !sum.levels.length) return;
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
@ -310,7 +310,11 @@ function nav(id, icon, label, meta = "", extra = "", tip = "") {
function skillNav(skill, me, doing) {
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1;
const flame = heat >= 0 ? `<span class="fdot" style="--c:${flameTint(heat)}"></span>` : "";
const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `<span class="alert"></span>` : "";
const tip = heat >= 0 ? `${C.fires[fire].name}: ${C.flames[heat][0]}` : low ? "A fire is low" : "";
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, flame + busy + low, tip);
}
function pinnedNav(me, doing) {
@ -381,6 +385,12 @@ function topbar() {
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : [];
if (fires.some(([, n]) => n.heat >= 0)) {
const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n");
chips += `<button class="chip fire-pips" data-nav="skill:firekeeping" title="${esc(tip)}">${fires.map(([, n]) =>
`<i class="pip ${n.heat >= 0 ? "lit" : ""} ${n.low ? "low" : ""}" style="--c:${flameTint(n.heat)}"></i>`).join("")}</button>`;
}
const w = S.happenings?.weather;
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`;
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
@ -391,11 +401,11 @@ function topbar() {
function trackedBar() {
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
if (!a) return "";
const ready = Object.entries(a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
const ready = Object.entries(me.fuelled?.[a.id] || a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
const go = !me.blocked[a.id] && me.action?.id !== a.id;
return `<div class="tracked ${ready ? "ready" : ""}">
<button class="tracked-name" data-nav="skill:${a.skill}" data-nav-group="${esc(a.group || "")}" title="${esc(a.name)}">${a.icon}<span class="label">${esc(a.name)}</span></button>
<span class="io">${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")}</span>
<span class="io">${inputChips(a)}</span>
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""}
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
</div>`;
@ -430,6 +440,18 @@ function itemChip(id, qty, have) {
return `<span class="item-chip ${short ? "short" : ""}" title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": "))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
}
// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through.
function inputChips(a) {
const me = S.me, ins = me.fuelled?.[a.id] || a.inputs;
return Object.entries(a.inputs).map(([id, q]) => {
const need = ins[id] || 0, have = me.bank[id] || 0;
if (need === q) return itemChip(id, q, have);
const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`;
return need ? `<span class="item-chip ${have < need ? "short" : ""}" ${tip}>${item(id).icon} ${fmt(have)} <span class="have">/ <s>${q}</s> ${need}</span></span>`
: `<span class="item-chip paid" ${tip}>${item(id).icon} <s>${q}</s></span>`;
}).join("");
}
// Progress towards a building cost: "have / need".
function costChip(icon, name, need, have) {
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
@ -459,6 +481,8 @@ function skillPage(id) {
: `<div class="panel muted">Nothing under construction.</div>`;
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
html += skillGear(id);
if (id === "firekeeping") return html + firekeepingPage();
html += fireBand(id);
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
html += skillHappenings(id);
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
@ -539,6 +563,196 @@ function skillGear(skill) {
return cards.length ? `<div class="gear-row">${cards.join("")}</div>` : "";
}
// --- fires ----------------------------------------------------------------
// The guild's shared fires. Between polls the client burns them down itself, hottest
// fuel first, the same way the server does.
const ROMAN = ["I", "II", "III", "IV", "V", "VI"];
// Core to edge of each flame colour, for the animated fires.
const FLAME_RAMPS = [["#fff3cf", "#ffb54a", "#ff6a2a", "#8a210a"], ["#fffbe6", "#ffe680", "#f2a81f", "#7a4a06"],
["#ffffff", "#f6f4ff", "#c9c4e0", "#4d4a66"], ["#eef8ff", "#8fd0ff", "#3485ea", "#10347a"],
["#ecfff1", "#8af5ab", "#22b85a", "#0a4a25"], ["#f8efff", "#d6b0ff", "#9150f0", "#3a1474"]];
const STILL = matchMedia("(prefers-reduced-motion: reduce)").matches;
let firewood = Number(remember("firewood") ?? -1); // the log tier picked for tending
const flames = {}; // fire -> its particles, kept across renders
let fireTextAt = 0;
const fireOfSkill = (skill) => Object.keys(C.fires).find((id) => C.fires[id].skill === skill);
const flameTint = (tier) => tier >= 0 ? C.flames[tier][1] : "#5d626b";
const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firewood[tier]}`).level;
function fireNow(id) {
const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c }));
let burn = Math.max(0, serverNow() - f.at) * f.speed;
while (burn > 0 && fuel.length) {
const top = fuel.reduce((best, c) => (c.tier > best.tier ? c : best));
const used = Math.min(burn, top.left);
top.left -= used;
burn -= used;
if (top.left <= 1e-6) fuel.splice(fuel.indexOf(top), 1);
}
const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0);
return { fuel, heat, left, low: heat >= 0 && left < C.fire_low, speed: f.speed };
}
function pickFirewood() {
const me = S.me, open = C.firewood.map((_, t) => t).filter((t) => me.levels.firekeeping >= tendLevel(t));
if (open.includes(firewood)) return firewood;
return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0;
}
function firekeepingPage() {
const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed;
const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1);
const logs = C.firewood.map((log, tier) => {
const level = tendLevel(tier), locked = me.levels.firekeeping < level;
const tip = `${item(log).name} · ${C.flames[tier][0]} · ${locked ? `Needs level ${level}` : `${perLog(tier)} XP a log`}`;
return `<button class="logbtn ${tier === pick ? "on" : ""}" style="--c:${flameTint(tier)}" data-firewood="${tier}" ${locked ? "disabled" : ""} title="${esc(tip)}"><span class="swatch"></span>${item(log).icon} ${fmt(me.bank[log] || 0)}</button>`;
}).join("");
return `<div class="panel fire-tools"><div class="logs">${logs}</div>
<span class="tag" title="Fire and XP per log">${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP</span>
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span></div>
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick)).join("")}</div>`;
}
function fireCard(id, pick) {
const me = S.me, spec = C.fires[id], f = S.city.fires[id], n = fireNow(id), skill = skillOf(spec.skill);
const mine = me.action?.id.startsWith(`tend_${id}_`);
const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start];
const crafting = f.users.filter((u) => !u.action.startsWith("tend_"));
const doing = (u) => C.actions.find((a) => a.id === u.action);
const who = crafting.length
? `<span title="${esc(crafting.map((u) => `${u.name}: ${doing(u).name}`).join("\n"))}">👤 ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}</span>`
: `<span class="muted">👤 0</span>`;
return `<div class="fire-card ${n.heat >= 0 ? "lit" : "out"} ${mine ? "active" : ""}" style="--c:${flameTint(n.heat)}">
<div class="fire-stage"><canvas data-flame="${id}"></canvas><span class="fire-state" data-fire-state="${id}"></span></div>
<div class="fire-body">
<div class="fire-head">${glyph(spec.glyph, "#e7e9f0")}<b>${esc(spec.name)}</b><span class="muted small">${skill.icon} ${esc(skill.name)}</span></div>
<div class="fuel" data-fuel="${id}"></div>
<div class="fire-line"><span data-fire-left="${id}"></span><span data-fire-covers="${id}"></span></div>
<div class="fire-line small">${who}<span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div>
${mine ? `<div class="bar thick"><span data-progress></span></div>` : ""}
<div class="foot"><span class="why">${esc(why || "")}</span>${mine
? `<button class="btn danger" data-stop>Stop</button>`
: `<button class="btn primary" data-start="${start}" ${why ? "disabled" : ""}>Tend</button>`}</div>
</div></div>`;
}
// The fire behind a crafting skill, at the top of its page.
function fireBand(skill) {
const id = fireOfSkill(skill);
if (!id) return "";
const n = fireNow(id), spec = C.fires[id], lit = n.heat >= 0;
const pays = lit && `Pays the log for tier ${ROMAN[n.heat]} and below${id === "forge" ? ", and a coal" : ""}`;
return `<div class="fire-band ${lit ? "lit" : ""}" style="--c:${flameTint(n.heat)}">
${glyph(spec.glyph, lit ? flameTint(n.heat) : "#98a1b4")}
<div class="grow"><b>${esc(spec.name)}</b> <span class="muted small">${lit ? `${C.flames[n.heat][0]} · <span data-fire-time="${id}"></span>` : "Out"}</span></div>
${lit ? `<span class="tag flame" title="${esc(pays)}">🪵 ≤ ${ROMAN[n.heat]}</span>` : ""}
<button class="btn sm" data-nav="skill:firekeeping">🔥 Tend</button>
</div>`;
}
function animateFires() {
if (!S?.me || !S.city?.fires) return;
const text = performance.now() - fireTextAt > 250;
if (text) fireTextAt = performance.now();
for (const id of Object.keys(C.fires)) {
const canvas = document.querySelector(`[data-flame="${id}"]`);
if (!canvas && !text) continue;
const n = fireNow(id);
if (canvas) drawFlame(canvas, id, n);
if (!text) continue;
const set = (sel, html) => { for (const el of document.querySelectorAll(sel)) if (el.dataset.v !== html) { el.innerHTML = html; el.dataset.v = html; } };
const bank = Math.max(S.me.firekeeping.bank * 60, n.left);
const tiers = C.flames.map((_, t) => n.fuel.filter((c) => c.tier === t).reduce((sum, c) => sum + c.left, 0));
set(`[data-fuel="${id}"]`, tiers.map((left, t) => left ? `<i style="--c:${flameTint(t)};width:${(left / bank * 100).toFixed(2)}%"></i>` : "").join(""));
set(`[data-fire-left="${id}"]`, `<b>${dur(n.left / n.speed)}</b> / ${dur(S.me.firekeeping.bank * 60 / n.speed)}`);
set(`[data-fire-time="${id}"]`, dur(n.left / n.speed));
set(`[data-fire-covers="${id}"]`, n.heat >= 0 ? `<span class="tag flame" style="--c:${flameTint(n.heat)}" title="Pays the log for tier ${ROMAN[n.heat]} and below">🪵 ≤ ${ROMAN[n.heat]}</span>` : "");
set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]);
for (const el of document.querySelectorAll(`[data-fire-state="${id}"]`)) el.className = `fire-state ${n.heat < 0 ? "out" : n.low ? "low" : ""}`;
}
}
function rampAt(ramp, t) {
const s = t * (ramp.length - 1), i = Math.min(Math.floor(s), ramp.length - 2), f = s - i;
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16));
const a = rgb(ramp[i]), b = rgb(ramp[i + 1]);
return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(",")})`;
}
// A fire on a canvas: rising flame particles in its colour, taller with more fuel;
// smoke when it's low, a grey bed of ash when it's out.
function drawFlame(cv, id, n) {
const st = flames[id] ||= { parts: [], at: performance.now() };
const t = performance.now(), dt = Math.min(0.05, (t - st.at) / 1000);
st.at = t;
const r = cv.getBoundingClientRect(), d = Math.min(devicePixelRatio || 1, 2);
if (!r.width) return;
if (cv.width !== Math.round(r.width * d)) { cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d); }
const ctx = cv.getContext("2d"), W = cv.width, H = cv.height, cx = W / 2, base = H - 22 * d;
const lit = n.heat >= 0, ramp = FLAME_RAMPS[Math.max(0, n.heat)];
const power = lit ? Math.min(1, 0.25 + n.left / 14400) : 0;
ctx.clearRect(0, 0, W, H);
if (lit && !STILL) {
for (let i = Math.round(90 * power * dt) + 1; i > 0; i--) st.parts.push({ k: "f", x: cx + (Math.random() - 0.5) * 46 * d * (0.6 + power * 0.6), y: base,
vx: (Math.random() - 0.5) * 18 * d, vy: -(40 + Math.random() * 60) * d * (0.55 + power), life: 0,
max: 0.55 + Math.random() * 0.5 * (0.5 + power), r: (7 + Math.random() * 9) * d * (0.6 + power * 0.5) });
if (Math.random() < dt * 6 * power) st.parts.push({ k: "e", x: cx + (Math.random() - 0.5) * 40 * d, y: base - 10 * d,
vx: (Math.random() - 0.5) * 30 * d, vy: -(60 + Math.random() * 60) * d, life: 0, max: 1 + Math.random(), r: 1.4 * d });
}
if (!STILL && (!lit || n.low) && Math.random() < dt * (lit ? 5 : 3)) st.parts.push({ k: "s", x: cx + (Math.random() - 0.5) * 20 * d,
y: base - (lit ? 40 * d : 0), vx: (Math.random() - 0.3) * 10 * d, vy: -(18 + Math.random() * 14) * d, life: 0, max: 2.5 + Math.random(), r: (6 + Math.random() * 5) * d });
ctx.fillStyle = "#3a2a1e";
ctx.save();
ctx.translate(cx, base + 8 * d);
for (const [angle, w] of [[-0.18, 70], [0.2, 64], [0, 50]]) {
ctx.save(); ctx.rotate(angle); ctx.beginPath(); ctx.roundRect(-w / 2 * d, -5 * d, w * d, 10 * d, 5 * d); ctx.fill(); ctx.restore();
}
ctx.restore();
if (lit) {
const glow = ctx.createRadialGradient(cx, base + 4 * d, 2, cx, base + 4 * d, 60 * d);
glow.addColorStop(0, ramp[1] + "aa");
glow.addColorStop(1, ramp[3] + "00");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
} else {
ctx.fillStyle = "#4a4a50";
ctx.beginPath(); ctx.ellipse(cx, base + 12 * d, 38 * d, 5 * d, 0, 0, Math.PI * 2); ctx.fill();
}
if (lit && STILL) {
const h = 44 * d * (0.6 + power * 0.5), g = ctx.createRadialGradient(cx, base - 18 * d, 2, cx, base - 18 * d, h);
ramp.forEach((c, i) => g.addColorStop(i / 3, c + (i === 3 ? "00" : "ff")));
ctx.fillStyle = g;
ctx.beginPath(); ctx.ellipse(cx, base - 18 * d, 26 * d, h, 0, 0, Math.PI * 2); ctx.fill();
}
st.parts = st.parts.filter((p) => (p.life += dt) < p.max);
ctx.globalCompositeOperation = "lighter";
for (const p of st.parts) {
p.x += p.vx * dt;
p.y += p.vy * dt;
const k = p.life / p.max;
if (p.k === "f") {
p.vx += (cx - p.x) * 1.8 * dt;
ctx.globalAlpha = (1 - k) * 0.55;
ctx.fillStyle = rampAt(ramp, Math.min(1, k * 1.25));
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - k * 0.7), 0, Math.PI * 2); ctx.fill();
} else if (p.k === "e") {
ctx.globalAlpha = 1 - k;
ctx.fillStyle = ramp[1];
ctx.fillRect(p.x, p.y, p.r * 2, p.r * 2);
}
}
ctx.globalCompositeOperation = "source-over";
for (const p of st.parts) if (p.k === "s") {
const k = p.life / p.max;
ctx.globalAlpha = (1 - k) * 0.22;
ctx.fillStyle = "#9aa0ae";
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + k * 2), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function prayerPanel() {
const me = S.me, city = S.city, level = me.levels.prayer;
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
@ -773,8 +987,7 @@ function actionCard(a) {
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
const chance = ([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`;
@ -1665,6 +1878,7 @@ function animate() {
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
animateFires();
}
function loop() {
@ -1693,6 +1907,7 @@ document.addEventListener("click", (e) => {
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});