guildhall/static/app.js
rope 8afcba79fc Dungeon loot: patterns, masterworks, relics, uniques
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:48:16 +01:00

2551 lines
156 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 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
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
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;
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");
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.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.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;
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")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
${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`)}`;
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>`;
}
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() : ""}`;
}
// 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 }[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>` : "";
}
// --- 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 = [];
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">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;
}
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>`;
}
// 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 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 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, while resting) the button to go in early.
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>
${f && run.leader === S.uid ? `<button class="btn sm" data-pull data-pull-btn>Pull now</button>` : ""}</div>` : "";
let foe = `<div class="foe-idle muted">${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>`;
}
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"]);
// 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, , 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" || kind === "cc")) them.cast = null;
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 };
}
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 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 while they eat.
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 / 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 aim = pick && st[pick[2]] && !st[pick[2]].out ? pick[2] : me.aim && st[me.aim] && !st[me.aim].out ? me.aim : 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 === aim && !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)}%`;
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;
const spec = cast && (el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities)[cast.label];
const castEl = el.querySelector("[data-u-cast]");
const key = cast ? `${cast.label}:${cast.from}` : "";
if (castEl.dataset.key !== key) {
castEl.dataset.key = key;
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></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>`;
}
}
}
// 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.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);
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 + 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)} ${Object.keys(a.outputs).map(needTag).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">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>`;
}
// 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 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} 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">${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 || 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]}` : "?");
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: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", 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 hold the armoury's swords and shields. 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]));
}
// 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];
const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out);
const worth = Object.entries(a.inputs).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></div>`;
}
const need = 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)}`, ...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></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></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-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]);
slots = Object.entries(a.inputs).map(([id, per]) => {
const need = per * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id);
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></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 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>
${titled(p)} <span class="muted small">${cls.name} · total level ${p.total} · ${esc(p.look.home)}</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("");
}
// --- 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>${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>
${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>`;
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>`);
}
// 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) => {
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.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) {
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");
return act("/api/press", { id: d.press });
}
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 (d.learn) return act("/api/learn", { item: d.learn });
if (d.aim) 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: true });
if ("stop" in d) return act("/api/stop", {});
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); return render(); }
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) {
const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide: draft.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 ("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.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 ("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) >= a.inputs[id] * 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.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 === "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 ? FIGHT_POLL_MS : POLL_MS)) refresh();
}, 500);
requestAnimationFrame(loop);
})();