Combat rework: packs, abilities, rest, health potions, group bosses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
472609ac70
commit
57d11e30da
10 changed files with 1562 additions and 380 deletions
451
game.py
451
game.py
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue