From 5898e2006fac878938e4bc33adca9ce59449125d Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 20 Oct 2026 07:51:16 +0100 Subject: [PATCH] Combat: each enemy starts with fresh swing timers; show the pause after a kill Co-Authored-By: Claude Opus 5.5 --- game.py | 22 ++++++++-------------- static/app.js | 9 ++++++--- static/styles.css | 1 + tests/test_game.py | 24 ++++++++---------------- 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/game.py b/game.py index a9b7dd7..eaacd3b 100644 --- a/game.py +++ b/game.py @@ -1195,12 +1195,9 @@ def fight_odds(player: dict, action: dict) -> dict: "kill_time": round(kill, 1), "damage": round(hurt, 1)} -def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, carry: float = 0) -> dict: - """Play out one fight swing by swing. Nothing changes until it's over: the result is applied then. - - `carry` is how long ago (in game seconds) you last swung: your swing timer runs on from one - enemy to the next, so outclassing an enemy means killing as fast as you can swing. - """ +def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: + """Play out one fight swing by swing, both swing timers starting empty. Nothing changes + until it's over: the result is applied then.""" foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat") me = fighter(player, blessed, brew, foe) shrug = 0 if me["pierce"] else foe["reduction"] @@ -1211,16 +1208,13 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, wind = blessed["wind"] and not (player["action"] or {}).get("wind_used") pace = speed(world) # A bow looses its first arrow before the enemy closes in. - ready = max(0.0, me["interval"] - carry) - mine, theirs, result, t = (min(ready, 0.5) if me["first"] else ready), foe["interval"], None, 0.0 - swung = -carry + mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0 while result is None: t = min(mine, theirs) if t > MAX_FIGHT: result = "fled" break if mine <= theirs: - swung = mine mine += me["interval"] crit = rng.random() < me["crit"] landed = rng.random() < hit @@ -1258,7 +1252,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, elif starving: result = "fled" return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), - "hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, "carry": t - swung, + "hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"], "eaten": eaten, "damage": hurt, "pp": blessed["cost"], "wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")} @@ -1519,8 +1513,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if "enemy" in action: outing = player["action"] - fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng, outing.get("carry", 0)) - outing["carry"] = fight.get("carry", 0) + RESPAWN # fights from before the carry-over have none + fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng) + outing.pop("carry", None) # from a short-lived version that ran the swing timer on if player["prayers"] and not fight["pp"]: summary["prayer_out"] = True paid = min(player["pp"], fight["pp"]) @@ -1647,7 +1641,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N if "enemy" in action and not can_start(world, player, action): if not current.get("fight"): regen(world, player, current["start"], blessing(player)["regen"]) - current["fight"] = simulate_fight(world, player, action, rng, current.get("carry", 0)) + current["fight"] = simulate_fight(world, player, action, rng) duration = current["fight"]["duration"] else: duration = action_time(world, player, action) diff --git a/static/app.js b/static/app.js index 0267adb..efe6c37 100644 --- a/static/app.js +++ b/static/app.js @@ -489,6 +489,8 @@ function animateFight(now) { } const panel = $("[data-fight]"); if (!panel) return; + // Between kills: the next enemy is on its way, both swing timers empty. + panel.classList.toggle("waiting", t < 0); let shown = -1; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; const last = f.events[shown]; @@ -505,16 +507,17 @@ function animateFight(now) { for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) { let swung = 0; for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; } - set(`${who}-swing`, (el) => { el.style.width = over ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; }); + set(`${who}-swing`, (el) => { el.style.width = over || t < 0 ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; }); } set("log", (el) => { - const key = `${a.start}:${shown}:${over}`; + const key = `${a.start}:${shown}:${over}:${t < 0 ? Math.ceil(-t * 10) : ""}`; if (el.dataset.key === key) return; el.dataset.key = key; const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy)); if (over) lines.unshift(f.result === "won" ? `
  • ${esc(f.enemy)} is beaten. The next one is coming…
  • ` : `
  • You fall back.
  • `); - el.innerHTML = lines.join("") || `
  • ${t < 0 ? "The next one is coming…" : "Squaring up…"}
  • `; + el.innerHTML = t < 0 ? `
  • Victory! Next enemy in ${(-t).toFixed(1)}s
  • ` + : lines.join("") || `
  • Squaring up…
  • `; }); } diff --git a/static/styles.css b/static/styles.css index 2b5504f..4245b94 100644 --- a/static/styles.css +++ b/static/styles.css @@ -231,6 +231,7 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p .fund { display: flex; flex-direction: column; gap: 6px; } .fight .fighters { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; } .fight .vs { font-size: 22px; } +.fight.waiting .fighter:last-child { opacity: .45; } .fighter { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .fighter-name { display: flex; justify-content: space-between; gap: 8px; } .fighter-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/tests/test_game.py b/tests/test_game.py index 6ad62e6..5e54954 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -536,14 +536,6 @@ class CombatTest(unittest.TestCase): self.assertAlmostEqual(p["hp"], fight["hp"], delta=0.1) self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause - def test_a_fight_from_before_the_carry_over_still_finishes(self): - world, p = self.fighter(level=30, weapon="iron_sword") - game.start_action(world, p, "fight_wolves", T0) - game.process_player(world, p, T0 + 0.1, random.Random(1)) - del p["action"]["fight"]["carry"] - summary = game.process_player(world, p, T0 + 60, random.Random(1)) - self.assertGreater(summary["wins"], 0) - def test_outclassing_an_enemy_kills_faster(self): kills = {} for level in (20, 90): @@ -552,15 +544,15 @@ class CombatTest(unittest.TestCase): kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"] self.assertGreater(kills[90], kills[20] * 1.3) - def test_the_swing_timer_runs_on_between_enemies(self): + def test_each_enemy_starts_with_both_swing_timers_empty(self): world, p = self.fighter(level=60, weapon="rune_sword") - wolves = game.ACTION_BY_ID["fight_wolves"] - fresh = game.simulate_fight(world, p, wolves, random.Random(1)) - carried = game.simulate_fight(world, p, wolves, random.Random(1), carry=1.0) - first = lambda fight: next(e[0] for e in fight["events"] if e[1] == "you") - self.assertEqual(first(fresh), 2.4) - self.assertEqual(first(carried), 1.4) # a second already gone in the pause between wolves - + game.start_action(world, p, "fight_wolves", T0) + game.process_player(world, p, T0 + 0.1, random.Random(1)) + first = p["action"]["fight"] + game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1)) + self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill + second = p["action"]["fight"] + self.assertEqual(next(e[0] for e in second["events"] if e[1] == "you"), 2.4) # a full swing, from scratch def test_preferred_food_is_eaten_first(self): world, p = self.fighter() p["bank"] = {"bread": 5, "cooked_salmon": 5}