Combat rework: packs, abilities, rest, health potions, group bosses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 09:49:21 +01:00
parent 472609ac70
commit 57d11e30da
10 changed files with 1562 additions and 380 deletions

451
game.py
View file

@ -12,6 +12,8 @@ import math
import random
from datetime import date
import combat
HOUR = 3600
DAY = 24 * HOUR
OFFLINE_CAP = 24 * HOUR
@ -22,8 +24,10 @@ LUCKY_CHANCE = 0.1
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
MAX_LOG = 200
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max
TOO_HURT = 0.3 # won't start a fight below this share without food
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster
KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out
KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting
def _xp_table(max_level: int = 99) -> list[int]:
@ -57,7 +61,7 @@ ITEMS = {
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
# `food` feeds that many townsfolk for two days; `heal` restores HP in a fight.
# `food` feeds that many townsfolk for two days; `heal` restores HP resting between fights.
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
@ -467,6 +471,14 @@ for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS:
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
# Health potions: the one heal you take into a fight, drunk once a fight when you're low.
# Each tier is the tier's herb and its cooked fish.
HEALTH_HEAL = [20, 40, 65, 90, 115, 140]
HEALTH_POTIONS = [(f"health_potion_{_tier + 1}", f"Health potion {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0],
FISH[_tier][1][0], HEALTH_HEAL[_tier]) for _tier in range(len(HEALTH_HEAL))]
for _id, _name, _level, _herb, _fish, _heal in HEALTH_POTIONS:
ITEMS[_id] = {"name": _name, "icon": "❤️", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_fish]["value"], "drink": _heal}
# What the city eats first: plain food before the good stuff.
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
EQUIP_SLOTS = (["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket", "storage", "bag"]
@ -624,11 +636,14 @@ def _tiered_actions() -> list[dict]:
# expeditions: one area per tier, each with four races (each feeding one weapon
# line with its trophy, and every armour line with the area's reagents) and a boss.
CRIT_CHANCE = 0.05
CRIT_MULT = 1.5
RESPAWN = 2 # 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
EAT_DELAY = 1.5 # seconds each bite holds back your next swing
CRIT_MULT = combat.CRIT_MULT
RESPAWN = 2 # the least rest between one fight and the next
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
BOSS_HITS = (1.3, 0.15) # ...and hit this hard against a pair, and this much harder for each fighter more
MAX_FIGHT = combat.MAX_FIGHT
PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you...
OUTNUMBERED = (0.65, 0.65) # ...each of them then with this share of the HP and the hits
DAMAGE_TYPES = {
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"},
@ -663,14 +678,16 @@ def _slug(name: str) -> str:
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP
# Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
ARCHETYPES = {
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
"skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4),
"caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3),
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
"boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 1),
"boss": (1.4, 3.0, 1.0, 1.0, 1.2, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.9, 1),
}
@ -682,7 +699,7 @@ def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resist
hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype]
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight,
"hp": hp, "interval": interval, "xp": round(hp * 0.13 + level * 0.5),
"hp": round(hp * TOUGHER), "interval": interval, "xp": round(hp * 0.13 + level * 0.5),
"accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion),
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))),
"attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)}
@ -704,9 +721,10 @@ def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: i
def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict:
"""One big enemy: everyone who brings it down gets their own loot."""
"""One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings
it down gets their own loot."""
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
boss=extra.pop("is_boss", True), personal_loot=True, **extra)
boss=extra.pop("is_boss", True), personal_loot=True, together=GROUP_SIZE, **extra)
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
@ -941,7 +959,7 @@ COMBAT_ACTIONS = [
_encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
*_area_fights(),
# Area bosses open at the area's level + 5, halfway through it: hard alone, comfortable together.
# Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights.
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
("blunt", "pierce"), gold=25, req=20,
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]),
@ -974,6 +992,9 @@ ACTIONS = [
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
fire="still", fuel=POTION_TIER[potion])
for potion, name, level, herb, second, skill, _effect in POTIONS],
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
_fuelled({herb: 1, fish: 1}, tier), {potion: 1}, group=f"Tier {ROMAN[tier]}", fire="still", fuel=tier)
for tier, (potion, name, level, herb, fish, _heal) in enumerate(HEALTH_POTIONS)],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
_action("hunt_deer", "hunting", "Hunt deer", 1, 25, 6, outputs={"raw_meat": 2, "hide": 1}),
_action("hunt_boar", "hunting", "Hunt boar", 15, 35, 15, outputs={"raw_meat": 2, "boar_hide": 1}),
@ -1140,7 +1161,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 25
WORLD_VERSION = 26
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1337,6 +1358,14 @@ def migrate(world: dict, now: float | None = None) -> dict:
if player["action"]:
player["action"].pop("fight", None)
world["version"] = 25
if world["version"] < 26:
# Packs and abilities: fights in progress are drawn up again.
for player in world["players"].values():
if player["action"]:
player["action"].pop("fight", None)
for party in world["parties"].values():
party["fight"] = None
world["version"] = 26
return world
@ -1722,13 +1751,17 @@ def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp"))
def regen_boost(world: dict | None, player: dict) -> float:
"""How much faster than usual you heal: the enchanted hearths, and amulets of regeneration."""
boost = 2 if world and fitted(world, "enchanted_hearths") else 1
return boost * (1 + gear_bonus(player, "regen") / 100)
def regen(world: dict, player: dict, now: float) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
boost = 2 if fitted(world, "enchanted_hearths") else 1
boost *= 1 + gear_bonus(player, "regen") / 100
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
@ -1746,20 +1779,23 @@ def affinity(enemy: dict, damage: str) -> float:
def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict:
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by
the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows,
ward soaks magic, both help you dodge, and a shield blocks."""
ward soaks magic, both help you dodge, and a shield blocks. The weapon and your trade give your
abilities; a sword and shield hold the line, a staff heals."""
style_id = weapon_style(player)
style = STYLES[style_id]
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
base = (2 + power / 3) * style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
lands = affinity(enemy, style["damage"]) if enemy else 1
taken = blessed["taken"] * (1 - brew.get("reduce", 0))
return {"style": style_id, "damage": style["damage"],
shield = bool(player["equip"].get("shield"))
return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield),
"abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield),
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
"accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * lands)),
"base": base, "max_hit": max(2, round(base * lands)),
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
"first": style.get("first", False), "affinity": lands,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"block": BLOCK_CHANCE if shield else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
"taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}}
@ -1768,62 +1804,91 @@ def _taken(me: dict, damage: str) -> float:
return me["taken"]["magic" if damage == "magic" else "physical"]
def hit_chance(accuracy: float, evasion: float) -> float:
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
hit_chance = combat.hit_chance
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, bite: int = 0) -> dict:
"""`bite` is what the next food heals: eating holds back your swings, so a fight you have
to eat through runs longer."""
def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]:
"""Resting back to full before a fight: how many game seconds, and the food eaten on the way.
A bite is only taken when it's quicker than resting, so better food makes for shorter rests."""
top = max_hp(player)
missing = top - (player["hp"] if hp is None else hp)
rate = top * REST_RATE * regen_boost(world, player)
seconds, eaten = 0.0, {}
for food in edible(player):
heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0)
while missing > 0 and left and min(heal, missing) / rate > BITE:
eaten[food] = eaten.get(food, 0) + 1
left -= 1
missing -= heal
seconds += BITE
return max(RESPAWN, seconds + max(0.0, missing) / rate), eaten
def health_potion(player: dict) -> str | None:
"""The strongest health potion in the bank: the one you'd drink in a fight."""
potions = [item for item in player["bank"] if ITEMS[item].get("drink")]
return max(potions, key=lambda item: ITEMS[item]["drink"]) if potions else None
# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on
# average (from tools/balance.py). Only the encounter cards' estimates use them.
ABILITY_SHARE = {"sword": 1.15, "hammer": 1.45, "staff": 1.35, "bow": 1.3}
ENEMY_SHARE = {"brute": 1.4, "skirmisher": 1.3, "caster": 1.6, "elite": 1.5, "boss": 1.6, "dragon": 1.8}
GUARD_SHARE = {"sword": 0.73, "hammer": 0.85, "staff": 0.33, "bow": 0.72} # what your abilities keep off you
PACK_SHARE = 1 + PACK_EXTRA * (2 * OUTNUMBERED[0] - 1) # a pack's HP, for one of you, against a lone enemy's
def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, pack: float = 1.0) -> dict:
me = fighter(player, blessed, brew, foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"])
dps = hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"] * ABILITY_SHARE[me["style"]]
kill = min(foe["hp"] * pack / dps, MAX_FIGHT)
taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"])
per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"]
per_second *= ENEMY_SHARE[foe["archetype"]] * GUARD_SHARE[me["style"]] * pack
per_second = max(0, per_second - blessed["mend"] * max_hp(player))
if bite:
eating = per_second * EAT_DELAY / bite # seconds spent eating per second of fighting
kill = kill / (1 - eating) if eating < 1 else MAX_FIGHT
kill = min(kill, MAX_FIGHT)
hurt = per_second * kill
return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"],
"their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))),
"kill_time": kill, "damage": hurt}
"kill_time": kill, "damage": per_second * kill}
def fight_odds(player: dict, action: dict) -> dict:
"""What a fight in this encounter looks like on average, for the cards, and against each variant."""
def fight_odds(player: dict, action: dict, world: dict | None = None) -> dict:
"""What a fight in this encounter looks like on average, for the cards, and against each variant:
the time to win it, the damage it costs, and the rest after it."""
blessed, brew = blessing(player), potion_effect(player, "combat")
variants = action["enemy"]["variants"]
pantry = edible(player)
bite = ITEMS[pantry[0]]["heal"] if pantry else 0
rows = [_variant_odds(player, foe, blessed, brew, bite) for foe in variants]
pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair
rows = [_variant_odds(player, foe, blessed, brew, pack) for foe in variants]
total = sum(foe["weight"] for foe in variants)
mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total
top = max_hp(player)
rest = rest_plan(world, player, top - min(top, mean("damage")))[0]
return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player),
"max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows),
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1),
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), "rest": round(rest, 1),
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
"damage": round(row["damage"], 1)} for row in rows]}
def fight_member(world: dict, player: dict, foe: dict, at: float = 0.0) -> dict:
def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
"""One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
fight, so playing it out again later goes the same way."""
blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe)
me = fighter(player, blessed, brew)
potion = health_potion(player)
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
"start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"],
"interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"],
"pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"],
"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"],
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"]}
FIGHT_PRIVATE = ("fighter", "hp_in", "pantry", "mend", "wind")
FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows")
def fight_view(fight: dict) -> dict:
@ -1836,120 +1901,60 @@ def member_of(fight: dict, uid: str) -> dict:
return next(member for member in fight["members"] if member["id"] == uid)
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
"""Pick the enemy and play the fight out for everyone in it."""
def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
"""Who you're up against: one of the encounter's variants, as many as there are of you and
often one more, weaker each when they outnumber you. A rare elite or a boss comes alone."""
variants = action["enemy"]["variants"]
variant = rng.choices(range(len(variants)), [v["weight"] for v in variants])[0]
foe = variants[variant]
pace = speed(world)
fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"],
"xp": foe["xp"], "enemy_interval": foe["interval"] / pace,
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
"members": [fight_member(world, player, foe) for player in players]}
return play_fight(action, fight)
weights = [foe["weight"] for foe in variants]
first = rng.choices(range(len(variants)), weights)[0]
picks, (hp, hits) = [first], (1.0, 1.0)
if action.get("together"):
hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE))
if len(variants) > 1 and variants[first]["archetype"] != "elite":
count = size + (rng.random() < PACK_EXTRA)
common = [i for i, foe in enumerate(variants) if foe["archetype"] != "elite"]
picks += rng.choices(common, [weights[i] for i in common], k=count - 1)
if count > size:
hp, hits = OUTNUMBERED
share = 1.0 if action.get("together") else hp # a boss grown for its group still pays each of them one boss
return [{**{key: variants[i][key] for key in ("name", "archetype", "level", "boss", "interval", "accuracy",
"evasion", "attack", "vulnerable", "resists")},
"variant": i, "hp": max(1, round(variants[i]["hp"] * hp)), "max_hit": max(1, round(variants[i]["max_hit"] * hits)),
"xp": variants[i]["xp"] * share, "share": share} for i in picks]
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
"""Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the
fight out."""
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
member = fight_member(world, player)
member.update(eaten=eaten, hp_in=member["top"])
rest = max(rest, seconds)
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": action["enemy"]["name"],
"pack": draw_pack(action, len(players), rng), "members": members}
return play_fight(fight)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
return draw_fight(world, [player], action, rng)
def play_fight(action: dict, fight: dict) -> dict:
"""Play out a fight swing by swing, every swing timer starting empty (a latecomer's from when they
tag in). Nothing changes until it's over: the result is applied then. The dice come from the fight's
own seed, so playing it again with someone tagging in or stepping out changes nothing before then."""
rng = random.Random(fight["seed"])
foe = action["enemy"]["variants"][fight["variant"]]
pace = fight["pace"]
sides = []
for member in fight["members"]:
me = member["fighter"]
# A bow looses its first arrow before the enemy closes in.
sides.append({"member": member, "me": me, "hp": member["hp_in"], "pantry": dict(member["pantry"]),
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "eaten": {}, "hurt": 0,
"dealt": 0, "wind": member["wind"], "out": None,
"hit": hit_chance(me["accuracy"], foe["evasion"]), "their_hit": hit_chance(foe["accuracy"], me["evasion"])})
foe_hp, theirs, events, t, won = foe["hp"], foe["interval"], [], 0.0, False
swings = 0 # an enemy with more than one attack takes turns with them
while True:
# The next thing to happen: someone stepping out, someone's swing (in order of who came first), the enemy's.
moves = [(theirs, 2, -1)]
for i, side in enumerate(sides):
if not side["out"]:
if side["member"]["left"] is not None:
moves.append((side["member"]["left"], 0, i))
moves.append((side["next"], 1, i))
if len(moves) == 1:
break
t, kind, i = min(moves)
if t > MAX_FIGHT:
for side in sides:
side["out"] = side["out"] or "fled"
break
if kind == 0:
sides[i]["out"] = "left"
continue
if kind == 1:
side, me = sides[i], sides[i]["me"]
top = side["member"]["top"]
side["next"] += me["interval"]
side["hp"] = min(top, side["hp"] + side["member"]["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing
crit = rng.random() < me["crit"]
landed = rng.random() < side["hit"]
dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0
foe_hp = max(0, foe_hp - dealt)
side["dealt"] += dealt
events.append([round(t / pace, 2), side["member"]["id"], ("crit" if crit else "hit") if landed else "miss",
dealt, round(side["hp"]), foe_hp, me["damage"]])
if not foe_hp:
won = True
break
continue
theirs += foe["interval"]
targets = [side for side in sides if not side["out"] and side["member"]["at"] <= t]
if not targets:
continue
side = targets[0] if len(targets) == 1 else rng.choice(targets)
me, uid, top = side["me"], side["member"]["id"], side["member"]["top"]
attack = foe["attack"][swings % len(foe["attack"])]
swings += 1
crit = rng.random() < CRIT_CHANCE
landed = rng.random() < side["their_hit"]
if landed and rng.random() < me["block"]:
events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(side["hp"])), foe_hp, attack, uid])
continue
dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, attack))) \
if landed else 0
side["hp"] -= dealt
side["hurt"] += dealt
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt,
max(0, round(side["hp"])), foe_hp, attack, uid])
pantry = side["pantry"]
while side["hp"] < top * EAT_BELOW and any(pantry.values()):
food = next(item for item, left in pantry.items() if left)
pantry[food] -= 1
side["eaten"][food] = side["eaten"].get(food, 0) + 1
side["hp"] = min(top, side["hp"] + ITEMS[food]["heal"])
side["next"] += EAT_DELAY
events.append([round(t / pace, 2), uid, "eat", ITEMS[food]["heal"], round(side["hp"]), foe_hp, food])
beaten, starving = side["hp"] <= 0, side["hp"] < top * FLEE_BELOW and not any(pantry.values())
if (beaten or starving) and side["wind"]:
# Second Wind: the retreat that was coming turns into half health, once per outing.
side["wind"] = False
side["hp"] = top / 2
events.append([round(t / pace, 2), uid, "wind", 0, round(side["hp"]), foe_hp])
elif beaten:
side["hp"], side["out"] = 1, "lost"
elif starving:
side["out"] = "fled"
for side in sides:
member = side["member"]
member.update(hp=side["hp"], eaten=side["eaten"], damage=side["hurt"], dealt=side["dealt"],
wind_used=member["wind"] and not side["wind"],
result=side["out"] or ("won" if won and member["at"] <= t else "left"))
standing = [side for side in sides if side["out"] not in ("left", None)]
fight.update(events=events, end=round(t / pace, 2), duration=t / pace,
result="won" if won else standing[-1]["out"] if standing else "left")
return fight
def play_fight(fight: dict) -> dict:
"""Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over:
the result is applied then. The dice come from the fight's own seed, so playing it again with
someone tagging in or stepping out changes nothing before then."""
return combat.run(fight)
def pack_xp(fight: dict, killed_only: bool = False) -> float:
"""The pack's XP: on a loss, the enemies brought down and a third of the rest."""
if not killed_only:
return sum(foe["xp"] for foe in fight["pack"])
kills = set(fight["kills"])
return sum(foe["xp"] * (1 if f"e{i}" in kills else 1 / 3) for i, foe in enumerate(fight["pack"]))
# --- prayer -------------------------------------------------------------------
@ -2296,8 +2301,6 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return "No raid to prepare for"
if threat.get("drilled", 0) >= drill_needed(threat):
return "The militia is as drilled as it can be"
if "enemy" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
return "Too hurt to fight. Rest, or bring food"
return None
@ -2352,9 +2355,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if "enemy" in action:
outing = player["action"]
fight = fight or 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
xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met
mine = member_of(fight, player["id"])
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
if player["prayers"] and not mine["pp"]:
summary["prayer_out"] = True
paid = min(player["pp"], mine["pp"])
@ -2362,26 +2364,33 @@ 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)
for food, count in mine["eaten"].items():
count = min(count, player["bank"].get(food, 0))
if count:
bank_take(player, food, count)
summary["eaten"][food] = summary["eaten"].get(food, 0) + count
player["hp"], player["hp_at"] = mine["hp"], now
used = {**({mine["potion_item"]: 1} if mine["drank"] else {}), **({"arrows": mine["shot"]} if mine["shot"] else {})}
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
for item, count in spent.items():
count = min(count, player["bank"].get(item, 0))
if count:
bank_take(player, item, count)
summary[key][item] = summary[key].get(item, 0) + count
player["hp"] = max_hp(player) * KNOCKED_OUT_HP if mine["down"] else mine["hp"]
player["hp_at"] = now
summary["damage"] += mine["damage"]
if mine["wind_used"]:
outing["wind_used"] = True
summary["second_wind"] += 1
if mine["result"] != "won":
# Beaten or out of food: fall back with nothing but your life.
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
summary["losses"] += 1
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
drop_out(world, player, now, reason)
summary["stopped"] = reason
gain_xp(world, player, "combat", xp // 3, now, summary)
gain_xp(world, player, "combat", xp, now, summary)
outing["downs"] = outing.get("downs", 0) + 1
reason = "Fell back: the fight dragged on" if mine["result"] == "fled" else \
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
if reason:
drop_out(world, player, now, reason)
summary["stopped"] = reason
return
outing.pop("downs", None)
summary["wins"] += 1
gold, drops = loot or roll_loot(action, rng)
gold, drops = loot or pack_loot(action, fight, rng)
player["gold"] += gold
summary["gold"] += gold
for item, qty in drops.items():
@ -2472,7 +2481,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
@ -2495,6 +2504,11 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
if not current:
return summary
limit = min(now, offline_limit(world, player))
if ACTION_BY_ID[current["id"]].get("together"):
# A group fight with no group: wait at it for someone to tag in.
current.pop("fight", None)
current["start"] = max(current["start"], limit)
return summary
while player["action"]:
action = growing(player)
if current.get("plot2") and can_start(world, player, action):
@ -2512,15 +2526,6 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
break
burn_fires(world, current["start"] + duration)
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not current.get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
current["wind_used"] = True
player["hp"] = max_hp(player) / 2
summary["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
stop(player, reason)
summary["stopped"] = reason
@ -2533,8 +2538,6 @@ 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:
@ -2574,12 +2577,23 @@ def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -
stop(player, reason)
def roll_loot(action: dict, rng: random.Random) -> tuple[int, dict]:
"""A kill's gold and drops."""
gold = rng.randint(*action["gold"]) if action.get("gold") else 0
def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]:
"""A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often."""
gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0
drops = {}
for item, qty, odds in action.get("drops", []):
if rng.random() < odds:
if rng.random() < odds * share:
drops[item] = drops.get(item, 0) + qty
return gold, drops
def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict]:
"""Everything the pack carried."""
gold, drops = 0, {}
for foe in fight["pack"]:
more, found = roll_loot(action, rng, foe["share"])
gold += more
for item, qty in found.items():
drops[item] = drops.get(item, 0) + qty
return gold, drops
@ -2651,6 +2665,8 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
pid = str(world["next_party_id"])
world["next_party_id"] += 1
solo = host["action"]
if action.get("together"):
solo["start"] = now # waiting for a group: it starts as you arrive
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
"start": solo["start"], "fight": solo.pop("fight", None)}
solo["party"] = pid
@ -2659,10 +2675,11 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
player["stopped"] = None
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
at = max(0.0, (now - party["start"]) * fight["pace"])
foe = action["enemy"]["variants"][fight["variant"]]
fight["members"].append(fight_member(world, player, foe, at))
play_fight(action, fight)
# Straight in, as you are: the others may still be resting, or already in the thick of it.
regen(world, player, now)
at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"])
fight["members"].append(fight_member(world, player, at))
play_fight(fight)
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
return None
@ -2674,13 +2691,13 @@ def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
party["members"].remove(player["id"])
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
action = ACTION_BY_ID[party["action"]]
member = member_of(fight, player["id"])
if now <= party["start"] or len(fight["members"]) == 1:
fight["members"].remove(member)
left = (now - party["start"]) * fight["pace"] - fight["rest"]
if left <= member["at"] or len(fight["members"]) == 1:
fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought
else:
member["left"] = max(member["at"], (now - party["start"]) * fight["pace"])
party["fight"] = play_fight(action, fight) if party["members"] and fight["members"] else None
member["left"] = left
party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None
if len(party["members"]) <= 1:
del world["parties"][pid]
for uid in party["members"]:
@ -2703,15 +2720,6 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
for player in members:
regen(world, player, party["start"])
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not player["action"].get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
player["action"]["wind_used"] = True
player["hp"] = max_hp(player) / 2
player.setdefault("away", empty_summary())["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
drop_out(world, player, party["start"], reason)
player.setdefault("away", empty_summary())["stopped"] = reason
@ -2735,15 +2743,15 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
break
burn_fires(world, end)
fight, party["fight"] = party["fight"], None
party["start"] = end + RESPAWN / speed(world) # a moment before the next enemy turns up
party["start"] = end
for player in members:
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
if action.get("personal_loot"):
shares = {uid: roll_loot(action, rng) for uid in winners}
shares = {uid: pack_loot(action, fight, rng) for uid in winners}
else:
shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {}
shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {}
for player in members:
if results.get(player["id"], "left") == "left":
continue
@ -3101,6 +3109,8 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo
leave_party(world, player, current["party"], now)
player["action"] = entry
player["stopped"] = None
if action.get("together"):
log(world, now, "party", f"{player['name']} is looking for a group: {action['name']}", player["id"])
return None
@ -4711,6 +4721,8 @@ def _assign_glyphs() -> None:
for potion, *_rest, skill, effect in POTIONS:
tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint)
for tier, (potion, *_rest) in enumerate(HEALTH_POTIONS):
ITEMS[potion].update(glyph=flasks[tier], tint="#e0506e")
for skill in SKILLS:
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
for key, spec in CLASSES.items():
@ -4752,6 +4764,27 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-24", "title": "Combat rework", "notes": [
("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). "
"A rare elite still comes alone."),
("new", "Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield "
"tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: "
"hover them on the Combat page."),
("new", "Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn't tanking, casters "
"cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack "
"whoever has the most threat, so a sword and shield draws them away from the rest."),
("new", "Food is only eaten resting between fights: you rest back to full before each one, and better food "
"rests you faster. No food just means longer rests."),
("new", "Health potions I–VI (Herblore 1–75, the tier's herb and cooked fish): the one heal you take into a "
"fight, drunk once a fight under 35% HP."),
("new", "Knocked out at 0 HP: you lose that fight and come round at 30% HP. Three in a row and you stop. "
"Retreating is gone."),
("new", "Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. "
"They grow with the group: +75% HP for each fighter."),
("balance", "Enemies have 20% more HP for the same XP, since everyone has abilities now."),
("balance", "Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level's max hit, then ×1.3 "
"against a pair."),
]},
{"date": "2026-10-23", "title": "City needs", "notes": [
("new", "City needs are now indicated in profession menus."),
]},
@ -4793,7 +4826,9 @@ def content() -> dict:
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,