914 lines
44 KiB
Python
914 lines
44 KiB
Python
"""The fight engine: a party against a pack, stepped in fixed slices of game time.
|
||
|
||
A fight is played out whole as soon as it's drawn up, and again whenever someone tags in or
|
||
steps out, so the page can replay it. Each slice draws from a random seeded by the fight and
|
||
the slice number: playing it again with a latecomer changes nothing before they arrive.
|
||
|
||
Everyone still swings on their weapon's timer. On top of that, the weapon in your hands
|
||
gives four abilities (learned at combat 1, 15, 30 and 45) and your trade one more. Each is
|
||
on auto (a rule decides when, and runs its cooldown half again as long) or in your hands
|
||
(pressed, on its real cooldown). Your hands only count while you're watching: a member's
|
||
`watch` is the moment of the fight their manual choices hold until, and after it everything
|
||
goes back to auto. Presses, targets and switches are a member's `inputs`, each at the moment
|
||
it was made, so playing the fight again from the start with them changes nothing before then.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
|
||
STEP = 0.25 # game seconds per slice
|
||
GCD = 1.5
|
||
AUTO_DELAY = 0.5 # auto waits this long after it could act
|
||
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
|
||
AUTO_REACT = 1.0 # seconds into an enemy's cast before auto stops it
|
||
WATCH = 12 # real seconds since a player last looked that their manual choices still hold
|
||
QUEUE = 1.5 # a press waits this long for its cooldown or the global one before it's dropped
|
||
CRIT_MULT = 1.5
|
||
ENEMY_CRIT = 0.05
|
||
SPREAD = 0.1 # abilities land within 10% of their size
|
||
HARD_ENRAGE = 3.0 # how much harder a dungeon boss hits once its time is up
|
||
HEAL_THREAT = 0.5
|
||
STICKY = 1.1 # an enemy only turns when someone has 10% more threat than its target
|
||
TANK_THREAT = 2.0
|
||
POTION_BELOW = 0.35 # a health potion is drunk under this share of HP, once a fight
|
||
MAX_FIGHT = 600 # a fight that drags on longer is broken off
|
||
UNLOCK = [1, 15, 30, 45]
|
||
|
||
|
||
def hit_chance(accuracy: float, evasion: float) -> float:
|
||
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
|
||
|
||
|
||
# --- abilities ---------------------------------------------------------------
|
||
# `c` is a share of the user's average hit (healing too). Rules (`auto`) say when auto uses it.
|
||
|
||
def _dmg(c):
|
||
return {"t": "dmg", "c": c}
|
||
|
||
|
||
def _aura(kind, dur, on, **extra):
|
||
return {"t": "aura", "kind": kind, "dur": dur, "on": on, **extra}
|
||
|
||
|
||
ABILITIES = {
|
||
# Sword and shield: hold the line.
|
||
"shield_bash": {"name": "Shield Bash", "needs": "shield", "target": "enemy", "cd": 10,
|
||
"offgcd": True, "threat": 2, "effects": [_dmg(1.2), {"t": "stun", "dur": 2}],
|
||
"auto": {"r": "stop", "else": True},
|
||
"tip": "Hit, stun 2s, stop a cast. Bosses only lose the cast."},
|
||
"challenge": {"name": "Challenge", "target": "enemy", "cd": 8, "offgcd": True,
|
||
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
|
||
"tip": "An enemy turns on you for 3s."},
|
||
"sweeping_cut": {"name": "Sweeping Cut", "target": "aoe", "cd": 10, "threat": 2,
|
||
"effects": [{"t": "aoe", "c": 0.8}], "auto": {"r": "aoe", "n": 2},
|
||
"tip": "Hits every enemy. Double threat."},
|
||
"shield_wall": {"name": "Shield Wall", "needs": "shield", "target": "self", "cd": 30, "offgcd": True,
|
||
"effects": [_aura("taken", 6, "self", mult=0.5)], "auto": {"r": "tanking", "pct": 0.6},
|
||
"tip": "Half damage taken for 6s."},
|
||
# Warhammer: slow, heavy, and hard to stand up to.
|
||
"crushing_blow": {"name": "Crushing Blow", "target": "enemy", "cd": 16, "cast": 1.5,
|
||
"effects": [_dmg(1.5)], "auto": {"r": "always"}, "tip": "Wind up 1.5s, hit for 1.5×."},
|
||
"concuss": {"name": "Concuss", "target": "enemy", "cd": 15, "offgcd": True,
|
||
"effects": [{"t": "stun", "dur": 3}], "auto": {"r": "stop", "else": True},
|
||
"tip": "Stun 3s, stop a cast. Bosses only lose the cast."},
|
||
"earthshaker": {"name": "Earthshaker", "target": "aoe", "cd": 12,
|
||
"effects": [{"t": "aoe", "c": 0.5}, {"t": "delay", "by": 1.5}], "auto": {"r": "aoe", "n": 2},
|
||
"tip": "Hits every enemy and puts their next swing back 1.5s."},
|
||
"unstoppable": {"name": "Unstoppable", "target": "self", "cd": 45, "offgcd": True,
|
||
"effects": [_aura("done", 12, "self", mult=1.2)], "auto": {"r": "big"},
|
||
"tip": "+20% damage for 12s."},
|
||
# Staff: magic, and keeping everyone standing.
|
||
"bolt": {"name": "Bolt", "target": "enemy", "cd": 10, "cast": 1.5,
|
||
"effects": [_dmg(1.1)], "auto": {"r": "always"}, "tip": "Cast 1.5s, magic hit for 1.1×."},
|
||
"mend": {"name": "Mend", "target": "ally", "cd": 10, "cast": 2,
|
||
"effects": [{"t": "heal", "c": 2.0}], "auto": {"r": "heal", "pct": 0.5},
|
||
"tip": "Cast 2s, heal someone for 2× your hit."},
|
||
"ward": {"name": "Ward", "target": "ally", "cd": 20,
|
||
"effects": [{"t": "shield", "c": 2.0, "dur": 20}], "auto": {"r": "ward", "pct": 0.8},
|
||
"tip": "Soaks 2× your hit for someone, for up to 20s."},
|
||
"thornbind": {"name": "Thornbind", "target": "enemy", "cd": 20,
|
||
"effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"},
|
||
"tip": "Holds an enemy 15s. Damage breaks it. Not bosses."},
|
||
# Bow: fast, first, and from a distance.
|
||
"aimed_shot": {"name": "Aimed Shot", "target": "enemy", "cd": 10, "cast": 2,
|
||
"effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."},
|
||
"pinning_shot": {"name": "Pinning Shot", "target": "enemy", "cd": 12, "offgcd": True,
|
||
"effects": [_dmg(0.5), {"t": "interrupt"}], "auto": {"r": "stop"},
|
||
"tip": "Hit and stop a cast."},
|
||
"barbed_arrow": {"name": "Barbed Arrow", "target": "enemy", "cd": 12,
|
||
"effects": [_dmg(0.4), {"t": "dot", "c": 0.25, "every": 2, "dur": 12}], "auto": {"r": "dot"},
|
||
"tip": "Hit, then bleeds 6× 0.25 over 12s."},
|
||
"volley": {"name": "Volley", "target": "aoe", "cd": 10,
|
||
"effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2},
|
||
"tip": "Hits every enemy."},
|
||
# Trades: one each, whatever the weapon.
|
||
"find_the_seam": {"name": "Find the Seam", "target": "enemy", "cd": 30, "offgcd": True,
|
||
"effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6},
|
||
"tip": "8s: its resistance doesn't count and its weakness counts double, for everyone."},
|
||
"barricade": {"name": "Barricade", "target": "party", "cd": 40, "offgcd": True,
|
||
"effects": [_aura("taken", 8, "party", mult=0.75)], "auto": {"r": "danger", "pct": 0.6},
|
||
"tip": "Everyone takes 25% less damage for 8s."},
|
||
"hearty_meal": {"name": "Hearty Meal", "target": "party", "cd": 30,
|
||
"effects": [{"t": "hot", "c": 0.3, "every": 2, "dur": 10}], "auto": {"r": "party_hot", "pct": 0.75},
|
||
"tip": "Everyone heals 5× 0.3 of your hit over 10s."},
|
||
"mark_prey": {"name": "Mark Prey", "target": "enemy", "cd": 30, "offgcd": True,
|
||
"effects": [_aura("vuln", 15, "target", mult=1.15)], "auto": {"r": "debuff", "pct": 0.6},
|
||
"tip": "It takes 15% more damage from everyone for 15s."},
|
||
}
|
||
WEAPON_KITS = {
|
||
"sword": ["shield_bash", "challenge", "sweeping_cut", "shield_wall"],
|
||
"hammer": ["crushing_blow", "concuss", "earthshaker", "unstoppable"],
|
||
"staff": ["bolt", "mend", "ward", "thornbind"],
|
||
"bow": ["aimed_shot", "pinning_shot", "barbed_arrow", "volley"],
|
||
}
|
||
TRADE_ABILITY = {"miner": "find_the_seam", "builder": "barricade", "homesteader": "hearty_meal", "ranger": "mark_prey"}
|
||
# The order auto looks down: stopping casts and staying alive before damage.
|
||
PRIORITY = ["pinning_shot", "shield_bash", "concuss", "challenge", "shield_wall", "barricade", "unstoppable",
|
||
"mend", "ward", "hearty_meal", "thornbind", "find_the_seam", "mark_prey", "barbed_arrow",
|
||
"earthshaker", "sweeping_cut", "volley", "crushing_blow", "aimed_shot", "bolt"]
|
||
for _id, _ability in ABILITIES.items():
|
||
_ability["id"] = _id
|
||
for _style, _kit in WEAPON_KITS.items():
|
||
if _id in _kit:
|
||
_ability.update(style=_style, level=UNLOCK[_kit.index(_id)])
|
||
for _cls, _trade in TRADE_ABILITY.items():
|
||
if _trade == _id:
|
||
_ability.update(trade=_cls, level=1)
|
||
|
||
|
||
def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str]:
|
||
"""What you can use with this weapon at this combat level, in the order auto tries them."""
|
||
kit = [a for a in WEAPON_KITS[style] if ABILITIES[a]["level"] <= level and (shield or not ABILITIES[a].get("needs"))]
|
||
if cls in TRADE_ABILITY:
|
||
kit.append(TRADE_ABILITY[cls])
|
||
return sorted(kit, key=PRIORITY.index)
|
||
|
||
|
||
def modded(ab: dict, mod: dict | None) -> dict:
|
||
"""An ability as something you wear changes it: `cd` and `cast` multiply, `c` multiplies every
|
||
effect's size, `dur` adds seconds to every effect that lasts, and `extra` adds effects."""
|
||
if not mod:
|
||
return ab
|
||
ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]}
|
||
for key in ("cd", "cast"):
|
||
if key in mod and ab.get(key):
|
||
ab[key] = ab[key] * mod[key]
|
||
for eff in ab["effects"]:
|
||
if "c" in eff and "c" in mod:
|
||
eff["c"] *= mod["c"]
|
||
if "dur" in eff and "dur" in mod:
|
||
eff["dur"] += mod["dur"]
|
||
ab["effects"] += [dict(eff) for eff in mod.get("extra", [])]
|
||
return ab
|
||
|
||
|
||
def combine_mods(mods: list[dict]) -> dict:
|
||
"""Several things changing the same abilities: multipliers multiply, durations add, extras pile up."""
|
||
out = {}
|
||
for mod in mods:
|
||
for ab_id, change in mod.items():
|
||
have = out.setdefault(ab_id, {})
|
||
for key, value in change.items():
|
||
if key in ("cd", "cast", "c"):
|
||
have[key] = have.get(key, 1) * value
|
||
elif key == "dur":
|
||
have[key] = have.get(key, 0) + value
|
||
elif key == "extra":
|
||
have[key] = have.get(key, []) + value
|
||
else:
|
||
have[key] = value
|
||
return out
|
||
|
||
|
||
def role(style: str, shield: bool) -> str:
|
||
return "tank" if style == "sword" and shield else "healer" if style == "staff" else "damage"
|
||
|
||
|
||
# --- enemy abilities ---------------------------------------------------------
|
||
# Every archetype has its own, so every area's races use them. `effect` is (kind, share of
|
||
# the enemy's average hit, damage type: None for its own). Telegraphed casts always land.
|
||
|
||
ENEMY_ABILITIES = {
|
||
"heavy_slam": {"name": "Heavy Slam", "cast": 2.0, "every": 14, "first": 5, "interrupt": False,
|
||
"target": "target", "effect": ("hit", 1.6, "blunt")},
|
||
"lunge": {"name": "Lunge", "cast": 0, "every": 10, "first": 4, "interrupt": False,
|
||
"target": "soft", "effect": ("hit", 1.2, None)},
|
||
"hex_bolt": {"name": "Hex Bolt", "cast": 2.5, "every": 12, "first": 3, "interrupt": True,
|
||
"target": "target", "effect": ("hit", 1.8, "magic")},
|
||
"sweep": {"name": "Sweep", "cast": 1.5, "every": 16, "first": 8, "interrupt": False,
|
||
"target": "all", "effect": ("aoe", 0.8, "slash")},
|
||
"fire_breath": {"name": "Fire Breath", "cast": 3.0, "every": 18, "first": 6, "interrupt": False,
|
||
"target": "all", "effect": ("aoe", 1.4, "magic")},
|
||
"tail_swipe": {"name": "Tail Swipe", "cast": 0, "every": 12, "first": 7, "interrupt": False,
|
||
"target": "target", "effect": ("hit", 1.2, "blunt")},
|
||
# Dungeon bosses' own: the ones a group has to answer.
|
||
"stone_curse": {"name": "Stone Curse", "cast": 2.0, "every": 6, "first": 4, "interrupt": True,
|
||
"target": "target", "effect": ("hit", 3.5, "magic")},
|
||
"ground_pound": {"name": "Ground Pound", "cast": 3.0, "every": 18, "first": 10, "interrupt": False,
|
||
"target": "all", "effect": ("aoe", 1.6, "blunt")},
|
||
"shatter": {"name": "Shatter", "cast": 2.5, "every": 14, "first": 6, "interrupt": False,
|
||
"target": "all", "effect": ("aoe", 1.5, "blunt")},
|
||
"shriek": {"name": "Shriek", "cast": 2.0, "every": 9, "first": 4, "interrupt": True,
|
||
"target": "all", "effect": ("aoe", 1.4, "magic")},
|
||
}
|
||
|
||
|
||
def _curse(name, school):
|
||
"""A boss's must-interrupt: one target, very hard, every few seconds."""
|
||
return {"name": name, "cast": 2.0, "every": 6, "first": 4, "interrupt": True, "target": "target",
|
||
"effect": ("hit", 3.5, school)}
|
||
|
||
|
||
def _storm(name, school):
|
||
"""An interruptible blast at everyone."""
|
||
return {"name": name, "cast": 2.0, "every": 9, "first": 4, "interrupt": True, "target": "all",
|
||
"effect": ("aoe", 1.4, school)}
|
||
|
||
|
||
def _quake(name, school):
|
||
"""A slow blast at everyone that can't be stopped: brace for it."""
|
||
return {"name": name, "cast": 3.0, "every": 18, "first": 10, "interrupt": False, "target": "all",
|
||
"effect": ("aoe", 1.6, school)}
|
||
|
||
|
||
# Each dungeon's bosses, named for who casts them.
|
||
ENEMY_ABILITIES.update({
|
||
"hex_storm": _storm("Hex Storm", "magic"), "crushing_decree": _curse("Crushing Decree", "blunt"),
|
||
"royal_stomp": _quake("Royal Stomp", "blunt"), "witchfire": _storm("Witchfire", "magic"),
|
||
"cheap_shot": _curse("Cheap Shot", "pierce"), "execute": _curse("Execute", "slash"),
|
||
"whirlwind": _quake("Whirlwind", "slash"), "rune_blast": _curse("Rune Blast", "magic"),
|
||
"venom_spray": _storm("Venom Spray", "magic"), "grave_chill": _curse("Grave Chill", "magic"),
|
||
"soul_rend": _quake("Soul Rend", "magic"), "forge_blast": _curse("Forge Blast", "magic"),
|
||
"war_cry": _storm("War Cry", "slash"), "acid_spit": _curse("Acid Spit", "magic"),
|
||
"acid_breath": _quake("Acid Breath", "magic"), "deep_freeze": _curse("Deep Freeze", "magic"),
|
||
"wail": _storm("Wail", "magic"), "frost_lance": _curse("Frost Lance", "magic"),
|
||
"frost_breath": _quake("Frost Breath", "magic"), "void_bolt": _curse("Void Bolt", "magic"),
|
||
"flame_wave": _storm("Flame Wave", "magic"), "inferno": _curse("Inferno", "magic"),
|
||
})
|
||
ARCHETYPE_ABILITIES = {"brute": ["heavy_slam"], "skirmisher": ["lunge"], "caster": ["hex_bolt"],
|
||
"elite": ["heavy_slam"], "boss": ["heavy_slam", "sweep"], "dragon": ["fire_breath", "tail_swipe"]}
|
||
ENRAGE = {"elite": (0.3, 1.4), "boss": (0.3, 1.4), "dragon": (0.25, 1.5)} # under this share of HP, hits this much harder
|
||
VULNERABLE, RESISTANT = 1.25, 0.75
|
||
|
||
|
||
# --- setting up --------------------------------------------------------------
|
||
|
||
def _member_unit(member: dict) -> dict:
|
||
me = member["fighter"]
|
||
return {"id": member["id"], "side": "party", "name": member["name"], "top": member["top"], "hp": member["hp_in"],
|
||
"at": member["at"], "left": member["left"], "in": False, "out": None, "out_at": None, "role": me["role"],
|
||
"damage": me["damage"], "interval": me["interval"], "accuracy": me["accuracy"], "evasion": me["evasion"],
|
||
"crit": me["crit"], "block": me["block"], "taken": me["taken"], "base": me["base"],
|
||
"abilities": list(me["abilities"]), "cds": {}, "busy": 0.0, "cast": None, "auras": [],
|
||
"kit": {a: modded(ABILITIES[a], me.get("mods", {}).get(a)) for a in me["abilities"]},
|
||
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "stun": 0.0, "target": None,
|
||
"mend": member["mend"], "wind": member["wind"], "wind_used": False,
|
||
"potion": member.get("potion") or 0, "drank": False,
|
||
"dealt": 0, "hurt": 0, "down": False, "manual": set(member.get("manual", [])),
|
||
"watch": member.get("watch", -1.0), "inputs": sorted(member.get("inputs", []), key=lambda i: i[0]),
|
||
"seen": 0, "queued": None, "picked": False, "ftarget": None}
|
||
|
||
|
||
def _enemy_unit(index: int, foe: dict) -> dict:
|
||
return {"id": f"e{index}", "side": "enemy", "name": foe["name"], "archetype": foe["archetype"],
|
||
"boss": foe["archetype"] in ("boss", "dragon") or foe.get("boss", False),
|
||
"top": foe["hp"], "hp": foe["hp"], "interval": foe["interval"], "accuracy": foe["accuracy"],
|
||
"evasion": foe["evasion"], "max_hit": foe["max_hit"], "attack": foe["attack"],
|
||
"vulnerable": foe["vulnerable"], "resists": foe["resists"],
|
||
"next": foe["interval"] + 0.4 * index, "swings": 0, "cast": None, "auras": [], "threat": {},
|
||
"target": None, "taunt": None, "since": 0.0, "stun": 0.0, "cc": 0.0, "dead": False, "enraged": 1.0,
|
||
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"] + 0.5 * index}
|
||
for a in foe.get("abilities") or ARCHETYPE_ABILITIES.get(foe["archetype"], [])],
|
||
# A dungeon boss's: adds that wait (`dormant`) until it's hurt enough, shouts, and a hard enrage.
|
||
"dormant": foe.get("dormant", False), "phases": [dict(p) for p in foe.get("phases", [])],
|
||
"enrage_at": foe.get("enrage_at")}
|
||
|
||
|
||
def run(fight: dict) -> dict:
|
||
"""Play the fight out from the start. `fight` carries `seed`, `pace`, `rest` (game seconds before
|
||
it starts), `pack` (the enemies) and `members` (each with `fighter`, `at` and `left` in seconds of
|
||
the fight). Fills in `events`, the `kills`, each member's outcome, `result`, `end` and `duration`."""
|
||
units = {}
|
||
for member in fight["members"]:
|
||
units[member["id"]] = _member_unit(member)
|
||
for index, foe in enumerate(fight["pack"]):
|
||
unit = _enemy_unit(index, foe)
|
||
units[unit["id"]] = unit
|
||
state = {"units": units, "events": [], "kills": [], "pace": fight["pace"], "rest": fight["rest"]}
|
||
t, n, result = 0.0, 0, None
|
||
while result is None:
|
||
t = round(t + STEP, 4)
|
||
rng = random.Random(f"{fight['seed']}:{n}")
|
||
n += 1
|
||
result = _step(state, t, rng)
|
||
for member in fight["members"]:
|
||
unit = units[member["id"]]
|
||
won = result == "won" and unit["out"] in (None, "down") and unit["in"]
|
||
member.update(hp=max(0, unit["hp"]), damage=unit["hurt"], dealt=unit["dealt"], wind_used=unit["wind_used"],
|
||
drank=unit["drank"], down=unit["down"], out_at=unit["out_at"],
|
||
result="won" if won else unit["out"] or ("left" if not unit["in"] else result))
|
||
fight.update(events=state["events"], kills=state["kills"], result=result,
|
||
end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"])
|
||
return fight
|
||
|
||
|
||
def _real(state: dict, t: float) -> float:
|
||
"""A moment of the fight in real seconds since its action started (rest included)."""
|
||
return round((state["rest"] + t) / state["pace"], 2)
|
||
|
||
|
||
def _event(state: dict, t: float, src: str | None, tgt: str | None, kind: str, amount: float = 0,
|
||
label: str = "") -> None:
|
||
target = state["units"].get(tgt or "")
|
||
hp = max(0, round(target["hp"])) if target else None
|
||
state["events"].append([_real(state, t), src, tgt, kind, round(amount), hp, label])
|
||
|
||
|
||
def _party(state: dict) -> list[dict]:
|
||
return [u for u in state["units"].values() if u["side"] == "party" and u["in"] and not u["out"]]
|
||
|
||
|
||
def _enemies(state: dict) -> list[dict]:
|
||
return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"] and not u["dormant"]]
|
||
|
||
|
||
# --- a slice -------------------------------------------------------------------
|
||
|
||
def _step(state: dict, t: float, rng: random.Random) -> str | None:
|
||
units = state["units"]
|
||
for unit in units.values():
|
||
if unit["side"] != "party" or unit["out"]:
|
||
continue
|
||
if not unit["in"] and unit["at"] <= t + 1e-9:
|
||
unit["in"] = True
|
||
for enemy in _enemies(state):
|
||
enemy["threat"].setdefault(unit["id"], 10.0 if unit["role"] == "tank" else 0.0)
|
||
if unit["in"] and unit["left"] is not None and unit["left"] <= t + 1e-9:
|
||
_out(state, unit, "left", t)
|
||
for unit in list(units.values()):
|
||
if _alive(unit):
|
||
_tick(state, unit, t, rng)
|
||
for unit in list(units.values()):
|
||
if _alive(unit) and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
|
||
_finish_cast(state, unit, t, rng)
|
||
for unit in _party(state):
|
||
_inputs(state, unit, t)
|
||
_think(state, unit, t, rng)
|
||
for enemy in _enemies(state):
|
||
if enemy["cc"] and enemy["cc"] <= t + 1e-9:
|
||
enemy["cc"] = 0 # the hold wears off
|
||
_event(state, t, None, enemy["id"], "free")
|
||
_enemy_think(state, enemy, t, rng)
|
||
for unit in list(units.values()):
|
||
if _alive(unit):
|
||
_swing(state, unit, t, rng)
|
||
for enemy in _enemies(state):
|
||
rule = ENRAGE.get(enemy["archetype"])
|
||
if rule and enemy["enraged"] == 1.0 and enemy["hp"] < enemy["top"] * rule[0]:
|
||
enemy["enraged"] = rule[1]
|
||
_event(state, t, enemy["id"], enemy["id"], "enrage")
|
||
if enemy["enrage_at"] is not None and t >= enemy["enrage_at"] and enemy["enraged"] < HARD_ENRAGE:
|
||
enemy["enraged"] = HARD_ENRAGE # out of patience
|
||
_event(state, t, enemy["id"], enemy["id"], "enrage", 0, "hard")
|
||
for phase in enemy["phases"]:
|
||
if not phase.get("done") and enemy["hp"] <= enemy["top"] * phase["at"]:
|
||
phase["done"] = True
|
||
if phase.get("say"):
|
||
_event(state, t, enemy["id"], None, "yell", 0, phase["say"])
|
||
for uid in phase.get("wake", []):
|
||
_wake(state, state["units"][uid], t)
|
||
if not _enemies(state):
|
||
return "won"
|
||
if not _party(state) and not any(u["side"] == "party" and not u["in"] and not u["out"] for u in units.values()):
|
||
return "lost"
|
||
if t >= MAX_FIGHT:
|
||
for unit in _party(state):
|
||
unit["out"] = "fled"
|
||
return "fled"
|
||
return None
|
||
|
||
|
||
def _wake(state: dict, enemy: dict, t: float) -> None:
|
||
"""An add joins the fight: on whoever's tanking, else the first of you."""
|
||
enemy["dormant"] = False
|
||
enemy["next"] = t + 1.0
|
||
for slot in enemy["abil"]:
|
||
slot["next"] = t + ENEMY_ABILITIES[slot["id"]]["first"]
|
||
party = _party(state)
|
||
for unit in party:
|
||
enemy["threat"][unit["id"]] = 10.0 if unit["role"] == "tank" else 0.0
|
||
if party and not any(enemy["threat"].values()):
|
||
enemy["threat"][party[0]["id"]] = 1.0
|
||
_event(state, t, enemy["id"], None, "join")
|
||
|
||
|
||
def _alive(unit: dict) -> bool:
|
||
return not unit["dead"] and not unit["dormant"] if unit["side"] == "enemy" else unit["in"] and not unit["out"]
|
||
|
||
|
||
def _tick(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
if unit["side"] == "party" and unit["mend"]:
|
||
unit["hp"] = min(unit["top"], unit["hp"] + unit["mend"] * unit["top"] * STEP) # Rapid Heal
|
||
for aura in list(unit["auras"]):
|
||
if aura["kind"] in ("dot", "hot") and t >= aura["next"] - 1e-9:
|
||
aura["next"] += aura["every"]
|
||
source = state["units"].get(aura["by"])
|
||
if aura["kind"] == "dot":
|
||
_hurt(state, source, unit, aura["amount"], aura["school"], t, rng, label=aura["id"], crit=False, tick=True)
|
||
else:
|
||
_heal(state, source, unit, aura["amount"], t, aura["id"], tick=True)
|
||
if not _alive(unit):
|
||
return
|
||
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
|
||
unit["auras"].remove(aura)
|
||
|
||
|
||
# --- your side -----------------------------------------------------------------
|
||
|
||
def _inputs(state: dict, unit: dict, t: float) -> None:
|
||
"""What the player did up to now: presses queue up, taps pick targets, switches move abilities
|
||
between auto and their hands."""
|
||
inputs = unit["inputs"]
|
||
while unit["seen"] < len(inputs) and inputs[unit["seen"]][0] <= t + 1e-9:
|
||
at, kind, arg, *more = inputs[unit["seen"]]
|
||
unit["seen"] += 1
|
||
other = state["units"].get(arg or "")
|
||
if kind == "press" and arg in unit["abilities"]:
|
||
unit["queued"] = {"ab": arg, "target": more[0] if more else None, "at": at}
|
||
elif kind == "target" and other and _alive(other):
|
||
if other["side"] == "enemy":
|
||
unit["target"], unit["picked"] = arg, True
|
||
else:
|
||
unit["ftarget"] = arg
|
||
elif kind == "mode":
|
||
(unit["manual"].add if more and more[0] else unit["manual"].discard)(arg)
|
||
|
||
|
||
def _ready(unit: dict, ab: dict, t: float, manual: bool) -> str | None:
|
||
if unit["cds"].get(ab["id"], 0) > t + 1e-9:
|
||
return "cooldown"
|
||
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
|
||
return "gcd"
|
||
return None
|
||
|
||
|
||
def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
if unit["potion"] and not unit["drank"] and unit["hp"] < unit["top"] * POTION_BELOW:
|
||
unit["drank"] = True
|
||
_heal(state, None, unit, unit["potion"], t, "potion")
|
||
if unit["stun"] > t or unit["cast"]:
|
||
return
|
||
_pick_target(state, unit, t)
|
||
watching = t < unit["watch"]
|
||
queued = unit["queued"]
|
||
if queued:
|
||
ab = unit["kit"][queued["ab"]]
|
||
why = _ready(unit, ab, t, manual=True)
|
||
if why is None:
|
||
unit["queued"] = None
|
||
target = _manual_target(state, unit, ab, queued["target"], t)
|
||
if target is not False:
|
||
_use(state, unit, ab, target, t, rng, manual=True)
|
||
if unit["cast"] or not ab.get("offgcd"):
|
||
return
|
||
elif t - queued["at"] > QUEUE:
|
||
unit["queued"] = None
|
||
for ab_id in unit["abilities"]:
|
||
ab = unit["kit"][ab_id]
|
||
if (watching and ab_id in unit["manual"]) or _ready(unit, ab, t, manual=False):
|
||
continue
|
||
target = _rule(state, unit, ab, t)
|
||
if target is False:
|
||
continue
|
||
_use(state, unit, ab, target, t, rng)
|
||
if unit["cast"] or not ab.get("offgcd"):
|
||
return
|
||
|
||
|
||
def _manual_target(state: dict, unit: dict, ab: dict, given: str | None, t: float):
|
||
"""Who a press lands on: the frame you tapped, else the one the ability would pick, else your target."""
|
||
units = state["units"]
|
||
kind = ab["target"]
|
||
if kind in ("self", "aoe", "party"):
|
||
return None
|
||
if kind == "ally":
|
||
for choice in (given, unit["ftarget"]):
|
||
other = units.get(choice or "")
|
||
if other and other["side"] == "party" and _alive(other):
|
||
return other["id"]
|
||
return min(_party(state), key=_share)["id"]
|
||
other = units.get(given or "")
|
||
if other and other["side"] == "enemy" and _alive(other):
|
||
return other["id"]
|
||
if ab["auto"]["r"] in ("stop", "cc", "taunt"):
|
||
smart = _rule(state, unit, {**ab, "auto": {**ab["auto"], "else": False}}, t)
|
||
if smart:
|
||
return smart
|
||
target = units.get(unit["target"] or "")
|
||
return target["id"] if target and _alive(target) else False
|
||
|
||
|
||
def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random, manual: bool = False) -> None:
|
||
unit["cds"][ab["id"]] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
|
||
if not ab.get("offgcd"):
|
||
unit["busy"] = t + max(GCD, ab.get("cast", 0))
|
||
if ab.get("cast"):
|
||
unit["cast"] = {"ab": ab["id"], "target": target, "start": t, "end": t + ab["cast"]}
|
||
_event(state, t, unit["id"], target, "cast", 0, ab["id"])
|
||
else:
|
||
_apply(state, unit, ab, target, t, rng)
|
||
|
||
|
||
def _finish_cast(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
cast, unit["cast"] = unit["cast"], None
|
||
if unit["side"] == "party":
|
||
_apply(state, unit, unit["kit"][cast["ab"]], cast["target"], t, rng)
|
||
else:
|
||
_enemy_effect(state, unit, cast["ab"], cast["target"], t, rng)
|
||
|
||
|
||
def _avg(unit: dict, enemy: dict | None = None) -> float:
|
||
"""The unit's average weapon hit, against this enemy if there is one."""
|
||
top = max(2, round(unit["base"] * (_affinity(enemy, unit["damage"]) if enemy else 1)))
|
||
return (top + 1) / 2
|
||
|
||
|
||
def _affinity(enemy: dict, damage: str) -> float:
|
||
seam = any(a["kind"] == "seam" for a in enemy["auras"])
|
||
if damage in enemy["vulnerable"]:
|
||
return 1 + (VULNERABLE - 1) * (2 if seam else 1)
|
||
if damage in enemy["resists"]:
|
||
return 1 if seam else RESISTANT
|
||
return 1
|
||
|
||
|
||
def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.Random) -> None:
|
||
units = state["units"]
|
||
target = units.get(target_id or "")
|
||
if ab["target"] in ("enemy", "ally") and (not target or not _alive(target)):
|
||
return
|
||
threat = ab.get("threat", 1)
|
||
if ab["target"] != "enemy" or not any(e["t"] == "dmg" for e in ab["effects"]):
|
||
_event(state, t, unit["id"], target_id, "use", 0, ab["id"])
|
||
spread = lambda: 1 + rng.uniform(-SPREAD, SPREAD)
|
||
for eff in ab["effects"]:
|
||
kind = eff["t"]
|
||
if kind == "dmg":
|
||
_hurt(state, unit, target, eff["c"] * _avg(unit, target) * spread(), unit["damage"], t, rng,
|
||
threat=threat, label=ab["id"])
|
||
elif kind == "aoe":
|
||
for enemy in _enemies(state):
|
||
if enemy["cc"] <= t:
|
||
_hurt(state, unit, enemy, eff["c"] * _avg(unit, enemy) * spread(), unit["damage"], t, rng,
|
||
threat=threat, label=ab["id"])
|
||
elif kind == "dot":
|
||
if target and _alive(target):
|
||
_add_aura(target, {"id": ab["id"], "kind": "dot", "by": unit["id"], "school": unit["damage"],
|
||
"amount": eff["c"] * _avg(unit, target), "every": eff["every"],
|
||
"next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
|
||
elif kind == "heal":
|
||
_heal(state, unit, _second(state, target) if eff.get("second") else target,
|
||
eff["c"] * _avg(unit) * spread(), t, ab["id"])
|
||
elif kind == "hot":
|
||
for ally in _party(state):
|
||
_add_aura(ally, {"id": ab["id"], "kind": "hot", "by": unit["id"], "amount": eff["c"] * _avg(unit),
|
||
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
|
||
elif kind == "shield":
|
||
holder = _second(state, target) if eff.get("second") else target
|
||
if not holder:
|
||
continue
|
||
_add_aura(holder, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit),
|
||
"until": t + eff["dur"]})
|
||
_heal_threat(state, unit, eff["c"] * _avg(unit))
|
||
elif kind == "aura":
|
||
holders = _party(state) if eff["on"] == "party" else [target] if eff["on"] == "target" else [unit]
|
||
spec = {k: v for k, v in eff.items() if k not in ("t", "dur", "on")}
|
||
for holder in holders:
|
||
if holder and _alive(holder):
|
||
_add_aura(holder, {**spec, "id": ab["id"], "by": unit["id"], "until": t + eff["dur"]})
|
||
elif kind == "taunt":
|
||
top = max(target["threat"].values(), default=0)
|
||
target["threat"][unit["id"]] = top * STICKY + 1
|
||
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
|
||
target["target"] = unit["id"]
|
||
elif kind == "stun":
|
||
if not _alive(target):
|
||
continue
|
||
_stop_cast(state, target, unit, t, force=not target["boss"])
|
||
if not target["boss"]:
|
||
target["stun"] = max(target["stun"], t + eff["dur"])
|
||
target["next"] = max(target["next"], t + eff["dur"])
|
||
elif kind == "interrupt":
|
||
if _alive(target):
|
||
_stop_cast(state, target, unit, t)
|
||
elif kind == "cc":
|
||
if target["boss"]:
|
||
continue
|
||
_stop_cast(state, target, unit, t, force=True, quiet=True)
|
||
target["cc"] = t + eff["dur"]
|
||
_event(state, t, unit["id"], target["id"], "cc", 0, ab["id"])
|
||
for other in _party(state):
|
||
if other["target"] == target["id"]:
|
||
other["target"] = None
|
||
elif kind == "delay":
|
||
for enemy in _enemies(state):
|
||
enemy["next"] += eff["by"]
|
||
|
||
|
||
def _second(state: dict, first: dict | None) -> dict | None:
|
||
"""The most hurt of your side after `first`: for heals and wards that reach a second ally."""
|
||
others = [u for u in _party(state) if u is not first]
|
||
return min(others, key=_share) if others else None
|
||
|
||
|
||
def _stop_cast(state: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
|
||
cast = enemy.get("cast")
|
||
if not cast or (not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]):
|
||
return
|
||
enemy["cast"] = None
|
||
if not quiet:
|
||
_event(state, t, by["id"], enemy["id"], "stop", 0, cast["ab"])
|
||
|
||
|
||
def _add_aura(unit: dict, aura: dict) -> None:
|
||
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
|
||
unit["auras"].append(aura)
|
||
|
||
|
||
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
|
||
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
|
||
|
||
|
||
def _share(unit: dict) -> float:
|
||
return unit["hp"] / unit["top"]
|
||
|
||
|
||
def _pick_target(state: dict, unit: dict, t: float) -> None:
|
||
current = state["units"].get(unit["target"] or "")
|
||
if current and not current["dead"] and (current["cc"] <= t or unit["picked"]):
|
||
return
|
||
unit["picked"] = False
|
||
enemies = _enemies(state)
|
||
free = [e for e in enemies if e["cc"] <= t] or enemies
|
||
if not free:
|
||
unit["target"] = None
|
||
return
|
||
if unit["role"] == "tank":
|
||
loose = [e for e in free if e["target"] != unit["id"]]
|
||
unit["target"] = (loose or sorted(free, key=_share))[0]["id"]
|
||
return
|
||
tank = next((u for u in _party(state) if u["role"] == "tank" and u["target"]), None)
|
||
focus = state["units"].get(tank["target"]) if tank else None
|
||
if focus and not focus["dead"] and focus["cc"] <= t:
|
||
unit["target"] = focus["id"]
|
||
else:
|
||
unit["target"] = min(free, key=_share)["id"]
|
||
|
||
|
||
def _stoppable(cast: dict, ab: dict, enemy: dict) -> bool:
|
||
spec = ENEMY_ABILITIES[cast["ab"]]
|
||
stuns = any(e["t"] == "stun" for e in ab["effects"])
|
||
return spec["interrupt"] or (stuns and not enemy["boss"])
|
||
|
||
|
||
def _rule(state: dict, unit: dict, ab: dict, t: float):
|
||
"""Auto's decision: the target to use this on, None for no target, False for not now."""
|
||
rule = ab["auto"]
|
||
r = rule["r"]
|
||
enemies = _enemies(state)
|
||
party = _party(state)
|
||
target = state["units"].get(unit["target"] or "")
|
||
if target and target["dead"]:
|
||
target = None
|
||
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
|
||
if r == "always":
|
||
return target["id"] if target else False
|
||
if r == "stop":
|
||
casting = [e for e in enemies if e["cast"] and t - e["cast"]["start"] >= AUTO_REACT - 1e-9
|
||
and _stoppable(e["cast"], ab, e)]
|
||
if casting:
|
||
return casting[0]["id"]
|
||
return target["id"] if rule.get("else") and target else False
|
||
if r == "taunt":
|
||
if unit["role"] != "tank":
|
||
return False
|
||
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
|
||
and t - e["since"] >= AUTO_REACT]
|
||
return loose[0]["id"] if loose else False
|
||
if r == "aoe":
|
||
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
|
||
if r == "tanking":
|
||
big = any(e["boss"] or e["archetype"] == "elite" or e["cast"] and e["cast"]["target"] in (unit["id"], None)
|
||
for e in on_me)
|
||
return None if on_me and (big or _share(unit) < rule["pct"]) else False
|
||
if r == "big":
|
||
return None if target and (target["boss"] or target["archetype"] == "elite" or len(enemies) >= 2) else False
|
||
if r == "danger":
|
||
threatening = len([e for e in enemies if e["cc"] <= t]) >= 2 or any(e["boss"] for e in enemies)
|
||
return None if threatening or any(_share(u) < rule["pct"] for u in party) else False
|
||
if r == "heal":
|
||
low = min(party, key=_share, default=None)
|
||
return low["id"] if low and _share(low) < rule["pct"] else False
|
||
if r == "ward":
|
||
targeted = {e["target"] for e in enemies}
|
||
bare = [u for u in party if not any(a["kind"] == "shield" for a in u["auras"])
|
||
and (u["id"] in targeted or _share(u) < rule["pct"])]
|
||
return min(bare, key=_share)["id"] if bare else False
|
||
if r == "party_hot":
|
||
return None if any(_share(u) < rule["pct"] and not _has(u, ab["id"], unit["id"]) for u in party) else False
|
||
if r == "cc":
|
||
free = [e for e in enemies if e["cc"] <= t]
|
||
if len(free) < 2:
|
||
return False
|
||
focus = {u["target"] for u in party if u["role"] == "tank"} | {unit["target"]}
|
||
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _share(e) > 0.5]
|
||
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
|
||
if r == "dot":
|
||
return target["id"] if target and not _has(target, ab["id"], unit["id"]) and _share(target) > 0.3 else False
|
||
if r == "debuff":
|
||
return target["id"] if target and not _has(target, ab["id"]) and _share(target) > rule["pct"] else False
|
||
return False
|
||
|
||
|
||
# --- their side ----------------------------------------------------------------
|
||
|
||
def _enemy_think(state: dict, enemy: dict, t: float, rng: random.Random) -> None:
|
||
if enemy["stun"] > t or enemy["cc"] > t:
|
||
enemy["cast"] = None
|
||
return
|
||
_enemy_target(state, enemy, t)
|
||
if enemy["cast"] or not enemy["target"]:
|
||
return
|
||
for slot in enemy["abil"]:
|
||
if t < slot["next"]:
|
||
continue
|
||
spec = ENEMY_ABILITIES[slot["id"]]
|
||
slot["next"] = t + spec["every"]
|
||
target = enemy["target"]
|
||
if spec["target"] == "soft":
|
||
soft = [u for u in _party(state) if u["role"] != "tank"]
|
||
target = rng.choice(soft)["id"] if soft else target
|
||
elif spec["target"] == "all":
|
||
target = None
|
||
if spec["cast"]:
|
||
enemy["cast"] = {"ab": slot["id"], "target": target, "start": t, "end": t + spec["cast"]}
|
||
_event(state, t, enemy["id"], target, "cast", 0, slot["id"])
|
||
else:
|
||
_enemy_effect(state, enemy, slot["id"], target, t, rng)
|
||
return
|
||
|
||
|
||
def _enemy_target(state: dict, enemy: dict, t: float) -> None:
|
||
units = state["units"]
|
||
taunt = enemy["taunt"]
|
||
if taunt and taunt["until"] > t and _alive(units[taunt["by"]]):
|
||
enemy["target"] = taunt["by"]
|
||
return
|
||
table = {uid: v for uid, v in enemy["threat"].items() if _alive(units[uid])}
|
||
if not table:
|
||
party = _party(state)
|
||
enemy["target"] = party[0]["id"] if party else None
|
||
if party:
|
||
enemy["threat"][party[0]["id"]] = 1.0
|
||
enemy["since"] = t
|
||
return
|
||
top = max(table, key=table.get)
|
||
current = enemy["target"]
|
||
if current in table and table[top] <= table[current] * STICKY:
|
||
return
|
||
enemy["target"], enemy["since"] = top, t
|
||
|
||
|
||
def _enemy_effect(state: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||
spec = ENEMY_ABILITIES[ab_id]
|
||
kind, c, school = spec["effect"]
|
||
amount = c * (enemy["max_hit"] + 1) / 2 * enemy["enraged"]
|
||
if kind == "hit":
|
||
target = state["units"].get(target_id or "")
|
||
if target and _alive(target):
|
||
_hurt(state, enemy, target, amount * (1 + rng.uniform(-SPREAD, SPREAD)),
|
||
school or enemy["attack"][0], t, rng, label=ab_id, crit=False)
|
||
else:
|
||
_event(state, t, enemy["id"], None, "use", 0, ab_id)
|
||
for unit in _party(state):
|
||
_hurt(state, enemy, unit, amount * (1 + rng.uniform(-SPREAD, SPREAD)), school, t, rng,
|
||
label=ab_id, crit=False)
|
||
|
||
|
||
# --- blows ---------------------------------------------------------------------
|
||
|
||
def _swing(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||
if t < unit["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit.get("cc", 0) > t:
|
||
return
|
||
target = state["units"].get(unit["target"] or "")
|
||
if not target or not _alive(target):
|
||
return
|
||
unit["next"] = t + unit["interval"]
|
||
if unit["side"] == "party":
|
||
top = max(2, round(unit["base"] * _affinity(target, unit["damage"])))
|
||
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
|
||
_event(state, t, unit["id"], target["id"], "miss")
|
||
return
|
||
crit = rng.random() < unit["crit"]
|
||
amount = top * CRIT_MULT if crit else rng.randint(1, top)
|
||
_hurt(state, unit, target, amount, unit["damage"], t, rng, crit=False, critted=crit, swing=True)
|
||
return
|
||
attack = unit["attack"][unit["swings"] % len(unit["attack"])]
|
||
unit["swings"] += 1
|
||
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
|
||
_event(state, t, unit["id"], target["id"], "miss", 0, attack)
|
||
return
|
||
if rng.random() < target["block"]:
|
||
_event(state, t, unit["id"], target["id"], "block", 0, attack)
|
||
return
|
||
crit = rng.random() < ENEMY_CRIT
|
||
amount = (unit["max_hit"] * CRIT_MULT if crit else rng.randint(1, unit["max_hit"])) * unit["enraged"]
|
||
_hurt(state, unit, target, amount, attack, t, rng, crit=False, critted=crit, swing=True)
|
||
|
||
|
||
def _hurt(state: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
|
||
rng: random.Random, threat: float = 1.0, crit: bool = True, critted: bool = False,
|
||
swing: bool = False, label: str = "", tick: bool = False) -> None:
|
||
if not _alive(target) or amount <= 0:
|
||
return
|
||
if crit and source and source["side"] == "party" and rng.random() < source["crit"]:
|
||
amount, critted = amount * CRIT_MULT, True
|
||
if source:
|
||
for aura in source["auras"]:
|
||
if aura["kind"] == "done":
|
||
amount *= aura["mult"]
|
||
for aura in target["auras"]:
|
||
if aura["kind"] in ("taken", "vuln"):
|
||
amount *= aura["mult"]
|
||
if target["side"] == "party":
|
||
amount *= target["taken"]["magic" if school == "magic" else "physical"]
|
||
amount = max(1, round(amount))
|
||
dealt = amount
|
||
for aura in list(target["auras"]):
|
||
if aura["kind"] == "shield" and amount:
|
||
soak = min(amount, round(aura["amount"]))
|
||
aura["amount"] -= soak
|
||
amount -= soak
|
||
if aura["amount"] < 1:
|
||
target["auras"].remove(aura)
|
||
target["hp"] -= amount
|
||
if source and source["side"] == "party":
|
||
source["dealt"] += dealt
|
||
if target["side"] == "party":
|
||
target["hurt"] += amount
|
||
kind = "absorb" if not amount else "tick" if tick else "crit" if critted else "hit"
|
||
_event(state, t, source["id"] if source else None, target["id"], kind, dealt, label or school)
|
||
if target["side"] == "enemy" and source:
|
||
boost = TANK_THREAT if source.get("role") == "tank" else 1
|
||
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + dealt * threat * boost
|
||
if target["cc"] > t:
|
||
target["cc"] = 0
|
||
_event(state, t, source["id"], target["id"], "free")
|
||
if target["hp"] <= 0:
|
||
_fall(state, target, t)
|
||
|
||
|
||
def _heal(state: dict, source: dict | None, target: dict, amount: float, t: float, label: str,
|
||
tick: bool = False) -> None:
|
||
if not target or not _alive(target):
|
||
return
|
||
gain = max(0, min(round(amount), target["top"] - target["hp"]))
|
||
target["hp"] += gain
|
||
_event(state, t, source["id"] if source else target["id"], target["id"], "mend" if tick else "heal", gain, label)
|
||
if source and gain:
|
||
_heal_threat(state, source, gain)
|
||
|
||
|
||
def _heal_threat(state: dict, source: dict, amount: float) -> None:
|
||
enemies = _enemies(state)
|
||
for enemy in enemies:
|
||
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
|
||
|
||
|
||
def _fall(state: dict, unit: dict, t: float) -> None:
|
||
if unit["side"] == "enemy":
|
||
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
|
||
state["kills"].append(unit["id"])
|
||
_event(state, t, None, unit["id"], "die")
|
||
for other in _party(state):
|
||
if other["target"] == unit["id"]:
|
||
other["target"] = None
|
||
return
|
||
if unit["wind"]:
|
||
# Second Wind: the knockout that was coming turns into half health, once per outing.
|
||
unit["wind"], unit["wind_used"] = False, True
|
||
unit["hp"] = unit["top"] / 2
|
||
_event(state, t, unit["id"], unit["id"], "wind")
|
||
return
|
||
unit["down"] = True
|
||
_out(state, unit, "down", t)
|
||
|
||
|
||
def _out(state: dict, unit: dict, why: str, t: float) -> None:
|
||
unit["out"], unit["out_at"], unit["cast"], unit["auras"] = why, t, 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
|