"use strict"; // Guildhall client: renders the server's state and animates progress bars // between polls. The server is the source of truth for everything. const POLL_MS = 10000; const SUMMARY_MODAL_AT = 25; let C = null; // static content tables let S = null; // latest state let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let pickedClass = null; // registration let refreshing = false; let refreshedFor = null; // action start a completion refresh was sent for const $ = (sel, root = document) => root.querySelector(sel); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); const fmt = (n) => Math.floor(n).toLocaleString("en-GB"); const serverNow = () => Date.now() / 1000 + skew; const item = (id) => C.items[id] || { name: id, icon: "❔", value: 0 }; const skillOf = (id) => C.skills.find((s) => s.id === id); const playerName = (uid) => S.players.find((p) => p.id === uid)?.name || uid; function remember(key, value) { try { if (value === undefined) return localStorage.getItem("gh." + key); localStorage.setItem("gh." + key, value); } catch { /* private mode: fine */ } return null; } // --- server --------------------------------------------------------------- async function api(path, body) { const options = body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; const res = await fetch(path, options); const data = await res.json().catch(() => ({ error: "The server didn't answer properly" })); if (!res.ok) throw new Error(data.error || res.statusText); return data; } async function act(path, body) { try { accept(await api(path, body)); } catch (err) { toast(err.message, "bad"); } } async function refresh() { if (refreshing) return; refreshing = true; try { accept(await api("/api/state")); } catch (err) { toast(err.message, "bad"); } finally { refreshing = false; } } function accept(state) { S = state; skew = state.now - Date.now() / 1000; if (state.summary) showSummary(state.summary); render(); } // --- time ----------------------------------------------------------------- function dur(seconds) { if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`; seconds = Math.max(0, Math.round(seconds)); const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600); const m = Math.floor(seconds % 3600 / 60), s = seconds % 60; if (d) return `${d}d ${h}h`; if (h) return `${h}h ${m}m`; if (m) return `${m}m ${s}s`; return `${s}s`; } function clock(t) { return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); } function ago(t) { const s = serverNow() - t; if (s < 60) return "just now"; return dur(s) + " ago"; } // --- 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); while ($("#toasts").children.length > 5) $("#toasts").firstChild.remove(); } function showSummary(sum) { if (!sum.done && !sum.stopped) return; if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum); const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); if (got.length) toast(got.join(" · "), "good"); 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`); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); } 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(""); openModal(`

    While you were away

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

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

    +${fmt(sum.gold)} gold

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

    Your bank was full, so this went to the city stockpile (you earned favour for it):

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

    Stopped: ${esc(sum.stopped)}

    ` : ""}

    `); } function openModal(html) { const modal = $("#modal"); modal.innerHTML = ``; modal.hidden = false; } // --- rendering ------------------------------------------------------------ function render() { const kept = {}; for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value; const focus = document.activeElement?.dataset?.keep; $("#side").innerHTML = S.me ? sidebar() : `
    Guildhall alpha
    ${S.dev ? devbar() : ""}`; $("#top").innerHTML = topbar(); $("#main").innerHTML = S.me ? pageHtml() : register(); for (const [key, value] of Object.entries(kept)) { const el = document.querySelector(`[data-keep="${key}"]`); if (el) el.value = value; } if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus(); const msgs = $(".msgs"); if (msgs) msgs.scrollTop = msgs.scrollHeight; animate(); } function nav(id, icon, label, meta = "", extra = "") { return ``; } function sidebar() { const me = S.me, cls = C.classes[me.cls]; const doing = me.action && C.actions.find((a) => a.id === me.action.id); const raid = S.upcoming?.kind === "raid"; let html = `
    Guildhall alpha
    ${esc(me.name)}
    ${cls.icon} ${cls.name}
    🪙 ${fmt(me.gold)}✨ ${fmt(me.favour)}
    ${nav("town", "🏘️", "Town square", `${S.city.population}`)} ${nav("council", "📜", "Council & threats", "", raid ? `` : "")} ${nav("board", "💬", "Town board")} ${nav("roster", "👥", "Guild roster", `${S.players.length}`)} ${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; let group = null; for (const skill of C.skills) { if (skill.class && skill.class !== me.cls) continue; if (skill.group !== group) { group = skill.group; html += ``; } const busy = doing?.skill === skill.id ? `` : ""; html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy); } return html + (S.dev ? devbar() : ""); } function devbar() { return `
    Dev mode · playing as ${esc(S.uid)}
    `; } const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank" }; function topbar() { let title = TITLES[page] || "Guildhall"; if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title; if (!S.me) title = "Welcome to Guildhall"; let chips = ""; if (S.me?.action) { const action = C.actions.find((a) => a.id === S.me.action.id); chips += ``; } else if (S.me) { chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`; } if (S.upcoming) chips += ``; return `

    ${esc(title)}

    ${chips}`; } function eventIcon(kind) { return { raid: "⚔️", merchant: "🐪", refugees: "🧳" }[kind] || "❗"; } function eventWhen(ev, short = false) { const now = serverNow(); if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`; if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`; return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`; } function pageHtml() { if (page.startsWith("skill:")) { const id = page.slice(6); const skill = skillOf(id); if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id); page = "town"; } return ({ town, council, board, roster, bank }[page] || town)(); } function itemChip(id, qty, have) { const short = have !== undefined && have < qty; return `${item(id).icon} ${fmt(qty)}${have !== undefined ? ` / ${fmt(have)}` : ""}`; } // --- skill pages ---------------------------------------------------------- function skillPage(id) { const me = S.me, skill = skillOf(id), level = me.levels[id], xp = me.xp[id]; const floor = C.xp_table[level], ceil = C.xp_table[level + 1]; const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100; const cls = C.classes[me.cls]; const fast = cls.fast.includes(id); let html = `
    ${skill.icon}

    ${skill.name}

    Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP
    ${fast ? `${cls.icon} ${cls.name} bonus` : ""}
    `; if (id === "combat") html += combatPanel(); if (id === "reconnaissance") html += `
    Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
    `; html += `
    ${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}
    `; return html; } function combatPanel() { const me = S.me; const slot = (name, label) => { const id = me.equip[name]; return `
    ${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) ` : `No ${label}`}
    `; }; return `

    Equipment

    ${slot("weapon", "weapon")} ${slot("shield", "shield")}

    Combat power ${me.power} = combat level + gear${me.cls === "warrior" ? ". As a warrior you count for ×1.5 in the garrison" : ""}. Equip swords and shields from the bank.

    `; } function actionCard(a) { const me = S.me; const active = me.action?.id === a.id; const why = me.blocked[a.id]; const locked = why && (why.startsWith("Needs") || why.startsWith("Only")); const out = Object.keys(a.outputs)[0]; const icon = out ? item(out).icon : skillOf(a.skill).icon; const time = me.times[a.id]; let io = ""; if (Object.keys(a.inputs).length) { io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") + `→`; } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); let extra = ""; if (a.skill === "combat") { const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); extra = `
    Win chance ${Math.round(chance * 100)}%${a.gold ? ` · ${a.gold} gold` : ""}
    `; if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
    Each win weakens the coming raid by 3%, up to 30%.
    `; } if (a.special === "recon") extra = `
    +${a.recon} recon on the next event
    `; return `
    ${icon}
    ${esc(a.name)}
    Level ${a.level} · ${dur(time)} · ${a.xp} XP
    ${io ? `
    ${io}
    ` : ""} ${extra} ${active ? `
    ` : ""}
    ${!active && why ? esc(why) : ""} ${active ? `` : ``}
    `; } // --- bank ----------------------------------------------------------------- function bank() { const me = S.me; const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0])); if (selected && !me.bank[selected]) selected = null; let slots = entries.map(([id, q]) => ` `).join(""); for (let i = entries.length; i < me.slots; i++) slots += `
    `; return `

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

    ${slots}

    When an item doesn't fit:
    Raids never touch your bank. Overflow sent to the city still earns favour.

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

    ${esc(it.name)}

    You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
    ${it.slot ? `
    ` : ""} ${it.food ? `
    Feeds ${it.food} townsfolk for two days in the city granary.
    ` : ""}
    Donate to the city · +${it.value} favour each
    ${qtyBtns("data-donate")}
    ${others.length ? `
    Give to
    ${qtyBtns("data-give")}
    ` : ""}
    Sell
    ${qtyBtns("data-sell")}
    ${goldPanel()}`; } function goldPanel() { return `

    Gold

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

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

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

    Favour with the council

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

    More perks to come. The council remembers who helped.

    `; } // --- town ----------------------------------------------------------------- function town() { const city = S.city; let html = `
    Day
    ${city.day}
    Population
    ${city.population} / ${city.housing} homes
    Treasury
    ${fmt(city.treasury)}
    Defence
    ${fmt(city.defence.total)}
    Raids repelled
    ${city.repelled}
    `; if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; const work = city.construction; if (work) { const spec = C.buildings[work.building]; html += ``; } html += `
    Buildings
    `; for (const [key, b] of Object.entries(city.buildings)) { const spec = C.buildings[key]; const cost = b.cost ? Object.entries(b.cost).map(([id, q]) => id === "gold" ? `🪙 ${fmt(q)} / ${fmt(city.treasury)}` : itemChip(id, q, city.stockpile[id] || 0)).join("") : ""; const label = b.damaged ? "Repair" : `Build level ${b.level + 1}`; html += `
    ${spec.icon}
    ${esc(spec.name)} ${b.level} / ${spec.max} ${b.damaged ? `Damaged` : ""}
    ${esc(spec.effect)}
    ${b.cost ? `
    ${cost}
    ${work ? "Builders busy" : ""}
    ` : `
    Fully built
    `}
    `; } html += `
    City stockpile
    `; const stock = Object.entries(city.stockpile); html += stock.length ? `
    ${stock.map(([id, q]) => itemChip(id, q)).join("")}
    ` : `

    Empty. Donate from your bank.

    `; html += `

    Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.

    `; return html; } // --- council -------------------------------------------------------------- function pips(level) { return `${[1, 2, 3].map((i) => ``).join("")}`; } function council() { const ev = S.upcoming, city = S.city, me = S.me; let html = ""; if (!ev) { html += `

    All quiet

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

    `; } else { html += `

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

    ${esc(ev.headline)}
    Expected${eventWhen(ev)} ${ev.range ? `Strength${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}` : ""} ${ev.trait ? `Scouts say${esc(ev.trait)}` : ""} ${ev.camp ? `Their campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} ${me.cls === "scout" || ev.recon ? `Reconnaissance${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}` : ""}
    ${ev.intel < 3 ? `

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

    ` : ""}`; if (ev.kind === "raid") { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); html += `
    Our defence${fmt(city.defence.total)}
    vs
    Raiders${strength}
    `; } html += `
    `; if (ev.kind === "raid") html += raidPrep(ev); } html += `
    Past events
    `; if (!S.history.length) html += `

    Nothing has happened yet.

    `; for (const h of S.history) { if (!h.report) continue; const win = h.kind !== "raid" || h.report.title.startsWith("Victory"); html += `
    ${eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} ${h.kind === "raid" ? `

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

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

    ${esc(l)}

    `).join("")}
    `; } return html; } function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; const names = { townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; return `
    ${item(r.item).icon}${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} ${fmt(r.have)} / ${fmt(r.want)} ${mine ? `` : ""}
    `; }).join(""); return `

    Council requests

    ${reqs}

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

    Defence

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

    Garrison

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

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

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

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

    Town board

    ${msgs || `

    No messages yet. Say hello, or ask for that iron you need.

    `}

    Town ledger

    `; } function roster() { return `
    ${S.players.map((p) => { const cls = C.classes[p.cls]; const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `
    ${s.icon} ${s.name} ${p.levels[s.id]}
    `).join(""); return `
    ${cls.icon}
    ${esc(p.name)} ${cls.name} · total level ${p.total}
    ${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
    ✨ ${fmt(p.donated)} given to the city
    ${levels}
    `; }).join("")}
    `; } // --- registration --------------------------------------------------------- function register() { const cards = Object.entries(C.classes).map(([id, c]) => ` `).join(""); return `

    A new city on the frontier

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

    ${cards}

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

    `; } // --- animation ------------------------------------------------------------ function animate() { const now = serverNow(); const a = S?.me?.action; if (a) { const pct = Math.min(1, (now - a.start) / a.duration); for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; if (pct >= 1 && refreshedFor !== a.start) { refreshedFor = a.start; setTimeout(refresh, 150); } } for (const el of document.querySelectorAll("[data-span]")) { const [start, end] = el.dataset.span.split(",").map(Number); el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%"; } for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now); } function loop() { if (S) animate(); requestAnimationFrame(loop); } // --- events --------------------------------------------------------------- document.addEventListener("click", (e) => { const t = e.target.closest("button, [data-menu], [data-close]"); if (!t) return; const d = t.dataset; if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; } if ("menu" in d) { $("#app").classList.toggle("open"); return; } if (d.nav) { page = d.nav; remember("page", page); selected = null; history.replaceState(null, "", "#" + page); $("#app").classList.remove("open"); render(); window.scrollTo(0, 0); return; } if (d.start) return act("/api/action", { id: d.start }); if ("stop" in d) return act("/api/stop", {}); if ("select" in d) { selected = d.select || null; render(); 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.build) return act("/api/build", { building: d.build }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) }); if ("devEvent" in d) return act("/api/dev/event", {}); }); $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); document.addEventListener("submit", async (e) => { const form = e.target.closest("[data-form]"); if (!form) return; e.preventDefault(); const field = form.querySelector("[data-keep]"); const value = field.value.trim(); if (form.dataset.form === "register") { if (!pickedClass) return toast("Pick a class first", "bad"); return act("/api/register", { name: value, cls: pickedClass }); } if (form.dataset.form === "board") { if (!value) return; field.value = ""; return act("/api/board", { text: value }); } if (form.dataset.form === "gold") { field.value = ""; return act("/api/donate", { item: "gold", qty: Number(value) }); } if (form.dataset.form === "dev-user" && value) { document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`; field.value = ""; return refresh(); } }); document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); (async function boot() { try { C = await api("/api/content"); await refresh(); } catch (err) { $("#main").innerHTML = `

    Couldn't reach the city: ${esc(err.message)}

    `; return; } setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS); requestAnimationFrame(loop); })();