Active play and the Watchtower Depths dungeon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db1a00e8aa
commit
9d61de2012
9 changed files with 790 additions and 56 deletions
137
combat.py
137
combat.py
|
|
@ -5,8 +5,12 @@ steps out, so the page can replay it. Each slice draws from a random seeded by t
|
|||
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.
|
||||
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
|
||||
|
|
@ -18,9 +22,12 @@ 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
|
||||
|
|
@ -159,6 +166,15 @@ ENEMY_ABILITIES = {
|
|||
"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")},
|
||||
}
|
||||
ARCHETYPE_ABILITIES = {"brute": ["heavy_slam"], "skirmisher": ["lunge"], "caster": ["hex_bolt"],
|
||||
"elite": ["heavy_slam"], "boss": ["heavy_slam", "sweep"], "dragon": ["fire_breath", "tail_swipe"]}
|
||||
|
|
@ -178,7 +194,9 @@ def _member_unit(member: dict) -> dict:
|
|||
"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}
|
||||
"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:
|
||||
|
|
@ -190,7 +208,10 @@ def _enemy_unit(index: int, foe: dict) -> dict:
|
|||
"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"], [])]}
|
||||
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:
|
||||
|
|
@ -238,7 +259,7 @@ def _party(state: dict) -> list[dict]:
|
|||
|
||||
|
||||
def _enemies(state: dict) -> list[dict]:
|
||||
return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"]]
|
||||
return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"] and not u["dormant"]]
|
||||
|
||||
|
||||
# --- a slice -------------------------------------------------------------------
|
||||
|
|
@ -261,6 +282,7 @@ def _step(state: dict, t: float, rng: random.Random) -> str | None:
|
|||
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:
|
||||
|
|
@ -275,6 +297,16 @@ def _step(state: dict, t: float, rng: random.Random) -> str | None:
|
|||
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()):
|
||||
|
|
@ -286,8 +318,22 @@ def _step(state: dict, t: float, rng: random.Random) -> str | None:
|
|||
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"] if unit["side"] == "enemy" else unit["in"] and not unit["out"]
|
||||
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:
|
||||
|
|
@ -309,6 +355,35 @@ def _tick(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
|||
|
||||
# --- 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"
|
||||
if ab.get("cost", 0) > unit["arrows"]:
|
||||
return "arrows"
|
||||
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
|
||||
|
|
@ -316,13 +391,23 @@ def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
|||
if unit["stun"] > t or unit["cast"]:
|
||||
return
|
||||
_pick_target(state, unit, t)
|
||||
watching = t < unit["watch"]
|
||||
queued = unit["queued"]
|
||||
if queued:
|
||||
ab = ABILITIES[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 why == "arrows" or t - queued["at"] > QUEUE:
|
||||
unit["queued"] = None
|
||||
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"]:
|
||||
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:
|
||||
|
|
@ -332,8 +417,31 @@ def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
|
|||
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
|
||||
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 ab.get("cost"):
|
||||
unit["arrows"] -= ab["cost"]
|
||||
unit["shot"] += ab["cost"]
|
||||
|
|
@ -462,8 +570,9 @@ def _share(unit: dict) -> float:
|
|||
|
||||
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:
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue