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:
parent
fcaabf5ae2
commit
a35bcd6512
5 changed files with 146 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue