diff --git a/game.py b/game.py
index 0665c35..69ce729 100644
--- a/game.py
+++ b/game.py
@@ -6073,6 +6073,7 @@ PATCH_NOTES = [
("new", "Enemy casts that can't be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses."),
("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."),
]},
{"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 baca614..5eda0ca 100644
--- a/static/app.js
+++ b/static/app.js
@@ -943,7 +943,8 @@ function combatFrame() {
foe = `
${encounter?.icon || ""}${esc(f.enemy)}
${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` · rare` : ""}`, true,
` ${traits(v)}`)).join("")}
- `;
+
+ `;
}
return `
${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}
@@ -1071,6 +1072,28 @@ function fightState(f, t) {
return { st, shown };
}
+// The damage meter: what each fighter has dealt so far, and per second since they joined (game seconds,
+// so dev speed doesn't inflate it). Best first, bars scaled to the top DPS.
+const DAMAGE = new Set(["hit", "crit", "tick"]);
+let lastMeter = null; // the fight just finished, shown dimmed while resting for the next one
+function dpsMeter(f, t) {
+ const rows = f.members.map((m) => {
+ let dmg = 0, heal = 0;
+ for (const e of f.events) {
+ if (e[0] > t) break;
+ if (e[1] !== m.id) continue;
+ if (DAMAGE.has(e[3])) dmg += e[4] || 0;
+ else if (e[3] === "heal" && e[6] !== "potion") heal += e[4] || 0;
+ }
+ const secs = Math.max(1, Math.min(t, f.end) - (m.at + f.rest) / f.pace) * f.pace;
+ return { m, dmg, heal, dps: dmg / secs };
+ }).sort((x, y) => y.dmg - x.dmg);
+ const top = Math.max(...rows.map((r) => r.dps)) || 1;
+ return rows.map(({ m, dmg, heal, dps }) => `
+ ${esc(playerName(m.id))}${dps < 10 ? dps.toFixed(1) : fmt(Math.round(dps))} · ${fmt(dmg)}`).join("");
+}
+
function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
@@ -1166,6 +1189,20 @@ function animateFight(now) {
: lines.join("") || `
Squaring up…`;
}
}
+ // Refreshed on every swing and once a second, since DPS falls between hits.
+ const meter = panel.querySelector('[data-f="dps"]');
+ if (meter) {
+ const stale = resting && lastMeter?.start !== a.start ? lastMeter : null;
+ const key = resting ? `rest:${stale?.start}` : `${a.start}:${shown}:${Math.floor(Math.min(t, f.end))}`;
+ if (meter.dataset.key !== key) {
+ meter.dataset.key = key;
+ if (!resting) lastMeter = { start: a.start, rows: dpsMeter(f, t) };
+ const rows = resting ? stale?.rows : lastMeter.rows;
+ meter.classList.toggle("stale", resting);
+ meter.innerHTML = rows ? `
${resting ? "Last fight" : "Damage"}DPS · total
+
` : "";
+ }
+ }
}
// A piece of jewellery's bonus, e.g. "+8% accuracy".
diff --git a/static/styles.css b/static/styles.css
index 6f73a16..89ff817 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -545,6 +545,15 @@ button.sheet-skill:hover { background: var(--panel-2); }
.pill.yours { box-shadow: 0 0 0 2px var(--gold); }
/* Fixed at 7 lines (newest first) so the frame doesn't grow and shrink with the log; older lines drop off the bottom */
.combat-frame .fight-log { margin: 0; line-height: 1.35; height: calc(7 * 1.35em + 12px); min-height: 0; overflow: hidden; }
+/* Damage meter under the log: a row per fighter, the bar behind it scaled to the top DPS */
+.dps { display: grid; gap: 6px; font-size: 13px; }
+.dps.stale { opacity: .55; }
+.dps-head { display: flex; justify-content: space-between; }
+.dps ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }
+.dps li { position: relative; display: flex; justify-content: space-between; gap: 8px; padding: 3px 8px; border-radius: 6px; background: var(--bg); overflow: hidden; }
+.dps li > :not(.dps-fill) { position: relative; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dps-fill { position: absolute; inset: 0 auto 0 0; background: rgba(152, 161, 180, .18); transition: width .3s ease-out; }
+.dps li.mine .dps-fill { background: rgba(224, 176, 74, .3); }
.prayer-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.prayer-bar .pp { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; margin-right: 4px; }
.pray-btn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; padding: 0; cursor: pointer;