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

@ -91,7 +91,9 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
Damaged buildings stop working until repaired but keep their level.
Every fight uses up arrows and breaks some of the swords and shields
handed out (worst first), more in a close fight or a defeat.
Now and then a dragon comes instead of bandits.
Now and then a dragon comes instead of bandits. Sentries raise the alarm
five minutes out, and everyone watches the battle play out once, live or
on their next visit, before the report.
- **Happenings:** three more tracks run on their own cooldowns. Minor threats
(wolves, a mine collapse, goblin thieves…) slow or block a skill until
players deal with them; hardships (sickness, locusts, rot, storms, fire,

9
app.py
View file

@ -315,6 +315,12 @@ class Handler(BaseHTTPRequestHandler):
return "Bad quantity"
if path == "/api/action":
return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once")))
if path == "/api/seen":
event_id = body.get("id")
if not isinstance(event_id, int):
return "Bad event"
player["seen_threat"] = max(player.get("seen_threat", 0), event_id)
return None
if path == "/api/stop":
game.stop(player)
return None
@ -472,6 +478,7 @@ class Handler(BaseHTTPRequestHandler):
now = self.store.now()
player = world["players"].get(uid)
upcoming = game.upcoming_event(world)
unseen = game.unseen_threat(world, player) if player else None
history = [
{"id": e["id"], "kind": e["kind"], "track": e.get("track", "major"), "at": e["resolved"], "report": e.get("report")}
for e in reversed(world["events"]) if e["status"] == "done"
@ -485,6 +492,8 @@ class Handler(BaseHTTPRequestHandler):
"favours": game.favours(world, now),
"happenings": game.happenings_view(world, now),
"history": history,
"threat_report": unseen and {"id": unseen["id"], "kind": unseen["kind"], "at": unseen["resolved"],
"report": unseen["report"]},
"players": roster(world, now),
"ledger": world["ledger"][-80:],
"board": world["board"][-80:],

39
game.py
View file

@ -743,7 +743,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 14
WORLD_VERSION = 15
def new_world(now: float, speed: float = 1.0) -> dict:
@ -872,9 +872,29 @@ def migrate(world: dict, now: float | None = None) -> dict:
stop(player, "Shields are made in Construction now")
_return_overfunding(world)
world["version"] = 14
if world["version"] < 15:
# Raid reports pop up once for everyone; ones from before that don't.
latest = last_threat(world)
for player in world["players"].values():
player["seen_threat"] = latest["id"] if latest else 0
world["version"] = 15
return world
def last_threat(world: dict) -> dict | None:
"""The most recent raid or dragon attack that has played out."""
return next((e for e in reversed(world["events"]) if e["status"] == "done" and e["kind"] in THREATS
and e.get("report")), None)
def unseen_threat(world: dict, player: dict) -> dict | None:
"""The latest raid or dragon attack this player hasn't watched yet, if it happened while they were here."""
event = last_threat(world)
if event and event["id"] > player.get("seen_threat", 0) and event["resolved"] >= player["joined"]:
return event
return None
def _migrate_potions(world: dict) -> None:
"""Potions from before the tiers become their tiered equivalents, uses and all."""
def renamed(stock: dict) -> None:
@ -2221,16 +2241,22 @@ SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")
WINDOW_HOURS = [24, 8, 2, 0]
THREATS = ("raid", "dragon")
GATES_WARNING = 300 # seconds of warning before a threat strikes
def event_view(world: dict, event: dict, now: float) -> dict:
"""What the city knows about an upcoming event, filtered by intel."""
level = intel_level(world, event, now)
half = WINDOW_HOURS[level] * HOUR / speed(world)
costs = intel_costs(event) if "strength" in event else [0, 0, 0]
centre = event["fire_at"] + event["jitter"] * half * 0.6
# In the last few minutes the sentries see them coming, whatever the intel.
gates = event["kind"] in THREATS and event["fire_at"] - now <= GATES_WARNING / speed(world)
view = {
"id": event["id"], "kind": event["kind"], "intel": level,
"id": event["id"], "kind": event["kind"], "intel": level, "at_gates": gates,
"earliest": max(now, centre - half), "latest": max(now, centre + half),
"exact": event["fire_at"] if level >= 3 else None,
"exact": event["fire_at"] if level >= 3 or gates else None,
"recon": int(min(event["recon"], costs[-1])), "recon_full": costs[-1],
"recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0,
}
@ -2250,6 +2276,8 @@ def event_view(world: dict, event: dict, now: float) -> dict:
view["strength"] = round(strength)
view["trait"] = event["trait"]
view["requests"] = council_requests(world, event)
if gates:
view["headline"] = "The dragon is overhead!"
elif event["kind"] == "raid":
strength = event["strength"] * (1 - event["sabotage"])
view["headline"] = "Travellers speak of trouble on the roads."
@ -2269,6 +2297,8 @@ def event_view(world: dict, event: dict, now: float) -> dict:
view["camp"] = True
view["sabotage"] = round(event["sabotage"] * 100)
view["requests"] = council_requests(world, event)
if gates:
view["headline"] = "Raiders at the gates!"
elif event["kind"] == "merchant":
view["headline"] = "A merchant caravan is on its way. Sell prices +50% while it's here."
else:
@ -2378,7 +2408,8 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
lines.extend(_wear_lines(broken, arrows))
if gold or taken:
stolen = ", ".join(amount(item, qty) for item, qty in taken.items())
lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else "."))
loot = [f"{gold:,} gold"] if gold else []
lines.append("The raiders carried off " + " and ".join(loot + ([stolen] if stolen else [])) + ".")
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
if severity > 0.35 and built:
key = rng.choice(built)

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); }