wog/combat.py
rope 71dd99795a First slice: classes, combat, questing, Deadmines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:52:42 +01:00

798 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:]}