guildhall/combat.py

923 lines
45 KiB
Python
Raw Normal View History

"""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. Each is
on auto (a rule decides when, and runs its cooldown half again as long) or in your hands
(pressed, on its real cooldown). Your hands only count while you're watching: a member's
`watch` is the moment of the fight their manual choices hold until, and after it everything
goes back to auto. Presses, targets and switches are a member's `inputs`, each at the moment
it was made, so playing the fight again from the start with them changes nothing before then.
"""
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
WATCH = 12 # real seconds since a player last looked that their manual choices still hold
QUEUE = 1.5 # a press waits this long for its cooldown or the global one before it's dropped
CRIT_MULT = 1.5
ENEMY_CRIT = 0.05
SPREAD = 0.1 # abilities land within 10% of their size
HARD_ENRAGE = 3.0 # how much harder a dungeon boss hits once its time is up
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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "target": "enemy", "cd": 10, "cast": 2,
"effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."},
"pinning_shot": {"name": "Pinning Shot", "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", "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", "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", "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", "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", "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", "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 modded(ab: dict, mod: dict | None) -> dict:
"""An ability as something you wear changes it: `cd` and `cast` multiply, `c` multiplies every
effect's size, `dur` adds seconds to every effect that lasts, `cost` replaces the arrows it takes,
and `extra` adds effects."""
if not mod:
return ab
ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]}
for key in ("cd", "cast"):
if key in mod and ab.get(key):
ab[key] = ab[key] * mod[key]
if "cost" in mod:
ab["cost"] = mod["cost"]
for eff in ab["effects"]:
if "c" in eff and "c" in mod:
eff["c"] *= mod["c"]
if "dur" in eff and "dur" in mod:
eff["dur"] += mod["dur"]
ab["effects"] += [dict(eff) for eff in mod.get("extra", [])]
return ab
def combine_mods(mods: list[dict]) -> dict:
"""Several things changing the same abilities: multipliers multiply, durations add, extras pile up."""
out = {}
for mod in mods:
for ab_id, change in mod.items():
have = out.setdefault(ab_id, {})
for key, value in change.items():
if key in ("cd", "cast", "c"):
have[key] = have.get(key, 1) * value
elif key == "dur":
have[key] = have.get(key, 0) + value
elif key == "extra":
have[key] = have.get(key, []) + value
else:
have[key] = value
return out
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", "cast": 2.0, "every": 14, "first": 5, "interrupt": False,
"target": "target", "effect": ("hit", 1.6, "blunt")},
"lunge": {"name": "Lunge", "cast": 0, "every": 10, "first": 4, "interrupt": False,
"target": "soft", "effect": ("hit", 1.2, None)},
"hex_bolt": {"name": "Hex Bolt", "cast": 2.5, "every": 12, "first": 3, "interrupt": True,
"target": "target", "effect": ("hit", 1.8, "magic")},
"sweep": {"name": "Sweep", "cast": 1.5, "every": 16, "first": 8, "interrupt": False,
"target": "all", "effect": ("aoe", 0.8, "slash")},
"fire_breath": {"name": "Fire Breath", "cast": 3.0, "every": 18, "first": 6, "interrupt": False,
"target": "all", "effect": ("aoe", 1.4, "magic")},
"tail_swipe": {"name": "Tail Swipe", "cast": 0, "every": 12, "first": 7, "interrupt": False,
"target": "target", "effect": ("hit", 1.2, "blunt")},
# Dungeon bosses' own: the ones a group has to answer.
"stone_curse": {"name": "Stone Curse", "cast": 2.0, "every": 6, "first": 4, "interrupt": True,
"target": "target", "effect": ("hit", 3.5, "magic")},
"ground_pound": {"name": "Ground Pound", "cast": 3.0, "every": 18, "first": 10, "interrupt": False,
"target": "all", "effect": ("aoe", 1.6, "blunt")},
"shatter": {"name": "Shatter", "cast": 2.5, "every": 14, "first": 6, "interrupt": False,
"target": "all", "effect": ("aoe", 1.5, "blunt")},
"shriek": {"name": "Shriek", "cast": 2.0, "every": 9, "first": 4, "interrupt": True,
"target": "all", "effect": ("aoe", 1.4, "magic")},
}
def _curse(name, school):
"""A boss's must-interrupt: one target, very hard, every few seconds."""
return {"name": name, "cast": 2.0, "every": 6, "first": 4, "interrupt": True, "target": "target",
"effect": ("hit", 3.5, school)}
def _storm(name, school):
"""An interruptible blast at everyone."""
return {"name": name, "cast": 2.0, "every": 9, "first": 4, "interrupt": True, "target": "all",
"effect": ("aoe", 1.4, school)}
def _quake(name, school):
"""A slow blast at everyone that can't be stopped: brace for it."""
return {"name": name, "cast": 3.0, "every": 18, "first": 10, "interrupt": False, "target": "all",
"effect": ("aoe", 1.6, school)}
# Each dungeon's bosses, named for who casts them.
ENEMY_ABILITIES.update({
"hex_storm": _storm("Hex Storm", "magic"), "crushing_decree": _curse("Crushing Decree", "blunt"),
"royal_stomp": _quake("Royal Stomp", "blunt"), "witchfire": _storm("Witchfire", "magic"),
"cheap_shot": _curse("Cheap Shot", "pierce"), "execute": _curse("Execute", "slash"),
"whirlwind": _quake("Whirlwind", "slash"), "rune_blast": _curse("Rune Blast", "magic"),
"venom_spray": _storm("Venom Spray", "magic"), "grave_chill": _curse("Grave Chill", "magic"),
"soul_rend": _quake("Soul Rend", "magic"), "forge_blast": _curse("Forge Blast", "magic"),
"war_cry": _storm("War Cry", "slash"), "acid_spit": _curse("Acid Spit", "magic"),
"acid_breath": _quake("Acid Breath", "magic"), "deep_freeze": _curse("Deep Freeze", "magic"),
"wail": _storm("Wail", "magic"), "frost_lance": _curse("Frost Lance", "magic"),
"frost_breath": _quake("Frost Breath", "magic"), "void_bolt": _curse("Void Bolt", "magic"),
"flame_wave": _storm("Flame Wave", "magic"), "inferno": _curse("Inferno", "magic"),
})
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": [],
"kit": {a: modded(ABILITIES[a], me.get("mods", {}).get(a)) for a in me["abilities"]},
"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, "manual": set(member.get("manual", [])),
"watch": member.get("watch", -1.0), "inputs": sorted(member.get("inputs", []), key=lambda i: i[0]),
"seen": 0, "queued": None, "picked": False, "ftarget": None}
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 foe.get("abilities") or ARCHETYPE_ABILITIES.get(foe["archetype"], [])],
# A dungeon boss's: adds that wait (`dormant`) until it's hurt enough, shouts, and a hard enrage.
"dormant": foe.get("dormant", False), "phases": [dict(p) for p in foe.get("phases", [])],
"enrage_at": foe.get("enrage_at")}
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"] and not u["dormant"]]
# --- 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):
_inputs(state, unit, t)
_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 enemy["enrage_at"] is not None and t >= enemy["enrage_at"] and enemy["enraged"] < HARD_ENRAGE:
enemy["enraged"] = HARD_ENRAGE # out of patience
_event(state, t, enemy["id"], enemy["id"], "enrage", 0, "hard")
for phase in enemy["phases"]:
if not phase.get("done") and enemy["hp"] <= enemy["top"] * phase["at"]:
phase["done"] = True
if phase.get("say"):
_event(state, t, enemy["id"], None, "yell", 0, phase["say"])
for uid in phase.get("wake", []):
_wake(state, state["units"][uid], t)
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 _wake(state: dict, enemy: dict, t: float) -> None:
"""An add joins the fight: on whoever's tanking, else the first of you."""
enemy["dormant"] = False
enemy["next"] = t + 1.0
for slot in enemy["abil"]:
slot["next"] = t + ENEMY_ABILITIES[slot["id"]]["first"]
party = _party(state)
for unit in party:
enemy["threat"][unit["id"]] = 10.0 if unit["role"] == "tank" else 0.0
if party and not any(enemy["threat"].values()):
enemy["threat"][party[0]["id"]] = 1.0
_event(state, t, enemy["id"], None, "join")
def _alive(unit: dict) -> bool:
return not unit["dead"] and not unit["dormant"] 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 _inputs(state: dict, unit: dict, t: float) -> None:
"""What the player did up to now: presses queue up, taps pick targets, switches move abilities
between auto and their hands."""
inputs = unit["inputs"]
while unit["seen"] < len(inputs) and inputs[unit["seen"]][0] <= t + 1e-9:
at, kind, arg, *more = inputs[unit["seen"]]
unit["seen"] += 1
other = state["units"].get(arg or "")
if kind == "press" and arg in unit["abilities"]:
unit["queued"] = {"ab": arg, "target": more[0] if more else None, "at": at}
elif kind == "target" and other and _alive(other):
if other["side"] == "enemy":
unit["target"], unit["picked"] = arg, True
else:
unit["ftarget"] = arg
elif kind == "mode":
(unit["manual"].add if more and more[0] else unit["manual"].discard)(arg)
def _ready(unit: dict, ab: dict, t: float, manual: bool) -> str | None:
if unit["cds"].get(ab["id"], 0) > t + 1e-9:
return "cooldown"
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
return "gcd"
if ab.get("cost", 0) > unit["arrows"]:
return "arrows"
return None
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)
watching = t < unit["watch"]
queued = unit["queued"]
if queued:
ab = unit["kit"][queued["ab"]]
why = _ready(unit, ab, t, manual=True)
if why is None:
unit["queued"] = None
target = _manual_target(state, unit, ab, queued["target"], t)
if target is not False:
_use(state, unit, ab, target, t, rng, manual=True)
if unit["cast"] or not ab.get("offgcd"):
return
elif why == "arrows" or t - queued["at"] > QUEUE:
unit["queued"] = None
for ab_id in unit["abilities"]:
ab = unit["kit"][ab_id]
if (watching and ab_id in unit["manual"]) or _ready(unit, ab, t, manual=False):
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 _manual_target(state: dict, unit: dict, ab: dict, given: str | None, t: float):
"""Who a press lands on: the frame you tapped, else the one the ability would pick, else your target."""
units = state["units"]
kind = ab["target"]
if kind in ("self", "aoe", "party"):
return None
if kind == "ally":
for choice in (given, unit["ftarget"]):
other = units.get(choice or "")
if other and other["side"] == "party" and _alive(other):
return other["id"]
return min(_party(state), key=_share)["id"]
other = units.get(given or "")
if other and other["side"] == "enemy" and _alive(other):
return other["id"]
if ab["auto"]["r"] in ("stop", "cc", "taunt"):
smart = _rule(state, unit, {**ab, "auto": {**ab["auto"], "else": False}}, t)
if smart:
return smart
target = units.get(unit["target"] or "")
return target["id"] if target and _alive(target) else False
def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random, manual: bool = False) -> None:
unit["cds"][ab["id"]] = t + ab.get("cd", 0) * (1 if manual else 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, unit["kit"][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, _second(state, target) if eff.get("second") else 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":
holder = _second(state, target) if eff.get("second") else target
if not holder:
continue
_add_aura(holder, {"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 _second(state: dict, first: dict | None) -> dict | None:
"""The most hurt of your side after `first`: for heals and wards that reach a second ally."""
others = [u for u in _party(state) if u is not first]
return min(others, key=_share) if others else None
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 or unit["picked"]):
return
unit["picked"] = False
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