guildhall/combat.py
rope 57d11e30da Combat rework: packs, abilities, rest, health potions, group bosses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:49:21 +01:00

729 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The fight engine: a party against a pack, stepped in fixed slices of game time.
A fight is played out whole as soon as it's drawn up, and again whenever someone tags in or
steps out, so the page can replay it. Each slice draws from a random seeded by the fight and
the slice number: playing it again with a latecomer changes nothing before they arrive.
Everyone still swings on their weapon's timer. On top of that, the weapon in your hands
gives four abilities (learned at combat 1, 15, 30 and 45) and your trade one more. For now
every ability is on auto: a rule decides when, and runs its cooldown half again as long.
"""
from __future__ import annotations
import random
STEP = 0.25 # game seconds per slice
GCD = 1.5
AUTO_DELAY = 0.5 # auto waits this long after it could act
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
AUTO_REACT = 1.0 # seconds into an enemy's cast before auto stops it
CRIT_MULT = 1.5
ENEMY_CRIT = 0.05
SPREAD = 0.1 # abilities land within 10% of their size
HEAL_THREAT = 0.5
STICKY = 1.1 # an enemy only turns when someone has 10% more threat than its target
TANK_THREAT = 2.0
POTION_BELOW = 0.35 # a health potion is drunk under this share of HP, once a fight
MAX_FIGHT = 600 # a fight that drags on longer is broken off
UNLOCK = [1, 15, 30, 45]
def hit_chance(accuracy: float, evasion: float) -> float:
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
# --- abilities ---------------------------------------------------------------
# `c` is a share of the user's average hit (healing too). Rules (`auto`) say when auto uses it.
def _dmg(c):
return {"t": "dmg", "c": c}
def _aura(kind, dur, on, **extra):
return {"t": "aura", "kind": kind, "dur": dur, "on": on, **extra}
ABILITIES = {
# Sword and shield: hold the line.
"shield_bash": {"name": "Shield Bash", "icon": "🛡️", "needs": "shield", "target": "enemy", "cd": 10,
"offgcd": True, "threat": 2, "effects": [_dmg(1.2), {"t": "stun", "dur": 2}],
"auto": {"r": "stop", "else": True},
"tip": "Hit, stun 2s, stop a cast. Bosses only lose the cast."},
"challenge": {"name": "Challenge", "icon": "😤", "target": "enemy", "cd": 8, "offgcd": True,
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
"tip": "An enemy turns on you for 3s."},
"sweeping_cut": {"name": "Sweeping Cut", "icon": "🌀", "target": "aoe", "cd": 10, "threat": 2,
"effects": [{"t": "aoe", "c": 0.8}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy. Double threat."},
"shield_wall": {"name": "Shield Wall", "icon": "🧱", "needs": "shield", "target": "self", "cd": 30, "offgcd": True,
"effects": [_aura("taken", 6, "self", mult=0.5)], "auto": {"r": "tanking", "pct": 0.6},
"tip": "Half damage taken for 6s."},
# Warhammer: slow, heavy, and hard to stand up to.
"crushing_blow": {"name": "Crushing Blow", "icon": "🔨", "target": "enemy", "cd": 16, "cast": 1.5,
"effects": [_dmg(1.5)], "auto": {"r": "always"}, "tip": "Wind up 1.5s, hit for 1.5×."},
"concuss": {"name": "Concuss", "icon": "💫", "target": "enemy", "cd": 15, "offgcd": True,
"effects": [{"t": "stun", "dur": 3}], "auto": {"r": "stop", "else": True},
"tip": "Stun 3s, stop a cast. Bosses only lose the cast."},
"earthshaker": {"name": "Earthshaker", "icon": "🌋", "target": "aoe", "cd": 12,
"effects": [{"t": "aoe", "c": 0.5}, {"t": "delay", "by": 1.5}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy and puts their next swing back 1.5s."},
"unstoppable": {"name": "Unstoppable", "icon": "😡", "target": "self", "cd": 45, "offgcd": True,
"effects": [_aura("done", 12, "self", mult=1.2)], "auto": {"r": "big"},
"tip": "+20% damage for 12s."},
# Staff: magic, and keeping everyone standing.
"bolt": {"name": "Bolt", "icon": "✨", "target": "enemy", "cd": 10, "cast": 1.5,
"effects": [_dmg(1.1)], "auto": {"r": "always"}, "tip": "Cast 1.5s, magic hit for 1.1×."},
"mend": {"name": "Mend", "icon": "💚", "target": "ally", "cd": 10, "cast": 2,
"effects": [{"t": "heal", "c": 2.0}], "auto": {"r": "heal", "pct": 0.5},
"tip": "Cast 2s, heal someone for 2× your hit."},
"ward": {"name": "Ward", "icon": "🔮", "target": "ally", "cd": 20,
"effects": [{"t": "shield", "c": 2.0, "dur": 20}], "auto": {"r": "ward", "pct": 0.8},
"tip": "Soaks 2× your hit for someone, for up to 20s."},
"thornbind": {"name": "Thornbind", "icon": "🌿", "target": "enemy", "cd": 20,
"effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"},
"tip": "Holds an enemy 15s. Damage breaks it. Not bosses."},
# Bow: fast, first, and from a distance.
"aimed_shot": {"name": "Aimed Shot", "icon": "🎯", "target": "enemy", "cd": 10, "cast": 2,
"effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."},
"pinning_shot": {"name": "Pinning Shot", "icon": "📌", "target": "enemy", "cd": 12, "offgcd": True,
"effects": [_dmg(0.5), {"t": "interrupt"}], "auto": {"r": "stop"},
"tip": "Hit and stop a cast."},
"barbed_arrow": {"name": "Barbed Arrow", "icon": "🩸", "target": "enemy", "cd": 12,
"effects": [_dmg(0.4), {"t": "dot", "c": 0.25, "every": 2, "dur": 12}], "auto": {"r": "dot"},
"tip": "Hit, then bleeds 6× 0.25 over 12s."},
"volley": {"name": "Volley", "icon": "🏹", "target": "aoe", "cd": 10, "cost": 5,
"effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy. 5 arrows."},
# Trades: one each, whatever the weapon.
"find_the_seam": {"name": "Find the Seam", "icon": "⛏️", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6},
"tip": "8s: its resistance doesn't count and its weakness counts double, for everyone."},
"barricade": {"name": "Barricade", "icon": "🚧", "target": "party", "cd": 40, "offgcd": True,
"effects": [_aura("taken", 8, "party", mult=0.75)], "auto": {"r": "danger", "pct": 0.6},
"tip": "Everyone takes 25% less damage for 8s."},
"hearty_meal": {"name": "Hearty Meal", "icon": "🍲", "target": "party", "cd": 30,
"effects": [{"t": "hot", "c": 0.3, "every": 2, "dur": 10}], "auto": {"r": "party_hot", "pct": 0.75},
"tip": "Everyone heals 5× 0.3 of your hit over 10s."},
"mark_prey": {"name": "Mark Prey", "icon": "🐾", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("vuln", 15, "target", mult=1.15)], "auto": {"r": "debuff", "pct": 0.6},
"tip": "It takes 15% more damage from everyone for 15s."},
}
WEAPON_KITS = {
"sword": ["shield_bash", "challenge", "sweeping_cut", "shield_wall"],
"hammer": ["crushing_blow", "concuss", "earthshaker", "unstoppable"],
"staff": ["bolt", "mend", "ward", "thornbind"],
"bow": ["aimed_shot", "pinning_shot", "barbed_arrow", "volley"],
}
TRADE_ABILITY = {"miner": "find_the_seam", "builder": "barricade", "homesteader": "hearty_meal", "ranger": "mark_prey"}
# The order auto looks down: stopping casts and staying alive before damage.
PRIORITY = ["pinning_shot", "shield_bash", "concuss", "challenge", "shield_wall", "barricade", "unstoppable",
"mend", "ward", "hearty_meal", "thornbind", "find_the_seam", "mark_prey", "barbed_arrow",
"earthshaker", "sweeping_cut", "volley", "crushing_blow", "aimed_shot", "bolt"]
for _id, _ability in ABILITIES.items():
_ability["id"] = _id
for _style, _kit in WEAPON_KITS.items():
if _id in _kit:
_ability.update(style=_style, level=UNLOCK[_kit.index(_id)])
for _cls, _trade in TRADE_ABILITY.items():
if _trade == _id:
_ability.update(trade=_cls, level=1)
def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str]:
"""What you can use with this weapon at this combat level, in the order auto tries them."""
kit = [a for a in WEAPON_KITS[style] if ABILITIES[a]["level"] <= level and (shield or not ABILITIES[a].get("needs"))]
if cls in TRADE_ABILITY:
kit.append(TRADE_ABILITY[cls])
return sorted(kit, key=PRIORITY.index)
def role(style: str, shield: bool) -> str:
return "tank" if style == "sword" and shield else "healer" if style == "staff" else "damage"
# --- enemy abilities ---------------------------------------------------------
# Every archetype has its own, so every area's races use them. `effect` is (kind, share of
# the enemy's average hit, damage type: None for its own). Telegraphed casts always land.
ENEMY_ABILITIES = {
"heavy_slam": {"name": "Heavy Slam", "icon": "💥", "cast": 2.0, "every": 14, "first": 5, "interrupt": False,
"target": "target", "effect": ("hit", 1.6, "blunt")},
"lunge": {"name": "Lunge", "icon": "🗡️", "cast": 0, "every": 10, "first": 4, "interrupt": False,
"target": "soft", "effect": ("hit", 1.2, None)},
"hex_bolt": {"name": "Hex Bolt", "icon": "🔥", "cast": 2.5, "every": 12, "first": 3, "interrupt": True,
"target": "target", "effect": ("hit", 1.8, "magic")},
"sweep": {"name": "Sweep", "icon": "🌀", "cast": 1.5, "every": 16, "first": 8, "interrupt": False,
"target": "all", "effect": ("aoe", 0.8, "slash")},
"fire_breath": {"name": "Fire Breath", "icon": "🐉", "cast": 3.0, "every": 18, "first": 6, "interrupt": False,
"target": "all", "effect": ("aoe", 1.4, "magic")},
"tail_swipe": {"name": "Tail Swipe", "icon": "💢", "cast": 0, "every": 12, "first": 7, "interrupt": False,
"target": "target", "effect": ("hit", 1.2, "blunt")},
}
ARCHETYPE_ABILITIES = {"brute": ["heavy_slam"], "skirmisher": ["lunge"], "caster": ["hex_bolt"],
"elite": ["heavy_slam"], "boss": ["heavy_slam", "sweep"], "dragon": ["fire_breath", "tail_swipe"]}
ENRAGE = {"elite": (0.3, 1.4), "boss": (0.3, 1.4), "dragon": (0.25, 1.5)} # under this share of HP, hits this much harder
VULNERABLE, RESISTANT = 1.25, 0.75
# --- setting up --------------------------------------------------------------
def _member_unit(member: dict) -> dict:
me = member["fighter"]
return {"id": member["id"], "side": "party", "name": member["name"], "top": member["top"], "hp": member["hp_in"],
"at": member["at"], "left": member["left"], "in": False, "out": None, "role": me["role"],
"damage": me["damage"], "interval": me["interval"], "accuracy": me["accuracy"], "evasion": me["evasion"],
"crit": me["crit"], "block": me["block"], "taken": me["taken"], "base": me["base"],
"abilities": list(me["abilities"]), "cds": {}, "busy": 0.0, "cast": None, "auras": [],
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "stun": 0.0, "target": None,
"mend": member["mend"], "wind": member["wind"], "wind_used": False,
"potion": member.get("potion") or 0, "drank": False, "arrows": member.get("arrows", 0), "shot": 0,
"dealt": 0, "hurt": 0, "down": False}
def _enemy_unit(index: int, foe: dict) -> dict:
return {"id": f"e{index}", "side": "enemy", "name": foe["name"], "archetype": foe["archetype"],
"boss": foe["archetype"] in ("boss", "dragon") or foe.get("boss", False),
"top": foe["hp"], "hp": foe["hp"], "interval": foe["interval"], "accuracy": foe["accuracy"],
"evasion": foe["evasion"], "max_hit": foe["max_hit"], "attack": foe["attack"],
"vulnerable": foe["vulnerable"], "resists": foe["resists"],
"next": foe["interval"] + 0.4 * index, "swings": 0, "cast": None, "auras": [], "threat": {},
"target": None, "taunt": None, "since": 0.0, "stun": 0.0, "cc": 0.0, "dead": False, "enraged": 1.0,
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"] + 0.5 * index}
for a in ARCHETYPE_ABILITIES.get(foe["archetype"], [])]}
def run(fight: dict) -> dict:
"""Play the fight out from the start. `fight` carries `seed`, `pace`, `rest` (game seconds before
it starts), `pack` (the enemies) and `members` (each with `fighter`, `at` and `left` in seconds of
the fight). Fills in `events`, the `kills`, each member's outcome, `result`, `end` and `duration`."""
units = {}
for member in fight["members"]:
units[member["id"]] = _member_unit(member)
for index, foe in enumerate(fight["pack"]):
unit = _enemy_unit(index, foe)
units[unit["id"]] = unit
state = {"units": units, "events": [], "kills": [], "pace": fight["pace"], "rest": fight["rest"]}
t, n, result = 0.0, 0, None
while result is None:
t = round(t + STEP, 4)
rng = random.Random(f"{fight['seed']}:{n}")
n += 1
result = _step(state, t, rng)
for member in fight["members"]:
unit = units[member["id"]]
won = result == "won" and unit["out"] in (None, "down") and unit["in"]
member.update(hp=max(0, unit["hp"]), damage=unit["hurt"], dealt=unit["dealt"], wind_used=unit["wind_used"],
drank=unit["drank"], shot=unit["shot"], down=unit["down"],
result="won" if won else unit["out"] or ("left" if not unit["in"] else result))
fight.update(events=state["events"], kills=state["kills"], result=result,
end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"])
return fight
def _real(state: dict, t: float) -> float:
"""A moment of the fight in real seconds since its action started (rest included)."""
return round((state["rest"] + t) / state["pace"], 2)
def _event(state: dict, t: float, src: str | None, tgt: str | None, kind: str, amount: float = 0,
label: str = "") -> None:
target = state["units"].get(tgt or "")
hp = max(0, round(target["hp"])) if target else None
state["events"].append([_real(state, t), src, tgt, kind, round(amount), hp, label])
def _party(state: dict) -> list[dict]:
return [u for u in state["units"].values() if u["side"] == "party" and u["in"] and not u["out"]]
def _enemies(state: dict) -> list[dict]:
return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"]]
# --- a slice -------------------------------------------------------------------
def _step(state: dict, t: float, rng: random.Random) -> str | None:
units = state["units"]
for unit in units.values():
if unit["side"] != "party" or unit["out"]:
continue
if not unit["in"] and unit["at"] <= t + 1e-9:
unit["in"] = True
for enemy in _enemies(state):
enemy["threat"].setdefault(unit["id"], 10.0 if unit["role"] == "tank" else 0.0)
if unit["in"] and unit["left"] is not None and unit["left"] <= t + 1e-9:
_out(state, unit, "left", t)
for unit in list(units.values()):
if _alive(unit):
_tick(state, unit, t, rng)
for unit in list(units.values()):
if _alive(unit) and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
_finish_cast(state, unit, t, rng)
for unit in _party(state):
_think(state, unit, t, rng)
for enemy in _enemies(state):
if enemy["cc"] and enemy["cc"] <= t + 1e-9:
enemy["cc"] = 0 # the hold wears off
_event(state, t, None, enemy["id"], "free")
_enemy_think(state, enemy, t, rng)
for unit in list(units.values()):
if _alive(unit):
_swing(state, unit, t, rng)
for enemy in _enemies(state):
rule = ENRAGE.get(enemy["archetype"])
if rule and enemy["enraged"] == 1.0 and enemy["hp"] < enemy["top"] * rule[0]:
enemy["enraged"] = rule[1]
_event(state, t, enemy["id"], enemy["id"], "enrage")
if not _enemies(state):
return "won"
if not _party(state) and not any(u["side"] == "party" and not u["in"] and not u["out"] for u in units.values()):
return "lost"
if t >= MAX_FIGHT:
for unit in _party(state):
unit["out"] = "fled"
return "fled"
return None
def _alive(unit: dict) -> bool:
return not unit["dead"] if unit["side"] == "enemy" else unit["in"] and not unit["out"]
def _tick(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if unit["side"] == "party" and unit["mend"]:
unit["hp"] = min(unit["top"], unit["hp"] + unit["mend"] * unit["top"] * STEP) # Rapid Heal
for aura in list(unit["auras"]):
if aura["kind"] in ("dot", "hot") and t >= aura["next"] - 1e-9:
aura["next"] += aura["every"]
source = state["units"].get(aura["by"])
if aura["kind"] == "dot":
_hurt(state, source, unit, aura["amount"], aura["school"], t, rng, label=aura["id"], crit=False, tick=True)
else:
_heal(state, source, unit, aura["amount"], t, aura["id"], tick=True)
if not _alive(unit):
return
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
unit["auras"].remove(aura)
# --- your side -----------------------------------------------------------------
def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if unit["potion"] and not unit["drank"] and unit["hp"] < unit["top"] * POTION_BELOW:
unit["drank"] = True
_heal(state, None, unit, unit["potion"], t, "potion")
if unit["stun"] > t or unit["cast"]:
return
_pick_target(state, unit, t)
for ab_id in unit["abilities"]:
ab = ABILITIES[ab_id]
if unit["cds"].get(ab_id, 0) > t + 1e-9:
continue
if not ab.get("offgcd") and unit["busy"] + AUTO_DELAY > t + 1e-9:
continue
if ab.get("cost", 0) > unit["arrows"]:
continue
target = _rule(state, unit, ab, t)
if target is False:
continue
_use(state, unit, ab, target, t, rng)
if unit["cast"] or not ab.get("offgcd"):
return
def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random) -> None:
unit["cds"][ab["id"]] = t + ab.get("cd", 0) * AUTO_CD
if ab.get("cost"):
unit["arrows"] -= ab["cost"]
unit["shot"] += ab["cost"]
if not ab.get("offgcd"):
unit["busy"] = t + max(GCD, ab.get("cast", 0))
if ab.get("cast"):
unit["cast"] = {"ab": ab["id"], "target": target, "start": t, "end": t + ab["cast"]}
_event(state, t, unit["id"], target, "cast", 0, ab["id"])
else:
_apply(state, unit, ab, target, t, rng)
def _finish_cast(state: dict, unit: dict, t: float, rng: random.Random) -> None:
cast, unit["cast"] = unit["cast"], None
if unit["side"] == "party":
_apply(state, unit, ABILITIES[cast["ab"]], cast["target"], t, rng)
else:
_enemy_effect(state, unit, cast["ab"], cast["target"], t, rng)
def _avg(unit: dict, enemy: dict | None = None) -> float:
"""The unit's average weapon hit, against this enemy if there is one."""
top = max(2, round(unit["base"] * (_affinity(enemy, unit["damage"]) if enemy else 1)))
return (top + 1) / 2
def _affinity(enemy: dict, damage: str) -> float:
seam = any(a["kind"] == "seam" for a in enemy["auras"])
if damage in enemy["vulnerable"]:
return 1 + (VULNERABLE - 1) * (2 if seam else 1)
if damage in enemy["resists"]:
return 1 if seam else RESISTANT
return 1
def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.Random) -> None:
units = state["units"]
target = units.get(target_id or "")
if ab["target"] in ("enemy", "ally") and (not target or not _alive(target)):
return
threat = ab.get("threat", 1)
if ab["target"] != "enemy" or not any(e["t"] == "dmg" for e in ab["effects"]):
_event(state, t, unit["id"], target_id, "use", 0, ab["id"])
spread = lambda: 1 + rng.uniform(-SPREAD, SPREAD)
for eff in ab["effects"]:
kind = eff["t"]
if kind == "dmg":
_hurt(state, unit, target, eff["c"] * _avg(unit, target) * spread(), unit["damage"], t, rng,
threat=threat, label=ab["id"])
elif kind == "aoe":
for enemy in _enemies(state):
if enemy["cc"] <= t:
_hurt(state, unit, enemy, eff["c"] * _avg(unit, enemy) * spread(), unit["damage"], t, rng,
threat=threat, label=ab["id"])
elif kind == "dot":
if target and _alive(target):
_add_aura(target, {"id": ab["id"], "kind": "dot", "by": unit["id"], "school": unit["damage"],
"amount": eff["c"] * _avg(unit, target), "every": eff["every"],
"next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
elif kind == "heal":
_heal(state, unit, target, eff["c"] * _avg(unit) * spread(), t, ab["id"])
elif kind == "hot":
for ally in _party(state):
_add_aura(ally, {"id": ab["id"], "kind": "hot", "by": unit["id"], "amount": eff["c"] * _avg(unit),
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
elif kind == "shield":
_add_aura(target, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit),
"until": t + eff["dur"]})
_heal_threat(state, unit, eff["c"] * _avg(unit))
elif kind == "aura":
holders = _party(state) if eff["on"] == "party" else [target] if eff["on"] == "target" else [unit]
spec = {k: v for k, v in eff.items() if k not in ("t", "dur", "on")}
for holder in holders:
if holder and _alive(holder):
_add_aura(holder, {**spec, "id": ab["id"], "by": unit["id"], "until": t + eff["dur"]})
elif kind == "taunt":
top = max(target["threat"].values(), default=0)
target["threat"][unit["id"]] = top * STICKY + 1
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
target["target"] = unit["id"]
elif kind == "stun":
if not _alive(target):
continue
_stop_cast(state, target, unit, t, force=not target["boss"])
if not target["boss"]:
target["stun"] = max(target["stun"], t + eff["dur"])
target["next"] = max(target["next"], t + eff["dur"])
elif kind == "interrupt":
if _alive(target):
_stop_cast(state, target, unit, t)
elif kind == "cc":
if target["boss"]:
continue
_stop_cast(state, target, unit, t, force=True, quiet=True)
target["cc"] = t + eff["dur"]
_event(state, t, unit["id"], target["id"], "cc", 0, ab["id"])
for other in _party(state):
if other["target"] == target["id"]:
other["target"] = None
elif kind == "delay":
for enemy in _enemies(state):
enemy["next"] += eff["by"]
def _stop_cast(state: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
cast = enemy.get("cast")
if not cast or (not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]):
return
enemy["cast"] = None
if not quiet:
_event(state, t, by["id"], enemy["id"], "stop", 0, cast["ab"])
def _add_aura(unit: dict, aura: dict) -> None:
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
unit["auras"].append(aura)
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
def _share(unit: dict) -> float:
return unit["hp"] / unit["top"]
def _pick_target(state: dict, unit: dict, t: float) -> None:
current = state["units"].get(unit["target"] or "")
if current and not current["dead"] and current["cc"] <= t:
return
enemies = _enemies(state)
free = [e for e in enemies if e["cc"] <= t] or enemies
if not free:
unit["target"] = None
return
if unit["role"] == "tank":
loose = [e for e in free if e["target"] != unit["id"]]
unit["target"] = (loose or sorted(free, key=_share))[0]["id"]
return
tank = next((u for u in _party(state) if u["role"] == "tank" and u["target"]), None)
focus = state["units"].get(tank["target"]) if tank else None
if focus and not focus["dead"] and focus["cc"] <= t:
unit["target"] = focus["id"]
else:
unit["target"] = min(free, key=_share)["id"]
def _stoppable(cast: dict, ab: dict, enemy: dict) -> bool:
spec = ENEMY_ABILITIES[cast["ab"]]
stuns = any(e["t"] == "stun" for e in ab["effects"])
return spec["interrupt"] or (stuns and not enemy["boss"])
def _rule(state: dict, unit: dict, ab: dict, t: float):
"""Auto's decision: the target to use this on, None for no target, False for not now."""
rule = ab["auto"]
r = rule["r"]
enemies = _enemies(state)
party = _party(state)
target = state["units"].get(unit["target"] or "")
if target and target["dead"]:
target = None
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
if r == "always":
return target["id"] if target else False
if r == "stop":
casting = [e for e in enemies if e["cast"] and t - e["cast"]["start"] >= AUTO_REACT - 1e-9
and _stoppable(e["cast"], ab, e)]
if casting:
return casting[0]["id"]
return target["id"] if rule.get("else") and target else False
if r == "taunt":
if unit["role"] != "tank":
return False
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
and t - e["since"] >= AUTO_REACT]
return loose[0]["id"] if loose else False
if r == "aoe":
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
if r == "tanking":
big = any(e["boss"] or e["archetype"] == "elite" or e["cast"] and e["cast"]["target"] in (unit["id"], None)
for e in on_me)
return None if on_me and (big or _share(unit) < rule["pct"]) else False
if r == "big":
return None if target and (target["boss"] or target["archetype"] == "elite" or len(enemies) >= 2) else False
if r == "danger":
threatening = len([e for e in enemies if e["cc"] <= t]) >= 2 or any(e["boss"] for e in enemies)
return None if threatening or any(_share(u) < rule["pct"] for u in party) else False
if r == "heal":
low = min(party, key=_share, default=None)
return low["id"] if low and _share(low) < rule["pct"] else False
if r == "ward":
targeted = {e["target"] for e in enemies}
bare = [u for u in party if not any(a["kind"] == "shield" for a in u["auras"])
and (u["id"] in targeted or _share(u) < rule["pct"])]
return min(bare, key=_share)["id"] if bare else False
if r == "party_hot":
return None if any(_share(u) < rule["pct"] and not _has(u, ab["id"], unit["id"]) for u in party) else False
if r == "cc":
free = [e for e in enemies if e["cc"] <= t]
if len(free) < 2:
return False
focus = {u["target"] for u in party if u["role"] == "tank"} | {unit["target"]}
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _share(e) > 0.5]
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
if r == "dot":
return target["id"] if target and not _has(target, ab["id"], unit["id"]) and _share(target) > 0.3 else False
if r == "debuff":
return target["id"] if target and not _has(target, ab["id"]) and _share(target) > rule["pct"] else False
return False
# --- their side ----------------------------------------------------------------
def _enemy_think(state: dict, enemy: dict, t: float, rng: random.Random) -> None:
if enemy["stun"] > t or enemy["cc"] > t:
enemy["cast"] = None
return
_enemy_target(state, enemy, t)
if enemy["cast"] or not enemy["target"]:
return
for slot in enemy["abil"]:
if t < slot["next"]:
continue
spec = ENEMY_ABILITIES[slot["id"]]
slot["next"] = t + spec["every"]
target = enemy["target"]
if spec["target"] == "soft":
soft = [u for u in _party(state) if u["role"] != "tank"]
target = rng.choice(soft)["id"] if soft else target
elif spec["target"] == "all":
target = None
if spec["cast"]:
enemy["cast"] = {"ab": slot["id"], "target": target, "start": t, "end": t + spec["cast"]}
_event(state, t, enemy["id"], target, "cast", 0, slot["id"])
else:
_enemy_effect(state, enemy, slot["id"], target, t, rng)
return
def _enemy_target(state: dict, enemy: dict, t: float) -> None:
units = state["units"]
taunt = enemy["taunt"]
if taunt and taunt["until"] > t and _alive(units[taunt["by"]]):
enemy["target"] = taunt["by"]
return
table = {uid: v for uid, v in enemy["threat"].items() if _alive(units[uid])}
if not table:
party = _party(state)
enemy["target"] = party[0]["id"] if party else None
if party:
enemy["threat"][party[0]["id"]] = 1.0
enemy["since"] = t
return
top = max(table, key=table.get)
current = enemy["target"]
if current in table and table[top] <= table[current] * STICKY:
return
enemy["target"], enemy["since"] = top, t
def _enemy_effect(state: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
spec = ENEMY_ABILITIES[ab_id]
kind, c, school = spec["effect"]
amount = c * (enemy["max_hit"] + 1) / 2 * enemy["enraged"]
if kind == "hit":
target = state["units"].get(target_id or "")
if target and _alive(target):
_hurt(state, enemy, target, amount * (1 + rng.uniform(-SPREAD, SPREAD)),
school or enemy["attack"][0], t, rng, label=ab_id, crit=False)
else:
_event(state, t, enemy["id"], None, "use", 0, ab_id)
for unit in _party(state):
_hurt(state, enemy, unit, amount * (1 + rng.uniform(-SPREAD, SPREAD)), school, t, rng,
label=ab_id, crit=False)
# --- blows ---------------------------------------------------------------------
def _swing(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if t < unit["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit.get("cc", 0) > t:
return
target = state["units"].get(unit["target"] or "")
if not target or not _alive(target):
return
unit["next"] = t + unit["interval"]
if unit["side"] == "party":
top = max(2, round(unit["base"] * _affinity(target, unit["damage"])))
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
_event(state, t, unit["id"], target["id"], "miss")
return
crit = rng.random() < unit["crit"]
amount = top * CRIT_MULT if crit else rng.randint(1, top)
_hurt(state, unit, target, amount, unit["damage"], t, rng, crit=False, critted=crit, swing=True)
return
attack = unit["attack"][unit["swings"] % len(unit["attack"])]
unit["swings"] += 1
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
_event(state, t, unit["id"], target["id"], "miss", 0, attack)
return
if rng.random() < target["block"]:
_event(state, t, unit["id"], target["id"], "block", 0, attack)
return
crit = rng.random() < ENEMY_CRIT
amount = (unit["max_hit"] * CRIT_MULT if crit else rng.randint(1, unit["max_hit"])) * unit["enraged"]
_hurt(state, unit, target, amount, attack, t, rng, crit=False, critted=crit, swing=True)
def _hurt(state: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
rng: random.Random, threat: float = 1.0, crit: bool = True, critted: bool = False,
swing: bool = False, label: str = "", tick: bool = False) -> None:
if not _alive(target) or amount <= 0:
return
if crit and source and source["side"] == "party" and rng.random() < source["crit"]:
amount, critted = amount * CRIT_MULT, True
if source:
for aura in source["auras"]:
if aura["kind"] == "done":
amount *= aura["mult"]
for aura in target["auras"]:
if aura["kind"] in ("taken", "vuln"):
amount *= aura["mult"]
if target["side"] == "party":
amount *= target["taken"]["magic" if school == "magic" else "physical"]
amount = max(1, round(amount))
dealt = amount
for aura in list(target["auras"]):
if aura["kind"] == "shield" and amount:
soak = min(amount, round(aura["amount"]))
aura["amount"] -= soak
amount -= soak
if aura["amount"] < 1:
target["auras"].remove(aura)
target["hp"] -= amount
if source and source["side"] == "party":
source["dealt"] += dealt
if target["side"] == "party":
target["hurt"] += amount
kind = "absorb" if not amount else "tick" if tick else "crit" if critted else "hit"
_event(state, t, source["id"] if source else None, target["id"], kind, dealt, label or school)
if target["side"] == "enemy" and source:
boost = TANK_THREAT if source.get("role") == "tank" else 1
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + dealt * threat * boost
if target["cc"] > t:
target["cc"] = 0
_event(state, t, source["id"], target["id"], "free")
if target["hp"] <= 0:
_fall(state, target, t)
def _heal(state: dict, source: dict | None, target: dict, amount: float, t: float, label: str,
tick: bool = False) -> None:
if not target or not _alive(target):
return
gain = max(0, min(round(amount), target["top"] - target["hp"]))
target["hp"] += gain
_event(state, t, source["id"] if source else target["id"], target["id"], "mend" if tick else "heal", gain, label)
if source and gain:
_heal_threat(state, source, gain)
def _heal_threat(state: dict, source: dict, amount: float) -> None:
enemies = _enemies(state)
for enemy in enemies:
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
def _fall(state: dict, unit: dict, t: float) -> None:
if unit["side"] == "enemy":
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
state["kills"].append(unit["id"])
_event(state, t, None, unit["id"], "die")
for other in _party(state):
if other["target"] == unit["id"]:
other["target"] = None
return
if unit["wind"]:
# Second Wind: the knockout that was coming turns into half health, once per outing.
unit["wind"], unit["wind_used"] = False, True
unit["hp"] = unit["top"] / 2
_event(state, t, unit["id"], unit["id"], "wind")
return
unit["down"] = True
_out(state, unit, "down", t)
def _out(state: dict, unit: dict, why: str, t: float) -> None:
unit["out"], unit["cast"], unit["auras"] = why, None, []
_event(state, t, unit["id"], unit["id"], why)
for enemy in _enemies(state):
enemy["threat"].pop(unit["id"], None)
if enemy["target"] == unit["id"]:
enemy["target"] = None