"use strict";
// Guildhall client: renders the server's state and animates progress bars
// between polls. The server is the source of truth for everything.
const POLL_MS = 10000;
const FIGHT_POLL_MS = 2000; // in a fight someone may tag in: keep everyone's view of it close
const SUMMARY_MODAL_AT = 25;
let C = null; // static content tables
let S = null; // latest state
let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item
let dollPick = null; // equipment slot shown on the character sheet
let achPick = null; // achievement chip spelled out: "section.card.entry"
let orderTab = "take"; // work orders: for you, all open, yours
let draft = null; // a work order being written
let seenOrders = null; // order ids already known, so only new ones toast
let deliveredSeen = {}; // how much of each order had arrived
let earnedSeen = {}; // gold each order had paid you for reagents
let pickedClass = null; // registration
let queueOpen = false; // the crafting queue panel under the top bar
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for
let polledAt = 0;
let tagging = false; // this page just tagged into someone's fight
// What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with
// every press, so a tap and a press straight after it don't wait on each other.
let aim = { fight: null, foe: null, ally: null };
let retriedAt = 0; // last time a fight past its kill asked again for the next one
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
let warned = null; // threat the "at the gates" alarm was raised for
let watching = null; // raid report open in the modal
const $ = (sel, root = document) => root.querySelector(sel);
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
const glyph = (name, tint) => ``;
// A player's banner: field and trim colours, with their emblem on it.
const crest = (look, h = 26) => look ? `` : "";
const titled = (p) => `${crest(p.look)} ${esc(p.name)}${p.look?.title ? ` ${esc(p.look.title)}` : ""}`;
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const fmt = (n) => Math.floor(n).toLocaleString("en-GB");
const serverNow = () => Date.now() / 1000 + skew;
const item = (id) => C.items[id] || { name: id, icon: "❔", value: 0 };
const skillOf = (id) => C.skills.find((s) => s.id === id);
const playerName = (uid) => S.players.find((p) => p.id === uid)?.name || uid;
// An open tab polls every 10s and a hidden one stops, so a quiet minute means they've gone.
const ONLINE_S = 45;
const online = (p) => p.id === S.uid || serverNow() - p.seen < ONLINE_S;
const presence = (p) => online(p) ? `` : "";
const lastSeen = (p) => online(p) ? "" : ` · seen ${ago(p.seen)}`;
function remember(key, value) {
try {
if (value === undefined) return localStorage.getItem("gh." + key);
localStorage.setItem("gh." + key, value);
} catch { /* private mode: fine */ }
return null;
}
// --- server ---------------------------------------------------------------
async function api(path, body) {
const options = body === undefined ? {} : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
};
const res = await fetch(path, options);
const data = await res.json().catch(() => ({ error: "The server didn't answer properly" }));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function act(path, body) {
try {
accept(await api(path, body));
} catch (err) {
toast(err.message, "bad");
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
polledAt = Date.now();
try {
accept(await api("/api/state"));
failures = 0;
} catch (err) {
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
} finally {
refreshing = false;
}
}
function accept(state) {
version ??= state.version;
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden && !draft) {
location.reload(); // a new build was deployed
return;
}
// Someone tagging into your fight (not you tagging into theirs) gets a toast.
const fightingWith = S?.me && !tagging ? S.me.action?.with || [] : null;
tagging = false;
S = state;
skew = state.now - Date.now() / 1000;
for (const uid of fightingWith ? state.me?.action?.with || [] : []) {
if (!fightingWith.includes(uid)) toast(`⚔️ ${playerName(uid)} joined the fight`, "good");
}
if (state.summary) showSummary(state.summary);
orderNews(state);
const ev = state.upcoming;
if (ev?.at_gates && warned !== ev.id) {
warned = ev.id;
toast(ev.kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!", "bad");
}
render();
showThreat();
}
// --- time -----------------------------------------------------------------
function dur(seconds) {
if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`;
seconds = Math.max(0, Math.round(seconds));
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600);
const m = Math.floor(seconds % 3600 / 60), s = seconds % 60;
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
function clock(t) {
return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
// Coarse on purpose: the markup stays the same between polls (see setHtml).
function ago(t) {
const s = serverNow() - t;
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// A live "3h 12m" until `t`, filled in and kept current by animate().
const countdown = (t) => ``;
// --- toasts & summaries ---------------------------------------------------
function toast(text, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.textContent = text;
$("#toasts").append(el);
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500);
const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4;
while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove();
}
function showSummary(sum) {
if (!sum.done && !sum.stopped && !sum.levels.length) return;
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
if (got.length) toast(got.join(" · "), "good");
const eaten = Object.entries(sum.eaten || {});
if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`);
if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level");
const spilled = Object.entries(sum.overflow);
if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`);
const sold = Object.entries(sum.sold || {});
if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
if (sum.skipped?.length) toast(`⏭️ Skipped: ${skippedNames(sum)}`, "bad");
for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad");
for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`);
if (sum.prayer_out) toast("🙏 Out of prayer points", "bad");
if (sum.second_wind) toast("🌬️ Second Wind! Back to half HP", "level");
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
}
// Queued makes passed over for want of materials.
function skippedNames(sum) {
return sum.skipped.map((id) => C.actions.find((x) => x.id === id).name).join(", ");
}
function awayModal(sum) {
const items = Object.entries(sum.items).map(([id, q]) => `
${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
`).join("");
const xp = Object.entries(sum.xp).map(([id, q]) => `
${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP
`;
}
case "die": {
const v = u(tgt);
if (v.side === "them") return `
${v.id === "d" ? `${v.name} flees` : v.id === "ram" ? "The ram is burning" : `${v.name} break and run`}
`;
return `
${v.uid ? (v.uid === S.uid ? "You're knocked out" : `${v.name} is knocked out`)
: v.id === "walls" ? "The walls are breached" : `${v.name} fall back`}
`;
}
}
return "";
}
// Laid out like the combat page: you and your abilities, then the rest of the garrison and the city's
// squads; across from you the raiders, the fight log and the damage meter.
function openBattle(t, closeAttr) {
const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
const b = battleScript(t);
const unit = (u) => `
${u.label}${dmgIcon(u.dmg, "attack")}
${u.every ? `
` : ""}
${casters.has(u.id) ? `` : ""}
`;
const casters = new Set(b.events.filter((e) => e[3] === "cast").map((e) => e[1])); // room for a cast bar only where one comes
const us = b.units.filter((u) => u.side === "us"), them = b.units.filter((u) => u.side === "them");
const me = us.find((u) => u.uid === S.uid);
const kit = me ? `
` : "";
}
}
// Whether a line of the replay makes the log (see drawBattle).
function battleTold(e, b) {
const [, src, tgt, kind, n, , label] = e;
const mine = (id) => b.byId[id]?.uid === S.uid;
if (kind === "cast" || kind === "stop" || kind === "die") return true;
if (kind === "miss") return mine(src);
return !!n && (kind === "crit" || mine(src) || mine(tgt) || !!C.abilities[label] && !!b.byId[src]?.uid);
}
function openModal(html) {
const modal = $("#modal");
modal.innerHTML = `
${html}
`;
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() : `
Guildhall beta
${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]")];
// Replacing a focused dropdown closes it mid-pick: hold the page until it lets go.
if (!document.activeElement?.matches("#main select")) 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 ``;
}
function skillNav(skill, me, doing) {
const busy = doing?.skill === skill.id ? `` : "";
const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1;
const flame = heat >= 0 ? `` : "";
const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `` : "";
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 ? `
${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`, "", `Combat level ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)}
${achievementsNav(me)}`;
let group = null;
for (const skill of C.skills) {
if (skill.class && skill.class !== me.cls) continue;
if (skill.group !== group) {
group = skill.group;
html += `
${group}
`;
}
html += skillNav(skill, me, doing);
}
return html + (S.dev ? devbar() : "")
+ `
Icons by Lorc, Delapouite and others at game-icons.net, CC BY 3.0
`;
}
function devbar() {
return `
Dev mode · playing as ${esc(S.uid)}
`;
}
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 += ``;
} else if (S.me) {
chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`;
}
if (S.me?.action?.left || S.me?.queue.length) {
chips += ``;
}
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 += ``;
}
const w = S.happenings?.weather;
if (w) chips += ``;
if (S.upcoming) chips += ``;
return `
${esc(title)}
${chips}${S.me ? trackedBar() : ""}${S.me && queueOpen ? queuePanel() : ""}`;
}
// The crafting queue, opened from the ⏳ chip: what's being made, what's next, and in what order.
function queuePanel() {
const me = S.me, q = me.queue, recipe = (id) => C.actions.find((x) => x.id === id);
const now = me.action?.left && recipe(me.action.id), back = me.action?.resume && recipe(me.action.resume);
const rows = q.map((e, i) => {
const a = recipe(e.id), why = me.blocked[e.id];
return `
`;
}
function queueTip() {
return `Queued: ${S.me.queue.map((q) => `${C.actions.find((x) => x.id === q.id).name} ×${fmt(q.n)}`).join(" → ")}`;
}
// Makes lined up after this one: tap ✕ to drop one.
function queueRow() {
const q = S.me.queue;
if (!q.length) return "";
return `
`;
}
// Start, or while a make runs: After Q sets what comes once it and the queue are done,
// and on that one, Skip Q drops them and starts it now.
function startButton(a, size, why) {
const making = S.me.action?.left, after = making && S.me.action.resume === a.id;
const [label, title] = !making ? ["Start", ""] : after ? ["Skip Q", "Skip queue: drop the make and queue, start this now"]
: ["After Q", "After queue: do this once the make and queue are done"];
return ``;
}
// 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 `
`;
if (id === "combat") html += combatFrame();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `
Nothing under construction.
`;
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
html += skillGear(id);
if (id === "firekeeping") return html + firekeepingPage();
html += fireBand(id);
if (id === "reconnaissance") html += `
Raises intel on the coming threat. Higher levels open as it nears.
`;
html += skillHappenings(id);
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
if (wanted.length) html += `
${ORDER_ICON()}
${wanted.length} work order${wanted.length > 1 ? "s" : ""}
`;
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) => ``).join("");
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions.filter((a) => !a.group);
if (loose.length) html += `
`;
}
// 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 += `
`;
}
// 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 ? `Start a crop to plant plot 1.`
: a.plot2 ? `Plot 1: ${name(a.id)} · Plot 2: ${name(a.plot2)}· alternating, 20% faster`
: `Plot 1: ${name(a.id)} · Plot 2: empty · focused, 30% faster`;
return `
🌱 ${status}
`;
}
// The skill's active potion, or the ones in the bank that could be drunk for it.
// A skill's potion and tool side by side, each a slot with what you could switch to beside it.
function skillGear(skill) {
const me = S.me, cards = [];
// Health potions aren't drunk ahead: a fight takes the best one in the bank by itself.
if (skill === "combat") {
const hp = me.health_potion, it = hp && item(hp);
cards.push(`
${item(hp || "health_potion_1").icon}
${it ? `${esc(it.name)}+${it.drink} HP under ${Math.round(C.potion_below * 100)}% · ${fmt(me.bank[hp])} in bank · auto`
: `No health potion`}
`);
}
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]) =>
``).join("");
cards.push(`
`;
}
// 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 `
Offerings ward the next raid. +${boost}% prayer points from bones.
`
: `
No temple yet.
`}
`;
}
// 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 `${glyph(t.glyph, t.tint)}`;
}
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 `
${e.variants.map((v, i) => {
const fit = o.variants[i].affinity;
return `
`;
}
// 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 = "") => `
`;
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
const run = a?.run;
// A dungeon run: where you are in it, and (for the leader) the button to start it, or to go in early while resting.
const lead = run?.leader === S.uid;
const stage = run ? `
${esc(run.dungeon)}${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""}
${!run.started && lead ? ``
: f && lead ? `` : ""}
` : "";
const group = run && [S.uid, ...(a.with || [])].map((uid) => uid === S.uid ? "You" : esc(playerName(uid))).join(", ");
let foe = `
${run && !run.started ? `👥 ${run.size} / ${C.dungeon_size} · ${group} ${lead ? "Start when everyone's in" : `${esc(playerName(run.leader))} starts the run`}`
: run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back`
: run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}
`;
}
// 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 `
${on ? `` : ""}
`;
};
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 `
`;
}
// 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 ``;
}).join("");
return `
${skillOf("prayer").icon} ${fmt(me.pp)}${buttons}
`;
}
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
const LAG = 0.6; // seconds the "just lost" part of an HP bar trails behind
const STOPPED = 1.2; // seconds an interrupted cast stays on its bar
// A line of the fight, terse: who, to whom, how much, with what.
function fightLine(e, f) {
const [, src, tgt, kind, n, , label] = e;
const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id));
const ab = C.abilities[label] || C.enemy_abilities[label];
const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
const hurtsMe = tgt === S.uid && src?.[0] === "e";
const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted";
switch (kind) {
case "hit": case "crit": case "tick":
return `
`;
}
}
// Refreshed on every swing and once a second, since DPS falls between hits.
const meter = panel.querySelector('[data-f="dps"]');
if (meter) {
const stale = resting && lastMeter?.start !== a.start ? lastMeter : null;
const key = resting ? `rest:${stale?.start}` : `${a.start}:${shown}:${Math.floor(Math.min(t, f.end))}`;
if (meter.dataset.key !== key) {
meter.dataset.key = key;
if (!resting) lastMeter = { start: a.start, rows: dpsMeter(f, t) };
const rows = resting ? stale?.rows : lastMeter.rows;
meter.classList.toggle("stale", resting);
meter.innerHTML = rows ? `
${resting ? "Last fight" : "Damage"}DPS · total
${rows}
` : "";
}
}
}
// 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 = "") => `${icon}${text === "" ? "" : `${text}`}`;
const hands = (two) => `${two ? "2H" : "1H"}`;
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)), "", ""];
}
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 `
${row.join("")}${req}${bonus || ""}
${it.unique ? `
✦ ${esc(it.unique)}
` : ""}`;
}
// 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 `
`;
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.drink) parts.push(`❤️ +${it.drink} in a fight`);
if (it.food) parts.push(`feeds ${it.food} for 2 days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.style) {
const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0];
parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`);
}
if (it.kit) parts.push(`+${Math.round(it.armour * C.jerkin_defence * 10) / 10} city defence per paid guard`);
else if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it));
if (it.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).name.toLowerCase()}`);
if (it.slots) parts.push(storageText(it));
if (it.stack) parts.push(storageText(it));
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
if (it.trophy) parts.push(`Trophy for stronger weapons`);
if (it.fitting) {
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
parts.push(`${C.buildings[f.building].icon} ${C.buildings[f.building].name} Lv ${f.level}: ${f.effect}${done ? " · fitted" : ""}`);
}
return parts.join(", ");
}
function actionCard(a) {
const me = S.me;
const active = me.action?.id === a.id;
const second = me.action?.plot2 === a.id; // a homesteader's second plot
const growingNow = me.action?.growing === a.id;
const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second
&& me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left;
const why = me.blocked[a.id];
const locked = isLocked(a);
// Queue it behind the make that's running (materials only have to be there when its turn comes),
// or with nothing being made, start one: whatever you were doing is what comes after.
const making = Boolean(me.action?.left);
const canQueue = !active && me.queue_why[a.id] === null;
const queueOff = making ? me.queue.length >= C.queue_max : Boolean(why);
const queueTitle = making ? (queueOff ? `Queue full (${C.queue_max})` : "Queue: make this many after the current make")
: why || "Make this many, then queue more behind it";
const queued = me.queue.map((q, i) => q.id === a.id ? i + 1 : 0).filter(Boolean);
const out = Object.keys(a.outputs)[0];
const icon = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `→` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
const chance = ([id, q, odds]) => `${item(id).icon} ${Math.round(odds * 100)}%`;
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 ? `🏰 Dungeon · ${C.group_size}–${C.dungeon_size} `
: a.together ? `👥 Group ` : "")
+ (a.personal_loot ? `🎁 Own loot` : "");
extra = `${e.boss || own ? `
${e.boss ? `Boss ` : ""}${own}
` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`;
if (a.drops) io += a.drops.map(chance).join("");
if (a.loot) {
const l = a.loot, pct = (x) => `${Math.round(x * 100)}%`;
io += `${item(l.relic).icon}`
+ `${item(l.patterns[0]).icon} ${l.patterns.length}`
+ `${item(l.unique).icon} ${pct(l.odds.unique)}`;
}
if (a.special === "sabotage") extra += `
-1% raid strength per win (max 30%)
`;
}
if (a.special === "drill") {
const s = S.favours.sergeant, boost = s.boost;
extra = !s.needed ? `
${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 ? `
`;
}
// 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) => `
⚔️ ${group.map((p) => esc(p.name)).join(", ")}
`).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) => ``).join("");
return `
${glyph(spec.glyph, spec.tint)} ${b.of ? `Statue of ${esc(playerName(b.of))}` : spec.name}${ago(b.at)}
Carved in: ${donors}
`;
}).join("");
return `
Monuments
${rows}${built}
Looks only. Gold here earns no favour: your name goes in the stone.
`;
}
// 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 `
${boost
? `` : ""}
`;
}
// 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 `
`;
}
// "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 `
${h.season.icon} ${h.season.name}, day ${h.season.day}
${w.icon} ${esc(w.name)}
${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}
`;
}
// "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(`
`;
}
// 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 ? `Paused` : `Fitted`;
} else if (b.level < f.level) {
state = `Needs Lv ${f.level}`;
} else {
const mine = S.me.bank[f.id] || 0, stores = S.city.stockpile[f.id] || 0;
state = mine || stores
? `${mine ? `` : ""}
${stores ? `` : ""}`
: `Construction ${recipe.level}`;
}
return `
${it.icon}${esc(f.name)}${esc(f.effect)}${state}
`;
});
return rows.length ? `
${rows.join("")}
` : "";
}
// --- council --------------------------------------------------------------
function pips(level) {
return `${[1, 2, 3].map((i) => ``).join("")}`;
}
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 += `
`;
}
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 `
${icon}${title}${text}
`;
};
return `
Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}
${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.`)}
`;
}
function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence;
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", jerkins: "Jerkins the guards wear", arrows: "Arrows", garrison: "Garrison" };
const full = d.posts * d.wage, owed = Math.max(0, full - d.wages);
const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0;
return `
Keep working while on guard. Victory earns favour and combat XP.
`;
}
// --- work orders ------------------------------------------------------------
const ORDER_ICON = () => glyph("scroll-unfurled", "#e3c060");
// An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough.
function canTake(o) {
if (o.status !== "open" || o.by === S.uid) return false;
if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false;
return S.me.levels[o.skill] >= o.level;
}
// Toasts for new orders you can take, and for things delivered to you. Nothing on the first load.
function orderNews(state) {
const orders = state.orders || [], uid = state.uid;
if (seenOrders) {
for (const o of orders) {
if (!seenOrders.has(o.id) && o.by !== uid && state.me && state.me.levels[o.skill] >= o.level && o.status === "open") {
toast(`📜 ${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""} · ${fmt(o.reward)} gold`, "level");
}
const was = deliveredSeen[o.id];
if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good");
const got = (o.earned || {})[uid] || 0, had = earnedSeen[o.id];
if (had !== undefined && got > had) toast(`🤝 ${item(o.item).name} · +${fmt(got - had)} gold`, "good");
}
}
seenOrders = new Set(orders.map((o) => o.id));
deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered]));
earnedSeen = Object.fromEntries(orders.map((o) => [o.id, (o.earned || {})[uid] || 0]));
}
// What one run of a recipe takes right now: a lit fire pays its log (the forge a coal too).
const runInputs = (a) => S.me.fuelled?.[a.id] || a.inputs;
const firePays = (a, id, qty) => `the ${C.fires[a.fire].name.toLowerCase()} pays ${fmt(qty)}`;
// A reagent the fire pays outright: struck through, nothing to put in.
function paidSlot(a, id, qty) {
return `
${item(id).icon}${fmt(qty)}${esc(item(id).name)}
`;
}
// Recipe slots on a crafting order. Anyone can put reagents in the pool; the crafter brings the rest.
function orderSlots(o) {
const a = C.actions.find((x) => x.id === o.action), out = a.outputs[o.item], ins = runInputs(a);
const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out);
const worth = Object.entries(ins).reduce((sum, [id, per]) => sum + item(id).value * per, 0);
return Object.entries(a.inputs).map(([id, per]) => {
const name = item(id).name, pool = o.provided[id] || 0, have = S.me.bank[id] || 0;
if (o.status !== "open") {
return `
${item(id).icon}×${fmt(per * all)}${esc(name)}
`;
}
const need = (ins[id] || 0) * left;
if (!need) return paidSlot(a, id, per * left);
const who = Object.entries(o.gave || {}).filter(([, items]) => items[id]).map(([uid, items]) => `${playerName(uid)} ${fmt(items[id])}`);
const tip = [`${name}: ${fmt(pool)} / ${fmt(need)}`, ...(need < per * left ? [firePays(a, id, per * left - need)] : []), ...who].join(" · ");
if (pool >= need) {
return `
${item(id).icon}
${fmt(need)}✓${esc(name)}
`;
}
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 `
${fmt(pool)} / ${fmt(need)}${esc(name)}
`;
}).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"
? `
`).join("");
}
// --- achievements ----------------------------------------------------------
// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
function achievementsNav(me) {
const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
}
function achievements() {
const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
const [have, need] = tally(all);
return `
`;
}
// 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) => `${icon}${value}`;
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 `
${cells.join("")}
`;
}
// 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 ``;
});
const off = p.equip[slot] && !two ? `` : "";
if (!tiles.length && !off) return `
Nothing else in your bank fits here.
`;
return `
Swap for
${tiles.join("")}${off}
`;
}
// 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 ``;
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 ``;
}
function foodPanel(p) {
const id = p.food && p.bank[p.food] ? p.food : null;
const head = id ? `
${item(id).icon}${esc(item(id).name)}${HEART} ${item(id).heal} · ${fmt(p.bank[id])} left
`) + when;
}
// The equipment panel, the same on the character sheet and the combat page (which leaves out tools).
function equipmentPanel(p, mine, tools = true) {
return `
Equipment
${paperDoll(p, mine)}
${tools ? `
Tools
${toolSlots(p)}` : ""}
`;
}
// 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 `
${it ? it.icon : s.icon}`;
};
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 `
`;
}
// --- registration ---------------------------------------------------------
function classCards() {
return Object.entries(C.classes).map(([id, c]) => `
`).join("");
}
// After the classes became trades, everyone picks once more. Nothing is lost.
function repickPage() {
return `
Pick your trade
Classes are now trades, gathering through crafting. You keep every level and item. Free, once.