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
|
|
@ -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
9
app.py
|
|
@ -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
39
game.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue