First slice: classes, combat, questing, Deadmines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
71dd99795a
17 changed files with 4522 additions and 0 deletions
798
combat.py
Normal file
798
combat.py
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
"""The fight engine: a party against a pack, stepped in fixed slices of time.
|
||||
|
||||
A fight is one JSON-able dict. It only ever moves forward through `advance`, which
|
||||
replays the slices between its last step and now; each slice draws from a random
|
||||
seeded by the fight and the slice number, so the same presses give the same fight.
|
||||
|
||||
Every ability is either on auto (a rule decides when, on a longer cooldown) or in
|
||||
the player's hands. A player's hands only count while they're watching: once they
|
||||
look away, everything they hold falls back to auto.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from content import ABILITIES, BOSS_DMG, BOSS_HP, ELITE_DMG, ELITE_HP, ENEMY_ABILITIES, MOBS
|
||||
|
||||
STEP = 0.25 # seconds per slice
|
||||
GCD = 1.5
|
||||
ENERGY_GCD = 1.0 # rogues
|
||||
AUTO_DELAY = 0.5 # auto waits this long after it could act
|
||||
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
|
||||
AUTO_REACT = 1.0 # seconds into a cast before auto interrupts it
|
||||
WATCH = 12 # seconds since a player last looked that their manual choices still hold
|
||||
QUEUE = 1.5 # a press waits this long for the global cooldown before it's dropped
|
||||
CRIT, CRIT_MULT = 0.05, 1.5
|
||||
SPREAD = 0.1
|
||||
HEAL_THREAT = 0.5
|
||||
STICKY = 1.1 # a mob only turns when someone has 10% more threat than its target
|
||||
TANK_THREAT = 1.8
|
||||
PET_THREAT = 0.5
|
||||
PET_TANK_THREAT = 3.0
|
||||
MANA_REGEN = 0.02 # share of max mana per second
|
||||
ENERGY_REGEN = 10
|
||||
RAGE_PER_SWING = 7
|
||||
RAGE_TAKEN = 40 # rage for taking a whole health bar of damage
|
||||
MAX_TIME = 300
|
||||
LOG = 40
|
||||
EVENTS = 60
|
||||
|
||||
|
||||
def mob_hp(level: int) -> float:
|
||||
return 35 + 15 * level + 0.8 * level * level
|
||||
|
||||
|
||||
def mob_swing(level: int) -> float:
|
||||
"""Damage of an ordinary mob's 2s swing at this level."""
|
||||
return 3 + 1.5 * level
|
||||
|
||||
|
||||
# --- building a fight ------------------------------------------------------
|
||||
|
||||
def mob_unit(uid: str, mob_id: str, level: int, hp_scale: float = 1.0, dmg_scale: float = 1.0) -> dict:
|
||||
mob = MOBS[mob_id]
|
||||
hp = mob_hp(level) * mob.get("hp", 1)
|
||||
swing = mob_swing(level) * mob.get("dmg", 1)
|
||||
if mob.get("elite"):
|
||||
hp, swing = hp * ELITE_HP, swing * ELITE_DMG
|
||||
if mob.get("boss"):
|
||||
hp, swing = hp * BOSS_HP, swing * BOSS_DMG
|
||||
hp = max(1, round(hp * hp_scale))
|
||||
return {
|
||||
"id": uid, "mob": mob_id, "name": mob["name"], "side": "enemy", "kind": mob["kind"], "level": level,
|
||||
"hp": hp, "max_hp": hp, "power": 0, "armor": 0,
|
||||
"swing": {"dmg": swing * dmg_scale, "every": 2.0, "next": 0.5, "school": "phys"},
|
||||
"auras": [], "cast": None, "threat": {}, "target": None, "taunt": None,
|
||||
"stun": 0, "cc": 0, "root": 0, "dead": False,
|
||||
"boss": bool(mob.get("boss")), "elite": bool(mob.get("elite")), "rare": bool(mob.get("rare")),
|
||||
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"]} for a in mob.get("abilities", [])],
|
||||
"phases": [{**phase, "done": False} for phase in mob.get("phases", [])],
|
||||
}
|
||||
|
||||
|
||||
def new_fight(fid: str, seed: int, at: float, party: list[dict], enemies: list[dict], kind: str,
|
||||
name: str = "", scale: tuple = (1.0, 1.0)) -> dict:
|
||||
fight = {"id": fid, "seed": seed, "at": at, "t": 0.0, "n": 0, "state": "on", "kind": kind, "name": name,
|
||||
"units": {}, "log": [], "ev": [], "kills": [], "next": 0, "scale": list(scale), "ended": None}
|
||||
for unit in party + enemies:
|
||||
fight["units"][unit["id"]] = unit
|
||||
for enemy in enemies:
|
||||
_engage(fight, enemy)
|
||||
return fight
|
||||
|
||||
|
||||
def _engage(fight: dict, enemy: dict) -> None:
|
||||
"""Who a fresh mob starts on: the tank, else the first one in."""
|
||||
party = alive(fight, "party")
|
||||
for unit in party:
|
||||
enemy["threat"][unit["id"]] = 10.0 if _tanks(unit) else 0.0
|
||||
if party and not any(enemy["threat"].values()):
|
||||
enemy["threat"][party[0]["id"]] = 1.0
|
||||
|
||||
|
||||
def _tanks(unit: dict) -> bool:
|
||||
return unit.get("role") == "tank" or unit.get("pet_tank", False)
|
||||
|
||||
|
||||
def alive(fight: dict, side: str) -> list[dict]:
|
||||
return [u for u in fight["units"].values() if u["side"] == side and not u["dead"]]
|
||||
|
||||
|
||||
def players(fight: dict) -> list[dict]:
|
||||
return [u for u in fight["units"].values() if u.get("kind") == "player"]
|
||||
|
||||
|
||||
# --- the outside world's handles ------------------------------------------
|
||||
|
||||
def advance(fight: dict, now: float, watch: dict | None = None) -> None:
|
||||
"""Run every slice up to `now` (game time)."""
|
||||
end = now - fight["at"]
|
||||
watch = watch or {}
|
||||
while fight["state"] == "on" and fight["t"] + STEP <= end + 1e-9:
|
||||
_step(fight, watch)
|
||||
|
||||
|
||||
def end_time(fight: dict) -> float:
|
||||
return fight["at"] + fight["t"]
|
||||
|
||||
|
||||
def press(fight: dict, uid: str, ability: str, now: float, target: str | None = None) -> str | None:
|
||||
unit = fight["units"].get(uid)
|
||||
if fight["state"] != "on" or not unit or unit["dead"]:
|
||||
return "You're not fighting"
|
||||
if ability not in unit["abilities"]:
|
||||
return "You don't know that"
|
||||
unit["queued"] = {"ab": ability, "target": target, "at": now - fight["at"]}
|
||||
return None
|
||||
|
||||
|
||||
def set_target(fight: dict, uid: str, target: str) -> str | None:
|
||||
unit, other = fight["units"].get(uid), fight["units"].get(target)
|
||||
if not unit or not other or other["dead"]:
|
||||
return "No such target"
|
||||
if other["side"] == "enemy":
|
||||
unit["target"], unit["picked"] = target, True
|
||||
else:
|
||||
unit["ftarget"] = target
|
||||
return None
|
||||
|
||||
|
||||
# --- a slice ---------------------------------------------------------------
|
||||
|
||||
def _step(fight: dict, watch: dict) -> None:
|
||||
fight["t"] = t = round(fight["t"] + STEP, 4)
|
||||
rng = random.Random(f"{fight['seed']}:{fight['n']}")
|
||||
fight["n"] += 1
|
||||
now = fight["at"] + t
|
||||
units = list(fight["units"].values())
|
||||
for unit in units:
|
||||
if not unit["dead"]:
|
||||
_tick(fight, unit, t, rng)
|
||||
for unit in units:
|
||||
if not unit["dead"] and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
|
||||
_finish_cast(fight, unit, t, rng)
|
||||
for unit in units:
|
||||
if not unit["dead"] and unit["side"] == "party":
|
||||
seen = watch.get(unit.get("owner") or unit["id"])
|
||||
_think(fight, unit, t, rng, seen is not None and now - seen < WATCH)
|
||||
for unit in units:
|
||||
if not unit["dead"] and unit["side"] == "enemy":
|
||||
_enemy_think(fight, unit, t, rng)
|
||||
for unit in units:
|
||||
if not unit["dead"]:
|
||||
_swing(fight, unit, t, rng)
|
||||
_phases(fight, t, rng)
|
||||
_check_end(fight, t)
|
||||
|
||||
|
||||
def _tick(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||
res = unit.get("res")
|
||||
if res and res["type"] == "mana":
|
||||
res["cur"] = min(res["max"], res["cur"] + res["max"] * MANA_REGEN * STEP)
|
||||
elif res and res["type"] == "energy":
|
||||
res["cur"] = min(res["max"], res["cur"] + ENERGY_REGEN * STEP)
|
||||
for aura in list(unit["auras"]):
|
||||
if aura["kind"] in ("dot", "hot", "ground") and t >= aura["next"] - 1e-9:
|
||||
aura["next"] += aura["every"]
|
||||
source = fight["units"].get(aura["by"])
|
||||
if aura["kind"] == "dot":
|
||||
_damage(fight, source, unit, aura["amount"], aura["school"], t, rng, crit=False)
|
||||
elif aura["kind"] == "hot":
|
||||
_heal(fight, source, unit, aura["amount"], t, rng, crit=False)
|
||||
else:
|
||||
for enemy in alive(fight, "enemy"):
|
||||
if enemy["cc"] <= t:
|
||||
_damage(fight, unit, enemy, aura["amount"], aura["school"], t, rng, crit=False,
|
||||
threat=aura.get("threat", 1))
|
||||
if unit["dead"]:
|
||||
return
|
||||
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
|
||||
unit["auras"].remove(aura)
|
||||
if aura["kind"] == "enrage":
|
||||
unit["swing"]["dmg"] /= aura["mult"]
|
||||
|
||||
|
||||
# --- player side -----------------------------------------------------------
|
||||
|
||||
def _think(fight: dict, unit: dict, t: float, rng: random.Random, watching: bool) -> None:
|
||||
if unit["stun"] > t or unit["cast"]:
|
||||
return
|
||||
_pick_target(fight, unit, t)
|
||||
if unit["kind"] == "pet":
|
||||
return
|
||||
queued = unit.get("queued")
|
||||
if queued and watching:
|
||||
if t - queued["at"] > QUEUE:
|
||||
unit["queued"] = None
|
||||
else:
|
||||
why = _not_ready(unit, queued["ab"], t, manual=True)
|
||||
if why is None:
|
||||
target = _manual_target(fight, unit, queued["ab"], queued["target"], t)
|
||||
unit["queued"] = None
|
||||
if target is not False:
|
||||
_use(fight, unit, queued["ab"], target, t, rng, manual=True)
|
||||
if unit["cast"] or not ABILITIES[queued["ab"]].get("offgcd"):
|
||||
return
|
||||
elif why == "mana":
|
||||
unit["queued"] = None
|
||||
_event(fight, t, unit["id"], 0, "oom")
|
||||
elif queued:
|
||||
unit["queued"] = None
|
||||
for ab_id in unit["priority"]:
|
||||
if ab_id not in unit["abilities"] or (watching and ab_id in unit["manual"]):
|
||||
continue
|
||||
if _not_ready(unit, ab_id, t, manual=False):
|
||||
continue
|
||||
target = _rule(fight, unit, ab_id, t)
|
||||
if target is False:
|
||||
continue
|
||||
_use(fight, unit, ab_id, target, t, rng, manual=False)
|
||||
if unit["cast"] or not ABILITIES[ab_id].get("offgcd"):
|
||||
return
|
||||
|
||||
|
||||
def _not_ready(unit: dict, ab_id: str, t: float, manual: bool) -> str | None:
|
||||
ab = ABILITIES[ab_id]
|
||||
if unit["cds"].get(ab_id, 0) > t + 1e-9:
|
||||
return "cooldown"
|
||||
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
|
||||
return "gcd"
|
||||
cost = _cost(unit, ab)
|
||||
if cost and unit["res"]["cur"] < cost:
|
||||
return unit["res"]["type"]
|
||||
return None
|
||||
|
||||
|
||||
def _cost(unit: dict, ab: dict) -> float:
|
||||
cost = ab.get("cost", 0)
|
||||
if cost and cost < 1:
|
||||
return cost * unit["res"]["max"]
|
||||
return cost
|
||||
|
||||
|
||||
def _use(fight: dict, unit: dict, ab_id: str, target, t: float, rng: random.Random, manual: bool) -> None:
|
||||
ab = ABILITIES[ab_id]
|
||||
cost = _cost(unit, ab)
|
||||
if cost:
|
||||
unit["res"]["cur"] -= cost
|
||||
unit["cds"][ab_id] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
|
||||
cast = ab.get("cast", 0)
|
||||
if not ab.get("offgcd"):
|
||||
gcd = ENERGY_GCD if unit["res"] and unit["res"]["type"] == "energy" else GCD
|
||||
unit["busy"] = t + max(gcd, cast)
|
||||
if cast:
|
||||
unit["cast"] = {"ab": ab_id, "name": ab["name"], "icon": ab["icon"], "target": target,
|
||||
"start": t, "end": t + cast}
|
||||
else:
|
||||
_apply(fight, unit, ab_id, target, t, rng)
|
||||
|
||||
|
||||
def _finish_cast(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||
cast, unit["cast"] = unit["cast"], None
|
||||
if unit["side"] == "party":
|
||||
_apply(fight, unit, cast["ab"], cast["target"], t, rng)
|
||||
else:
|
||||
_enemy_effect(fight, unit, cast["ab"], cast["target"], t, rng)
|
||||
|
||||
|
||||
def _apply(fight: dict, unit: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||||
ab = ABILITIES[ab_id]
|
||||
units = fight["units"]
|
||||
target = units.get(target_id) if target_id else None
|
||||
if ab["target"] in ("enemy", "ally", "pet") and (not target or target["dead"]):
|
||||
return
|
||||
power = unit["power"]
|
||||
threat = ab.get("threat", 1)
|
||||
on = f" on {target['name']}" if target and target is not unit else ""
|
||||
if ab["target"] != "enemy" or any(e["t"] in ("cc", "interrupt", "stun", "taunt") for e in ab["effects"]):
|
||||
_log(fight, t, f"{unit['name']} · {ab['name']}{on}", "cast", unit["id"])
|
||||
for eff in ab["effects"]:
|
||||
kind = eff["t"]
|
||||
if kind == "dmg":
|
||||
_damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, threat=threat, label=ab["name"])
|
||||
elif kind == "aoe":
|
||||
for enemy in alive(fight, "enemy"):
|
||||
if enemy["cc"] <= t:
|
||||
_damage(fight, unit, enemy, eff["c"] * power, eff["school"], t, rng, threat=threat,
|
||||
label=ab["name"])
|
||||
elif kind in ("dot", "hot"):
|
||||
_add_aura(target, {"id": ab_id, "kind": kind, "by": unit["id"], "amount": eff["c"] * power,
|
||||
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||||
"school": eff.get("school"), "name": ab["name"], "icon": ab["icon"]})
|
||||
elif kind == "ground":
|
||||
_add_aura(unit, {"id": ab_id, "kind": "ground", "by": unit["id"], "amount": eff["c"] * power,
|
||||
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||||
"school": eff["school"], "threat": threat, "name": ab["name"], "icon": ab["icon"]})
|
||||
elif kind == "heal":
|
||||
_heal(fight, unit, target, eff["c"] * power, t, rng, label=ab["name"])
|
||||
elif kind == "shield":
|
||||
if any(a["kind"] == "weakened" for a in target["auras"]):
|
||||
continue
|
||||
_add_aura(target, {"id": ab_id, "kind": "shield", "by": unit["id"], "amount": eff["c"] * power,
|
||||
"until": t + eff["dur"], "name": ab["name"], "icon": ab["icon"]})
|
||||
_add_aura(target, {"id": "weakened_soul", "kind": "weakened", "by": unit["id"], "until": t + 15,
|
||||
"name": "Weakened Soul", "icon": "🩹"})
|
||||
_heal_threat(fight, unit, eff["c"] * power * 0.5)
|
||||
elif kind == "aura":
|
||||
holder = target or unit
|
||||
spec = {k: v for k, v in eff.items() if k not in ("t", "dur")}
|
||||
_add_aura(holder, {**spec, "id": ab_id, "by": unit["id"], "until": t + eff["dur"]})
|
||||
elif kind == "taunt":
|
||||
top = max(target["threat"].values(), default=0)
|
||||
target["threat"][unit["id"]] = top * STICKY + 1
|
||||
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
|
||||
target["target"] = unit["id"]
|
||||
elif kind == "stun":
|
||||
_stop_cast(fight, target, unit, t, force=not target["boss"])
|
||||
if not target["boss"]:
|
||||
target["stun"] = t + eff["dur"]
|
||||
elif kind == "interrupt":
|
||||
_stop_cast(fight, target, unit, t)
|
||||
elif kind == "cc":
|
||||
if target["boss"]:
|
||||
_log(fight, t, f"{target['name']} is immune", "miss")
|
||||
continue
|
||||
_stop_cast(fight, target, unit, t, force=True, quiet=True)
|
||||
target["cc"] = t + eff["dur"]
|
||||
target["cc_icon"] = ab["icon"]
|
||||
for other in alive(fight, "party"):
|
||||
if other["target"] == target["id"] and not other.get("picked"):
|
||||
other["target"] = None
|
||||
elif kind == "freeze":
|
||||
for enemy in alive(fight, "enemy"):
|
||||
if not enemy["boss"]:
|
||||
enemy["root"] = t + eff["dur"]
|
||||
elif kind == "combo":
|
||||
unit["combo"] = min(5, unit["combo"] + eff["n"])
|
||||
elif kind == "finisher":
|
||||
points, unit["combo"] = unit["combo"], 0
|
||||
if points:
|
||||
_damage(fight, unit, target, eff["c"] * power * points, "phys", t, rng, threat=threat,
|
||||
label=ab["name"])
|
||||
elif kind == "drain":
|
||||
dealt = _damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, label=ab["name"])
|
||||
_heal(fight, unit, unit, dealt, t, rng, crit=False)
|
||||
|
||||
|
||||
def _stop_cast(fight: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
|
||||
cast = enemy.get("cast")
|
||||
if not cast:
|
||||
return
|
||||
if not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]:
|
||||
return
|
||||
enemy["cast"] = None
|
||||
if not quiet:
|
||||
_log(fight, t, f"{by['name']} interrupts {enemy['name']}'s {cast['name']}", "interrupt", by["id"])
|
||||
_event(fight, t, enemy["id"], 0, "interrupt")
|
||||
|
||||
|
||||
def _add_aura(unit: dict, aura: dict) -> None:
|
||||
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
|
||||
unit["auras"].append(aura)
|
||||
|
||||
|
||||
def _pet_of(fight: dict, unit: dict) -> dict | None:
|
||||
return next((u for u in alive(fight, "party") if u.get("owner") == unit["id"]), None)
|
||||
|
||||
|
||||
def _pick_target(fight: dict, unit: dict, t: float) -> None:
|
||||
current = fight["units"].get(unit.get("target") or "")
|
||||
if current and not current["dead"] and (unit.get("picked") or current["cc"] <= t):
|
||||
return
|
||||
unit["picked"] = False
|
||||
enemies = alive(fight, "enemy")
|
||||
free = [e for e in enemies if e["cc"] <= t] or enemies
|
||||
if not free:
|
||||
unit["target"] = None
|
||||
return
|
||||
owner = fight["units"].get(unit.get("owner") or "")
|
||||
if unit["kind"] == "pet" and not unit.get("pet_tank") and owner and not owner["dead"] and owner.get("target"):
|
||||
unit["target"] = owner["target"]
|
||||
return
|
||||
if _tanks(unit):
|
||||
tanks = {u["id"] for u in alive(fight, "party") if _tanks(u)}
|
||||
loose = [e for e in free if e["target"] not in tanks]
|
||||
unit["target"] = (loose or sorted(free, key=lambda e: e["hp"] / e["max_hp"]))[0]["id"]
|
||||
return
|
||||
tank = next((u for u in alive(fight, "party") if _tanks(u) and u.get("target")), None)
|
||||
focus = fight["units"].get(tank["target"]) if tank else None
|
||||
if focus and not focus["dead"] and focus["cc"] <= t:
|
||||
unit["target"] = focus["id"]
|
||||
else:
|
||||
unit["target"] = min(free, key=lambda e: e["hp"] / e["max_hp"])["id"]
|
||||
|
||||
|
||||
def _hurt(unit: dict) -> float:
|
||||
return unit["hp"] / unit["max_hp"]
|
||||
|
||||
|
||||
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
|
||||
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
|
||||
|
||||
|
||||
def _rule(fight: dict, unit: dict, ab_id: str, t: float):
|
||||
"""Auto's decision: the target to use this on, None for no target, False for not now."""
|
||||
ab = ABILITIES[ab_id]
|
||||
rule = ab["auto"]
|
||||
r = rule["r"]
|
||||
enemies = alive(fight, "enemy")
|
||||
party = alive(fight, "party")
|
||||
target = fight["units"].get(unit.get("target") or "")
|
||||
if target and target["dead"]:
|
||||
target = None
|
||||
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
|
||||
if rule.get("save") and unit.get("role") == "healer" and len(players(fight)) > 1 and \
|
||||
unit["res"]["cur"] < rule["save"] * unit["res"]["max"]:
|
||||
return False # a group's healer keeps their mana for healing
|
||||
if r == "always":
|
||||
if ab["target"] == "enemy":
|
||||
return target["id"] if target else False
|
||||
return None
|
||||
if r == "dot":
|
||||
return target["id"] if target and not _has(target, ab_id, unit["id"]) and _hurt(target) > 0.2 else False
|
||||
if r == "heal":
|
||||
low = min(party, key=_hurt, default=None)
|
||||
return low["id"] if low and _hurt(low) < rule["pct"] else False
|
||||
if r == "hot":
|
||||
hurt = sorted((u for u in party if _hurt(u) < rule["pct"] and not _has(u, ab_id, unit["id"])), key=_hurt)
|
||||
return hurt[0]["id"] if hurt else False
|
||||
if r == "shield":
|
||||
targeted = {e["target"] for e in enemies}
|
||||
hurt = [u for u in party if not any(a["kind"] in ("shield", "weakened") for a in u["auras"])
|
||||
and (u["id"] in targeted and _hurt(u) < 0.95 or _hurt(u) < rule["pct"])]
|
||||
return min(hurt, key=_hurt)["id"] if hurt else False
|
||||
if r == "self_below":
|
||||
return target["id"] if target and _hurt(unit) < rule["pct"] else False
|
||||
if r == "taunt":
|
||||
if not _tanks(unit):
|
||||
return False
|
||||
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
|
||||
and not _tanks(fight["units"][e["target"]]) and t - e.get("since", 0) >= AUTO_REACT]
|
||||
return loose[0]["id"] if loose else False
|
||||
if r == "aoe":
|
||||
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
|
||||
if r == "interrupt":
|
||||
casting = [e for e in enemies if e["cast"] and ENEMY_ABILITIES[e["cast"]["ab"]]["interrupt"]
|
||||
and t - e["cast"]["start"] >= AUTO_REACT]
|
||||
return casting[0]["id"] if casting else False
|
||||
if r == "combo":
|
||||
return target["id"] if target and unit["combo"] >= rule["n"] else False
|
||||
if r == "cc":
|
||||
free = [e for e in enemies if e["cc"] <= t]
|
||||
if len(free) < 2:
|
||||
return False
|
||||
focus = {u.get("target") for u in party if _tanks(u)} | {unit.get("target")}
|
||||
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _hurt(e) > 0.5]
|
||||
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
|
||||
if r == "peel":
|
||||
return None if on_me and not _tanks(unit) and any(not e["boss"] for e in on_me) else False
|
||||
if r == "tanking":
|
||||
return None if on_me and (any(e["boss"] or e["elite"] for e in on_me) or _hurt(unit) < rule["pct"]) else False
|
||||
if r == "danger":
|
||||
return None if on_me and _hurt(unit) < rule["pct"] else False
|
||||
if r == "buff":
|
||||
return None if not _has(unit, ab_id) else False
|
||||
if r == "boss":
|
||||
pet = _pet_of(fight, unit)
|
||||
if not pet or not target:
|
||||
return False
|
||||
return pet["id"] if target["boss"] or target["elite"] or target["rare"] else False
|
||||
return False
|
||||
|
||||
|
||||
def _manual_target(fight: dict, unit: dict, ab_id: str, given: str | None, t: float):
|
||||
ab = ABILITIES[ab_id]
|
||||
units = fight["units"]
|
||||
kind = ab["target"]
|
||||
if kind in ("self", "aoe"):
|
||||
return None
|
||||
if kind == "pet":
|
||||
pet = _pet_of(fight, unit)
|
||||
return pet["id"] if pet else False
|
||||
if kind == "ally":
|
||||
for choice in (given, unit.get("ftarget")):
|
||||
other = units.get(choice or "")
|
||||
if other and not other["dead"] and other["side"] == "party":
|
||||
return other["id"]
|
||||
return min(alive(fight, "party"), key=_hurt)["id"]
|
||||
other = units.get(given or "")
|
||||
if other and not other["dead"] and other["side"] == "enemy":
|
||||
return other["id"]
|
||||
smart = _rule(fight, unit, ab_id, t) if ab["auto"]["r"] in ("interrupt", "cc", "taunt") else False
|
||||
if smart:
|
||||
return smart
|
||||
target = units.get(unit.get("target") or "")
|
||||
return target["id"] if target and not target["dead"] else False
|
||||
|
||||
|
||||
# --- enemy side ------------------------------------------------------------
|
||||
|
||||
def _enemy_think(fight: dict, enemy: dict, t: float, rng: random.Random) -> None:
|
||||
if enemy["stun"] > t or enemy["cc"] > t:
|
||||
enemy["cast"] = None
|
||||
return
|
||||
_enemy_target(fight, enemy, t)
|
||||
if enemy["cast"] or not enemy["target"]:
|
||||
return
|
||||
for slot in enemy["abil"]:
|
||||
if t < slot["next"]:
|
||||
continue
|
||||
spec = ENEMY_ABILITIES[slot["id"]]
|
||||
target = _enemy_ability_target(fight, enemy, spec, rng)
|
||||
if target is False:
|
||||
slot["next"] = t + 2
|
||||
continue
|
||||
slot["next"] = t + spec["every"]
|
||||
if spec["cast"]:
|
||||
enemy["cast"] = {"ab": slot["id"], "name": spec["name"], "icon": spec["icon"], "target": target,
|
||||
"start": t, "end": t + spec["cast"], "interrupt": spec["interrupt"]}
|
||||
else:
|
||||
_enemy_effect(fight, enemy, slot["id"], target, t, rng)
|
||||
return
|
||||
|
||||
|
||||
def _enemy_target(fight: dict, enemy: dict, t: float) -> None:
|
||||
units = fight["units"]
|
||||
taunt = enemy.get("taunt")
|
||||
if taunt and taunt["until"] > t and not units[taunt["by"]]["dead"]:
|
||||
enemy["target"] = taunt["by"]
|
||||
return
|
||||
table = {uid: v for uid, v in enemy["threat"].items() if not units[uid]["dead"]}
|
||||
if not table:
|
||||
party = alive(fight, "party")
|
||||
enemy["target"] = party[0]["id"] if party else None
|
||||
if party:
|
||||
enemy["threat"][party[0]["id"]] = 1.0
|
||||
return
|
||||
top = max(table, key=table.get)
|
||||
current = enemy.get("target")
|
||||
if current in table and table[top] <= table[current] * STICKY:
|
||||
return
|
||||
enemy["target"], enemy["since"] = top, t
|
||||
|
||||
|
||||
def _enemy_ability_target(fight: dict, enemy: dict, spec: dict, rng: random.Random):
|
||||
kind = spec["target"]
|
||||
if kind == "target":
|
||||
return enemy["target"]
|
||||
if kind == "all":
|
||||
return None
|
||||
if kind == "hurt_ally":
|
||||
hurt = [e for e in alive(fight, "enemy") if _hurt(e) < 0.7]
|
||||
return min(hurt, key=_hurt)["id"] if hurt else False
|
||||
if kind == "random_soft":
|
||||
soft = [u for u in alive(fight, "party") if u["kind"] == "player" and not _tanks(u)]
|
||||
return rng.choice(soft)["id"] if soft else enemy["target"]
|
||||
return False
|
||||
|
||||
|
||||
def _enemy_effect(fight: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||||
spec = ENEMY_ABILITIES[ab_id]
|
||||
kind, amount, school = spec["effect"]
|
||||
target = fight["units"].get(target_id or "")
|
||||
swing = enemy["swing"]["dmg"]
|
||||
if kind == "hit":
|
||||
if target and not target["dead"]:
|
||||
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||||
_damage(fight, enemy, target, swing * amount, school, t, rng, label=spec["name"])
|
||||
elif kind == "aoe":
|
||||
_log(fight, t, f"{enemy['name']} · {spec['name']}", "enemy", enemy["id"])
|
||||
for unit in alive(fight, "party"):
|
||||
_damage(fight, enemy, unit, swing * amount, school, t, rng, label=spec["name"])
|
||||
elif kind == "heal":
|
||||
if target and not target["dead"]:
|
||||
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||||
gain = min(target["max_hp"] - target["hp"], round(target["max_hp"] * amount))
|
||||
target["hp"] += gain
|
||||
_event(fight, t, target["id"], gain, "heal")
|
||||
elif kind == "disarm":
|
||||
if target and not target["dead"]:
|
||||
_log(fight, t, f"{enemy['name']} disarms {target['name']}", "enemy", enemy["id"])
|
||||
_add_aura(target, {"id": "disarm", "kind": "disarm", "by": enemy["id"], "until": t + amount,
|
||||
"name": "Disarmed", "icon": "🫳"})
|
||||
|
||||
|
||||
def _phases(fight: dict, t: float, rng: random.Random) -> None:
|
||||
for enemy in alive(fight, "enemy"):
|
||||
for phase in enemy["phases"]:
|
||||
if phase["done"] or _hurt(enemy) > phase["at"]:
|
||||
continue
|
||||
phase["done"] = True
|
||||
_log(fight, t, f"{enemy['name']} yells: {phase['say']}", "yell", enemy["id"])
|
||||
for what, arg in phase["do"]:
|
||||
if what == "stun_all":
|
||||
for unit in alive(fight, "party"):
|
||||
unit["stun"] = t + arg
|
||||
unit["cast"] = None
|
||||
elif what == "enrage":
|
||||
enemy["swing"]["dmg"] *= arg
|
||||
enemy["auras"].append({"id": f"enrage{t}", "kind": "enrage", "by": enemy["id"], "mult": arg,
|
||||
"until": t + MAX_TIME, "name": "Enraged", "icon": "😡"})
|
||||
elif what == "summon":
|
||||
_summon(fight, enemy, arg, t)
|
||||
|
||||
|
||||
def _summon(fight: dict, by: dict, mobs: list, t: float) -> None:
|
||||
hp_scale, dmg_scale = fight["scale"]
|
||||
for mob_id in mobs:
|
||||
fight["next"] += 1
|
||||
unit = mob_unit(f"s{fight['next']}", mob_id, MOBS[mob_id]["lv"][1], hp_scale, dmg_scale)
|
||||
unit["swing"]["next"] = t + 1.0
|
||||
fight["units"][unit["id"]] = unit
|
||||
_engage(fight, unit)
|
||||
_log(fight, t, f"{unit['name']} joins the fight", "enemy", unit["id"])
|
||||
|
||||
|
||||
# --- shared mechanics ------------------------------------------------------
|
||||
|
||||
def _swing(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||
swing = unit.get("swing")
|
||||
if not swing or t < swing["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit["cc"] > t:
|
||||
return
|
||||
if unit["side"] == "enemy" and unit["root"] > t:
|
||||
return
|
||||
target = fight["units"].get(unit.get("target") or "")
|
||||
if not target or target["dead"]:
|
||||
return
|
||||
swing["next"] = t + swing["every"]
|
||||
amount = swing["dmg"]
|
||||
if any(a["kind"] == "disarm" for a in unit["auras"]):
|
||||
amount *= 0.5
|
||||
_damage(fight, unit, target, amount, swing["school"], t, rng, swing=True)
|
||||
res = unit.get("res")
|
||||
if res and res["type"] == "rage":
|
||||
res["cur"] = min(res["max"], res["cur"] + RAGE_PER_SWING)
|
||||
|
||||
|
||||
def _damage(fight: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
|
||||
rng: random.Random, threat: float = 1.0, crit: bool = True, swing: bool = False,
|
||||
label: str = "") -> int:
|
||||
if target["dead"] or amount <= 0:
|
||||
return 0
|
||||
critted = crit and source is not None and source["side"] == "party" and rng.random() < CRIT
|
||||
if critted:
|
||||
amount *= CRIT_MULT
|
||||
amount *= 1 + rng.uniform(-SPREAD, SPREAD)
|
||||
if source:
|
||||
for aura in source["auras"]:
|
||||
if aura["kind"] == "done":
|
||||
amount *= aura["mult"]
|
||||
for aura in target["auras"]:
|
||||
if aura["kind"] == "vuln" and aura["school"] == school:
|
||||
amount *= aura["mult"]
|
||||
elif aura["kind"] == "taken" and aura["school"] in (school, "all"):
|
||||
amount *= aura["mult"]
|
||||
if school == "phys" and target["side"] == "party" and source:
|
||||
armor = target["armor"]
|
||||
amount *= 1 - armor / (armor + 400 + 85 * source["level"])
|
||||
amount = max(1, round(amount))
|
||||
dealt = amount
|
||||
for aura in list(target["auras"]):
|
||||
if aura["kind"] == "shield" and amount:
|
||||
soak = min(amount, round(aura["amount"]))
|
||||
aura["amount"] -= soak
|
||||
amount -= soak
|
||||
if aura["amount"] < 1:
|
||||
target["auras"].remove(aura)
|
||||
target["hp"] -= amount
|
||||
_event(fight, t, target["id"], dealt, "crit" if critted else ("absorb" if not amount else "dmg"),
|
||||
source["id"] if source else None, label)
|
||||
if target["side"] == "enemy" and source:
|
||||
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + \
|
||||
dealt * threat * source.get("threat_mult", 1)
|
||||
if target["cc"] > t:
|
||||
target["cc"] = 0
|
||||
_log(fight, t, f"{target['name']} breaks free", "miss", target["id"])
|
||||
res = target.get("res")
|
||||
if res and res["type"] == "rage" and amount:
|
||||
res["cur"] = min(res["max"], res["cur"] + amount / target["max_hp"] * RAGE_TAKEN)
|
||||
if swing and source and source["side"] == "enemy":
|
||||
for aura in list(target["auras"]):
|
||||
if aura["kind"] == "reflect":
|
||||
aura["charges"] -= 1
|
||||
if aura["charges"] <= 0:
|
||||
target["auras"].remove(aura)
|
||||
_damage(fight, target, source, aura["c"] * target["power"], aura["school"], t, rng,
|
||||
threat=2.0, label=aura["name"])
|
||||
break
|
||||
if target["hp"] <= 0:
|
||||
_die(fight, target, t)
|
||||
return dealt
|
||||
|
||||
|
||||
def _heal(fight: dict, source: dict | None, target: dict, amount: float, t: float, rng: random.Random,
|
||||
crit: bool = True, label: str = "") -> int:
|
||||
if target["dead"] or amount <= 0:
|
||||
return 0
|
||||
critted = crit and rng.random() < CRIT
|
||||
if critted:
|
||||
amount *= CRIT_MULT
|
||||
amount = round(amount * (1 + rng.uniform(-SPREAD, SPREAD)))
|
||||
gain = max(0, min(amount, target["max_hp"] - target["hp"]))
|
||||
target["hp"] += gain
|
||||
_event(fight, t, target["id"], amount, "heal", source["id"] if source else None, label)
|
||||
if source and gain:
|
||||
_heal_threat(fight, source, gain)
|
||||
return gain
|
||||
|
||||
|
||||
def _heal_threat(fight: dict, source: dict, amount: float) -> None:
|
||||
enemies = alive(fight, "enemy")
|
||||
for enemy in enemies:
|
||||
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
|
||||
|
||||
|
||||
def _die(fight: dict, unit: dict, t: float) -> None:
|
||||
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
|
||||
_log(fight, t, f"{unit['name']} dies", "death", unit["id"])
|
||||
if unit["side"] == "enemy":
|
||||
fight["kills"].append(unit["mob"])
|
||||
for other in alive(fight, "party"):
|
||||
if other.get("target") == unit["id"]:
|
||||
other["target"], other["picked"] = None, False
|
||||
for mob_id in MOBS[unit["mob"]].get("on_death", []):
|
||||
_summon(fight, unit, [mob_id], t)
|
||||
else:
|
||||
for enemy in alive(fight, "enemy"):
|
||||
enemy["threat"].pop(unit["id"], None)
|
||||
if enemy["target"] == unit["id"]:
|
||||
enemy["target"] = None
|
||||
|
||||
|
||||
def _check_end(fight: dict, t: float) -> None:
|
||||
if not alive(fight, "enemy"):
|
||||
fight["state"] = "won"
|
||||
elif not any(not u["dead"] for u in players(fight)):
|
||||
fight["state"] = "lost"
|
||||
elif t >= MAX_TIME:
|
||||
fight["state"] = "lost"
|
||||
_log(fight, t, "The fight drags on too long. You fall back.", "death")
|
||||
if fight["state"] != "on":
|
||||
fight["ended"] = fight["at"] + t
|
||||
|
||||
|
||||
def _log(fight: dict, t: float, text: str, kind: str, who: str | None = None) -> None:
|
||||
fight["log"].append({"t": t, "text": text, "kind": kind, "who": who})
|
||||
del fight["log"][:-LOG]
|
||||
|
||||
|
||||
def _event(fight: dict, t: float, uid: str, amount: float, kind: str, source: str | None = None,
|
||||
label: str = "") -> None:
|
||||
fight["ev"].append({"t": t, "u": uid, "n": round(amount), "k": kind, "s": source, "l": label})
|
||||
del fight["ev"][:-EVENTS]
|
||||
|
||||
|
||||
# --- what the client sees --------------------------------------------------
|
||||
|
||||
def view(fight: dict) -> dict:
|
||||
units = []
|
||||
for unit in fight["units"].values():
|
||||
item = {key: unit.get(key) for key in ("id", "name", "side", "kind", "cls", "role", "level", "hp",
|
||||
"max_hp", "target", "boss", "elite", "rare", "dead", "owner",
|
||||
"combo", "busy", "icon", "mob")}
|
||||
item["hp"] = max(0, round(unit["hp"]))
|
||||
if unit.get("res"):
|
||||
item["res"] = {"type": unit["res"]["type"], "cur": round(unit["res"]["cur"]), "max": unit["res"]["max"]}
|
||||
if unit["cast"]:
|
||||
item["cast"] = {k: unit["cast"].get(k) for k in ("name", "icon", "start", "end", "target", "interrupt")}
|
||||
item["auras"] = [{"icon": a.get("icon"), "name": a.get("name"), "until": round(a["until"], 2),
|
||||
"kind": a["kind"], "mine": a["by"]} for a in unit["auras"] if a.get("icon")]
|
||||
t = fight["t"]
|
||||
item["cc"] = unit.get("cc_icon") if unit["cc"] > t else None
|
||||
item["stun"] = unit["stun"] > t
|
||||
item["root"] = unit.get("root", 0) > t
|
||||
if unit["side"] == "enemy":
|
||||
table = {k: v for k, v in unit["threat"].items() if not fight["units"][k]["dead"]}
|
||||
top = max(table.values(), default=0) or 1
|
||||
item["threat"] = sorted(([k, round(v / top, 2)] for k, v in table.items() if v > 0),
|
||||
key=lambda kv: -kv[1])[:4]
|
||||
if unit.get("kind") == "player":
|
||||
item["cds"] = {k: round(v, 2) for k, v in unit["cds"].items() if v > t}
|
||||
item["manual"] = unit["manual"]
|
||||
item["abilities"] = unit["abilities"]
|
||||
item["ftarget"] = unit.get("ftarget")
|
||||
item["queued"] = (unit.get("queued") or {}).get("ab")
|
||||
units.append(item)
|
||||
return {"id": fight["id"], "t": fight["t"], "at": fight["at"], "state": fight["state"], "kind": fight["kind"],
|
||||
"name": fight["name"], "units": units, "log": fight["log"][-14:], "ev": fight["ev"][-30:]}
|
||||
Loading…
Add table
Add a link
Reference in a new issue