diff --git a/game.py b/game.py
index 69ce729..33f9559 100644
--- a/game.py
+++ b/game.py
@@ -3090,16 +3090,9 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current:
current["left"] -= 1
- if current["left"] <= 0:
- back = current.get("resume")
- if back:
- plot2 = current.get("resume_plot2")
- player["action"] = current = {"id": back, "start": current["start"]}
- if plot2:
- current.update(plot2=plot2, turn=0)
- summary["resumed"] = back
- else:
- stop(player)
+ if current["left"] <= 0 and finish_once(player, current["start"]):
+ current = player["action"]
+ summary["resumed"] = current["id"]
if player["action"] is current and "enemy" not in action and not current.get("plot2"):
# Out of materials: stop now, not once the next bar has filled.
reason = can_start(world, player, growing(player))
@@ -3117,6 +3110,19 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason
+def finish_once(player: dict, at: float, reason: str | None = None) -> bool:
+ """A one-off is done: back to what you were doing before (True), or stop."""
+ current = player["action"]
+ back = current.get("resume")
+ if not back:
+ stop(player, reason)
+ return False
+ player["action"] = {"id": back, "start": at}
+ if current.get("resume_plot2"):
+ player["action"].update(plot2=current["resume_plot2"], turn=0)
+ return True
+
+
def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -> None:
"""Stop, stepping out of a shared fight if you're in one."""
current = player["action"]
@@ -3180,7 +3186,7 @@ def offline_limit(world: dict, player: dict) -> float:
def joinable(world: dict, player: dict) -> bool:
"""Whether someone's fight is one others can tag into."""
current = player["action"]
- if not current or "left" in current or "order" in current:
+ if not current or "order" in current:
return False
action = ACTION_BY_ID[current["id"]]
if action.get("dungeon"):
@@ -3309,9 +3315,9 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
if world["parties"].get(pid) is not party:
break # the last of them carries on alone, and picks up when they're back
at = max(party["start"], offline_limit(world, player))
- wind = {"wind_used": True} if player["action"].get("wind_used") else {}
+ keep = {k: v for k, v in player["action"].items() if k in ("wind_used", "left", "resume", "resume_plot2")}
leave_party(world, player, pid, at)
- player["action"] = {"id": action["id"], "start": at, **wind}
+ player["action"] = {"id": action["id"], "start": at, **keep}
continue
if end > now:
break
@@ -3336,6 +3342,15 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
for other in members:
if other is not player:
together[other["name"]] = together.get(other["name"], 0) + 1
+ current = player["action"]
+ if not action.get("dungeon") and "left" in current:
+ # Fought once: step out, and back to what you were doing. The rest carry on.
+ current["left"] -= 1
+ if current["left"] <= 0:
+ if world["parties"].get(current.get("party")) is party:
+ leave_party(world, player, pid, end)
+ if finish_once(player, end):
+ summary["resumed"] = player["action"]["id"]
if action.get("dungeon") and world["parties"].get(pid) is party:
_advance_run(world, party, action, fight, members, end, rng)
@@ -3382,8 +3397,11 @@ def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: l
log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}")
del world["parties"][party["id"]]
for player in members:
- stop(player, reason)
- player.setdefault("away", empty_summary())["stopped"] = reason
+ summary = player.setdefault("away", empty_summary())
+ if finish_once(player, now, reason):
+ summary["resumed"] = player["action"]["id"]
+ else:
+ summary["stopped"] = reason
def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None:
@@ -6074,6 +6092,8 @@ PATCH_NOTES = [
("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still "
"waits for the slowest, and Pull now sends each in as healed as they really are."),
("new", "DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting."),
+ ("new", "Improved raid UI"),
+ ("fix", "Bugfix: Combats started using \"Fight once\" can now be joined."),
]},
{"date": "2026-10-24", "title": "Dungeon loot", "notes": [
("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, "
diff --git a/static/app.js b/static/app.js
index 5eda0ca..7679ca7 100644
--- a/static/app.js
+++ b/static/app.js
@@ -218,71 +218,413 @@ 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(`
-
-
${dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!"}
-
Our defence ${fmt(r.defence)}
-
-
${foe} ${fmt(r.attack)}
-
-
${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}
-
-
${esc(r.title)}
- ${r.garrison.length ? `
Garrison: ${r.garrison.map((g) => esc(g.name)).join(", ")}
` : ""}
- ${r.lines.map((l) => `
${esc(l)}
`).join("")}
-
-
Skip
- Close
-
`);
- playBattle(t.id, r.defence, r.attack, won, dragon);
+ openBattle(t, `data-raid-seen="${t.id}"`);
}
-// 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;
+// A past fight, watched again from the council's history.
+function replayBattle(id) {
+ const h = S.history.find((x) => x.id === id);
+ if (h?.report) openBattle(h, "data-battle-close");
+}
+
+// Who stands on each side. Ours: the garrison by name, then the city's defence in squads, each as
+// strong as its share of the total. Theirs: the raiders' strength split into bands, or the one dragon.
+// `every` is how often each swings, in seconds of the replay; walls and the ram only take blows.
+const SQUADS = [
+ { id: "guards", parts: ["militia", "weapons", "shields"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 },
+ { id: "archers", parts: ["arrows"], name: "Archers", glyph: "bow-arrow", dmg: "pierce", every: 1.8 },
+ { id: "walls", parts: ["walls"], name: "Walls", glyph: "stone-wall", dmg: "blunt", still: true },
+ { id: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 },
+ { id: "swords", parts: ["mercenaries"], name: "Sellswords", glyph: "hooded-assassin", dmg: "slash", every: 2.2 },
+ { id: "ward", parts: ["ward"], name: "Temple ward", glyph: "prayer", dmg: "magic", every: 3 },
+];
+const RAID_BANDS = [
+ { id: "cut", name: "Cutthroats", glyph: "bowie-knife", dmg: "slash", every: 1.8 },
+ { id: "bow", name: "Bowmen", glyph: "pocket-bow", dmg: "pierce", every: 2 },
+ { id: "brute", name: "Brutes", glyph: "fist", dmg: "blunt", every: 3 },
+];
+const STOPPERS = ["shield_bash", "pinning_shot", "concuss"]; // abilities that cut a cast short
+const OURS = "#d4dae2", THEIRS = "#e08a7a";
+const BATTLE_STEPS = 32, BATTLE_DT = 0.65, BATTLE_LEAD = 1.2; // ~22s a fight
+// An ability's cooldown in the replay: as on auto, but twice as quick, since the fight is short.
+const battleCd = (id) => (C.abilities[id]?.cd || 8) * C.auto_cd / 2;
+
+// The big moves: a cast bar, then a burst. Players' abilities come from C.abilities.
+function battleMove(label) {
+ return { ram: { name: "Ram", icon: glyph("wood-beam", THEIRS) },
+ charge: { name: "Charge", icon: glyph("crossed-swords", OURS) },
+ war_cry: C.enemy_abilities.war_cry, fire_breath: C.enemy_abilities.fire_breath }[label] || C.abilities[label] || C.enemy_abilities[label];
+}
+
+function battleSides(t, rand) {
+ const r = t.report, parts = r.parts || {}, us = [], them = [];
+ const sum = Object.values(parts).reduce((a, b) => a + b, 0);
+ const scale = sum ? r.defence / sum : 1; // hunger takes a fifth off the whole line
+ r.garrison.forEach((g, i) => {
+ const p = S.players.find((x) => x.id === g.id);
+ const style = item(p?.equip?.weapon).style || "sword";
+ const known = C.weapon_kits[style].filter((id) => (p?.levels.combat ?? 1) >= C.abilities[id].level
+ && (C.abilities[id].needs !== "shield" || p?.equip?.shield));
+ const hits = (id, kinds) => C.abilities[id].effects.some((e) => kinds.includes(e.t));
+ const kit = known.filter((id) => hits(id, ["dmg", "aoe", "dot"]));
+ const burst = known.find((id) => hits(id, ["aoe"])) || known.find((id) => hits(id, ["dmg"]));
+ const stop = known.find((id) => STOPPERS.includes(id));
+ us.push({ id: `g${i}`, uid: g.id, name: g.id === S.uid ? "You" : esc(g.name), label: `${crest(p?.look, 18)} ${esc(g.name)}`,
+ top: g.power * scale, dmg: C.styles[style].damage, known, kit, burst, stop, every: C.styles[style].interval });
+ });
+ const squad = (sq, top) => top >= 1 && us.push({ ...sq, top, squad: true,
+ label: `${glyph(sq.glyph, OURS)} ${sq.id === "guards" && r.guards ? `Guards ×${r.guards}` : sq.name}` });
+ if (sum) for (const sq of SQUADS) squad(sq, sq.parts.reduce((a, k) => a + (parts[k] || 0), 0) * scale);
+ else squad(SQUADS[3], r.defence - r.garrison.reduce((a, g) => a + g.power, 0));
+ const band = (b, top) => them.push({ ...b, top, side: "them", label: `${glyph(b.glyph, THEIRS)} ${b.name}` });
+ if (t.kind === "dragon") {
+ const name = (r.group || "the dragon").replace(/^\w/, (c) => c.toUpperCase());
+ band({ id: "d", name: "Dragon", glyph: "dragon-head", dmg: "magic", every: 3, kit: ["tail_swipe"] }, r.attack);
+ them[0].name = esc(name);
+ them[0].label = `${glyph("dragon-head", THEIRS)} ${esc(name)}`;
+ } else {
+ const chief = r.attack * 0.2, ram = parts.walls ? r.attack * 0.15 : 0;
+ band({ id: "chief", name: "Warlord", glyph: "visored-helm", dmg: "slash", every: 2.4 }, chief);
+ if (ram) band({ id: "ram", name: "Battering ram", glyph: "wood-beam", dmg: "blunt", still: true }, ram);
+ const weights = RAID_BANDS.map(() => 0.6 + rand() * 0.8), total = weights.reduce((a, b) => a + b, 0);
+ RAID_BANDS.forEach((b, i) => band(b, (r.attack - chief - ram) * weights[i] / total));
+ }
+ for (const u of us) u.side = "us";
+ return { us, them };
+}
+
+// The whole fight, worked out up front and seeded by the event so everyone sees the same one. Pure
+// theatre: the outcome is already decided. The loser's side runs out; the winner keeps as much as the
+// gap between the two. In a close fight the loser surges in the middle and the winner turns it late.
+function battleScript(t) {
+ let seed = t.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"],
+ const won = t.report.title.startsWith("Victory"), dragon = t.kind === "dragon";
+ const { us, them } = battleSides(t, rand);
+ const units = [...us, ...them], byId = Object.fromEntries(units.map((u) => [u.id, u]));
+ for (const u of units) u.hp = u.top;
+ const usTop = us.reduce((a, u) => a + u.top, 0), themTop = them.reduce((a, u) => a + u.top, 0);
+ const close = Math.min(usTop, themTop) / Math.max(usTop, themTop, 1), k = close * close;
+ const keep = Math.max(0.1, 1 - close);
+ const p1 = 0.3 + (rand() - 0.5) * 0.1, p2 = 0.72 + (rand() - 0.5) * 0.1;
+ const lerp = (x, y) => x + (y - x) * k;
+ // How much of its losses each side has taken by each point of the fight.
+ const winner = [[0, 0], [p1, lerp(p1, 0.2)], [1, 1]];
+ const loser = [[0, 0], [p1, lerp(p1, 0.35)], [p2, lerp(p2, 0.55)], [1, 1]];
+ const at = (pts, x) => {
+ for (let i = 1; i < pts.length; i++) {
+ const [x0, y0] = pts[i - 1], [x1, y1] = pts[i];
+ if (x <= x1) return y0 + (y1 - y0) * (x - x0) / (x1 - x0);
+ }
+ return 1;
};
- 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}%`;
+ const losses = (pts, loss) => {
+ const steps = Array.from({ length: BATTLE_STEPS }, (_, i) =>
+ (at(pts, (i + 1) / BATTLE_STEPS) - at(pts, i / BATTLE_STEPS)) * (0.6 + rand() * 0.8));
+ const total = steps.reduce((a, b) => a + b, 0);
+ return steps.map((s) => s / total * loss);
+ };
+ const toUs = losses(won ? winner : loser, won ? usTop * (1 - keep) : usTop);
+ const toThem = losses(won ? loser : winner, won ? themTop : themTop * (1 - keep));
+ const sideOf = { us: toUs, them: toThem };
+ const time = (step) => BATTLE_LEAD + step * BATTLE_DT;
+ const live = (side) => (side === "us" ? us : them).filter((u) => u.hp > 0.01);
+ const pick = (list, weight) => {
+ let x = rand() * list.reduce((a, u) => a + weight(u), 0);
+ for (const u of list) if ((x -= weight(u)) <= 0) return u;
+ return list[list.length - 1];
+ };
+
+ // The big moves. Each side's surge piles the next two steps' damage into one burst; a foiled cast
+ // is the raiders' war cry, cut short.
+ const plan = {};
+ const add = (step, move) => { step = Math.min(BATTLE_STEPS - 1, step); (plan[step] ||= []).push(move); };
+ const b1 = Math.round(p1 * BATTLE_STEPS), b2 = Math.round(p2 * BATTLE_STEPS);
+ const best = us.find((u) => u.burst);
+ const ourMove = byId.archers ? { src: "archers", label: "volley", aoe: true }
+ : best ? { src: best.id, label: best.burst, aoe: true }
+ : { src: us.find((u) => !u.still)?.id ?? us[0].id, label: "charge", aoe: true };
+ const theirMove = dragon ? { src: "d", label: "fire_breath", aoe: true }
+ : byId.ram ? { src: "ram", label: "ram", tgt: "walls" } : { src: "chief", label: "war_cry", aoe: true };
+ const late = b2 + 2, mid = b1 + 3;
+ add(won ? late : mid, { ...ourMove, to: "them" });
+ add(won ? mid : late, { ...theirMove, to: "us" });
+ if (!dragon) add(won ? b2 : b1 + 7, { src: "chief", label: "war_cry", foiled: true });
+
+ const events = [];
+ const push = (e) => { e[0] = Math.max(e[0], (events.at(-1)?.[0] ?? 0) + 0.01); events.push(e); };
+ const focus = {};
+ // Damage dealt to one side, as hits from its foes; overflow spills onto the next. The losing side
+ // is picked off unit by unit; the winning side wears down evenly.
+ const strike = (when, src, to, amount, label, kind = "hit", only = null) => {
+ while (amount > 0.01) {
+ const pool = only ? [only].filter((u) => u.hp > 0.01) : live(to);
+ if (!pool.length) return;
+ const tgt = (to === "us") === won ? pick(pool, (u) => u.top * (u.hp / u.top) ** 4)
+ : pool.includes(focus[to]) && rand() < 0.5 ? focus[to] : pick(pool, (u) => u.hp);
+ focus[to] = tgt;
+ const n = Math.min(amount, tgt.hp);
+ tgt.hp = Math.max(0, tgt.hp - n);
+ amount -= n;
+ push([when, src.id, tgt.id, kind, n, tgt.hp, label]);
+ if (tgt.hp <= 0.01) { tgt.hp = 0; push([when + 0.05, src.id, tgt.id, "die"]); }
+ when += 0.08;
+ if (only) only = null;
+ }
+ };
+ const attacker = (side) => {
+ const all = live(side), fighting = all.filter((u) => !u.still);
+ return (fighting.length ? fighting : all).length ? pick(fighting.length ? fighting : all, (u) => u.top) : null;
+ };
+ // Everyone swings on their own clock. Each step's damage to a side is shared among the swings that
+ // land on it then, by how strong the swinger is; players use their abilities as they come off cooldown.
+ const next = {}, used = {}, carry = { us: 0, them: 0 };
+ for (const u of units) if (u.every && !u.still) { next[u.id] = BATTLE_LEAD + rand() * u.every * 0.7; u.from = next[u.id] - u.every; used[u.id] = {}; }
+ for (let step = 0; step < BATTLE_STEPS; step++) {
+ const base = time(step);
+ // Casts start three steps ahead of their burst; a foiled one is stopped a step short.
+ for (const move of plan[step + 3] || []) {
+ const src = byId[move.src];
+ if (src?.hp > 0.01) push([base, move.src, null, "cast", 0, null, move.label, time(step + 3)]);
+ }
+ for (const move of plan[step + 1] || []) {
+ if (!move.foiled || !(byId[move.src]?.hp > 0.01)) continue;
+ const stoppers = us.filter((u) => u.hp > 0.01 && u.stop);
+ const stopper = stoppers[Math.floor(rand() * stoppers.length)]
+ || live("us").find((u) => u.id === "archers") || attacker("us");
+ if (stopper) push([base + 0.05, stopper.id, move.src, "stop", 0, null,
+ stopper.stop || "", move.label]);
+ if (stopper?.stop) used[stopper.id][stopper.stop] = base;
+ }
+ // A burst takes this step's damage and most of the next two's.
+ for (const move of plan[step] || []) {
+ const src = byId[move.src], dealt = sideOf[move.to];
+ if (move.foiled || !(src?.hp > 0.01)) continue;
+ for (let j = 1; j <= 2 && step + j < BATTLE_STEPS; j++) { dealt[step] += dealt[step + j] * 0.8; dealt[step + j] *= 0.2; }
+ const amount = dealt[step];
+ dealt[step] = 0;
+ if (used[src.id]) used[src.id][move.label] = base;
+ if (move.aoe) {
+ const pool = live(move.to), sum = pool.reduce((a, u) => a + u.hp, 0);
+ let when = base;
+ for (const u of pool) { strike(when, src, move.to, amount * u.hp / sum, move.label, "crit", u); when += 0.04; }
+ } else strike(base, src, move.to, amount, move.label, "crit", byId[move.tgt]);
+ }
+ // The swings that land this step. A side no one swings at keeps its share for the next step.
+ const swings = [];
+ for (const u of units) {
+ while (next[u.id] < base + BATTLE_DT) { swings.push([next[u.id], u]); next[u.id] += u.every * (0.85 + rand() * 0.3); }
+ }
+ swings.sort((x, y) => x[0] - y[0]);
+ const budget = { us: toUs[step] + carry.us, them: toThem[step] + carry.them }, weight = { us: 0, them: 0 };
+ const blows = swings.map(([when, u]) => {
+ const to = u.side === "us" ? "them" : "us";
+ const ready = (u.kit || []).filter((id) => !(used[u.id][id] > when - battleCd(id)));
+ const label = ready.length && rand() < 0.5 ? ready[Math.floor(rand() * ready.length)] : u.dmg;
+ const crit = rand() < 0.1, w = u.top * (0.7 + rand() * 0.6) * (crit ? 1.8 : 1) * (label === u.dmg ? 1 : 1.5);
+ weight[to] += w;
+ return { when, u, to, label, crit, w };
+ });
+ for (const side of ["us", "them"]) carry[side] = weight[side] ? 0 : budget[side];
+ for (const { when, u, to, label, crit, w } of blows) {
+ const amount = budget[to] * w / weight[to];
+ if (!(u.hp > 0.01)) { carry[to] += amount; continue; } // fell earlier this step
+ if (label !== u.dmg) used[u.id][label] = when;
+ if (amount >= 0.5) { strike(when, u, to, amount, label, crit ? "crit" : "hit"); continue; }
+ carry[to] += amount;
+ const tgt = live(to)[0];
+ if (tgt) push([when, u.id, tgt.id, "miss", 0, null, u.dmg]);
+ }
+ }
+ // Whatever's left of the losing side breaks at the last blow.
+ const end = time(BATTLE_STEPS);
+ for (const u of won ? them : us) if (u.hp > 0) { u.hp = 0; push([end - 0.1, u.id, u.id, "hit", 0, 0, ""]); push([end - 0.05, u.id, u.id, "die"]); }
+ return { units, byId, events, end: end + 0.4, won };
+}
+
+// Where everyone stands `t` seconds in: HP, last swing, cast under way, out or not.
+function battleState(b, t) {
+ const st = {};
+ for (const u of b.units) st[u.id] = { hp: u.top, top: u.top, swung: null, cast: null, stopped: null, hit: null, out: false, cds: {} };
+ let shown = -1;
+ while (shown + 1 < b.events.length && b.events[shown + 1][0] <= t) {
+ const [time, src, tgt, kind, n, hp, label, until] = b.events[++shown];
+ const who = st[src], them = st[tgt];
+ if (them && hp != null) them.hp = hp;
+ if (who && (kind === "miss" || (kind === "hit" || kind === "crit") && n)) who.swung = time;
+ if (who && C.abilities[label] && (kind === "hit" || kind === "crit" || kind === "stop") && who.cds[label] !== time) who.cds[label] = time;
+ if (who && kind === "cast") who.cast = { label, from: time, until };
+ if (them && kind === "stop" && them.cast) { them.stopped = { label: them.cast.label, at: time }; them.cast = null; }
+ if (them && (kind === "hit" || kind === "crit") && n) them.hit = { at: time, n, kind };
+ if (them && kind === "die") { them.out = true; them.cast = null; }
+ }
+ return { st, shown };
+}
+
+// A line of the fight, terse: who, to whom, how much, with what.
+function battleLine(e, b) {
+ const [, src, tgt, kind, n, , label, extra] = e;
+ const u = (id) => b.byId[id], name = (id) => u(id)?.name ?? "";
+ const move = battleMove(label);
+ const what = move ? ` ${move.icon || ""} ${esc(move.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
+ const theirs = u(src)?.side === "them";
+ switch (kind) {
+ case "hit": case "crit":
+ return n ? `${name(src)} → ${name(tgt)} ${Math.max(1, Math.round(n))} ${kind === "crit" ? " crit" : ""}${what} ` : "";
+ case "miss": return `${name(src)} → ${name(tgt)} miss `;
+ case "cast": return `${name(src)}${what}… `;
+ case "stop": {
+ const cut = battleMove(extra);
+ return `${name(src)}${what} ✋ ${name(tgt)} ${cut?.icon || ""} ${esc(cut?.name)} `;
+ }
+ case "die": {
+ const v = u(tgt);
+ if (v.side === "them") return `${v.id === "d" ? `${v.name} flees` : v.id === "ram" ? "The ram is burning" : `${v.name} break and run`} `;
+ return `${v.uid ? (v.uid === S.uid ? "You're knocked out" : `${v.name} is knocked out`)
+ : v.id === "walls" ? "The walls are breached" : `${v.name} fall back`} `;
+ }
+ }
+ return "";
+}
+
+// Laid out like the combat page: you and your abilities, then the rest of the garrison and the city's
+// squads; across from you the raiders, the fight log and the damage meter.
+function openBattle(t, closeAttr) {
+ const r = t.report, dragon = t.kind === "dragon", won = r.title.startsWith("Victory");
+ const b = battleScript(t);
+ const unit = (u) => `
+
${u.label}${dmgIcon(u.dmg, "attack")}
+
+
+ ${u.every ? `
` : ""}
+ ${casters.has(u.id) ? `
` : ""}
+
`;
+ const casters = new Set(b.events.filter((e) => e[3] === "cast").map((e) => e[1])); // room for a cast bar only where one comes
+ const us = b.units.filter((u) => u.side === "us"), them = b.units.filter((u) => u.side === "them");
+ const me = us.find((u) => u.uid === S.uid);
+ const kit = me ? `${me.known.map((id) => `
+ ${C.abilities[id].icon} `).join("")}
` : "";
+ const group = (r.group || (dragon ? "the dragon" : "the raiders")).replace(/^\w/, (c) => c.toUpperCase());
+ openModal(`
+
+
${dragon ? `🐉 ${esc(group)} attacks!` : `⚔️ ${esc(group)} attack!`}
+
${fmt(r.defence)}
+
+
${fmt(r.attack)}
+
+
+ ${me ? unit(me) + kit : ""}
+
${us.filter((u) => u.uid && u !== me).map(unit).join("")}
+
${us.filter((u) => !u.uid).map(unit).join("")}
+
+
+
${them.map(unit).join("")}
+
${dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…"}
+
+
+
${esc(r.title)}
+ ${r.lines.map((l) => `
${esc(l)}
`).join("")}
+
+
Skip
+ Close
+
+
+
`);
+ playBattle(b, $(".battle"));
+}
+
+function playBattle(b, box) {
+ const start = performance.now() / 1000;
+ let skipped = false;
+ const frame = () => {
+ if (!box.isConnected) return;
+ const t = skipped ? b.end : performance.now() / 1000 - start;
+ drawBattle(b, box, t);
+ if (t < b.end) return requestAnimationFrame(frame);
$('[data-battle="report"]', box).hidden = false;
$("[data-battle-skip]", box).hidden = true;
- $("[data-raid-seen]", box).hidden = false;
+ const close = $("[data-raid-seen], [data-battle-close]", box);
+ close.hidden = false;
+ close.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
- 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", `${said} `);
- while (log.children.length > 4) log.lastElementChild.remove();
- }, every);
- playBattle.finish = finish;
+ playBattle.skip = () => { skipped = true; };
+ requestAnimationFrame(frame);
+}
+
+function drawBattle(b, box, t) {
+ const { st, shown } = battleState(b, t), before = t < b.end ? battleState(b, t - LAG).st : st;
+ let ours = 0, theirs = 0;
+ for (const u of b.units) if (u.side === "us") ours += st[u.id].hp; else theirs += st[u.id].hp;
+ $("[data-b-tug]", box).style.width = `${ours / (ours + theirs || 1) * 100}%`;
+ $("[data-b-us]", box).textContent = fmt(Math.ceil(ours));
+ $("[data-b-them]", box).textContent = fmt(Math.ceil(theirs));
+ for (const el of box.querySelectorAll("[data-b]")) {
+ const u = st[el.dataset.b], was = before[el.dataset.b], spec = b.byId[el.dataset.b];
+ el.classList.toggle("out", u.out);
+ el.querySelector("[data-u-bar]").style.width = `${u.hp / u.top * 100}%`;
+ el.querySelector("[data-u-lag]").style.width = `${Math.max(u.hp, was.hp) / u.top * 100}%`;
+ el.querySelector("[data-u-hp]").textContent = `${fmt(Math.ceil(u.hp))} / ${fmt(Math.ceil(u.top))}`;
+ const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
+ num.textContent = hit ? `−${Math.max(1, Math.round(hit.n))}` : "";
+ num.className = `hitnum bad ${hit?.kind === "crit" ? "crit" : ""}`;
+ num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
+ const swing = el.querySelector("[data-u-swing]");
+ if (swing) swing.style.width = u.out || t >= b.end ? "0%" : `${Math.max(0, Math.min(100, (t - (u.swung ?? spec.from)) / spec.every * 100))}%`;
+ const cast = u.cast && t < u.cast.until ? u.cast : null;
+ const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
+ const move = battleMove(cast?.label || stopped?.label);
+ const castEl = el.querySelector("[data-u-cast]");
+ const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.at}` : "";
+ if (castEl && castEl.dataset.key !== key) {
+ castEl.dataset.key = key;
+ castEl.classList.toggle("stopped", !!stopped);
+ castEl.innerHTML = cast ? `${move.icon || ""} ${esc(move.name)} `
+ : stopped ? `✋ ${move.icon || ""} ${esc(move.name)} interrupted ` : "";
+ }
+ if (cast && castEl) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
+ }
+ // Your abilities: the shade is the cooldown left, a flash when one's just been used.
+ const mine = b.units.find((u) => u.uid === S.uid);
+ for (const el of box.querySelectorAll("[data-bab]")) {
+ const used = st[mine.id].cds[el.dataset.bab], cd = battleCd(el.dataset.bab);
+ const left = used == null || t >= b.end ? 0 : Math.max(0, used + cd - t);
+ el.querySelector("[data-ab-cd]").style.height = `${left / cd * 100}%`;
+ el.classList.toggle("fired", used != null && t - used < 0.6);
+ }
+ // The log keeps to what you'd follow: your own blows and the ones on you, abilities, crits, the big
+ // moves and who falls. Everyone's swings are on the bars.
+ const log = $("[data-b-log]", box);
+ b.told ||= b.events.filter((e) => battleTold(e, b));
+ let told = 0;
+ while (told < b.told.length && b.told[told][0] <= t) told++;
+ if (told && log.dataset.key !== `${told}:${t >= b.end}`) {
+ log.dataset.key = `${told}:${t >= b.end}`;
+ const lines = b.told.slice(Math.max(0, told - 7), told).reverse().map((e) => battleLine(e, b)).filter(Boolean);
+ if (t >= b.end) lines.unshift(b.won ? `Victory! ` : `The line breaks. `);
+ log.innerHTML = lines.slice(0, 7).join("");
+ }
+ // The damage meter: what each of ours has dealt so far, best first.
+ const meter = $("[data-b-dps]", box), secs = Math.max(1, Math.min(t, b.end) - BATTLE_LEAD);
+ const key = `${shown}:${Math.floor(Math.min(t, b.end))}`;
+ if (meter.dataset.key !== key) {
+ meter.dataset.key = key;
+ const dealt = {};
+ for (const e of b.events.slice(0, shown + 1)) if (e[3] === "hit" || e[3] === "crit") dealt[e[1]] = (dealt[e[1]] || 0) + e[4];
+ const rows = b.units.filter((u) => u.side === "us" && dealt[u.id]).sort((x, y) => dealt[y.id] - dealt[x.id]).slice(0, 5);
+ const top = Math.max(...rows.map((u) => dealt[u.id]), 1);
+ meter.innerHTML = rows.length ? `Damage DPS · total
+ ${rows.map((u) => `
+ ${u.uid ? esc(playerName(u.uid)) : esc(u.name)} ${(dealt[u.id] / secs).toFixed(1)} · ${fmt(Math.round(dealt[u.id]))} `).join("")} ` : "";
+ }
+}
+
+// Whether a line of the replay makes the log (see drawBattle).
+function battleTold(e, b) {
+ const [, src, tgt, kind, n, , label] = e;
+ const mine = (id) => b.byId[id]?.uid === S.uid;
+ if (kind === "cast" || kind === "stop" || kind === "die") return true;
+ if (kind === "miss") return mine(src);
+ return !!n && (kind === "crit" || mine(src) || mine(tgt) || !!C.abilities[label] && !!b.byId[src]?.uid);
}
function openModal(html) {
@@ -1799,6 +2141,7 @@ function council() {
: { fortune: "win", hardship: "loss", minor: "loss" }[h.track] || "";
html += `
${h.report.icon || eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)}
+ ${fight ? `
▶ Watch ` : ""}
${fight ? `
Defence ${fmt(h.report.defence)} vs ${h.kind === "dragon" ? "the dragon" : "raiders"} ${fmt(h.report.attack)}${h.report.garrison.length ? ` · garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}
` : ""}
${h.report.lines.map((l) => `
${esc(l)}
`).join("")}
`;
@@ -2092,6 +2435,40 @@ function notes() {
}).join("")}`).join("");
}
+// --- achievements ----------------------------------------------------------
+// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
+
+const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
+
+function achievementsNav(me) {
+ const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
+ return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
+}
+
+function achievements() {
+ const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
+ const [have, need] = tally(all);
+ return `Achievements ${have} / ${need}
+
`
+ + sections.map((s, si) => {
+ const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
+ return `${esc(s.name)} ${sh} / ${sn}
+ ${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}
`;
+ }).join("");
+}
+
+function achCard(card, key) {
+ const [have, need] = tally(card.entries);
+ const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null;
+ const icon = (e) => e.colour ? ` ` : glyph(e.glyph, e.tint);
+ const chips = card.entries.map((e, i) => `${icon(e)} `).join("");
+ return `${esc(card.name)} ${have} / ${need}
+
+
${chips}
+ ${pick ? `
${pick.done ? "✓" : "🔒"} ${esc(pick.name)} ${pick.tip ? ` · ${esc(pick.tip)} ` : ""}
` : ""}
`;
+}
+
// --- character sheet -----------------------------------------------------
const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" };
@@ -2367,52 +2744,20 @@ function loop() {
requestAnimationFrame(loop);
}
-// --- achievements ----------------------------------------------------------
-// A record of what's left to unlock, read off what you already have. Tap a chip for what it is.
-
-const tally = (entries) => [entries.filter((e) => e.done).length, entries.length];
-
-function achievementsNav(me) {
- const [have, need] = tally(me.achievements.flatMap((s) => s.cards.flatMap((c) => c.entries)));
- return nav("achievements", "🏆", "Achievements", `${have}/${need}`, "", `${have} of ${need} unlocked`);
-}
-
-function achievements() {
- const sections = S.me.achievements, all = sections.flatMap((s) => s.cards.flatMap((c) => c.entries));
- const [have, need] = tally(all);
- return `Achievements ${have} / ${need}
-
`
- + sections.map((s, si) => {
- const [sh, sn] = tally(s.cards.flatMap((c) => c.entries));
- return `${esc(s.name)} ${sh} / ${sn}
- ${s.cards.map((c, ci) => achCard(c, `${si}.${ci}`)).join("")}
`;
- }).join("");
-}
-
-function achCard(card, key) {
- const [have, need] = tally(card.entries);
- const pick = achPick?.startsWith(key + ".") ? card.entries[Number(achPick.split(".")[2])] : null;
- const icon = (e) => e.colour ? ` ` : glyph(e.glyph, e.tint);
- const chips = card.entries.map((e, i) => `${icon(e)} `).join("");
- return `${esc(card.name)} ${have} / ${need}
-
-
${chips}
- ${pick ? `
${pick.done ? "✓" : "🔒"} ${esc(pick.name)} ${pick.tip ? ` · ${esc(pick.tip)} ` : ""}
` : ""}
`;
-}
-
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close], [data-aim]");
if (!t) return;
const d = t.dataset;
- if (t.matches("[data-battle-skip]")) return playBattle.finish?.();
+ if (t.matches("[data-battle-skip]")) return playBattle.skip?.();
+ if (t.matches("[data-battle-close]")) { $("#modal").hidden = true; showThreat(); return; }
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 (d.replay) return replayBattle(Number(d.replay));
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) {
diff --git a/static/styles.css b/static/styles.css
index 89ff817..522a5c4 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -451,10 +451,30 @@ button.sheet-skill:hover { background: var(--panel-2); }
.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; }
+.modal-box:has(.battle) { max-width: 980px; max-height: 92vh; }
+.tug { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; margin: 4px 0 18px; font-variant-numeric: tabular-nums; }
+.tug > b:first-child { color: var(--good); } .tug > b:last-child { color: var(--bad); }
+.tug-bar { position: relative; background: var(--bad); }
+.tug-bar > span { background: var(--good); }
+.tug-bar::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 2px; background: var(--panel); }
+.battle-frame { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; }
+.b-side { display: grid; gap: 12px; align-content: start; min-width: 0; }
+.battle .squads { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px 14px; }
+.battle .fighter-name .gi { width: 16px; height: 16px; vertical-align: -3px; }
+.battle .fighter-name .crest { vertical-align: -4px; }
+.battle .fighter.squad .bar.hp { height: 6px; }
+.fight.battle .bar.hp > span.lag { background: rgba(255, 236, 200, .55); transition: none; }
+.battle .fight-log { margin: 0; padding: 0; line-height: 1.35; height: calc(7 * 1.35em + 12px); min-height: 0; overflow: hidden; }
+.battle .btns { margin: 0; }
+/* Narrow: you, then the raiders and the log, then the rest of the city */
+@media (max-width: 700px) {
+ .battle-frame { display: flex; flex-direction: column; gap: 14px; }
+ .b-side { display: contents; }
+ .battle .b-me { order: 1; } .battle .ability-bar { order: 2; } .b-foes { order: 3; } .battle .fight-log { order: 4; }
+ .b-allies { order: 5; } .b-city { order: 6; } .battle .dps { order: 7; } .battle [data-battle] { order: 8; } .battle .btns { order: 9; }
+ .battle .squads { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
+ .battle .fighter-name { flex-direction: row; }
+}
.battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; }
.battle-result.win { color: var(--good); }
.battle-result.loss { color: var(--bad); }
diff --git a/tests/test_game.py b/tests/test_game.py
index 6bb1938..1934398 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -2868,6 +2868,22 @@ class PartyTest(unittest.TestCase):
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together")
+ def test_a_boss_fought_once_can_be_joined(self):
+ world, wil, kay = self.pair(level=30)
+ for p in (wil, kay):
+ p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
+ legs="steel_legs")
+ p["bank"] = {"cooked_salmon": 5000}
+ game.start_action(world, wil, "mine_stone", T0)
+ self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0, once=True))
+ self.assertTrue(game.joinable(world, wil))
+ self.assertIsNone(game.join_fight(world, kay, "wil", T0))
+ self.online(world, T0 + 3600, random.Random(6))
+ self.assertEqual(wil["away"]["with"], {"Kay": 1})
+ self.assertEqual(wil["action"]["id"], "mine_stone") # one kill, then back to mining
+ self.assertEqual(kay["action"]["id"], "fight_ruins") # waiting for the next group
+ self.assertEqual(world["parties"], {})
+
def test_bosses_give_everyone_their_own_loot(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):