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

-

-
`); - 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…"}
    +
    + +

    +

    +
    +
    +
    `); + 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 ? `
    DamageDPS · total
    + ` : ""; + } +} + +// 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 ? `` : ""} ${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) => ``).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) => ``).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):