"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); 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).icon} ${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: your prayers have gone quiet", "bad"); if (sum.second_wind) toast("🌬️ Second Wind! Back on your feet at half health", "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 ? `

    Your bank was full, so this was sold for ${fmt(sum.sold_gold)} gold:

    ` : ""} ${spilled ? `

    Your bank was full, so this went to the city stockpile (for half the favour of giving it yourself):

    ` : ""} ${sum.stopped ? `

    Stopped: ${esc(sum.stopped)}

    ` : ""}

    `); } function openModal(html) { const modal = $("#modal"); modal.innerHTML = ``; 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 = "") { return ``; } 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} · ❤️ ${fmt(me.hp)}/${me.max_hp}
    🪙 ${fmt(me.gold)}✨ ${fmt(me.favour)}
    ${nav("town", "🏘️", "Town square", `${S.city.population}`)} ${nav("council", "📜", "Council & threats", "", raid ? `` : "")} ${nav("board", "💬", "Town board")} ${nav("roster", "👥", "Guild roster", `${S.players.length}`)} ${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)} ${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; 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 += ``; } 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() : ""); } function devbar() { return `
    Dev mode · playing as ${esc(S.uid)}
    `; } 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.id); chips += ``; } else if (S.me) { chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`; } if (S.upcoming) chips += ``; return `

    ${esc(title)}

    ${chips}`; } function eventIcon(kind) { return { raid: "⚔️", 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 (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 += combatPanel(); if (id === "prayer") html += prayerPanel(); html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
    Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
    `; const actions = C.actions.filter((a) => a.skill === id); const groups = [...new Set(actions.map((a) => a.group).filter(Boolean))]; if (groups.length > 2) { // Many tiers: one tab per tier, opening on the highest one you can work. const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); let current = tabs[id] || remember(`tab.${id}`); if (!groups.includes(current)) current = [...groups].reverse().find(open) || groups[0]; const bar = groups.map((g) => ``).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"))); } // 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]) => ``).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 ? `Herblore can brew ${kinds.length === 1 ? "one" : "some"} for ${skill === "combat" ? "fighting" : esc(skillOf(skill).name.toLowerCase())}.` : ""}
    `; } 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 ``; }).join(""); const temple = city.buildings.temple.level; return `

    Prayers · ${me.prayers.length} / ${C.max_prayers} active

    🙏 ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}
    ${cards}

    Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.

    Temple

    ${temple ? `

    Devotion ${fmt(city.devotion)} · divine ward +${city.ward} defence for the next raid (up to +${15 * temple} at temple level ${temple}).

    Offering bones builds devotion; it wards the city through the next raid, then starts again. The temple also adds ${temple} prayer point${temple > 1 ? "s" : ""} to every bone you bury.

    ` : `

    No temple yet. Build one on the Town square to offer bones for a divine ward in raids, and to get more prayer points from bones.

    `}
    `; } 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 ` : `No tool. A smith can make you one that speeds this up.`}
    `; } 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)}` : `No ${label}`}
    `; }; const foods = me.edible.map((id) => ``).join(""); const hpPct = me.hp / me.max_hp * 100; return `

    Health

    ❤️ ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour

    🙏 ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`}

    Below half health you eat from your bank automatically. ${me.edible.length ? "" : `You have no food in your bank.`} Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.

    Equipment

    ${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}

    Power ${me.power} (level + weapon + shield + half your armour) · armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take${me.cls === "warrior" ? " · as a warrior you count ×1.5 in the garrison" : ""}. Equip gear from the bank.

    `; } // 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} ${it.food === 1 ? "person" : "people"} for two days`); if (it.power) parts.push(`+${it.power} power`); 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`); return parts.join(", "); } function actionCard(a) { const me = S.me; const active = me.action?.id === a.id; const why = me.blocked[a.id]; const locked = isLocked(a); const out = Object.keys(a.outputs)[0]; const icon = out ? item(out).icon : skillOf(a.skill).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.skill === "combat") { const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); extra = `
    Win chance ${Math.round(chance * 100)}% · about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` · ${a.gold} gold` : ""}
    `; if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
    Each win weakens the coming raid by 3%, up to 30%.
    `; } if (a.special === "recon") extra = `
    +${a.recon} recon on the next event
    `; const temple = S.city.buildings.temple?.level || 0; if (a.special === "bury") extra = `
    🙏 +${a.pp + temple} prayer points
    `; if (a.special === "offer") extra = `
    ⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward
    `; 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} ${active ? `
    ` : ""} ${active && me.action.left ? `
    Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
    ` : ""}
    ${!active && why ? esc(why) : ""} ${active ? `` : ` `}
    `; } // --- 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; let slots = entries.map(([id, q]) => ` `).join(""); for (let i = entries.length; i < me.slots; i++) slots += `
    `; return `

    Bank · ${entries.length} / ${me.slots} slots · up to ${fmt(me.stack)} each

    ${slots}

    When an item doesn't fit:
    Raids never touch your bank. Overflow sent to the city earns half the favour of giving it yourself; anything the city has no use for is sold instead.

    ${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) => ``).join("") + ``; 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"}.
    ${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` : ``}
    ` : ""} ${it.food ? `
    Feeds ${it.food} townsfolk for two days in the city granary.
    ` : ""} ${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. Sell it, or give it to someone who can use it.
    `} ${others.length ? `
    Give to
    ${qtyBtns("data-give")}
    ` : ""}
    Sell
    ${qtyBtns("data-sell")}
    ${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 `
    ${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

    🪙 ${fmt(S.me.gold)}

    The treasury pays the gold part of new buildings. 1 favour per gold.

    `; } function favourPanel() { const me = S.me; return `

    Favour with the council

    ✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total

    Favour can also hire builders, pay informants and rally the militia: see .

    `; } // --- 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 more houses to grow`; const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "The largest a settlement can be"; 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
    `; if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; const work = city.construction; if (work) { const spec = C.buildings[work.building]; const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); const hire = S.favours.builders; html += ``; } html += `
    Buildings

    Each building collects its own fund. Materials in a fund are safe from raiders, and building starts on its own once the fund is complete.

    ${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 += `

    Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.

    `; return html; } function buildingCard(key, b) { const spec = C.buildings[key], city = S.city, me = S.me; const tags = { building: `Under construction`, queued: `Funded · waiting for builders`, max: `Fully built`, capped: `Next level when the settlement grows`, }; 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 ? "🪙" : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); return `
    ${chip} ${mine ? `` : ""} ${stores ? `` : ""}
    `; }).join("")}
    `; } const levelText = b.cap < spec.max ? `Lv ${b.level} · max ${b.cap} for a ${city.tier_name.toLowerCase()}` : `Lv ${b.level} / ${spec.max}`; return `
    ${spec.icon}
    ${esc(spec.name)} ${levelText} ${b.damaged ? `Damaged: repairs needed` : ""}
    ${esc(spec.effect)}
    ${body}
    `; } // --- council -------------------------------------------------------------- function pips(level) { return `${[1, 2, 3].map((i) => ``).join("")}`; } function council() { const ev = S.upcoming, city = S.city, me = S.me; let html = ""; if (!ev) { html += `

    All quiet

    No news on the roads. The spymasters will report as soon as something stirs.

    `; } else { html += `

    ${eventIcon(ev.kind)} ${ev.kind === "raid" ? "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 campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} ${me.cls === "scout" || ev.recon ? `Reconnaissance${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}` : ""}
    ${ev.intel < 3 ? `

    Better intel comes from the spymaster's guild and scouts' reconnaissance.

    ` : ""}`; if (ev.kind === "raid") { 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?.kind === "raid") 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 win = h.kind !== "raid" || h.report.title.startsWith("Victory"); html += `
    ${eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} ${h.kind === "raid" ? `

    Defence ${fmt(h.report.defence)} vs 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}
    `; }; return `

    Spend favour on the city · you have ✨ ${fmt(me.favour)}

    ${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Halves the time left on the current building (saves about ${dur(Math.round(f.builders.saves * 60) * 60)}).`)} ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")} ${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
    `; } function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; const names = { ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", 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 ? `` : ""}
    `; }).join(""); return `

    Council requests

    ${reqs}

    Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.

    Defence

    ${Object.entries(d.parts).map(([k, v]) => ``).join("")} ${d.starving ? `` : ""}
    ${names[k]}${fmt(v)}
    Hungry (×0.8)

    Garrison

    ${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet.`}

    ${me.garrison ? `` : ``}

    You can keep working while you're signed up. Victory earns the garrison favour and combat XP.

    `; } // --- board & roster ------------------------------------------------------- function board() { const msgs = S.board.map((m) => `
    ${esc(playerName(m.who))}${ago(m.at)}
    ${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. Say hello, or ask for that iron you need.

    `}

    Town ledger

    `; } 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` : ""}
    ✨ ${fmt(p.donated)} given to the city
    ${levels}

    `; }).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 * (p.cls === "warrior" ? 1.5 : 1)); 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} `; }).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)}
    ${Object.keys(p.bank).length} / ${p.slots} bank slots
    ` : ""}

    Class

    ${cls.icon} ${cls.name}

    ${esc(cls.bonus)}.

    ${esc(cls.signature)}.

    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 gear and tools from the .

    ` : ""}

    Skills

    ${skillRows}
    `; } // --- registration --------------------------------------------------------- function register() { const cards = Object.entries(C.classes).map(([id, c]) => ` `).join(""); return `

    A new city on the frontier

    You're one of a handful of adventurers settling a young city at the edge of the kingdom. Train your skills, supply the council, and keep the place standing when the bandits come. Your adventurer keeps working while you're away, for up to a day.

    ${cards}

    Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.

    `; } // --- animation ------------------------------------------------------------ function animate() { const now = serverNow(); const a = S?.me?.action; if (a) { const pct = Math.min(1, (now - a.start) / a.duration); 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.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.pray) { const on = S.me.prayers.includes(d.pray); if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "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 (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 = 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); })();