guildhall/static/app.js
2026-10-20 15:03:22 +01:00

1563 lines
88 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 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 pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for
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>`;
const COIN = glyph("two-coins", "#e0b04a"), 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];
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;
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;
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) {
location.reload(); // a new build was deployed
return;
}
S = state;
skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary);
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) 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");
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()}`);
}
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.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>` : ""}
${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.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;
const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
const foe = dragon ? "The dragon" : "Raiders";
openModal(`
<div class="battle">
<h2>${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}</h2>
<div class="battle-side"><span><b>Our defence</b> <span class="muted">${fmt(r.defence)}</span></span>
<div class="bar thick hp you"><span data-battle="us" style="width:100%"></span></div></div>
<div class="battle-side"><span><b>${foe}</b> <span class="muted">${fmt(r.attack)}</span></span>
<div class="bar thick hp"><span data-battle="them" style="width:100%"></span></div></div>
<ul class="fight-log battle-log" data-battle="log"><li class="muted">${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}</li></ul>
<div data-battle="report" hidden>
<div class="battle-result ${won ? "win" : "loss"}">${esc(r.title)}</div>
${r.garrison.length ? `<p class="small muted">Garrison: ${r.garrison.map((g) => esc(g.name)).join(", ")}</p>` : ""}
${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" data-raid-seen="${t.id}" hidden>Close</button></p>
</div>`);
playBattle(t.id, r.defence, r.attack, won, dragon);
}
// Pure theatre: the outcome is already decided. The loser's bar runs out; the winner's ends
// as high as the gap between the two sides.
function playBattle(id, us, them, won, dragon) {
const steps = 10, every = 900;
let seed = id * 9301 + 49297;
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const weights = Array.from({ length: steps }, () => 0.5 + rand());
const total = weights.reduce((a, b) => a + b, 0);
const loserEnd = 0, winnerEnd = Math.max(0.1, 1 - Math.min(us, them) / Math.max(us, them, 1));
const ours = won ? winnerEnd : loserEnd, theirs = won ? loserEnd : winnerEnd;
const lines = {
good: dragon ? ["Arrows find the dragon's wing", "The ballista bolt strikes home", "Spears drive it back from the walls"]
: ["Arrows rain down on the raiders", "The walls hold", "The militia drives them back", "A charge breaks their line"],
bad: dragon ? ["Fire sweeps the rooftops", "Its claws tear at the gate", "The defenders scatter from its breath"]
: ["Raiders scale the walls", "The gate shudders under the ram", "A volley cuts down defenders"],
};
let step = 0, done = 0, said = "";
const finish = () => {
clearInterval(timer);
const box = $(".battle");
if (!box) return;
$('[data-battle="us"]', box).style.width = `${ours * 100}%`;
$('[data-battle="them"]', box).style.width = `${theirs * 100}%`;
$('[data-battle="report"]', box).hidden = false;
$("[data-battle-skip]", box).hidden = true;
$("[data-raid-seen]", box).hidden = false;
};
const timer = setInterval(() => {
const box = $(".battle");
if (!box) return clearInterval(timer);
if (step >= steps) return finish();
done += weights[step] / total;
step++;
$('[data-battle="us"]', box).style.width = `${(1 - (1 - ours) * done) * 100}%`;
$('[data-battle="them"]', box).style.width = `${(1 - (1 - theirs) * done) * 100}%`;
// Each clash goes the way of the side that ends up ahead, now and then the other.
const ahead = rand() < 0.7 ? won : !won;
const pool = (ahead ? lines.good : lines.bad).filter((l) => l !== said);
const log = $('[data-battle="log"]', box);
said = pool[Math.floor(rand() * pool.length)];
log.insertAdjacentHTML("afterbegin", `<li class="${ahead ? "good" : "bad"}">${said}</li>`);
while (log.children.length > 4) log.lastElementChild.remove();
}, every);
playBattle.finish = finish;
}
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;
setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
setHtml($("#main"), S.me ? pageHtml() : register());
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 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";
let html = `
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</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>
<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("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
<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`)}`;
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>`;
}
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
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", board: "Town board", roster: "Guild roster", 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"><span data-progress></span></span></button>`;
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
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}`;
}
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, board, roster, bank }[page] || town)();
}
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} ${fmt(qty)}${have !== undefined ? ` <span class="have">/ ${fmt(have)}</span>` : ""}</span>`;
}
// 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>`;
}
// --- 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}</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 += fightPanel() + combatPanel();
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 += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(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 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 bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</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.
function potionPanel(skill) {
const me = S.me;
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (!kinds.length) return "";
const active = me.potions[skill];
const owned = kinds.filter(([id]) => me.bank[id]);
const drink = owned.map(([id, it]) => `<button class="btn sm" data-potion="${id}" title="${esc(it.potion.text)}">${it.icon} ${esc(it.name)} (${fmt(me.bank[id])})</button>`).join("");
return `<div class="panel tool-panel">${active
? `${item(active.item).icon} <b>${esc(item(active.item).name)}</b> <span class="muted">${esc(item(active.item).potion.text)} · ${fmt(active.charges)} uses left${me.bank[active.item] ? `, ${fmt(me.bank[active.item])} more in the bank` : ""}</span>`
: `<span class="muted">No potion.</span>`}
${!active && drink ? `<span class="btns">Drink: ${drink}</span>` : ""}
${!active && !drink ? `<span class="small muted">Brewed in Herblore.</span>` : ""}
</div>`;
}
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;
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>
<p class="small muted">Prayers cost points each fight. Bury bones for more.</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. +${temple} prayer point${temple > 1 ? "s" : ""} per bone buried.</p>`
: `<p class="muted">No temple yet.</p>`}
</div></div>`;
}
function toolPanel(skill) {
const tool = S.me.equip[`tool_${skill}`];
return `<div class="panel tool-panel">${tool
? `${item(tool).icon} <b>${esc(item(tool).name)}</b> <span class="muted">${Math.round(item(tool).speed * 100)}% faster</span>
<button class="btn sm" data-unequip="tool_${skill}">Unequip</button>`
: `<span class="muted">No tool.</span>`}</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" ? ` <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>`;
}
// The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel() {
const f = S.me.action?.fight;
if (!f) return "";
const side = (who, name, foe) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b>${foe ? `<span class="v-res">${resistIcons(foe)}</span>` : ""}<span class="small muted" data-f="${who}-hp"></span></div>
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>
</div>`;
return `<div class="panel fight" data-fight="${S.me.action.start}">
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy, f.foe)}</div>
<ul class="fight-log" data-f="log"></ul>
</div>`;
}
function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e;
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (kind === "block") return `<li class="good">You block ${foe}'s blow</li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b>${type}</li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b>${type}</li>`;
}
function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
const t = now - a.start;
const over = t >= f.end;
// From the kill until the next enemy turns up (the next fight's start, once the server sends it),
// the panel is the same quiet countdown: no enemy, both swing timers empty.
const waiting = t < 0 || (over && f.result === "won");
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp;
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`;
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", waiting);
// Your HP everywhere follows the fight as it plays out.
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); };
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; });
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; });
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
let swung = 0;
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
set(`${who}-swing`, (el) => { el.style.width = over || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
}
set("log", (el) => {
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
if (el.dataset.key === key) return;
el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over && !waiting) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
});
}
// How you fight with what's in your hands, and what your trade fights best with.
function styleNote(me) {
const weapon = me.equip.weapon, styleId = (weapon && item(weapon).style) || "sword", st = C.styles[styleId];
const own = C.class_style[me.cls], traits = {
sword: `${st.interval}s${me.equip.shield ? `, shield blocks ${Math.round(C.block_chance * 100)}%` : ""}`,
hammer: `${st.interval}s, double crits`,
staff: `${st.interval}s, accurate`,
bow: `${st.interval}s, shoots first`,
};
return `<p class="small">${dmgIcon(st.damage)} ${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${C.damage_types[st.damage].name.toLowerCase()}, ${traits[styleId]}.
${own === styleId ? `<b class="good">Trade weapon: +15% damage.</b>` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}</p>`;
}
function combatPanel() {
const me = S.me;
const stat = (id) => gearStats(item(id));
const slot = (name, label) => {
const id = me.equip[name];
return `<div class="gear-slot">${id
? `<span>${item(id).icon} ${esc(item(id).name)} <span class="muted small">${stat(id)}</span></span><button class="btn sm" data-unequip="${name}">Unequip</button>`
: `<span class="muted">No ${label}</span>`}</div>`;
};
const foods = me.edible.map((id) => `<option value="${id}" ${me.food === id ? "selected" : ""}>${esc(item(id).name)} (heals ${item(id).heal}, ${fmt(me.bank[id])} left)</option>`).join("");
const hpPct = me.hp / me.max_hp * 100;
return `<div class="cols">
<div class="panel"><h2>Health</h2>
<div class="hp-line"><b>${HEART} <span data-live-hp>${fmt(me.hp)}</span> / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span data-live-hpbar style="width:${hpPct}%"></span></div>
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
<p class="small">${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(out of points)</b>`}` : `<span class="muted">No prayers active.</span>`}
<button class="linkish" data-nav="skill:prayer">Prayers</button></p>
<p class="small muted">Eats below half HP, retreats when out of food.
${me.edible.length ? "" : `<b class="warn">No food in your bank.</b>`}</p>
</div>
<div class="panel"><h2>Equipment</h2>
<div class="gear">${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}</div>
<p class="small muted">Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% physical) · ward ${me.ward} (-${Math.round(me.ward_reduction * 100)}% magic)</p>
${styleNote(me)}
</div></div>`;
}
// 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`];
return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : "");
}
const GEAR_SLOTS = ["weapon", "shield", "helmet", "body", "legs"];
// 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`)];
}
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>`;
}
// 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.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.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(`Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
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);
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 += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length ? `<span class="arrow">→</span>` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy;
extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}`;
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(([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>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">-3% 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 + temple} 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)}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
</div></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</span></div>` : ""}
${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</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"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Once, then resume">${a.enemy ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "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>`;
}
// --- bank -----------------------------------------------------------------
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 `
<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()}
</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 qtyBtns = (attr) => [1, 10, 100].filter((q) => q <= have).map((q) => `<button class="btn sm" ${attr}="${id}" data-qty="${q}">${q}</button>`).join("")
+ `<button class="btn sm" ${attr}="${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} in fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</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">${qtyBtns("data-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">${qtyBtns("data-give")}</div></div>` : ""}
<div class="row"><div class="k">Sell</div><div class="btns">${qtyBtns("data-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) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${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 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();
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 += `<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;
}
// 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">${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}</div></div>`;
}
// 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, lines = [];
const note = (icon, name, text) => lines.push(`<span>${icon} <b>${esc(name)}</b> <span class="muted">${esc(text)}</span></span>`);
const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block);
if (touches(h.weather)) note(h.weather.icon, h.weather.name, h.weather.text);
for (const e of h.effects) if (touches(e)) note(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);
note(p.icon, p.name, !fix ? p.text : fix.skill === skill ? "Clear it below." : `${skillOf(fix.skill).name} clears it (Town square).`);
}
return lines.length ? `<div class="panel tool-panel skill-happenings">${lines.join("")}</div>` : "";
}
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]}` : "?");
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">Raiders</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 ? `<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: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" };
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>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>`;
}
// --- board & roster -------------------------------------------------------
function board() {
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${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 roster() {
return `<div class="grid wide">${S.players.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>
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</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></p></div>`;
}).join("")}</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 tool = p.equip[`tool_${s.id}`];
const marks = (cls.fast.includes(s.id) ? `<span title="${cls.name}: ${Math.round((1 - C.class_speed) * 100)}% faster">${cls.icon}</span>` : "")
+ (tool ? `<span title="${esc(`${item(tool).name}: ${Math.round(item(tool).speed * 100)}% faster`)}">${item(tool).icon}</span>` : "");
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>${esc(p.name)}</h2>
<div class="muted">${cls.name} · total level ${total} · ${days === 1 ? "first day" : `${days} days`} in the city</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>
<div class="panel"><h2>Equipment</h2>
${paperDoll(p, mine)}
<h2 style="margin-top:16px">Tools</h2>
${toolSlots(p)}
</div>
</div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
</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"],
["ammo", "Ammo", "quiver"], ["boots", "Boots", "leather-boot"], ["trinket", "Trinket", "crystal-ball"],
];
function dollSlot(p, slot, label, outline, 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>` : ""}</button>`;
}
function paperDoll(p, mine) {
const worn = DOLL.filter(([slot]) => p.equip[slot]).map(([slot]) => slot);
const pick = (dollPick && slot2(dollPick, p)) || (worn.includes("weapon") ? "weapon" : worn[0]) || "weapon";
const label = DOLL.find(([slot]) => slot === 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);
const detail = it ? `<div class="worn-head"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b>
<span class="muted small">${label}${two ? " (two-handed weapon)" : ""}</span></span>
${mine && !two ? `<button class="btn sm" data-unequip="${pick}" title="Back to the bank">✕</button>` : ""}</div>
${gearChips(it, p)}`
: `<div class="muted small">${label}: nothing worn.${mine ? ` Equip from the <button class="linkish" data-nav="bank">bank</button>.` : ""}</div>`;
const st = C.styles[(p.equip.weapon && item(p.equip.weapon).style) || "sword"], dt = C.damage_types[st.damage];
return `<div class="doll-wrap">
<div class="doll"><span class="doll-figure">${glyph("swordman", "#e6e8ee")}</span>
${DOLL.map(([slot, name, outline]) => dollSlot(p, slot, name, outline, slot === pick)).join("")}</div>
<div class="doll-side">
<div class="doll-detail">${detail}</div>
<div class="doll-stats">
<span>${glyph(dt.glyph, dt.tint)} ${dt.name}, a swing every ${st.interval}s</span>
<span class="muted">Power</span><b>${p.power}</b>
<span class="muted">${glyph("checked-shield", "#d4dae2")} Armour</span><b>${p.armour} <span class="muted small">-${Math.round(p.reduction * 100)}% physical</span></b>
<span class="muted">${glyph("magic-shield", "#a77be8")} Ward</span><b>${p.ward} <span class="muted small">-${Math.round(p.ward_reduction * 100)}% magic</span></b>
</div>
</div>
</div>`;
}
// A picked slot only counts if it's one this character has.
function slot2(slot, p) {
return slot in p.equip ? slot : null;
}
// Tools: one small slot per gathering skill.
function toolSlots(p) {
return `<div class="tool-slots">${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => {
const id = p.equip[`tool_${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 tool`)}">
${it ? it.icon : s.icon}</span>`;
}).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) {
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 + (a.respawn || 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);
}
function loop() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-battle-skip]")) return playBattle.finish?.();
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 (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) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if (d.doll) {
dollPick = d.doll;
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.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: 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.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.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", {});
});
document.addEventListener("change", (e) => {
if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null });
});
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
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 === "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) refresh(); }, POLL_MS);
requestAnimationFrame(loop);
})();