Happenings: weather and seasons, minor threats, hardships, fortunes, dragons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 09:34:18 +01:00
parent a4e4bd4385
commit 75327f30e0
6 changed files with 903 additions and 38 deletions

View file

@ -249,7 +249,10 @@ function devbar() {
<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>
<button class="btn sm" data-dev-event>Threat now</button>
<button class="btn sm" data-dev-happen="fortune">Fortune</button>
<button class="btn sm" data-dev-happen="hardship">Hardship</button>
<button class="btn sm" data-dev-happen="minor">Problem</button>
</div></div>`;
}
@ -267,12 +270,14 @@ function topbar() {
} 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>`;
const w = S.happenings?.weather;
if (w) chips += `<button class="chip" data-nav="town" title="${esc(w.name)}: ${esc(w.text)}">${w.icon}</button>`;
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "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] || "❗";
return { raid: "⚔️", dragon: "🐉", merchant: "🐪", refugees: "🧳" }[kind] || "❗";
}
function eventWhen(ev, short = false) {
@ -328,7 +333,9 @@ function skillPage(id) {
html += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id);
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>`;
const actions = C.actions.filter((a) => a.skill === id);
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.
@ -597,7 +604,9 @@ function town() {
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
${weatherCard()}
</div>`;
html += happeningsPanel();
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>`;
if (city.construction) html += siteBanner();
@ -631,6 +640,61 @@ function siteBanner() {
</div></div>`;
}
function weatherCard() {
const h = S.happenings, w = h.weather;
return `<div class="stat" title="${esc(w.text)}"><div class="k">${h.season.icon} ${h.season.name}, day ${h.season.day}</div>
<div class="v">${w.icon} <small>${esc(w.name)}</small></div>
<div class="note">${w.next === w.kind ? `Staying ${esc(w.name.toLowerCase())}` : `${w.next_icon} ${esc(w.next_name)}`} in ${countdown(w.until)}</div></div>`;
}
// 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(`<div class="happening"><span class="ico">${h.weather.icon}</span><span class="grow"><b>${esc(h.weather.name)}</b><span class="small muted">${esc(h.weather.text)}</span></span></div>`);
if (h.cold) rows.push(`<div class="happening bad"><span class="ico">🥶</span><span class="grow"><b>Out of firewood</b><span class="small muted">People are cold and eating half again as much. Donate logs.</span></span></div>`);
for (const e of h.effects) {
const hire = e.offer ? `<button class="btn sm gold" data-hire ${city.treasury < e.cost ? "disabled" : ""}>Hire for ${fmt(e.cost)} gold</button>` : "";
rows.push(`<div class="happening"><span class="ico">${e.icon}</span><span class="grow"><b>${esc(e.name)}</b>
<span class="small muted">${esc(e.text)} · ${countdown(e.until)} left</span></span>${hire}</div>`);
}
for (const p of h.problems) {
let help = "";
if (p.action) {
const a = C.actions.find((x) => x.id === p.action);
help = `<span class="small muted">${fmt(p.work)} / ${fmt(p.needed)} done</span>
<button class="btn sm primary" data-nav="skill:${a.skill}">${esc(a.name)}</button>`;
}
const gifts = Object.entries(p.need || {}).map(([id, need]) => {
const missing = p.missing[id] || 0;
const mine = Math.min(missing, me.bank[id] || 0), stores = Math.min(missing, city.stockpile[id] || 0);
return `<div class="fund-row">${costChip(item(id).icon, item(id).name, need, need - missing)}<span class="btns">
${mine ? `<button class="btn sm gold" data-aid="${p.kind}" data-item="${id}" data-source="bank">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-aid="${p.kind}" data-item="${id}" data-source="city">Stockpile ${fmt(stores)}</button>` : ""}
</span></div>`;
}).join("");
rows.push(`<div class="happening bad"><span class="ico">${p.icon}</span><span class="grow"><b>${esc(p.name)}</b>
<span class="small muted">${esc(p.text)}${p.until ? ` · ${countdown(p.until)} left` : ""}</span>${gifts}</span>${help}</div>`);
}
if (!rows.length) return "";
return `<div class="panel"><h2>Happenings</h2><div class="happenings">${rows.join("")}</div></div>`;
}
// What's speeding up, slowing down or blocking this skill right now.
function skillHappenings(skill) {
const h = S.happenings, lines = [];
const note = (icon, name, text) => lines.push(`<span>${icon} <b>${esc(name)}</b> <span class="muted">${esc(text)}</span></span>`);
const touches = (src) => ["speed", "yield", "xp"].some((k) => src[k] && skill in src[k]) || (src.block && skill in src.block);
if (touches(h.weather)) note(h.weather.icon, h.weather.name, h.weather.text);
for (const e of h.effects) if (touches(e)) note(e.icon, e.name, e.text);
for (const p of h.problems) {
if (!C.problems[p.kind] || !touches(C.problems[p.kind])) continue;
const fix = p.action && C.actions.find((x) => x.id === p.action);
note(p.icon, p.name, !fix ? p.text : fix.skill === skill ? "Help clear it below." : `${skillOf(fix.skill).name} can clear it: see the Town square.`);
}
return lines.length ? `<div class="panel tool-panel skill-happenings">${lines.join("")}</div>` : "";
}
function buildingCard(key, b) {
const spec = C.buildings[key], city = S.city, me = S.me;
const tags = {
@ -673,8 +737,9 @@ function council() {
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>
const threat = ["raid", "dragon"].includes(ev.kind);
html += `<div class="panel ${threat ? "threat-card" : ""}">
<h2>${eventIcon(ev.kind)} ${threat ? "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>
@ -684,22 +749,24 @@ function council() {
${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") {
if (threat) {
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>`;
}
html += favourSpends();
if (ev?.kind === "raid") html += raidPrep(ev);
if (ev && ["raid", "dragon"].includes(ev.kind)) 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>` : ""}
const fight = ["raid", "dragon"].includes(h.kind);
const tone = fight ? (h.report.title.startsWith("Victory") ? "win" : "loss")
: { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || "";
html += `<div class="report ${tone}">
<b>${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
${fight ? `<p class="muted">Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}</p>` : ""}
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
</div>`;
}
@ -727,7 +794,7 @@ function favourSpends() {
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 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 `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
@ -933,6 +1000,9 @@ document.addEventListener("click", (e) => {
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.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");