diff --git a/README.md b/README.md
index 4eee2f1..19530f1 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Beta. The core game is in place; changes from here are mostly balance and tuning
Shield Wall; the shield ones need a shield), warhammer hits hard and stuns
(Crushing Blow, Concuss, Earthshaker, Unstoppable), staff heals and holds
(Bolt, Mend, Ward, Thornbind), bow shoots from afar (Aimed Shot, Pinning
- Shot, Barbed Arrow, Volley, which takes 5 arrows). Trades: Find the Seam
+ Shot, Barbed Arrow, Volley). Trades: Find the Seam
(miner: a target's resistance stops counting), Barricade (builder: the group
takes less), Hearty Meal (homesteader: the group heals over time), Mark Prey
(ranger: a target takes more from everyone).
diff --git a/combat.py b/combat.py
index 6da3f6e..2ff1e9c 100644
--- a/combat.py
+++ b/combat.py
@@ -99,9 +99,9 @@ ABILITIES = {
"barbed_arrow": {"name": "Barbed Arrow", "target": "enemy", "cd": 12,
"effects": [_dmg(0.4), {"t": "dot", "c": 0.25, "every": 2, "dur": 12}], "auto": {"r": "dot"},
"tip": "Hit, then bleeds 6× 0.25 over 12s."},
- "volley": {"name": "Volley", "target": "aoe", "cd": 10, "cost": 5,
+ "volley": {"name": "Volley", "target": "aoe", "cd": 10,
"effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2},
- "tip": "Hits every enemy. 5 arrows."},
+ "tip": "Hits every enemy."},
# Trades: one each, whatever the weapon.
"find_the_seam": {"name": "Find the Seam", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6},
@@ -147,16 +147,13 @@ def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str]
def modded(ab: dict, mod: dict | None) -> dict:
"""An ability as something you wear changes it: `cd` and `cast` multiply, `c` multiplies every
- effect's size, `dur` adds seconds to every effect that lasts, `cost` replaces the arrows it takes,
- and `extra` adds effects."""
+ effect's size, `dur` adds seconds to every effect that lasts, and `extra` adds effects."""
if not mod:
return ab
ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]}
for key in ("cd", "cast"):
if key in mod and ab.get(key):
ab[key] = ab[key] * mod[key]
- if "cost" in mod:
- ab["cost"] = mod["cost"]
for eff in ab["effects"]:
if "c" in eff and "c" in mod:
eff["c"] *= mod["c"]
@@ -267,7 +264,7 @@ def _member_unit(member: dict) -> dict:
"kit": {a: modded(ABILITIES[a], me.get("mods", {}).get(a)) for a in me["abilities"]},
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "stun": 0.0, "target": None,
"mend": member["mend"], "wind": member["wind"], "wind_used": False,
- "potion": member.get("potion") or 0, "drank": False, "arrows": member.get("arrows", 0), "shot": 0,
+ "potion": member.get("potion") or 0, "drank": False,
"dealt": 0, "hurt": 0, "down": False, "manual": set(member.get("manual", [])),
"watch": member.get("watch", -1.0), "inputs": sorted(member.get("inputs", []), key=lambda i: i[0]),
"seen": 0, "queued": None, "picked": False, "ftarget": None}
@@ -309,7 +306,7 @@ def run(fight: dict) -> dict:
unit = units[member["id"]]
won = result == "won" and unit["out"] in (None, "down") and unit["in"]
member.update(hp=max(0, unit["hp"]), damage=unit["hurt"], dealt=unit["dealt"], wind_used=unit["wind_used"],
- drank=unit["drank"], shot=unit["shot"], down=unit["down"],
+ drank=unit["drank"], down=unit["down"],
result="won" if won else unit["out"] or ("left" if not unit["in"] else result))
fight.update(events=state["events"], kills=state["kills"], result=result,
end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"])
@@ -453,8 +450,6 @@ def _ready(unit: dict, ab: dict, t: float, manual: bool) -> str | None:
return "cooldown"
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
return "gcd"
- if ab.get("cost", 0) > unit["arrows"]:
- return "arrows"
return None
@@ -477,7 +472,7 @@ def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
_use(state, unit, ab, target, t, rng, manual=True)
if unit["cast"] or not ab.get("offgcd"):
return
- elif why == "arrows" or t - queued["at"] > QUEUE:
+ elif t - queued["at"] > QUEUE:
unit["queued"] = None
for ab_id in unit["abilities"]:
ab = unit["kit"][ab_id]
@@ -516,9 +511,6 @@ def _manual_target(state: dict, unit: dict, ab: dict, given: str | None, t: floa
def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random, manual: bool = False) -> None:
unit["cds"][ab["id"]] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
- if ab.get("cost"):
- unit["arrows"] -= ab["cost"]
- unit["shot"] += ab["cost"]
if not ab.get("offgcd"):
unit["busy"] = t + max(GCD, ab.get("cast", 0))
if ab.get("cast"):
diff --git a/game.py b/game.py
index e5196e6..11f7526 100644
--- a/game.py
+++ b/game.py
@@ -1347,8 +1347,8 @@ UNIQUES = {
{"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15},
"ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}),
"wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a",
- "Volley takes no arrows and hits 40% harder; Aimed Shot aims 25% faster.",
- {"volley": {"cost": 0, "c": 1.4}, "aimed_shot": {"cast": 0.75}}),
+ "Volley hits 40% harder; Aimed Shot aims 25% faster.",
+ {"volley": {"c": 1.4}, "aimed_shot": {"cast": 0.75}}),
}
# The pieces every dungeon's patterns reforge, by what they're made of at each tier.
MASTERWORK_PIECES = ["sword", "warhammer", "staff", "bow", "heavy", "medium", "light"]
@@ -2286,13 +2286,13 @@ def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
"start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"],
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
"fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion,
- "arrows": player["bank"].get("arrows", 0), "mend": blessed["mend"],
+ "mend": blessed["mend"],
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"],
"manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], "mods": me["mods"],
"watch": at + combat.WATCH * speed(world)}
-FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows")
+FIGHT_PRIVATE = ("fighter", "mend", "wind")
def fight_view(fight: dict) -> dict:
@@ -2891,7 +2891,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
player["pp"] -= paid
summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
- used = {**({mine["potion_item"]: 1} if mine["drank"] else {}), **({"arrows": mine["shot"]} if mine["shot"] else {})}
+ used = {mine["potion_item"]: 1} if mine["drank"] else {}
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
for item, count in spent.items():
count = min(count, player["bank"].get(item, 0))
@@ -5978,6 +5978,12 @@ PATCH_NOTES = [
"lens, Wyrmscale quiver. Abilities it changes get a ✦."),
("new", "Choose when you eat between fights: below 25 / 50 / 75% of your HP, always, or never (the food slot, "
"under \"Eat below\"). Default 50%: above it you just rest, so a scratch doesn't cost food."),
+ ("new", "Tapping an enemy or ally switches at once, and your next press goes to them: tap, then interrupt, "
+ "without waiting."),
+ ("new", "HP bars show what was just lost, with each fighter's last hit or heal by their HP."),
+ ("new", "An interrupted cast stays on the enemy's bar for a moment, struck through: ✋ interrupted."),
+ ("balance", "Volley no longer takes arrows: abilities never cost anything. Wyrmscale quiver: Volley 40% harder "
+ "and Aimed Shot 25% faster."),
]},
{"date": "2026-10-24", "title": "A dungeon in every area", "notes": [
("new", "Six more dungeons for 2–4, seven pulls each with two mini-bosses and a final boss: Goblin Warren "
diff --git a/static/app.js b/static/app.js
index a0b8e76..59607d1 100644
--- a/static/app.js
+++ b/static/app.js
@@ -24,6 +24,9 @@ let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for
let polledAt = 0;
let tagging = false; // this page just tagged into someone's fight
+// What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with
+// every press, so a tap and a press straight after it don't wait on each other.
+let aim = { fight: null, foe: null, ally: null };
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
@@ -906,8 +909,8 @@ function combatFrame() {
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
const unit = (id, name, foe = false, extra = "") => `
-
${name}${extra} ${foe ? "" : HEART} ${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}
-
+
${name}${extra} ${foe ? "" : HEART} ${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}
+
`;
@@ -973,6 +976,8 @@ function prayerBar(me) {
}
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
+const LAG = 0.6; // seconds the "just lost" part of an HP bar trails behind
+const STOPPED = 1.2; // seconds an interrupted cast stays on its bar
// A line of the fight, terse: who, to whom, how much, with what.
function fightLine(e, f) {
@@ -1023,7 +1028,7 @@ function fightState(f, t) {
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; });
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) {
- const [time, src, tgt, kind, , hp, label] = f.events[++shown];
+ const [time, src, tgt, kind, n, hp, label] = f.events[++shown];
const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp;
if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time;
@@ -1039,7 +1044,9 @@ function fightState(f, t) {
}
if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt;
if (who && kind === "heal" && label === "potion") who.drank = true;
+ if (them && kind === "stop" && them.cast) them.stopped = { label: them.cast.label, at: time };
if (them && (kind === "stop" || kind === "cc")) them.cast = null;
+ if (them && ["hit", "crit", "tick", "heal", "mend"].includes(kind) && n) them.hit = { at: time, n, kind };
if (them && kind === "cc") them.held = true;
if (them && (kind === "free" || kind === "die")) them.held = false;
if (them && kind === "die") them.out = "dead";
@@ -1057,6 +1064,7 @@ function animateFight(now) {
const over = t >= f.end;
const resting = t < rest;
const { st, shown } = fightState(f, t);
+ const before = fightState(f, t - LAG).st; // the HP a moment ago, trailing the bar
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
const me = st[mine.id];
// Resting: everyone's HP climbs back to full while they eat.
@@ -1072,15 +1080,25 @@ function animateFight(now) {
const pull = panel.querySelector("[data-pull-btn]");
if (pull) pull.hidden = !resting;
const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e");
- const aim = pick && st[pick[2]] && !st[pick[2]].out ? pick[2] : me.aim && st[me.aim] && !st[me.aim].out ? me.aim : null;
+ const live = (id) => id && st[id] && !st[id].out && !st[id].hidden;
+ const tapped = aim.fight === a.start ? aim : {};
+ const aimed = live(tapped.foe) ? tapped.foe : live(pick?.[2]) ? pick[2] : live(me.aim) ? me.aim : null;
+ const healing = live(tapped.ally) ? tapped.ally : null;
for (const el of panel.querySelectorAll("[data-u]")) {
const u = st[el.dataset.u];
if (!u) continue;
- el.classList.toggle("aimed", el.dataset.u === aim && !over);
+ el.classList.toggle("aimed", (el.dataset.u === aimed || el.dataset.u === healing) && !over);
const gone = !!u.out || over;
el.classList.toggle("out", !!u.out);
el.hidden = !!u.hidden;
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
+ const was = before[el.dataset.u];
+ el.querySelector("[data-u-lag]").style.width = `${Math.max(0, Math.max(u.hp, resting ? 0 : was?.hp ?? u.hp) / u.top * 100)}%`;
+ const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
+ const healed = hit && (hit.kind === "heal" || hit.kind === "mend");
+ num.textContent = hit ? `${healed ? "+" : "−"}${hit.n}` : "";
+ num.className = `hitnum ${healed ? "good" : "bad"} ${hit?.kind === "crit" ? "crit" : ""}`;
+ num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : "";
const statusEl = el.querySelector("[data-u-status]");
@@ -1090,12 +1108,17 @@ function animateFight(now) {
}
el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`;
const cast = !gone && u.cast && t < u.cast.until ? u.cast : null;
- const spec = cast && (el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities)[cast.label];
+ // A cast someone stopped stays up a moment, struck through.
+ const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
+ const table = el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities;
+ const spec = cast ? table[cast.label] : stopped ? table[stopped.label] : null;
const castEl = el.querySelector("[data-u-cast]");
- const key = cast ? `${cast.label}:${cast.from}` : "";
+ const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.label}:${stopped.at}` : "";
if (castEl.dataset.key !== key) {
castEl.dataset.key = key;
- castEl.innerHTML = cast ? `${spec.icon} ${esc(spec.name)}` : "";
+ castEl.classList.toggle("stopped", !!stopped);
+ castEl.innerHTML = cast ? `${spec.icon} ${esc(spec.name)}`
+ : stopped ? `✋ ${spec.icon} ${esc(spec.name)}interrupted` : "";
}
if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
}
@@ -2300,13 +2323,22 @@ document.addEventListener("click", (e) => {
if (d.press) {
if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself");
t.closest(".ab")?.classList.add("queued");
- return act("/api/press", { id: d.press });
+ const kind = C.abilities[d.press].target, mine = aim.fight === S.me.action?.start;
+ const target = !mine ? null : kind === "enemy" ? aim.foe : kind === "ally" ? aim.ally : null;
+ return act("/api/press", { id: d.press, ...(target ? { target } : {}) });
}
if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) });
if ("pull" in d) return act("/api/pull", {});
if (d.learn) return act("/api/learn", { item: d.learn });
if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) });
- if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
+ if (d.aim) {
+ const fight = S.me.action?.start;
+ if (aim.fight !== fight) aim = { fight, foe: null, ally: null };
+ if (d.aim[0] === "e") aim.foe = d.aim;
+ else aim.ally = d.aim;
+ animateFight(serverNow()); // the outline moves now; the server hears about it in the background
+ return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
+ }
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
diff --git a/static/styles.css b/static/styles.css
index 64a33d5..03a651b 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -587,6 +587,16 @@ button.sheet-skill:hover { background: var(--panel-2); }
.ab-lv .gi { width: 10px; height: 10px; }
.ab.boosted .ab-press::after { content: "✦"; position: absolute; top: 1px; right: 3px; font-size: 10px; color: var(--gold); }
.unique-text { color: var(--gold); margin-top: 4px; }
+/* Damage on the bars: what was just lost trails behind, the last hit or heal by the HP */
+.fight .bar.hp { position: relative; }
+.fight .bar.hp > .lag { position: absolute; inset: 0 auto 0 0; background: rgba(255, 236, 200, .55); transition: width .25s ease-out; }
+.fight .bar.hp > span:not(.lag) { position: relative; }
+.hitnum { font-weight: 700; margin-right: 6px; font-variant-numeric: tabular-nums; }
+.hitnum.bad { color: var(--bad); }
+.hitnum.good { color: var(--good); }
+.hitnum.crit { font-size: 1.15em; }
+.cast.stopped { color: var(--muted); }
+.cast.stopped s { text-decoration-thickness: 2px; }
.eat-below { display: flex; flex-wrap: wrap; gap: 4px; }
.eat-below .btn.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
.ab.locked { opacity: .35; filter: grayscale(1); }
diff --git a/tests/test_game.py b/tests/test_game.py
index 9856494..5740b22 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -756,15 +756,11 @@ class AbilityTest(unittest.TestCase):
self.assertEqual(game.ITEMS["health_potion_3"]["drink"], game.HEALTH_HEAL[2])
self.assertNotIn("health_potion_3", game.edible({"bank": {"health_potion_3": 1}, "food": None}))
- def test_volley_spends_arrows(self):
+ def test_volley_needs_no_arrows(self):
world, p = self.fighter("ranger", 45, weapon="maple_bow")
- p["bank"] = {"arrows": 100}
- game.start_action(world, p, "fight_goblins", T0)
- summary = game.process_player(world, p, T0 + 1800, random.Random(2))
- shot = summary["used"].get("arrows", 0)
- self.assertGreater(shot, 0)
- self.assertEqual(shot % 5, 0)
- self.assertEqual(p["bank"].get("arrows", 0), 100 - shot)
+ p["bank"] = {}
+ fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(2))
+ self.assertTrue(any(e[1] == p["id"] and e[6] == "volley" for e in fight["events"]))
def test_a_party_rests_as_long_as_its_slowest(self):
world = world_with(("wil", "builder"), ("kay", "builder"))
@@ -836,6 +832,13 @@ class ActivePlayTest(unittest.TestCase):
dead = {e[2]: e[0] for e in self.fight["events"] if e[3] == "die"}
self.assertTrue(all(e[2] == "e1" for e in swings if e[0] < dead.get("e1", 1e9)))
+ def test_a_press_lands_on_the_enemy_it_names(self):
+ game.set_manual(self.world, self.p, "concuss", True, T0)
+ self.p["xp"]["combat"] = game.XP_TABLE[30]
+ self.assertIsNone(game.press(self.world, self.p, "concuss", self.at(3), "e1")) # not the one you're hitting
+ used = [e for e in self.fight["events"] if e[1] == "ash" and e[6] == "concuss" and e[0] >= self.at(3) - T0]
+ self.assertEqual(used[0][2], "e1")
+
def test_no_presses_while_resting_or_out_of_a_fight(self):
game.set_manual(self.world, self.p, "crushing_blow", True, T0)
self.assertEqual(game.press(self.world, self.p, "crushing_blow", T0), "Still resting")
@@ -1028,10 +1031,11 @@ class LootTest(unittest.TestCase):
plain = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
world, p = self.ranger("wyrmscale_quiver")
mods = game.fighter(p, game.NO_BLESSING, {})["mods"]
- self.assertEqual(mods["volley"], {"cost": 0, "c": 1.4})
+ self.assertEqual(mods["volley"], {"c": 1.4})
quivered = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
- self.assertGreater(game.member_of(plain, "ash")["shot"], 0)
- self.assertEqual(game.member_of(quivered, "ash")["shot"], 0) # no arrows
+ aims = lambda f: [e[0] for e in f["events"] if e[1] == "ash" and e[6] == "aimed_shot" and e[3] in ("cast", "hit", "crit")]
+ cast, landed = aims(quivered)[:2]
+ self.assertAlmostEqual(landed - cast, 1.5, places=1) # 2s of aiming, a quarter shorter
def test_a_pendant_mends_a_second_ally(self):
world = world_with(("kay", "homesteader"), ("wil", "builder"), ("bo", "miner"))