guildhall/static/app.js

756 lines
37 KiB
JavaScript
Raw Normal View History

"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
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmt = (n) => Math.floor(n).toLocaleString("en-GB");
const serverNow = () => Date.now() / 1000 + skew;
const item = (id) => C.items[id] || { name: id, icon: "❔", value: 0 };
const skillOf = (id) => C.skills.find((s) => s.id === id);
const playerName = (uid) => S.players.find((p) => p.id === uid)?.name || uid;
function remember(key, value) {
try {
if (value === undefined) return localStorage.getItem("gh." + key);
localStorage.setItem("gh." + key, value);
} catch { /* private mode: fine */ }
return null;
}
// --- server ---------------------------------------------------------------
async function api(path, body) {
const options = body === undefined ? {} : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
};
const res = await fetch(path, options);
const data = await res.json().catch(() => ({ error: "The server didn't answer properly" }));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function act(path, body) {
try {
accept(await api(path, body));
} catch (err) {
toast(err.message, "bad");
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
accept(await api("/api/state"));
failures = 0;
} catch (err) {
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
} finally {
refreshing = false;
}
}
function accept(state) {
version ??= state.version;
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden) {
location.reload(); // a new build was deployed
return;
}
S = state;
skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary);
render();
}
// --- time -----------------------------------------------------------------
function dur(seconds) {
if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`;
seconds = Math.max(0, Math.round(seconds));
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600);
const m = Math.floor(seconds % 3600 / 60), s = seconds % 60;
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
function clock(t) {
return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
}
// Coarse on purpose: the markup stays the same between polls (see setHtml).
function ago(t) {
const s = serverNow() - t;
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// A live "3h 12m" until `t`, filled in and kept current by animate().
const countdown = (t) => `<span data-countdown="${t}"></span>`;
// --- toasts & summaries ---------------------------------------------------
function toast(text, kind = "") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.textContent = text;
$("#toasts").append(el);
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500);
const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4;
while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove();
}
function showSummary(sum) {
if (!sum.done && !sum.stopped) return;
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`);
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
if (got.length) toast(got.join(" · "), "good");
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]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
const xp = Object.entries(sum.xp).map(([id, q]) => `<li>${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP</li>`).join("");
const levels = sum.levels.map(([id, l]) => `<li>🎉 ${esc(skillOf(id).name)} reached level ${l}</li>`).join("");
const spilled = Object.entries(sum.overflow).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
openModal(`
<h2>While you were away</h2>
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
${xp ? `<ul>${xp}</ul>` : ""}
${levels ? `<ul>${levels}</ul>` : ""}
${spilled ? `<p class="muted small">Your bank was full, so this went to the city stockpile (you earned favour for it):</p><ul>${spilled}</ul>` : ""}
${sum.stopped ? `<p class="tag bad">Stopped: ${esc(sum.stopped)}</p>` : ""}
<p><button class="btn primary" data-close>Back to work</button></p>`);
}
function openModal(html) {
const modal = $("#modal");
modal.innerHTML = `<div class="modal-box">${html}</div>`;
modal.hidden = false;
}
// --- rendering ------------------------------------------------------------
function render() {
const kept = {};
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep;
setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
setHtml($("#main"), S.me ? pageHtml() : register());
for (const [key, value] of Object.entries(kept)) {
const el = document.querySelector(`[data-keep="${key}"]`);
if (el) el.value = value;
}
if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus();
const msgs = $(".msgs");
if (msgs) msgs.scrollTop = msgs.scrollHeight;
animate();
}
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
// cancels the swipe on iOS, and polls arrive every few seconds.
function setHtml(el, html) {
if (el.dataset.html === html) return;
const scroll = el.scrollTop;
el.innerHTML = html;
el.dataset.html = html;
el.scrollTop = scroll;
}
function setMenu(open) {
$("#app").classList.toggle("open", open);
document.documentElement.classList.toggle("menu-open", open);
}
function nav(id, icon, label, meta = "", extra = "") {
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
}
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 = `
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name}</div>
<div class="purse"><span class="gold" title="Gold">🪙 ${fmt(me.gold)}</span><span class="favour" title="Favour">✨ ${fmt(me.favour)}</span></div>
</div>
<div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "")}
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
<div class="navhead">You</div>
${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 += `<div class="navhead">${group}</div>`;
}
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
return html + (S.dev ? devbar() : "");
}
function devbar() {
return `<div class="devbar"><b>Dev mode</b> · playing as ${esc(S.uid)}
<form data-form="dev-user"><input type="text" data-keep="dev-user" placeholder="switch user"><button class="btn sm">Go</button></form>
<div class="row">
<button class="btn sm" data-dev-advance="1">+1h</button>
<button class="btn sm" data-dev-advance="8">+8h</button>
<button class="btn sm" data-dev-advance="24">+1d</button>
<button class="btn sm" data-dev-event>Event now</button>
</div></div>`;
}
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 += `<button class="chip act" data-nav="skill:${action.skill}">${skillOf(action.skill).icon} <span class="label">${esc(action.name)}</span><span class="bar mini"><span data-progress></span></span></button>`;
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
if (S.upcoming) chips += `<button class="chip ${S.upcoming.kind === "raid" ? "threat" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${eventWhen(S.upcoming, true)}</button>`;
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}`;
}
function eventIcon(kind) {
return { raid: "⚔️", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
}
function eventWhen(ev, short = false) {
if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
}
function pageHtml() {
if (page.startsWith("skill:")) {
const id = page.slice(6);
const skill = skillOf(id);
if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id);
page = "town";
}
return ({ town, council, board, roster, bank }[page] || town)();
}
function itemChip(id, qty, have) {
const short = have !== undefined && have < qty;
return `<span class="item-chip ${short ? "short" : ""}" title="${esc(item(id).name)}">${item(id).icon} ${fmt(qty)}${have !== undefined ? ` <span class="have">/ ${fmt(have)}</span>` : ""}</span>`;
}
// Progress towards a building cost: "have / need".
function costChip(icon, name, need, have) {
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(name)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
}
// --- 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 = `
<div class="panel"><div class="skill-head">
<div class="big">${skill.icon}</div>
<div class="info">
<h2>${skill.name}</h2>
<div class="lv">Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP</div>
<div class="bar xp thick"><span style="width:${pct}%"></span></div>
</div>
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`;
if (id === "combat") html += combatPanel();
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
html += `<div class="grid">${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}</div>`;
return html;
}
function combatPanel() {
const me = S.me;
const slot = (name, label) => {
const id = me.equip[name];
return `<div class="item-chip">${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) <button class="btn sm" data-unequip="${name}">Unequip</button>` : `<span class="muted">No ${label}</span>`}</div>`;
};
return `<div class="panel"><h2>Equipment</h2>
<div class="btns">${slot("weapon", "weapon")} ${slot("shield", "shield")}</div>
<p class="small muted">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.</p></div>`;
}
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("") + `<span class="arrow">→</span>`;
}
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 = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b>${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
}
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
return `
<div class="action ${active ? "active" : ""} ${locked ? "locked" : ""}">
<div class="top"><div class="ico">${icon}</div><div>
<div class="name">${esc(a.name)}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
</div></div>
${io ? `<div class="io">${io}</div>` : ""}
${extra}
${active ? `<div class="bar thick"><span data-progress></span></div>` : ""}
<div class="foot">
<span class="why">${!active && why ? esc(why) : ""}</span>
${active ? `<button class="btn danger" data-stop>Stop</button>` : `<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button>`}
</div>
</div>`;
}
// --- 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]) => `
<button class="slot ${selected === id ? "on" : ""}" data-select="${id}" title="${esc(item(id).name)}">
<span class="qty ${q >= me.stack ? "full" : ""}">${fmt(q)}</span>${item(id).icon}<span class="nm">${esc(item(id).name)}</span>
</button>`).join("");
for (let i = entries.length; i < me.slots; i++) slots += `<div class="slot empty"></div>`;
return `
<div class="bank-wrap">
<div>
<div class="panel">
<h2>Bank · ${entries.length} / ${me.slots} slots · up to ${fmt(me.stack)} each</h2>
<div class="slots">${slots}</div>
<p class="small muted">When an item doesn't fit:
<button class="btn sm ${me.overflow === "city" ? "gold" : ""}" data-overflow="city">Send it to the city</button>
<button class="btn sm ${me.overflow === "stop" ? "gold" : ""}" data-overflow="stop">Stop working</button>
<br>Raids never touch your bank. Overflow sent to the city still earns favour.</p>
</div>
${favourPanel()}
</div>
<div>${selected ? itemPanel(selected) : goldPanel()}</div>
</div>`;
}
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) => `<button class="btn sm" ${attr}="${id}" data-qty="${q}">${q}</button>`).join("")
+ `<button class="btn sm" ${attr}="${id}" data-qty="all">All (${fmt(have)})</button>`;
return `<div class="panel item-panel">
<div class="ico">${it.icon}</div>
<h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${it.slot ? `<div class="row"><button class="btn gold" data-equip="${id}">Equip ${it.slot} (+${it.power} power)</button></div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""}
<div class="row"><div class="k">Donate to the city · +${it.value} favour each</div><div class="btns">${qtyBtns("data-donate")}</div></div>
${others.length ? `<div class="row"><div class="k">Give to <select data-keep="give-to">${others.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select></div><div class="btns">${qtyBtns("data-give")}</div></div>` : ""}
<div class="row"><div class="k">Sell</div><div class="btns">${qtyBtns("data-sell")}</div></div>
<div class="row"><button class="btn sm" data-select="">Close</button></div>
</div>${goldPanel()}`;
}
function goldPanel() {
return `<div class="panel"><h2>Gold</h2>
<p class="gold">🪙 ${fmt(S.me.gold)}</p>
<form data-form="gold" class="btns"><input type="number" min="1" data-keep="gold" placeholder="Amount" style="width:120px"><button class="btn gold">Donate to treasury</button></form>
<p class="small muted">The treasury pays the gold part of new buildings. 1 favour per gold.</p></div>`;
}
function favourPanel() {
const me = S.me;
return `<div class="panel"><h2>Favour with the council</h2>
<p class="favour">✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
<p class="small muted">More perks to come. The council remembers who helped.</p></div>`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
let html = `<div class="stats">
<div class="stat"><div class="k">Day</div><div class="v">${city.day}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div></div>
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div></div>
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
</div>`;
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
const work = city.construction;
if (work) {
const spec = C.buildings[work.building];
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
}
html += `<div class="section-title">Buildings</div><div class="grid wide">`;
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"
? costChip("🪙", "Gold", q, city.treasury)
: costChip(item(id).icon, item(id).name, q, city.stockpile[id] || 0)).join("") : "";
const label = b.damaged ? "Repair" : `Build level ${b.level + 1}`;
html += `<div class="action building ${b.damaged ? "damaged" : ""}">
<div class="top"><div class="ico">${spec.icon}</div><div>
<div class="name">${esc(spec.name)} <span class="lv muted">${b.level} / ${spec.max}</span> ${b.damaged ? `<span class="tag bad">Damaged</span>` : ""}</div>
<div class="sub">${esc(spec.effect)}</div>
</div></div>
${b.cost ? `<div class="cost">${cost}</div>
<div class="foot"><span class="why">${work ? "Builders busy" : ""}</span>
<button class="btn ${b.damaged ? "danger" : "primary"}" data-build="${key}" ${b.missing.length || work ? "disabled" : ""}>${label}</button></div>`
: `<div class="tag good">Fully built</div>`}
</div>`;
}
html += `</div><div class="section-title">City stockpile</div><div class="panel">`;
const stock = Object.entries(city.stockpile);
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty. Donate from your bank.</p>`;
html += `<p class="small muted">Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.</p></div>`;
return html;
}
// --- council --------------------------------------------------------------
function pips(level) {
return `<span class="pips" title="Intel level ${level} of 3">${[1, 2, 3].map((i) => `<i class="${i <= level ? "on" : ""}"></i>`).join("")}</span>`;
}
function council() {
const ev = S.upcoming, city = S.city, me = S.me;
let html = "";
if (!ev) {
html += `<div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads. The spymasters will report as soon as something stirs.</p></div>`;
} else {
html += `<div class="panel ${ev.kind === "raid" ? "threat-card" : ""}">
<h2>${eventIcon(ev.kind)} ${ev.kind === "raid" ? "Threat" : "News"} · intel ${pips(ev.intel)}</h2>
<div class="headline">${esc(ev.headline)}</div>
<div class="kv">
<span class="k">Expected</span><span>${eventWhen(ev)}</span>
${ev.range ? `<span class="k">Strength</span><span>${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}</span>` : ""}
${ev.trait ? `<span class="k">Scouts say</span><span>${esc(ev.trait)}</span>` : ""}
${ev.camp ? `<span class="k">Their camp</span><span>Located. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.</span>` : ""}
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}</span>` : ""}
</div>
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p>` : ""}`;
if (ev.kind === "raid") {
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
}
html += `</div>`;
if (ev.kind === "raid") html += raidPrep(ev);
}
html += `<div class="section-title">Past events</div>`;
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
for (const h of S.history) {
if (!h.report) continue;
const win = h.kind !== "raid" || h.report.title.startsWith("Victory");
html += `<div class="report ${h.kind === "raid" ? (win ? "win" : "loss") : ""}">
<b>${eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
${h.kind === "raid" ? `<p class="muted">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(", ")}` : ""}</p>` : ""}
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
</div>`;
}
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 `<div class="req"><span>${item(r.item).icon}</span><span>${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join("");
return `<div class="cols">
<div class="panel"><h2>Council requests</h2>${reqs}
<p class="small muted">Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.</p></div>
<div class="panel"><h2>Defence</h2>
<table class="parts">${Object.entries(d.parts).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")}
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>
<h2 style="margin-top:16px">Garrison</h2>
<p class="small">${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `<span class="muted">Nobody has signed up yet.</span>`}</p>
${me.garrison ? `<button class="btn" data-garrison="0">Leave the garrison</button>`
: `<button class="btn primary" data-garrison="1">Stand guard (+${me.cls === "warrior" ? me.power * 1.5 : me.power})</button>`}
<p class="small muted">You can keep working while you're signed up. Victory earns the garrison favour and combat XP.</p>
</div></div>`;
}
// --- board & roster -------------------------------------------------------
function board() {
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${esc(playerName(m.who))}</span><span class="when">${ago(m.at)}</span><div class="text">${esc(m.text)}</div></div>`).join("");
const ledger = [...S.ledger].reverse().map((l) => `<li>${esc(l.text)}<span class="when">${ago(l.at)}</span></li>`).join("");
return `<div class="cols">
<div class="panel"><h2>Town board</h2>
<div class="msgs">${msgs || `<p class="muted">No messages yet. Say hello, or ask for that iron you need.</p>`}</div>
<form data-form="board"><textarea data-keep="board" maxlength="500" placeholder="Write to the guild…"></textarea>
<p><button class="btn primary">Post</button></p></form></div>
<div class="panel"><h2>Town ledger</h2><ul class="ledger">${ledger || `<li class="muted">Nothing recorded yet.</li>`}</ul></div>
</div>`;
}
function roster() {
return `<div class="grid wide">${S.players.map((p) => {
const cls = C.classes[p.cls];
const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `<div>${s.icon} <span>${s.name}</span> ${p.levels[s.id]}</div>`).join("");
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</span>
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
<div class="small favour">✨ ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div></div>`;
}).join("")}</div>`;
}
// --- registration ---------------------------------------------------------
function register() {
const cards = Object.entries(C.classes).map(([id, c]) => `
<button class="class-card ${pickedClass === id ? "on" : ""}" data-class="${id}">
<span class="ico">${c.icon}</span><span><b>${c.name}</b><p>${esc(c.bonus)}.</p><p>${esc(c.signature)}.</p></span>
</button>`).join("");
return `<div class="welcome">
<h2>A new city on the frontier</h2>
<p class="muted">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.</p>
<div class="classes">${cards}</div>
<form data-form="register" class="btns">
<input type="text" data-keep="name" maxlength="20" placeholder="Your adventurer's name" style="flex:1;min-width:200px">
<button class="btn primary" ${pickedClass ? "" : "disabled"}>Enter the city</button>
</form>
<p class="small muted">Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.</p>
</div>`;
}
// --- animation ------------------------------------------------------------
function animate() {
const now = serverNow();
const a = S?.me?.action;
if (a) {
const pct = Math.min(1, (now - a.start) / a.duration);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start;
setTimeout(refresh, 150);
}
}
for (const el of document.querySelectorAll("[data-span]")) {
const [start, end] = el.dataset.span.split(",").map(Number);
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
}
function loop() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if ("stop" in d) return act("/api/stop", {});
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });
if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value });
if (d.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip });
if (d.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(); });
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
(function swipeMenu() {
const phone = matchMedia("(max-width: 900px)");
let drag = null;
const reset = () => {
$("#side").style.transform = $("#side").style.transition = "";
$("#scrim").style.opacity = "";
$("#app").classList.remove("dragging");
};
document.addEventListener("touchstart", (e) => {
if (drag) release();
if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return;
const t = e.touches[0];
drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target };
drag.target.addEventListener("touchmove", move, { passive: false });
drag.target.addEventListener("touchend", finish);
drag.target.addEventListener("touchcancel", finish);
}, { passive: true });
function release() {
drag.target.removeEventListener("touchmove", move);
drag.target.removeEventListener("touchend", finish);
drag.target.removeEventListener("touchcancel", finish);
if (drag.axis === "x") reset();
drag = null;
}
function move(e) {
if (!drag) return;
const t = e.touches[0];
const dx = t.clientX - drag.x, dy = t.clientY - drag.y;
if (!drag.axis) {
if (Math.hypot(dx, dy) < 10) return;
const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0);
drag.axis = sideways ? "x" : "y";
if (!sideways) return;
drag.width = $("#side").offsetWidth;
$("#side").style.transition = "none";
$("#app").classList.add("dragging");
}
if (drag.axis !== "x") return;
e.preventDefault();
drag.dx = dx;
// 0 is fully open, -width fully shut.
const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width));
$("#side").style.transform = `translateX(${offset}px)`;
$("#scrim").style.opacity = String(1 + offset / drag.width);
}
function finish(e) {
if (!drag) return;
const swiped = drag.axis === "x";
const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms
const open = drag.open
? !(drag.dx < -drag.width * 0.35 || speed < -0.4)
: drag.dx > drag.width * 0.35 || speed > 0.4;
// Hand back to the stylesheet; its transition carries on from here.
release();
if (swiped) setMenu(open);
}
})();
(async function boot() {
try {
C = await api("/api/content");
await refresh();
} catch (err) {
$("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;
return;
}
setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS);
requestAnimationFrame(loop);
})();