2026-10-24 09:49:21 +01:00
|
|
|
|
"""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.
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"shield_bash": {"name": "Shield Bash", "needs": "shield", "target": "enemy", "cd": 10,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"challenge": {"name": "Challenge", "target": "enemy", "cd": 8, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
|
|
|
|
|
|
"tip": "An enemy turns on you for 3s."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"sweeping_cut": {"name": "Sweeping Cut", "target": "aoe", "cd": 10, "threat": 2,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [{"t": "aoe", "c": 0.8}], "auto": {"r": "aoe", "n": 2},
|
|
|
|
|
|
"tip": "Hits every enemy. Double threat."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"shield_wall": {"name": "Shield Wall", "needs": "shield", "target": "self", "cd": 30, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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.
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"crushing_blow": {"name": "Crushing Blow", "target": "enemy", "cd": 16, "cast": 1.5,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_dmg(1.5)], "auto": {"r": "always"}, "tip": "Wind up 1.5s, hit for 1.5×."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"concuss": {"name": "Concuss", "target": "enemy", "cd": 15, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [{"t": "stun", "dur": 3}], "auto": {"r": "stop", "else": True},
|
|
|
|
|
|
"tip": "Stun 3s, stop a cast. Bosses only lose the cast."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"earthshaker": {"name": "Earthshaker", "target": "aoe", "cd": 12,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"unstoppable": {"name": "Unstoppable", "target": "self", "cd": 45, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_aura("done", 12, "self", mult=1.2)], "auto": {"r": "big"},
|
|
|
|
|
|
"tip": "+20% damage for 12s."},
|
|
|
|
|
|
# Staff: magic, and keeping everyone standing.
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"bolt": {"name": "Bolt", "target": "enemy", "cd": 10, "cast": 1.5,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_dmg(1.1)], "auto": {"r": "always"}, "tip": "Cast 1.5s, magic hit for 1.1×."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"mend": {"name": "Mend", "target": "ally", "cd": 10, "cast": 2,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [{"t": "heal", "c": 2.0}], "auto": {"r": "heal", "pct": 0.5},
|
|
|
|
|
|
"tip": "Cast 2s, heal someone for 2× your hit."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"ward": {"name": "Ward", "target": "ally", "cd": 20,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"thornbind": {"name": "Thornbind", "target": "enemy", "cd": 20,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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.
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"aimed_shot": {"name": "Aimed Shot", "target": "enemy", "cd": 10, "cast": 2,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"pinning_shot": {"name": "Pinning Shot", "target": "enemy", "cd": 12, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_dmg(0.5), {"t": "interrupt"}], "auto": {"r": "stop"},
|
|
|
|
|
|
"tip": "Hit and stop a cast."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"barbed_arrow": {"name": "Barbed Arrow", "target": "enemy", "cd": 12,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"volley": {"name": "Volley", "target": "aoe", "cd": 10, "cost": 5,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2},
|
|
|
|
|
|
"tip": "Hits every enemy. 5 arrows."},
|
|
|
|
|
|
# Trades: one each, whatever the weapon.
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"find_the_seam": {"name": "Find the Seam", "target": "enemy", "cd": 30, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"barricade": {"name": "Barricade", "target": "party", "cd": 40, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"effects": [_aura("taken", 8, "party", mult=0.75)], "auto": {"r": "danger", "pct": 0.6},
|
|
|
|
|
|
"tip": "Everyone takes 25% less damage for 8s."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"hearty_meal": {"name": "Hearty Meal", "target": "party", "cd": 30,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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."},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"mark_prey": {"name": "Mark Prey", "target": "enemy", "cd": 30, "offgcd": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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 = {
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"heavy_slam": {"name": "Heavy Slam", "cast": 2.0, "every": 14, "first": 5, "interrupt": False,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"target": "target", "effect": ("hit", 1.6, "blunt")},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"lunge": {"name": "Lunge", "cast": 0, "every": 10, "first": 4, "interrupt": False,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"target": "soft", "effect": ("hit", 1.2, None)},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"hex_bolt": {"name": "Hex Bolt", "cast": 2.5, "every": 12, "first": 3, "interrupt": True,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"target": "target", "effect": ("hit", 1.8, "magic")},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"sweep": {"name": "Sweep", "cast": 1.5, "every": 16, "first": 8, "interrupt": False,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"target": "all", "effect": ("aoe", 0.8, "slash")},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"fire_breath": {"name": "Fire Breath", "cast": 3.0, "every": 18, "first": 6, "interrupt": False,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"target": "all", "effect": ("aoe", 1.4, "magic")},
|
2026-10-24 10:01:41 +01:00
|
|
|
|
"tail_swipe": {"name": "Tail Swipe", "cast": 0, "every": 12, "first": 7, "interrupt": False,
|
2026-10-24 09:49:21 +01:00
|
|
|
|
"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
|