Active play and the Watchtower Depths dungeon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 12:05:23 +01:00
parent db1a00e8aa
commit 9d61de2012
9 changed files with 790 additions and 56 deletions

137
combat.py
View file

@ -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: