"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 FIGHT_POLL_MS = 2000; // in a fight someone may tag in: keep everyone's view of it close const SUMMARY_MODAL_AT = 25; let C = null; // static content tables let S = null; // latest state let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let dollPick = null; // equipment slot shown on the character sheet let orderTab = "take"; // work orders: for you, all open, yours let draft = null; // a work order being written let seenOrders = null; // order ids already known, so only new ones toast let deliveredSeen = {}; // how much of each order had arrived let earnedSeen = {}; // gold each order had paid you for reagents let pickedClass = null; // registration const tabs = {}; // skill -> selected recipe tab let refreshing = false; let refreshedFor = null; // action (start and length) a completion refresh was sent for let polledAt = 0; let tagging = false; // this page just tagged into someone's fight // What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with // every press, so a tap and a press straight after it don't wait on each other. let aim = { fight: null, foe: null, ally: null }; 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) => ``; // A player's banner: field and trim colours, with their emblem on it. const crest = (look, h = 26) => look ? `` : ""; const titled = (p) => `${crest(p.look)} ${esc(p.name)}${p.look?.title ? ` ${esc(p.look.title)}` : ""}`; const COIN = glyph("two-coins", "#e0b04a"), ANY_FOOD = glyph("knife-fork", "#d9c6a0"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c"); function iconise(content) { const entries = [...Object.values(content.items), ...content.skills, ...Object.values(content.classes), ...Object.values(content.buildings), ...content.prayers, ...content.actions, ...Object.values(content.abilities), ...Object.values(content.enemy_abilities)]; 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; polledAt = Date.now(); try { accept(await api("/api/state")); failures = 0; } catch (err) { // A deploy restarts the server for a second or two; only speak up if // the city stays out of reach. if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad"); } finally { refreshing = false; } } function accept(state) { version ??= state.version; if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden && !draft) { location.reload(); // a new build was deployed return; } // Someone tagging into your fight (not you tagging into theirs) gets a toast. const fightingWith = S?.me && !tagging ? S.me.action?.with || [] : null; tagging = false; S = state; skew = state.now - Date.now() / 1000; for (const uid of fightingWith ? state.me?.action?.with || [] : []) { if (!fightingWith.includes(uid)) toast(`⚔️ ${playerName(uid)} joined the fight`, "good"); } if (state.summary) showSummary(state.summary); orderNews(state); const ev = state.upcoming; if (ev?.at_gates && warned !== ev.id) { warned = ev.id; toast(ev.kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!", "bad"); } render(); showThreat(); } // --- time ----------------------------------------------------------------- function dur(seconds) { if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`; seconds = Math.max(0, Math.round(seconds)); const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600); const m = Math.floor(seconds % 3600 / 60), s = seconds % 60; if (d) return `${d}d ${h}h`; if (h) return `${h}h ${m}m`; if (m) return `${m}m ${s}s`; return `${s}s`; } function clock(t) { return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); } // Coarse on purpose: the markup stays the same between polls (see setHtml). function ago(t) { const s = serverNow() - t; if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } // A live "3h 12m" until `t`, filled in and kept current by animate(). const countdown = (t) => ``; // --- toasts & summaries --------------------------------------------------- function toast(text, kind = "") { const el = document.createElement("div"); el.className = "toast " + kind; el.textContent = text; $("#toasts").append(el); setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500); const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4; while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove(); } function showSummary(sum) { if (!sum.done && !sum.stopped && !sum.levels.length) return; if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum); const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); if (got.length) toast(got.join(" · "), "good"); const eaten = Object.entries(sum.eaten || {}); if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`); if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level"); const spilled = Object.entries(sum.overflow); if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`); const sold = Object.entries(sum.sold || {}); if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad"); for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`); if (sum.prayer_out) toast("🙏 Out of prayer points", "bad"); if (sum.second_wind) toast("🌬️ Second Wind! Back to half HP", "level"); if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`); } function awayModal(sum) { const items = Object.entries(sum.items).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); const xp = Object.entries(sum.xp).map(([id, q]) => `
  • ${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP
  • `).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.with || {}).length ? `

    ⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}

    ` : ""} ${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(", ")}.

    ` : ""} ${Object.keys(sum.used || {}).length ? `

    Used ${Object.entries(sum.used).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; if (page === "notes") remember("notes", notesKey()); setHtml($("#side"), S.me ? sidebar() : `
    Guildhall beta
    ${S.dev ? devbar() : ""}`); setHtml($("#top"), topbar()); // The fires keep burning through a re-render: put the old canvases back in place of the new ones. const burning = [...document.querySelectorAll("canvas[data-flame]")]; setHtml($("#main"), S.me ? pageHtml() : register()); for (const old of burning) { const fresh = document.querySelector(`canvas[data-flame="${old.dataset.flame}"]`); if (fresh && fresh !== old) fresh.replaceWith(old); } fireTextAt = 0; // and fill in their bars and times straight away for (const [key, value] of Object.entries(kept)) { const el = document.querySelector(`[data-keep="${key}"]`); if (el) el.value = value; } if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus(); const msgs = $(".msgs"); if (msgs) msgs.scrollTop = msgs.scrollHeight; animate(); } // Only touch the DOM when the markup changed. Replacing an element mid-swipe // cancels the swipe on iOS, and polls arrive every few seconds. function setHtml(el, html) { if (el.dataset.html === html) return; const scroll = el.scrollTop; el.innerHTML = html; el.dataset.html = html; el.scrollTop = scroll; } function setMenu(open) { $("#app").classList.toggle("open", open); document.documentElement.classList.toggle("menu-open", open); } function nav(id, icon, label, meta = "", extra = "", tip = "") { return ``; } function skillNav(skill, me, doing) { const busy = doing?.skill === skill.id ? `` : ""; const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1; const flame = heat >= 0 ? `` : ""; const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `` : ""; const tip = heat >= 0 ? `${C.fires[fire].name}: ${C.flames[heat][0]}` : low ? "A fire is low" : ""; return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, flame + busy + low, tip); } function pinnedNav(me, doing) { const pinned = C.skills.filter((s) => me.pinned.includes(s.id) && (!s.class || s.class === me.cls)); return pinned.length ? `${pinned.map((s) => skillNav(s, me, doing)).join("")}` : ""; } 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"; const takeable = (S.orders || []).filter(canTake).length; let html = `
    Guildhall beta
    ${esc(me.name)}
    ${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}
    ${COIN} ${fmt(me.gold)}${FAVOUR} ${fmt(me.favour)}
    ${pinnedNav(me, doing)} ${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)} ${nav("council", "📜", "Council & threats", "", raid ? `` : "", raid ? "A threat is coming" : "")} ${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `${takeable}` : "", takeable ? `${takeable} for you` : "")} ${nav("board", "💬", "Town board")} ${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)} ${nav("notes", "📰", "Patch notes", "", notesUnread() ? `new` : "", notesUnread() ? `What's new: ${C.patch_notes[0].title}` : "")} ${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 += ``; } html += skillNav(skill, me, doing); } return html + (S.dev ? devbar() : "") + `

    Icons by Lorc, Delapouite and others at game-icons.net, CC BY 3.0

    `; } function devbar() { return `
    Dev mode · playing as ${esc(S.uid)}
    `; } const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", notes: "Patch notes", bank: "Bank", character: "Character" }; function topbar() { let title = TITLES[page] || "Guildhall"; if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title; if (page.startsWith("character:")) title = S.players.find((p) => p.id === page.slice(10))?.name || "Character"; if (!S.me) title = "Welcome to Guildhall"; let chips = ""; if (S.me?.action) { const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id)); chips += ``; } else if (S.me) { chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`; } const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : []; if (fires.some(([, n]) => n.heat >= 0)) { const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n"); chips += ``; } const w = S.happenings?.weather; if (w) chips += ``; if (S.upcoming) chips += ``; return `

    ${esc(title)}

    ${chips}${S.me ? trackedBar() : ""}`; } // The recipe you're after, under the top bar on every page: have / need for each input. function trackedBar() { const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked); if (!a) return ""; const ready = Object.entries(me.fuelled?.[a.id] || a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q); const go = !me.blocked[a.id] && me.action?.id !== a.id; return `
    ${inputChips(a)} ${go ? `` : ""}
    `; } function eventIcon(kind) { return { raid: "⚔️", dragon: "🐉", merchant: "🐪", refugees: "🧳" }[kind] || "❗"; } function eventWhen(ev, short = false) { if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`; if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`; return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`; } function pageHtml() { if (S.me.repick) return repickPage(); if (page.startsWith("skill:")) { const id = page.slice(6); const skill = skillOf(id); if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id); page = "town"; } if (page.startsWith("character:")) return characterSheet(page.slice(10)); if (page === "character") return characterSheet(S.uid); return ({ town, council, orders, board, roster, notes, bank }[page] || town)(); } // Recipe input as "have / need"; without `have`, just the quantity. function itemChip(id, qty, have) { const short = have !== undefined && have < qty; return `${item(id).icon} ${have !== undefined ? `${fmt(have)} / ${fmt(qty)}` : fmt(qty)}`; } // A recipe's inputs as "have / need", with the fuel a lit fire pays struck through. function inputChips(a) { const me = S.me, ins = me.fuelled?.[a.id] || a.inputs; return Object.entries(a.inputs).map(([id, q]) => { const need = ins[id] || 0, have = me.bank[id] || 0; if (need === q) return itemChip(id, q, have); const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`; return need ? `${item(id).icon} ${fmt(have)} / ${q} ${need}` : ``; }).join(""); } // Progress towards a building cost: "have / need". function costChip(icon, name, need, have) { return `${icon} ${fmt(have)} / ${fmt(need)}`; } // What the city still needs of each item from building funds, fittings and happenings, after the stockpile. // Recomputed once per state. let needsState = null, needsCache = {}; function fundNeeds() { if (needsState === S) return needsCache; const city = S.city, missing = {}, why = {}; const add = (id, short, line) => { missing[id] = (missing[id] || 0) + short; (why[id] ||= []).push(line); }; for (const [key, b] of Object.entries(city.buildings)) { const spec = C.buildings[key]; if (b.status === "funding") for (const [id, need] of Object.entries(b.target)) { const short = b.missing[id] || 0; if (id !== "gold" && short) add(id, short, `${spec.name} · ${fmt(need - short)} / ${fmt(need)}`); } for (const f of C.fittings) { if (f.building === key && b.level >= f.level && !b.fittings.includes(f.id)) add(f.id, 1, `${spec.name} · 0 / 1`); } } for (const p of S.happenings.problems) for (const [id, need] of Object.entries(p.need || {})) { const short = p.missing[id] || 0; if (short) add(id, short, `${p.name} · ${fmt(need - short)} / ${fmt(need)}`); } needsCache = {}; for (const [id, short] of Object.entries(missing)) { const stock = city.stockpile[id] || 0; if (short > stock) needsCache[id] = { qty: short - stock, why: stock ? [...why[id], `Stockpile ${fmt(stock)}`] : why[id] }; } needsState = S; return needsCache; } // How many of an item the city still needs, with a have / need line per reason, or null. function cityNeed(id) { const it = item(id), fund = fundNeeds()[id]; let qty = fund?.qty || 0; const why = [...(fund?.why || [])]; for (const r of S.upcoming?.requests || []) { const short = r.want - r.have; const counts = r.any === "food" ? it.food : r.any ? it.slot === r.any : id === r.item; if (short <= 0 || !counts) continue; qty += r.any === "food" ? Math.ceil(short / it.food) : short; why.push(`Council: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`); } return qty ? { qty, why } : null; } function needTag(id) { const n = cityNeed(id); return n ? `🏘️ ${fmt(n.qty)}` : ""; } // --- 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 += skillGear(id); if (id === "firekeeping") return html + firekeepingPage(); html += fireBand(id); if (id === "reconnaissance") html += `
    Raises intel on the coming threat. Higher levels open as it nears.
    `; html += skillHappenings(id); const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o)); if (wanted.length) html += ``; 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 needed = (g) => actions.some((a) => a.group === g && !isLocked(a) && Object.keys(a.outputs).some(cityNeed)); 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. // A skill's potion and tool side by side, each a slot with what you could switch to beside it. function skillGear(skill) { const me = S.me, cards = []; const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill); if (kinds.length) { const active = me.potions[skill], it = active && item(active.item); const others = kinds.filter(([id]) => me.bank[id] && id !== active?.item).map(([id, p]) => ``).join(""); cards.push(`
    ${it ? it.icon : glyph("round-bottom-flask", "#98a1b4")}
    ${it ? `${esc(it.name)}${esc(it.potion.text)} · ${fmt(active.charges)} uses` : `No potion`}
    ${others ? `
    ${others}
    ` : ""}
    `); } for (const [kind, none] of [["tool", "No tool"], ["station", "No station"]]) { const slot = `${kind}_${skill}`; if (!(slot in me.equip)) continue; const id = me.equip[slot], it = id && item(id); const others = Object.keys(me.bank).filter((x) => item(x)[kind] === skill).sort((a, b) => item(b).speed - item(a).speed).map((x) => { const [s, level] = item(x).req || [], why = s && me.levels[s] < level ? `Needs ${skillOf(s).name.toLowerCase()} ${level}` : ""; return ``; }).join(""); cards.push(`
    ${it ? it.icon : glyph(kind === "tool" ? "toolbox" : "anvil", "#98a1b4")}
    ${it ? `${esc(it.name)}${Math.round(it.speed * 100)}% faster` : `${none}`}
    ${others || it ? `
    ${others}${it ? `` : ""}
    ` : ""}
    `); } return cards.length ? `
    ${cards.join("")}
    ` : ""; } // --- fires ---------------------------------------------------------------- // The guild's shared fires. Between polls the client burns them down itself, the same // way the server does: the burn shared evenly between everyone's piles, each pile's // hottest fuel first. const ROMAN = ["I", "II", "III", "IV", "V", "VI"]; // Core to edge of each flame colour, for the animated fires. const FLAME_RAMPS = [["#fff3cf", "#ffb54a", "#ff6a2a", "#8a210a"], ["#fffbe6", "#ffe680", "#f2a81f", "#7a4a06"], ["#ffffff", "#f6f4ff", "#c9c4e0", "#4d4a66"], ["#eef8ff", "#8fd0ff", "#3485ea", "#10347a"], ["#ecfff1", "#8af5ab", "#22b85a", "#0a4a25"], ["#f8efff", "#d6b0ff", "#9150f0", "#3a1474"]]; const STILL = matchMedia("(prefers-reduced-motion: reduce)").matches; let firewood = Number(remember("firewood") ?? -1); // the log tier picked for tending const flames = {}; // fire -> its particles, kept across renders let fireTextAt = 0; const fireOfSkill = (skill) => Object.keys(C.fires).find((id) => C.fires[id].skill === skill); const flameTint = (tier) => tier >= 0 ? C.flames[tier][1] : "#5d626b"; const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firewood[tier]}`).level; function fireNow(id) { const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c })); const hottest = (by) => fuel.filter((c) => c.by === by).reduce((best, c) => (c.tier > best.tier ? c : best)); let burn = Math.max(0, serverNow() - f.at) * f.speed; while (burn > 1e-9 && fuel.length) { const heads = [...new Set(fuel.map((c) => c.by))].map(hottest); const step = Math.min(burn, Math.min(...heads.map((c) => c.left)) * heads.length); for (const c of heads) { c.left -= step / heads.length; if (c.left <= 1e-6) fuel.splice(fuel.indexOf(c), 1); } burn -= step; } const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0); const mine = fuel.filter((c) => c.by === S.uid); return { fuel, mine, heat, left, low: heat >= 0 && left < C.fire_low, speed: f.speed }; } function pickFirewood() { const me = S.me, open = C.firewood.map((_, t) => t).filter((t) => me.levels.firekeeping >= tendLevel(t)); if (open.includes(firewood)) return firewood; return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0; } function firekeepingPage() { const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed; const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1); const logs = C.firewood.map((log, tier) => { const level = tendLevel(tier), locked = me.levels.firekeeping < level; const tip = `${item(log).name} · ${C.flames[tier][0]} · ${locked ? `Needs level ${level}` : `${perLog(tier)} XP a log`}`; return ``; }).join(""); return `
    ${logs}
    ${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP ${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}
    ${Object.keys(C.fires).map((id) => fireCard(id, pick)).join("")}
    `; } function fireCard(id, pick) { const me = S.me, spec = C.fires[id], f = S.city.fires[id], n = fireNow(id), skill = skillOf(spec.skill); const mine = me.action?.id.startsWith(`tend_${id}_`); const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start]; const crafting = f.users.filter((u) => !u.action.startsWith("tend_")); const doing = (u) => C.actions.find((a) => a.id === u.action); const piles = [...new Set(f.fuel.map((c) => c.by))]; const keepers = piles.length ? `🔥 ${piles.length}` : ""; const who = crafting.length ? `👤 ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}` : `👤 0`; return `
    ${glyph(spec.glyph, "#e7e9f0")}${esc(spec.name)}${skill.icon} ${esc(skill.name)}
    🪵 ${fmt(f.saved || 0)}
    ${who}${keepers}
    ${mine ? `
    ` : ""}
    ${esc(why || "")}${mine ? `` : ``}
    `; } // The fire behind a crafting skill, at the top of its page. function fireBand(skill) { const id = fireOfSkill(skill); if (!id) return ""; const n = fireNow(id), spec = C.fires[id], lit = n.heat >= 0; const pays = lit && `Pays the log for tier ${ROMAN[n.heat]} and below${id === "forge" ? ", and a coal" : ""}`; return `
    ${glyph(spec.glyph, lit ? flameTint(n.heat) : "#98a1b4")}
    ${esc(spec.name)} ${lit ? `${C.flames[n.heat][0]} · ` : "Out"}
    ${lit ? `🪵 ≤ ${ROMAN[n.heat]}` : ""}
    `; } function animateFires() { if (!S?.me || !S.city?.fires) return; const text = performance.now() - fireTextAt > 250; if (text) fireTextAt = performance.now(); for (const id of Object.keys(C.fires)) { const canvas = document.querySelector(`[data-flame="${id}"]`); if (!canvas && !text) continue; const n = fireNow(id); if (canvas) drawFlame(canvas, id, n); if (!text) continue; const set = (sel, html) => { for (const el of document.querySelectorAll(sel)) if (el.dataset.v !== html) { el.innerHTML = html; el.dataset.v = html; } }; const bank = S.me.firekeeping.bank * 60, mine = n.mine.reduce((sum, c) => sum + c.left, 0); const tiers = C.flames.map((_, t) => n.mine.filter((c) => c.tier === t).reduce((sum, c) => sum + c.left, 0)); set(`[data-fuel="${id}"]`, tiers.map((left, t) => left ? `` : "").join("")); set(`[data-fire-left="${id}"]`, n.heat >= 0 ? `🔥 ${dur(n.left / n.speed)}` : ""); set(`[data-fire-mine="${id}"]`, `You ${dur(mine / n.speed)} / ${dur(bank / n.speed)}`); set(`[data-fire-time="${id}"]`, dur(n.left / n.speed)); set(`[data-fire-covers="${id}"]`, n.heat >= 0 ? `🪵 ≤ ${ROMAN[n.heat]}` : ""); set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]); for (const el of document.querySelectorAll(`[data-fire-state="${id}"]`)) el.className = `fire-state ${n.heat < 0 ? "out" : n.low ? "low" : ""}`; } } function rampAt(ramp, t) { const s = t * (ramp.length - 1), i = Math.min(Math.floor(s), ramp.length - 2), f = s - i; const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16)); const a = rgb(ramp[i]), b = rgb(ramp[i + 1]); return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(",")})`; } // A fire on a canvas: rising flame particles in its colour, taller with more fuel; // smoke when it's low, a grey bed of ash when it's out. function drawFlame(cv, id, n) { const st = flames[id] ||= { parts: [], at: performance.now() }; const t = performance.now(), dt = Math.min(0.05, (t - st.at) / 1000); st.at = t; const r = cv.getBoundingClientRect(), d = Math.min(devicePixelRatio || 1, 2); if (!r.width) return; if (cv.width !== Math.round(r.width * d)) { cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d); } const ctx = cv.getContext("2d"), W = cv.width, H = cv.height, cx = W / 2, base = H - 22 * d; const lit = n.heat >= 0, ramp = FLAME_RAMPS[Math.max(0, n.heat)]; const power = lit ? Math.min(1, 0.25 + n.left / 14400) : 0; ctx.clearRect(0, 0, W, H); if (lit && !STILL) { for (let i = Math.round(90 * power * dt) + 1; i > 0; i--) st.parts.push({ k: "f", x: cx + (Math.random() - 0.5) * 46 * d * (0.6 + power * 0.6), y: base, vx: (Math.random() - 0.5) * 18 * d, vy: -(40 + Math.random() * 60) * d * (0.55 + power), life: 0, max: 0.55 + Math.random() * 0.5 * (0.5 + power), r: (7 + Math.random() * 9) * d * (0.6 + power * 0.5) }); if (Math.random() < dt * 6 * power) st.parts.push({ k: "e", x: cx + (Math.random() - 0.5) * 40 * d, y: base - 10 * d, vx: (Math.random() - 0.5) * 30 * d, vy: -(60 + Math.random() * 60) * d, life: 0, max: 1 + Math.random(), r: 1.4 * d }); } if (!STILL && (!lit || n.low) && Math.random() < dt * (lit ? 5 : 3)) st.parts.push({ k: "s", x: cx + (Math.random() - 0.5) * 20 * d, y: base - (lit ? 40 * d : 0), vx: (Math.random() - 0.3) * 10 * d, vy: -(18 + Math.random() * 14) * d, life: 0, max: 2.5 + Math.random(), r: (6 + Math.random() * 5) * d }); ctx.fillStyle = "#3a2a1e"; ctx.save(); ctx.translate(cx, base + 8 * d); for (const [angle, w] of [[-0.18, 70], [0.2, 64], [0, 50]]) { ctx.save(); ctx.rotate(angle); ctx.beginPath(); ctx.roundRect(-w / 2 * d, -5 * d, w * d, 10 * d, 5 * d); ctx.fill(); ctx.restore(); } ctx.restore(); if (lit) { const glow = ctx.createRadialGradient(cx, base + 4 * d, 2, cx, base + 4 * d, 60 * d); glow.addColorStop(0, ramp[1] + "aa"); glow.addColorStop(1, ramp[3] + "00"); ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H); } else { ctx.fillStyle = "#4a4a50"; ctx.beginPath(); ctx.ellipse(cx, base + 12 * d, 38 * d, 5 * d, 0, 0, Math.PI * 2); ctx.fill(); } if (lit && STILL) { const h = 44 * d * (0.6 + power * 0.5), g = ctx.createRadialGradient(cx, base - 18 * d, 2, cx, base - 18 * d, h); ramp.forEach((c, i) => g.addColorStop(i / 3, c + (i === 3 ? "00" : "ff"))); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(cx, base - 18 * d, 26 * d, h, 0, 0, Math.PI * 2); ctx.fill(); } st.parts = st.parts.filter((p) => (p.life += dt) < p.max); ctx.globalCompositeOperation = "lighter"; for (const p of st.parts) { p.x += p.vx * dt; p.y += p.vy * dt; const k = p.life / p.max; if (p.k === "f") { p.vx += (cx - p.x) * 1.8 * dt; ctx.globalAlpha = (1 - k) * 0.55; ctx.fillStyle = rampAt(ramp, Math.min(1, k * 1.25)); ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - k * 0.7), 0, Math.PI * 2); ctx.fill(); } else if (p.k === "e") { ctx.globalAlpha = 1 - k; ctx.fillStyle = ramp[1]; ctx.fillRect(p.x, p.y, p.r * 2, p.r * 2); } } ctx.globalCompositeOperation = "source-over"; for (const p of st.parts) if (p.k === "s") { const k = p.life / p.max; ctx.globalAlpha = (1 - k) * 0.22; ctx.fillStyle = "#9aa0ae"; ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + k * 2), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } 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.

    `}
    `; } // 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" && !e.boss ? ` · rare` : ""} ${fmt(v.hp)} HP ${resistIcons(v)}
    `; }).join("")}
    `; } // Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto), // your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with // what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as // it plays out. function combatFrame() { const me = S.me, a = me.action, f = a?.fight; const encounter = a && C.actions.find((x) => x.id === a.id); const waiting = a?.waiting; const others = f ? f.members.filter((m) => m.id !== S.uid) : []; const unit = (id, name, foe = false, extra = "") => `
    ${name}${extra} ${foe ? "" : HEART} ${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}
    `; const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join(""); const run = a?.run; // A dungeon run: where you are in it, and (for the leader) the button to start it, or to go in early while resting. const lead = run?.leader === S.uid; const stage = run ? `
    ${esc(run.dungeon)} ${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""} ${!run.started && lead ? `` : f && lead ? `` : ""}
    ` : ""; const group = run && [S.uid, ...(a.with || [])].map((uid) => uid === S.uid ? "You" : esc(playerName(uid))).join(", "); let foe = `
    ${run && !run.started ? `👥 ${run.size} / ${C.dungeon_size} · ${group}
    ${lead ? "Start when everyone's in" : `${esc(playerName(run.leader))} starts the run`}` : run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back` : run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}
    `; if (f) { const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage; const traits = (v) => `${v.attack.map((k) => dmgIcon(k, "attack")).join("")} ${v.vulnerable.map((k) => `${dmgIcon(k, "vuln")}`).join("")}${v.resists.map((k) => `${dmgIcon(k, "resist")}`).join("")}`; foe = `
    ${encounter?.icon || ""}${esc(f.enemy)}
    ${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` · rare` : ""}`, true, ` ${traits(v)}`)).join("")}
    `; } return `
    ${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}
    ${stage}${foe}
    `; } // Your abilities, from your weapon and your trade: each lights up as it's used and shows its cooldown. // On AUTO a rule uses it on a longer cooldown; on YOU you press it (tap, or its number key) on the real // one, for as long as you're watching. The weapon's ones still to come are dimmed with their level. function abilityBar(me) { const known = me.fighter.abilities, style = me.fighter.style || "sword"; const shield = !!me.equip.shield; const tile = (id, on) => { const ab = C.abilities[id], manual = on && me.manual.includes(id); const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`; const base = (ab.cd || 0) * (me.fighter.mods?.[id]?.cd ?? 1); const cd = base ? ` · ${+(manual ? base : base * C.auto_cd).toFixed(1)}s cooldown` : ""; const boosts = (me.fighter.boosts?.[id] || []).map((n) => ` · ✦ ${n}`).join(""); const tip = `${ab.name}: ${ab.tip}${cd}${boosts}${why ? ` · ${why}` : ""}`; return ` ${on ? `` : ""} `; }; const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id))); const trade = C.trade_ability[me.cls]; const potion = me.health_potion; return `
    ${kit.join("")}${trade ? `${tile(trade, true)}` : ""} ${item(potion || "health_potion_1").icon}${potion ? `${fmt(me.bank[potion])}` : ""}
    `; } // 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}
    `; } const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]); const LAG = 0.6; // seconds the "just lost" part of an HP bar trails behind const STOPPED = 1.2; // seconds an interrupted cast stays on its bar // A line of the fight, terse: who, to whom, how much, with what. function fightLine(e, f) { const [, src, tgt, kind, n, , label] = e; const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id)); const ab = C.abilities[label] || C.enemy_abilities[label]; const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : ""; const hurtsMe = tgt === S.uid && src?.[0] === "e"; const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted"; switch (kind) { case "hit": case "crit": case "tick": return `
  • ${name(src)} → ${name(tgt)} ${n}${kind === "crit" ? " crit" : ""}${what}
  • `; case "miss": return `
  • ${name(src)} → ${name(tgt)} miss
  • `; case "block": return `
  • ${name(tgt)} block${tgt === S.uid ? "" : "s"}${what}
  • `; case "absorb": return `
  • ${C.abilities.ward.icon} ${name(tgt)} soak${tgt === S.uid ? "" : "s"} ${n}
  • `; case "cast": return `
  • ${name(src)}${what}…
  • `; case "use": return `
  • ${name(src)}${what}${tgt && tgt !== src ? ` → ${name(tgt)}` : ""}
  • `; case "stop": return `
  • ${name(src)} ✋ ${name(tgt)}${what}
  • `; case "heal": return `
  • ${label === "potion" ? `${name(tgt)} ${item("health_potion_1").icon} potion` : `${name(src)}${what} → ${name(tgt)}`} +${n}
  • `; case "mend": return `
  • ${name(tgt)} +${n}${what}
  • `; case "cc": return `
  • ${what} ${name(tgt)} held
  • `; case "free": return `
  • ${name(tgt)} breaks free
  • `; case "die": return `
  • ${name(tgt)} falls
  • `; case "enrage": return `
  • ${C.abilities.unstoppable.icon} ${name(src)} ${label === "hard" ? "loses patience" : "enrages"}
  • `; case "yell": return `
  • ${name(src)}: “${esc(label)}”
  • `; case "join": return `
  • ${name(src)} joins the fight
  • `; case "wind": return `
  • ${C.prayers.find((p) => p.id === "second_wind").icon} Second wind! ${name(src)}
  • `; case "down": return `
  • ${C.abilities.concuss.icon} ${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}
  • `; case "left": return `
  • ${name(src)} left
  • `; } return ""; } // Whether a use of an ability came from a press (within the queue window before it), so it ran on its // real cooldown. function pressedNear(f, uid, ab, time) { const m = f.members.find((x) => x.id === uid); return (m.inputs || []).some((i) => i[1] === "press" && i[2] === ab && time >= (f.rest + i[0]) / f.pace - 0.01 && time <= (f.rest + i[0] + 1.5) / f.pace + 0.01); } // Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not, // and when each of your abilities was last used. function fightState(f, t) { const rest = f.rest / f.pace, st = {}; for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, cast: null, out: null, cds: {}, manualAt: {}, pressed: new Set((m.inputs || []).filter((i) => i[1] === "press").map((i) => i[2])) }; f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; }); let shown = -1; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) { const [time, src, tgt, kind, n, hp, label] = f.events[++shown]; const who = st[src], them = st[tgt]; if (them && hp != null) them.hp = hp; if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time; if (who && kind === "cast") { const spec = src[0] === "e" ? C.enemy_abilities[label] : C.abilities[label]; const mod = src[0] === "e" ? 1 : f.members.find((m) => m.id === src)?.mods?.[label]?.cast ?? 1; who.cast = { label, from: time, until: time + spec.cast * mod / f.pace }; } const ab = C.abilities[label]; if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind)) && who.cds[label] !== time) { who.cds[label] = time; who.manualAt[label] = who.pressed.has(`${label}`) && pressedNear(f, src, label, time); } if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt; if (who && kind === "heal" && label === "potion") who.drank = true; if (them && kind === "stop" && them.cast) them.stopped = { label: them.cast.label, at: time }; if (them && (kind === "stop" || kind === "cc")) them.cast = null; if (them && ["hit", "crit", "tick", "heal", "mend"].includes(kind) && n) them.hit = { at: time, n, kind }; if (them && kind === "cc") them.held = true; if (them && (kind === "free" || kind === "die")) them.held = false; if (them && kind === "die") them.out = "dead"; if (who && (kind === "down" || kind === "left")) who.out = kind; if (who && kind === "enrage") who.enraged = true; if (who && kind === "join") { who.hidden = false; who.from = time; } } return { st, shown }; } function animateFight(now) { const a = S.me?.action, f = a?.fight; if (!f) return; const t = now - a.start, rest = f.rest / f.pace; const over = t >= f.end; const resting = t < rest; const { st, shown } = fightState(f, t); const before = fightState(f, t - LAG).st; // the HP a moment ago, trailing the bar const mine = f.members.find((m) => m.id === S.uid) || f.members[0]; const me = st[mine.id]; // Resting: everyone's HP climbs back to full while they eat. if (resting) for (const m of f.members) st[m.id].hp = m.start_hp + (m.hp_in - m.start_hp) * Math.min(1, t / rest); const enemies = f.pack.map((_, i) => st[`e${i}`]); const left = enemies.reduce((sum, e) => sum + e.hp, 0), total = enemies.reduce((sum, e) => sum + e.top, 0); for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${(resting ? 1 : left / total) * 100}%`; for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(me.hp); for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, me.hp / me.top * 100)}%`; const panel = $("[data-fight]"); if (!panel) return; panel.classList.toggle("waiting", resting); const pull = panel.querySelector("[data-pull-btn]"); if (pull) pull.hidden = !resting; const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e"); const live = (id) => id && st[id] && !st[id].out && !st[id].hidden; const tapped = aim.fight === a.start ? aim : {}; const aimed = live(tapped.foe) ? tapped.foe : live(pick?.[2]) ? pick[2] : live(me.aim) ? me.aim : null; const healing = live(tapped.ally) ? tapped.ally : null; for (const el of panel.querySelectorAll("[data-u]")) { const u = st[el.dataset.u]; if (!u) continue; el.classList.toggle("aimed", (el.dataset.u === aimed || el.dataset.u === healing) && !over); const gone = !!u.out || over; el.classList.toggle("out", !!u.out); el.hidden = !!u.hidden; el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`; const was = before[el.dataset.u]; el.querySelector("[data-u-lag]").style.width = `${Math.max(0, Math.max(u.hp, resting ? 0 : was?.hp ?? u.hp) / u.top * 100)}%`; const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]"); const healed = hit && (hit.kind === "heal" || hit.kind === "mend"); num.textContent = hit ? `${healed ? "+" : "−"}${hit.n}` : ""; num.className = `hitnum ${healed ? "good" : "bad"} ${hit?.kind === "crit" ? "crit" : ""}`; num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0"; el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`; const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : ""; const statusEl = el.querySelector("[data-u-status]"); if (statusEl.dataset.key !== status) { statusEl.dataset.key = status; statusEl.innerHTML = status ? C.abilities[status].icon : ""; } el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`; const cast = !gone && u.cast && t < u.cast.until ? u.cast : null; // A cast someone stopped stays up a moment, struck through. const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null; const table = el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities; const spec = cast ? table[cast.label] : stopped ? table[stopped.label] : null; const castEl = el.querySelector("[data-u-cast]"); const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.label}:${stopped.at}` : ""; if (castEl.dataset.key !== key) { castEl.dataset.key = key; castEl.classList.toggle("stopped", !!stopped); castEl.innerHTML = cast ? `${spec.icon} ${esc(spec.name)}` : stopped ? `✋ ${spec.icon} ${esc(spec.name)}interrupted` : ""; } if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`; } // Your abilities: the shade is the cooldown left, a flash when one's just been used. for (const el of panel.querySelectorAll("[data-ab]")) { const id = el.dataset.ab; if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; } const ab = C.abilities[id], used = me.cds?.[id]; const cd = (ab.cd || 0) * (mine.mods?.[id]?.cd ?? 1) * (me.manualAt?.[id] ? 1 : C.auto_cd) / f.pace; const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t); el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`; el.classList.toggle("fired", used != null && t - used < 0.6); } const log = panel.querySelector('[data-f="log"]'); if (log) { const key = `${a.start}:${shown}:${over}:${resting ? Math.ceil(rest - t) : ""}`; if (log.dataset.key !== key) { log.dataset.key = key; const eaten = Object.entries(mine.eaten || {}); const lines = f.events.slice(Math.max(0, shown - 6), shown + 1).reverse().map((e) => fightLine(e, f)).filter(Boolean); if (over) lines.unshift(f.result === "won" ? `
  • Victory! Next fight coming…
  • ` : `
  • ${f.result === "fled" ? "It dragged on: you fall back." : "Beaten."}
  • `); log.innerHTML = resting ? `
  • Resting · ${Math.ceil(rest - t)}s
  • ${eaten.length ? `
  • ${HEART} ${eaten.map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}
  • ` : ""}` : lines.join("") || `
  • Squaring up…
  • `; } } } // A piece of jewellery's bonus, e.g. "+8% accuracy". function jewelText(it) { const st = C.jewel_stats[it.jewel]; return `${st.sign || "+"}${it[it.jewel]}${st.unit} ${st.name.toLowerCase()}`; } // 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`, it.jewel && !["power", "ward"].includes(it.jewel) && jewelText(it)]; return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : ""); } const GEAR_SLOTS = ["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket"]; // 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`)]; } else if (it.jewel) { const st = C.jewel_stats[it.jewel]; row = [cell(glyph(st.glyph, st.tint), `${st.sign || "+"}${it[it.jewel]}${st.unit}`, jewelText(it)), "", ""]; } 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 || ""}
    ${it.unique ? `
    ✦ ${esc(it.unique)}
    ` : ""}`; } // A pattern: what it teaches, and the button to learn it (used up). function patternRow(id, it) { const made = item(it.pattern), skill = skillOf(it.skill), known = (S.me.patterns || []).includes(id); const short = S.me.levels[it.skill] < it.level; return `
    Teaches ${made.icon} ${esc(made.name)} · ${skill.icon} ${skill.name} ${it.level}
    ${known ? `Known` : ``}
    `; } // 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.drink) parts.push(`❤️ +${it.drink} in a fight`); 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(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`); if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it)); if (it.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).name.toLowerCase()}`); if (it.slots) parts.push(storageText(it)); if (it.stack) parts.push(storageText(it)); 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 += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `→` : ""); } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); const chance = ([id, q, odds]) => `${item(id).icon} ${Math.round(odds * 100)}%`; if (a.finds) io += a.finds.map(chance).join(""); let extra = ""; if (a.enemy) { const o = me.fight_odds[a.id], e = a.enemy; const own = (a.dungeon ? `🏰 Dungeon · ${C.group_size}–${C.dungeon_size} ` : a.together ? `👥 Group ` : "") + (a.personal_loot ? `🎁 Own loot` : ""); extra = `${e.boss || own ? `
    ${e.boss ? `Boss ` : ""}${own}
    ` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; if (a.drops) io += a.drops.map(chance).join(""); if (a.loot) { const l = a.loot, pct = (x) => `${Math.round(x * 100)}%`; io += `${item(l.relic).icon}` + `${item(l.patterns[0]).icon} ${l.patterns.length}` + `${item(l.unique).icon} ${pct(l.odds.unique)}`; } if (a.special === "sabotage") extra += `
    -1% 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)} ${Object.keys(a.outputs).map(needTag).join("")}
    Level ${a.level} · ${dur(time)} · ${a.xp} XP
    ${Object.keys(a.inputs).length ? `` : ""}
    ${io ? `
    ${io}
    ` : ""} ${extra} ${me.action?.plot2 && (active || second) ? `
    Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
    ` : ""} ${growingNow && !a.enemy ? `
    ` : ""} ${active && a.enemy ? `
    ⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}
    ` : ""} ${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 ? `` : ""} `}
    `; } // Who else is fighting this encounter, with a button to tag in: one per group fighting together. function fightingHere(a, why) { const mine = S.me.action?.party; const here = S.players.filter((p) => p.id !== S.uid && p.fight === a.id && !(mine && p.party === mine)); const groups = [...new Map(here.map((p) => [p.party || p.id, here.filter((q) => (q.party || q.id) === (p.party || p.id))])).values()]; return groups.map((group) => `
    ⚔️ ${group.map((p) => esc(p.name)).join(", ")}
    `).join(""); } // --- bank ----------------------------------------------------------------- // A strongbox's extra slots or a bag's bigger stack. function storageText(it) { return it.slots ? `+${it.slots} bank slots` : `+${fmt(it.stack)} per stack`; } // The strongbox and bag slots, each with what else you could swap in. function storageGear() { const me = S.me; const card = (slot, empty, outline) => { const id = me.equip[slot], it = id && item(id), size = (x) => x.slots || x.stack; const others = Object.keys(me.bank).filter((x) => item(x).slot === slot).sort((a, b) => size(item(b)) - size(item(a))) .map((x) => ``).join(""); return `
    ${it ? it.icon : glyph(outline, "#98a1b4")}
    ${it ? `${esc(it.name)}${storageText(it)}` : `${empty}`}
    ${others || it ? `
    ${others}${it ? `` : ""}
    ` : ""}
    `; }; return `
    ${card("storage", "No strongbox", "locked-chest")}${card("bag", "No bag", "shoulder-bag")}
    `; } 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 ` ${storageGear()}

    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()} ${strongboxPanel()}
    ${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} resting between fights.
    ${S.me.food === id ? `Your fighting food` : ``}
    ` : ""} ${it.drink ? `
    +${it.drink} HP in a fight, drunk once a fight under ${Math.round(C.potion_below * 100)}% HP.
    ` : ""} ${it.pattern ? patternRow(id, it) : ""} ${it.relic ? `
    Reforges masterworks with a pattern from ${esc(C.actions.find((a) => a.id === it.relic).name)}.
    ` : ""} ${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 || it.station) return `Use for ${skillOf(it.tool || it.station).name.toLowerCase()} (${Math.round(it.speed * 100)}% faster)`; return "Equip"; } function goldPanel() { return `

    Gold

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

    1 favour per gold.

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

    Strongbox${me.rented ? ` · ${me.rented} rented` : ""}

    Yours for good. Each costs 35% more.

    `; } 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(); html += caravanPanel(); 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 += monumentsPanel(); 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; } function lookChip(id) { const c = C.cosmetics[id]; if (c.kind === "colour") return ``; if (c.kind === "emblem") return glyph(emblemGlyph(id), "#e7e9f0"); return `📜`; } const emblemGlyph = (id) => C.cosmetics[id].value || C.actions.find((a) => a.id === C.cosmetics[id].boss).glyph; function caravanPanel() { const c = S.me.caravan; if (!c) return ""; const rows = c.wares.map((w) => { const price = w.skill ? ` · ${COIN} ${fmt(w.prices[w.skill])}` : ""; const picker = w.skill ? "" : ``; const per = w.charges ? `next ${fmt(w.charges)} ${w.per}` : "against the coming threat"; return `
    ${glyph(...w.glyph)}${esc(w.name)} ${w.skill ? `${skillOf(w.skill).icon} ` : ""}${esc(w.text)} · ${per}${picker}
    `; }); const look = c.look, have = look.owned || look.bought; rows.push(`
    ${lookChip(look.id)}${esc(look.name)}For your banner (character sheet)
    `); return `

    🐫 Caravan · leaves in ${countdown(c.until)}

    ${rows.join("")}

    One of each per visit. Wares boost your own work. Pricier the higher your level.

    `; } function monumentsPanel() { const m = S.city.monuments, me = S.me; const rows = Object.entries(C.monuments).map(([key, spec]) => { if (spec.once && m.built.some((b) => b.kind === key)) return ""; const head = `${glyph(spec.glyph, spec.tint)}`; if (key === "statue" && m.carving) { const pick = m.carving.by === S.uid ? ` ` : `${esc(playerName(m.carving.by))} picks who it honours.`; return `
    ${head}${spec.name}Funded. Who should it honour?${pick}
    `; } const fund = m.funds[key] || { gold: 0 }; return `
    ${head}${spec.name}${esc(spec.text)} ${COIN} ${fmt(fund.gold)} / ${fmt(spec.cost)}
    `; }).join(""); const built = m.built.map((b) => { const spec = C.monuments[b.kind]; const donors = Object.entries(b.donors).sort((x, y) => y[1] - x[1]).map(([uid]) => esc(playerName(uid))).join(", "); return `
    ${glyph(spec.glyph, spec.tint)} ${b.of ? `Statue of ${esc(playerName(b.of))}` : spec.name} ${ago(b.at)}

    Carved in: ${donors}

    `; }).join(""); return `
    Monuments
    ${rows}${built}

    Looks only. Gold here earns no favour: your name goes in the stone.

    `; } // Progress towards a goal favour can boost. Favour doubles the next `boost` points players put // in, so the striped stretch behind the bar (twice the boost) is where work counts double. function boostBar(done, total, boost, tip) { const pct = (x) => Math.min(100, x / total * 100); return `
    ${boost ? `` : ""}
    `; } // The building site: labour so far, who is helping, and ways to speed it up. function siteBanner() { const city = S.city, work = city.construction, me = S.me; const spec = C.buildings[work.building]; const queued = city.queue.map((key) => esc(C.buildings[key].name)).join(", "); const hire = S.favours.builders; const working = me.action?.id === "labour"; const builders = S.players.filter((p) => p.skill === "construction" && p.doing === "Work on the building site").map((p) => esc(p.name)); return ``; } // "Rain: farming 10% faster. Clear skies in 3h 20m." function weatherTip(w) { return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${w.next_name} in ${dur(w.until - serverNow())}.`; } function weatherCard() { const h = S.happenings, w = h.weather; return `
    ${h.season.icon} ${h.season.name}, day ${h.season.day}
    ${w.icon} ${esc(w.name)}
    ${w.next_icon} ${esc(w.next_name)} in ${countdown(w.until)}
    `; } // "Fog in 3h 20m: scouting 30% and hunting 10% slower." function forecastTip(w) { return `${w.next_name} in ${dur(w.until - serverNow())}: ${w.next_text.charAt(0).toLowerCase()}${w.next_text.slice(1)}.`; } // Everything going on in the city right now, and what anyone can do about it. function happeningsPanel() { const h = S.happenings, me = S.me, city = S.city; const rows = []; if (h.weather.kind !== "clear") rows.push(`
    ${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, banners = []; const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block); const good = (src) => (src.speed?.[skill] ?? 1) < 1 || (src.yield?.[skill] ?? 1) > 1 || (src.xp?.[skill] ?? 1) > 1; const banner = (tone, icon, name, text, button = "") => banners.push(``); if (touches(h.weather)) banner(good(h.weather) ? "good" : "bad", h.weather.icon, h.weather.name, h.weather.text); for (const e of h.effects) if (touches(e)) banner(good(e) ? "good" : "bad", 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); const button = fix ? (fix.skill === skill ? `` : ``) : ``; banner("bad", p.icon, p.name, p.text, button); } for (const b of S.me.boosts.filter((x) => x.skill === skill)) { const w = C.wares[b.ware]; banner("good", glyph(...w.glyph), w.name, `${w.text} · ${b.left ? `${fmt(b.left)} ${w.per} left` : "until the threat lands"}`); } return banners.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: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", arrows: "Arrows", garrison: "Garrison" }; const full = d.posts * d.wage, owed = Math.max(0, full - d.wages); 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.

    Guards · ${d.guards} / ${d.posts} paid

    Wage chest ${COIN} ${fmt(d.wages)} / ${fmt(full)} · ${d.wage} gold a guard per threat

    Only paid guards stand, and only they hold the armoury's swords and shields. Paying earns favour.

    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.

    `; } // --- work orders ------------------------------------------------------------ const ORDER_ICON = () => glyph("scroll-unfurled", "#e3c060"); // An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough. function canTake(o) { if (o.status !== "open" || o.by === S.uid) return false; if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false; return S.me.levels[o.skill] >= o.level; } // Toasts for new orders you can take, and for things delivered to you. Nothing on the first load. function orderNews(state) { const orders = state.orders || [], uid = state.uid; if (seenOrders) { for (const o of orders) { if (!seenOrders.has(o.id) && o.by !== uid && state.me && state.me.levels[o.skill] >= o.level && o.status === "open") { toast(`📜 ${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""} · ${fmt(o.reward)} gold`, "level"); } const was = deliveredSeen[o.id]; if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good"); const got = (o.earned || {})[uid] || 0, had = earnedSeen[o.id]; if (had !== undefined && got > had) toast(`🤝 ${item(o.item).name} · +${fmt(got - had)} gold`, "good"); } } seenOrders = new Set(orders.map((o) => o.id)); deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered])); earnedSeen = Object.fromEntries(orders.map((o) => [o.id, (o.earned || {})[uid] || 0])); } // What one run of a recipe takes right now: a lit fire pays its log (the forge a coal too). const runInputs = (a) => S.me.fuelled?.[a.id] || a.inputs; const firePays = (a, id, qty) => `the ${C.fires[a.fire].name.toLowerCase()} pays ${fmt(qty)}`; // A reagent the fire pays outright: struck through, nothing to put in. function paidSlot(a, id, qty) { return ``; } // Recipe slots on a crafting order. Anyone can put reagents in the pool; the crafter brings the rest. function orderSlots(o) { const a = C.actions.find((x) => x.id === o.action), out = a.outputs[o.item], ins = runInputs(a); const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out); const worth = Object.entries(ins).reduce((sum, [id, per]) => sum + item(id).value * per, 0); return Object.entries(a.inputs).map(([id, per]) => { const name = item(id).name, pool = o.provided[id] || 0, have = S.me.bank[id] || 0; if (o.status !== "open") { return `
    ${item(id).icon} ×${fmt(per * all)}${esc(name)}
    `; } const need = (ins[id] || 0) * left; if (!need) return paidSlot(a, id, per * left); const who = Object.entries(o.gave || {}).filter(([, items]) => items[id]).map(([uid, items]) => `${playerName(uid)} ${fmt(items[id])}`); const tip = [`${name}: ${fmt(pool)} / ${fmt(need)}`, ...(need < per * left ? [firePays(a, id, per * left - need)] : []), ...who].join(" · "); if (pool >= need) { return `
    ${item(id).icon} ${fmt(need)}✓${esc(name)}
    `; } const give = Math.min(have, need - pool); // What putting them in pays you once they're crafted: your value's slice of the reagents' share. const share = o.by === S.uid ? 0 : Math.round(o.reward / o.qty * out * C.input_share * item(id).value / worth * give); const fill = give ? `${tip} · +${fmt(give)}${share ? ` → ${fmt(share)} gold` : ""}` : `${tip} · 🎒 0`; return `
    ${fmt(pool)} / ${fmt(need)}${esc(name)}
    `; }).join(""); } function orderCard(o) { const it = item(o.item), mine = o.by === S.uid, skill = skillOf(o.skill); const short = S.me.levels[o.skill] < o.level, have = S.me.bank[o.item] || 0, left = o.qty - o.delivered; const progress = o.qty > 1 || o.kind === "gather" ? `
    ${fmt(o.delivered)} / ${fmt(o.qty)}${o.kind === "gather" && Object.keys(o.from).length ? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}
    ` : ""; let buttons = "", status = ""; if (o.status !== "open") { status = `${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}`; if (mine && !o.held) buttons = ``; } else if (mine) { buttons = ``; if (o.claimed) status = `${esc(playerName(o.claimed))}`; } else if (o.kind === "gather") { buttons = ``; } else if (o.claimed && o.claimed !== S.uid) { status = `${esc(playerName(o.claimed))}`; } else { const working = S.me.action?.order === o.id; const why = short ? `Needs ${skill.name.toLowerCase()} ${o.level}` : S.me.blocked[o.action] && !working && !S.me.blocked[o.action].startsWith("Out of") ? S.me.blocked[o.action] : ""; buttons = `${o.claimed === S.uid ? `` : ""} ${have ? `` : ""} ${working ? `Crafting` : ``}`; } const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward); return `
    ${it.icon}
    ${o.kind === "gather" ? "Wanted: " : ""}${esc(it.name)}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}
    ${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}
    ${skill.icon} ${o.level}
    ${o.kind === "craft" ? `
    ${orderSlots(o)}
    ` : ""} ${Object.keys(o.earned || {}).length ? `
    🤝 ${Object.entries(o.earned).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}
    ` : ""} ${progress}
    ${COIN} ${reward}${status}${buttons}
    `; } function orderPicker(list) { const d = draft, actions = Object.entries(list).map(([id, action]) => [id, C.actions.find((x) => x.id === action)]); const skills = [...new Set(actions.map(([, a]) => a.skill))]; const picked = d.item && actions.find(([id]) => id === d.item)?.[1]; d.skill = skills.includes(d.skill) ? d.skill : picked?.skill || skills[0]; const inSkill = actions.filter(([, a]) => a.skill === d.skill); const groups = [...new Set(inSkill.map(([, a]) => a.group).filter(Boolean))]; if (!groups.includes(d.group)) d.group = picked?.skill === d.skill && picked.group || groups[0]; const q = (d.search || "").trim().toLowerCase(); const shown = q ? actions.filter(([id]) => item(id).name.toLowerCase().includes(q)) : inSkill.filter(([, a]) => !groups.length || a.group === d.group); const others = S.players.filter((p) => p.id !== S.uid); const tiles = shown.sort((x, y) => x[1].level - y[1].level).map(([id, a]) => { const makers = others.filter((p) => p.levels[a.skill] >= a.level).length; const tip = `${item(id).name} · ${skillOf(a.skill).name} ${a.level}${d.kind === "craft" ? ` · 👥 ${makers}` : ""}`; return ``; }).join(""); return `
    ${skills.map((k) => ``).join("")}
    ${groups.length && !q ? `
    ${groups.map((g) => ``).join("")}
    ` : ""}
    ${tiles || `—`}
    `; } function orderComposer() { const d = draft, list = d.kind === "craft" ? C.order_recipes : C.order_raw; let slots = "", maker = ""; const a = d.item && C.actions.find((x) => x.id === list[d.item]); if (a) { const smiths = S.players.filter((p) => p.id !== S.uid && p.levels[a.skill] >= a.level).length; maker = `
    ${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}
    `; if (d.kind === "craft") { const runs = Math.ceil(d.qty / a.outputs[d.item]), ins = runInputs(a); slots = Object.entries(a.inputs).map(([id, per]) => { const need = (ins[id] || 0) * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id); if (!need) return paidSlot(a, id, per * runs); return `
    ${fmt(have)} / ${fmt(need)}${esc(item(id).name)}
    `; }).join(""); } } const gold = d.kind === "craft" ? d.reward : d.reward * d.qty; const why = !a ? "" : d.qty > C.order_max[d.kind] ? `Max ${fmt(C.order_max[d.kind])}` : gold > S.me.gold ? `Gold ${fmt(S.me.gold)} / ${fmt(gold)}` : ""; return `
    ${orderPicker(list)}
    ${a ? `
    ${item(d.item).icon}
    ${esc(item(d.item).name)}${maker}
    ` : `
    —
    `}
    × max ${fmt(C.order_max[d.kind])}
    ${slots ? `
    ${slots}
    ` : ""}
    ${COIN} ${d.kind === "gather" ? `each · ${fmt(gold)}` : ""}
    ${why ? `${esc(why)}` : ""}
    `; } function orders() { const all = S.orders || [], open = all.filter((o) => o.status === "open"); const takeable = open.filter(canTake), yours = all.filter((o) => o.by === S.uid); const lists = { take: takeable, open, yours }; if (!lists[orderTab]) orderTab = "take"; const shown = lists[orderTab]; const old = orderTab === "yours" && yours.some((o) => o.status !== "open" && !o.held); const tab =(key, label, n) => ``; return `
    ${ORDER_ICON()}

    Work orders

    ${draft ? orderComposer() : ""}
    ${tab("take", "For you", takeable.length)}${tab("open", "All open", 0)}${tab("yours", "Yours", 0)} ${old ? `` : ""}
    ${shown.length ? `
    ${shown.map(orderCard).join("")}
    ` : `

    None.

    `}`; } // --- board & roster ------------------------------------------------------- function board() { const msgs = S.board.map((m) => `
    ${crest(S.players.find((p) => p.id === m.who)?.look, 18)} ${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 joinButton(p) { if (!p.fight || p.id === S.uid || (p.party && p.party === S.me?.action?.party)) return ""; const why = S.me?.blocked[p.fight]; return ` `; } 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}
    ${titled(p)} ${cls.name} · total level ${p.total} · ${esc(p.look.home)}
    ${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
    ${FAVOUR} ${fmt(p.donated)} given to the city
    ${levels}

    ${joinButton(p)}

    `; }).join("")}
    `; } // Patch notes, newest first. The badge stays until this browser has opened the page since the last release. const NOTE_KINDS = { new: ["✨", "good", "New"], balance: ["⚖️", "gold", "Balance"], fix: ["🔧", "warn", "Fixed"] }; const notesKey = () => C.patch_notes[0] ? `${C.patch_notes[0].date} ${C.patch_notes[0].title}` : ""; const notesUnread = () => !!C.patch_notes[0] && remember("notes") !== notesKey(); function notes() { return C.patch_notes.map((release) => `

    ${esc(release.title)} ${esc(release.date)}

    `).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 gear = [p.equip[`tool_${s.id}`], p.equip[`station_${s.id}`]].filter(Boolean); const marks = (cls.fast.includes(s.id) ? `${cls.icon}` : "") + gear.map((id) => `${item(id).icon}`).join(""); 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)} ${mine ? lookPanel(p) : ""}

    Skills

    ${skillRows}
    `; } function lookPanel(p) { const look = p.look, wear = (kind) => Object.keys(C.cosmetics).filter((k) => C.cosmetics[k].kind === kind && (C.free_looks.includes(k) || p.owned.includes(k))); const swatches = (part) => wear("colour").map((k) => ``).join(""); const emblems = [``, ...wear("emblem").map((k) => ``)].join(""); const home = C.homes.findIndex(([name]) => name === p.shown.home), next = C.homes[home + 1]; return `

    Banner & title

    ${crest(p.shown, 48)}${esc(p.name)}${p.shown.title ? ` ${esc(p.shown.title)}` : ""}

    Field

    ${swatches("field")}

    Trim

    ${swatches("trim")}

    Emblem

    ${emblems}

    Title

    Home · ${esc(p.shown.home)}

    ${next ? `` : `

    As grand as it gets.

    `}

    Dyes, emblems and titles come with the caravan. Titles are also earned: master a skill (50), defend the city, slay a dragon.

    `; } // 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"], ["trinket", "Trinket", "crystal-ball"], ["boots", "Boots", "leather-boot"], ["food", "Food", "hot-meal"], ]; function dollSlot(p, slot, label, outline, picked) { if (slot === "food") return foodSlot(p, 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 === "food") slot = foodPanel(p); else 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("")}
    ${combatStats(p)}
    ${slot}
    `; } // Your fighting numbers, always in the same place under the slot details: icons and numbers, // with what each means on hover. function combatStats(p) { const f = p.fighter, pct = (x) => `${Math.round(x * 100)}%`, dt = C.damage_types[f.damage]; const cell = (icon, value, tip) => `${icon}${value}`; const cells = [ cell(glyph("biceps", "#e06a6a"), p.power, "Power: from your level and gear"), cell(glyph("checked-shield", "#d4dae2"), p.armour, `Armour: ${pct(p.reduction)} less damage from blows`), cell(glyph("magic-shield", "#a77be8"), p.ward, `Ward: ${pct(p.ward_reduction)} less damage from magic`), cell(glyph(dt.glyph, dt.tint), f.max_hit, `Max hit (${dt.name.toLowerCase()})`), cell(glyph("striking-diamonds", "#f0d060"), pct(f.crit), "Crit chance"), cell(glyph("stopwatch", "#9fb3c8"), `${f.interval}s`, `A swing every ${f.interval}s`), cell(glyph("archery-target", "#e0a050"), fmt(f.accuracy), "Accuracy"), cell(glyph("dodging", "#8fd0e0"), fmt(f.evasion), "Evasion"), cell(glyph("shield-reflect", "#e0b04a"), pct(f.block), "Block chance (with a shield)"), ]; return `
    ${cells.join("")}
    `; } // What else in the bank fits the picked slot, best first: click one to put it on. For weapons the // corner shows the damage type, so you can pick one for what you're about to fight. function swapGrid(p, slot, two) { const fits = Object.keys(p.bank).filter((id) => item(id).slot === slot) .sort((a, b) => (item(b).tier ?? 0) - (item(a).tier ?? 0) || (item(b).power ?? 0) - (item(a).power ?? 0)); const tiles = fits.map((id) => { const it = item(id), [skill, level] = it.req || []; const why = two ? `Your ${item(p.equip.weapon).name.toLowerCase()} takes both hands` : skill && p.levels[skill] < level ? `Needs ${skillOf(skill).name.toLowerCase()} ${level}` : ""; const corner = it.style ? dmgIcon(C.styles[it.style].damage) : ""; const tip = [it.name, gearStats(it), it.style ? (it.two_handed ? "two-handed" : "one-handed") : "", why].filter(Boolean).join(" · "); return ``; }); const off = p.equip[slot] && !two ? `` : ""; if (!tiles.length && !off) return `
    Nothing else in your bank fits here.
    `; return `
    Swap for
    ${tiles.join("")}${off}
    `; } // A picked slot only counts if it's one this character has. function slot2(slot, p) { return slot in p.equip || (slot === "food" && "food" in p) ? slot : null; } // Food for resting between fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it. function foodSlot(p, picked) { if (!("food" in p)) return ``; const id = p.food && p.bank[p.food] ? p.food : null; const none = !p.edible.length; const tip = none ? "No food in your bank" : id ? `Food: ${item(id).name}, heals ${item(id).heal}` : "Food: cheapest first"; return ``; } function foodPanel(p) { const id = p.food && p.bank[p.food] ? p.food : null; const head = id ? `
    ${item(id).icon}${esc(item(id).name)} ${HEART} ${item(id).heal} · ${fmt(p.bank[id])} left
    ` : `
    ${ANY_FOOD}Cheapest first
    `; const tiles = [...p.edible].sort((a, b) => item(a).heal - item(b).heal).map((f) => ``); const cheap = ``; const below = p.eat_below ?? C.eat_below; const label = (x) => x === 0 ? "Never" : x === 1 ? "Always" : `${Math.round(x * 100)}%`; const when = `
    Eat below
    ${C.eat_choices.map((x) => ``).join("")}
    `; return head + (p.edible.length ? `
    Eat first
    ${cheap}${tiles.join("")}
    ` : `
    No food in your bank.
    `) + when; } // The equipment panel, the same on the character sheet and the combat page (which leaves out tools). function equipmentPanel(p, mine, tools = true) { return `

    Equipment

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

    Tools

    ${toolSlots(p)}` : ""}
    `; } // Tools: one small slot per gathering skill. // Gathering tools in a row; under them each crafting skill's tool, with its station below it. function toolSlots(p) { const slot = (kind, s) => { const id = p.equip[`${kind}_${s.id}`], it = id && item(id); return ` ${it ? it.icon : s.icon}`; }; const gathering = C.skills.filter((s) => `tool_${s.id}` in p.equip && !(`station_${s.id}` in p.equip)); const crafting = C.skills.filter((s) => `station_${s.id}` in p.equip); return `
    ${gathering.map((s) => slot("tool", s)).join("")}
    ${crafting.map((s) => slot("tool", s) + slot("station", s)).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 && !a.waiting) { 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 + 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); animateFires(); } function loop() { if (S) animate(); requestAnimationFrame(loop); } // --- events --------------------------------------------------------------- document.addEventListener("click", (e) => { const t = e.target.closest("button, [data-menu], [data-close], [data-aim]"); 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) { if (d.navGroup) { tabs[d.nav.slice(6)] = d.navGroup; remember(`tab.${d.nav.slice(6)}`, d.navGroup); } page = d.nav; remember("page", page); selected = null; history.replaceState(null, "", "#" + page); setMenu(false); render(); window.scrollTo(0, 0); return; } if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); } if (d.press) { if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself"); t.closest(".ab")?.classList.add("queued"); const kind = C.abilities[d.press].target, mine = aim.fight === S.me.action?.start; const target = !mine ? null : kind === "enemy" ? aim.foe : kind === "ally" ? aim.ally : null; return act("/api/press", { id: d.press, ...(target ? { target } : {}) }); } if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) }); if ("pull" in d) return act("/api/pull", {}); if ("startRun" in d) return act("/api/run", {}); if (d.learn) return act("/api/learn", { item: d.learn }); if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) }); if (d.aim) { const fight = S.me.action?.start; if (aim.fight !== fight) aim = { fight, foe: null, ally: null }; if (d.aim[0] === "e") aim.foe = d.aim; else aim.ally = d.aim; animateFight(serverNow()); // the outline moves now; the server hears about it in the background return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); } 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.join) { tagging = true; return act("/api/join", { id: d.join }); } if ("orderNew" in d) { draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 }; return render(); } if (d.draftKind) { draft = { kind: d.draftKind, item: "", qty: 1, provide: [], reward: 0 }; clearSearch(); return render(); } if (d.draftSkill) { draft.skill = d.draftSkill; draft.search = ""; clearSearch(); return render(); } if (d.draftGroup) { draft.group = d.draftGroup; return render(); } if (d.draftItem) { pickOrderItem(d.draftItem); render(); // On a phone the named list is long: bring the amount, price and Post into view. if (draft.item && matchMedia("(max-width: 900px)").matches) $(".compose-grid")?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (d.provide) { draft.provide = draft.provide.includes(d.provide) ? draft.provide.filter((x) => x !== d.provide) : [...draft.provide, d.provide]; return render(); } if ("orderPost" in d) { // A fire lit since picking may now pay a reagent that was ticked. const a = draft.kind === "craft" && C.actions.find((x) => x.id === C.order_recipes[draft.item]); const provide = a ? draft.provide.filter((id) => runInputs(a)[id]) : draft.provide; const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide, reward: draft.reward }; return api("/api/order", body).then((state) => { draft = null; orderTab = "yours"; accept(state); }).catch((err) => toast(err.message, "bad")); } if (d.otab) { orderTab = d.otab; return render(); } if (d.orderWork) return act("/api/order/work", { id: Number(d.orderWork) }); if (d.orderDrop) return act("/api/order/drop", { id: Number(d.orderDrop) }); if (d.orderDeliver) return act("/api/order/deliver", { id: Number(d.orderDeliver) }); if (d.orderCancel) return act("/api/order/cancel", { id: Number(d.orderCancel) }); if (d.orderRemove) return act("/api/order/remove", { id: Number(d.orderRemove) }); if ("orderClear" in d) return act("/api/order/remove", {}); if (d.orderFill) return act("/api/order/provide", { id: Number(d.orderFill), item: d.item }); 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.pin) return act("/api/pin", { skill: d.pin }); if (d.track) return act("/api/track", { id: d.track }); if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : 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.wages) return act("/api/wages", { source: d.wages, qty: "all" }); if ("rent" in d) return act("/api/rent", {}); if (d.buy) return act("/api/buy", { ware: d.buy, skill: $("[data-keep=tutor-skill]")?.value }); if ("statue" in d) return act("/api/statue", { of: $("[data-keep=statue-of]").value }); if ("home" in d) return act("/api/home", {}); if (d.look) return act("/api/look", { look: { ...S.me.look, [d.look]: d.value || null } }); 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", {}); }); function clearSearch() { const field = $("[data-keep=order-search]"); if (field) field.value = ""; } function pickOrderItem(id) { const list = draft.kind === "craft" ? C.order_recipes : C.order_raw; draft.item = list[id] ? id : ""; draft.provide = []; // A starting price: what the item's worth, each. draft.reward = draft.item ? item(draft.item).value * (draft.kind === "craft" ? draft.qty : 1) : 0; } document.addEventListener("input", (e) => { if (e.target.dataset.keep !== "order-search" || !draft) return; draft.search = e.target.value; render(); }); document.addEventListener("change", (e) => { if ("lookTitle" in e.target.dataset) return act("/api/look", { look: { ...S.me.look, title: e.target.value || null } }); const key = e.target.dataset.draft; if (!key || !draft) return; if (key === "item") { pickOrderItem(e.target.value); } else { const before = draft.qty; draft[key] = Math.max(key === "qty" ? 1 : 0, Math.floor(Number(e.target.value) || 0)); if (key === "qty") draft.qty = Math.min(draft.qty, C.order_max[draft.kind]); // A crafting order's reward is the total: it follows the quantity at the same price each. if (key === "qty" && draft.kind === "craft") draft.reward = Math.round(draft.reward / before * draft.qty); if (key === "qty" && draft.kind === "craft" && draft.item) { // Drop provided reagents the bank can no longer cover at the new quantity. const a = C.actions.find((x) => x.id === C.order_recipes[draft.item]), runs = Math.ceil(draft.qty / a.outputs[draft.item]); draft.provide = draft.provide.filter((id) => (S.me.bank[id] || 0) >= (runInputs(a)[id] || 0) * runs); } } render(); }); $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); // Number keys press your abilities, in the bar's order (the ones you know). document.addEventListener("keydown", (e) => { if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return; const n = Number(e.key); if (!n) return; const tile = document.querySelectorAll(".ability-bar .ab:not(.locked) [data-press]")[n - 1]; if (tile) { e.preventDefault(); tile.click(); } }); 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 === "monument") { field.value = ""; return act("/api/monument", { id: form.dataset.id, qty: Number(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 && Date.now() - polledAt >= (S?.me?.action?.fight || S?.me?.action?.run ? FIGHT_POLL_MS : POLL_MS)) refresh(); }, 500); requestAnimationFrame(loop); })();