799 lines
34 KiB
Python
799 lines
34 KiB
Python
|
|
"""The fight engine: a party against a pack, stepped in fixed slices of time.
|
||
|
|
|
||
|
|
A fight is one JSON-able dict. It only ever moves forward through `advance`, which
|
||
|
|
replays the slices between its last step and now; each slice draws from a random
|
||
|
|
seeded by the fight and the slice number, so the same presses give the same fight.
|
||
|
|
|
||
|
|
Every ability is either on auto (a rule decides when, on a longer cooldown) or in
|
||
|
|
the player's hands. A player's hands only count while they're watching: once they
|
||
|
|
look away, everything they hold falls back to auto.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import random
|
||
|
|
|
||
|
|
from content import ABILITIES, BOSS_DMG, BOSS_HP, ELITE_DMG, ELITE_HP, ENEMY_ABILITIES, MOBS
|
||
|
|
|
||
|
|
STEP = 0.25 # seconds per slice
|
||
|
|
GCD = 1.5
|
||
|
|
ENERGY_GCD = 1.0 # rogues
|
||
|
|
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 a cast before auto interrupts it
|
||
|
|
WATCH = 12 # seconds since a player last looked that their manual choices still hold
|
||
|
|
QUEUE = 1.5 # a press waits this long for the global cooldown before it's dropped
|
||
|
|
CRIT, CRIT_MULT = 0.05, 1.5
|
||
|
|
SPREAD = 0.1
|
||
|
|
HEAL_THREAT = 0.5
|
||
|
|
STICKY = 1.1 # a mob only turns when someone has 10% more threat than its target
|
||
|
|
TANK_THREAT = 1.8
|
||
|
|
PET_THREAT = 0.5
|
||
|
|
PET_TANK_THREAT = 3.0
|
||
|
|
MANA_REGEN = 0.02 # share of max mana per second
|
||
|
|
ENERGY_REGEN = 10
|
||
|
|
RAGE_PER_SWING = 7
|
||
|
|
RAGE_TAKEN = 40 # rage for taking a whole health bar of damage
|
||
|
|
MAX_TIME = 300
|
||
|
|
LOG = 40
|
||
|
|
EVENTS = 60
|
||
|
|
|
||
|
|
|
||
|
|
def mob_hp(level: int) -> float:
|
||
|
|
return 35 + 15 * level + 0.8 * level * level
|
||
|
|
|
||
|
|
|
||
|
|
def mob_swing(level: int) -> float:
|
||
|
|
"""Damage of an ordinary mob's 2s swing at this level."""
|
||
|
|
return 3 + 1.5 * level
|
||
|
|
|
||
|
|
|
||
|
|
# --- building a fight ------------------------------------------------------
|
||
|
|
|
||
|
|
def mob_unit(uid: str, mob_id: str, level: int, hp_scale: float = 1.0, dmg_scale: float = 1.0) -> dict:
|
||
|
|
mob = MOBS[mob_id]
|
||
|
|
hp = mob_hp(level) * mob.get("hp", 1)
|
||
|
|
swing = mob_swing(level) * mob.get("dmg", 1)
|
||
|
|
if mob.get("elite"):
|
||
|
|
hp, swing = hp * ELITE_HP, swing * ELITE_DMG
|
||
|
|
if mob.get("boss"):
|
||
|
|
hp, swing = hp * BOSS_HP, swing * BOSS_DMG
|
||
|
|
hp = max(1, round(hp * hp_scale))
|
||
|
|
return {
|
||
|
|
"id": uid, "mob": mob_id, "name": mob["name"], "side": "enemy", "kind": mob["kind"], "level": level,
|
||
|
|
"hp": hp, "max_hp": hp, "power": 0, "armor": 0,
|
||
|
|
"swing": {"dmg": swing * dmg_scale, "every": 2.0, "next": 0.5, "school": "phys"},
|
||
|
|
"auras": [], "cast": None, "threat": {}, "target": None, "taunt": None,
|
||
|
|
"stun": 0, "cc": 0, "root": 0, "dead": False,
|
||
|
|
"boss": bool(mob.get("boss")), "elite": bool(mob.get("elite")), "rare": bool(mob.get("rare")),
|
||
|
|
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"]} for a in mob.get("abilities", [])],
|
||
|
|
"phases": [{**phase, "done": False} for phase in mob.get("phases", [])],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def new_fight(fid: str, seed: int, at: float, party: list[dict], enemies: list[dict], kind: str,
|
||
|
|
name: str = "", scale: tuple = (1.0, 1.0)) -> dict:
|
||
|
|
fight = {"id": fid, "seed": seed, "at": at, "t": 0.0, "n": 0, "state": "on", "kind": kind, "name": name,
|
||
|
|
"units": {}, "log": [], "ev": [], "kills": [], "next": 0, "scale": list(scale), "ended": None}
|
||
|
|
for unit in party + enemies:
|
||
|
|
fight["units"][unit["id"]] = unit
|
||
|
|
for enemy in enemies:
|
||
|
|
_engage(fight, enemy)
|
||
|
|
return fight
|
||
|
|
|
||
|
|
|
||
|
|
def _engage(fight: dict, enemy: dict) -> None:
|
||
|
|
"""Who a fresh mob starts on: the tank, else the first one in."""
|
||
|
|
party = alive(fight, "party")
|
||
|
|
for unit in party:
|
||
|
|
enemy["threat"][unit["id"]] = 10.0 if _tanks(unit) else 0.0
|
||
|
|
if party and not any(enemy["threat"].values()):
|
||
|
|
enemy["threat"][party[0]["id"]] = 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def _tanks(unit: dict) -> bool:
|
||
|
|
return unit.get("role") == "tank" or unit.get("pet_tank", False)
|
||
|
|
|
||
|
|
|
||
|
|
def alive(fight: dict, side: str) -> list[dict]:
|
||
|
|
return [u for u in fight["units"].values() if u["side"] == side and not u["dead"]]
|
||
|
|
|
||
|
|
|
||
|
|
def players(fight: dict) -> list[dict]:
|
||
|
|
return [u for u in fight["units"].values() if u.get("kind") == "player"]
|
||
|
|
|
||
|
|
|
||
|
|
# --- the outside world's handles ------------------------------------------
|
||
|
|
|
||
|
|
def advance(fight: dict, now: float, watch: dict | None = None) -> None:
|
||
|
|
"""Run every slice up to `now` (game time)."""
|
||
|
|
end = now - fight["at"]
|
||
|
|
watch = watch or {}
|
||
|
|
while fight["state"] == "on" and fight["t"] + STEP <= end + 1e-9:
|
||
|
|
_step(fight, watch)
|
||
|
|
|
||
|
|
|
||
|
|
def end_time(fight: dict) -> float:
|
||
|
|
return fight["at"] + fight["t"]
|
||
|
|
|
||
|
|
|
||
|
|
def press(fight: dict, uid: str, ability: str, now: float, target: str | None = None) -> str | None:
|
||
|
|
unit = fight["units"].get(uid)
|
||
|
|
if fight["state"] != "on" or not unit or unit["dead"]:
|
||
|
|
return "You're not fighting"
|
||
|
|
if ability not in unit["abilities"]:
|
||
|
|
return "You don't know that"
|
||
|
|
unit["queued"] = {"ab": ability, "target": target, "at": now - fight["at"]}
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def set_target(fight: dict, uid: str, target: str) -> str | None:
|
||
|
|
unit, other = fight["units"].get(uid), fight["units"].get(target)
|
||
|
|
if not unit or not other or other["dead"]:
|
||
|
|
return "No such target"
|
||
|
|
if other["side"] == "enemy":
|
||
|
|
unit["target"], unit["picked"] = target, True
|
||
|
|
else:
|
||
|
|
unit["ftarget"] = target
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# --- a slice ---------------------------------------------------------------
|
||
|
|
|
||
|
|
def _step(fight: dict, watch: dict) -> None:
|
||
|
|
fight["t"] = t = round(fight["t"] + STEP, 4)
|
||
|
|
rng = random.Random(f"{fight['seed']}:{fight['n']}")
|
||
|
|
fight["n"] += 1
|
||
|
|
now = fight["at"] + t
|
||
|
|
units = list(fight["units"].values())
|
||
|
|
for unit in units:
|
||
|
|
if not unit["dead"]:
|
||
|
|
_tick(fight, unit, t, rng)
|
||
|
|
for unit in units:
|
||
|
|
if not unit["dead"] and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
|
||
|
|
_finish_cast(fight, unit, t, rng)
|
||
|
|
for unit in units:
|
||
|
|
if not unit["dead"] and unit["side"] == "party":
|
||
|
|
seen = watch.get(unit.get("owner") or unit["id"])
|
||
|
|
_think(fight, unit, t, rng, seen is not None and now - seen < WATCH)
|
||
|
|
for unit in units:
|
||
|
|
if not unit["dead"] and unit["side"] == "enemy":
|
||
|
|
_enemy_think(fight, unit, t, rng)
|
||
|
|
for unit in units:
|
||
|
|
if not unit["dead"]:
|
||
|
|
_swing(fight, unit, t, rng)
|
||
|
|
_phases(fight, t, rng)
|
||
|
|
_check_end(fight, t)
|
||
|
|
|
||
|
|
|
||
|
|
def _tick(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
|
|
res = unit.get("res")
|
||
|
|
if res and res["type"] == "mana":
|
||
|
|
res["cur"] = min(res["max"], res["cur"] + res["max"] * MANA_REGEN * STEP)
|
||
|
|
elif res and res["type"] == "energy":
|
||
|
|
res["cur"] = min(res["max"], res["cur"] + ENERGY_REGEN * STEP)
|
||
|
|
for aura in list(unit["auras"]):
|
||
|
|
if aura["kind"] in ("dot", "hot", "ground") and t >= aura["next"] - 1e-9:
|
||
|
|
aura["next"] += aura["every"]
|
||
|
|
source = fight["units"].get(aura["by"])
|
||
|
|
if aura["kind"] == "dot":
|
||
|
|
_damage(fight, source, unit, aura["amount"], aura["school"], t, rng, crit=False)
|
||
|
|
elif aura["kind"] == "hot":
|
||
|
|
_heal(fight, source, unit, aura["amount"], t, rng, crit=False)
|
||
|
|
else:
|
||
|
|
for enemy in alive(fight, "enemy"):
|
||
|
|
if enemy["cc"] <= t:
|
||
|
|
_damage(fight, unit, enemy, aura["amount"], aura["school"], t, rng, crit=False,
|
||
|
|
threat=aura.get("threat", 1))
|
||
|
|
if unit["dead"]:
|
||
|
|
return
|
||
|
|
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
|
||
|
|
unit["auras"].remove(aura)
|
||
|
|
if aura["kind"] == "enrage":
|
||
|
|
unit["swing"]["dmg"] /= aura["mult"]
|
||
|
|
|
||
|
|
|
||
|
|
# --- player side -----------------------------------------------------------
|
||
|
|
|
||
|
|
def _think(fight: dict, unit: dict, t: float, rng: random.Random, watching: bool) -> None:
|
||
|
|
if unit["stun"] > t or unit["cast"]:
|
||
|
|
return
|
||
|
|
_pick_target(fight, unit, t)
|
||
|
|
if unit["kind"] == "pet":
|
||
|
|
return
|
||
|
|
queued = unit.get("queued")
|
||
|
|
if queued and watching:
|
||
|
|
if t - queued["at"] > QUEUE:
|
||
|
|
unit["queued"] = None
|
||
|
|
else:
|
||
|
|
why = _not_ready(unit, queued["ab"], t, manual=True)
|
||
|
|
if why is None:
|
||
|
|
target = _manual_target(fight, unit, queued["ab"], queued["target"], t)
|
||
|
|
unit["queued"] = None
|
||
|
|
if target is not False:
|
||
|
|
_use(fight, unit, queued["ab"], target, t, rng, manual=True)
|
||
|
|
if unit["cast"] or not ABILITIES[queued["ab"]].get("offgcd"):
|
||
|
|
return
|
||
|
|
elif why == "mana":
|
||
|
|
unit["queued"] = None
|
||
|
|
_event(fight, t, unit["id"], 0, "oom")
|
||
|
|
elif queued:
|
||
|
|
unit["queued"] = None
|
||
|
|
for ab_id in unit["priority"]:
|
||
|
|
if ab_id not in unit["abilities"] or (watching and ab_id in unit["manual"]):
|
||
|
|
continue
|
||
|
|
if _not_ready(unit, ab_id, t, manual=False):
|
||
|
|
continue
|
||
|
|
target = _rule(fight, unit, ab_id, t)
|
||
|
|
if target is False:
|
||
|
|
continue
|
||
|
|
_use(fight, unit, ab_id, target, t, rng, manual=False)
|
||
|
|
if unit["cast"] or not ABILITIES[ab_id].get("offgcd"):
|
||
|
|
return
|
||
|
|
|
||
|
|
|
||
|
|
def _not_ready(unit: dict, ab_id: str, t: float, manual: bool) -> str | None:
|
||
|
|
ab = ABILITIES[ab_id]
|
||
|
|
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"
|
||
|
|
cost = _cost(unit, ab)
|
||
|
|
if cost and unit["res"]["cur"] < cost:
|
||
|
|
return unit["res"]["type"]
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _cost(unit: dict, ab: dict) -> float:
|
||
|
|
cost = ab.get("cost", 0)
|
||
|
|
if cost and cost < 1:
|
||
|
|
return cost * unit["res"]["max"]
|
||
|
|
return cost
|
||
|
|
|
||
|
|
|
||
|
|
def _use(fight: dict, unit: dict, ab_id: str, target, t: float, rng: random.Random, manual: bool) -> None:
|
||
|
|
ab = ABILITIES[ab_id]
|
||
|
|
cost = _cost(unit, ab)
|
||
|
|
if cost:
|
||
|
|
unit["res"]["cur"] -= cost
|
||
|
|
unit["cds"][ab_id] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
|
||
|
|
cast = ab.get("cast", 0)
|
||
|
|
if not ab.get("offgcd"):
|
||
|
|
gcd = ENERGY_GCD if unit["res"] and unit["res"]["type"] == "energy" else GCD
|
||
|
|
unit["busy"] = t + max(gcd, cast)
|
||
|
|
if cast:
|
||
|
|
unit["cast"] = {"ab": ab_id, "name": ab["name"], "icon": ab["icon"], "target": target,
|
||
|
|
"start": t, "end": t + cast}
|
||
|
|
else:
|
||
|
|
_apply(fight, unit, ab_id, target, t, rng)
|
||
|
|
|
||
|
|
|
||
|
|
def _finish_cast(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
|
|
cast, unit["cast"] = unit["cast"], None
|
||
|
|
if unit["side"] == "party":
|
||
|
|
_apply(fight, unit, cast["ab"], cast["target"], t, rng)
|
||
|
|
else:
|
||
|
|
_enemy_effect(fight, unit, cast["ab"], cast["target"], t, rng)
|
||
|
|
|
||
|
|
|
||
|
|
def _apply(fight: dict, unit: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||
|
|
ab = ABILITIES[ab_id]
|
||
|
|
units = fight["units"]
|
||
|
|
target = units.get(target_id) if target_id else None
|
||
|
|
if ab["target"] in ("enemy", "ally", "pet") and (not target or target["dead"]):
|
||
|
|
return
|
||
|
|
power = unit["power"]
|
||
|
|
threat = ab.get("threat", 1)
|
||
|
|
on = f" on {target['name']}" if target and target is not unit else ""
|
||
|
|
if ab["target"] != "enemy" or any(e["t"] in ("cc", "interrupt", "stun", "taunt") for e in ab["effects"]):
|
||
|
|
_log(fight, t, f"{unit['name']} · {ab['name']}{on}", "cast", unit["id"])
|
||
|
|
for eff in ab["effects"]:
|
||
|
|
kind = eff["t"]
|
||
|
|
if kind == "dmg":
|
||
|
|
_damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, threat=threat, label=ab["name"])
|
||
|
|
elif kind == "aoe":
|
||
|
|
for enemy in alive(fight, "enemy"):
|
||
|
|
if enemy["cc"] <= t:
|
||
|
|
_damage(fight, unit, enemy, eff["c"] * power, eff["school"], t, rng, threat=threat,
|
||
|
|
label=ab["name"])
|
||
|
|
elif kind in ("dot", "hot"):
|
||
|
|
_add_aura(target, {"id": ab_id, "kind": kind, "by": unit["id"], "amount": eff["c"] * power,
|
||
|
|
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||
|
|
"school": eff.get("school"), "name": ab["name"], "icon": ab["icon"]})
|
||
|
|
elif kind == "ground":
|
||
|
|
_add_aura(unit, {"id": ab_id, "kind": "ground", "by": unit["id"], "amount": eff["c"] * power,
|
||
|
|
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||
|
|
"school": eff["school"], "threat": threat, "name": ab["name"], "icon": ab["icon"]})
|
||
|
|
elif kind == "heal":
|
||
|
|
_heal(fight, unit, target, eff["c"] * power, t, rng, label=ab["name"])
|
||
|
|
elif kind == "shield":
|
||
|
|
if any(a["kind"] == "weakened" for a in target["auras"]):
|
||
|
|
continue
|
||
|
|
_add_aura(target, {"id": ab_id, "kind": "shield", "by": unit["id"], "amount": eff["c"] * power,
|
||
|
|
"until": t + eff["dur"], "name": ab["name"], "icon": ab["icon"]})
|
||
|
|
_add_aura(target, {"id": "weakened_soul", "kind": "weakened", "by": unit["id"], "until": t + 15,
|
||
|
|
"name": "Weakened Soul", "icon": "🩹"})
|
||
|
|
_heal_threat(fight, unit, eff["c"] * power * 0.5)
|
||
|
|
elif kind == "aura":
|
||
|
|
holder = target or unit
|
||
|
|
spec = {k: v for k, v in eff.items() if k not in ("t", "dur")}
|
||
|
|
_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":
|
||
|
|
_stop_cast(fight, target, unit, t, force=not target["boss"])
|
||
|
|
if not target["boss"]:
|
||
|
|
target["stun"] = t + eff["dur"]
|
||
|
|
elif kind == "interrupt":
|
||
|
|
_stop_cast(fight, target, unit, t)
|
||
|
|
elif kind == "cc":
|
||
|
|
if target["boss"]:
|
||
|
|
_log(fight, t, f"{target['name']} is immune", "miss")
|
||
|
|
continue
|
||
|
|
_stop_cast(fight, target, unit, t, force=True, quiet=True)
|
||
|
|
target["cc"] = t + eff["dur"]
|
||
|
|
target["cc_icon"] = ab["icon"]
|
||
|
|
for other in alive(fight, "party"):
|
||
|
|
if other["target"] == target["id"] and not other.get("picked"):
|
||
|
|
other["target"] = None
|
||
|
|
elif kind == "freeze":
|
||
|
|
for enemy in alive(fight, "enemy"):
|
||
|
|
if not enemy["boss"]:
|
||
|
|
enemy["root"] = t + eff["dur"]
|
||
|
|
elif kind == "combo":
|
||
|
|
unit["combo"] = min(5, unit["combo"] + eff["n"])
|
||
|
|
elif kind == "finisher":
|
||
|
|
points, unit["combo"] = unit["combo"], 0
|
||
|
|
if points:
|
||
|
|
_damage(fight, unit, target, eff["c"] * power * points, "phys", t, rng, threat=threat,
|
||
|
|
label=ab["name"])
|
||
|
|
elif kind == "drain":
|
||
|
|
dealt = _damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, label=ab["name"])
|
||
|
|
_heal(fight, unit, unit, dealt, t, rng, crit=False)
|
||
|
|
|
||
|
|
|
||
|
|
def _stop_cast(fight: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
|
||
|
|
cast = enemy.get("cast")
|
||
|
|
if not cast:
|
||
|
|
return
|
||
|
|
if not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]:
|
||
|
|
return
|
||
|
|
enemy["cast"] = None
|
||
|
|
if not quiet:
|
||
|
|
_log(fight, t, f"{by['name']} interrupts {enemy['name']}'s {cast['name']}", "interrupt", by["id"])
|
||
|
|
_event(fight, t, enemy["id"], 0, "interrupt")
|
||
|
|
|
||
|
|
|
||
|
|
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 _pet_of(fight: dict, unit: dict) -> dict | None:
|
||
|
|
return next((u for u in alive(fight, "party") if u.get("owner") == unit["id"]), None)
|
||
|
|
|
||
|
|
|
||
|
|
def _pick_target(fight: dict, unit: dict, t: float) -> None:
|
||
|
|
current = fight["units"].get(unit.get("target") or "")
|
||
|
|
if current and not current["dead"] and (unit.get("picked") or current["cc"] <= t):
|
||
|
|
return
|
||
|
|
unit["picked"] = False
|
||
|
|
enemies = alive(fight, "enemy")
|
||
|
|
free = [e for e in enemies if e["cc"] <= t] or enemies
|
||
|
|
if not free:
|
||
|
|
unit["target"] = None
|
||
|
|
return
|
||
|
|
owner = fight["units"].get(unit.get("owner") or "")
|
||
|
|
if unit["kind"] == "pet" and not unit.get("pet_tank") and owner and not owner["dead"] and owner.get("target"):
|
||
|
|
unit["target"] = owner["target"]
|
||
|
|
return
|
||
|
|
if _tanks(unit):
|
||
|
|
tanks = {u["id"] for u in alive(fight, "party") if _tanks(u)}
|
||
|
|
loose = [e for e in free if e["target"] not in tanks]
|
||
|
|
unit["target"] = (loose or sorted(free, key=lambda e: e["hp"] / e["max_hp"]))[0]["id"]
|
||
|
|
return
|
||
|
|
tank = next((u for u in alive(fight, "party") if _tanks(u) and u.get("target")), None)
|
||
|
|
focus = fight["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=lambda e: e["hp"] / e["max_hp"])["id"]
|
||
|
|
|
||
|
|
|
||
|
|
def _hurt(unit: dict) -> float:
|
||
|
|
return unit["hp"] / unit["max_hp"]
|
||
|
|
|
||
|
|
|
||
|
|
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 _rule(fight: dict, unit: dict, ab_id: str, t: float):
|
||
|
|
"""Auto's decision: the target to use this on, None for no target, False for not now."""
|
||
|
|
ab = ABILITIES[ab_id]
|
||
|
|
rule = ab["auto"]
|
||
|
|
r = rule["r"]
|
||
|
|
enemies = alive(fight, "enemy")
|
||
|
|
party = alive(fight, "party")
|
||
|
|
target = fight["units"].get(unit.get("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 rule.get("save") and unit.get("role") == "healer" and len(players(fight)) > 1 and \
|
||
|
|
unit["res"]["cur"] < rule["save"] * unit["res"]["max"]:
|
||
|
|
return False # a group's healer keeps their mana for healing
|
||
|
|
if r == "always":
|
||
|
|
if ab["target"] == "enemy":
|
||
|
|
return target["id"] if target else False
|
||
|
|
return None
|
||
|
|
if r == "dot":
|
||
|
|
return target["id"] if target and not _has(target, ab_id, unit["id"]) and _hurt(target) > 0.2 else False
|
||
|
|
if r == "heal":
|
||
|
|
low = min(party, key=_hurt, default=None)
|
||
|
|
return low["id"] if low and _hurt(low) < rule["pct"] else False
|
||
|
|
if r == "hot":
|
||
|
|
hurt = sorted((u for u in party if _hurt(u) < rule["pct"] and not _has(u, ab_id, unit["id"])), key=_hurt)
|
||
|
|
return hurt[0]["id"] if hurt else False
|
||
|
|
if r == "shield":
|
||
|
|
targeted = {e["target"] for e in enemies}
|
||
|
|
hurt = [u for u in party if not any(a["kind"] in ("shield", "weakened") for a in u["auras"])
|
||
|
|
and (u["id"] in targeted and _hurt(u) < 0.95 or _hurt(u) < rule["pct"])]
|
||
|
|
return min(hurt, key=_hurt)["id"] if hurt else False
|
||
|
|
if r == "self_below":
|
||
|
|
return target["id"] if target and _hurt(unit) < rule["pct"] else False
|
||
|
|
if r == "taunt":
|
||
|
|
if not _tanks(unit):
|
||
|
|
return False
|
||
|
|
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
|
||
|
|
and not _tanks(fight["units"][e["target"]]) and t - e.get("since", 0) >= 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 == "interrupt":
|
||
|
|
casting = [e for e in enemies if e["cast"] and ENEMY_ABILITIES[e["cast"]["ab"]]["interrupt"]
|
||
|
|
and t - e["cast"]["start"] >= AUTO_REACT]
|
||
|
|
return casting[0]["id"] if casting else False
|
||
|
|
if r == "combo":
|
||
|
|
return target["id"] if target and unit["combo"] >= rule["n"] else False
|
||
|
|
if r == "cc":
|
||
|
|
free = [e for e in enemies if e["cc"] <= t]
|
||
|
|
if len(free) < 2:
|
||
|
|
return False
|
||
|
|
focus = {u.get("target") for u in party if _tanks(u)} | {unit.get("target")}
|
||
|
|
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _hurt(e) > 0.5]
|
||
|
|
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
|
||
|
|
if r == "peel":
|
||
|
|
return None if on_me and not _tanks(unit) and any(not e["boss"] for e in on_me) else False
|
||
|
|
if r == "tanking":
|
||
|
|
return None if on_me and (any(e["boss"] or e["elite"] for e in on_me) or _hurt(unit) < rule["pct"]) else False
|
||
|
|
if r == "danger":
|
||
|
|
return None if on_me and _hurt(unit) < rule["pct"] else False
|
||
|
|
if r == "buff":
|
||
|
|
return None if not _has(unit, ab_id) else False
|
||
|
|
if r == "boss":
|
||
|
|
pet = _pet_of(fight, unit)
|
||
|
|
if not pet or not target:
|
||
|
|
return False
|
||
|
|
return pet["id"] if target["boss"] or target["elite"] or target["rare"] else False
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _manual_target(fight: dict, unit: dict, ab_id: str, given: str | None, t: float):
|
||
|
|
ab = ABILITIES[ab_id]
|
||
|
|
units = fight["units"]
|
||
|
|
kind = ab["target"]
|
||
|
|
if kind in ("self", "aoe"):
|
||
|
|
return None
|
||
|
|
if kind == "pet":
|
||
|
|
pet = _pet_of(fight, unit)
|
||
|
|
return pet["id"] if pet else False
|
||
|
|
if kind == "ally":
|
||
|
|
for choice in (given, unit.get("ftarget")):
|
||
|
|
other = units.get(choice or "")
|
||
|
|
if other and not other["dead"] and other["side"] == "party":
|
||
|
|
return other["id"]
|
||
|
|
return min(alive(fight, "party"), key=_hurt)["id"]
|
||
|
|
other = units.get(given or "")
|
||
|
|
if other and not other["dead"] and other["side"] == "enemy":
|
||
|
|
return other["id"]
|
||
|
|
smart = _rule(fight, unit, ab_id, t) if ab["auto"]["r"] in ("interrupt", "cc", "taunt") else False
|
||
|
|
if smart:
|
||
|
|
return smart
|
||
|
|
target = units.get(unit.get("target") or "")
|
||
|
|
return target["id"] if target and not target["dead"] else False
|
||
|
|
|
||
|
|
|
||
|
|
# --- enemy side ------------------------------------------------------------
|
||
|
|
|
||
|
|
def _enemy_think(fight: dict, enemy: dict, t: float, rng: random.Random) -> None:
|
||
|
|
if enemy["stun"] > t or enemy["cc"] > t:
|
||
|
|
enemy["cast"] = None
|
||
|
|
return
|
||
|
|
_enemy_target(fight, 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"]]
|
||
|
|
target = _enemy_ability_target(fight, enemy, spec, rng)
|
||
|
|
if target is False:
|
||
|
|
slot["next"] = t + 2
|
||
|
|
continue
|
||
|
|
slot["next"] = t + spec["every"]
|
||
|
|
if spec["cast"]:
|
||
|
|
enemy["cast"] = {"ab": slot["id"], "name": spec["name"], "icon": spec["icon"], "target": target,
|
||
|
|
"start": t, "end": t + spec["cast"], "interrupt": spec["interrupt"]}
|
||
|
|
else:
|
||
|
|
_enemy_effect(fight, enemy, slot["id"], target, t, rng)
|
||
|
|
return
|
||
|
|
|
||
|
|
|
||
|
|
def _enemy_target(fight: dict, enemy: dict, t: float) -> None:
|
||
|
|
units = fight["units"]
|
||
|
|
taunt = enemy.get("taunt")
|
||
|
|
if taunt and taunt["until"] > t and not units[taunt["by"]]["dead"]:
|
||
|
|
enemy["target"] = taunt["by"]
|
||
|
|
return
|
||
|
|
table = {uid: v for uid, v in enemy["threat"].items() if not units[uid]["dead"]}
|
||
|
|
if not table:
|
||
|
|
party = alive(fight, "party")
|
||
|
|
enemy["target"] = party[0]["id"] if party else None
|
||
|
|
if party:
|
||
|
|
enemy["threat"][party[0]["id"]] = 1.0
|
||
|
|
return
|
||
|
|
top = max(table, key=table.get)
|
||
|
|
current = enemy.get("target")
|
||
|
|
if current in table and table[top] <= table[current] * STICKY:
|
||
|
|
return
|
||
|
|
enemy["target"], enemy["since"] = top, t
|
||
|
|
|
||
|
|
|
||
|
|
def _enemy_ability_target(fight: dict, enemy: dict, spec: dict, rng: random.Random):
|
||
|
|
kind = spec["target"]
|
||
|
|
if kind == "target":
|
||
|
|
return enemy["target"]
|
||
|
|
if kind == "all":
|
||
|
|
return None
|
||
|
|
if kind == "hurt_ally":
|
||
|
|
hurt = [e for e in alive(fight, "enemy") if _hurt(e) < 0.7]
|
||
|
|
return min(hurt, key=_hurt)["id"] if hurt else False
|
||
|
|
if kind == "random_soft":
|
||
|
|
soft = [u for u in alive(fight, "party") if u["kind"] == "player" and not _tanks(u)]
|
||
|
|
return rng.choice(soft)["id"] if soft else enemy["target"]
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _enemy_effect(fight: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||
|
|
spec = ENEMY_ABILITIES[ab_id]
|
||
|
|
kind, amount, school = spec["effect"]
|
||
|
|
target = fight["units"].get(target_id or "")
|
||
|
|
swing = enemy["swing"]["dmg"]
|
||
|
|
if kind == "hit":
|
||
|
|
if target and not target["dead"]:
|
||
|
|
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||
|
|
_damage(fight, enemy, target, swing * amount, school, t, rng, label=spec["name"])
|
||
|
|
elif kind == "aoe":
|
||
|
|
_log(fight, t, f"{enemy['name']} · {spec['name']}", "enemy", enemy["id"])
|
||
|
|
for unit in alive(fight, "party"):
|
||
|
|
_damage(fight, enemy, unit, swing * amount, school, t, rng, label=spec["name"])
|
||
|
|
elif kind == "heal":
|
||
|
|
if target and not target["dead"]:
|
||
|
|
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||
|
|
gain = min(target["max_hp"] - target["hp"], round(target["max_hp"] * amount))
|
||
|
|
target["hp"] += gain
|
||
|
|
_event(fight, t, target["id"], gain, "heal")
|
||
|
|
elif kind == "disarm":
|
||
|
|
if target and not target["dead"]:
|
||
|
|
_log(fight, t, f"{enemy['name']} disarms {target['name']}", "enemy", enemy["id"])
|
||
|
|
_add_aura(target, {"id": "disarm", "kind": "disarm", "by": enemy["id"], "until": t + amount,
|
||
|
|
"name": "Disarmed", "icon": "🫳"})
|
||
|
|
|
||
|
|
|
||
|
|
def _phases(fight: dict, t: float, rng: random.Random) -> None:
|
||
|
|
for enemy in alive(fight, "enemy"):
|
||
|
|
for phase in enemy["phases"]:
|
||
|
|
if phase["done"] or _hurt(enemy) > phase["at"]:
|
||
|
|
continue
|
||
|
|
phase["done"] = True
|
||
|
|
_log(fight, t, f"{enemy['name']} yells: {phase['say']}", "yell", enemy["id"])
|
||
|
|
for what, arg in phase["do"]:
|
||
|
|
if what == "stun_all":
|
||
|
|
for unit in alive(fight, "party"):
|
||
|
|
unit["stun"] = t + arg
|
||
|
|
unit["cast"] = None
|
||
|
|
elif what == "enrage":
|
||
|
|
enemy["swing"]["dmg"] *= arg
|
||
|
|
enemy["auras"].append({"id": f"enrage{t}", "kind": "enrage", "by": enemy["id"], "mult": arg,
|
||
|
|
"until": t + MAX_TIME, "name": "Enraged", "icon": "😡"})
|
||
|
|
elif what == "summon":
|
||
|
|
_summon(fight, enemy, arg, t)
|
||
|
|
|
||
|
|
|
||
|
|
def _summon(fight: dict, by: dict, mobs: list, t: float) -> None:
|
||
|
|
hp_scale, dmg_scale = fight["scale"]
|
||
|
|
for mob_id in mobs:
|
||
|
|
fight["next"] += 1
|
||
|
|
unit = mob_unit(f"s{fight['next']}", mob_id, MOBS[mob_id]["lv"][1], hp_scale, dmg_scale)
|
||
|
|
unit["swing"]["next"] = t + 1.0
|
||
|
|
fight["units"][unit["id"]] = unit
|
||
|
|
_engage(fight, unit)
|
||
|
|
_log(fight, t, f"{unit['name']} joins the fight", "enemy", unit["id"])
|
||
|
|
|
||
|
|
|
||
|
|
# --- shared mechanics ------------------------------------------------------
|
||
|
|
|
||
|
|
def _swing(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
|
|
swing = unit.get("swing")
|
||
|
|
if not swing or t < swing["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit["cc"] > t:
|
||
|
|
return
|
||
|
|
if unit["side"] == "enemy" and unit["root"] > t:
|
||
|
|
return
|
||
|
|
target = fight["units"].get(unit.get("target") or "")
|
||
|
|
if not target or target["dead"]:
|
||
|
|
return
|
||
|
|
swing["next"] = t + swing["every"]
|
||
|
|
amount = swing["dmg"]
|
||
|
|
if any(a["kind"] == "disarm" for a in unit["auras"]):
|
||
|
|
amount *= 0.5
|
||
|
|
_damage(fight, unit, target, amount, swing["school"], t, rng, swing=True)
|
||
|
|
res = unit.get("res")
|
||
|
|
if res and res["type"] == "rage":
|
||
|
|
res["cur"] = min(res["max"], res["cur"] + RAGE_PER_SWING)
|
||
|
|
|
||
|
|
|
||
|
|
def _damage(fight: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
|
||
|
|
rng: random.Random, threat: float = 1.0, crit: bool = True, swing: bool = False,
|
||
|
|
label: str = "") -> int:
|
||
|
|
if target["dead"] or amount <= 0:
|
||
|
|
return 0
|
||
|
|
critted = crit and source is not None and source["side"] == "party" and rng.random() < CRIT
|
||
|
|
if critted:
|
||
|
|
amount *= CRIT_MULT
|
||
|
|
amount *= 1 + rng.uniform(-SPREAD, SPREAD)
|
||
|
|
if source:
|
||
|
|
for aura in source["auras"]:
|
||
|
|
if aura["kind"] == "done":
|
||
|
|
amount *= aura["mult"]
|
||
|
|
for aura in target["auras"]:
|
||
|
|
if aura["kind"] == "vuln" and aura["school"] == school:
|
||
|
|
amount *= aura["mult"]
|
||
|
|
elif aura["kind"] == "taken" and aura["school"] in (school, "all"):
|
||
|
|
amount *= aura["mult"]
|
||
|
|
if school == "phys" and target["side"] == "party" and source:
|
||
|
|
armor = target["armor"]
|
||
|
|
amount *= 1 - armor / (armor + 400 + 85 * source["level"])
|
||
|
|
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
|
||
|
|
_event(fight, t, target["id"], dealt, "crit" if critted else ("absorb" if not amount else "dmg"),
|
||
|
|
source["id"] if source else None, label)
|
||
|
|
if target["side"] == "enemy" and source:
|
||
|
|
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + \
|
||
|
|
dealt * threat * source.get("threat_mult", 1)
|
||
|
|
if target["cc"] > t:
|
||
|
|
target["cc"] = 0
|
||
|
|
_log(fight, t, f"{target['name']} breaks free", "miss", target["id"])
|
||
|
|
res = target.get("res")
|
||
|
|
if res and res["type"] == "rage" and amount:
|
||
|
|
res["cur"] = min(res["max"], res["cur"] + amount / target["max_hp"] * RAGE_TAKEN)
|
||
|
|
if swing and source and source["side"] == "enemy":
|
||
|
|
for aura in list(target["auras"]):
|
||
|
|
if aura["kind"] == "reflect":
|
||
|
|
aura["charges"] -= 1
|
||
|
|
if aura["charges"] <= 0:
|
||
|
|
target["auras"].remove(aura)
|
||
|
|
_damage(fight, target, source, aura["c"] * target["power"], aura["school"], t, rng,
|
||
|
|
threat=2.0, label=aura["name"])
|
||
|
|
break
|
||
|
|
if target["hp"] <= 0:
|
||
|
|
_die(fight, target, t)
|
||
|
|
return dealt
|
||
|
|
|
||
|
|
|
||
|
|
def _heal(fight: dict, source: dict | None, target: dict, amount: float, t: float, rng: random.Random,
|
||
|
|
crit: bool = True, label: str = "") -> int:
|
||
|
|
if target["dead"] or amount <= 0:
|
||
|
|
return 0
|
||
|
|
critted = crit and rng.random() < CRIT
|
||
|
|
if critted:
|
||
|
|
amount *= CRIT_MULT
|
||
|
|
amount = round(amount * (1 + rng.uniform(-SPREAD, SPREAD)))
|
||
|
|
gain = max(0, min(amount, target["max_hp"] - target["hp"]))
|
||
|
|
target["hp"] += gain
|
||
|
|
_event(fight, t, target["id"], amount, "heal", source["id"] if source else None, label)
|
||
|
|
if source and gain:
|
||
|
|
_heal_threat(fight, source, gain)
|
||
|
|
return gain
|
||
|
|
|
||
|
|
|
||
|
|
def _heal_threat(fight: dict, source: dict, amount: float) -> None:
|
||
|
|
enemies = alive(fight, "enemy")
|
||
|
|
for enemy in enemies:
|
||
|
|
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
|
||
|
|
|
||
|
|
|
||
|
|
def _die(fight: dict, unit: dict, t: float) -> None:
|
||
|
|
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
|
||
|
|
_log(fight, t, f"{unit['name']} dies", "death", unit["id"])
|
||
|
|
if unit["side"] == "enemy":
|
||
|
|
fight["kills"].append(unit["mob"])
|
||
|
|
for other in alive(fight, "party"):
|
||
|
|
if other.get("target") == unit["id"]:
|
||
|
|
other["target"], other["picked"] = None, False
|
||
|
|
for mob_id in MOBS[unit["mob"]].get("on_death", []):
|
||
|
|
_summon(fight, unit, [mob_id], t)
|
||
|
|
else:
|
||
|
|
for enemy in alive(fight, "enemy"):
|
||
|
|
enemy["threat"].pop(unit["id"], None)
|
||
|
|
if enemy["target"] == unit["id"]:
|
||
|
|
enemy["target"] = None
|
||
|
|
|
||
|
|
|
||
|
|
def _check_end(fight: dict, t: float) -> None:
|
||
|
|
if not alive(fight, "enemy"):
|
||
|
|
fight["state"] = "won"
|
||
|
|
elif not any(not u["dead"] for u in players(fight)):
|
||
|
|
fight["state"] = "lost"
|
||
|
|
elif t >= MAX_TIME:
|
||
|
|
fight["state"] = "lost"
|
||
|
|
_log(fight, t, "The fight drags on too long. You fall back.", "death")
|
||
|
|
if fight["state"] != "on":
|
||
|
|
fight["ended"] = fight["at"] + t
|
||
|
|
|
||
|
|
|
||
|
|
def _log(fight: dict, t: float, text: str, kind: str, who: str | None = None) -> None:
|
||
|
|
fight["log"].append({"t": t, "text": text, "kind": kind, "who": who})
|
||
|
|
del fight["log"][:-LOG]
|
||
|
|
|
||
|
|
|
||
|
|
def _event(fight: dict, t: float, uid: str, amount: float, kind: str, source: str | None = None,
|
||
|
|
label: str = "") -> None:
|
||
|
|
fight["ev"].append({"t": t, "u": uid, "n": round(amount), "k": kind, "s": source, "l": label})
|
||
|
|
del fight["ev"][:-EVENTS]
|
||
|
|
|
||
|
|
|
||
|
|
# --- what the client sees --------------------------------------------------
|
||
|
|
|
||
|
|
def view(fight: dict) -> dict:
|
||
|
|
units = []
|
||
|
|
for unit in fight["units"].values():
|
||
|
|
item = {key: unit.get(key) for key in ("id", "name", "side", "kind", "cls", "role", "level", "hp",
|
||
|
|
"max_hp", "target", "boss", "elite", "rare", "dead", "owner",
|
||
|
|
"combo", "busy", "icon", "mob")}
|
||
|
|
item["hp"] = max(0, round(unit["hp"]))
|
||
|
|
if unit.get("res"):
|
||
|
|
item["res"] = {"type": unit["res"]["type"], "cur": round(unit["res"]["cur"]), "max": unit["res"]["max"]}
|
||
|
|
if unit["cast"]:
|
||
|
|
item["cast"] = {k: unit["cast"].get(k) for k in ("name", "icon", "start", "end", "target", "interrupt")}
|
||
|
|
item["auras"] = [{"icon": a.get("icon"), "name": a.get("name"), "until": round(a["until"], 2),
|
||
|
|
"kind": a["kind"], "mine": a["by"]} for a in unit["auras"] if a.get("icon")]
|
||
|
|
t = fight["t"]
|
||
|
|
item["cc"] = unit.get("cc_icon") if unit["cc"] > t else None
|
||
|
|
item["stun"] = unit["stun"] > t
|
||
|
|
item["root"] = unit.get("root", 0) > t
|
||
|
|
if unit["side"] == "enemy":
|
||
|
|
table = {k: v for k, v in unit["threat"].items() if not fight["units"][k]["dead"]}
|
||
|
|
top = max(table.values(), default=0) or 1
|
||
|
|
item["threat"] = sorted(([k, round(v / top, 2)] for k, v in table.items() if v > 0),
|
||
|
|
key=lambda kv: -kv[1])[:4]
|
||
|
|
if unit.get("kind") == "player":
|
||
|
|
item["cds"] = {k: round(v, 2) for k, v in unit["cds"].items() if v > t}
|
||
|
|
item["manual"] = unit["manual"]
|
||
|
|
item["abilities"] = unit["abilities"]
|
||
|
|
item["ftarget"] = unit.get("ftarget")
|
||
|
|
item["queued"] = (unit.get("queued") or {}).get("ab")
|
||
|
|
units.append(item)
|
||
|
|
return {"id": fight["id"], "t": fight["t"], "at": fight["at"], "state": fight["state"], "kind": fight["kind"],
|
||
|
|
"name": fight["name"], "units": units, "log": fight["log"][-14:], "ev": fight["ev"][-30:]}
|