${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)}
${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 += `
`;
if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel();
html += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id);
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.
`;
const actions = C.actions.filter((a) => a.skill === id);
const groups = [...new Set(actions.map((a) => a.group).filter(Boolean))];
if (groups.length > 2) {
// Many tiers: one tab per tier, opening on the highest one you can work.
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
if (!groups.includes(current)) current = [...groups].reverse().find(open) || groups[0];
const bar = groups.map((g) => ``).join("");
return html + `
`;
}
// 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 += `
`;
}
// Locked: missing a level, class or building, as opposed to just out of materials.
function isLocked(a) {
const why = S.me.blocked[a.id];
return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only")));
}
// The skill's active potion, or the ones in the bank that could be drunk for it.
function potionPanel(skill) {
const me = S.me;
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (!kinds.length) return "";
const active = me.potions[skill];
const owned = kinds.filter(([id]) => me.bank[id]);
const drink = owned.map(([id, it]) => ``).join("");
return `
`;
}
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
🙏 ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}
${cards}
Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.
Temple
${temple ? `
Devotion ${fmt(city.devotion)} · divine ward +${city.ward} defence for the next raid (up to +${15 * temple} at temple level ${temple}).
Offering bones builds devotion; it wards the city through the next raid, then starts again. The temple also adds ${temple} prayer point${temple > 1 ? "s" : ""} to every bone you bury.
`
: `
No temple yet. Build one on the Town square to offer bones for a divine ward in raids, and to get more prayer points from bones.
${tool
? `${item(tool).icon} ${esc(item(tool).name)}${Math.round(item(tool).speed * 100)}% faster
`
: `No tool. A smith can make you one that speeds this up.`}
`;
}
function combatPanel() {
const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
const slot = (name, label) => {
const id = me.equip[name];
return `
Below half health you eat from your bank automatically.
${me.edible.length ? "" : `You have no food in your bank.`}
Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.
Power ${me.power} (level + weapon + shield + half your armour) · armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take${me.cls === "warrior" ? " · as a warrior you count ×1.5 in the garrison" : ""}. Equip gear from the bank.
`;
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.food) parts.push(`feeds ${it.food} ${it.food === 1 ? "person" : "people"} for two days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.armour) parts.push(`+${it.armour} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
return parts.join(", ");
}
function actionCard(a) {
const me = S.me;
const active = me.action?.id === a.id;
const why = me.blocked[a.id];
const locked = isLocked(a);
const out = Object.keys(a.outputs)[0];
const icon = 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("")
+ (Object.keys(a.outputs).length ? `→` : "");
}
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)}% · about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` · ${a.gold} gold` : ""}
`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join("");
if (a.special === "sabotage") extra += `
Each win weakens the coming raid by 3%, up to 30%.
`;
}
if (a.special === "recon") extra = `
+${a.recon} recon on the next event
`;
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "bury") extra = `
🙏 +${a.pp + temple} prayer points
`;
if (a.special === "offer") extra = `
⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward
`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
if (made.length) extra += `
${made.join(" · ")}
`;
return `
${icon}
${esc(a.name)}
Level ${a.level} · ${dur(time)} · ${a.xp} XP
${io ? `
${io}
` : ""}
${extra}
${active ? `
` : ""}
${active && me.action.left ? `
Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
`;
}
// --- 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 earns half the favour of giving it yourself;
anything the city has no use for is sold instead.
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
Favour can also hire builders, pay informants and rally the militia: see .
`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
const growth = city.starving ? `Hungry: ${city.growth} a day`
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 ? `Overcrowded: ${city.growth} a day`
: `Full. Build more houses to grow`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "The largest a settlement can be";
let html = `
`;
}
// --- 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.
`;
}
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 ✨ ${fmt(me.favour)}
${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Halves the time left on the current building (saves about ${dur(Math.round(f.builders.saves * 60) * 60)}).`)}
${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")}
${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
`;
}
function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence;
const names = { ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", 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 `
You're one of a handful of adventurers settling a young city at the edge of the kingdom.
Train your skills, supply the council, and keep the place standing when the bandits come.
Your adventurer keeps working while you're away, for up to a day.
${cards}
Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.
`;
}
// --- animation ------------------------------------------------------------
function animate() {
const now = serverNow();
const a = S?.me?.action;
if (a) {
const pct = Math.min(1, (now - a.start) / a.duration);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start;
setTimeout(refresh, 150);
}
}
for (const el of document.querySelectorAll("[data-span]")) {
const [start, end] = el.dataset.span.split(",").map(Number);
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
}
function loop() {
if (S) animate();
requestAnimationFrame(loop);
}
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if ("select" in d) {
selected = d.select || null;
render();
// On a phone the item panel sits under the bank grid: bring it into view.
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const qty = d.qty === "all" ? "all" : Number(d.qty);
if (d.donate) return act("/api/donate", { item: d.donate, qty });
if (d.sell) return act("/api/sell", { item: d.sell, qty });
if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value });
if (d.equip) return act("/api/equip", { item: d.equip });
if (d.unequip) return act("/api/equip", { slot: d.unequip });
if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.pray) {
const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad");
return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] });
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; }
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {});
});
document.addEventListener("change", (e) => {
if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null });
});
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
document.addEventListener("submit", async (e) => {
const form = e.target.closest("[data-form]");
if (!form) return;
e.preventDefault();
const field = form.querySelector("[data-keep]");
const value = field.value.trim();
if (form.dataset.form === "register") {
if (!pickedClass) return toast("Pick a class first", "bad");
return act("/api/register", { name: value, cls: pickedClass });
}
if (form.dataset.form === "board") {
if (!value) return;
field.value = "";
return act("/api/board", { text: value });
}
if (form.dataset.form === "gold") {
field.value = "";
return act("/api/donate", { item: "gold", qty: Number(value) });
}
if (form.dataset.form === "dev-user" && value) {
document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`;
field.value = "";
return refresh();
}
});
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
(function swipeMenu() {
const phone = matchMedia("(max-width: 900px)");
let drag = null;
const reset = () => {
$("#side").style.transform = $("#side").style.transition = "";
$("#scrim").style.opacity = "";
$("#app").classList.remove("dragging");
};
document.addEventListener("touchstart", (e) => {
if (drag) release();
if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return;
const t = e.touches[0];
drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target };
drag.target.addEventListener("touchmove", move, { passive: false });
drag.target.addEventListener("touchend", finish);
drag.target.addEventListener("touchcancel", finish);
}, { passive: true });
function release() {
drag.target.removeEventListener("touchmove", move);
drag.target.removeEventListener("touchend", finish);
drag.target.removeEventListener("touchcancel", finish);
if (drag.axis === "x") reset();
drag = null;
}
function move(e) {
if (!drag) return;
const t = e.touches[0];
const dx = t.clientX - drag.x, dy = t.clientY - drag.y;
if (!drag.axis) {
if (Math.hypot(dx, dy) < 10) return;
const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0);
drag.axis = sideways ? "x" : "y";
if (!sideways) return;
drag.width = $("#side").offsetWidth;
$("#side").style.transition = "none";
$("#app").classList.add("dragging");
}
if (drag.axis !== "x") return;
e.preventDefault();
drag.dx = dx;
// 0 is fully open, -width fully shut.
const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width));
$("#side").style.transform = `translateX(${offset}px)`;
$("#scrim").style.opacity = String(1 + offset / drag.width);
}
function finish(e) {
if (!drag) return;
const swiped = drag.axis === "x";
const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms
const open = drag.open
? !(drag.dx < -drag.width * 0.35 || speed < -0.4)
: drag.dx > drag.width * 0.35 || speed > 0.4;
// Hand back to the stylesheet; its transition carries on from here.
release();
if (swiped) setMenu(open);
}
})();
(async function boot() {
try {
C = await api("/api/content");
await refresh();
} catch (err) {
$("#main").innerHTML = `