${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 += `
`;
if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`;
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.
`;
html += skillHappenings(id);
const live = new Set(S.happenings.problems.map((p) => p.kind));
const actions = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem)));
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("");
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions.filter((a) => !a.group);
if (loose.length) 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
${skillOf("prayer").icon} ${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`);
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} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`);
}
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 = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length ? `→` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = "";
if (a.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 === "labour") {
const boost = S.favours.builders.boost;
extra = `
${skillOf("construction").icon} +${me.labour_per_action} labour per action${boost ? ` ×2 labour and XP for the next ${fmt(boost)}` : ""}
`;
}
// --- bank -----------------------------------------------------------------
function bank() {
const me = S.me;
const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0]));
if (selected && !me.bank[selected]) selected = null;
// An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
const stacks = entries.flatMap(([id, q]) => {
const parts = [];
for (let left = q; left > 0; left -= me.stack) parts.push([id, Math.min(left, me.stack)]);
return parts;
});
let slots = stacks.map(([id, q]) => `
`).join("");
for (let i = stacks.length; i < me.slots; i++) slots += ``;
return `
Bank · ${me.slots_used} / ${me.slots} slots · ${fmt(me.stack)} per stack
${slots}
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
${FAVOUR} ${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 = `
Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.
`;
return html;
}
// 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 `
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}${hire.boost ? `Master builder on site: the next ${fmt(hire.boost)} labour counts double, with double XP. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}
`;
}
// "Rain: farming 10% faster. Clear skies in 3h 20m."
function weatherTip(w) {
const next = w.next === w.kind ? `Staying ${w.name.toLowerCase()}` : w.next_name;
return `${w.name}: ${w.text.charAt(0).toLowerCase()}${w.text.slice(1)}. ${next} 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}
`;
}
// 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(`
`;
}
// 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 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 ${FAVOUR} ${fmt(me.favour)}
${option("builders", "🏗️", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)}
${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 = { mercenaries: "Hired sellswords", 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 `
Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.
Defence
${Object.entries(d.parts).map(([k, v]) => `
${names[k]}
${fmt(v)}
`).join("")}
${d.starving ? `
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.
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.install) return act("/api/install", { fitting: d.install, source: d.source });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.unpost) {
if (!confirm("Delete this message?")) return;
return act("/api/board/delete", { id: Number(d.unpost) });
}
if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source });
if ("hire" in d) return act("/api/hire", {});
if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen });
if (d.pray) {
const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`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 = iconise(await api("/api/content"));
await refresh();
} catch (err) {
$("#main").innerHTML = `