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