guildhall/static/app.js
rope 54f703341e Guard jerkins: leather-only city kit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:40:41 +00:00

3163 lines
195 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// Guildhall client: renders the server's state and animates progress bars
// between polls. The server is the source of truth for everything.
const POLL_MS = 10000;
const FIGHT_POLL_MS = 2000; // in a fight someone may tag in: keep everyone's view of it close
const SUMMARY_MODAL_AT = 25;
let C = null; // static content tables
let S = null; // latest state
let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item
let dollPick = null; // equipment slot shown on the character sheet
let achPick = null; // achievement chip spelled out: "section.card.entry"
let orderTab = "take"; // work orders: for you, all open, yours
let draft = null; // a work order being written
let seenOrders = null; // order ids already known, so only new ones toast
let deliveredSeen = {}; // how much of each order had arrived
let earnedSeen = {}; // gold each order had paid you for reagents
let pickedClass = null; // registration
let queueOpen = false; // the crafting queue panel under the top bar
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for
let polledAt = 0;
let tagging = false; // this page just tagged into someone's fight
// What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with
// every press, so a tap and a press straight after it don't wait on each other.
let aim = { fight: null, foe: null, ally: null };
let retriedAt = 0; // last time a fight past its kill asked again for the next one
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
let warned = null; // threat the "at the gates" alarm was raised for
let watching = null; // raid report open in the modal
const $ = (sel, root = document) => root.querySelector(sel);
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
const glyph = (name, tint) => `<svg class="gi" style="color:${tint}" aria-hidden="true"><use href="/static/icons.svg#${name}"></use></svg>`;
// A player's banner: field and trim colours, with their emblem on it.
const crest = (look, h = 26) => look ? `<svg class="crest" width="${Math.round(h * 0.8)}" height="${h}" viewBox="0 0 30 38" aria-hidden="true">
<path d="M2 2h26v24l-13 10L2 26z" fill="${look.field}" stroke="${look.trim}" stroke-width="2.5"></path>
${look.emblem ? `<use href="/static/icons.svg#${look.emblem}" x="6" y="6" width="18" height="18" style="color:${look.trim}"></use>` : ""}</svg>` : "";
const titled = (p) => `${crest(p.look)} <b>${esc(p.name)}</b>${p.look?.title ? ` <span class="title">${esc(p.look.title)}</span>` : ""}`;
const COIN = glyph("two-coins", "#e0b04a"), ANY_FOOD = glyph("knife-fork", "#d9c6a0"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c");
function iconise(content) {
const entries = [...Object.values(content.items), ...content.skills, ...Object.values(content.classes),
...Object.values(content.buildings), ...content.prayers, ...content.actions,
...Object.values(content.abilities), ...Object.values(content.enemy_abilities)];
for (const entry of entries) if (entry.glyph) entry.icon = glyph(entry.glyph, entry.tint);
return content;
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmt = (n) => Math.floor(n).toLocaleString("en-GB");
const serverNow = () => Date.now() / 1000 + skew;
const item = (id) => C.items[id] || { name: id, icon: "❔", value: 0 };
const skillOf = (id) => C.skills.find((s) => s.id === id);
const playerName = (uid) => S.players.find((p) => p.id === uid)?.name || uid;
// An open tab polls every 10s and a hidden one stops, so a quiet minute means they've gone.
const ONLINE_S = 45;
const online = (p) => p.id === S.uid || serverNow() - p.seen < ONLINE_S;
const presence = (p) => online(p) ? `<span class="online" title="Online"></span>` : "";
const lastSeen = (p) => online(p) ? "" : ` · seen ${ago(p.seen)}`;
function remember(key, value) {
try {
if (value === undefined) return localStorage.getItem("gh." + key);
localStorage.setItem("gh." + key, value);
} catch { /* private mode: fine */ }
return null;
}
// --- server ---------------------------------------------------------------
async function api(path, body) {
const options = body === undefined ? {} : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
};
const res = await fetch(path, options);
const data = await res.json().catch(() => ({ error: "The server didn't answer properly" }));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function act(path, body) {
try {
accept(await api(path, body));
} catch (err) {
toast(err.message, "bad");
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
polledAt = Date.now();
try {
accept(await api("/api/state"));
failures = 0;
} catch (err) {
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
} finally {
refreshing = false;
}
}
function accept(state) {
version ??= state.version;
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden && !draft) {
location.reload(); // a new build was deployed
return;
}
// Someone tagging into your fight (not you tagging into theirs) gets a toast.
const fightingWith = S?.me && !tagging ? S.me.action?.with || [] : null;
tagging = false;
S = state;
skew = state.now - Date.now() / 1000;
for (const uid of fightingWith ? state.me?.action?.with || [] : []) {
if (!fightingWith.includes(uid)) toast(`⚔️ ${playerName(uid)} joined the fight`, "good");
}
if (state.summary) showSummary(state.summary);
orderNews(state);
const ev = state.upcoming;
if (ev?.at_gates && warned !== ev.id) {
warned = ev.id;
toast(ev.kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!", "bad");
}
render();
showThreat();
}
// --- time -----------------------------------------------------------------
function dur(seconds) {
if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`;
seconds = Math.max(0, Math.round(seconds));
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600);
const m = Math.floor(seconds % 3600 / 60), s = seconds % 60;
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
function clock(t) {
return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
// Coarse on purpose: the markup stays the same between polls (see setHtml).
function ago(t) {
const s = serverNow() - t;
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// A live "3h 12m" until `t`, filled in and kept current by animate().
const countdown = (t) => `<span data-countdown="${t}"></span>`;
// --- toasts & summaries ---------------------------------------------------
function toast(text, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.textContent = text;
$("#toasts").append(el);
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500);
const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4;
while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove();
}
function showSummary(sum) {
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`);
if (got.length) toast(got.join(" · "), "good");
const eaten = Object.entries(sum.eaten || {});
if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`);
if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level");
const spilled = Object.entries(sum.overflow);
if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`);
const sold = Object.entries(sum.sold || {});
if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
if (sum.skipped?.length) toast(`⏭️ Skipped: ${skippedNames(sum)}`, "bad");
for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad");
for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`);
if (sum.prayer_out) toast("🙏 Out of prayer points", "bad");
if (sum.second_wind) toast("🌬️ Second Wind! Back to half HP", "level");
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
}
// Queued makes passed over for want of materials.
function skippedNames(sum) {
return sum.skipped.map((id) => C.actions.find((x) => x.id === id).name).join(", ");
}
function awayModal(sum) {
const items = Object.entries(sum.items).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
const xp = Object.entries(sum.xp).map(([id, q]) => `<li>${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP</li>`).join("");
const levels = sum.levels.map(([id, l]) => `<li>🎉 ${esc(skillOf(id).name)} reached level ${l}</li>`).join("");
const spilled = Object.entries(sum.overflow).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
const sold = Object.entries(sum.sold || {}).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
openModal(`
<h2>While you were away</h2>
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${Object.keys(sum.with || {}).length ? `<p class="muted small">⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}</p>` : ""}
${Object.keys(sum.eaten || {}).length ? `<p class="muted small">Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
${Object.keys(sum.used || {}).length ? `<p class="muted small">Used ${Object.entries(sum.used).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
${xp ? `<ul>${xp}</ul>` : ""}
${levels ? `<ul>${levels}</ul>` : ""}
${sold ? `<p class="muted small">Bank full, sold for ${fmt(sum.sold_gold)} gold:</p><ul>${sold}</ul>` : ""}
${spilled ? `<p class="muted small">Bank full, sent to the stockpile:</p><ul>${spilled}</ul>` : ""}
${sum.skipped?.length ? `<p class="tag bad">⏭️ Skipped: ${esc(skippedNames(sum))}</p>` : ""}
${sum.stopped ? `<p class="tag bad">Stopped: ${esc(sum.stopped)}</p>` : ""}
<p><button class="btn primary" data-close>Back to work</button></p>`);
}
// A raid or dragon attack, played out for everyone once: the clash, then the report.
function showThreat() {
const t = S.threat_report;
if (!t || watching === t.id || !$("#modal").hidden) return;
watching = t.id;
openBattle(t, `data-raid-seen="${t.id}"`);
}
// A past fight, watched again from the council's history.
function replayBattle(id) {
const h = S.history.find((x) => x.id === id);
if (h?.report) openBattle(h, "data-battle-close");
}
// Who stands on each side. Ours: the garrison by name, then the city's defence in squads, each as
// strong as its share of the total. Theirs: the raiders' strength split into bands, or the one dragon.
// `every` is how often each swings, in seconds of the replay; walls and the ram only take blows.
const SQUADS = [
{ id: "guards", parts: ["militia", "weapons", "shields", "jerkins"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 },
{ id: "archers", parts: ["arrows"], name: "Archers", glyph: "bow-arrow", dmg: "pierce", every: 1.8 },
{ id: "walls", parts: ["walls"], name: "Walls", glyph: "stone-wall", dmg: "blunt", still: true },
{ id: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 },
{ id: "swords", parts: ["mercenaries"], name: "Sellswords", glyph: "hooded-assassin", dmg: "slash", every: 2.2 },
{ id: "ward", parts: ["ward"], name: "Temple ward", glyph: "prayer", dmg: "magic", every: 3 },
];
const RAID_BANDS = [
{ id: "cut", name: "Cutthroats", glyph: "bowie-knife", dmg: "slash", every: 1.8 },
{ id: "bow", name: "Bowmen", glyph: "pocket-bow", dmg: "pierce", every: 2 },
{ id: "brute", name: "Brutes", glyph: "fist", dmg: "blunt", every: 3 },
];
const STOPPERS = ["shield_bash", "pinning_shot", "concuss"]; // abilities that cut a cast short
const OURS = "#d4dae2", THEIRS = "#e08a7a";
const BATTLE_STEPS = 32, BATTLE_DT = 0.65, BATTLE_LEAD = 1.2; // ~22s a fight
// An ability's cooldown in the replay: as on auto, but twice as quick, since the fight is short.
const battleCd = (id) => (C.abilities[id]?.cd || 8) * C.auto_cd / 2;
// The big moves: a cast bar, then a burst. Players' abilities come from C.abilities.
function battleMove(label) {
return { ram: { name: "Ram", icon: glyph("wood-beam", THEIRS) },
charge: { name: "Charge", icon: glyph("crossed-swords", OURS) },
war_cry: C.enemy_abilities.war_cry, fire_breath: C.enemy_abilities.fire_breath }[label] || C.abilities[label] || C.enemy_abilities[label];
}
function battleSides(t, rand) {
const r = t.report, parts = r.parts || {}, us = [], them = [];
const sum = Object.values(parts).reduce((a, b) => a + b, 0);
const scale = sum ? r.defence / sum : 1; // hunger takes a fifth off the whole line
r.garrison.forEach((g, i) => {
const p = S.players.find((x) => x.id === g.id);
const style = item(p?.equip?.weapon).style || "sword";
const known = C.weapon_kits[style].filter((id) => (p?.levels.combat ?? 1) >= C.abilities[id].level
&& (C.abilities[id].needs !== "shield" || p?.equip?.shield));
const hits = (id, kinds) => C.abilities[id].effects.some((e) => kinds.includes(e.t));
const kit = known.filter((id) => hits(id, ["dmg", "aoe", "dot"]));
const burst = known.find((id) => hits(id, ["aoe"])) || known.find((id) => hits(id, ["dmg"]));
const stop = known.find((id) => STOPPERS.includes(id));
us.push({ id: `g${i}`, uid: g.id, name: g.id === S.uid ? "You" : esc(g.name), label: `${crest(p?.look, 18)} ${esc(g.name)}`,
top: g.power * scale, dmg: C.styles[style].damage, known, kit, burst, stop, every: C.styles[style].interval });
});
const squad = (sq, top) => top >= 1 && us.push({ ...sq, top, squad: true,
label: `${glyph(sq.glyph, OURS)} ${sq.id === "guards" && r.guards ? `Guards ×${r.guards}` : sq.name}` });
if (sum) for (const sq of SQUADS) squad(sq, sq.parts.reduce((a, k) => a + (parts[k] || 0), 0) * scale);
else squad(SQUADS[3], r.defence - r.garrison.reduce((a, g) => a + g.power, 0));
const band = (b, top) => them.push({ ...b, top, side: "them", label: `${glyph(b.glyph, THEIRS)} ${b.name}` });
if (t.kind === "dragon") {
const name = (r.group || "the dragon").replace(/^\w/, (c) => c.toUpperCase());
band({ id: "d", name: "Dragon", glyph: "dragon-head", dmg: "magic", every: 3, kit: ["tail_swipe"] }, r.attack);
them[0].name = esc(name);
them[0].label = `${glyph("dragon-head", THEIRS)} ${esc(name)}`;
} else {
const chief = r.attack * 0.2, ram = parts.walls ? r.attack * 0.15 : 0;
band({ id: "chief", name: "Warlord", glyph: "visored-helm", dmg: "slash", every: 2.4 }, chief);
if (ram) band({ id: "ram", name: "Battering ram", glyph: "wood-beam", dmg: "blunt", still: true }, ram);
const weights = RAID_BANDS.map(() => 0.6 + rand() * 0.8), total = weights.reduce((a, b) => a + b, 0);
RAID_BANDS.forEach((b, i) => band(b, (r.attack - chief - ram) * weights[i] / total));
}
for (const u of us) u.side = "us";
return { us, them };
}
// The whole fight, worked out up front and seeded by the event so everyone sees the same one. Pure
// theatre: the outcome is already decided. The loser's side runs out; the winner keeps as much as the
// gap between the two. In a close fight the loser surges in the middle and the winner turns it late.
function battleScript(t) {
let seed = t.id * 9301 + 49297;
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const won = t.report.title.startsWith("Victory"), dragon = t.kind === "dragon";
const { us, them } = battleSides(t, rand);
const units = [...us, ...them], byId = Object.fromEntries(units.map((u) => [u.id, u]));
for (const u of units) u.hp = u.top;
const usTop = us.reduce((a, u) => a + u.top, 0), themTop = them.reduce((a, u) => a + u.top, 0);
const close = Math.min(usTop, themTop) / Math.max(usTop, themTop, 1), k = close * close;
const keep = Math.max(0.1, 1 - close);
const p1 = 0.3 + (rand() - 0.5) * 0.1, p2 = 0.72 + (rand() - 0.5) * 0.1;
const lerp = (x, y) => x + (y - x) * k;
// How much of its losses each side has taken by each point of the fight.
const winner = [[0, 0], [p1, lerp(p1, 0.2)], [1, 1]];
const loser = [[0, 0], [p1, lerp(p1, 0.35)], [p2, lerp(p2, 0.55)], [1, 1]];
const at = (pts, x) => {
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1], [x1, y1] = pts[i];
if (x <= x1) return y0 + (y1 - y0) * (x - x0) / (x1 - x0);
}
return 1;
};
const losses = (pts, loss) => {
const steps = Array.from({ length: BATTLE_STEPS }, (_, i) =>
(at(pts, (i + 1) / BATTLE_STEPS) - at(pts, i / BATTLE_STEPS)) * (0.6 + rand() * 0.8));
const total = steps.reduce((a, b) => a + b, 0);
return steps.map((s) => s / total * loss);
};
const toUs = losses(won ? winner : loser, won ? usTop * (1 - keep) : usTop);
const toThem = losses(won ? loser : winner, won ? themTop : themTop * (1 - keep));
const sideOf = { us: toUs, them: toThem };
const time = (step) => BATTLE_LEAD + step * BATTLE_DT;
const live = (side) => (side === "us" ? us : them).filter((u) => u.hp > 0.01);
const pick = (list, weight) => {
let x = rand() * list.reduce((a, u) => a + weight(u), 0);
for (const u of list) if ((x -= weight(u)) <= 0) return u;
return list[list.length - 1];
};
// The big moves. Each side's surge piles the next two steps' damage into one burst; a foiled cast
// is the raiders' war cry, cut short.
const plan = {};
const add = (step, move) => { step = Math.min(BATTLE_STEPS - 1, step); (plan[step] ||= []).push(move); };
const b1 = Math.round(p1 * BATTLE_STEPS), b2 = Math.round(p2 * BATTLE_STEPS);
const best = us.find((u) => u.burst);
const ourMove = byId.archers ? { src: "archers", label: "volley", aoe: true }
: best ? { src: best.id, label: best.burst, aoe: true }
: { src: us.find((u) => !u.still)?.id ?? us[0].id, label: "charge", aoe: true };
const theirMove = dragon ? { src: "d", label: "fire_breath", aoe: true }
: byId.ram ? { src: "ram", label: "ram", tgt: "walls" } : { src: "chief", label: "war_cry", aoe: true };
const late = b2 + 2, mid = b1 + 3;
add(won ? late : mid, { ...ourMove, to: "them" });
add(won ? mid : late, { ...theirMove, to: "us" });
if (!dragon) add(won ? b2 : b1 + 7, { src: "chief", label: "war_cry", foiled: true });
const events = [];
const push = (e) => { e[0] = Math.max(e[0], (events.at(-1)?.[0] ?? 0) + 0.01); events.push(e); };
const focus = {};
// Damage dealt to one side, as hits from its foes; overflow spills onto the next. The losing side
// is picked off unit by unit; the winning side wears down evenly.
const strike = (when, src, to, amount, label, kind = "hit", only = null) => {
while (amount > 0.01) {
const pool = only ? [only].filter((u) => u.hp > 0.01) : live(to);
if (!pool.length) return;
const tgt = (to === "us") === won ? pick(pool, (u) => u.top * (u.hp / u.top) ** 4)
: pool.includes(focus[to]) && rand() < 0.5 ? focus[to] : pick(pool, (u) => u.hp);
focus[to] = tgt;
const n = Math.min(amount, tgt.hp);
tgt.hp = Math.max(0, tgt.hp - n);
amount -= n;
push([when, src.id, tgt.id, kind, n, tgt.hp, label]);
if (tgt.hp <= 0.01) { tgt.hp = 0; push([when + 0.05, src.id, tgt.id, "die"]); }
when += 0.08;
if (only) only = null;
}
};
const attacker = (side) => {
const all = live(side), fighting = all.filter((u) => !u.still);
return (fighting.length ? fighting : all).length ? pick(fighting.length ? fighting : all, (u) => u.top) : null;
};
// Everyone swings on their own clock. Each step's damage to a side is shared among the swings that
// land on it then, by how strong the swinger is; players use their abilities as they come off cooldown.
const next = {}, used = {}, carry = { us: 0, them: 0 };
for (const u of units) if (u.every && !u.still) { next[u.id] = BATTLE_LEAD + rand() * u.every * 0.7; u.from = next[u.id] - u.every; used[u.id] = {}; }
for (let step = 0; step < BATTLE_STEPS; step++) {
const base = time(step);
// Casts start three steps ahead of their burst; a foiled one is stopped a step short.
for (const move of plan[step + 3] || []) {
const src = byId[move.src];
if (src?.hp > 0.01) push([base, move.src, null, "cast", 0, null, move.label, time(step + 3)]);
}
for (const move of plan[step + 1] || []) {
if (!move.foiled || !(byId[move.src]?.hp > 0.01)) continue;
const stoppers = us.filter((u) => u.hp > 0.01 && u.stop);
const stopper = stoppers[Math.floor(rand() * stoppers.length)]
|| live("us").find((u) => u.id === "archers") || attacker("us");
if (stopper) push([base + 0.05, stopper.id, move.src, "stop", 0, null,
stopper.stop || "", move.label]);
if (stopper?.stop) used[stopper.id][stopper.stop] = base;
}
// A burst takes this step's damage and most of the next two's.
for (const move of plan[step] || []) {
const src = byId[move.src], dealt = sideOf[move.to];
if (move.foiled || !(src?.hp > 0.01)) continue;
for (let j = 1; j <= 2 && step + j < BATTLE_STEPS; j++) { dealt[step] += dealt[step + j] * 0.8; dealt[step + j] *= 0.2; }
const amount = dealt[step];
dealt[step] = 0;
if (used[src.id]) used[src.id][move.label] = base;
if (move.aoe) {
const pool = live(move.to), sum = pool.reduce((a, u) => a + u.hp, 0);
let when = base;
for (const u of pool) { strike(when, src, move.to, amount * u.hp / sum, move.label, "crit", u); when += 0.04; }
} else strike(base, src, move.to, amount, move.label, "crit", byId[move.tgt]);
}
// The swings that land this step. A side no one swings at keeps its share for the next step.
const swings = [];
for (const u of units) {
while (next[u.id] < base + BATTLE_DT) { swings.push([next[u.id], u]); next[u.id] += u.every * (0.85 + rand() * 0.3); }
}
swings.sort((x, y) => x[0] - y[0]);
const budget = { us: toUs[step] + carry.us, them: toThem[step] + carry.them }, weight = { us: 0, them: 0 };
const blows = swings.map(([when, u]) => {
const to = u.side === "us" ? "them" : "us";
const ready = (u.kit || []).filter((id) => !(used[u.id][id] > when - battleCd(id)));
const label = ready.length && rand() < 0.5 ? ready[Math.floor(rand() * ready.length)] : u.dmg;
const crit = rand() < 0.1, w = u.top * (0.7 + rand() * 0.6) * (crit ? 1.8 : 1) * (label === u.dmg ? 1 : 1.5);
weight[to] += w;
return { when, u, to, label, crit, w };
});
for (const side of ["us", "them"]) carry[side] = weight[side] ? 0 : budget[side];
for (const { when, u, to, label, crit, w } of blows) {
const amount = budget[to] * w / weight[to];
if (!(u.hp > 0.01)) { carry[to] += amount; continue; } // fell earlier this step
if (label !== u.dmg) used[u.id][label] = when;
if (amount >= 0.5) { strike(when, u, to, amount, label, crit ? "crit" : "hit"); continue; }
carry[to] += amount;
const tgt = live(to)[0];
if (tgt) push([when, u.id, tgt.id, "miss", 0, null, u.dmg]);
}
}
// Whatever's left of the losing side breaks at the last blow.
const end = time(BATTLE_STEPS);
for (const u of won ? them : us) if (u.hp > 0) { u.hp = 0; push([end - 0.1, u.id, u.id, "hit", 0, 0, ""]); push([end - 0.05, u.id, u.id, "die"]); }
return { units, byId, events, end: end + 0.4, won };
}
// Where everyone stands `t` seconds in: HP, last swing, cast under way, out or not.
function battleState(b, t) {
const st = {};
for (const u of b.units) st[u.id] = { hp: u.top, top: u.top, swung: null, cast: null, stopped: null, hit: null, out: false, cds: {} };
let shown = -1;
while (shown + 1 < b.events.length && b.events[shown + 1][0] <= t) {
const [time, src, tgt, kind, n, hp, label, until] = b.events[++shown];
const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp;
if (who && (kind === "miss" || (kind === "hit" || kind === "crit") && n)) who.swung = time;
if (who && C.abilities[label] && (kind === "hit" || kind === "crit" || kind === "stop") && who.cds[label] !== time) who.cds[label] = time;
if (who && kind === "cast") who.cast = { label, from: time, until };
if (them && kind === "stop" && them.cast) { them.stopped = { label: them.cast.label, at: time }; them.cast = null; }
if (them && (kind === "hit" || kind === "crit") && n) them.hit = { at: time, n, kind };
if (them && kind === "die") { them.out = true; them.cast = null; }
}
return { st, shown };
}
// A line of the fight, terse: who, to whom, how much, with what.
function battleLine(e, b) {
const [, src, tgt, kind, n, , label, extra] = e;
const u = (id) => b.byId[id], name = (id) => u(id)?.name ?? "";
const move = battleMove(label);
const what = move ? ` ${move.icon || ""} ${esc(move.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
const theirs = u(src)?.side === "them";
switch (kind) {
case "hit": case "crit":
return n ? `<li class="${theirs ? "bad" : u(src)?.uid === S.uid ? "" : "muted"}">${name(src)} → ${name(tgt)} <b>${Math.max(1, Math.round(n))}</b>${kind === "crit" ? " crit" : ""}${what}</li>` : "";
case "miss": return `<li class="muted">${name(src)} → ${name(tgt)} miss</li>`;
case "cast": return `<li class="warn">${name(src)}${what}…</li>`;
case "stop": {
const cut = battleMove(extra);
return `<li class="good">${name(src)}${what} ✋ ${name(tgt)} <s>${cut?.icon || ""} ${esc(cut?.name)}</s></li>`;
}
case "die": {
const v = u(tgt);
if (v.side === "them") return `<li class="good">${v.id === "d" ? `${v.name} flees` : v.id === "ram" ? "The ram is burning" : `${v.name} break and run`}</li>`;
return `<li class="bad"><b>${v.uid ? (v.uid === S.uid ? "You're knocked out" : `${v.name} is knocked out`)
: v.id === "walls" ? "The walls are breached" : `${v.name} fall back`}</b></li>`;
}
}
return "";
}
// Laid out like the combat page: you and your abilities, then the rest of the garrison and the city's
// squads; across from you the raiders, the fight log and the damage meter.
function openBattle(t, closeAttr) {
const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
const b = battleScript(t);
const unit = (u) => `<div class="fighter ${u.side === "them" ? "foe-unit" : u.uid === S.uid ? "b-me" : "ally"} ${u.squad ? "squad" : ""}" data-b="${u.id}">
<div class="fighter-name"><b>${u.label}<span class="traits-inline">${dmgIcon(u.dmg, "attack")}</span></b>
<span class="small muted"><span class="hitnum" data-u-hitnum></span><span data-u-hp></span></span></div>
<div class="bar hp ${u.side === "them" ? "foe" : "you"}"><span class="lag" data-u-lag></span><span data-u-bar style="width:100%"></span></div>
${u.every ? `<div class="bar swing"><span data-u-swing></span></div>` : ""}
${casters.has(u.id) ? `<div class="cast small" data-u-cast></div>` : ""}
</div>`;
const casters = new Set(b.events.filter((e) => e[3] === "cast").map((e) => e[1])); // room for a cast bar only where one comes
const us = b.units.filter((u) => u.side === "us"), them = b.units.filter((u) => u.side === "them");
const me = us.find((u) => u.uid === S.uid);
const kit = me ? `<div class="ability-bar">${me.known.map((id) => `<span class="ab auto" data-bab="${id}" title="${esc(C.abilities[id].name)}">
<span class="ab-press">${C.abilities[id].icon}<span class="ab-cd" data-ab-cd></span></span></span>`).join("")}</div>` : "";
const group = (r.group || (dragon ? "the dragon" : "the raiders")).replace(/^\w/, (c) => c.toUpperCase());
openModal(`
<div class="battle fight">
<h2>${dragon ? `🐉 ${esc(group)} attacks!` : `⚔️ ${esc(group)} attack!`}</h2>
<div class="tug"><b data-b-us>${fmt(r.defence)}</b>
<div class="bar thick tug-bar"><span data-b-tug style="width:${r.defence / Math.max(r.defence + r.attack, 1) * 100}%"></span></div>
<b data-b-them>${fmt(r.attack)}</b></div>
<div class="battle-frame">
<div class="b-side">
${me ? unit(me) + kit : ""}
<div class="pack squads b-allies">${us.filter((u) => u.uid && u !== me).map(unit).join("")}</div>
<div class="pack squads b-city">${us.filter((u) => !u.uid).map(unit).join("")}</div>
</div>
<div class="b-side">
<div class="pack b-foes">${them.map(unit).join("")}</div>
<ul class="fight-log" data-b-log><li class="muted">${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}</li></ul>
<div class="dps" data-b-dps></div>
<div data-battle="report" hidden>
<div class="battle-result ${won ? "win" : "loss"}">${esc(r.title)}</div>
${r.lines.map((l) => `<p class="small">${esc(l)}</p>`).join("")}
</div>
<p class="btns"><button class="btn" data-battle-skip>Skip</button>
<button class="btn primary" ${closeAttr} hidden>Close</button></p>
</div>
</div>
</div>`);
playBattle(b, $(".battle"));
}
function playBattle(b, box) {
const start = performance.now() / 1000;
let skipped = false;
const frame = () => {
if (!box.isConnected) return;
const t = skipped ? b.end : performance.now() / 1000 - start;
drawBattle(b, box, t);
if (t < b.end) return requestAnimationFrame(frame);
$('[data-battle="report"]', box).hidden = false;
$("[data-battle-skip]", box).hidden = true;
const close = $("[data-raid-seen], [data-battle-close]", box);
close.hidden = false;
close.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
playBattle.skip = () => { skipped = true; };
requestAnimationFrame(frame);
}
function drawBattle(b, box, t) {
const { st, shown } = battleState(b, t), before = t < b.end ? battleState(b, t - LAG).st : st;
let ours = 0, theirs = 0;
for (const u of b.units) if (u.side === "us") ours += st[u.id].hp; else theirs += st[u.id].hp;
$("[data-b-tug]", box).style.width = `${ours / (ours + theirs || 1) * 100}%`;
$("[data-b-us]", box).textContent = fmt(Math.ceil(ours));
$("[data-b-them]", box).textContent = fmt(Math.ceil(theirs));
for (const el of box.querySelectorAll("[data-b]")) {
const u = st[el.dataset.b], was = before[el.dataset.b], spec = b.byId[el.dataset.b];
el.classList.toggle("out", u.out);
el.querySelector("[data-u-bar]").style.width = `${u.hp / u.top * 100}%`;
el.querySelector("[data-u-lag]").style.width = `${Math.max(u.hp, was.hp) / u.top * 100}%`;
el.querySelector("[data-u-hp]").textContent = `${fmt(Math.ceil(u.hp))} / ${fmt(Math.ceil(u.top))}`;
const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
num.textContent = hit ? `−${Math.max(1, Math.round(hit.n))}` : "";
num.className = `hitnum bad ${hit?.kind === "crit" ? "crit" : ""}`;
num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
const swing = el.querySelector("[data-u-swing]");
if (swing) swing.style.width = u.out || t >= b.end ? "0%" : `${Math.max(0, Math.min(100, (t - (u.swung ?? spec.from)) / spec.every * 100))}%`;
const cast = u.cast && t < u.cast.until ? u.cast : null;
const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
const move = battleMove(cast?.label || stopped?.label);
const castEl = el.querySelector("[data-u-cast]");
const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.at}` : "";
if (castEl && castEl.dataset.key !== key) {
castEl.dataset.key = key;
castEl.classList.toggle("stopped", !!stopped);
castEl.innerHTML = cast ? `<span>${move.icon || ""} ${esc(move.name)}</span><span class="bar"><span></span></span>`
: stopped ? `<span>✋ <s>${move.icon || ""} ${esc(move.name)}</s></span><span class="small">interrupted</span>` : "";
}
if (cast && castEl) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
}
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
const mine = b.units.find((u) => u.uid === S.uid);
for (const el of box.querySelectorAll("[data-bab]")) {
const used = st[mine.id].cds[el.dataset.bab], cd = battleCd(el.dataset.bab);
const left = used == null || t >= b.end ? 0 : Math.max(0, used + cd - t);
el.querySelector("[data-ab-cd]").style.height = `${left / cd * 100}%`;
el.classList.toggle("fired", used != null && t - used < 0.6);
}
// The log keeps to what you'd follow: your own blows and the ones on you, abilities, crits, the big
// moves and who falls. Everyone's swings are on the bars.
const log = $("[data-b-log]", box);
b.told ||= b.events.filter((e) => battleTold(e, b));
let told = 0;
while (told < b.told.length && b.told[told][0] <= t) told++;
if (told && log.dataset.key !== `${told}:${t >= b.end}`) {
log.dataset.key = `${told}:${t >= b.end}`;
const lines = b.told.slice(Math.max(0, told - 7), told).reverse().map((e) => battleLine(e, b)).filter(Boolean);
if (t >= b.end) lines.unshift(b.won ? `<li class="good"><b>Victory!</b></li>` : `<li class="bad"><b>The line breaks.</b></li>`);
log.innerHTML = lines.slice(0, 7).join("");
}
// The damage meter: what each of ours has dealt so far, best first.
const meter = $("[data-b-dps]", box), secs = Math.max(1, Math.min(t, b.end) - BATTLE_LEAD);
const key = `${shown}:${Math.floor(Math.min(t, b.end))}`;
if (meter.dataset.key !== key) {
meter.dataset.key = key;
const dealt = {};
for (const e of b.events.slice(0, shown + 1)) if (e[3] === "hit" || e[3] === "crit") dealt[e[1]] = (dealt[e[1]] || 0) + e[4];
const rows = b.units.filter((u) => u.side === "us" && dealt[u.id]).sort((x, y) => dealt[y.id] - dealt[x.id]).slice(0, 5);
const top = Math.max(...rows.map((u) => dealt[u.id]), 1);
meter.innerHTML = rows.length ? `<div class="dps-head small muted"><span>Damage</span><span title="Damage per second · total">DPS · total</span></div>
<ul>${rows.map((u) => `<li class="${u === mine ? "mine" : ""}"><span class="dps-fill" style="width:${dealt[u.id] / top * 100}%"></span>
<b>${u.uid ? esc(playerName(u.uid)) : esc(u.name)}</b><span>${(dealt[u.id] / secs).toFixed(1)} <span class="muted">· ${fmt(Math.round(dealt[u.id]))}</span></span></li>`).join("")}</ul>` : "";
}
}
// Whether a line of the replay makes the log (see drawBattle).
function battleTold(e, b) {
const [, src, tgt, kind, n, , label] = e;
const mine = (id) => b.byId[id]?.uid === S.uid;
if (kind === "cast" || kind === "stop" || kind === "die") return true;
if (kind === "miss") return mine(src);
return !!n && (kind === "crit" || mine(src) || mine(tgt) || !!C.abilities[label] && !!b.byId[src]?.uid);
}
function openModal(html) {
const modal = $("#modal");
modal.innerHTML = `<div class="modal-box">${html}</div>`;
modal.hidden = false;
}
// --- rendering ------------------------------------------------------------
function render() {
const kept = {};
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep;
if (page === "notes") remember("notes", notesKey());
setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>beta</small></div>${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
// The fires keep burning through a re-render: put the old canvases back in place of the new ones.
const burning = [...document.querySelectorAll("canvas[data-flame]")];
setHtml($("#main"), S.me ? pageHtml() : register());
for (const old of burning) {
const fresh = document.querySelector(`canvas[data-flame="${old.dataset.flame}"]`);
if (fresh && fresh !== old) fresh.replaceWith(old);
}
fireTextAt = 0; // and fill in their bars and times straight away
for (const [key, value] of Object.entries(kept)) {
const el = document.querySelector(`[data-keep="${key}"]`);
if (el) el.value = value;
}
if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus();
const msgs = $(".msgs");
if (msgs) msgs.scrollTop = msgs.scrollHeight;
animate();
}
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
// cancels the swipe on iOS, and polls arrive every few seconds.
function setHtml(el, html) {
if (el.dataset.html === html) return;
const scroll = el.scrollTop;
el.innerHTML = html;
el.dataset.html = html;
el.scrollTop = scroll;
}
function setMenu(open) {
$("#app").classList.toggle("open", open);
document.documentElement.classList.toggle("menu-open", open);
}
function nav(id, icon, label, meta = "", extra = "", tip = "") {
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"${tip ? ` title="${esc(tip)}"` : ""}><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
}
function skillNav(skill, me, doing) {
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
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) {
const pinned = C.skills.filter((s) => me.pinned.includes(s.id) && (!s.class || s.class === me.cls));
return pinned.length ? `<div class="navhead">📌 Pinned</div>${pinned.map((s) => skillNav(s, me, doing)).join("")}` : "";
}
function sidebar() {
const me = S.me, cls = C.classes[me.cls];
const doing = me.action && C.actions.find((a) => a.id === me.action.id);
const raid = S.upcoming?.kind === "raid";
const takeable = (S.orders || []).filter(canTake).length;
let html = `
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>beta</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by donating">${FAVOUR} ${fmt(me.favour)}</span></div>
</div>
${pinnedNav(me, doing)}
<div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")}
${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `<span class="badge">${takeable}</span>` : "", takeable ? `${takeable} for you` : "")}
${nav("board", "💬", "Town board")}
${rosterNav()}
${nav("notes", "📰", "Patch notes", "", notesUnread() ? `<span class="badge">new</span>` : "", notesUnread() ? `What's new: ${C.patch_notes[0].title}` : "")}
<div class="navhead">You</div>
${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`, "", `Combat level ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)}
${achievementsNav(me)}`;
let group = null;
for (const skill of C.skills) {
if (skill.class && skill.class !== me.cls) continue;
if (skill.group !== group) {
group = skill.group;
html += `<div class="navhead">${group}</div>`;
}
html += skillNav(skill, me, doing);
}
return html + (S.dev ? devbar() : "")
+ `<p class="credit">Icons by Lorc, Delapouite and others at <a href="https://game-icons.net" target="_blank" rel="noopener">game-icons.net</a>, CC BY 3.0</p>`;
}
function devbar() {
return `<div class="devbar"><b>Dev mode</b> · playing as ${esc(S.uid)}
<form data-form="dev-user"><input type="text" data-keep="dev-user" placeholder="switch user"><button class="btn sm">Go</button></form>
<div class="row">
<button class="btn sm" data-dev-advance="1">+1h</button>
<button class="btn sm" data-dev-advance="8">+8h</button>
<button class="btn sm" data-dev-advance="24">+1d</button>
<button class="btn sm" data-dev-event>Threat now</button>
<button class="btn sm" data-dev-happen="fortune">Fortune</button>
<button class="btn sm" data-dev-happen="hardship">Hardship</button>
<button class="btn sm" data-dev-happen="minor">Problem</button>
</div></div>`;
}
const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", notes: "Patch notes", bank: "Bank", character: "Character" };
function topbar() {
let title = TITLES[page] || "Guildhall";
if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title;
if (page.startsWith("character:")) title = S.players.find((p) => p.id === page.slice(10))?.name || "Character";
if (!S.me) title = "Welcome to Guildhall";
let chips = "";
if (S.me?.action) {
const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id));
chips += `<button class="chip act" data-nav="skill:${action.skill}">${skillOf(action.skill).icon} <span class="label">${esc(action.name)}</span><span class="bar mini ${S.me.action.fight ? "foe" : ""}"><span data-progress></span></span></button>`;
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
if (S.me?.action?.left || S.me?.queue.length) {
chips += `<button class="chip ${queueOpen ? "on" : ""}" data-queue-menu title="Crafting queue">⏳ ${S.me.queue.length}</button>`;
}
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>`;
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}${S.me ? trackedBar() : ""}${S.me && queueOpen ? queuePanel() : ""}`;
}
// The crafting queue, opened from the ⏳ chip: what's being made, what's next, and in what order.
function queuePanel() {
const me = S.me, q = me.queue, recipe = (id) => C.actions.find((x) => x.id === id);
const now = me.action?.left && recipe(me.action.id), back = me.action?.resume && recipe(me.action.resume);
const rows = q.map((e, i) => {
const a = recipe(e.id), why = me.blocked[e.id];
return `<li class="qp-row"><span class="qp-pos">${i + 1}</span>${a.icon}
<span class="qp-name">${esc(a.name)}${why ? `<span class="why small" title="Skipped if still short when its turn comes">${esc(why)}</span>` : ""}</span>
<input type="number" min="1" max="${C.make_max}" value="${e.n}" data-qset="${i}" data-keep="qset-${i}-${e.id}" title="How many">
<button class="btn sm" data-queue-move="${i}" data-to="${i - 1}" ${i ? "" : "disabled"} title="Up">▲</button>
<button class="btn sm" data-queue-move="${i}" data-to="${i + 1}" ${i < q.length - 1 ? "" : "disabled"} title="Down">▼</button>
<button class="btn sm danger" data-unqueue="${i}" title="Remove">✕</button></li>`;
}).join("");
return `<div class="queue-panel">
<div class="qp-head"><b>⏳ Crafting queue</b><span class="muted small">${q.length} / ${C.queue_max}</span><button class="x" data-queue-menu title="Close">✕</button></div>
${now ? `<div class="qp-row now">${now.icon}<span class="qp-name">${esc(now.name)}<span class="muted small">${fmt(me.action.left)} left</span></span>
<button class="btn sm" data-queue-skip title="Drop this make, on to the next">Skip</button></div>` : ""}
${rows ? `<ol class="qp-list">${rows}</ol>` : `<p class="muted small">Empty. +Q on a recipe adds to it.</p>`}
<div class="qp-foot"><span class="muted small">Then ${back ? `back to ${esc(back.name.toLowerCase())}` : "idle"}</span>
${q.length ? `<button class="btn sm" data-queue-clear>Clear</button>` : ""}</div>
</div>`;
}
function queueTip() {
return `Queued: ${S.me.queue.map((q) => `${C.actions.find((x) => x.id === q.id).name} ×${fmt(q.n)}`).join(" → ")}`;
}
// Makes lined up after this one: tap ✕ to drop one.
function queueRow() {
const q = S.me.queue;
if (!q.length) return "";
return `<div class="queue small">⏳ ${q.map((e, i) => {
const a = C.actions.find((x) => x.id === e.id);
return `<span class="item-chip" title="${esc(a.name)}">${a.icon} ×${fmt(e.n)}<button class="x" data-unqueue="${i}" title="Remove">✕</button></span>`;
}).join(`<span class="arrow">→</span>`)}</div>`;
}
// The recipe you're after, under the top bar on every page: have / need for each input.
function trackedBar() {
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
if (!a) return "";
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">${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>`;
}
function eventIcon(kind) {
return { raid: "⚔️", dragon: "🐉", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
}
function eventWhen(ev, short = false) {
if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
}
function pageHtml() {
if (S.me.repick) return repickPage();
if (page.startsWith("skill:")) {
const id = page.slice(6);
const skill = skillOf(id);
if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id);
page = "town";
}
if (page.startsWith("character:")) return characterSheet(page.slice(10));
if (page === "character") return characterSheet(S.uid);
return ({ town, council, orders, board, roster, notes, bank, achievements }[page] || town)();
}
// Recipe input as "have / need"; without `have`, just the quantity.
function itemChip(id, qty, have) {
const short = have !== undefined && have < qty;
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>`;
}
// What the city still needs of each item from building funds, fittings and happenings, after the stockpile.
// Recomputed once per state.
let needsState = null, needsCache = {};
function fundNeeds() {
if (needsState === S) return needsCache;
const city = S.city, missing = {}, why = {};
const add = (id, short, line) => { missing[id] = (missing[id] || 0) + short; (why[id] ||= []).push(line); };
for (const [key, b] of Object.entries(city.buildings)) {
const spec = C.buildings[key];
if (b.status === "funding") for (const [id, need] of Object.entries(b.target)) {
const short = b.missing[id] || 0;
if (id !== "gold" && short) add(id, short, `${spec.name} · ${fmt(need - short)} / ${fmt(need)}`);
}
for (const f of C.fittings) {
if (f.building === key && b.level >= f.level && !b.fittings.includes(f.id)) add(f.id, 1, `${spec.name} · 0 / 1`);
}
}
for (const p of S.happenings.problems) for (const [id, need] of Object.entries(p.need || {})) {
const short = p.missing[id] || 0;
if (short) add(id, short, `${p.name} · ${fmt(need - short)} / ${fmt(need)}`);
}
needsCache = {};
for (const [id, short] of Object.entries(missing)) {
const stock = city.stockpile[id] || 0;
if (short > stock) needsCache[id] = { qty: short - stock, why: stock ? [...why[id], `Stockpile ${fmt(stock)}`] : why[id] };
}
needsState = S;
return needsCache;
}
// How many of an item the city still needs, with a have / need line per reason, or null.
function cityNeed(id) {
const it = item(id), fund = fundNeeds()[id];
let qty = fund?.qty || 0;
const why = [...(fund?.why || [])];
for (const r of S.upcoming?.requests || []) {
const short = r.want - r.have;
const counts = r.any === "food" ? it.food : r.any ? it.slot === r.any : id === r.item;
if (short <= 0 || !counts) continue;
qty += r.any === "food" ? Math.ceil(short / it.food) : short;
why.push(`Council: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`);
}
return qty ? { qty, why } : null;
}
function needTag(id) {
const n = cityNeed(id);
return n ? `<span class="tag gold" title="${esc(`City needs ${fmt(n.qty)} ${item(id).name}\n${n.why.join("\n")}`)}">🏘️ ${fmt(n.qty)}</span>` : "";
}
// A fitting the town already has: working, or paused while its building is damaged.
function fittedTag(id) {
const f = C.fittings.find((x) => x.id === id), b = f && S.city.buildings[f.building];
if (!b?.fittings.includes(id)) return "";
const where = `${C.buildings[f.building].icon} ${C.buildings[f.building].name}`;
return b.damaged ? `<span class="tag bad" title="${esc(`Fitted in the ${where}, paused until it's repaired`)}">✓ Paused</span>`
: `<span class="tag good" title="${esc(`Fitted in the ${where}`)}">✓ Fitted</span>`;
}
// --- skill pages ----------------------------------------------------------
function skillPage(id) {
const me = S.me, skill = skillOf(id), level = me.levels[id], xp = me.xp[id];
const floor = C.xp_table[level], ceil = C.xp_table[level + 1];
const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100;
const cls = C.classes[me.cls];
const fast = cls.fast.includes(id);
let html = `
<div class="panel"><div class="skill-head">
<div class="big">${skill.icon}</div>
<div class="info">
<h2>${skill.name} <button class="pin ${me.pinned.includes(id) ? "on" : ""}" data-pin="${id}" title="${me.pinned.includes(id) ? "Unpin" : "Pin"}">📌</button></h2>
<div class="lv">Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP</div>
<div class="bar xp thick"><span style="width:${pct}%"></span></div>
</div>
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`;
if (id === "combat") html += combatFrame();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `<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));
if (wanted.length) html += `<div class="banner good happening-banner"><span class="banner-ico">${ORDER_ICON()}</span>
<div class="grow"><b>${wanted.length} work order${wanted.length > 1 ? "s" : ""}</b>
<div class="small">${wanted.map((o) => esc(item(o.item).name)).join(" · ")}</div></div>
<button class="btn" data-nav="orders">Work orders</button></div>`;
const live = new Set(S.happenings.problems.map((p) => p.kind));
const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem)));
// Tabs and sections keep their designed order; what's inside each is sorted by the level it needs.
const groups = [...new Set(listed.map((a) => a.group).filter(Boolean))];
const actions = [...listed].sort((a, b) => a.level - b.level);
if (groups.length > 2) {
// Many groups: one tab each.
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "tailoring", "herblore", "combat"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const needed = (g) => actions.some((a) => a.group === g && !isLocked(a) && Object.keys(a.outputs).some(cityNeed));
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}${needed(g) ? ` <span title="City needs">🏘️</span>` : ""}</button>`).join("");
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions.filter((a) => !a.group);
if (loose.length) html += `<div class="grid" style="margin-bottom:14px">${loose.map(actionCard).join("")}</div>`;
return html + `<div class="tabs">${bar}</div><div class="grid">${actions.filter((a) => a.group === current).map(actionCard).join("")}</div>`;
}
// A few groups: each under its own heading, in the order they first appear.
let cards = actions.filter((a) => !a.group).map(actionCard).join("");
for (const g of groups) {
cards += `<div class="section-title grid-head">${esc(g)}</div>` + actions.filter((a) => a.group === g).map(actionCard).join("");
}
return html + `<div class="grid">${cards}</div>`;
}
// Locked: missing a level, class or building, as opposed to just out of materials.
function isLocked(a) {
const why = S.me.blocked[a.id];
return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only")));
}
// A homesteader's two plots: one crop 30% faster, or two crops sharing a 20% faster pace.
function plotsPanel() {
const a = S.me.action, farming = a && C.actions.find((x) => x.id === a.id)?.skill === "farming";
const name = (id) => esc(C.actions.find((x) => x.id === id).name);
const status = !farming ? `<span class="muted">Start a crop to plant plot 1.</span>`
: a.plot2 ? `Plot 1: <b>${name(a.id)}</b> · Plot 2: <b>${name(a.plot2)}</b> <span class="muted">· alternating, 20% faster</span>`
: `Plot 1: <b>${name(a.id)}</b> · Plot 2: <span class="muted">empty · focused, 30% faster</span>`;
return `<div class="panel tool-panel"><span>🌱 ${status}</span></div>`;
}
// The skill's active potion, or the ones in the bank that could be drunk for it.
// A skill's potion and tool side by side, each a slot with what you could switch to beside it.
function skillGear(skill) {
const me = S.me, cards = [];
// Health potions aren't drunk ahead: a fight takes the best one in the bank by itself.
if (skill === "combat") {
const hp = me.health_potion, it = hp && item(hp);
cards.push(`<div class="panel gear-card">
<span class="doll-slot ${it ? "" : "empty"}" title="${esc(it ? `${it.name}: +${it.drink} HP, drunk once a fight under ${Math.round(C.potion_below * 100)}%` : "No health potion")}">${item(hp || "health_potion_1").icon}</span>
<div class="gear-text">${it ? `<b>${esc(it.name)}</b><span class="muted small">+${it.drink} HP under ${Math.round(C.potion_below * 100)}% · ${fmt(me.bank[hp])} in bank · auto</span>`
: `<span class="muted">No health potion</span>`}</div></div>`);
}
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (kinds.length) {
const active = me.potions[skill], it = active && item(active.item);
const others = kinds.filter(([id]) => me.bank[id] && id !== active?.item).map(([id, p]) =>
`<button class="doll-slot swap" data-potion="${id}" title="${esc(`${p.name}: ${p.potion.text}`)}">${p.icon}<span class="swap-qty">${fmt(me.bank[id])}</span></button>`).join("");
cards.push(`<div class="panel gear-card">
<span class="doll-slot ${it ? "" : "empty"}" title="${esc(it ? `${it.name}: ${it.potion.text}` : "No potion")}">${it ? it.icon : glyph("round-bottom-flask", "#98a1b4")}</span>
<div class="gear-text">${it ? `<b>${esc(it.name)}</b><span class="muted small">${esc(it.potion.text)} · ${fmt(active.charges)} uses</span>`
: `<span class="muted">${skill === "combat" ? "No combat potion" : "No potion"}</span>`}</div>
${others ? `<div class="swap-grid">${others}</div>` : ""}</div>`);
}
for (const [kind, none] of [["tool", "No tool"], ["station", "No station"]]) {
const slot = `${kind}_${skill}`;
if (!(slot in me.equip)) continue;
const id = me.equip[slot], it = id && item(id);
const others = Object.keys(me.bank).filter((x) => item(x)[kind] === skill).sort((a, b) => item(b).speed - item(a).speed).map((x) => {
const [s, level] = item(x).req || [], why = s && me.levels[s] < level ? `Needs ${skillOf(s).name.toLowerCase()} ${level}` : "";
return `<button class="doll-slot swap" data-equip="${x}" ${why ? "disabled" : ""} title="${esc([`${item(x).name}: ${Math.round(item(x).speed * 100)}% faster`, why].filter(Boolean).join(" · "))}">${item(x).icon}</button>`;
}).join("");
cards.push(`<div class="panel gear-card">
<span class="doll-slot ${it ? "" : "empty"}" title="${esc(it ? it.name : none)}">${it ? it.icon : glyph(kind === "tool" ? "toolbox" : "anvil", "#98a1b4")}</span>
<div class="gear-text">${it ? `<b>${esc(it.name)}</b><span class="muted small">${Math.round(it.speed * 100)}% faster</span>` : `<span class="muted">${none}</span>`}</div>
${others || it ? `<div class="swap-grid">${others}${it ? `<button class="doll-slot swap off" data-unequip="${slot}" title="Take it off">${glyph("cancel", "#98a1b4")}</button>` : ""}</div>` : ""}</div>`);
}
return cards.length ? `<div class="gear-row">${cards.join("")}</div>` : "";
}
// --- fires ----------------------------------------------------------------
// The guild's shared fires. Between polls the client burns them down itself, the same
// way the server does: the burn shared evenly between everyone's piles, each pile's
// hottest fuel first.
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 }));
const hottest = (by) => fuel.filter((c) => c.by === by).reduce((best, c) => (c.tier > best.tier ? c : best));
let burn = Math.max(0, serverNow() - f.at) * f.speed;
while (burn > 1e-9 && fuel.length) {
const heads = [...new Set(fuel.map((c) => c.by))].map(hottest);
const step = Math.min(burn, Math.min(...heads.map((c) => c.left)) * heads.length);
for (const c of heads) {
c.left -= step / heads.length;
if (c.left <= 1e-6) fuel.splice(fuel.indexOf(c), 1);
}
burn -= step;
}
const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0);
const mine = fuel.filter((c) => c.by === S.uid);
return { fuel, mine, 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 piles = [...new Set(f.fuel.map((c) => c.by))];
const keepers = piles.length ? `<span title="${esc(piles.map(playerName).join("\n"))}">🔥 ${piles.length}</span>` : "";
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="fire-line"><span data-fire-left="${id}" title="Until it's out"></span><span data-fire-covers="${id}"></span></div>
<div class="fuel" data-fuel="${id}" title="Your pile"></div>
<div class="fire-line small"><span data-fire-mine="${id}" title="Your pile / the most you can bank"></span><span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div>
<div class="fire-line small">${who}${keepers}</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 = S.me.firekeeping.bank * 60, mine = n.mine.reduce((sum, c) => sum + c.left, 0);
const tiers = C.flames.map((_, t) => n.mine.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 / Math.max(bank, mine) * 100).toFixed(2)}%"></i>` : "").join(""));
set(`[data-fire-left="${id}"]`, n.heat >= 0 ? `🔥 <b>${dur(n.left / n.speed)}</b>` : "");
set(`[data-fire-mine="${id}"]`, `You ${dur(mine / n.speed)} / ${dur(bank / 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;
}
// Temple levels and its carved altar: +25% prayer points from bones each.
function bonePercent() {
const temple = S.city.buildings.temple;
if (temple?.damaged) return 0;
return 25 *((temple?.level || 0) + (temple?.fittings || []).includes("carved_altar"));
}
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);
const cards = C.prayers.map((p) => {
const on = me.prayers.includes(p.id), locked = level < p.level;
const full = !on && me.prayers.length >= C.max_prayers;
return `<button class="prayer ${on ? "on" : ""} ${locked ? "locked" : ""}" data-pray="${p.id}" ${locked ? "disabled" : ""}
title="${locked ? `Needs prayer level ${p.level}` : full ? `Max ${C.max_prayers} active` : ""}">
<span class="ico">${p.icon}</span>
<span class="grow"><b>${esc(p.name)}</b><span class="small muted">${esc(p.text)}</span></span>
<span class="small ${locked ? "why" : "muted"}">${locked ? `Lv ${p.level}` : `${p.cost} pt${p.cost > 1 ? "s" : ""}/fight`}</span>
</button>`;
}).join("");
const temple = city.buildings.temple.level, boost = bonePercent();
const bury = me.bury !== false;
return `<div class="cols">
<div class="panel"><h2>Prayers · ${me.prayers.length} / ${C.max_prayers} active</h2>
<div class="prayer-points">${skillOf("prayer").icon} <b>${fmt(me.pp)}</b> prayer points${cost ? ` · ${cost} per fight (~${fmt(me.pp / cost)} fights)` : ""}</div>
<div class="prayers">${cards}</div>
<div class="row"><button class="btn sm ${bury ? "gold" : ""}" data-bury="${bury ? "off" : "on"}"
title="Fight bones turn into prayer points and ½ bury XP as they drop. Off: they go to the bank.">🦴 Bury on the spot: ${bury ? "on" : "off"}</button></div>
<p class="small muted">Prayers cost points each fight; bones refill them.</p>
</div>
<div class="panel"><h2>Temple</h2>
${temple ? `<p>Devotion <b>${fmt(city.devotion)}</b> · ward <b>+${city.ward}</b> defence (max +${15 * temple})</p>
<p class="small muted">Offerings ward the next raid. +${boost}% prayer points from bones.</p>`
: `<p class="muted">No temple yet.</p>`}
</div></div>`;
}
// A damage type's icon: what an enemy attacks with, or (tinted) what it's vulnerable to or resists.
function dmgIcon(type, how = "") {
const t = C.damage_types[type], name = t.name.toLowerCase();
const tip = how === "vuln" ? `Vulnerable to ${name} (+${Math.round((C.vulnerable - 1) * 100)}%)`
: how === "resist" ? `Resists ${name} (-${Math.round((1 - C.resistant) * 100)}%)`
: how === "attack" ? `Attacks with ${name}` : `${t.name} damage`;
return `<span class="dmg ${how}" title="${esc(tip)}">${glyph(t.glyph, t.tint)}</span>`;
}
function resistIcons(v) {
return v.vulnerable.map((k) => dmgIcon(k, "vuln")).join("") + v.resists.map((k) => dmgIcon(k, "resist")).join("");
}
// Who turns up in an encounter: each variant, what it attacks with, and how it takes each damage type.
// A variant your weapon is good against is green, one that resists it red.
function variantRows(e, o) {
return `<div class="variants">${e.variants.map((v, i) => {
const fit = o.variants[i].affinity;
return `<div class="variant ${fit > 1 ? "good" : fit < 1 ? "bad" : ""}">
<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
<span class="v-name">${esc(v.name)}${v.archetype === "elite" && !e.boss ? ` <span class="muted">· rare</span>` : ""}</span>
<span class="v-hp muted">${fmt(v.hp)} HP</span>
<span class="v-res">${resistIcons(v)}</span>
</div>`;
}).join("")}</div>`;
}
// Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto),
// your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with
// what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as
// it plays out.
function combatFrame() {
const me = S.me, a = me.action, f = a?.fight;
const encounter = a && C.actions.find((x) => x.id === a.id);
const waiting = a?.waiting;
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"} ${f ? "aimable" : ""}"
data-u="${esc(id)}" ${f ? `data-aim="${esc(id)}" title="${foe ? "Tap to fight this one" : "Tap to aim your heals here"}"` : ""}>
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span class="hitnum" data-u-hitnum></span><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
<div class="bar hp ${foe ? "foe" : "you"}"><span class="lag" data-u-lag></span><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-u-swing></span></div>
<div class="cast small" data-u-cast></div>
</div>`;
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
const run = a?.run;
// A dungeon run: where you are in it, and (for the leader) the button to start it, or to go in early while resting.
const lead = run?.leader === S.uid;
const stage = run ? `<div class="run-head"><b>${esc(run.dungeon)}</b>
<span class="muted small">${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""}</span>
${!run.started && lead ? `<button class="btn sm primary" data-start-run title="No one can join once you start">Start</button>`
: f && lead ? `<button class="btn sm" data-pull data-pull-btn>Pull now</button>` : ""}</div>` : "";
const group = run && [S.uid, ...(a.with || [])].map((uid) => uid === S.uid ? "You" : esc(playerName(uid))).join(", ");
let foe = `<div class="foe-idle muted">${run && !run.started ? `👥 ${run.size} / ${C.dungeon_size} · ${group}<br>${lead ? "Start when everyone's in" : `${esc(playerName(run.leader))} starts the run`}`
: run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back`
: run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
if (f) {
const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage;
const traits = (v) => `<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
<span class="v-res">${v.vulnerable.map((k) => `<span class="${k === mine ? "yours" : ""}">${dmgIcon(k, "vuln")}</span>`).join("")}${v.resists.map((k) => `<span class="${k === mine ? "yours" : ""}">${dmgIcon(k, "resist")}</span>`).join("")}</span>`;
foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span><b>${esc(f.enemy)}</b></div>
<div class="pack">${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` <span class="muted">· rare</span>` : ""}`, true,
` <span class="traits-inline">${traits(v)}</span>`)).join("")}</div>
<ul class="fight-log" data-f="log"></ul>
<div class="dps" data-f="dps"></div>`;
}
return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}>
<div class="you-side">${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}</div>
<div class="foe-side">${stage}${foe}</div>
</div>`;
}
// Your abilities, from your weapon and your trade: each lights up as it's used and shows its cooldown.
// On AUTO a rule uses it on a longer cooldown; on YOU you press it (tap, or its number key) on the real
// one, for as long as you're watching. The weapon's ones still to come are dimmed with their level.
function abilityBar(me) {
const known = me.fighter.abilities, style = me.fighter.style || "sword";
const shield = !!me.equip.shield;
const tile = (id, on) => {
const ab = C.abilities[id], manual = on && me.manual.includes(id);
const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`;
const base = (ab.cd || 0) * (me.fighter.mods?.[id]?.cd ?? 1);
const cd = base ? ` · ${+(manual ? base : base * C.auto_cd).toFixed(1)}s cooldown` : "";
const boosts = (me.fighter.boosts?.[id] || []).map((n) => ` · ✦ ${n}`).join("");
const tip = `${ab.name}: ${ab.tip}${cd}${boosts}${why ? ` · ${why}` : ""}`;
return `<span class="ab ${on ? (manual ? "manual" : "auto") : "locked"} ${boosts && on ? "boosted" : ""}" data-ab="${id}">
<button class="ab-press" data-press="${id}" title="${esc(tip)}" ${on ? "" : "disabled"}>${ab.icon}<span class="ab-cd" data-ab-cd></span>
${on ? "" : `<span class="ab-lv">${why === "needs a shield" ? glyph("checked-shield", "#d4dae2") : ab.level}</span>`}</button>
${on ? `<button class="ab-mode" data-mode="${id}" title="${esc(manual ? `Yours: press it while you watch. Look away ${C.watch}s and it's back on auto. Tap for auto.`
: `Auto: used by itself on a ${C.auto_cd}× cooldown. Tap to take it yourself.`)}">${manual ? "YOU" : "AUTO"}</button>` : ""}
</span>`;
};
const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id)));
const trade = C.trade_ability[me.cls];
const potion = me.health_potion;
return `<div class="ability-bar">${kit.join("")}${trade ? `<span class="ab-gap"></span>${tile(trade, true)}` : ""}
<span class="ab-gap"></span><span class="ab potion ${potion ? "" : "locked"}" data-ab="potion"
title="${esc(potion ? `${item(potion).name}: +${item(potion).drink} HP, once a fight under ${Math.round(C.potion_below * 100)}% · ${fmt(me.bank[potion])} left` : "No health potion")}">${item(potion || "health_potion_1").icon}${potion ? `<span class="swap-qty">${fmt(me.bank[potion])}</span>` : ""}</span></div>`;
}
// Prayers you can switch on and off between swings, and the points left to pay for them.
function prayerBar(me) {
const level = me.levels.prayer;
const buttons = C.prayers.filter((p) => p.level <= level).map((p) => {
const on = me.prayers.includes(p.id);
return `<button class="pray-btn ${on ? "on" : ""}" data-pray="${p.id}" title="${esc(`${p.name}: ${p.text} · ${p.cost} pt${p.cost > 1 ? "s" : ""}/fight`)}">${p.icon}</button>`;
}).join("");
return `<div class="prayer-bar"><span class="pp ${me.prayers.length && !me.blessing.cost ? "warn" : ""}" title="Prayer points">${skillOf("prayer").icon} ${fmt(me.pp)}</span>${buttons}</div>`;
}
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
const LAG = 0.6; // seconds the "just lost" part of an HP bar trails behind
const STOPPED = 1.2; // seconds an interrupted cast stays on its bar
// A line of the fight, terse: who, to whom, how much, with what.
function fightLine(e, f) {
const [, src, tgt, kind, n, , label] = e;
const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id));
const ab = C.abilities[label] || C.enemy_abilities[label];
const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
const hurtsMe = tgt === S.uid && src?.[0] === "e";
const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted";
switch (kind) {
case "hit": case "crit": case "tick":
return `<li class="${tone}">${name(src)} → ${name(tgt)} <b>${n}</b>${kind === "crit" ? " crit" : ""}${what}</li>`;
case "miss": return `<li class="muted">${name(src)} → ${name(tgt)} miss</li>`;
case "block": return `<li class="${tgt === S.uid ? "good" : "muted"}">${name(tgt)} block${tgt === S.uid ? "" : "s"}${what}</li>`;
case "absorb": return `<li class="${tgt === S.uid ? "good" : "muted"}">${C.abilities.ward.icon} ${name(tgt)} soak${tgt === S.uid ? "" : "s"} ${n}</li>`;
case "cast": return `<li class="${src?.[0] === "e" ? "warn" : tone}">${name(src)}${what}…</li>`;
case "use": return `<li class="${tone}">${name(src)}${what}${tgt && tgt !== src ? ` → ${name(tgt)}` : ""}</li>`;
case "stop": return `<li class="good">${name(src)} ✋ ${name(tgt)}${what}</li>`;
case "heal": return `<li class="good">${label === "potion" ? `${name(tgt)} ${item("health_potion_1").icon} potion` : `${name(src)}${what} → ${name(tgt)}`} +${n}</li>`;
case "mend": return `<li class="muted">${name(tgt)} +${n}${what}</li>`;
case "cc": return `<li class="good">${what} ${name(tgt)} held</li>`;
case "free": return `<li class="muted">${name(tgt)} breaks free</li>`;
case "die": return `<li class="good">${name(tgt)} falls</li>`;
case "enrage": return `<li class="bad">${C.abilities.unstoppable.icon} ${name(src)} ${label === "hard" ? "<b>loses patience</b>" : "enrages"}</li>`;
case "yell": return `<li class="warn">${name(src)}: <b>“${esc(label)}”</b></li>`;
case "join": return `<li class="warn">${name(src)} joins the fight</li>`;
case "wind": return `<li class="good">${C.prayers.find((p) => p.id === "second_wind").icon} Second wind! ${name(src)}</li>`;
case "down": return `<li class="bad">${C.abilities.concuss.icon} <b>${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}</b></li>`;
case "left": return `<li class="muted">${name(src)} left</li>`;
}
return "";
}
// Whether a use of an ability came from a press (within the queue window before it), so it ran on its
// real cooldown.
function pressedNear(f, uid, ab, time) {
const m = f.members.find((x) => x.id === uid);
return (m.inputs || []).some((i) => i[1] === "press" && i[2] === ab && time >= (f.rest + i[0]) / f.pace - 0.01
&& time <= (f.rest + i[0] + 1.5) / f.pace + 0.01);
}
// Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not,
// and when each of your abilities was last used.
function fightState(f, t) {
const rest = f.rest / f.pace, st = {};
for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null,
cast: null, out: null, cds: {}, manualAt: {}, pressed: new Set((m.inputs || []).filter((i) => i[1] === "press").map((i) => i[2])) };
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; });
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) {
const [time, src, tgt, kind, n, hp, label] = f.events[++shown];
const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp;
if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time;
if (who && kind === "cast") {
const spec = src[0] === "e" ? C.enemy_abilities[label] : C.abilities[label];
const mod = src[0] === "e" ? 1 : f.members.find((m) => m.id === src)?.mods?.[label]?.cast ?? 1;
who.cast = { label, from: time, until: time + spec.cast * mod / f.pace };
}
const ab = C.abilities[label];
if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind)) && who.cds[label] !== time) {
who.cds[label] = time;
who.manualAt[label] = who.pressed.has(`${label}`) && pressedNear(f, src, label, time);
}
if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt;
if (who && kind === "heal" && label === "potion") who.drank = true;
if (them && kind === "stop" && them.cast) them.stopped = { label: them.cast.label, at: time };
if (them && (kind === "stop" || kind === "cc")) them.cast = null;
if (them && ["hit", "crit", "tick", "heal", "mend"].includes(kind) && n) them.hit = { at: time, n, kind };
if (them && kind === "cc") them.held = true;
if (them && (kind === "free" || kind === "die")) them.held = false;
if (them && kind === "die") them.out = "dead";
if (who && (kind === "down" || kind === "left")) who.out = kind;
if (who && kind === "enrage") who.enraged = true;
if (who && kind === "join") { who.hidden = false; who.from = time; }
}
return { st, shown };
}
// The damage meter: what each fighter has dealt so far, and per second since they joined (game seconds,
// so dev speed doesn't inflate it). Best first, bars scaled to the top DPS.
const DAMAGE = new Set(["hit", "crit", "tick"]);
let lastMeter = null; // the fight just finished, shown dimmed while resting for the next one
function dpsMeter(f, t) {
const rows = f.members.map((m) => {
let dmg = 0, heal = 0;
for (const e of f.events) {
if (e[0] > t) break;
if (e[1] !== m.id) continue;
if (DAMAGE.has(e[3])) dmg += e[4] || 0;
else if (e[3] === "heal" && e[6] !== "potion") heal += e[4] || 0;
}
const secs = Math.max(1, Math.min(t, f.end) - (m.at + f.rest) / f.pace) * f.pace;
return { m, dmg, heal, dps: dmg / secs };
}).sort((x, y) => y.dmg - x.dmg);
const top = Math.max(...rows.map((r) => r.dps)) || 1;
return rows.map(({ m, dmg, heal, dps }) => `<li class="${m.id === S.uid ? "mine" : ""}"
title="${esc(`${fmt(dmg)} damage${heal ? ` · ${fmt(heal)} healing` : ""}`)}"><span class="dps-fill" style="width:${dps / top * 100}%"></span>
<b>${esc(playerName(m.id))}</b><span>${dps < 10 ? dps.toFixed(1) : fmt(Math.round(dps))} <span class="muted">· ${fmt(dmg)}</span></span></li>`).join("");
}
function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
const t = now - a.start, rest = f.rest / f.pace;
const over = t >= f.end;
const resting = t < rest;
const { st, shown } = fightState(f, t);
const before = fightState(f, t - LAG).st; // the HP a moment ago, trailing the bar
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
const me = st[mine.id];
// Resting: everyone's HP climbs back to full at their own pace, then waits for the slowest.
if (resting) for (const m of f.members) st[m.id].hp = m.start_hp + (m.hp_in - m.start_hp) * Math.min(1, t * f.pace / (m.rest || f.rest));
const enemies = f.pack.map((_, i) => st[`e${i}`]);
const left = enemies.reduce((sum, e) => sum + e.hp, 0), total = enemies.reduce((sum, e) => sum + e.top, 0);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${(resting ? 1 : left / total) * 100}%`;
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(me.hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, me.hp / me.top * 100)}%`;
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", resting);
const pull = panel.querySelector("[data-pull-btn]");
if (pull) pull.hidden = !resting;
const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e");
const live = (id) => id && st[id] && !st[id].out && !st[id].hidden;
const tapped = aim.fight === a.start ? aim : {};
const aimed = live(tapped.foe) ? tapped.foe : live(pick?.[2]) ? pick[2] : live(me.aim) ? me.aim : null;
const healing = live(tapped.ally) ? tapped.ally : null;
for (const el of panel.querySelectorAll("[data-u]")) {
const u = st[el.dataset.u];
if (!u) continue;
el.classList.toggle("aimed", (el.dataset.u === aimed || el.dataset.u === healing) && !over);
const gone = !!u.out || over;
el.classList.toggle("out", !!u.out);
el.hidden = !!u.hidden;
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
const was = before[el.dataset.u];
el.querySelector("[data-u-lag]").style.width = `${Math.max(0, Math.max(u.hp, resting ? 0 : was?.hp ?? u.hp) / u.top * 100)}%`;
const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
const healed = hit && (hit.kind === "heal" || hit.kind === "mend");
num.textContent = hit ? `${healed ? "+" : "−"}${hit.n}` : "";
num.className = `hitnum ${healed ? "good" : "bad"} ${hit?.kind === "crit" ? "crit" : ""}`;
num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : "";
const statusEl = el.querySelector("[data-u-status]");
if (statusEl.dataset.key !== status) {
statusEl.dataset.key = status;
statusEl.innerHTML = status ? C.abilities[status].icon : "";
}
el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`;
const cast = !gone && u.cast && t < u.cast.until ? u.cast : null;
// A cast someone stopped stays up a moment, struck through.
const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
const table = el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities;
const spec = cast ? table[cast.label] : stopped ? table[stopped.label] : null;
const castEl = el.querySelector("[data-u-cast]");
const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.label}:${stopped.at}` : "";
if (castEl.dataset.key !== key) {
castEl.dataset.key = key;
castEl.classList.toggle("stopped", !!stopped);
// A cast no interrupt can stop gets a padlock; stuns still stop it on all but bosses.
const foe = f.pack[+el.dataset.u.slice(1)];
const locked = cast && spec.interrupt === false;
const boss = foe && (foe.boss || foe.archetype === "boss" || foe.archetype === "dragon");
castEl.classList.toggle("locked", !!locked);
castEl.title = locked ? `Can't be interrupted${boss ? "" : " · stuns stop it"}` : "";
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}${locked ? ` ${glyph("padlock", "currentColor")}` : ""}</span><span class="bar"><span></span></span>`
: stopped ? `<span>✋ <s>${spec.icon} ${esc(spec.name)}</s></span><span class="small">interrupted</span>` : "";
}
if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
}
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
for (const el of panel.querySelectorAll("[data-ab]")) {
const id = el.dataset.ab;
if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; }
const ab = C.abilities[id], used = me.cds?.[id];
const cd = (ab.cd || 0) * (mine.mods?.[id]?.cd ?? 1) * (me.manualAt?.[id] ? 1 : C.auto_cd) / f.pace;
const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t);
el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`;
el.classList.toggle("fired", used != null && t - used < 0.6);
}
const log = panel.querySelector('[data-f="log"]');
if (log) {
const key = `${a.start}:${shown}:${over}:${resting ? Math.ceil(rest - t) : ""}`;
if (log.dataset.key !== key) {
log.dataset.key = key;
const eaten = Object.entries(mine.eaten || {});
const lines = f.events.slice(Math.max(0, shown - 6), shown + 1).reverse().map((e) => fightLine(e, f)).filter(Boolean);
if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>Victory!</b> Next fight coming…</li>`
: `<li class="bad"><b>${f.result === "fled" ? "It dragged on: you fall back." : "Beaten."}</b></li>`);
log.innerHTML = resting ? `<li class="muted">Resting · ${Math.ceil(rest - t)}s</li>${eaten.length
? `<li class="muted">${HEART} ${eaten.map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}</li>` : ""}`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
}
}
// Refreshed on every swing and once a second, since DPS falls between hits.
const meter = panel.querySelector('[data-f="dps"]');
if (meter) {
const stale = resting && lastMeter?.start !== a.start ? lastMeter : null;
const key = resting ? `rest:${stale?.start}` : `${a.start}:${shown}:${Math.floor(Math.min(t, f.end))}`;
if (meter.dataset.key !== key) {
meter.dataset.key = key;
if (!resting) lastMeter = { start: a.start, rows: dpsMeter(f, t) };
const rows = resting ? stale?.rows : lastMeter.rows;
meter.classList.toggle("stale", resting);
meter.innerHTML = rows ? `<div class="dps-head small muted"><span>${resting ? "Last fight" : "Damage"}</span><span title="Damage per second · total">DPS · total</span></div>
<ul>${rows}</ul>` : "";
}
}
}
// A piece of jewellery's bonus, e.g. "+8% accuracy".
function jewelText(it) {
const st = C.jewel_stats[it.jewel];
return `${st.sign || "+"}${it[it.jewel]}${st.unit} ${st.name.toLowerCase()}`;
}
// Power, armour and ward, and the armour's weight.
function gearStats(it) {
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`, it.ward && `+${it.ward} ward`,
it.jewel && !["power", "ward"].includes(it.jewel) && jewelText(it)];
return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : "");
}
const GEAR_SLOTS = ["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket"];
// A weapon's or armour piece's stats in a small grid. Weapons: damage type and power | 1H or 2H | swing
// time; armour: armour | weight | ward. Below: what it needs to wear, and any bonus for you.
function gearChips(it, who = S.me) {
const cell = (icon, text, tip, cls = "") => `<span class="gstat ${cls}" title="${esc(tip)}">${icon}${text === "" ? "" : `<span>${text}</span>`}</span>`;
const hands = (two) => `<span class="hands">${two ? "2H" : "1H"}</span>`;
let row = ["", "", ""];
if (it.style) {
const st = C.styles[it.style], dt = C.damage_types[st.damage];
row = [cell(glyph(dt.glyph, dt.tint), it.power, `${dt.name} damage, +${it.power} power`),
cell(hands(it.two_handed), "", it.two_handed ? "Two-handed: no shield" : "One-handed: room for a shield"),
cell(glyph("stopwatch", "#9fb3c8"), `${st.interval}s`, `A swing every ${st.interval}s`)];
} else if (it.slot === "shield") {
row = [cell(glyph("checked-shield", "#d4dae2"), it.armour, `+${it.armour} armour, +${it.power} power`),
cell(hands(false), "", "Held in one hand"),
cell(glyph("checked-shield", "#e0b04a"), `${Math.round(C.block_chance * 100)}%`, "Blocks this share of hits")];
} else if (it.weight) {
row = [cell(glyph("checked-shield", "#d4dae2"), it.armour, `+${it.armour} armour: less damage from blows`),
cell("", C.weights[it.weight].name, `${C.weights[it.weight].name} armour`, "muted"),
cell(glyph("magic-shield", "#a77be8"), it.ward, `+${it.ward} ward: less damage from magic`)];
} else if (it.jewel) {
const st = C.jewel_stats[it.jewel];
row = [cell(glyph(st.glyph, st.tint), `${st.sign || "+"}${it[it.jewel]}${st.unit}`, jewelText(it)), "<span></span>", "<span></span>"];
}
let req = "", bonus = "";
if (it.req) {
const [skill, level] = it.req;
req = cell(skillOf(skill).icon, level, `Needs ${skillOf(skill).name.toLowerCase()} ${level}`, who.levels[skill] < level ? "short" : "");
}
if (it.style && C.class_style[who.cls] === it.style) {
bonus = cell(C.classes[who.cls].icon, "+15%", "The trade's own weapon: +15% damage", "trade");
}
return `<div class="stat-grid">${row.join("")}${req}<span></span>${bonus || "<span></span>"}</div>
${it.unique ? `<div class="small unique-text">✦ ${esc(it.unique)}</div>` : ""}`;
}
// A pattern: what it teaches, and the button to learn it (used up).
function patternRow(id, it) {
const made = item(it.pattern), skill = skillOf(it.skill), known = (S.me.patterns || []).includes(id);
const short = S.me.levels[it.skill] < it.level;
return `<div class="row"><div class="small">Teaches ${made.icon} <b>${esc(made.name)}</b> · ${skill.icon} ${skill.name} ${it.level}</div>
${known ? `<span class="tag good">Known</span>` : `<button class="btn sm gold" data-learn="${id}" ${short ? `disabled title="Needs ${esc(skill.name.toLowerCase())} ${it.level}"` : ""}>Learn</button>`}</div>`;
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.drink) parts.push(`❤️ +${it.drink} in a fight`);
if (it.food) parts.push(`feeds ${it.food} for 2 days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.style) {
const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0];
parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`);
}
if (it.kit) parts.push(`+${Math.round(it.armour * C.jerkin_defence * 10) / 10} city defence per paid guard`);
else if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it));
if (it.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).name.toLowerCase()}`);
if (it.slots) parts.push(storageText(it));
if (it.stack) parts.push(storageText(it));
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
if (it.trophy) parts.push(`Trophy for stronger weapons`);
if (it.fitting) {
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
parts.push(`${C.buildings[f.building].icon} ${C.buildings[f.building].name} Lv ${f.level}: ${f.effect}${done ? " · fitted" : ""}`);
}
return parts.join(", ");
}
function actionCard(a) {
const me = S.me;
const active = me.action?.id === a.id;
const second = me.action?.plot2 === a.id; // a homesteader's second plot
const growingNow = me.action?.growing === a.id;
const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second
&& me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left;
const why = me.blocked[a.id];
const locked = isLocked(a);
// Queue it behind the make that's running: materials only have to be there when its turn comes.
const canQueue = me.action?.left && !active && me.queue_why[a.id] === null;
const queued = me.queue.map((q, i) => q.id === a.id ? i + 1 : 0).filter(Boolean);
const out = Object.keys(a.outputs)[0];
const icon = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
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>`;
if (a.finds) io += a.finds.map(chance).join("");
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy;
const own = (a.dungeon ? `<span class="tag warn" title="${esc(`${a.pulls.length} pulls: ${a.pulls.join(" · ")}. Trash falls on auto; the bosses take your own hands. ${C.dungeon_wipes} wipes on a pull and the run's over.`)}">🏰 Dungeon · ${C.group_size}–${C.dungeon_size}</span> `
: a.together ? `<span class="tag warn" title="Fought by ${C.group_size} or more: alone, you wait for someone to join">👥 Group</span> ` : "")
+ (a.personal_loot ? `<span class="tag good" title="Everyone in the fight gets their own loot">🎁 Own loot</span>` : "");
extra = `${e.boss || own ? `<div>${e.boss ? `<span class="tag warn">Boss</span> ` : ""}${own}</div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
if (a.drops) io += a.drops.map(chance).join("");
if (a.loot) {
const l = a.loot, pct = (x) => `${Math.round(x * 100)}%`;
io += `<span class="item-chip" title="${esc(`${item(l.relic).name}: from every boss, to reforge masterworks`)}">${item(l.relic).icon}</span>`
+ `<span class="item-chip" title="${esc(`Patterns (${l.patterns.length}): ${l.patterns.map((p) => item(item(p).pattern).name).join(", ")}. ${pct(l.odds.pattern[0])} from a mini-boss, ${pct(l.odds.pattern[1])} from the last boss`)}">${item(l.patterns[0]).icon} ${l.patterns.length}</span>`
+ `<span class="item-chip" title="${esc(`${item(l.unique).name}: ${item(l.unique).unique}`)}">${item(l.unique).icon} ${pct(l.odds.unique)}</span>`;
}
if (a.special === "sabotage") extra += `<div class="small muted">-1% raid strength per win (max 30%)</div>`;
}
if (a.special === "drill") {
const s = S.favours.sergeant, boost = s.boost;
extra = !s.needed ? `<div class="small muted">Only while a raid is coming.</div>`
: `<div class="small makes">${skillOf("combat").icon} +${+me.drill_per_action.toFixed(2)} drill · ${fmt(s.points)} / ${fmt(s.needed)} points, worth ${fmt(s.defence)} of ${fmt(s.cap)} defence</div>
${boostBar(s.points, s.needed, boost, "Drill sergeant: this stretch drills twice as fast")}`;
}
if (a.special === "recon") {
const boost = S.favours.informant.boost;
const ev = S.upcoming, full = ev && ev.recon >= ev.recon_full;
extra = full ? `<div class="small muted">Fully scouted: XP only.</div>`
: `<div class="small makes">${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon</div>
${ev?.recon_full ? boostBar(ev.recon, ev.recon_full, boost, "Informant: this stretch scouts twice as fast") : ""}`;
}
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") {
extra = `<div class="small makes">${skillOf("construction").icon} +${me.labour_per_action} labour per action</div>`;
}
if (a.special === "bury") extra = `<div class="small makes">${skillOf("prayer").icon} +${+(a.pp * (1 + bonePercent() / 100)).toFixed(2)} prayer points</div>`;
if (a.special === "offer") extra = `<div class="small makes">${C.buildings.temple.icon} +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion</div>`;
const gear = Object.keys(a.outputs).filter((id) => GEAR_SLOTS.includes(item(id).slot));
const made = Object.keys(a.outputs).filter((id) => !gear.includes(id)).map(itemStats).filter(Boolean);
extra += gear.map((id) => gearChips(item(id))).join("");
if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`;
return `
<div class="action ${active || second ? "active" : ""} ${locked ? "locked" : ""}">
<div class="top"><div class="ico">${icon}</div><div>
<div class="name">${esc(a.name)} ${Object.keys(a.outputs).map((id) => needTag(id) + fittedTag(id)).join("")}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
</div>${Object.keys(a.inputs).length ? `<button class="pin ${me.tracked === a.id ? "on" : ""}" data-track="${a.id}" title="${me.tracked === a.id ? "Untrack" : "Track"}">📌</button>` : ""}</div>
${io ? `<div class="io">${io}</div>` : ""}
${extra}
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""}
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}</span></div>` : ""}
${active && me.action.left ? `<div class="small muted">${me.action.left === 1 ? "Just one" : `${fmt(me.action.left)} more`}${me.queue.length ? `, then ${me.queue.length} queued` : ""}${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>${queueRow()}` : ""}
${queued.length ? `<div><span class="tag" title="${esc(queueTip())}">⏳ Queued #${queued.join(", #")}</span></div>` : ""}
<div class="foot">
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
: active ? `<button class="btn danger" data-stop>Stop</button>`
: `<span class="btns">${!a.enemy && Object.keys(a.outputs).length
? `<form data-form="make" data-id="${a.id}" class="make" title="Make this many, then resume"><button class="btn" ${why ? "disabled" : ""}>Make</button><input type="number" min="1" max="${C.make_max}" value="1" data-keep="make-${a.id}" ${why && !canQueue ? "disabled" : ""}>${canQueue
? `<button class="btn q" data-q ${me.queue.length >= C.queue_max ? `disabled title="Queue full (${C.queue_max})"` : `title="Queue: make this many after the current make"`}>+Q</button>` : ""}</form>`
: `<button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Once, then resume">${a.enemy ? "Fight once" : "Once"}</button>`}
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow in plot 2">Plot 2</button>` : ""}
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
</div>
</div>`;
}
// Who else is fighting this encounter, with a button to tag in: one per group fighting together.
function fightingHere(a, why) {
const mine = S.me.action?.party;
const here = S.players.filter((p) => p.id !== S.uid && p.fight === a.id && !(mine && p.party === mine));
const groups = [...new Map(here.map((p) => [p.party || p.id, here.filter((q) => (q.party || q.id) === (p.party || p.id))])).values()];
return groups.map((group) => `<div class="fighting-here"><span class="small">⚔️ ${group.map((p) => esc(p.name)).join(", ")}</span>
<button class="btn sm" data-join="${esc(group[0].id)}" ${why ? `disabled title="${esc(why)}"` : ""}>Join</button></div>`).join("");
}
// --- bank -----------------------------------------------------------------
// A strongbox's extra slots or a bag's bigger stack.
function storageText(it) {
return it.slots ? `+${it.slots} bank slots` : `+${fmt(it.stack)} per stack`;
}
// The strongbox and bag slots, each with what else you could swap in.
function storageGear() {
const me = S.me;
const card = (slot, empty, outline) => {
const id = me.equip[slot], it = id && item(id), size = (x) => x.slots || x.stack;
const others = Object.keys(me.bank).filter((x) => item(x).slot === slot).sort((a, b) => size(item(b)) - size(item(a)))
.map((x) => `<button class="doll-slot swap" data-equip="${x}" title="${esc(`${item(x).name}: ${storageText(item(x))}`)}">${item(x).icon}</button>`).join("");
return `<div class="panel gear-card">
<span class="doll-slot ${it ? "" : "empty"}" title="${esc(it ? it.name : empty)}">${it ? it.icon : glyph(outline, "#98a1b4")}</span>
<div class="gear-text">${it ? `<b>${esc(it.name)}</b><span class="muted small">${storageText(it)}</span>` : `<span class="muted">${empty}</span>`}</div>
${others || it ? `<div class="swap-grid">${others}${it ? `<button class="doll-slot swap off" data-unequip="${slot}" title="Take it off">${glyph("cancel", "#98a1b4")}</button>` : ""}</div>` : ""}</div>`;
};
return `<div class="gear-row">${card("storage", "No strongbox", "locked-chest")}${card("bag", "No bag", "shoulder-bag")}</div>`;
}
function bank() {
const me = S.me;
const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0]));
if (selected && !me.bank[selected]) selected = null;
// An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
const stacks = entries.flatMap(([id, q]) => {
const parts = [];
for (let left = q; left > 0; left -= me.stack) parts.push([id, Math.min(left, me.stack)]);
return parts;
});
let slots = stacks.map(([id, q]) => `
<button class="slot ${selected === id ? "on" : ""}" data-select="${id}" title="${esc(item(id).name)}: ${fmt(me.bank[id])}${me.bank[id] > me.stack ? ` across ${Math.ceil(me.bank[id] / me.stack)} slots` : ""}">
<span class="qty ${q >= me.stack ? "full" : ""}">${fmt(q)}</span>${item(id).icon}<span class="nm">${esc(item(id).name)}</span>
</button>`).join("");
for (let i = stacks.length; i < me.slots; i++) slots += `<div class="slot empty"></div>`;
return `
${storageGear()}
<div class="bank-wrap">
<div>
<div class="panel">
<h2>Bank · ${me.slots_used} / ${me.slots} slots · ${fmt(me.stack)} per stack</h2>
<div class="slots">${slots}</div>
<p class="small muted">Bank full:
<button class="btn sm ${me.overflow === "city" ? "gold" : ""}" data-overflow="city">Send it to the city</button>
<button class="btn sm ${me.overflow === "stop" ? "gold" : ""}" data-overflow="stop">Stop working</button>
<br>Overflow earns half favour; what the city can't use is sold.</p>
</div>
${favourPanel()}
${strongboxPanel()}
</div>
<div>${selected ? itemPanel(selected) : goldPanel()}</div>
</div>`;
}
function itemPanel(id) {
const me = S.me, it = item(id), have = me.bank[id];
const merchant = S.city.merchant_until > serverNow();
const price = it.value * (merchant ? 1.5 : 1);
const others = S.players.filter((p) => p.id !== S.uid);
const qtyBox = (kind, label) => `<form data-form="qty" data-kind="${kind}" data-item="${id}" class="make sm"><button class="btn sm">${label}</button><input type="number" min="1" max="${have}" value="1" data-keep="${kind}-${id}"></form>`
+ `<button class="btn sm" data-${kind}="${id}" data-qty="all">All (${fmt(have)})</button>`;
return `<div class="panel item-panel">
<div class="ico">${it.icon}</div>
<h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${GEAR_SLOTS.includes(it.slot) ? gearChips(it) : ""}
${it.slot ? equipRow(id, it) : ""}
${it.potion ? `<div class="row"><div class="small">${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.</div>
<button class="btn gold" data-potion="${id}">Drink</button>
${S.me.potions[it.potion.skill] && S.me.potions[it.potion.skill].item !== id ? `<div class="small why">Replaces your ${esc(item(S.me.potions[it.potion.skill].item).name.toLowerCase())} (${S.me.potions[it.potion.skill].charges} uses left)</div>` : ""}</div>` : ""}
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} resting between fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first</button>`}</div>` : ""}
${it.drink ? `<div class="row small muted">+${it.drink} HP in a fight, drunk once a fight under ${Math.round(C.potion_below * 100)}% HP.</div>` : ""}
${it.pattern ? patternRow(id, it) : ""}
${it.relic ? `<div class="row small muted">Reforges masterworks with a pattern from ${esc(C.actions.find((a) => a.id === it.relic).name)}.</div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for 2 days.</div>` : ""}
${C.city_uses[id].length
? `<div class="row"><div class="k">Donate to the city · +${it.value} favour each</div><div class="btns">${qtyBox("donate", "Donate")}</div>
<div class="small muted">Used for ${esc(listing(C.city_uses[id]))}.</div></div>`
: `<div class="row small muted">The city has no use for this.</div>`}
${others.length ? `<div class="row"><div class="k">Give to <select data-keep="give-to">${others.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select></div><div class="btns">${qtyBox("give", "Give")}</div></div>` : ""}
<div class="row"><div class="k">Sell</div><div class="btns">${qtyBox("sell", "Sell")}</div></div>
<div class="row"><button class="btn sm" data-select="">Close</button></div>
</div>${goldPanel()}`;
}
function listing(words) {
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
}
function equipRow(id, it) {
const [skill, level] = it.req || [];
const short = skill && S.me.levels[skill] < level;
return `<div class="row"><button class="btn gold" data-equip="${id}" ${short ? "disabled" : ""}>${equipLabel(it)}</button>
${skill ? `<div class="small ${short ? "why" : "muted"}">Needs ${esc(skillOf(skill).name.toLowerCase())} level ${level}</div>` : ""}</div>`;
}
function equipLabel(it) {
if (it.tool || it.station) return `Use for ${skillOf(it.tool || it.station).name.toLowerCase()} (${Math.round(it.speed * 100)}% faster)`;
return "Equip";
}
function goldPanel() {
return `<div class="panel"><h2>Gold</h2>
<p class="gold">${COIN} ${fmt(S.me.gold)}</p>
<form data-form="gold" class="btns"><input type="number" min="1" data-keep="gold" placeholder="Amount" style="width:120px"><button class="btn gold">Donate to treasury</button></form>
<p class="small muted">1 favour per gold.</p></div>`;
}
function strongboxPanel() {
const me = S.me;
return `<div class="panel"><h2>Strongbox${me.rented ? ` · ${me.rented} rented` : ""}</h2>
<div class="btns"><button class="btn gold" data-rent ${me.gold < me.rent_cost ? "disabled" : ""}>+1 bank slot (${COIN} ${fmt(me.rent_cost)})</button></div>
<p class="small muted">Yours for good. Each costs 35% more.</p></div>`;
}
function favourPanel() {
const me = S.me;
return `<div class="panel"><h2>Favour with the council</h2>
<p class="favour">${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
<p class="small muted">More to spend on in <button class="linkish" data-nav="council">Council &amp; threats</button>.</p></div>`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
const growth = city.starving ? `<span class="bad">Hungry: ${city.growth} a day</span>`
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 ? `<span class="bad">Overcrowded: ${city.growth} a day</span>`
: `<span class="warn">Full: build houses</span>`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier";
let html = `<div class="stats">
<div class="stat"><div class="k">Settlement</div><div class="v">${city.tier_name}</div><div class="note">${next}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div><div class="note">${growth}</div></div>
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
${weatherCard()}
</div>`;
html += happeningsPanel();
html += caravanPanel();
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>Granary empty.</b> People are leaving, defence is down. Donate food.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>Merchant in town.</b> Sell prices +50% for ${countdown(city.merchant_until)}.</div></div>`;
if (city.construction) html += siteBanner();
html += `<div class="section-title">Buildings</div>
<p class="small muted">Funds are safe from raiders. Building starts once a fund is full.</p>
<div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
html += monumentsPanel();
html += `<div class="section-title">City stockpile</div><div class="panel">`;
const stock = Object.entries(city.stockpile);
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty.</p>`;
html += `<p class="small muted">Feeds the granary and armoury. Raiders can loot it.</p></div>`;
return html;
}
function lookChip(id) {
const c = C.cosmetics[id];
if (c.kind === "colour") return `<span class="swatch" style="background:${c.value}"></span>`;
if (c.kind === "emblem") return glyph(emblemGlyph(id), "#e7e9f0");
return `<span class="ico">📜</span>`;
}
const emblemGlyph = (id) => C.cosmetics[id].value || C.actions.find((a) => a.id === C.cosmetics[id].boss).glyph;
function caravanPanel() {
const c = S.me.caravan;
if (!c) return "";
const rows = c.wares.map((w) => {
const price = w.skill ? ` · ${COIN} ${fmt(w.prices[w.skill])}` : "";
const picker = w.skill ? "" : `<select data-keep="tutor-skill">${Object.keys(w.prices).map((s) =>
`<option value="${s}">${skillOf(s).name} · ${fmt(w.prices[s])}</option>`).join("")}</select>`;
const per = w.charges ? `next ${fmt(w.charges)} ${w.per}` : "against the coming threat";
return `<div class="ware">${glyph(...w.glyph)}<span class="grow"><b>${esc(w.name)}</b>
<span class="small">${w.skill ? `${skillOf(w.skill).icon} ` : ""}${esc(w.text)} · ${per}</span>${picker}</span>
<button class="btn sm gold" data-buy="${w.id}" ${w.bought ? "disabled" : ""}>${w.bought ? "Bought" : `Buy${price}`}</button></div>`;
});
const look = c.look, have = look.owned || look.bought;
rows.push(`<div class="ware">${lookChip(look.id)}<span class="grow"><b>${esc(look.name)}</b><span class="small muted">For your banner (character sheet)</span></span>
<button class="btn sm gold" data-buy="${look.id}" ${have ? "disabled" : ""}>${have ? "Owned" : `Buy · ${COIN} ${fmt(look.price)}`}</button></div>`);
return `<div class="panel"><h2>🐫 Caravan · leaves in ${countdown(c.until)}</h2><div class="wares">${rows.join("")}</div>
<p class="small muted">One of each per visit. Wares boost your own work. Pricier the higher your level.</p></div>`;
}
function monumentsPanel() {
const m = S.city.monuments, me = S.me;
const rows = Object.entries(C.monuments).map(([key, spec]) => {
if (spec.once && m.built.some((b) => b.kind === key)) return "";
const head = `<span class="ico">${glyph(spec.glyph, spec.tint)}</span>`;
if (key === "statue" && m.carving) {
const pick = m.carving.by === S.uid
? `<span class="btns"><select data-keep="statue-of">${S.players.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select>
<button class="btn sm gold" data-statue>Carve</button></span>`
: `<span class="small muted">${esc(playerName(m.carving.by))} picks who it honours.</span>`;
return `<div class="favour-option">${head}<span class="grow"><b>${spec.name}</b><span class="small muted">Funded. Who should it honour?</span></span>${pick}</div>`;
}
const fund = m.funds[key] || { gold: 0 };
return `<div class="favour-option">${head}<span class="grow"><b>${spec.name}</b><span class="small muted">${esc(spec.text)}</span>
<span class="small">${COIN} ${fmt(fund.gold)} / ${fmt(spec.cost)}</span><span class="bar"><span style="width:${fund.gold / spec.cost * 100}%"></span></span></span>
<form data-form="monument" data-id="${key}" class="btns"><input type="number" min="1" data-keep="mon-${key}" placeholder="Gold" style="width:90px"><button class="btn sm gold" ${me.gold ? "" : "disabled"}>Give</button></form></div>`;
}).join("");
const built = m.built.map((b) => {
const spec = C.monuments[b.kind];
const donors = Object.entries(b.donors).sort((x, y) => y[1] - x[1]).map(([uid]) => esc(playerName(uid))).join(", ");
return `<div class="report win"><b>${glyph(spec.glyph, spec.tint)} ${b.of ? `Statue of ${esc(playerName(b.of))}` : spec.name}</b> <span class="muted small">${ago(b.at)}</span><p class="small">Carved in: ${donors}</p></div>`;
}).join("");
return `<div class="section-title">Monuments</div><div class="panel">${rows}${built}
<p class="small muted">Looks only. Gold here earns no favour: your name goes in the stone.</p></div>`;
}
// Progress towards a goal favour can boost. Favour doubles the next `boost` points players put
// in, so the striped stretch behind the bar (twice the boost) is where work counts double.
function boostBar(done, total, boost, tip) {
const pct = (x) => Math.min(100, x / total * 100);
return `<div class="bar gold thick boost-bar"${boost ? ` title="${esc(tip)}"` : ""}>${boost
? `<span class="boost" style="width:${pct(done + 2 * boost)}%"></span>` : ""}<span style="width:${pct(done)}%"></span></div>`;
}
// The building site: labour so far, who is helping, and ways to speed it up.
function siteBanner() {
const city = S.city, work = city.construction, me = S.me;
const spec = C.buildings[work.building];
const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", ");
const hire = S.favours.builders;
const working = me.action?.id === "labour";
const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name));
return `<div class="banner"><span class="banner-ico">${skillOf("construction").icon}</span><div class="grow">
<div class="site-head"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b>
<span class="muted small">${fmt(work.labour)} / ${fmt(work.needed)} labour</span>
<span class="btns">
${working ? `<span class="btn sm working">Working</span>` : `<button class="btn sm primary" data-start="labour">Work on it</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="×2 labour and XP for the next ${hire.labour}">Master builder ${FAVOUR} ${fmt(hire.cost)}</button>`}
</span></div>
${boostBar(work.labour, work.needed, hire.boost, "Master builder: this stretch builds twice as fast")}
<div class="small muted" style="margin-top:6px">${builders.length ? `On site: ${builders.join(", ")}. ` : ""}Townsfolk +${fmt(city.townsfolk_labour)}/h.${queued ? ` Next: ${queued}.` : ""}</div>
</div></div>`;
}
// "Rain: farming 10% faster. Clear skies in 3h 20m."
function weatherTip(w) {
return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${w.next_name} in ${dur(w.until - serverNow())}.`;
}
function weatherCard() {
const h = S.happenings, w = h.weather;
return `<div class="stat" title="${esc(weatherTip(w))}"><div class="k">${h.season.icon} ${h.season.name}, day ${h.season.day}</div>
<div class="v">${w.icon} <small>${esc(w.name)}</small></div>
<div class="note" title="${esc(forecastTip(w))}">${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}</div></div>`;
}
// "Fog in 3h 20m: scouting 30% and hunting 10% slower."
function forecastTip(w) {
return `${w.next_name} in ${dur(w.until - serverNow())}: ${w.next_text.charAt(0).toLowerCase()}${w.next_text.slice(1)}.`;
}
// Everything going on in the city right now, and what anyone can do about it.
function happeningsPanel() {
const h = S.happenings, me = S.me, city = S.city;
const rows = [];
if (h.weather.kind !== "clear") rows.push(`<div class="happening"><span class="ico">${h.weather.icon}</span><span class="grow"><b>${esc(h.weather.name)}</b><span class="small muted">${esc(h.weather.text)}</span></span></div>`);
if (h.cold) rows.push(`<div class="happening bad"><span class="ico">🥶</span><span class="grow"><b>Out of firewood</b><span class="small muted">City eats 50% more. Donate logs.</span></span></div>`);
for (const e of h.effects) {
const hire = e.offer ? `<button class="btn sm gold" data-hire ${city.treasury < e.cost ? "disabled" : ""}>Hire for ${fmt(e.cost)} gold</button>` : "";
rows.push(`<div class="happening"><span class="ico">${e.icon}</span><span class="grow"><b>${esc(e.name)}</b>
<span class="small muted">${esc(e.text)} · ${countdown(e.until)} left</span></span>${hire}</div>`);
}
for (const p of h.problems) {
let help = "";
if (p.action) {
const a = C.actions.find((x) => x.id === p.action);
help = `<span class="small muted">${fmt(p.work)} / ${fmt(p.needed)} done</span>
<button class="btn sm primary" data-nav="skill:${a.skill}">${esc(a.name)}</button>`;
}
const gifts = Object.entries(p.need || {}).map(([id, need]) => {
const missing = p.missing[id] || 0;
const mine = Math.min(missing, me.bank[id] || 0), stores = Math.min(missing, city.stockpile[id] || 0);
return `<div class="fund-row">${costChip(item(id).icon, item(id).name, need, need - missing)}<span class="btns">
${mine ? `<button class="btn sm gold" data-aid="${p.kind}" data-item="${id}" data-source="bank">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-aid="${p.kind}" data-item="${id}" data-source="city">Stockpile ${fmt(stores)}</button>` : ""}
</span></div>`;
}).join("");
rows.push(`<div class="happening bad"><span class="ico">${p.icon}</span><span class="grow"><b>${esc(p.name)}</b>
<span class="small muted">${esc(p.text)}${p.until ? ` · ${countdown(p.until)} left` : ""}</span>${gifts}</span>${help}</div>`);
}
if (!rows.length) return "";
return `<div class="panel"><h2>Happenings</h2><div class="happenings">${rows.join("")}</div></div>`;
}
// What's speeding up, slowing down or blocking this skill right now.
function skillHappenings(skill) {
const h = S.happenings, banners = [];
const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block);
const good = (src) => (src.speed?.[skill] ?? 1) < 1 || (src.yield?.[skill] ?? 1) > 1 || (src.xp?.[skill] ?? 1) > 1;
const banner = (tone, icon, name, text, button = "") => banners.push(`<div class="banner ${tone} happening-banner">
<span class="banner-ico">${icon}</span><div class="grow"><b>${esc(name)}</b><div class="small">${esc(text)}</div></div>${button}</div>`);
if (touches(h.weather)) banner(good(h.weather) ? "good" : "bad", h.weather.icon, h.weather.name, h.weather.text);
for (const e of h.effects) if (touches(e)) banner(good(e) ? "good" : "bad", e.icon, e.name, e.text);
for (const p of h.problems) {
if (!C.problems[p.kind] || !touches(C.problems[p.kind])) continue;
const fix = p.action && C.actions.find((x) => x.id === p.action);
const button = fix ? (fix.skill === skill ? `<button class="btn primary" data-start="${fix.id}">${esc(fix.name)}</button>`
: `<button class="btn" data-nav="skill:${fix.skill}">${skillOf(fix.skill).icon} ${esc(fix.name)}</button>`)
: `<button class="btn" data-nav="town">Help on the Town square</button>`;
banner("bad", p.icon, p.name, p.text, button);
}
for (const b of S.me.boosts.filter((x) => x.skill === skill)) {
const w = C.wares[b.ware];
banner("good", glyph(...w.glyph), w.name, `${w.text} · ${b.left ? `${fmt(b.left)} ${w.per} left` : "until the threat lands"}`);
}
return banners.join("");
}
function buildingCard(key, b) {
const spec = C.buildings[key], city = S.city, me = S.me;
const tags = {
building: `<span class="tag gold">Under construction</span>`,
queued: `<span class="tag good">Funded · queued</span>`,
max: `<span class="tag good">Fully built</span>`,
capped: `<span class="tag">Needs a bigger settlement</span>`,
};
let body = tags[b.status] ? `<div>${tags[b.status]}</div>` : "";
if (b.status === "funding" || b.status === "queued") {
body += `<div class="fund">${Object.entries(b.target).map(([id, need]) => {
const missing = b.missing[id] || 0;
const gold = id === "gold";
const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0);
const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0);
const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing);
return `<div class="fund-row">${chip}<span class="btns">
${mine ? `<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-fund="${key}" data-item="${id}" data-source="city" title="From the city's ${gold ? "treasury" : "stockpile"}">${gold ? "Treasury" : "Stockpile"} ${fmt(stores)}</button>` : ""}
</span></div>`;
}).join("")}</div>`;
}
body += fittingRows(key, b);
const levelText = b.cap < spec.max ? `Lv ${b.level} / ${b.cap} (${city.tier_name.toLowerCase()} cap)` : `Lv ${b.level} / ${spec.max}`;
return `<div class="action building ${b.damaged ? "damaged" : ""}">
<div class="top"><div class="ico">${spec.icon}</div><div>
<div class="name">${esc(spec.name)} <span class="lv muted">${levelText}</span> ${b.damaged ? `<span class="tag bad">Damaged</span>` : ""}</div>
<div class="sub">${esc(spec.effect)}</div>
</div></div>${body}</div>`;
}
// Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level.
function fittingRows(key, b) {
const rows = C.fittings.filter((f) => f.building === key).map((f) => {
const it = item(f.id), recipe = C.actions.find((a) => a.id === `make_${f.id}`);
let state;
if (b.fittings.includes(f.id)) {
state = b.damaged ? `<span class="tag bad">Paused</span>` : `<span class="tag good">Fitted</span>`;
} else if (b.level < f.level) {
state = `<span class="tag">Needs Lv ${f.level}</span>`;
} else {
const mine = S.me.bank[f.id] || 0, stores = S.city.stockpile[f.id] || 0;
state = mine || stores
? `${mine ? `<button class="btn sm gold" data-install="${f.id}" data-source="bank" title="From your bank, earns favour">Fit</button>` : ""}
${stores ? `<button class="btn sm" data-install="${f.id}" data-source="city" title="From the city's stockpile">Fit from stockpile</button>` : ""}`
: `<span class="muted small">Construction ${recipe.level}</span>`;
}
return `<div class="fitting-row"><span class="fit-ico">${it.icon}</span>
<span class="fitting-text"><b>${esc(f.name)}</b> <span class="muted small">${esc(f.effect)}</span></span>
<span class="btns">${state}</span></div>`;
});
return rows.length ? `<div class="fittings">${rows.join("")}</div>` : "";
}
// --- council --------------------------------------------------------------
function pips(level) {
return `<span class="pips" title="Intel level ${level} of 3">${[1, 2, 3].map((i) => `<i class="${i <= level ? "on" : ""}"></i>`).join("")}</span>`;
}
function reconText(ev) {
const points = `${fmt(ev.recon)} / ${fmt(ev.recon_full)} points`;
if (ev.intel >= 3) return `${points} · complete`;
if (ev.recon_next <= 0) return `${points} · next level opens closer to the threat`;
return `${points} · ${fmt(ev.recon_next)} to the next level`;
}
function council() {
const ev = S.upcoming, city = S.city, me = S.me;
let html = "";
if (!ev) {
html += `<div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads.</p></div>`;
} else {
const threat = ["raid", "dragon"].includes(ev.kind);
html += `<div class="panel ${threat ? "threat-card" : ""}">
<h2>${eventIcon(ev.kind)} ${threat ? "Threat" : "News"} · intel ${pips(ev.intel)}</h2>
<div class="headline">${esc(ev.headline)}</div>
<div class="kv">
<span class="k">Expected</span><span>${eventWhen(ev)}</span>
${ev.range ? `<span class="k">Strength</span><span>${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}</span>` : ""}
${ev.trait ? `<span class="k">Scouts say</span><span>${esc(ev.trait)}</span>` : ""}
${ev.camp ? `<span class="k">Their camp</span><span>Found · sabotage it in Combat${ev.sabotage ? ` (-${ev.sabotage}% so far)` : ""}</span>` : ""}
<span class="k">Reconnaissance</span><span>${reconText(ev)}</span>
</div>
${ev.recon_full && ev.intel < 3 ? boostBar(ev.recon, ev.recon_full, S.favours.informant.boost, "Informant: this stretch scouts twice as fast") : ""}`;
if (threat) {
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
const foe = (ev.group || (ev.kind === "dragon" ? "dragon" : "raiders")).replace(/^\w/, (c) => c.toUpperCase());
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">${esc(foe)}</span><b>${strength}</b></div></div>`;
}
html += `</div>`;
}
html += favourSpends();
if (ev && ["raid", "dragon"].includes(ev.kind)) html += raidPrep(ev);
html += `<div class="section-title">Past events</div>`;
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
for (const h of S.history) {
if (!h.report) continue;
const fight = ["raid", "dragon"].includes(h.kind);
const tone = fight ? (h.report.title.startsWith("Victory") ? "win" : "loss")
: { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || "";
html += `<div class="report ${tone}">
<b>${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
${fight ? `<button class="btn sm" data-replay="${h.id}">▶ Watch</button>` : ""}
${fight ? `<p class="muted">Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}</p>` : ""}
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
</div>`;
}
return html;
}
function favourSpends() {
const f = S.favours, me = S.me;
const option = (kind, icon, title, text) => {
const o = f[kind];
const short = !o.why && me.favour < o.cost;
return `<div class="favour-option">
<span class="ico">${icon}</span>
<span class="grow"><b>${title}</b><span class="small muted">${text}</span></span>
<span class="btns"><button class="btn sm gold" data-favour="${kind}" ${o.why || short ? "disabled" : ""}
title="${esc(o.why || (short ? "Not enough favour" : ""))}">${FAVOUR} ${fmt(o.cost)}</button></span>
</div>`;
};
return `<div class="panel"><h2>Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}</h2>
${option("builders", "🏗️", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `×2 labour and XP for the next ${f.builders.labour}${f.builders.boost ? ` (${fmt(f.builders.boost)} left)` : ""}. Price rises per hire.`)}
${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `×2 recon and XP for the next ${f.informant.recon}${f.informant.boost ? ` (${fmt(f.informant.boost)} left)` : ""}. Price rises per hire.`)}
${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `×2 drill and XP for the next ${f.sergeant.drill}${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} left)` : ""}. Price rises per hire.`)}
</div>`;
}
function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence;
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", jerkins: "Jerkins the guards wear", arrows: "Arrows", garrison: "Garrison" };
const full = d.posts * d.wage, owed = Math.max(0, full - d.wages);
const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0;
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join("");
return `<div class="cols">
<div class="panel"><h2>Council requests</h2>${reqs}
<p class="small muted">Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.</p></div>
<div class="panel"><h2>Guards · ${d.guards} / ${d.posts} paid</h2>
<p class="small">Wage chest ${COIN} ${fmt(d.wages)} / ${fmt(full)} · ${d.wage} gold a guard per threat</p>
<div class="bar"><span style="width:${full ? Math.min(100, d.wages / full * 100) : 0}%"></span></div>
<div class="btns" style="margin-top:8px">
<button class="btn sm gold" data-wages="bank" ${!owed || !me.gold ? "disabled" : ""}>Pay ${COIN} ${fmt(Math.min(owed, me.gold))}</button>
<button class="btn sm" data-wages="city" ${!owed || city.treasury < 1 ? "disabled" : ""}>From the treasury (${fmt(city.treasury)})</button></div>
<p class="small muted">Only paid guards stand, and only they get the armoury's swords, shields and jerkins. Paying earns favour.</p>
<h2 style="margin-top:16px">Defence</h2>
<table class="parts">${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")}
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>
<h2 style="margin-top:16px">Garrison</h2>
<p class="small">${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `<span class="muted">Nobody yet.</span>`}</p>
${me.garrison ? `<button class="btn" data-garrison="0">Leave the garrison</button>`
: `<button class="btn primary" data-garrison="1">Stand guard (+${me.power})</button>`}
<p class="small muted">Keep working while on guard. Victory earns favour and combat XP.</p>
</div></div>`;
}
// --- work orders ------------------------------------------------------------
const ORDER_ICON = () => glyph("scroll-unfurled", "#e3c060");
// An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough.
function canTake(o) {
if (o.status !== "open" || o.by === S.uid) return false;
if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false;
return S.me.levels[o.skill] >= o.level;
}
// Toasts for new orders you can take, and for things delivered to you. Nothing on the first load.
function orderNews(state) {
const orders = state.orders || [], uid = state.uid;
if (seenOrders) {
for (const o of orders) {
if (!seenOrders.has(o.id) && o.by !== uid && state.me && state.me.levels[o.skill] >= o.level && o.status === "open") {
toast(`📜 ${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""} · ${fmt(o.reward)} gold`, "level");
}
const was = deliveredSeen[o.id];
if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good");
const got = (o.earned || {})[uid] || 0, had = earnedSeen[o.id];
if (had !== undefined && got > had) toast(`🤝 ${item(o.item).name} · +${fmt(got - had)} gold`, "good");
}
}
seenOrders = new Set(orders.map((o) => o.id));
deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered]));
earnedSeen = Object.fromEntries(orders.map((o) => [o.id, (o.earned || {})[uid] || 0]));
}
// What one run of a recipe takes right now: a lit fire pays its log (the forge a coal too).
const runInputs = (a) => S.me.fuelled?.[a.id] || a.inputs;
const firePays = (a, id, qty) => `the ${C.fires[a.fire].name.toLowerCase()} pays ${fmt(qty)}`;
// A reagent the fire pays outright: struck through, nothing to put in.
function paidSlot(a, id, qty) {
return `<div class="ro-slot paid" title="${esc(`${item(id).name}: ${firePays(a, id, qty)}`)}"><span class="doll-slot empty-req">${item(id).icon}</span>
<span class="ro-have muted"><s>${fmt(qty)}</s></span><span class="ro-name">${esc(item(id).name)}</span></div>`;
}
// Recipe slots on a crafting order. Anyone can put reagents in the pool; the crafter brings the rest.
function orderSlots(o) {
const a = C.actions.find((x) => x.id === o.action), out = a.outputs[o.item], ins = runInputs(a);
const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out);
const worth = Object.entries(ins).reduce((sum, [id, per]) => sum + item(id).value * per, 0);
return Object.entries(a.inputs).map(([id, per]) => {
const name = item(id).name, pool = o.provided[id] || 0, have = S.me.bank[id] || 0;
if (o.status !== "open") {
return `<div class="ro-slot open"><span class="doll-slot empty-req" title="${esc(name)}">${item(id).icon}</span>
<span class="ro-have muted">×${fmt(per * all)}</span><span class="ro-name">${esc(name)}</span></div>`;
}
const need = (ins[id] || 0) * left;
if (!need) return paidSlot(a, id, per * left);
const who = Object.entries(o.gave || {}).filter(([, items]) => items[id]).map(([uid, items]) => `${playerName(uid)} ${fmt(items[id])}`);
const tip = [`${name}: ${fmt(pool)} / ${fmt(need)}`, ...(need < per * left ? [firePays(a, id, per * left - need)] : []), ...who].join(" · ");
if (pool >= need) {
return `<div class="ro-slot filled" title="${esc(tip)}"><span class="doll-slot">${item(id).icon}
<span class="swap-qty">${fmt(need)}</span><span class="tick">✓</span></span><span class="ro-name">${esc(name)}</span></div>`;
}
const give = Math.min(have, need - pool);
// What putting them in pays you once they're crafted: your value's slice of the reagents' share.
const share = o.by === S.uid ? 0 : Math.round(o.reward / o.qty * out * C.input_share * item(id).value / worth * give);
const fill = give ? `${tip} · +${fmt(give)}${share ? ` → ${fmt(share)} gold` : ""}` : `${tip} · 🎒 0`;
return `<div class="ro-slot open"><button class="doll-slot empty-req" data-order-fill="${o.id}" data-item="${id}" ${give ? "" : "disabled"} title="${esc(fill)}">${item(id).icon}</button>
<span class="ro-have ${pool + have < need ? "bad" : "good"}">${fmt(pool)} / ${fmt(need)}</span><span class="ro-name">${esc(name)}</span></div>`;
}).join("");
}
function orderCard(o) {
const it = item(o.item), mine = o.by === S.uid, skill = skillOf(o.skill);
const short = S.me.levels[o.skill] < o.level, have = S.me.bank[o.item] || 0, left = o.qty - o.delivered;
const progress = o.qty > 1 || o.kind === "gather"
? `<div class="small muted">${fmt(o.delivered)} / ${fmt(o.qty)}${o.kind === "gather" && Object.keys(o.from).length
? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}</div>
<div class="bar"><span style="width:${o.delivered / o.qty * 100}%"></span></div>` : "";
let buttons = "", status = "";
if (o.status !== "open") {
status = `<span class="tag ${o.status === "done" ? "good" : ""}">${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}</span>`;
if (mine && !o.held) buttons = `<button class="btn" data-order-remove="${o.id}">Remove</button>`;
} else if (mine) {
buttons = `<button class="btn danger" data-order-cancel="${o.id}">Cancel</button>`;
if (o.claimed) status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
} else if (o.kind === "gather") {
buttons = `<button class="btn primary" data-order-deliver="${o.id}" ${have ? "" : "disabled"}>Deliver ${fmt(Math.min(have, left))}</button>`;
} else if (o.claimed && o.claimed !== S.uid) {
status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
} else {
const working = S.me.action?.order === o.id;
const why = short ? `Needs ${skill.name.toLowerCase()} ${o.level}` : S.me.blocked[o.action] && !working
&& !S.me.blocked[o.action].startsWith("Out of") ? S.me.blocked[o.action] : "";
buttons = `${o.claimed === S.uid ? `<button class="btn" data-order-drop="${o.id}">Drop</button>` : ""}
${have ? `<button class="btn" data-order-deliver="${o.id}">Deliver ${fmt(Math.min(have, left))}</button>` : ""}
${working ? `<span class="tag gold">Crafting</span>` : `<button class="btn primary" data-order-work="${o.id}" ${why ? `disabled title="${esc(why)}"` : ""}>Craft it</button>`}`;
}
const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward);
return `<div class="action order">
<div class="top"><div class="ico">${it.icon}</div><div>
<div class="name">${o.kind === "gather" ? "Wanted: " : ""}${esc(it.name)}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}</div>
<div class="sub">${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}</div></div>
<span class="ro-req ${short && !mine ? "bad" : ""}" title="${esc(`${skill.name} ${o.level}`)}">${skill.icon} ${o.level}</span></div>
${o.kind === "craft" ? `<div class="ro-slots">${orderSlots(o)}</div>` : ""}
${Object.keys(o.earned || {}).length ? `<div class="small muted">🤝 ${Object.entries(o.earned).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}</div>` : ""}
${progress}
<div class="foot"><span class="item-chip ro-reward">${COIN} ${reward}</span>${status}<span class="btns">${buttons}</span></div>
</div>`;
}
function orderPicker(list) {
const d = draft, actions = Object.entries(list).map(([id, action]) => [id, C.actions.find((x) => x.id === action)]);
const skills = [...new Set(actions.map(([, a]) => a.skill))];
const picked = d.item && actions.find(([id]) => id === d.item)?.[1];
d.skill = skills.includes(d.skill) ? d.skill : picked?.skill || skills[0];
const inSkill = actions.filter(([, a]) => a.skill === d.skill);
const groups = [...new Set(inSkill.map(([, a]) => a.group).filter(Boolean))];
if (!groups.includes(d.group)) d.group = picked?.skill === d.skill && picked.group || groups[0];
const q = (d.search || "").trim().toLowerCase();
const shown = q ? actions.filter(([id]) => item(id).name.toLowerCase().includes(q))
: inSkill.filter(([, a]) => !groups.length || a.group === d.group);
const others = S.players.filter((p) => p.id !== S.uid);
const tiles = shown.sort((x, y) => x[1].level - y[1].level).map(([id, a]) => {
const makers = others.filter((p) => p.levels[a.skill] >= a.level).length;
const tip = `${item(id).name} · ${skillOf(a.skill).name} ${a.level}${d.kind === "craft" ? ` · 👥 ${makers}` : ""}`;
return `<button class="doll-slot swap pick ${d.item === id ? "on" : ""} ${d.kind === "craft" && !makers ? "nobody" : ""}" data-draft-item="${id}" title="${esc(tip)}">
${item(id).icon}<span class="pick-name">${esc(item(id).name)}${d.kind === "craft" ? ` <span class="muted">👥 ${makers}</span>` : ""}</span><span class="pick-lvl">${a.level}</span></button>`;
}).join("");
return `<div class="picker">
<div class="pick-skills">${skills.map((k) => `<button class="pick-skill ${!q && k === d.skill ? "on" : ""}" data-draft-skill="${k}" title="${esc(skillOf(k).name)}">${skillOf(k).icon}</button>`).join("")}
<input type="text" data-keep="order-search" placeholder="🔍" value="${esc(d.search || "")}"></div>
${groups.length && !q ? `<div class="tabs sm">${groups.map((g) => `<button class="tab ${g === d.group ? "on" : ""}" data-draft-group="${esc(g)}">${esc(g)}</button>`).join("")}</div>` : ""}
<div class="swap-grid pick-grid">${tiles || `<span class="muted small">—</span>`}</div></div>`;
}
function orderComposer() {
const d = draft, list = d.kind === "craft" ? C.order_recipes : C.order_raw;
let slots = "", maker = "";
const a = d.item && C.actions.find((x) => x.id === list[d.item]);
if (a) {
const smiths = S.players.filter((p) => p.id !== S.uid && p.levels[a.skill] >= a.level).length;
maker = `<div class="small muted" title="${esc(`${skillOf(a.skill).name} ${a.level} · ${smiths} can make it`)}">${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}</div>`;
if (d.kind === "craft") {
const runs = Math.ceil(d.qty / a.outputs[d.item]), ins = runInputs(a);
slots = Object.entries(a.inputs).map(([id, per]) => {
const need = (ins[id] || 0) * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id);
if (!need) return paidSlot(a, id, per * runs);
return `<div class="ro-slot ${on ? "filled" : "open"}"><button class="doll-slot ${on ? "on" : "empty-req"}" data-provide="${id}"
${!on && have < need ? `disabled title="${esc(`${item(id).name}: ${fmt(have)} / ${fmt(need)}`)}"` : `title="${esc(item(id).name)}"`}>
${item(id).icon}${on ? `<span class="tick">✓</span>` : ""}</button>
<span class="ro-have ${have < need ? "bad" : "good"}">${fmt(have)} / ${fmt(need)}</span><span class="ro-name">${esc(item(id).name)}</span></div>`;
}).join("");
}
}
const gold = d.kind === "craft" ? d.reward : d.reward * d.qty;
const why = !a ? "" : d.qty > C.order_max[d.kind] ? `Max ${fmt(C.order_max[d.kind])}`
: gold > S.me.gold ? `Gold ${fmt(S.me.gold)} / ${fmt(gold)}` : "";
return `<div class="panel compose">
<div class="tabs" style="margin:0 0 14px"><button class="tab ${d.kind === "craft" ? "on" : ""}" data-draft-kind="craft">Craft</button>
<button class="tab ${d.kind === "gather" ? "on" : ""}" data-draft-kind="gather">Materials</button></div>
${orderPicker(list)}
<div class="compose-grid">
<div>${a ? `<div class="ro-pick"><span class="doll-slot on">${item(d.item).icon}</span><div><b>${esc(item(d.item).name)}</b>${maker}</div></div>`
: `<div class="ro-pick"><span class="doll-slot empty-req"></span><span class="muted">—</span></div>`}
<div class="stepper">×<input type="number" min="1" max="${C.order_max[d.kind]}" value="${d.qty}" data-draft="qty">
<span class="muted small">max ${fmt(C.order_max[d.kind])}</span></div></div>
<div>${slots ? `<div class="ro-slots big">${slots}</div>` : ""}</div>
<div><div class="reward-input">${COIN}<input type="number" min="0" value="${d.reward}" data-draft="reward">
${d.kind === "gather" ? `<span class="muted small">each · ${fmt(gold)}</span>` : ""}</div>
<div class="btns"><button class="btn primary" data-order-post ${a && !why ? "" : "disabled"}>Post</button>
${why ? `<span class="why">${esc(why)}</span>` : ""}</div></div>
</div></div>`;
}
function orders() {
const all = S.orders || [], open = all.filter((o) => o.status === "open");
const takeable = open.filter(canTake), yours = all.filter((o) => o.by === S.uid);
const lists = { take: takeable, open, yours };
if (!lists[orderTab]) orderTab = "take";
const shown = lists[orderTab];
const old = orderTab === "yours" && yours.some((o) => o.status !== "open" && !o.held);
const tab =(key, label, n) => `<button class="tab ${orderTab === key ? "on" : ""}" data-otab="${key}">${label}${n ? ` <b class="badge">${n}</b>` : ""}</button>`;
return `<div class="panel"><div class="skill-head"><div class="big">${ORDER_ICON()}</div>
<div class="info"><h2>Work orders</h2></div>
<button class="btn ${draft ? "" : "primary"}" data-order-new>${draft ? "Close" : "New order"}</button></div></div>
${draft ? orderComposer() : ""}
<div class="tabs">${tab("take", "For you", takeable.length)}${tab("open", "All open", 0)}${tab("yours", "Yours", 0)}
${old ? `<button class="btn" data-order-clear>Clear</button>` : ""}</div>
${shown.length ? `<div class="grid wide">${shown.map(orderCard).join("")}</div>` : `<p class="muted">None.</p>`}`;
}
// --- board & roster -------------------------------------------------------
function board() {
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${crest(S.players.find((p) => p.id === m.who)?.look, 18)} ${esc(playerName(m.who))}</span><span class="when">${ago(m.at)}</span>
${m.who === S.uid && m.id ? `<button class="msg-delete" data-unpost="${m.id}" title="Delete this message">✕</button>` : ""}
<div class="text">${esc(m.text)}</div></div>`).join("");
const ledger = [...S.ledger].reverse().map((l) => `<li>${esc(l.text)}<span class="when">${ago(l.at)}</span></li>`).join("");
return `<div class="cols">
<div class="panel"><h2>Town board</h2>
<div class="msgs">${msgs || `<p class="muted">No messages yet.</p>`}</div>
<form data-form="board"><textarea data-keep="board" maxlength="500" placeholder="Write to the guild…"></textarea>
<p><button class="btn primary">Post</button></p></form></div>
<div class="panel"><h2>Town ledger</h2><ul class="ledger">${ledger || `<li class="muted">Nothing recorded yet.</li>`}</ul></div>
</div>`;
}
function joinButton(p) {
if (!p.fight || p.id === S.uid || (p.party && p.party === S.me?.action?.party)) return "";
const why = S.me?.blocked[p.fight];
return ` <button class="btn sm primary" data-join="${esc(p.id)}" ${why ? `disabled title="${esc(why)}"` : ""}>⚔️ Join</button>`;
}
function rosterNav() {
const on = S.players.filter(online).length, others = S.players.some((p) => p.id !== S.uid && online(p));
return nav("roster", "👥", "Guild roster", `${on}/${S.players.length}`, others ? `<span class="dot"></span>` : "", `${on} of ${S.players.length} online`);
}
function roster() {
const ordered = [...S.players].sort((a, b) => online(b) - online(a)); // stable: by donations within each
return `<div class="grid wide">${ordered.map((p) => {
const cls = C.classes[p.cls];
const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `<div class="lvl ${p.levels[s.id] <= 1 ? "low" : ""}" title="${s.name} level ${p.levels[s.id]}"><span class="ico">${s.icon}</span><span class="nm">${s.name}</span><b>${p.levels[s.id]}</b></div>`).join("");
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
${presence(p)}${titled(p)} <span class="muted small">${cls.name} · total level ${p.total} · ${esc(p.look.home)}${lastSeen(p)}</span>
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div>
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button>${joinButton(p)}</p></div>`;
}).join("")}</div>`;
}
// Patch notes, newest first. The badge stays until this browser has opened the page since the last release.
const NOTE_KINDS = { new: ["✨", "good", "New"], balance: ["⚖️", "gold", "Balance"], fix: ["🔧", "warn", "Fixed"] };
const notesKey = () => C.patch_notes[0] ? `${C.patch_notes[0].date} ${C.patch_notes[0].title}` : "";
const notesUnread = () => !!C.patch_notes[0] && remember("notes") !== notesKey();
function notes() {
return C.patch_notes.map((release) => `<div class="panel"><h2>${esc(release.title)} <span class="muted small">${esc(release.date)}</span></h2>
<ul class="ledger">${release.notes.map(([kind, text]) => {
const [icon, tone, label] = NOTE_KINDS[kind];
return `<li><span class="tag ${tone}" title="${label}">${icon}</span> ${esc(text)}</li>`;
}).join("")}</ul></div>`).join("");
}
// --- achievements ----------------------------------------------------------
// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
function achievementsNav(me) {
const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
}
function achievements() {
const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
const [have, need] = tally(all);
return `<div class="panel"><h2>Achievements <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold thick"><span style="width:${have / need * 100}%"></span></div></div>`
+ sections.map((s, si) => {
const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
return `<h3 class="section-title">${esc(s.name)} <span class="ach-count">${sh} / ${sn}</span></h3>
<div class="grid">${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}</div>`;
}).join("");
}
function achCard(card, key) {
const [have, need] = tally(card.entries);
const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null;
const icon = (e) => e.colour ? `<span class="swatch" style="background:${e.colour}"></span>` : glyph(e.glyph, e.tint);
const chips = card.entries.map((e, i) => `<button class="ach ${e.done ? "done" : ""} ${e.held ? "held" : ""} ${pick === e ? "on" : ""}" data-ach="${key}.${i}"
title="${esc(`${e.name}${e.tip ? ` · ${e.tip}` : ""}`)}" aria-label="${esc(e.name)}">${icon(e)}</button>`).join("");
return `<div class="panel ach-card ${have === need ? "full" : ""}"><h2>${esc(card.name)} <span class="ach-count">${have} / ${need}</span></h2>
<div class="bar gold"><span style="width:${have / need * 100}%"></span></div>
<div class="ach-chips">${chips}</div>
${pick ? `<p class="small ach-line">${pick.done ? "✓" : "🔒"} <b>${esc(pick.name)}</b>${pick.tip ? ` <span class="muted">· ${esc(pick.tip)}</span>` : ""}</p>` : ""}</div>`;
}
// --- character sheet -----------------------------------------------------
const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" };
function characterSheet(uid) {
const mine = uid === S.uid;
const p = mine ? { ...S.players.find((x) => x.id === uid), ...S.me } : S.players.find((x) => x.id === uid);
if (!p) return `<p class="muted">No such adventurer.</p>`;
const cls = C.classes[p.cls];
const skills = C.skills.filter((s) => !s.class || s.class === p.cls);
const total = skills.reduce((sum, s) => sum + p.levels[s.id], 0);
const days = Math.max(1, Math.ceil((serverNow() - p.joined) / 86400));
const guard = Math.round(p.power);
const skillRows = skills.map((s) => {
const level = p.levels[s.id], xp = p.xp[s.id] || 0;
const floor = C.xp_table[level], ceil = C.xp_table[level + 1];
const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100;
const gear = [p.equip[`tool_${s.id}`], p.equip[`station_${s.id}`]].filter(Boolean);
const marks = (cls.fast.includes(s.id) ? `<span title="${cls.name}: ${Math.round((1 - C.class_speed) * 100)}% faster">${cls.icon}</span>` : "")
+ gear.map((id) => `<span title="${esc(`${item(id).name}: ${Math.round(item(id).speed * 100)}% faster`)}">${item(id).icon}</span>`).join("");
return `<${mine ? `button data-nav="skill:${s.id}"` : "div"} class="sheet-skill">
<span class="ico">${s.icon}</span>
<span class="grow"><span class="row1"><b>${s.name}</b><span class="marks">${marks}</span><span class="lv">${level}</span></span>
<span class="bar xp"><span style="width:${pct}%"></span></span></span>
</${mine ? "button" : "div"}>`;
}).join("");
return `
<div class="panel sheet-head">
<div class="big">${cls.icon}</div>
<div class="grow">
<h2>${presence(p)}${esc(p.name)}</h2>
<div class="muted">${cls.name} · total level ${total} · ${days === 1 ? "first day" : `${days} days`} in the city${lastSeen(p)}</div>
<div class="small" style="margin-top:6px">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div>
<div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div>
${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""}
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less physical</div></div>
<div class="stat"><div class="k">Ward</div><div class="v">${p.ward}</div><div class="note">${Math.round(p.ward_reduction * 100)}% less magic</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${p.slots_used} / ${p.slots} bank slots</div></div>` : ""}
</div>
<div class="cols">
<div>
<div class="panel"><h2>Class</h2>
<p><b>${cls.icon} ${cls.name}</b></p>
<p class="small">${esc(cls.bonus)}.</p>
<p class="small">${esc(cls.masterwork)}.</p>
<p class="small">${esc(cls.signature)}.</p>
<p class="small">${esc(cls.combat)}.</p>
</div>
${equipmentPanel(p, mine)}
${mine ? lookPanel(p) : ""}
</div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
</div>`;
}
function lookPanel(p) {
const look = p.look, wear = (kind) => Object.keys(C.cosmetics).filter((k) => C.cosmetics[k].kind === kind && (C.free_looks.includes(k) || p.owned.includes(k)));
const swatches = (part) => wear("colour").map((k) => `<button class="swatch ${look[part] === k ? "on" : ""}" style="background:${C.cosmetics[k].value}"
data-look="${part}" data-value="${k}" title="${esc(C.cosmetics[k].name)}" aria-label="${esc(C.cosmetics[k].name)}"></button>`).join("");
const emblems = [`<button class="btn sm ${look.emblem ? "" : "gold"}" data-look="emblem" data-value="">None</button>`,
...wear("emblem").map((k) => `<button class="btn sm ${look.emblem === k ? "gold" : ""}" data-look="emblem" data-value="${k}" title="${esc(C.cosmetics[k].name)}" aria-label="${esc(C.cosmetics[k].name)}">${glyph(emblemGlyph(k), "#e7e9f0")}</button>`)].join("");
const home = C.homes.findIndex(([name]) => name === p.shown.home), next = C.homes[home + 1];
return `<div class="panel"><h2>Banner &amp; title</h2>
<div class="look-preview">${crest(p.shown, 48)}<span><b>${esc(p.name)}</b>${p.shown.title ? ` <span class="title">${esc(p.shown.title)}</span>` : ""}</span></div>
<p class="small muted">Field</p><div class="swatches">${swatches("field")}</div>
<p class="small muted">Trim</p><div class="swatches">${swatches("trim")}</div>
<p class="small muted">Emblem</p><div class="btns">${emblems}</div>
<p class="small muted">Title</p><select data-look-title><option value="">None</option>${p.titles.map((t) => `<option ${look.title === t ? "selected" : ""}>${esc(t)}</option>`).join("")}</select>
<p class="small muted" style="margin-top:12px">Home · ${esc(p.shown.home)}</p>
${next ? `<button class="btn sm gold" data-home ${p.gold < next[1] ? "disabled" : ""}>${esc(next[0])} (${COIN} ${fmt(next[1])})</button>` : `<p class="small">As grand as it gets.</p>`}
<p class="small muted">Dyes, emblems and titles come with the caravan. Titles are also earned: master a skill (50), defend the city, slay a dragon.</p></div>`;
}
// The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with
// room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded.
// Picking a slot shows what's in it beside the grid.
const DOLL = [
["cape", "Cape", "cape"], ["helmet", "Head", "visored-helm"], ["amulet", "Amulet", "gem-pendant"],
["weapon", "Main hand", "pointy-sword"], ["body", "Body", "breastplate"], ["shield", "Off hand", "round-shield"],
["gloves", "Gloves", "gauntlet"], ["legs", "Legs", "leg-armor"], ["ring", "Ring", "ring"],
["trinket", "Trinket", "crystal-ball"], ["boots", "Boots", "leather-boot"], ["food", "Food", "hot-meal"],
];
function dollSlot(p, slot, label, outline, picked) {
if (slot === "food") return foodSlot(p, picked);
const known = slot in p.equip, two = slot === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed;
const id = two ? p.equip.weapon : p.equip[slot];
const tip = !known ? `${label}: not in the game yet` : id ? `${label}: ${item(id).name}` : `${label}: empty`;
return `<button class="doll-slot ${known ? "" : "future"} ${id ? "" : "empty"} ${two ? "ghost" : ""} ${picked ? "on" : ""}"
${known ? `data-doll="${slot}"` : "disabled"} title="${esc(tip)}">
${id ? item(id).icon : glyph(outline, "#98a1b4")}${two ? `<span class="hands">2H</span>` : ""}
${slot === "weapon" && id ? `<span class="slot-type">${dmgIcon(C.styles[item(id).style || "sword"].damage)}</span>` : ""}</button>`;
}
function paperDoll(p, mine) {
const pick = dollPick && slot2(dollPick, p);
let slot = "";
if (pick === "food") slot = foodPanel(p);
else if (pick) {
const label = DOLL.find(([s]) => s === pick)[1];
const two = pick === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed;
const id = two ? p.equip.weapon : p.equip[pick], it = id && item(id);
slot = `<div class="worn-head">${it ? `<span class="ico">${it.icon}</span><b>${esc(it.name)}</b>` : `<b>${label}</b><span class="muted small">empty</span>`}</div>
${it ? gearChips(it, p) : ""}${mine ? swapGrid(p, pick, two) : ""}`;
}
return `<div class="doll-wrap">
<div class="doll-col"><div class="doll"><span class="doll-figure">${glyph("swordman", "#e6e8ee")}</span>
${DOLL.map(([s, name, outline]) => dollSlot(p, s, name, outline, s === pick)).join("")}</div>${combatStats(p)}</div>
<div class="doll-side"><div class="doll-slot-info">${slot}</div></div>
</div>`;
}
// Your fighting numbers, always in the same place under the slot details: icons and numbers,
// with what each means on hover.
function combatStats(p) {
const f = p.fighter, pct = (x) => `${Math.round(x * 100)}%`, dt = C.damage_types[f.damage];
const cell = (icon, value, tip) => `<span class="gstat" title="${esc(tip)}">${icon}<span>${value}</span></span>`;
const cells = [
cell(glyph("biceps", "#e06a6a"), p.power, "Power: from your level and gear"),
cell(glyph("checked-shield", "#d4dae2"), p.armour, `Armour: ${pct(p.reduction)} less damage from blows`),
cell(glyph("magic-shield", "#a77be8"), p.ward, `Ward: ${pct(p.ward_reduction)} less damage from magic`),
cell(glyph(dt.glyph, dt.tint), f.max_hit, `Max hit (${dt.name.toLowerCase()})`),
cell(glyph("striking-diamonds", "#f0d060"), pct(f.crit), "Crit chance"),
cell(glyph("stopwatch", "#9fb3c8"), `${f.interval}s`, `A swing every ${f.interval}s`),
cell(glyph("archery-target", "#e0a050"), fmt(f.accuracy), "Accuracy"),
cell(glyph("dodging", "#8fd0e0"), fmt(f.evasion), "Evasion"),
cell(glyph("shield-reflect", "#e0b04a"), pct(f.block), "Block chance (with a shield)"),
];
return `<div class="combat-stats stat-grid">${cells.join("")}</div>`;
}
// What else in the bank fits the picked slot, best first: click one to put it on. For weapons the
// corner shows the damage type, so you can pick one for what you're about to fight.
function swapGrid(p, slot, two) {
const fits = Object.keys(p.bank).filter((id) => item(id).slot === slot)
.sort((a, b) => (item(b).tier ?? 0) - (item(a).tier ?? 0) || (item(b).power ?? 0) - (item(a).power ?? 0));
const tiles = fits.map((id) => {
const it = item(id), [skill, level] = it.req || [];
const why = two ? `Your ${item(p.equip.weapon).name.toLowerCase()} takes both hands`
: skill && p.levels[skill] < level ? `Needs ${skillOf(skill).name.toLowerCase()} ${level}` : "";
const corner = it.style ? dmgIcon(C.styles[it.style].damage) : "";
const tip = [it.name, gearStats(it), it.style ? (it.two_handed ? "two-handed" : "one-handed") : "", why].filter(Boolean).join(" · ");
return `<button class="doll-slot swap" data-equip="${id}" ${why ? "disabled" : ""} title="${esc(tip)}">${it.icon}
${corner ? `<span class="swap-type">${corner}</span>` : ""}${it.two_handed ? `<span class="hands">2H</span>` : ""}${p.bank[id] > 1 ? `<span class="swap-qty">${p.bank[id]}</span>` : ""}</button>`;
});
const off = p.equip[slot] && !two ? `<button class="doll-slot swap off" data-unequip="${slot}" title="Take it off, back to the bank">${glyph("cancel", "#98a1b4")}</button>` : "";
if (!tiles.length && !off) return `<div class="muted small">Nothing else in your bank fits here.</div>`;
return `<div class="swap-head muted small">Swap for</div><div class="swap-grid">${tiles.join("")}${off}</div>`;
}
// A picked slot only counts if it's one this character has.
function slot2(slot, p) {
return slot in p.equip || (slot === "food" && "food" in p) ? slot : null;
}
// Food for resting between fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it.
function foodSlot(p, picked) {
if (!("food" in p)) return `<span class="doll-slot future" title="Food"></span>`;
const id = p.food && p.bank[p.food] ? p.food : null;
const none = !p.edible.length;
const tip = none ? "No food in your bank" : id ? `Food: ${item(id).name}, heals ${item(id).heal}` : "Food: cheapest first";
return `<button class="doll-slot ${picked ? "on" : ""} ${none ? "starving" : ""}" data-doll="food" title="${esc(tip)}">
${none ? glyph("hot-meal", "#e2645c") : id ? item(id).icon : ANY_FOOD}${id ? `<span class="swap-qty">${fmt(p.bank[id])}</span>` : ""}</button>`;
}
function foodPanel(p) {
const id = p.food && p.bank[p.food] ? p.food : null;
const head = id ? `<div class="worn-head"><span class="ico">${item(id).icon}</span><b>${esc(item(id).name)}</b>
<span class="muted small">${HEART} ${item(id).heal} · ${fmt(p.bank[id])} left</span></div>`
: `<div class="worn-head"><span class="ico">${ANY_FOOD}</span><b>Cheapest first</b></div>`;
const tiles = [...p.edible].sort((a, b) => item(a).heal - item(b).heal).map((f) => `<button class="doll-slot swap ${f === id ? "on" : ""}" data-food="${f}"
title="${esc(`${item(f).name}: heals ${item(f).heal}`)}">${item(f).icon}<span class="swap-qty">${fmt(p.bank[f])}</span></button>`);
const cheap = `<button class="doll-slot swap ${id ? "" : "on"}" data-food="cheapest" title="Cheapest first">${ANY_FOOD}</button>`;
const below = p.eat_below ?? C.eat_below;
const label = (x) => x === 0 ? "Never" : x === 1 ? "Always" : `${Math.round(x * 100)}%`;
const when = `<div class="swap-head muted small" title="Between fights you eat while under this much of your HP, then rest the rest of the way">Eat below</div>
<div class="eat-below">${C.eat_choices.map((x) => `<button class="btn sm ${x === below ? "on" : ""}" data-eat="${x}">${label(x)}</button>`).join("")}</div>`;
return head + (p.edible.length ? `<div class="swap-head muted small">Eat first</div><div class="swap-grid">${cheap}${tiles.join("")}</div>`
: `<div class="small warn">No food in your bank.</div>`) + when;
}
// The equipment panel, the same on the character sheet and the combat page (which leaves out tools).
function equipmentPanel(p, mine, tools = true) {
return `<div class="panel"><h2>Equipment</h2>${paperDoll(p, mine)}
${tools ? `<h2 style="margin-top:16px">Tools</h2>${toolSlots(p)}` : ""}</div>`;
}
// Tools: one small slot per gathering skill.
// Gathering tools in a row; under them each crafting skill's tool, with its station below it.
function toolSlots(p) {
const slot = (kind, s) => {
const id = p.equip[`${kind}_${s.id}`], it = id && item(id);
return `<span class="doll-slot small ${id ? "" : "empty"}" title="${esc(it ? `${it.name}: ${Math.round(it.speed * 100)}% faster ${s.name.toLowerCase()}` : `${s.name}: no ${kind}`)}">
${it ? it.icon : s.icon}</span>`;
};
const gathering = C.skills.filter((s) => `tool_${s.id}` in p.equip && !(`station_${s.id}` in p.equip));
const crafting = C.skills.filter((s) => `station_${s.id}` in p.equip);
return `<div class="tool-slots">${gathering.map((s) => slot("tool", s)).join("")}</div>
<div class="tool-grid">${crafting.map((s) => slot("tool", s) + slot("station", s)).join("")}</div>`;
}
// --- registration ---------------------------------------------------------
function classCards() {
return Object.entries(C.classes).map(([id, c]) => `
<button class="class-card ${pickedClass === id ? "on" : ""}" data-class="${id}">
<span class="ico">${c.icon}</span><span><b>${c.name}</b><p>${esc(c.bonus)}.</p><p>${esc(c.masterwork)}.</p><p>${esc(c.signature)}.</p><p>${esc(c.combat)}.</p></span>
</button>`).join("");
}
// After the classes became trades, everyone picks once more. Nothing is lost.
function repickPage() {
return `<div class="welcome">
<h2>Pick your trade</h2>
<p class="muted">Classes are now trades, gathering through crafting. You keep every level and item. Free, once.</p>
<div class="classes">${classCards()}</div>
<div class="btns"><button class="btn primary" data-repick ${pickedClass ? "" : "disabled"}>Take up this trade</button></div>
<p class="small muted">Any class can train any skill.</p>
</div>`;
}
function register() {
const cards = classCards();
return `<div class="welcome">
<h2>A new city on the frontier</h2>
<p class="muted">Settle a frontier city together. Train, build, and hold off the bandits.
You keep working while away, for up to a day.</p>
<div class="classes">${cards}</div>
<form data-form="register" class="btns">
<input type="text" data-keep="name" maxlength="20" placeholder="Your adventurer's name" style="flex:1;min-width:200px">
<button class="btn primary" ${pickedClass ? "" : "disabled"}>Enter the city</button>
</form>
<p class="small muted">Any class can train any skill.</p>
</div>`;
}
// --- animation ------------------------------------------------------------
function animate() {
const now = serverNow();
const a = S?.me?.action;
animateFight(now);
if (a && !a.waiting) {
const pct = Math.min(1, (now - a.start) / a.duration);
// In a fight the bar is the enemy's health (set by animateFight), not a timer.
if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
const key = `${a.start}:${a.duration}`;
if (pct >= 1 && refreshedFor !== key) {
refreshedFor = key;
setTimeout(refresh, 150);
} else if (a.fight && now - a.start > a.duration + 1 && now - retriedAt > 2) {
retriedAt = now; // the next enemy hasn't turned up yet: ask again
refresh();
}
}
for (const el of document.querySelectorAll("[data-span]")) {
const [start, end] = el.dataset.span.split(",").map(Number);
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() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
if (queueOpen && !e.target.closest(".queue-panel, [data-queue-menu]")) { queueOpen = false; render(); }
const t = e.target.closest("button, [data-menu], [data-close], [data-aim]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-battle-skip]")) return playBattle.skip?.();
if (t.matches("[data-battle-close]")) { $("#modal").hidden = true; showThreat(); return; }
if (d.raidSeen) {
$("#modal").hidden = true;
return act("/api/seen", { id: Number(d.raidSeen) });
}
if (!$("#modal").hidden && $(".battle")) return; // the raid report closes with its own button, once watched
if (d.replay) return replayBattle(Number(d.replay));
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
if (d.navGroup) { tabs[d.nav.slice(6)] = d.navGroup; remember(`tab.${d.nav.slice(6)}`, d.navGroup); }
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
if (d.press) {
if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself");
t.closest(".ab")?.classList.add("queued");
const kind = C.abilities[d.press].target, mine = aim.fight === S.me.action?.start;
const target = !mine ? null : kind === "enemy" ? aim.foe : kind === "ally" ? aim.ally : null;
return act("/api/press", { id: d.press, ...(target ? { target } : {}) });
}
if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) });
if ("pull" in d) return act("/api/pull", {});
if ("startRun" in d) return act("/api/run", {});
if (d.learn) return act("/api/learn", { item: d.learn });
if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) });
if (d.aim) {
const fight = S.me.action?.start;
if (aim.fight !== fight) aim = { fight, foe: null, ally: null };
if (d.aim[0] === "e") aim.foe = d.aim;
else aim.ally = d.aim;
animateFight(serverNow()); // the outline moves now; the server hears about it in the background
return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
}
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: 1 });
if ("stop" in d) return act("/api/stop", {});
if (d.unqueue) return act("/api/queue/remove", { index: Number(d.unqueue) });
if ("queueMenu" in d) { queueOpen = !queueOpen; return render(); }
if (d.queueMove) return act("/api/queue/move", { index: Number(d.queueMove), to: Number(d.to) });
if ("queueSkip" in d) return act("/api/queue/skip", {});
if ("queueClear" in d) return act("/api/queue/clear", {});
if (d.join) { tagging = true; return act("/api/join", { id: d.join }); }
if ("orderNew" in d) {
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
return render();
}
if (d.draftKind) { draft = { kind: d.draftKind, item: "", qty: 1, provide: [], reward: 0 }; clearSearch(); return render(); }
if (d.draftSkill) { draft.skill = d.draftSkill; draft.search = ""; clearSearch(); return render(); }
if (d.draftGroup) { draft.group = d.draftGroup; return render(); }
if (d.draftItem) {
pickOrderItem(d.draftItem);
render();
// On a phone the named list is long: bring the amount, price and Post into view.
if (draft.item && matchMedia("(max-width: 900px)").matches) $(".compose-grid")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (d.provide) {
draft.provide = draft.provide.includes(d.provide) ? draft.provide.filter((x) => x !== d.provide) : [...draft.provide, d.provide];
return render();
}
if ("orderPost" in d) {
// A fire lit since picking may now pay a reagent that was ticked.
const a = draft.kind === "craft" && C.actions.find((x) => x.id === C.order_recipes[draft.item]);
const provide = a ? draft.provide.filter((id) => runInputs(a)[id]) : draft.provide;
const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide, reward: draft.reward };
return api("/api/order", body).then((state) => { draft = null; orderTab = "yours"; accept(state); }).catch((err) => toast(err.message, "bad"));
}
if (d.otab) { orderTab = d.otab; return render(); }
if (d.orderWork) return act("/api/order/work", { id: Number(d.orderWork) });
if (d.orderDrop) return act("/api/order/drop", { id: Number(d.orderDrop) });
if (d.orderDeliver) return act("/api/order/deliver", { id: Number(d.orderDeliver) });
if (d.orderCancel) return act("/api/order/cancel", { id: Number(d.orderCancel) });
if (d.orderRemove) return act("/api/order/remove", { id: Number(d.orderRemove) });
if ("orderClear" in d) return act("/api/order/remove", {});
if (d.orderFill) return act("/api/order/provide", { id: Number(d.orderFill), item: d.item });
if (d.doll) {
dollPick = dollPick === d.doll ? null : d.doll;
return render();
}
if (d.ach) {
achPick = achPick === d.ach ? null : d.ach;
return render();
}
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });
if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value });
if (d.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip });
if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" });
if (d.install) return act("/api/install", { fitting: d.install, source: d.source });
if ("plot" in d) return act("/api/plot", { id: d.plot || null });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.pin) return act("/api/pin", { skill: d.pin });
if (d.track) return act("/api/track", { id: d.track });
if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.unpost) {
if (!confirm("Delete this message?")) return;
return act("/api/board/delete", { id: Number(d.unpost) });
}
if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source });
if ("hire" in d) return act("/api/hire", {});
if (d.wages) return act("/api/wages", { source: d.wages, qty: "all" });
if ("rent" in d) return act("/api/rent", {});
if (d.buy) return act("/api/buy", { ware: d.buy, skill: $("[data-keep=tutor-skill]")?.value });
if ("statue" in d) return act("/api/statue", { of: $("[data-keep=statue-of]").value });
if ("home" in d) return act("/api/home", {});
if (d.look) return act("/api/look", { look: { ...S.me.look, [d.look]: d.value || null } });
if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen });
if (d.pray) {
const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Max ${C.max_prayers} prayers active`, "bad");
return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] });
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.bury) return act("/api/settings", { bury: d.bury === "on" });
if (d.class) { pickedClass = d.class; render(); return; }
if ("repick" in d) return act("/api/class", { cls: pickedClass });
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {});
});
function clearSearch() {
const field = $("[data-keep=order-search]");
if (field) field.value = "";
}
function pickOrderItem(id) {
const list = draft.kind === "craft" ? C.order_recipes : C.order_raw;
draft.item = list[id] ? id : "";
draft.provide = [];
// A starting price: what the item's worth, each.
draft.reward = draft.item ? item(draft.item).value * (draft.kind === "craft" ? draft.qty : 1) : 0;
}
document.addEventListener("input", (e) => {
if (e.target.dataset.keep !== "order-search" || !draft) return;
draft.search = e.target.value;
render();
});
document.addEventListener("change", (e) => {
if (e.target.dataset.qset) {
const n = Math.floor(Number(e.target.value));
if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad");
return act("/api/queue/set", { index: Number(e.target.dataset.qset), qty: n });
}
if ("lookTitle" in e.target.dataset) return act("/api/look", { look: { ...S.me.look, title: e.target.value || null } });
const key = e.target.dataset.draft;
if (!key || !draft) return;
if (key === "item") {
pickOrderItem(e.target.value);
} else {
const before = draft.qty;
draft[key] = Math.max(key === "qty" ? 1 : 0, Math.floor(Number(e.target.value) || 0));
if (key === "qty") draft.qty = Math.min(draft.qty, C.order_max[draft.kind]);
// A crafting order's reward is the total: it follows the quantity at the same price each.
if (key === "qty" && draft.kind === "craft") draft.reward = Math.round(draft.reward / before * draft.qty);
if (key === "qty" && draft.kind === "craft" && draft.item) {
// Drop provided reagents the bank can no longer cover at the new quantity.
const a = C.actions.find((x) => x.id === C.order_recipes[draft.item]), runs = Math.ceil(draft.qty / a.outputs[draft.item]);
draft.provide = draft.provide.filter((id) => (S.me.bank[id] || 0) >= (runInputs(a)[id] || 0) * runs);
}
}
render();
});
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
// Number keys press your abilities, in the bar's order (the ones you know).
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && queueOpen) { queueOpen = false; return render(); }
if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
const n = Number(e.key);
if (!n) return;
const tile = document.querySelectorAll(".ability-bar .ab:not(.locked) [data-press]")[n - 1];
if (tile) { e.preventDefault(); tile.click(); }
});
document.addEventListener("submit", async (e) => {
const form = e.target.closest("[data-form]");
if (!form) return;
e.preventDefault();
const field = form.querySelector("[data-keep]");
const value = field.value.trim();
if (form.dataset.form === "register") {
if (!pickedClass) return toast("Pick a class first", "bad");
return act("/api/register", { name: value, cls: pickedClass });
}
if (form.dataset.form === "board") {
if (!value) return;
field.value = "";
return act("/api/board", { text: value });
}
if (form.dataset.form === "monument") {
field.value = "";
return act("/api/monument", { id: form.dataset.id, qty: Number(value) });
}
if (form.dataset.form === "make") {
const n = Math.floor(Number(value));
if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad");
field.value = "1";
if (e.submitter?.hasAttribute("data-q")) return act("/api/queue", { id: form.dataset.id, qty: n });
return act("/api/action", { id: form.dataset.id, once: n });
}
if (form.dataset.form === "qty") {
const n = Math.floor(Number(value)), kind = form.dataset.kind;
if (!(n >= 1)) return toast("At least 1", "bad");
field.value = "1";
return act(`/api/${kind}`, { item: form.dataset.item, qty: n, ...(kind === "give" && { to: $("[data-keep=give-to]").value }) });
}
if (form.dataset.form === "gold") {
field.value = "";
return act("/api/donate", { item: "gold", qty: Number(value) });
}
if (form.dataset.form === "dev-user" && value) {
document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`;
field.value = "";
return refresh();
}
});
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
(function swipeMenu() {
const phone = matchMedia("(max-width: 900px)");
let drag = null;
const reset = () => {
$("#side").style.transform = $("#side").style.transition = "";
$("#scrim").style.opacity = "";
$("#app").classList.remove("dragging");
};
document.addEventListener("touchstart", (e) => {
if (drag) release();
if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return;
const t = e.touches[0];
drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target };
drag.target.addEventListener("touchmove", move, { passive: false });
drag.target.addEventListener("touchend", finish);
drag.target.addEventListener("touchcancel", finish);
}, { passive: true });
function release() {
drag.target.removeEventListener("touchmove", move);
drag.target.removeEventListener("touchend", finish);
drag.target.removeEventListener("touchcancel", finish);
if (drag.axis === "x") reset();
drag = null;
}
function move(e) {
if (!drag) return;
const t = e.touches[0];
const dx = t.clientX - drag.x, dy = t.clientY - drag.y;
if (!drag.axis) {
if (Math.hypot(dx, dy) < 10) return;
const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0);
drag.axis = sideways ? "x" : "y";
if (!sideways) return;
drag.width = $("#side").offsetWidth;
$("#side").style.transition = "none";
$("#app").classList.add("dragging");
}
if (drag.axis !== "x") return;
e.preventDefault();
drag.dx = dx;
// 0 is fully open, -width fully shut.
const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width));
$("#side").style.transform = `translateX(${offset}px)`;
$("#scrim").style.opacity = String(1 + offset / drag.width);
}
function finish(e) {
if (!drag) return;
const swiped = drag.axis === "x";
const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms
const open = drag.open
? !(drag.dx < -drag.width * 0.35 || speed < -0.4)
: drag.dx > drag.width * 0.35 || speed > 0.4;
// Hand back to the stylesheet; its transition carries on from here.
release();
if (swiped) setMenu(open);
}
})();
(async function boot() {
try {
C = iconise(await api("/api/content"));
await refresh();
} catch (err) {
$("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;
return;
}
setInterval(() => {
if (!document.hidden && Date.now() - polledAt >= (S?.me?.action?.fight || S?.me?.action?.run ? FIGHT_POLL_MS : POLL_MS)) refresh();
}, 500);
requestAnimationFrame(loop);
})();