Instant targeting, damage on bars, interrupted casts, no arrows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 14:21:08 +01:00
parent bc99a1e0c5
commit 049e2c771a
6 changed files with 85 additions and 41 deletions

View file

@ -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).

View file

@ -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"):

16
game.py
View file

@ -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 "

View file

@ -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 = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"} ${f ? "aimable" : ""}"
data-u="${esc(id)}" ${f ? `data-aim="${esc(id)}" title="${foe ? "Tap to fight this one" : "Tap to aim your heals here"}"` : ""}>
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
<div class="bar hp ${foe ? "foe" : "you"}"><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span class="hitnum" data-u-hitnum></span><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
<div class="bar hp ${foe ? "foe" : "you"}"><span class="lag" data-u-lag></span><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-u-swing></span></div>
<div class="cast small" data-u-cast></div>
</div>`;
@ -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 ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>` : "";
castEl.classList.toggle("stopped", !!stopped);
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>`
: stopped ? `<span>✋ <s>${spec.icon} ${esc(spec.name)}</s></span><span class="small">interrupted</span>` : "";
}
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", {});

View file

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

View file

@ -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"))