Combat: rewards on the kill, 1s pause, swing timer runs on between enemies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 07:32:54 +01:00
parent 4cb6ff5ec0
commit 60b7b23a05
3 changed files with 61 additions and 15 deletions

24
game.py
View file

@ -432,7 +432,7 @@ def _tiered_actions() -> list[dict]:
# the trophies higher weapons need, and a boss. # the trophies higher weapons need, and a boss.
CRIT_CHANCE = 0.05 CRIT_CHANCE = 0.05
CRIT_MULT = 1.5 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 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 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)} "kill_time": round(kill, 1), "damage": round(hurt, 1)}
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: 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.""" """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") foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe) me = fighter(player, blessed, brew, foe)
shrug = 0 if me["pierce"] else foe["reduction"] 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") wind = blessed["wind"] and not (player["action"] or {}).get("wind_used")
pace = speed(world) pace = speed(world)
# A bow looses its first arrow before the enemy closes in. # 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: while result is None:
t = min(mine, theirs) t = min(mine, theirs)
if t > MAX_FIGHT: if t > MAX_FIGHT:
result = "fled" result = "fled"
break break
if mine <= theirs: if mine <= theirs:
swung = mine
mine += me["interval"] mine += me["interval"]
crit = rng.random() < me["crit"] crit = rng.random() < me["crit"]
landed = rng.random() < hit landed = rng.random() < hit
@ -1251,7 +1258,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random)
elif starving: elif starving:
result = "fled" result = "fled"
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), 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"], "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"], "eaten": eaten, "damage": hurt, "pp": blessed["cost"],
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")} "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: if "enemy" in action:
outing = player["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"]: if player["prayers"] and not fight["pp"]:
summary["prayer_out"] = True summary["prayer_out"] = True
paid = min(player["pp"], fight["pp"]) 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 "enemy" in action and not can_start(world, player, action):
if not current.get("fight"): if not current.get("fight"):
regen(world, player, current["start"], blessing(player)["regen"]) 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"] duration = current["fight"]["duration"]
else: else:
duration = action_time(world, player, action) 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) _complete(world, player, action, current["start"] + duration, rng, summary)
summary["done"] += 1 summary["done"] += 1
current["start"] += duration 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"): if current.get("plot2"):
current["turn"] = 1 - current.get("turn", 0) current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current: if player["action"] is current and "left" in current:

View file

@ -478,9 +478,17 @@ function fightLine(e, enemy) {
} }
function animateFight(now) { function animateFight(now) {
const panel = $("[data-fight]"), a = S.me?.action, f = a?.fight; const a = S.me?.action, f = a?.fight;
if (!panel || !f) return; if (!f) return;
const t = now - a.start; 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; let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[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)); 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" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>` if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>`
: `<li class="bad"><b>You fall back.</b></li>`); : `<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = lines.join("") || `<li class="muted">Squaring up…</li>`; el.innerHTML = lines.join("") || `<li class="muted">${t < 0 ? "The next one is coming…" : "Squaring up…"}</li>`;
}); });
} }
@ -634,7 +642,8 @@ function actionCard(a) {
${io ? `<div class="io">${io}</div>` : ""} ${io ? `<div class="io">${io}</div>` : ""}
${extra} ${extra}
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""} ${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
${growingNow ? `<div class="bar thick"><span data-progress></span></div>` : ""} ${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""}
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting</span></div>` : ""}
${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""} ${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""}
<div class="foot"> <div class="foot">
<span class="why">${!active && !second && why ? esc(why) : ""}</span> <span class="why">${!active && !second && why ? esc(why) : ""}</span>
@ -1158,7 +1167,8 @@ function animate() {
animateFight(now); animateFight(now);
if (a) { if (a) {
const pct = Math.min(1, (now - a.start) / a.duration); 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) { if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start; refreshedFor = a.start;
setTimeout(refresh, 150); setTimeout(refresh, 150);

View file

@ -530,10 +530,36 @@ class CombatTest(unittest.TestCase):
self.assertEqual(fight["result"], "won") 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.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.assertEqual(fight["events"][-1][5], 0) # the last swing finished it
self.assertAlmostEqual(fight["duration"], fight["end"] + game.RESPAWN, places=1) self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill
summary = game.process_player(world, p, T0 + fight["duration"] + 0.5, random.Random(2)) summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2))
self.assertEqual(summary["wins"], 1) 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): def test_preferred_food_is_eaten_first(self):
world, p = self.fighter() world, p = self.fighter()