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 Shield Wall; the shield ones need a shield), warhammer hits hard and stuns
(Crushing Blow, Concuss, Earthshaker, Unstoppable), staff heals and holds (Crushing Blow, Concuss, Earthshaker, Unstoppable), staff heals and holds
(Bolt, Mend, Ward, Thornbind), bow shoots from afar (Aimed Shot, Pinning (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 (miner: a target's resistance stops counting), Barricade (builder: the group
takes less), Hearty Meal (homesteader: the group heals over time), Mark Prey takes less), Hearty Meal (homesteader: the group heals over time), Mark Prey
(ranger: a target takes more from everyone). (ranger: a target takes more from everyone).

View file

@ -99,9 +99,9 @@ ABILITIES = {
"barbed_arrow": {"name": "Barbed Arrow", "target": "enemy", "cd": 12, "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"}, "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."}, "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}, "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. # Trades: one each, whatever the weapon.
"find_the_seam": {"name": "Find the Seam", "target": "enemy", "cd": 30, "offgcd": True, "find_the_seam": {"name": "Find the Seam", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6}, "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: def modded(ab: dict, mod: dict | None) -> dict:
"""An ability as something you wear changes it: `cd` and `cast` multiply, `c` multiplies every """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, effect's size, `dur` adds seconds to every effect that lasts, and `extra` adds effects."""
and `extra` adds effects."""
if not mod: if not mod:
return ab return ab
ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]} ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]}
for key in ("cd", "cast"): for key in ("cd", "cast"):
if key in mod and ab.get(key): if key in mod and ab.get(key):
ab[key] = ab[key] * mod[key] ab[key] = ab[key] * mod[key]
if "cost" in mod:
ab["cost"] = mod["cost"]
for eff in ab["effects"]: for eff in ab["effects"]:
if "c" in eff and "c" in mod: if "c" in eff and "c" in mod:
eff["c"] *= mod["c"] 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"]}, "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, "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, "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", [])), "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]), "watch": member.get("watch", -1.0), "inputs": sorted(member.get("inputs", []), key=lambda i: i[0]),
"seen": 0, "queued": None, "picked": False, "ftarget": None} "seen": 0, "queued": None, "picked": False, "ftarget": None}
@ -309,7 +306,7 @@ def run(fight: dict) -> dict:
unit = units[member["id"]] unit = units[member["id"]]
won = result == "won" and unit["out"] in (None, "down") and unit["in"] 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"], 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)) 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, fight.update(events=state["events"], kills=state["kills"], result=result,
end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"]) 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" return "cooldown"
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9: if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
return "gcd" return "gcd"
if ab.get("cost", 0) > unit["arrows"]:
return "arrows"
return None 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) _use(state, unit, ab, target, t, rng, manual=True)
if unit["cast"] or not ab.get("offgcd"): if unit["cast"] or not ab.get("offgcd"):
return return
elif why == "arrows" or t - queued["at"] > QUEUE: elif t - queued["at"] > QUEUE:
unit["queued"] = None unit["queued"] = None
for ab_id in unit["abilities"]: for ab_id in unit["abilities"]:
ab = unit["kit"][ab_id] 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: 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) 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"): if not ab.get("offgcd"):
unit["busy"] = t + max(GCD, ab.get("cast", 0)) unit["busy"] = t + max(GCD, ab.get("cast", 0))
if ab.get("cast"): if ab.get("cast"):

16
game.py
View file

@ -1347,8 +1347,8 @@ UNIQUES = {
{"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15}, {"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15},
"ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}), "ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}),
"wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a", "wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a",
"Volley takes no arrows and hits 40% harder; Aimed Shot aims 25% faster.", "Volley hits 40% harder; Aimed Shot aims 25% faster.",
{"volley": {"cost": 0, "c": 1.4}, "aimed_shot": {"cast": 0.75}}), {"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. # 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"] 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"], "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), "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, "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"], "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"], "manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], "mods": me["mods"],
"watch": at + combat.WATCH * speed(world)} "watch": at + combat.WATCH * speed(world)}
FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows") FIGHT_PRIVATE = ("fighter", "mend", "wind")
def fight_view(fight: dict) -> dict: 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 player["pp"] -= paid
summary["pp_spent"] += paid summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary) 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 key, spent in (("eaten", mine["eaten"]), ("used", used)):
for item, count in spent.items(): for item, count in spent.items():
count = min(count, player["bank"].get(item, 0)) count = min(count, player["bank"].get(item, 0))
@ -5978,6 +5978,12 @@ PATCH_NOTES = [
"lens, Wyrmscale quiver. Abilities it changes get a ✦."), "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, " ("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."), "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": [ {"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 " ("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 refreshedFor = null; // action (start and length) a completion refresh was sent for
let polledAt = 0; let polledAt = 0;
let tagging = false; // this page just tagged into someone's fight 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 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 version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server 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 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" : ""}" 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"}"` : ""}> 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="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 data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></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="bar swing"><span data-u-swing></span></div>
<div class="cast small" data-u-cast></div> <div class="cast small" data-u-cast></div>
</div>`; </div>`;
@ -973,6 +976,8 @@ function prayerBar(me) {
} }
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]); 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. // A line of the fight, terse: who, to whom, how much, with what.
function fightLine(e, f) { 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 }; }); 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; let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) { 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]; const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp; if (them && hp != null) them.hp = hp;
if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time; 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?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt;
if (who && kind === "heal" && label === "potion") who.drank = true; 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 && (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 === "cc") them.held = true;
if (them && (kind === "free" || kind === "die")) them.held = false; if (them && (kind === "free" || kind === "die")) them.held = false;
if (them && kind === "die") them.out = "dead"; if (them && kind === "die") them.out = "dead";
@ -1057,6 +1064,7 @@ function animateFight(now) {
const over = t >= f.end; const over = t >= f.end;
const resting = t < rest; const resting = t < rest;
const { st, shown } = fightState(f, t); 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 mine = f.members.find((m) => m.id === S.uid) || f.members[0];
const me = st[mine.id]; const me = st[mine.id];
// Resting: everyone's HP climbs back to full while they eat. // 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]"); const pull = panel.querySelector("[data-pull-btn]");
if (pull) pull.hidden = !resting; 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 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]")) { for (const el of panel.querySelectorAll("[data-u]")) {
const u = st[el.dataset.u]; const u = st[el.dataset.u];
if (!u) continue; 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; const gone = !!u.out || over;
el.classList.toggle("out", !!u.out); el.classList.toggle("out", !!u.out);
el.hidden = !!u.hidden; el.hidden = !!u.hidden;
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`; 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)}`; 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 status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : "";
const statusEl = el.querySelector("[data-u-status]"); 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)}%`; 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 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 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) { if (castEl.dataset.key !== key) {
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)}%`; 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 (d.press) {
if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself"); if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself");
t.closest(".ab")?.classList.add("queued"); 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 (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 ("pull" in d) return act("/api/pull", {});
if (d.learn) return act("/api/learn", { item: d.learn }); if (d.learn) return act("/api/learn", { item: d.learn });
if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) }); 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.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true }); if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {}); 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-lv .gi { width: 10px; height: 10px; }
.ab.boosted .ab-press::after { content: "✦"; position: absolute; top: 1px; right: 3px; font-size: 10px; color: var(--gold); } .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; } .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 { display: flex; flex-wrap: wrap; gap: 4px; }
.eat-below .btn.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); } .eat-below .btn.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
.ab.locked { opacity: .35; filter: grayscale(1); } .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.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})) 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") world, p = self.fighter("ranger", 45, weapon="maple_bow")
p["bank"] = {"arrows": 100} p["bank"] = {}
game.start_action(world, p, "fight_goblins", T0) fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(2))
summary = game.process_player(world, p, T0 + 1800, random.Random(2)) self.assertTrue(any(e[1] == p["id"] and e[6] == "volley" for e in fight["events"]))
shot = summary["used"].get("arrows", 0)
self.assertGreater(shot, 0)
self.assertEqual(shot % 5, 0)
self.assertEqual(p["bank"].get("arrows", 0), 100 - shot)
def test_a_party_rests_as_long_as_its_slowest(self): def test_a_party_rests_as_long_as_its_slowest(self):
world = world_with(("wil", "builder"), ("kay", "builder")) 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"} 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))) 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): def test_no_presses_while_resting_or_out_of_a_fight(self):
game.set_manual(self.world, self.p, "crushing_blow", True, T0) game.set_manual(self.world, self.p, "crushing_blow", True, T0)
self.assertEqual(game.press(self.world, self.p, "crushing_blow", T0), "Still resting") 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)) plain = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
world, p = self.ranger("wyrmscale_quiver") world, p = self.ranger("wyrmscale_quiver")
mods = game.fighter(p, game.NO_BLESSING, {})["mods"] 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)) quivered = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
self.assertGreater(game.member_of(plain, "ash")["shot"], 0) 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")]
self.assertEqual(game.member_of(quivered, "ash")["shot"], 0) # no arrows 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): def test_a_pendant_mends_a_second_ally(self):
world = world_with(("kay", "homesteader"), ("wil", "builder"), ("bo", "miner")) world = world_with(("kay", "homesteader"), ("wil", "builder"), ("bo", "miner"))