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