"use strict"; // Guildhall client: renders the server's state and animates progress bars // between polls. The server is the source of truth for everything. const POLL_MS = 10000; const SUMMARY_MODAL_AT = 25; let C = null; // static content tables let S = null; // latest state let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let dollPick = null; // equipment slot shown on the character sheet let pickedClass = null; // registration const tabs = {}; // skill -> selected recipe tab let refreshing = false; let refreshedFor = null; // action (start and length) a completion refresh was sent for let retriedAt = 0; // last time a fight past its kill asked again for the next one let version = null; // server build this page was loaded from let failures = 0; // polls in a row that didn't reach the server let warned = null; // threat the "at the gates" alarm was raised for let watching = null; // raid report open in the modal const $ = (sel, root = document) => root.querySelector(sel); // Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material. const glyph = (name, tint) => ``; const COIN = glyph("two-coins", "#e0b04a"), 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); const ev = state.upcoming; if (ev?.at_gates && warned !== ev.id) { warned = ev.id; toast(ev.kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!", "bad"); } render(); showThreat(); } // --- time ----------------------------------------------------------------- function dur(seconds) { if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`; seconds = Math.max(0, Math.round(seconds)); const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600); const m = Math.floor(seconds % 3600 / 60), s = seconds % 60; if (d) return `${d}d ${h}h`; if (h) return `${h}h ${m}m`; if (m) return `${m}m ${s}s`; return `${s}s`; } function clock(t) { return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); } // Coarse on purpose: the markup stays the same between polls (see setHtml). function ago(t) { const s = serverNow() - t; if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } // A live "3h 12m" until `t`, filled in and kept current by animate(). const countdown = (t) => ``; // --- toasts & summaries --------------------------------------------------- function toast(text, kind = "") { const el = document.createElement("div"); el.className = "toast " + kind; el.textContent = text; $("#toasts").append(el); setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500); const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4; while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove(); } function showSummary(sum) { if (!sum.done && !sum.stopped) return; if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum); const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); if (got.length) toast(got.join(" · "), "good"); const eaten = Object.entries(sum.eaten || {}); if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`); if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level"); const spilled = Object.entries(sum.overflow); if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`); const sold = Object.entries(sum.sold || {}); if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad"); for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`); if (sum.prayer_out) toast("🙏 Out of prayer points", "bad"); if (sum.second_wind) toast("🌬️ Second Wind! Back to half HP", "level"); if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`); } function awayModal(sum) { const items = Object.entries(sum.items).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); const xp = Object.entries(sum.xp).map(([id, q]) => `
  • ${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP
  • `).join(""); const levels = sum.levels.map(([id, l]) => `
  • 🎉 ${esc(skillOf(id).name)} reached level ${l}
  • `).join(""); const spilled = Object.entries(sum.overflow).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); const sold = Object.entries(sum.sold || {}).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); openModal(`

    While you were away

    You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.

    ${Object.keys(sum.eaten || {}).length ? `

    Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.

    ` : ""} ${items ? `` : ""} ${sum.gold ? `

    +${fmt(sum.gold)} gold

    ` : ""} ${xp ? `` : ""} ${levels ? `` : ""} ${sold ? `

    Bank full, sold for ${fmt(sum.sold_gold)} gold:

    ` : ""} ${spilled ? `

    Bank full, sent to the stockpile:

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

    Stopped: ${esc(sum.stopped)}

    ` : ""}

    `); } // A raid or dragon attack, played out for everyone once: the clash, then the report. function showThreat() { const t = S.threat_report; if (!t || watching === t.id || !$("#modal").hidden) return; watching = t.id; const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory"); const foe = dragon ? "The dragon" : "Raiders"; openModal(`

    ${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}

    Our defence ${fmt(r.defence)}
    ${foe} ${fmt(r.attack)}

    `); playBattle(t.id, r.defence, r.attack, won, dragon); } // Pure theatre: the outcome is already decided. The loser's bar runs out; the winner's ends // as high as the gap between the two sides. function playBattle(id, us, them, won, dragon) { const steps = 10, every = 900; let seed = id * 9301 + 49297; const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280; const weights = Array.from({ length: steps }, () => 0.5 + rand()); const total = weights.reduce((a, b) => a + b, 0); const loserEnd = 0, winnerEnd = Math.max(0.1, 1 - Math.min(us, them) / Math.max(us, them, 1)); const ours = won ? winnerEnd : loserEnd, theirs = won ? loserEnd : winnerEnd; const lines = { good: dragon ? ["Arrows find the dragon's wing", "The ballista bolt strikes home", "Spears drive it back from the walls"] : ["Arrows rain down on the raiders", "The walls hold", "The militia drives them back", "A charge breaks their line"], bad: dragon ? ["Fire sweeps the rooftops", "Its claws tear at the gate", "The defenders scatter from its breath"] : ["Raiders scale the walls", "The gate shudders under the ram", "A volley cuts down defenders"], }; let step = 0, done = 0, said = ""; const finish = () => { clearInterval(timer); const box = $(".battle"); if (!box) return; $('[data-battle="us"]', box).style.width = `${ours * 100}%`; $('[data-battle="them"]', box).style.width = `${theirs * 100}%`; $('[data-battle="report"]', box).hidden = false; $("[data-battle-skip]", box).hidden = true; $("[data-raid-seen]", box).hidden = false; }; const timer = setInterval(() => { const box = $(".battle"); if (!box) return clearInterval(timer); if (step >= steps) return finish(); done += weights[step] / total; step++; $('[data-battle="us"]', box).style.width = `${(1 - (1 - ours) * done) * 100}%`; $('[data-battle="them"]', box).style.width = `${(1 - (1 - theirs) * done) * 100}%`; // Each clash goes the way of the side that ends up ahead, now and then the other. const ahead = rand() < 0.7 ? won : !won; const pool = (ahead ? lines.good : lines.bad).filter((l) => l !== said); const log = $('[data-battle="log"]', box); said = pool[Math.floor(rand() * pool.length)]; log.insertAdjacentHTML("afterbegin", `
  • ${said}
  • `); while (log.children.length > 4) log.lastElementChild.remove(); }, every); playBattle.finish = finish; } 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.growing || 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 (S.me.repick) return repickPage(); if (page.startsWith("skill:")) { const id = page.slice(6); const skill = skillOf(id); if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id); page = "town"; } if (page.startsWith("character:")) return characterSheet(page.slice(10)); if (page === "character") return characterSheet(S.uid); return ({ town, council, board, roster, bank }[page] || town)(); } function itemChip(id, qty, have) { const short = have !== undefined && have < qty; return `${item(id).icon} ${fmt(qty)}${have !== undefined ? ` / ${fmt(have)}` : ""}`; } // Progress towards a building cost: "have / need". function costChip(icon, name, need, have) { return `${icon} ${fmt(have)} / ${fmt(need)}`; } // --- skill pages ---------------------------------------------------------- function skillPage(id) { const me = S.me, skill = skillOf(id), level = me.levels[id], xp = me.xp[id]; const floor = C.xp_table[level], ceil = C.xp_table[level + 1]; const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100; const cls = C.classes[me.cls]; const fast = cls.fast.includes(id); let html = `
    ${skill.icon}

    ${skill.name}

    Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP
    ${fast ? `${cls.icon} ${cls.name} bonus` : ""}
    `; if (id === "combat") html += combatFrame(); if (id === "prayer") html += prayerPanel(); if (id === "construction") html += S.city.construction ? siteBanner() : `
    Nothing under construction.
    `; if (id === "farming" && me.cls === "homesteader") html += plotsPanel(); html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
    Raises intel on the coming threat. Higher levels open as it nears.
    `; html += skillHappenings(id); const live = new Set(S.happenings.problems.map((p) => p.kind)); const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem))); // Tabs and sections keep their designed order; what's inside each is sorted by the level it needs. const groups = [...new Set(listed.map((a) => a.group).filter(Boolean))]; const actions = [...listed].sort((a, b) => a.level - b.level); if (groups.length > 2) { // Many groups: one tab each. const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); let current = tabs[id] || remember(`tab.${id}`); // Tier ladders open on the highest tier you can work; categories open on the first. const ladder = ["smithing", "leatherworking", "tailoring", "herblore", "combat"].includes(id); if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0]; const bar = groups.map((g) => ``).join(""); // Ungrouped actions (the building site) stay above the tabs. const loose = actions.filter((a) => !a.group); if (loose.length) html += `
    ${loose.map(actionCard).join("")}
    `; return html + `
    ${bar}
    ${actions.filter((a) => a.group === current).map(actionCard).join("")}
    `; } // A few groups: each under its own heading, in the order they first appear. let cards = actions.filter((a) => !a.group).map(actionCard).join(""); for (const g of groups) { cards += `
    ${esc(g)}
    ` + actions.filter((a) => a.group === g).map(actionCard).join(""); } return html + `
    ${cards}
    `; } // Locked: missing a level, class or building, as opposed to just out of materials. function isLocked(a) { const why = S.me.blocked[a.id]; return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only"))); } // A homesteader's two plots: one crop 30% faster, or two crops sharing a 20% faster pace. function plotsPanel() { const a = S.me.action, farming = a && C.actions.find((x) => x.id === a.id)?.skill === "farming"; const name = (id) => esc(C.actions.find((x) => x.id === id).name); const status = !farming ? `Start a crop to plant plot 1.` : a.plot2 ? `Plot 1: ${name(a.id)} · Plot 2: ${name(a.plot2)} · alternating, 20% faster` : `Plot 1: ${name(a.id)} · Plot 2: empty · focused, 30% faster`; return `
    🌱 ${status}
    `; } // The skill's active potion, or the ones in the bank that could be drunk for it. function potionPanel(skill) { const me = S.me; const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill); if (!kinds.length) return ""; const active = me.potions[skill]; const owned = kinds.filter(([id]) => me.bank[id]); const drink = owned.map(([id, it]) => ``).join(""); return `
    ${active ? `${item(active.item).icon} ${esc(item(active.item).name)} ${esc(item(active.item).potion.text)} · ${fmt(active.charges)} uses left${me.bank[active.item] ? `, ${fmt(me.bank[active.item])} more in the bank` : ""}` : `No potion.`} ${!active && drink ? `Drink: ${drink}` : ""} ${!active && !drink ? `Brewed in Herblore.` : ""}
    `; } function prayerPanel() { const me = S.me, city = S.city, level = me.levels.prayer; const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0); const cards = C.prayers.map((p) => { const on = me.prayers.includes(p.id), locked = level < p.level; const full = !on && me.prayers.length >= C.max_prayers; return ``; }).join(""); const temple = city.buildings.temple.level; return `

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

    ${skillOf("prayer").icon} ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight (~${fmt(me.pp / cost)} fights)` : ""}
    ${cards}

    Prayers cost points each fight. Bury bones for more.

    Temple

    ${temple ? `

    Devotion ${fmt(city.devotion)} · ward +${city.ward} defence (max +${15 * temple})

    Offerings ward the next raid. +${temple} prayer point${temple > 1 ? "s" : ""} per bone buried.

    ` : `

    No temple yet.

    `}
    `; } function toolPanel(skill) { const tool = S.me.equip[`tool_${skill}`]; return `
    ${tool ? `${item(tool).icon} ${esc(item(tool).name)} ${Math.round(item(tool).speed * 100)}% faster ` : `No tool.`}
    `; } // A damage type's icon: what an enemy attacks with, or (tinted) what it's vulnerable to or resists. function dmgIcon(type, how = "") { const t = C.damage_types[type], name = t.name.toLowerCase(); const tip = how === "vuln" ? `Vulnerable to ${name} (+${Math.round((C.vulnerable - 1) * 100)}%)` : how === "resist" ? `Resists ${name} (-${Math.round((1 - C.resistant) * 100)}%)` : how === "attack" ? `Attacks with ${name}` : `${t.name} damage`; return `${glyph(t.glyph, t.tint)}`; } function resistIcons(v) { return v.vulnerable.map((k) => dmgIcon(k, "vuln")).join("") + v.resists.map((k) => dmgIcon(k, "resist")).join(""); } // Who turns up in an encounter: each variant, what it attacks with, and how it takes each damage type. // A variant your weapon is good against is green, one that resists it red. function variantRows(e, o) { return `
    ${e.variants.map((v, i) => { const fit = o.variants[i].affinity; return `
    ${v.attack.map((k) => dmgIcon(k, "attack")).join("")} ${esc(v.name)}${v.archetype === "elite" ? ` · rare` : ""} ${fmt(v.hp)} HP ${resistIcons(v)}
    `; }).join("")}
    `; } // Everything about fighting in one frame. Left: you, your prayers (switch them mid-fight) and your // gear (swap it mid-fight). Right: what you're fighting, what it attacks with, what it's weak to and // resists (your weapon's type outlined), and the fight as it plays out. function combatFrame() { const me = S.me, a = me.action, f = a?.fight; const encounter = f && C.actions.find((x) => x.id === a.id); const bars = (who) => `
    `; const you = `
    ${esc(me.name)}${HEART} ${fmt(me.hp)} / ${me.max_hp}
    ${bars("you")} ${me.edible.length ? "" : `
    No food in your bank.
    `}
    `; let foe = `
    Not fighting.
    `; if (f) { const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage; const pills = (label, kinds, how) => kinds?.length ? `
    ${label} ${kinds.map((k) => `${dmgIcon(k, how)}${C.damage_types[k].name}`).join("")}
    ` : ""; foe = `
    ${encounter?.icon || ""}
    ${esc(f.enemy)}
    ${bars("foe")}
    ${f.foe ? `
    ${pills("Attacks with", f.foe.attack, "attack")}${pills("Weak to", f.foe.vulnerable, "vuln")}${pills("Resists", f.foe.resists, "resist")}
    ` : ""} `; } return `
    ${you}${prayerBar(me)}${paperDoll(me, true)}
    ${foe}
    `; } // Prayers you can switch on and off between swings, and the points left to pay for them. function prayerBar(me) { const level = me.levels.prayer; const buttons = C.prayers.filter((p) => p.level <= level).map((p) => { const on = me.prayers.includes(p.id); return ``; }).join(""); return `
    ${skillOf("prayer").icon} ${fmt(me.pp)}${buttons}
    `; } function fightLine(e, enemy) { const [, who, kind, amount, , , food] = e; const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : ""; const foe = esc(enemy); if (kind === "eat") return `
  • You eat ${esc(item(food).name.toLowerCase())} (+${amount})
  • `; if (kind === "wind") return `
  • Second wind! Back on your feet
  • `; if (kind === "block") return `
  • You block ${foe}'s blow
  • `; if (who === "you") return kind === "miss" ? `
  • You miss
  • ` : `
  • You ${kind === "crit" ? "crit" : "hit"} for ${amount}${type}
  • `; return kind === "miss" ? `
  • ${foe} misses
  • ` : `
  • ${foe} ${kind === "crit" ? "crits" : "hits"} you for ${amount}${type}
  • `; } function animateFight(now) { const a = S.me?.action, f = a?.fight; if (!f) return; const t = now - a.start; const over = t >= f.end; // From the kill until the next enemy turns up (the next fight's start, once the server sends it), // the panel is the same quiet countdown: no enemy, both swing timers empty. const waiting = t < 0 || (over && f.result === "won"); const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t; let shown = -1; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; const last = f.events[shown]; const hp = last ? last[4] : f.start_hp, foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp; for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`; const panel = $("[data-fight]"); if (!panel) return; panel.classList.toggle("waiting", waiting); // Your HP everywhere follows the fight as it plays out. for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp); for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`; const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); }; set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; }); set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; }); set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; }); set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; }); for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) { let swung = 0; for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; } set(`${who}-swing`, (el) => { el.style.width = over || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; }); } set("log", (el) => { const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`; if (el.dataset.key === key) return; el.dataset.key = key; const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy)); if (over && !waiting) lines.unshift(`
  • You fall back.
  • `); el.innerHTML = waiting ? `
  • Victory! ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}
  • ` : lines.join("") || `
  • Squaring up…
  • `; }); } // Power, armour and ward, and the armour's weight. function gearStats(it) { const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`, it.ward && `+${it.ward} ward`]; return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : ""); } const GEAR_SLOTS = ["weapon", "shield", "helmet", "body", "legs"]; // A weapon's or armour piece's stats in a small grid. Weapons: damage type and power | 1H or 2H | swing // time; armour: armour | weight | ward. Below: what it needs to wear, and any bonus for you. function gearChips(it, who = S.me) { const cell = (icon, text, tip, cls = "") => `${icon}${text === "" ? "" : `${text}`}`; const hands = (two) => `${two ? "2H" : "1H"}`; let row = ["", "", ""]; if (it.style) { const st = C.styles[it.style], dt = C.damage_types[st.damage]; row = [cell(glyph(dt.glyph, dt.tint), it.power, `${dt.name} damage, +${it.power} power`), cell(hands(it.two_handed), "", it.two_handed ? "Two-handed: no shield" : "One-handed: room for a shield"), cell(glyph("stopwatch", "#9fb3c8"), `${st.interval}s`, `A swing every ${st.interval}s`)]; } else if (it.slot === "shield") { row = [cell(glyph("checked-shield", "#d4dae2"), it.armour, `+${it.armour} armour, +${it.power} power`), cell(hands(false), "", "Held in one hand"), cell(glyph("checked-shield", "#e0b04a"), `${Math.round(C.block_chance * 100)}%`, "Blocks this share of hits")]; } else if (it.weight) { row = [cell(glyph("checked-shield", "#d4dae2"), it.armour, `+${it.armour} armour: less damage from blows`), cell("", C.weights[it.weight].name, `${C.weights[it.weight].name} armour`, "muted"), cell(glyph("magic-shield", "#a77be8"), it.ward, `+${it.ward} ward: less damage from magic`)]; } let req = "", bonus = ""; if (it.req) { const [skill, level] = it.req; req = cell(skillOf(skill).icon, level, `Needs ${skillOf(skill).name.toLowerCase()} ${level}`, who.levels[skill] < level ? "short" : ""); } if (it.style && C.class_style[who.cls] === it.style) { bonus = cell(C.classes[who.cls].icon, "+15%", "The trade's own weapon: +15% damage", "trade"); } return `
    ${row.join("")}${req}${bonus || ""}
    `; } // What an item does once made: healing, defence, tool speed, and who can use it. function itemStats(id) { const it = item(id); const parts = []; if (it.heal) parts.push(`❤️ Heals ${it.heal}`); if (it.food) parts.push(`feeds ${it.food} for 2 days`); if (it.power) parts.push(`+${it.power} power`); if (it.style) { const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0]; parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`); } if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 })); if (it.reagent) parts.push(`Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`); if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`); if (it.trophy) parts.push(`Trophy for stronger weapons`); if (it.fitting) { const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id); parts.push(`${C.buildings[f.building].icon} ${C.buildings[f.building].name} Lv ${f.level}: ${f.effect}${done ? " · fitted" : ""}`); } return parts.join(", "); } function actionCard(a) { const me = S.me; const active = me.action?.id === a.id; const second = me.action?.plot2 === a.id; // a homesteader's second plot const growingNow = me.action?.growing === a.id; const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second && me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left; const why = me.blocked[a.id]; const locked = isLocked(a); const out = Object.keys(a.outputs)[0]; const icon = a.icon; const time = me.times[a.id]; let io = ""; if (Object.keys(a.inputs).length) { io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") + (Object.keys(a.outputs).length ? `→` : ""); } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); let extra = ""; if (a.enemy) { const o = me.fight_odds[a.id], e = a.enemy; extra = `${e.boss ? `
    Boss
    ` : ""}${variantRows(e, o)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
    -3% raid strength per win (max 30%)
    `; } if (a.special === "drill") { const s = S.favours.sergeant, boost = s.boost; extra = !s.needed ? `
    Only while a raid is coming.
    ` : `
    ${skillOf("combat").icon} +${+me.drill_per_action.toFixed(2)} drill · ${fmt(s.points)} / ${fmt(s.needed)} points, worth ${fmt(s.defence)} of ${fmt(s.cap)} defence
    ${boostBar(s.points, s.needed, boost, "Drill sergeant: this stretch drills twice as fast")}`; } if (a.special === "recon") { const boost = S.favours.informant.boost; const ev = S.upcoming, full = ev && ev.recon >= ev.recon_full; extra = full ? `
    Fully scouted: XP only.
    ` : `
    ${skillOf("reconnaissance").icon} +${+me.recon_gain[a.id].toFixed(2)} recon
    ${ev?.recon_full ? boostBar(ev.recon, ev.recon_full, boost, "Informant: this stretch scouts twice as fast") : ""}`; } const temple = S.city.buildings.temple?.level || 0; if (a.special === "labour") { extra = `
    ${skillOf("construction").icon} +${me.labour_per_action} labour per action
    `; } if (a.special === "bury") extra = `
    ${skillOf("prayer").icon} +${a.pp + temple} prayer points
    `; if (a.special === "offer") extra = `
    ${C.buildings.temple.icon} +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion
    `; const gear = Object.keys(a.outputs).filter((id) => GEAR_SLOTS.includes(item(id).slot)); const made = Object.keys(a.outputs).filter((id) => !gear.includes(id)).map(itemStats).filter(Boolean); extra += gear.map((id) => gearChips(item(id))).join(""); if (made.length) extra += `
    ${made.join(" · ")}
    `; return `
    ${icon}
    ${esc(a.name)}
    Level ${a.level} · ${dur(time)} · ${a.xp} XP
    ${io ? `
    ${io}
    ` : ""} ${extra} ${me.action?.plot2 && (active || second) ? `
    Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
    ` : ""} ${growingNow && !a.enemy ? `
    ` : ""} ${active && a.enemy ? `
    ⚔️ Fighting
    ` : ""} ${active && me.action.left ? `
    Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
    ` : ""}
    ${!active && !second && why ? esc(why) : ""} ${second ? `` : active ? `` : ` ${canPlot ? `` : ""} `}
    `; } // --- 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}

    Bank full:
    Overflow earns half favour; what the city can't use is sold.

    ${favourPanel()}
    ${selected ? itemPanel(selected) : goldPanel()}
    `; } function itemPanel(id) { const me = S.me, it = item(id), have = me.bank[id]; const merchant = S.city.merchant_until > serverNow(); const price = it.value * (merchant ? 1.5 : 1); const others = S.players.filter((p) => p.id !== S.uid); const qtyBtns = (attr) => [1, 10, 100].filter((q) => q <= have).map((q) => ``).join("") + ``; return `
    ${it.icon}

    ${esc(it.name)}

    You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
    ${GEAR_SLOTS.includes(it.slot) ? gearChips(it) : ""} ${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 2 days.
    ` : ""} ${C.city_uses[id].length ? `
    Donate to the city · +${it.value} favour each
    ${qtyBtns("data-donate")}
    Used for ${esc(listing(C.city_uses[id]))}.
    ` : `
    The city has no use for this.
    `} ${others.length ? `
    Give to
    ${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)`; return "Equip"; } function goldPanel() { return `

    Gold

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

    1 favour per gold.

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

    Favour with the council

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

    More to spend on in .

    `; } // --- town ----------------------------------------------------------------- function town() { const city = S.city; const growth = city.starving ? `Hungry: ${city.growth} a day` : city.growth > 0 ? `+${city.growth} a day` : city.growth < 0 ? `Overcrowded: ${city.growth} a day` : `Full: build houses`; const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier"; let html = `
    Settlement
    ${city.tier_name}
    ${next}
    Population
    ${city.population} / ${city.housing} homes
    ${growth}
    Food
    ${city.food_days ?? 0} days
    ${fmt(city.food)} meals in the granary
    Treasury
    ${fmt(city.treasury)}
    Day ${city.day}
    Defence
    ${fmt(city.defence.total)}
    ${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled
    ${weatherCard()}
    `; html += happeningsPanel(); if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; if (city.construction) html += siteBanner(); html += `
    Buildings

    Funds are safe from raiders. Building starts once a fund is full.

    ${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}
    `; html += `
    City stockpile
    `; const stock = Object.entries(city.stockpile); html += stock.length ? `
    ${stock.map(([id, q]) => itemChip(id, q)).join("")}
    ` : `

    Empty.

    `; html += `

    Feeds the granary and armoury. Raiders can loot it.

    `; return html; } // Progress towards a goal favour can boost. Favour doubles the next `boost` points players put // in, so the striped stretch behind the bar (twice the boost) is where work counts double. function boostBar(done, total, boost, tip) { const pct = (x) => Math.min(100, x / total * 100); return `
    ${boost ? `` : ""}
    `; } // The building site: labour so far, who is helping, and ways to speed it up. function siteBanner() { const city = S.city, work = city.construction, me = S.me; const spec = C.buildings[work.building]; const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); const hire = S.favours.builders; const working = me.action?.id === "labour"; const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name)); return ``; } // "Rain: farming 10% faster. Clear skies in 3h 20m." function weatherTip(w) { return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${w.next_name} in ${dur(w.until - serverNow())}.`; } function weatherCard() { const h = S.happenings, w = h.weather; return `
    ${h.season.icon} ${h.season.name}, day ${h.season.day}
    ${w.icon} ${esc(w.name)}
    ${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}
    `; } // Everything going on in the city right now, and what anyone can do about it. function happeningsPanel() { const h = S.happenings, me = S.me, city = S.city; const rows = []; if (h.weather.kind !== "clear") rows.push(`
    ${h.weather.icon}${esc(h.weather.name)}${esc(h.weather.text)}
    `); if (h.cold) rows.push(`
    🥶Out of firewoodCity eats 50% more. 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 ? "Clear it below." : `${skillOf(fix.skill).name} clears it (Town square).`); } return lines.length ? `
    ${lines.join("")}
    ` : ""; } function buildingCard(key, b) { const spec = C.buildings[key], city = S.city, me = S.me; const tags = { building: `Under construction`, queued: `Funded · queued`, max: `Fully built`, capped: `Needs a bigger settlement`, }; let body = tags[b.status] ? `
    ${tags[b.status]}
    ` : ""; if (b.status === "funding" || b.status === "queued") { body += `
    ${Object.entries(b.target).map(([id, need]) => { const missing = b.missing[id] || 0; const gold = id === "gold"; const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0); const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0); const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); return `
    ${chip} ${mine ? `` : ""} ${stores ? `` : ""}
    `; }).join("")}
    `; } body += fittingRows(key, b); const levelText = b.cap < spec.max ? `Lv ${b.level} / ${b.cap} (${city.tier_name.toLowerCase()} cap)` : `Lv ${b.level} / ${spec.max}`; return `
    ${spec.icon}
    ${esc(spec.name)} ${levelText} ${b.damaged ? `Damaged` : ""}
    ${esc(spec.effect)}
    ${body}
    `; } // Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level. function fittingRows(key, b) { const rows = C.fittings.filter((f) => f.building === key).map((f) => { const it = item(f.id), recipe = C.actions.find((a) => a.id === `make_${f.id}`); let state; if (b.fittings.includes(f.id)) { state = b.damaged ? `Paused` : `Fitted`; } else if (b.level < f.level) { state = `Needs Lv ${f.level}`; } else { const mine = S.me.bank[f.id] || 0, stores = S.city.stockpile[f.id] || 0; state = mine || stores ? `${mine ? `` : ""} ${stores ? `` : ""}` : `Construction ${recipe.level}`; } return `
    ${it.icon} ${esc(f.name)} ${esc(f.effect)} ${state}
    `; }); return rows.length ? `
    ${rows.join("")}
    ` : ""; } // --- council -------------------------------------------------------------- function pips(level) { return `${[1, 2, 3].map((i) => ``).join("")}`; } function reconText(ev) { const points = `${fmt(ev.recon)} / ${fmt(ev.recon_full)} points`; if (ev.intel >= 3) return `${points} · complete`; if (ev.recon_next <= 0) return `${points} · next level opens closer to the threat`; return `${points} · ${fmt(ev.recon_next)} to the next level`; } function council() { const ev = S.upcoming, city = S.city, me = S.me; let html = ""; if (!ev) { html += `

    All quiet

    No news on the roads.

    `; } else { const threat = ["raid", "dragon"].includes(ev.kind); html += `

    ${eventIcon(ev.kind)} ${threat ? "Threat" : "News"} · intel ${pips(ev.intel)}

    ${esc(ev.headline)}
    Expected${eventWhen(ev)} ${ev.range ? `Strength${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}` : ""} ${ev.trait ? `Scouts say${esc(ev.trait)}` : ""} ${ev.camp ? `Their campFound · sabotage it in Combat${ev.sabotage ? ` (-${ev.sabotage}% so far)` : ""}` : ""} Reconnaissance${reconText(ev)}
    ${ev.recon_full && ev.intel < 3 ? boostBar(ev.recon, ev.recon_full, S.favours.informant.boost, "Informant: this stretch scouts twice as fast") : ""}`; if (threat) { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); html += `
    Our defence${fmt(city.defence.total)}
    vs
    Raiders${strength}
    `; } html += `
    `; } html += favourSpends(); if (ev && ["raid", "dragon"].includes(ev.kind)) html += raidPrep(ev); html += `
    Past events
    `; if (!S.history.length) html += `

    Nothing has happened yet.

    `; for (const h of S.history) { if (!h.report) continue; const fight = ["raid", "dragon"].includes(h.kind); const tone = fight ? (h.report.title.startsWith("Victory") ? "win" : "loss") : { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || ""; html += `
    ${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} ${fight ? `

    Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}

    ` : ""} ${h.report.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    `; } return html; } function favourSpends() { const f = S.favours, me = S.me; const option = (kind, icon, title, text) => { const o = f[kind]; const short = !o.why && me.favour < o.cost; return `
    ${icon} ${title}${text}
    `; }; return `

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

    ${option("builders", "🏗️", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `×2 labour and XP for the next ${f.builders.labour}${f.builders.boost ? ` (${fmt(f.builders.boost)} left)` : ""}. Price rises per hire.`)} ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `×2 recon and XP for the next ${f.informant.recon}${f.informant.boost ? ` (${fmt(f.informant.boost)} left)` : ""}. Price rises per hire.`)} ${option("sergeant", "📯", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `×2 drill and XP for the next ${f.sergeant.drill}${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} left)` : ""}. Price rises per hire.`)}
    `; } function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; return `
    ${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} ${mine ? `` : ""}
    `; }).join(""); return `

    Council requests

    ${reqs}

    Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.

    Defence

    ${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => ``).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 yet.`}

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

    Keep working while on guard. Victory earns favour and combat XP.

    `; } // --- board & roster ------------------------------------------------------- function board() { const msgs = S.board.map((m) => `
    ${esc(playerName(m.who))}${ago(m.at)} ${m.who === S.uid && m.id ? `` : ""}
    ${esc(m.text)}
    `).join(""); const ledger = [...S.ledger].reverse().map((l) => `
  • ${esc(l.text)}${ago(l.at)}
  • `).join(""); return `

    Town board

    ${msgs || `

    No messages yet.

    `}

    Town ledger

    `; } 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); 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 physical
    Ward
    ${p.ward}
    ${Math.round(p.ward_reduction * 100)}% less magic
    Given to the city
    ${fmt(p.donated)}
    ${fmt(p.favour)} favour to spend
    ${mine ? `
    Purse
    ${fmt(p.gold)}
    ${p.slots_used} / ${p.slots} bank slots
    ` : ""}

    Class

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

    ${esc(cls.bonus)}.

    ${esc(cls.masterwork)}.

    ${esc(cls.signature)}.

    ${esc(cls.combat)}.

    ${equipmentPanel(p, mine)}

    Skills

    ${skillRows}
    `; } // The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with // room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded. // Picking a slot shows what's in it beside the grid. const DOLL = [ ["cape", "Cape", "cape"], ["helmet", "Head", "visored-helm"], ["amulet", "Amulet", "gem-pendant"], ["weapon", "Main hand", "pointy-sword"], ["body", "Body", "breastplate"], ["shield", "Off hand", "round-shield"], ["gloves", "Gloves", "gauntlet"], ["legs", "Legs", "leg-armor"], ["ring", "Ring", "ring"], ["ammo", "Ammo", "quiver"], ["boots", "Boots", "leather-boot"], ["trinket", "Trinket", "crystal-ball"], ]; function dollSlot(p, slot, label, outline, picked) { const known = slot in p.equip, two = slot === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed; const id = two ? p.equip.weapon : p.equip[slot]; const tip = !known ? `${label}: not in the game yet` : id ? `${label}: ${item(id).name}` : `${label}: empty`; return ``; } function paperDoll(p, mine) { const pick = dollPick && slot2(dollPick, p); let slot = ""; if (pick) { const label = DOLL.find(([s]) => s === pick)[1]; const two = pick === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed; const id = two ? p.equip.weapon : p.equip[pick], it = id && item(id); slot = `
    ${it ? `${it.icon}${esc(it.name)}` : `${label}empty`}
    ${it ? gearChips(it, p) : ""}${mine ? swapGrid(p, pick, two) : ""}`; } return `
    ${glyph("swordman", "#e6e8ee")} ${DOLL.map(([s, name, outline]) => dollSlot(p, s, name, outline, s === pick)).join("")}
    ${slot}
    ${combatStats(p)}
    `; } // Your fighting numbers, always in the same place under the slot details: icons and numbers, // with what each means on hover. function combatStats(p) { const f = p.fighter, pct = (x) => `${Math.round(x * 100)}%`, dt = C.damage_types[f.damage]; const cell = (icon, value, tip) => `${icon}${value}`; const cells = [ cell(glyph("biceps", "#e06a6a"), p.power, "Power: from your level and gear"), cell(glyph("checked-shield", "#d4dae2"), p.armour, `Armour: ${pct(p.reduction)} less damage from blows`), cell(glyph("magic-shield", "#a77be8"), p.ward, `Ward: ${pct(p.ward_reduction)} less damage from magic`), cell(glyph(dt.glyph, dt.tint), f.max_hit, `Max hit (${dt.name.toLowerCase()})`), cell(glyph("striking-diamonds", "#f0d060"), pct(f.crit), "Crit chance"), cell(glyph("stopwatch", "#9fb3c8"), `${f.interval}s`, `A swing every ${f.interval}s`), cell(glyph("archery-target", "#e0a050"), fmt(f.accuracy), "Accuracy"), cell(glyph("dodging", "#8fd0e0"), fmt(f.evasion), "Evasion"), cell(glyph("shield-reflect", "#e0b04a"), pct(f.block), "Block chance (with a shield)"), ]; return `
    Combat stats
    ${cells.join("")}
    `; } // What else in the bank fits the picked slot, best first: click one to put it on. For weapons the // corner shows the damage type, so you can pick one for what you're about to fight. function swapGrid(p, slot, two) { const fits = Object.keys(p.bank).filter((id) => item(id).slot === slot) .sort((a, b) => (item(b).tier ?? 0) - (item(a).tier ?? 0) || (item(b).power ?? 0) - (item(a).power ?? 0)); const tiles = fits.map((id) => { const it = item(id), [skill, level] = it.req || []; const why = two ? `Your ${item(p.equip.weapon).name.toLowerCase()} takes both hands` : skill && p.levels[skill] < level ? `Needs ${skillOf(skill).name.toLowerCase()} ${level}` : ""; const corner = it.style ? dmgIcon(C.styles[it.style].damage) : ""; const tip = [it.name, gearStats(it), it.style ? (it.two_handed ? "two-handed" : "one-handed") : "", why].filter(Boolean).join(" · "); return ``; }); const off = p.equip[slot] && !two ? `` : ""; if (!tiles.length && !off) return `
    Nothing else in your bank fits here.
    `; return `
    Swap for
    ${tiles.join("")}${off}
    `; } // A picked slot only counts if it's one this character has. function slot2(slot, p) { return slot in p.equip ? slot : null; } // The equipment panel, the same on the character sheet and the combat page (which leaves out tools). function equipmentPanel(p, mine, tools = true) { return `

    Equipment

    ${paperDoll(p, mine)} ${tools ? `

    Tools

    ${toolSlots(p)}` : ""}
    `; } // Tools: one small slot per gathering skill. function toolSlots(p) { return `
    ${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => { const id = p.equip[`tool_${s.id}`], it = id && item(id); return ` ${it ? it.icon : s.icon}`; }).join("")}
    `; } // --- registration --------------------------------------------------------- function classCards() { return Object.entries(C.classes).map(([id, c]) => ` `).join(""); } // After the classes became trades, everyone picks once more. Nothing is lost. function repickPage() { return `

    Pick your trade

    Classes are now trades, gathering through crafting. You keep every level and item. Free, once.

    ${classCards()}

    Any class can train any skill.

    `; } function register() { const cards = classCards(); return `

    A new city on the frontier

    Settle a frontier city together. Train, build, and hold off the bandits. You keep working while away, for up to a day.

    ${cards}

    Any class can train any skill.

    `; } // --- animation ------------------------------------------------------------ function animate() { const now = serverNow(); const a = S?.me?.action; animateFight(now); if (a) { const pct = Math.min(1, (now - a.start) / a.duration); // In a fight the bar is the enemy's health (set by animateFight), not a timer. if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; const key = `${a.start}:${a.duration}`; if (pct >= 1 && refreshedFor !== key) { refreshedFor = key; setTimeout(refresh, 150); } else if (a.fight && now - a.start > a.duration + (a.respawn || 1) && now - retriedAt > 2) { retriedAt = now; // the next enemy hasn't turned up yet: ask again refresh(); } } 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-battle-skip]")) return playBattle.finish?.(); if (d.raidSeen) { $("#modal").hidden = true; return act("/api/seen", { id: Number(d.raidSeen) }); } if (!$("#modal").hidden && $(".battle")) return; // the raid report closes with its own button, once watched if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); 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 (d.doll) { dollPick = dollPick === d.doll ? null : d.doll; return render(); } if ("select" in d) { selected = d.select || null; render(); // On a phone the item panel sits under the bank grid: bring it into view. if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } const qty = d.qty === "all" ? "all" : Number(d.qty); if (d.donate) return act("/api/donate", { item: d.donate, qty }); if (d.sell) return act("/api/sell", { item: d.sell, qty }); if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value }); if (d.equip) return act("/api/equip", { item: d.equip }); if (d.unequip) return act("/api/equip", { slot: d.unequip }); if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" }); if (d.install) return act("/api/install", { fitting: d.install, source: d.source }); if ("plot" in d) return act("/api/plot", { id: d.plot || null }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.food) return act("/api/food", { item: d.food }); if (d.potion) return act("/api/potion", { item: d.potion }); if (d.unpost) { if (!confirm("Delete this message?")) return; return act("/api/board/delete", { id: Number(d.unpost) }); } if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source }); if ("hire" in d) return act("/api/hire", {}); if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen }); if (d.pray) { const on = S.me.prayers.includes(d.pray); if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Max ${C.max_prayers} prayers active`, "bad"); return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] }); } if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } if ("repick" in d) return act("/api/class", { cls: pickedClass }); if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; } if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) }); if ("devEvent" in d) return act("/api/dev/event", {}); }); document.addEventListener("change", (e) => { }); $("#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); })();