diff --git a/README.md b/README.md index 49c8012..e1f996e 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/app.py b/app.py index f42e559..6ea5c31 100644 --- a/app.py +++ b/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:], diff --git a/game.py b/game.py index 4dfe0a9..afe1c43 100644 --- a/game.py +++ b/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) diff --git a/static/app.js b/static/app.js index dbada18..c10a840 100644 --- a/static/app.js +++ b/static/app.js @@ -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) {
`); } +// 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(` +Garrison: ${r.garrison.map((g) => esc(g.name)).join(", ")}
` : ""} + ${r.lines.map((l) => `${esc(l)}
`).join("")} ++
+