"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: 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 = "", tip = "") { 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} · ${HEART} ${fmt(me.hp)}/${me.max_hp}
    ${COIN} ${fmt(me.gold)}${FAVOUR} ${fmt(me.favour)}
    ${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"}`)} ${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 += ``; } 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)}
    `; } 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)}` : ""}`; } const w = S.happenings?.weather; if (w) chips += ``; if (S.upcoming) chips += ``; 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 (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(); if (id === "construction") html += S.city.construction ? siteBanner() : `
    Nothing is being built right now. Fund a building on the Town square and its site opens here.
    `; html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
    Reconnaissance on a coming threat raises its intel. Each level takes more than the last, a bigger band takes longer to read, and the sharpest news (the exact day and their camp) only comes in the last hours before they strike. The spymaster's guild adds to every point and gathers some on its own. The council & threats page shows the progress.
    `; html += skillHappenings(id); const live = new Set(S.happenings.problems.map((p) => p.kind)); const actions = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem))); 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(""); // 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"))); } // 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

    ${skillOf("prayer").icon} ${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

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

    ${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 ? "" : ` (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`); 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} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`); } 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 = 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 ("difficulty" in a) { 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 === "drill") { const s = S.favours.sergeant, boost = s.boost; extra = !s.needed ? `
    Fighters drill the townsfolk while a raid is coming. A fully drilled militia adds one defence per townsperson.
    ` : `
    ${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${boost ? ` ×2 drill and XP for the next ${fmt(boost)}` : ""}
    A bigger band needs more drilling. A fully drilled militia adds one defence per townsperson.
    `; } if (a.special === "recon") { const boost = S.favours.informant.boost; extra = `
    ${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon on the coming threat${boost ? ` ×2 recon and XP for the next ${fmt(boost)}` : ""}
    `; } const temple = S.city.buildings.temple?.level || 0; if (a.special === "labour") { const boost = S.favours.builders.boost; extra = `
    ${skillOf("construction").icon} +${me.labour_per_action} labour per action${boost ? ` ×2 labour and XP for the next ${fmt(boost)}` : ""}
    `; } 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 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; // 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]) => ` `).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}

    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

    ${COIN} ${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

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

    Favour can also hire master builders, informants and drill sergeants: 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
    ${weatherCard()}
    `; html += happeningsPanel(); if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; if (city.construction) html += siteBanner(); 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; } // The building site: labour so far, who is helping, and ways to speed it up. function siteBanner() { const city = S.city, work = city.construction, me = S.me; const spec = C.buildings[work.building]; const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); const hire = S.favours.builders; const working = me.action?.id === "labour"; const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name)); return ``; } // "Rain: farming 10% faster. Clear skies in 3h 20m." function weatherTip(w) { const next = w.next === w.kind ? `Staying ${w.name.toLowerCase()}` : w.next_name; return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${next} 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 === w.kind ? `Staying ${esc(w.name.toLowerCase())}` : `${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 firewoodPeople are cold and eating half again as much. Donate logs.
    `); for (const e of h.effects) { const hire = e.offer ? `` : ""; 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 `; } 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 ? `` : ""} ${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 ? "Help clear it below." : `${skillOf(fix.skill).name} can clear it: see the 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 · 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 ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); return `
    ${chip} ${mine ? `` : ""} ${stores ? `` : ""}
    `; }).join("")}
    `; } body += fittingRows(key, b); 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}
    `; } // Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level. function fittingRows(key, b) { const rows = C.fittings.filter((f) => f.building === key).map((f) => { const it = item(f.id), recipe = C.actions.find((a) => a.id === `make_${f.id}`); let state; if (b.fittings.includes(f.id)) { state = b.damaged ? `Paused` : `Fitted`; } else if (b.level < f.level) { state = `Needs Lv ${f.level}`; } else { const mine = S.me.bank[f.id] || 0, stores = S.city.stockpile[f.id] || 0; state = mine || stores ? `${mine ? `` : ""} ${stores ? `` : ""}` : `Construction ${recipe.level}`; } return `
    ${it.icon} ${esc(f.name)} ${esc(f.effect)} ${state}
    `; }); return rows.length ? `
    ${rows.join("")}
    ` : ""; } // --- council -------------------------------------------------------------- function pips(level) { return `${[1, 2, 3].map((i) => ``).join("")}`; } function reconText(ev) { const points = `${fmt(ev.recon)} / ${fmt(ev.recon_full)} points`; if (ev.intel >= 3) return `${points} · nothing more to learn`; const boost = S.favours.informant.boost ? ` · informant's leads: ×2 for the next ${fmt(S.favours.informant.boost)}` : ""; if (ev.recon_next <= 0) return `${points} · enough for the next level, but they're too far off to read yet${boost}`; return `${points} · ${fmt(ev.recon_next)} more for the next intel level${boost}`; } 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 { 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 campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} ${me.cls === "scout" || ev.recon ? `Reconnaissance${reconText(ev)}` : ""}
    ${ev.intel < 3 ? `

    Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.

    ` : ""}`; 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}
    `; }; 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) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)} ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads for the scouts: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)} ${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} drill points fighters put into the militia count double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
    `; } 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 ? `` : ""}
    `; }).join(""); return `

    Council requests

    ${reqs}

    Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.

    Defence

    ${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).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)} ${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. 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` : ""}
    ${FAVOUR} ${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)}
    ${p.slots_used} / ${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.install) return act("/api/install", { fitting: d.install, source: d.source }); 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(`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 = 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); })();