"use strict";
// Guildhall client: renders the server's state and animates progress bars
// between polls. The server is the source of truth for everything.
const POLL_MS = 10000;
const SUMMARY_MODAL_AT = 25;
let C = null; // static content tables
let S = null; // latest state
let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item
let dollPick = null; // equipment slot shown on the character sheet
let 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 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) => ``;
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];
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;
function remember(key, value) {
try {
if (value === undefined) return localStorage.getItem("gh." + key);
localStorage.setItem("gh." + key, value);
} catch { /* private mode: fine */ }
return null;
}
// --- server ---------------------------------------------------------------
async function api(path, body) {
const options = body === undefined ? {} : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
};
const res = await fetch(path, options);
const data = await res.json().catch(() => ({ error: "The server didn't answer properly" }));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function act(path, body) {
try {
accept(await api(path, body));
} catch (err) {
toast(err.message, "bad");
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
accept(await api("/api/state"));
failures = 0;
} catch (err) {
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
} finally {
refreshing = false;
}
}
function accept(state) {
version ??= state.version;
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden && !draft) {
location.reload(); // a new build was deployed
return;
}
S = state;
skew = state.now - Date.now() / 1000;
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");
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]) => `
${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
Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.
` : ""}
${items ? `
${items}
` : ""}
${sum.gold ? `
+${fmt(sum.gold)} gold
` : ""}
${xp ? `
${xp}
` : ""}
${levels ? `
${levels}
` : ""}
${sold ? `
Bank full, sold for ${fmt(sum.sold_gold)} gold:
${sold}
` : ""}
${spilled ? `
Bank full, sent to the stockpile:
${spilled}
` : ""}
${sum.stopped ? `
Stopped: ${esc(sum.stopped)}
` : ""}
`);
}
// 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(`
${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}
Our defence${fmt(r.defence)}
${foe}${fmt(r.attack)}
${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}
`;
modal.hidden = false;
}
// --- rendering ------------------------------------------------------------
function render() {
const kept = {};
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep;
setHtml($("#side"), S.me ? sidebar() : `
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]")];
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`)}`;
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", 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)}` : ""}`;
}
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() : ""}`;
}
// 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 `
${inputChips(a)}
${go ? `` : ""}
`;
}
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, 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 `${item(id).icon} ${have !== undefined ? `${fmt(have)} / ${fmt(qty)}` : fmt(qty)}`;
}
// 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 ? `${item(id).icon} ${fmt(have)} / ${q} ${need}`
: `${item(id).icon} ${q}`;
}).join("");
}
// Progress towards a building cost: "have / need".
function costChip(icon, name, need, have) {
return `${icon} ${fmt(have)} / ${fmt(need)}`;
}
// --- 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 = `
`;
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 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 = [];
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. +${temple} prayer point${temple > 1 ? "s" : ""} per bone buried.
`
: `
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, your prayers (switch them mid-fight) and your
// gear (swap it mid-fight). Right: what you're fighting, what it attacks with, what it's weak to and
// resists (your weapon's type outlined), and the fight as it plays out.
function combatFrame() {
const me = S.me, a = me.action, f = a?.fight;
const encounter = f && C.actions.find((x) => x.id === a.id);
const bars = (who) => `
`;
}
// 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 `
${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 ? `
`;
}
// --- 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 `
Feeds the granary and armoury. Raiders can loot it.
`;
return html;
}
// Progress towards a goal favour can boost. Favour doubles the next `boost` points players put
// in, so the striped stretch behind the bar (twice the boost) is where work counts double.
function boostBar(done, total, boost, tip) {
const pct = (x) => Math.min(100, x / total * 100);
return `
${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: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" };
const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0;
return `
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]));
}
// 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 `
${item(id).icon}×${fmt(per * all)}
`;
}
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 `
${item(id).icon}
${fmt(need)}✓
`;
}
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)}
`;
}).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"
? `
`;
}
// 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 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
`);
}
// 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.