From 60b7b23a052e075e7fa1e538f94b50e7fbfa63a2 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 20 Oct 2026 07:32:54 +0100 Subject: [PATCH] Combat: rewards on the kill, 1s pause, swing timer runs on between enemies Co-Authored-By: Claude Opus 5.5 --- game.py | 24 +++++++++++++++++------- static/app.js | 20 +++++++++++++++----- tests/test_game.py | 32 +++++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/game.py b/game.py index 6e5541f..a9b7dd7 100644 --- a/game.py +++ b/game.py @@ -432,7 +432,7 @@ def _tiered_actions() -> list[dict]: # the trophies higher weapons need, and a boss. CRIT_CHANCE = 0.05 CRIT_MULT = 1.5 -RESPAWN = 3 # seconds before the next enemy turns up +RESPAWN = 1 # seconds after a kill before the next enemy turns up MAX_FIGHT = 600 # a fight that drags on longer is broken off FLEE_BELOW = 0.2 # out of food and under this share of HP: get out @@ -1195,8 +1195,12 @@ 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) -> dict: - """Play out one fight swing by swing. Nothing changes until it's over: the result is applied then.""" +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. + """ 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"] @@ -1207,13 +1211,16 @@ 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. - mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0 + 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 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 @@ -1251,7 +1258,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 + RESPAWN) / pace, + "hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, "carry": t - swung, "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")} @@ -1512,7 +1519,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) + 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 if player["prayers"] and not fight["pp"]: summary["prayer_out"] = True paid = min(player["pp"], fight["pp"]) @@ -1639,7 +1647,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["fight"] = simulate_fight(world, player, action, rng, current.get("carry", 0)) duration = current["fight"]["duration"] else: duration = action_time(world, player, action) @@ -1667,6 +1675,8 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N _complete(world, player, action, current["start"] + duration, rng, summary) summary["done"] += 1 current["start"] += duration + if "enemy" in action and player["action"] is current: + current["start"] += RESPAWN / speed(world) # a moment before the next enemy turns up if current.get("plot2"): current["turn"] = 1 - current.get("turn", 0) if player["action"] is current and "left" in current: diff --git a/static/app.js b/static/app.js index 7f69d97..0267adb 100644 --- a/static/app.js +++ b/static/app.js @@ -478,9 +478,17 @@ function fightLine(e, enemy) { } function animateFight(now) { - const panel = $("[data-fight]"), a = S.me?.action, f = a?.fight; - if (!panel || !f) return; + const a = S.me?.action, f = a?.fight; + if (!f) return; const t = now - a.start; + { + let i = -1; + while (i + 1 < f.events.length && f.events[i + 1][0] <= t) i++; + const left = i >= 0 ? f.events[i][5] : f.enemy_hp; + for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${left / f.enemy_hp * 100}%`; + } + const panel = $("[data-fight]"); + if (!panel) return; let shown = -1; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; const last = f.events[shown]; @@ -506,7 +514,7 @@ function animateFight(now) { 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("") || `
  • Squaring up…
  • `; + el.innerHTML = lines.join("") || `
  • ${t < 0 ? "The next one is coming…" : "Squaring up…"}
  • `; }); } @@ -634,7 +642,8 @@ function actionCard(a) { ${io ? `
    ${io}
    ` : ""} ${extra} ${me.action?.plot2 && (active || second) ? `
    Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
    ` : ""} - ${growingNow ? `
    ` : ""} + ${growingNow && !a.enemy ? `
    ` : ""} + ${active && a.enemy ? `
    ⚔️ Fighting
    ` : ""} ${active && me.action.left ? `
    Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
    ` : ""}
    ${!active && !second && why ? esc(why) : ""} @@ -1158,7 +1167,8 @@ function animate() { animateFight(now); if (a) { const pct = Math.min(1, (now - a.start) / a.duration); - for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; + // In a fight the bar is the enemy's health (set by animateFight), not a timer. + if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; if (pct >= 1 && refreshedFor !== a.start) { refreshedFor = a.start; setTimeout(refresh, 150); diff --git a/tests/test_game.py b/tests/test_game.py index 5a562ab..6ad62e6 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -530,10 +530,36 @@ class CombatTest(unittest.TestCase): self.assertEqual(fight["result"], "won") self.assertTrue(all(e[1] in ("you", "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"])) self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it - self.assertAlmostEqual(fight["duration"], fight["end"] + game.RESPAWN, places=1) - summary = game.process_player(world, p, T0 + fight["duration"] + 0.5, random.Random(2)) + self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill + summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2)) self.assertEqual(summary["wins"], 1) - self.assertEqual(p["hp"], fight["hp"]) + 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): + world, p = self.fighter(level=level, weapon="rune_sword") + game.start_action(world, p, "fight_wolves", T0) + 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): + 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 def test_preferred_food_is_eaten_first(self): world, p = self.fighter()