"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 pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
let refreshedFor = null; // action start a completion refresh was sent for
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
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"), 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) {
location.reload(); // a new build was deployed
return;
}
S = state;
skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary);
render();
}
// --- 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) 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 `).join("");
const levels = sum.levels.map(([id, l]) => `🎉 ${esc(skillOf(id).name)} reached level ${l} `).join("");
const spilled = Object.entries(sum.overflow).map(([id, q]) => `${item(id).icon} ${fmt(q)} ${esc(item(id).name)} `).join("");
const sold = Object.entries(sum.sold || {}).map(([id, q]) => `${item(id).icon} ${fmt(q)} ${esc(item(id).name)} `).join("");
openModal(`
While you were away
You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.
${Object.keys(sum.eaten || {}).length ? `Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.
` : ""}
${items ? `` : ""}
${sum.gold ? `+${fmt(sum.gold)} gold
` : ""}
${xp ? `` : ""}
${levels ? `` : ""}
${sold ? `Bank full, sold for ${fmt(sum.sold_gold)} gold:
` : ""}
${spilled ? `Bank full, sent to the stockpile:
` : ""}
${sum.stopped ? `Stopped: ${esc(sum.stopped)}
` : ""}
Back to work
`);
}
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;
setHtml($("#side"), S.me ? sidebar() : `Guildhall
alpha ${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
setHtml($("#main"), S.me ? pageHtml() : register());
for (const [key, value] of Object.entries(kept)) {
const el = document.querySelector(`[data-keep="${key}"]`);
if (el) el.value = value;
}
if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus();
const msgs = $(".msgs");
if (msgs) msgs.scrollTop = msgs.scrollHeight;
animate();
}
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
// cancels the swipe on iOS, and polls arrive every few seconds.
function setHtml(el, html) {
if (el.dataset.html === html) return;
const scroll = el.scrollTop;
el.innerHTML = html;
el.dataset.html = html;
el.scrollTop = scroll;
}
function setMenu(open) {
$("#app").classList.toggle("open", open);
document.documentElement.classList.toggle("menu-open", open);
}
function nav(id, icon, label, meta = "", extra = "", tip = "") {
return `${icon} ${label} ${extra}${meta} `;
}
function sidebar() {
const me = S.me, cls = C.classes[me.cls];
const doing = me.action && C.actions.find((a) => a.id === me.action.id);
const raid = S.upcoming?.kind === "raid";
let html = `
Guildhall
alpha
${esc(me.name)}
${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)} /${me.max_hp}
${COIN} ${fmt(me.gold)} ${FAVOUR} ${fmt(me.favour)}
City
${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? ` ` : "", raid ? "A threat is coming" : "")}
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
You
${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}
`;
}
const busy = doing?.skill === skill.id ? ` ` : "";
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
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)}
+1h
+8h
+1d
Threat now
Fortune
Hardship
Problem
`;
}
const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" };
function topbar() {
let title = TITLES[page] || "Guildhall";
if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title;
if (page.startsWith("character:")) title = S.players.find((p) => p.id === page.slice(10))?.name || "Character";
if (!S.me) title = "Welcome to Guildhall";
let chips = "";
if (S.me?.action) {
const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id));
chips += `${skillOf(action.skill).icon} ${esc(action.name)} `;
} else if (S.me) {
chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""} `;
}
const w = S.happenings?.weather;
if (w) chips += `${w.icon} `;
if (S.upcoming) chips += `${eventIcon(S.upcoming.kind)} ${eventWhen(S.upcoming, true)} `;
return `${esc(title)} ${chips}`;
}
function eventIcon(kind) {
return { raid: "⚔️", dragon: "🐉", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
}
function eventWhen(ev, short = false) {
if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
}
function pageHtml() {
if (S.me.repick) return repickPage();
if (page.startsWith("skill:")) {
const id = page.slice(6);
const skill = skillOf(id);
if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id);
page = "town";
}
if (page.startsWith("character:")) return characterSheet(page.slice(10));
if (page === "character") return characterSheet(S.uid);
return ({ town, council, board, roster, bank }[page] || town)();
}
function itemChip(id, qty, have) {
const short = have !== undefined && have < qty;
return `${item(id).icon} ${fmt(qty)}${have !== undefined ? ` / ${fmt(have)} ` : ""} `;
}
// 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 = `
${skill.icon}
${skill.name}
Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP
${fast ? `
${cls.icon} ${cls.name} bonus ` : ""}
`;
if (id === "combat") html += fightPanel() + combatPanel();
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 += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id);
if (id === "reconnaissance") html += `Raises intel on the coming threat. Higher levels open as it nears.
`;
html += skillHappenings(id);
const live = new Set(S.happenings.problems.map((p) => p.kind));
const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem)));
// Tabs and sections keep their designed order; what's inside each is sorted by the level it needs.
const groups = [...new Set(listed.map((a) => a.group).filter(Boolean))];
const actions = [...listed].sort((a, b) => a.level - b.level);
if (groups.length > 2) {
// Many groups: one tab each.
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const bar = groups.map((g) => `${esc(g)} `).join("");
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions.filter((a) => !a.group);
if (loose.length) html += `${loose.map(actionCard).join("")}
`;
return html + `${bar}
${actions.filter((a) => a.group === current).map(actionCard).join("")}
`;
}
// 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 += `${esc(g)}
` + actions.filter((a) => a.group === g).map(actionCard).join("");
}
return html + `${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.
function potionPanel(skill) {
const me = S.me;
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (!kinds.length) return "";
const active = me.potions[skill];
const owned = kinds.filter(([id]) => me.bank[id]);
const drink = owned.map(([id, it]) => `${it.icon} ${esc(it.name)} (${fmt(me.bank[id])}) `).join("");
return `${active
? `${item(active.item).icon} ${esc(item(active.item).name)} ${esc(item(active.item).potion.text)} · ${fmt(active.charges)} uses left${me.bank[active.item] ? `, ${fmt(me.bank[active.item])} more in the bank` : ""} `
: `No potion. `}
${!active && drink ? `Drink: ${drink} ` : ""}
${!active && !drink ? `Brewed in Herblore. ` : ""}
`;
}
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 `
${p.icon}
${esc(p.name)} ${esc(p.text)}
${locked ? `Lv ${p.level}` : `${p.cost} pt${p.cost > 1 ? "s" : ""}/fight`}
`;
}).join("");
const temple = city.buildings.temple.level;
return `
Prayers · ${me.prayers.length} / ${C.max_prayers} active
${skillOf("prayer").icon} ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight (~${fmt(me.pp / cost)} fights)` : ""}
${cards}
Prayers cost points each fight. Bury bones for more.
Temple
${temple ? `
Devotion ${fmt(city.devotion)} · ward +${city.ward} defence (max +${15 * temple})
Offerings ward the next raid. +${temple} prayer point${temple > 1 ? "s" : ""} per bone buried.
`
: `
No temple yet.
`}
`;
}
function toolPanel(skill) {
const tool = S.me.equip[`tool_${skill}`];
return `${tool
? `${item(tool).icon} ${esc(item(tool).name)} ${Math.round(item(tool).speed * 100)}% faster
Unequip `
: `No tool. `}
`;
}
// What a monster is weak to and shrugs off, and how your weapon fares against it.
function weaknessLine(kind, odds) {
const table = C.weakness[kind];
if (!table) return "";
const names = (pick) => Object.entries(table).filter(([, m]) => pick(m)).map(([s]) => `${C.styles[s].name}s`).join(", ");
const weak = names((m) => m > 1), tough = names((m) => m < 1);
const yours = odds.weakness > 1 ? ` · your ${C.styles[odds.style].name} hits it hard `
: odds.weakness < 1 ? ` · your ${C.styles[odds.style].name} does half damage ` : "";
return `${weak ? `Weak to ${weak}` : ""}${weak && tough ? " · " : ""}${tough ? `shrugs off ${tough}` : ""}${yours}
`;
}
// The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel() {
const f = S.me.action?.fight;
if (!f) return "";
const side = (who, name) => ``;
return `
${side("you", S.me.name)}⚔️ ${side("foe", f.enemy)}
`;
}
function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e;
const foe = esc(enemy);
if (kind === "eat") return `You eat ${esc(item(food).name.toLowerCase())} (+${amount}) `;
if (kind === "wind") return `Second wind! Back on your feet `;
if (kind === "block") return `You block ${foe}'s blow `;
if (who === "you") return kind === "miss" ? `You miss ` : `You ${kind === "crit" ? "crit " : "hit"} for ${amount} `;
return kind === "miss" ? `${foe} misses ` : `${foe} ${kind === "crit" ? "crits " : "hits"} you for ${amount} `;
}
function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
const t = now - a.start;
{
let i = -1;
while (i + 1 < f.events.length && f.events[i + 1][0] <= t) i++;
const left = i >= 0 ? f.events[i][5] : f.enemy_hp;
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${left / f.enemy_hp * 100}%`;
}
const panel = $("[data-fight]");
if (!panel) return;
// Between kills: the next enemy is on its way, both swing timers empty.
panel.classList.toggle("waiting", t < 0);
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = last ? last[5] : f.enemy_hp;
// Your HP everywhere follows the fight as it plays out.
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); };
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; });
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; });
set("foe-hp", (el) => { el.textContent = `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
const over = t >= f.end;
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
let swung = 0;
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
set(`${who}-swing`, (el) => { el.style.width = over || t < 0 ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
}
set("log", (el) => {
const key = `${a.start}:${shown}:${over}:${t < 0 ? Math.ceil(-t * 10) : ""}`;
if (el.dataset.key === key) return;
el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over) lines.unshift(f.result === "won" ? `${esc(f.enemy)} is beaten. The next one is coming… `
: `You fall back. `);
el.innerHTML = t < 0 ? `Victory! Next enemy in ${(-t).toFixed(1)}s `
: lines.join("") || `Squaring up… `;
});
}
// How you fight with what's in your hands, and what your trade fights best with.
function styleNote(me) {
const weapon = me.equip.weapon, styleId = (weapon && item(weapon).style) || "sword", st = C.styles[styleId];
const own = C.class_style[me.cls], traits = {
sword: `${st.interval}s${me.equip.shield ? `, shield blocks ${Math.round(C.block_chance * 100)}%` : ""}`,
hammer: `${st.interval}s, double crits`,
staff: `${st.interval}s, accurate, ignores armour`,
bow: `${st.interval}s, shoots first`,
};
return `${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${traits[styleId]}.
${own === styleId ? `Trade weapon: +15% damage. ` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}
`;
}
function combatPanel() {
const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
const slot = (name, label) => {
const id = me.equip[name];
return `${id
? `${item(id).icon} ${esc(item(id).name)} ${stat(id)} Unequip `
: `No ${label} `}
`;
};
const foods = me.edible.map((id) => `${esc(item(id).name)} (heals ${item(id).heal}, ${fmt(me.bank[id])} left) `).join("");
const hpPct = me.hp / me.max_hp * 100;
return `
Health
${HEART} ${fmt(me.hp)} / ${me.max_hp} recovers ${Math.round(C.regen_per_hour * 100)}% an hour
Food in fights
${`Cheapest first ` + foods}
${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (out of points) `}` : `No prayers active. `}
Prayers
Eats below half HP, retreats when out of food.
${me.edible.length ? "" : `No food in your bank. `}
Equipment
${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}
Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% damage)
${styleNote(me)}
`;
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.food) parts.push(`feeds ${it.food} for 2 days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.style) {
const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0];
parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`);
}
if (it.armour) parts.push(`+${it.armour} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
if (it.trophy) parts.push(`Trophy for stronger weapons`);
if (it.fitting) {
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
parts.push(`${C.buildings[f.building].icon} ${C.buildings[f.building].name} Lv ${f.level}: ${f.effect}${done ? " · fitted" : ""}`);
}
return parts.join(", ");
}
function actionCard(a) {
const me = S.me;
const active = me.action?.id === a.id;
const second = me.action?.plot2 === a.id; // a homesteader's second plot
const growingNow = me.action?.growing === a.id;
const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second
&& me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left;
const why = me.blocked[a.id];
const locked = isLocked(a);
const out = Object.keys(a.outputs)[0];
const icon = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length ? `→ ` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
extra = `${HEART} ${fmt(e.hp)} HP · hits up to ${o.their_max} every ${e.interval}s${e.boss ? ` · Boss ` : ""}
You hit ${pct(o.hit)} (max ${o.max_hit}) · it hits ${pct(o.their_hit)}
~${dur(o.kill_time)} a kill · ${Math.round(o.damage)} damage taken${a.gold ? ` · ${a.gold} gold` : ""}
${weaknessLine(e.kind, o)}`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}% `).join("");
if (a.special === "sabotage") extra += `-3% raid strength per win (max 30%)
`;
}
if (a.special === "drill") {
const s = S.favours.sergeant, boost = s.boost;
extra = !s.needed ? `Only while a raid is coming.
`
: `${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
${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 ? `Fully scouted: XP only.
`
: `${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon
${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 = `${skillOf("construction").icon} +${me.labour_per_action} labour per action
`;
}
if (a.special === "bury") extra = `${skillOf("prayer").icon} +${a.pp + temple} prayer points
`;
if (a.special === "offer") extra = `${C.buildings.temple.icon} +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion
`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
if (made.length) extra += `${made.join(" · ")}
`;
return `
${icon}
${esc(a.name)}
Level ${a.level} · ${dur(time)} · ${a.xp} XP
${io ? `
${io}
` : ""}
${extra}
${me.action?.plot2 && (active || second) ? `
Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
` : ""}
${growingNow && !a.enemy ? `
` : ""}
${active && a.enemy ? `
⚔️ Fighting
` : ""}
${active && me.action.left ? `
Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
` : ""}
`;
}
// --- bank -----------------------------------------------------------------
function bank() {
const me = S.me;
const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0]));
if (selected && !me.bank[selected]) selected = null;
// An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
const stacks = entries.flatMap(([id, q]) => {
const parts = [];
for (let left = q; left > 0; left -= me.stack) parts.push([id, Math.min(left, me.stack)]);
return parts;
});
let slots = stacks.map(([id, q]) => `
${fmt(q)} ${item(id).icon}${esc(item(id).name)}
`).join("");
for (let i = stacks.length; i < me.slots; i++) slots += `
`;
return `
Bank · ${me.slots_used} / ${me.slots} slots · ${fmt(me.stack)} per stack
${slots}
Bank full:
Send it to the city
Stop working
Overflow earns half favour; what the city can't use is sold.
${favourPanel()}
${selected ? itemPanel(selected) : goldPanel()}
`;
}
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) => `${q} `).join("")
+ `All (${fmt(have)}) `;
return `
${it.icon}
${esc(it.name)}
You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
${it.slot ? equipRow(id, it) : ""}
${it.potion ? `
${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.
Drink
${S.me.potions[it.potion.skill] && S.me.potions[it.potion.skill].item !== id ? `
Replaces your ${esc(item(S.me.potions[it.potion.skill].item).name.toLowerCase())} (${S.me.potions[it.potion.skill].charges} uses left)
` : ""}
` : ""}
${it.heal ? `
Heals ${it.heal} in fights.
${S.me.food === id ? `
Your fighting food ` : `
Eat this first in fights `}
` : ""}
${it.food ? `
Feeds ${it.food} townsfolk for 2 days.
` : ""}
${C.city_uses[id].length
? `
Donate to the city · +${it.value} favour each
${qtyBtns("data-donate")}
Used for ${esc(listing(C.city_uses[id]))}.
`
: `
The city has no use for this.
`}
${others.length ? `
Give to ${others.map((p) => `${esc(p.name)} `).join("")}
${qtyBtns("data-give")}
` : ""}
Sell
${qtyBtns("data-sell")}
Close
${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 `${equipLabel(it)}
${skill ? `
Needs ${esc(skillOf(skill).name.toLowerCase())} level ${level}
` : ""}
`;
}
function equipLabel(it) {
if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`;
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`;
}
function goldPanel() {
return `Gold
${COIN} ${fmt(S.me.gold)}
1 favour per gold.
`;
}
function favourPanel() {
const me = S.me;
return `Favour with the council
${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total
+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)
More to spend on in Council & threats .
`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
const growth = city.starving ? `Hungry: ${city.growth} a day `
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 ? `Overcrowded: ${city.growth} a day `
: `Full: build houses `;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier";
let html = `
Settlement
${city.tier_name}
${next}
Population
${city.population} / ${city.housing} homes
${growth}
Food
${city.food_days ?? 0} days
${fmt(city.food)} meals in the granary
Treasury
${fmt(city.treasury)}
Day ${city.day}
Defence
${fmt(city.defence.total)}
${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled
${weatherCard()}
`;
html += happeningsPanel();
if (city.starving) html += `🍞
Granary empty. People are leaving, defence is down. Donate food.
`;
if (city.merchant_until > serverNow()) html += `🐪
Merchant in town. Sell prices +50% for ${countdown(city.merchant_until)}.
`;
if (city.construction) html += siteBanner();
html += `Buildings
Funds are safe from raiders. Building starts once a fund is full.
${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}
`;
html += `City stockpile
`;
const stock = Object.entries(city.stockpile);
html += stock.length ? `
${stock.map(([id, q]) => itemChip(id, q)).join("")}
` : `
Empty.
`;
html += `
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 `${skillOf("construction").icon}
${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}
${fmt(work.labour)} / ${fmt(work.needed)} labour
${working ? `Working ` : `Work on it `}
${hire.why ? "" : `Master builder ${FAVOUR} ${fmt(hire.cost)} `}
${boostBar(work.labour, work.needed, hire.boost, "Master builder: this stretch builds twice as fast")}
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}Townsfolk +${fmt(city.townsfolk_labour)}/h.${queued ? ` Next: ${queued}.` : ""}
`;
}
// "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)}
`;
}
// 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(`${h.weather.icon} ${esc(h.weather.name)} ${esc(h.weather.text)}
`);
if (h.cold) rows.push(`🥶 Out of firewood City eats 50% more. Donate logs.
`);
for (const e of h.effects) {
const hire = e.offer ? `Hire for ${fmt(e.cost)} gold ` : "";
rows.push(`${e.icon} ${esc(e.name)}
${esc(e.text)} · ${countdown(e.until)} left ${hire}
`);
}
for (const p of h.problems) {
let help = "";
if (p.action) {
const a = C.actions.find((x) => x.id === p.action);
help = `${fmt(p.work)} / ${fmt(p.needed)} done
${esc(a.name)} `;
}
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 `${costChip(item(id).icon, item(id).name, need, need - missing)}
${mine ? `Give ${fmt(mine)} ` : ""}
${stores ? `Stockpile ${fmt(stores)} ` : ""}
`;
}).join("");
rows.push(`${p.icon} ${esc(p.name)}
${esc(p.text)}${p.until ? ` · ${countdown(p.until)} left` : ""} ${gifts} ${help}
`);
}
if (!rows.length) return "";
return `Happenings ${rows.join("")}
`;
}
// What's speeding up, slowing down or blocking this skill right now.
function skillHappenings(skill) {
const h = S.happenings, lines = [];
const note = (icon, name, text) => lines.push(`${icon} ${esc(name)} ${esc(text)} `);
const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block);
if (touches(h.weather)) note(h.weather.icon, h.weather.name, h.weather.text);
for (const e of h.effects) if (touches(e)) note(e.icon, e.name, e.text);
for (const p of h.problems) {
if (!C.problems[p.kind] || !touches(C.problems[p.kind])) continue;
const fix = p.action && C.actions.find((x) => x.id === p.action);
note(p.icon, p.name, !fix ? p.text : fix.skill === skill ? "Clear it below." : `${skillOf(fix.skill).name} clears it (Town square).`);
}
return lines.length ? `${lines.join("")}
` : "";
}
function buildingCard(key, b) {
const spec = C.buildings[key], city = S.city, me = S.me;
const tags = {
building: `Under construction `,
queued: `Funded · queued `,
max: `Fully built `,
capped: `Needs a bigger settlement `,
};
let body = tags[b.status] ? `${tags[b.status]}
` : "";
if (b.status === "funding" || b.status === "queued") {
body += `${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 `
${chip}
${mine ? `Give ${fmt(mine)} ` : ""}
${stores ? `${gold ? "Treasury" : "Stockpile"} ${fmt(stores)} ` : ""}
`;
}).join("")}
`;
}
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 `
${spec.icon}
${esc(spec.name)} ${levelText} ${b.damaged ? `Damaged ` : ""}
${esc(spec.effect)}
${body}
`;
}
// 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 ? `Fit ` : ""}
${stores ? `Fit from stockpile ` : ""}`
: `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 += `All quiet No news on the roads.
`;
} else {
const threat = ["raid", "dragon"].includes(ev.kind);
html += `
${eventIcon(ev.kind)} ${threat ? "Threat" : "News"} · intel ${pips(ev.intel)}
${esc(ev.headline)}
Expected ${eventWhen(ev)}
${ev.range ? `Strength ${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`} ` : ""}
${ev.trait ? `Scouts say ${esc(ev.trait)} ` : ""}
${ev.camp ? `Their camp Found · sabotage it in Combat${ev.sabotage ? ` (-${ev.sabotage}% so far)` : ""} ` : ""}
Reconnaissance ${reconText(ev)}
${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 += `
Our defence ${fmt(city.defence.total)}
vs Raiders ${strength}
`;
}
html += `
`;
}
html += favourSpends();
if (ev && ["raid", "dragon"].includes(ev.kind)) html += raidPrep(ev);
html += `Past events
`;
if (!S.history.length) html += `Nothing has happened yet.
`;
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 += `
${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)}
${fight ? `
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(", ")}` : ""}
` : ""}
${h.report.lines.map((l) => `
${esc(l)}
`).join("")}
`;
}
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}
${FAVOUR} ${fmt(o.cost)}
`;
};
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 `${item(r.item).icon} ${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)}
${mine ? `
Give ${fmt(mine)} ` : "
"}
`;
}).join("");
return `
Council requests ${reqs}
Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.
Defence
${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => `${names[k]} ${fmt(v)} `).join("")}
${d.starving ? `Hungry (×0.8) ` : ""}
Garrison
${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody yet. `}
${me.garrison ? `
Leave the garrison `
: `
Stand guard (+${me.power}) `}
Keep working while on guard. Victory earns favour and combat XP.
`;
}
// --- board & roster -------------------------------------------------------
function board() {
const msgs = S.board.map((m) => `${esc(playerName(m.who))} ${ago(m.at)}
${m.who === S.uid && m.id ? `
✕ ` : ""}
${esc(m.text)}
`).join("");
const ledger = [...S.ledger].reverse().map((l) => `${esc(l.text)}${ago(l.at)} `).join("");
return `
Town board
${msgs || `
No messages yet.
`}
Town ledger ${ledger || `Nothing recorded yet. `}
`;
}
function roster() {
return `${S.players.map((p) => {
const cls = C.classes[p.cls];
const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `
${s.icon} ${s.name} ${p.levels[s.id]}
`).join("");
return `
${cls.icon}
${esc(p.name)} ${cls.name} · total level ${p.total}
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle `}${p.garrison ? ` · On guard ` : ""}
${FAVOUR} ${fmt(p.donated)} given to the city
${levels}
Character sheet
`;
}).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 `No such adventurer.
`;
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 gearSlot = (slot, label, empty) => {
const id = p.equip[slot];
const it = id && item(id);
const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster`
: [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
return `
${label}
${id ? `
${it.icon} ${esc(it.name)} ${stat}
${mine ? `✕ ` : ""}
`
: `
${empty}
`}
`;
};
const skillRows = skills.map((s) => {
const level = p.levels[s.id], xp = p.xp[s.id] || 0;
const floor = C.xp_table[level], ceil = C.xp_table[level + 1];
const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100;
const tool = p.equip[`tool_${s.id}`];
const marks = (cls.fast.includes(s.id) ? `${cls.icon} ` : "")
+ (tool ? `${item(tool).icon} ` : "");
return `<${mine ? `button data-nav="skill:${s.id}"` : "div"} class="sheet-skill">
${s.icon}
${s.name} ${marks} ${level}
${mine ? "button" : "div"}>`;
}).join("");
return `
${cls.icon}
${esc(p.name)}
${cls.name} · total level ${total} · ${days === 1 ? "first day" : `${days} days`} in the city
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle `}${p.garrison ? ` · On guard ` : ""}
Health
${fmt(p.hp)} / ${p.max_hp}
Combat power
${p.power}
${guard} in the garrison
${mine ? `
Prayer points
${fmt(p.pp)}
${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}
` : ""}
Armour
${p.armour}
${Math.round(p.reduction * 100)}% less damage
Given to the city
${fmt(p.donated)}
${fmt(p.favour)} favour to spend
${mine ? `
Purse
${fmt(p.gold)}
${p.slots_used} / ${p.slots} bank slots
` : ""}
Class
${cls.icon} ${cls.name}
${esc(cls.bonus)}.
${esc(cls.masterwork)}.
${esc(cls.signature)}.
${esc(cls.combat)}.
Equipment
${Object.entries(SLOT_NAMES).map(([slot, label]) => gearSlot(slot, label, "Nothing")).join("")}
Tools
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}
${mine ? `
Equip from the bank .
` : ""}
`;
}
// --- registration ---------------------------------------------------------
function classCards() {
return Object.entries(C.classes).map(([id, c]) => `
${c.icon} ${c.name} ${esc(c.bonus)}.
${esc(c.masterwork)}.
${esc(c.signature)}.
${esc(c.combat)}.
`).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.
${classCards()}
Take up this trade
Any class can train any skill.
`;
}
function register() {
const cards = classCards();
return `
A new city on the frontier
Settle a frontier city together. Train, build, and hold off the bandits.
You keep working while away, for up to a day.
${cards}
Enter the city
Any class can train any skill.
`;
}
// --- animation ------------------------------------------------------------
function animate() {
const now = serverNow();
const a = S?.me?.action;
animateFight(now);
if (a) {
const pct = Math.min(1, (now - a.start) / a.duration);
// In a fight the bar is the enemy's health (set by animateFight), not a timer.
if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start;
setTimeout(refresh, 150);
}
}
for (const el of document.querySelectorAll("[data-span]")) {
const [start, end] = el.dataset.span.split(",").map(Number);
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
}
function loop() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });
if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value });
if (d.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip });
if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" });
if (d.install) return act("/api/install", { fitting: d.install, source: d.source });
if ("plot" in d) return act("/api/plot", { id: d.plot || null });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.unpost) {
if (!confirm("Delete this message?")) return;
return act("/api/board/delete", { id: Number(d.unpost) });
}
if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source });
if ("hire" in d) return act("/api/hire", {});
if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen });
if (d.pray) {
const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Max ${C.max_prayers} prayers active`, "bad");
return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] });
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; }
if ("repick" in d) return act("/api/class", { cls: pickedClass });
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {});
});
document.addEventListener("change", (e) => {
if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null });
});
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
document.addEventListener("submit", async (e) => {
const form = e.target.closest("[data-form]");
if (!form) return;
e.preventDefault();
const field = form.querySelector("[data-keep]");
const value = field.value.trim();
if (form.dataset.form === "register") {
if (!pickedClass) return toast("Pick a class first", "bad");
return act("/api/register", { name: value, cls: pickedClass });
}
if (form.dataset.form === "board") {
if (!value) return;
field.value = "";
return act("/api/board", { text: value });
}
if (form.dataset.form === "gold") {
field.value = "";
return act("/api/donate", { item: "gold", qty: Number(value) });
}
if (form.dataset.form === "dev-user" && value) {
document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`;
field.value = "";
return refresh();
}
});
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
(function swipeMenu() {
const phone = matchMedia("(max-width: 900px)");
let drag = null;
const reset = () => {
$("#side").style.transform = $("#side").style.transition = "";
$("#scrim").style.opacity = "";
$("#app").classList.remove("dragging");
};
document.addEventListener("touchstart", (e) => {
if (drag) release();
if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return;
const t = e.touches[0];
drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target };
drag.target.addEventListener("touchmove", move, { passive: false });
drag.target.addEventListener("touchend", finish);
drag.target.addEventListener("touchcancel", finish);
}, { passive: true });
function release() {
drag.target.removeEventListener("touchmove", move);
drag.target.removeEventListener("touchend", finish);
drag.target.removeEventListener("touchcancel", finish);
if (drag.axis === "x") reset();
drag = null;
}
function move(e) {
if (!drag) return;
const t = e.touches[0];
const dx = t.clientX - drag.x, dy = t.clientY - drag.y;
if (!drag.axis) {
if (Math.hypot(dx, dy) < 10) return;
const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0);
drag.axis = sideways ? "x" : "y";
if (!sideways) return;
drag.width = $("#side").offsetWidth;
$("#side").style.transition = "none";
$("#app").classList.add("dragging");
}
if (drag.axis !== "x") return;
e.preventDefault();
drag.dx = dx;
// 0 is fully open, -width fully shut.
const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width));
$("#side").style.transform = `translateX(${offset}px)`;
$("#scrim").style.opacity = String(1 + offset / drag.width);
}
function finish(e) {
if (!drag) return;
const swiped = drag.axis === "x";
const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms
const open = drag.open
? !(drag.dx < -drag.width * 0.35 || speed < -0.4)
: drag.dx > drag.width * 0.35 || speed > 0.4;
// Hand back to the stylesheet; its transition carries on from here.
release();
if (swiped) setMenu(open);
}
})();
(async function boot() {
try {
C = iconise(await api("/api/content"));
await refresh();
} catch (err) {
$("#main").innerHTML = `Couldn't reach the city: ${esc(err.message)}
`;
return;
}
setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS);
requestAnimationFrame(loop);
})();