Raids: alarm at the gates, battle replay and report popup for everyone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 13:12:12 +01:00
parent fcaabf5ae2
commit a35bcd6512
5 changed files with 146 additions and 7 deletions

View file

@ -18,6 +18,8 @@ let refreshedFor = null; // action (start and length) a completion refresh was s
let retriedAt = 0; // last time a fight past its kill asked again for the next one
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
let warned = null; // threat the "at the gates" alarm was raised for
let watching = null; // raid report open in the modal
const $ = (sel, root = document) => root.querySelector(sel);
@ -90,7 +92,13 @@ function accept(state) {
S = state;
skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary);
const ev = state.upcoming;
if (ev?.at_gates && warned !== ev.id) {
warned = ev.id;
toast(ev.kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!", "bad");
}
render();
showThreat();
}
// --- time -----------------------------------------------------------------
@ -176,6 +184,78 @@ function awayModal(sum) {
<p><button class="btn primary" data-close>Back to work</button></p>`);
}
// A raid or dragon attack, played out for everyone once: the clash, then the report.
function showThreat() {
const t = S.threat_report;
if (!t || watching === t.id || !$("#modal").hidden) return;
watching = t.id;
const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
const foe = dragon ? "The dragon" : "Raiders";
openModal(`
<div class="battle">
<h2>${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}</h2>
<div class="battle-side"><span><b>Our defence</b> <span class="muted">${fmt(r.defence)}</span></span>
<div class="bar thick hp you"><span data-battle="us" style="width:100%"></span></div></div>
<div class="battle-side"><span><b>${foe}</b> <span class="muted">${fmt(r.attack)}</span></span>
<div class="bar thick hp"><span data-battle="them" style="width:100%"></span></div></div>
<ul class="fight-log battle-log" data-battle="log"><li class="muted">${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}</li></ul>
<div data-battle="report" hidden>
<div class="battle-result ${won ? "win" : "loss"}">${esc(r.title)}</div>
${r.garrison.length ? `<p class="small muted">Garrison: ${r.garrison.map((g) => esc(g.name)).join(", ")}</p>` : ""}
${r.lines.map((l) => `<p class="small">${esc(l)}</p>`).join("")}
</div>
<p class="btns"><button class="btn" data-battle-skip>Skip</button>
<button class="btn primary" data-raid-seen="${t.id}" hidden>Close</button></p>
</div>`);
playBattle(t.id, r.defence, r.attack, won, dragon);
}
// Pure theatre: the outcome is already decided. The loser's bar runs out; the winner's ends
// as high as the gap between the two sides.
function playBattle(id, us, them, won, dragon) {
const steps = 10, every = 900;
let seed = id * 9301 + 49297;
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const weights = Array.from({ length: steps }, () => 0.5 + rand());
const total = weights.reduce((a, b) => a + b, 0);
const loserEnd = 0, winnerEnd = Math.max(0.1, 1 - Math.min(us, them) / Math.max(us, them, 1));
const ours = won ? winnerEnd : loserEnd, theirs = won ? loserEnd : winnerEnd;
const lines = {
good: dragon ? ["Arrows find the dragon's wing", "The ballista bolt strikes home", "Spears drive it back from the walls"]
: ["Arrows rain down on the raiders", "The walls hold", "The militia drives them back", "A charge breaks their line"],
bad: dragon ? ["Fire sweeps the rooftops", "Its claws tear at the gate", "The defenders scatter from its breath"]
: ["Raiders scale the walls", "The gate shudders under the ram", "A volley cuts down defenders"],
};
let step = 0, done = 0, said = "";
const finish = () => {
clearInterval(timer);
const box = $(".battle");
if (!box) return;
$('[data-battle="us"]', box).style.width = `${ours * 100}%`;
$('[data-battle="them"]', box).style.width = `${theirs * 100}%`;
$('[data-battle="report"]', box).hidden = false;
$("[data-battle-skip]", box).hidden = true;
$("[data-raid-seen]", box).hidden = false;
};
const timer = setInterval(() => {
const box = $(".battle");
if (!box) return clearInterval(timer);
if (step >= steps) return finish();
done += weights[step] / total;
step++;
$('[data-battle="us"]', box).style.width = `${(1 - (1 - ours) * done) * 100}%`;
$('[data-battle="them"]', box).style.width = `${(1 - (1 - theirs) * done) * 100}%`;
// Each clash goes the way of the side that ends up ahead, now and then the other.
const ahead = rand() < 0.7 ? won : !won;
const pool = (ahead ? lines.good : lines.bad).filter((l) => l !== said);
const log = $('[data-battle="log"]', box);
said = pool[Math.floor(rand() * pool.length)];
log.insertAdjacentHTML("afterbegin", `<li class="${ahead ? "good" : "bad"}">${said}</li>`);
while (log.children.length > 4) log.lastElementChild.remove();
}, every);
playBattle.finish = finish;
}
function openModal(html) {
const modal = $("#modal");
modal.innerHTML = `<div class="modal-box">${html}</div>`;
@ -285,7 +365,7 @@ function topbar() {
}
const w = S.happenings?.weather;
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${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>`;
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}`;
}
@ -1199,7 +1279,13 @@ 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 (t.matches("[data-battle-skip]")) return playBattle.finish?.();
if (d.raidSeen) {
$("#modal").hidden = true;
return act("/api/seen", { id: Number(d.raidSeen) });
}
if (!$("#modal").hidden && $(".battle")) return; // the raid report closes with its own button, once watched
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
page = d.nav; remember("page", page); selected = null;

View file

@ -107,6 +107,8 @@ a { color: var(--xp); }
background: var(--panel); font-size: 13.5px; white-space: nowrap;
}
.chip.threat { border-color: rgba(226, 100, 92, .5); }
.chip.alarm { color: var(--bad); animation: alarm 1.2s ease-in-out infinite; }
@keyframes alarm { 50% { background: rgba(226, 100, 92, .25); border-color: var(--bad); } }
.chip .mini { width: 70px; }
main { padding: 24px; max-width: 1200px; width: 100%; }
@ -432,3 +434,12 @@ button.sheet-skill:hover { background: var(--panel-2); }
.credit a { color: var(--muted); }
.btn.working { color: var(--good); border-color: rgba(92, 191, 106, .5); background: rgba(92, 191, 106, .1); cursor: default; }
.banner-ico { font-size: 22px; display: flex; }
/* Raid replay */
.battle-side { display: grid; gap: 6px; margin: 12px 0; }
.battle-side .bar.hp.you > span { background: var(--good); }
.battle-side .bar > span { transition: width .8s ease-out; }
.modal-box .battle-log { min-height: 96px; margin: 14px 0 10px; padding-left: 0; }
.battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; }
.battle-result.win { color: var(--good); }
.battle-result.loss { color: var(--bad); }