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