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

355
game.py
View file

@ -987,6 +987,83 @@ COMBAT_ACTIONS = [
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]),
]
# --- dungeons ----------------------------------------------------------------
# A run of pulls for a group of two to four, in order, ending in the area's boss. Built for four:
# a smaller group meets lighter packs. Trash a group can clear on auto; the bosses it can't. They
# cast what has to be interrupted, hit what has to be braced for, call in help, and lose patience.
DUNGEON_SIZE = 4 # the most in a run
DUNGEON_WIPES = 3 # on one pull, and the run is over
DUNGEON_AWAY = 90 # real seconds without looking before a run waits for you
TRASH = (1.5, 1.5) # a pull's HP (and XP) and hits against the open world's
MINI_BOSS = 5.0 # a mini-boss's HP (and XP, and loot odds) against its race's elite
FINAL_BOSS = (2.5, 1.3) # the area boss's (and its adds') HP and hits against the open-world one
def dungeon_scale(size: int) -> tuple[float, float]:
"""HP and hits of a pull for a group this size."""
return 0.25 * size, 0.4 + 0.15 * size
def _dungeon_foe(action_id: str, variant: str, hp: float = 1.0, hits: float = 1.0, loot: float | None = None,
**extra) -> dict:
"""One of a race's variants, as a dungeon enemy: `hp` times as tough (and worth as much more XP),
hitting `hits` times as hard. It drops what its race does, `loot` (else `hp`) times as often."""
action = next(a for a in COMBAT_ACTIONS if a["id"] == action_id)
foe = next(v for v in action["enemy"]["variants"] if v["name"] == variant)
return {**{key: foe[key] for key in ("name", "archetype", "level", "interval", "accuracy", "evasion",
"attack", "vulnerable", "resists")},
"boss": extra.pop("boss", False), "hp": round(foe["hp"] * hp), "max_hit": max(1, round(foe["max_hit"] * hits)),
"xp": foe["xp"] * hp, "share": hp if loot is None else loot, "loot": action_id, **extra}
DUNGEONS = {
"dungeon_watchtower": {
"name": "Watchtower Depths", "area": "Old Watchtower", "tier": 1, "level": 20, "bonus_gold": 100,
"pulls": [
("Gatehouse", [_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1), _dungeon_foe("fight_clay_golems", "Mud shaper", *TRASH, 1),
_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1)]),
("Broken stair", [_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1), _dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1),
_dungeon_foe("fight_harpies", "Harpy windcaster", *TRASH, 1)]),
("Clay colossus", [_dungeon_foe("fight_clay_golems", "Clay colossus", MINI_BOSS, 3.0, boss=True,
abilities=["stone_curse", "shatter"], enrage_at=150)]),
("Barracks", [_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Wisp", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1)]),
("Troll den", [_dungeon_foe("fight_cave_trolls", "Troll bruiser", *TRASH, 1), _dungeon_foe("fight_cave_trolls", "Troll rockhurler", *TRASH, 1),
_dungeon_foe("fight_cave_trolls", "Troll hexer", *TRASH, 1)]),
("Harpy matriarch", [_dungeon_foe("fight_harpies", "Harpy matriarch", MINI_BOSS, 5.5, boss=True,
abilities=["shriek", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "To me, sisters!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True),
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True)]),
("Crypt", [_dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1)]),
("Watchtower ogre", [_dungeon_foe("fight_ruins", "Watchtower ogre", FINAL_BOSS[0], FINAL_BOSS[1], 2,
boss=True, abilities=["stone_curse", "ground_pound", "heavy_slam"],
enrage_at=210, phases=[{"at": 0.5, "say": "Crush them!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True)]),
],
},
}
def _dungeon_actions() -> list[dict]:
actions = []
for key, spec in DUNGEONS.items():
bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]]
variants = [{**foe, "weight": 1} for foe in bosses]
actions.append(_action(key, "combat", spec["name"], spec["level"], RESPAWN,
round(sum(foe["xp"] for _name, pack in spec["pulls"] for foe in pack)),
enemy={"name": spec["name"], "level": spec["level"], "boss": True, "variants": variants},
group=spec["area"], dungeon=key, together=GROUP_SIZE, personal_loot=True,
gold=[spec["bonus_gold"], spec["bonus_gold"]], drops=[list(d) for d in _boss_drops(spec["tier"])],
pulls=[name for name, _pack in spec["pulls"]]))
return actions
COMBAT_ACTIONS += _dungeon_actions()
ACTIONS = [
*COMBAT_ACTIONS,
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
@ -1167,7 +1244,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 27
WORLD_VERSION = 28
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1382,6 +1459,11 @@ def migrate(world: dict, now: float | None = None) -> dict:
if world["next_event_at"] and not upcoming_event(world):
world["next_event_at"] = min(world["next_event_at"], now)
world["version"] = 27
if world["version"] < 28:
# Abilities in your own hands.
for player in world["players"].values():
player.setdefault("manual", [])
world["version"] = 28
return world
@ -1638,7 +1720,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"hp": 44, "hp_at": now,
"action": None, "stopped": None,
"last_active": now, "overflow": "city",
"perks": {"bank_slot": 0},
"perks": {"bank_slot": 0}, "manual": [],
"garrison": False,
"donated": 0,
"pinned": [], # skills pinned to the top of the sidebar
@ -1901,7 +1983,9 @@ def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
"fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion,
"arrows": player["bank"].get("arrows", 0), "mend": blessed["mend"],
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"]}
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"],
"manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [],
"watch": at + combat.WATCH * speed(world)}
FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows")
@ -1939,9 +2023,10 @@ def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
"xp": variants[i]["xp"] * share, "share": share} for i in picks]
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
def draw_fight(world: dict, players: list, action: dict, rng: random.Random, start: float | None = None) -> dict:
"""Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the
fight out."""
fight out. A fight starting at `start` (real time) holds each player's manual abilities for as
long as they were last seen watching."""
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
@ -1951,11 +2036,101 @@ def draw_fight(world: dict, players: list, action: dict, rng: random.Random) ->
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": action["enemy"]["name"],
"pack": draw_pack(action, len(players), rng), "members": members}
for member, player in zip(members, players):
member["watch"] = -1.0 if start is None else fight_time(fight, start, player["last_active"] + combat.WATCH)
return play_fight(fight)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
return draw_fight(world, [player], action, rng)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, start: float | None = None) -> dict:
return draw_fight(world, [player], action, rng, start)
def fight_time(fight: dict, start: float, now: float) -> float:
"""A real moment as seconds into a fight (after its rest) that started at `start`."""
return (now - start) * fight["pace"] - fight["rest"]
def fight_clock(world: dict, player: dict) -> tuple[dict | None, float]:
"""The fight you're in and when it (its rest included) began."""
current = player["action"] or {}
party = world["parties"].get(current.get("party"))
if party:
return party["fight"], party["start"]
return current.get("fight"), current.get("start", 0)
def _my_fight(world: dict, player: dict, now: float) -> tuple[dict, dict, float] | str:
fight, start = fight_clock(world, player)
if not fight or now >= start + fight["duration"] or not any(m["id"] == player["id"] for m in fight["members"]):
return "You're not fighting"
member = member_of(fight, player["id"])
if member["result"] in ("down", "left"):
return "You're out of this fight"
return fight, member, fight_time(fight, start, now)
def watch_fight(world: dict, player: dict, now: float) -> None:
"""You're looking: your manual abilities hold a while longer, and the fight's future is played
again with them. Nothing that's already happened changes."""
mine = _my_fight(world, player, now)
if isinstance(mine, str):
return
fight, member, t = mine
if not member["manual"] and not member["inputs"]:
return
watch = t + combat.WATCH * fight["pace"]
if watch > member["watch"] + combat.STEP:
member["watch"] = watch
play_fight(fight)
def _input(world: dict, player: dict, now: float, kind: str, *args) -> str | None:
mine = _my_fight(world, player, now)
if isinstance(mine, str):
return mine
fight, member, t = mine
if t < 0:
return "Still resting"
at = math.ceil(t / combat.STEP - 1e-9) * combat.STEP # the next slice of the fight
member["inputs"].append([at, kind, *args])
member["watch"] = max(member["watch"], t + combat.WATCH * fight["pace"])
play_fight(fight)
return None
def press(world: dict, player: dict, ability: str, now: float, target: str | None = None) -> str | None:
"""Use one of your manual abilities: as soon as it's ready, if that's soon enough."""
if ability not in combat.ABILITIES:
return "No such ability"
if ability not in player["manual"]:
return "That one's on auto"
return _input(world, player, now, "press", ability, target)
def set_fight_target(world: dict, player: dict, target: str, now: float) -> str | None:
"""Tap an enemy to fight it, or someone on your side to aim your heals at them."""
return _input(world, player, now, "target", str(target))
def set_manual(world: dict, player: dict, ability: str, manual: bool, now: float) -> str | None:
"""Take an ability into your own hands (pressed, on its real cooldown, while you watch) or give it
back to auto."""
if ability not in combat.ABILITIES:
return "No such ability"
mine = player.setdefault("manual", [])
if manual and ability not in mine:
mine.append(ability)
elif not manual and ability in mine:
mine.remove(ability)
fight = _my_fight(world, player, now)
if isinstance(fight, str):
return None
fight, member, t = fight
if t < 0:
member["manual"] = list(mine)
play_fight(fight)
return None
return _input(world, player, now, "mode", ability, manual)
def play_fight(fight: dict) -> dict:
@ -1966,11 +2141,12 @@ def play_fight(fight: dict) -> dict:
def pack_xp(fight: dict, killed_only: bool = False) -> float:
"""The pack's XP: on a loss, the enemies brought down and a third of the rest."""
if not killed_only:
return sum(foe["xp"] for foe in fight["pack"])
"""The pack's XP: the enemies brought down, and on a loss a third of the rest (but never for an
add that didn't join)."""
kills = set(fight["kills"])
return sum(foe["xp"] * (1 if f"e{i}" in kills else 1 / 3) for i, foe in enumerate(fight["pack"]))
rest = 1 / 3 if killed_only else 0
return sum(foe["xp"] * (1 if f"e{i}" in kills else 0 if foe.get("dormant") else rest)
for i, foe in enumerate(fight["pack"]))
# --- prayer -------------------------------------------------------------------
@ -2370,7 +2546,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if "enemy" in action:
outing = player["action"]
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng)
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng, outing["start"])
mine = member_of(fight, player["id"])
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
if player["prayers"] and not mine["pp"]:
@ -2397,8 +2573,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
summary["losses"] += 1
gain_xp(world, player, "combat", xp, now, summary)
outing["downs"] = outing.get("downs", 0) + 1
reason = "Fell back: the fight dragged on" if mine["result"] == "fled" else \
outing["downs"] = 0 if action.get("dungeon") else outing.get("downs", 0) + 1
reason = None if action.get("dungeon") else "Fell back: the fight dragged on" if mine["result"] == "fled" else \
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
if reason:
drop_out(world, player, now, reason)
@ -2534,7 +2710,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
if "enemy" in action and not can_start(world, player, action):
if not current.get("fight"):
regen(world, player, current["start"])
current["fight"] = simulate_fight(world, player, action, rng)
current["fight"] = simulate_fight(world, player, action, rng, current["start"])
duration = current["fight"]["duration"]
else:
duration = action_time(world, player, action)
@ -2606,8 +2782,11 @@ def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int
def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict]:
"""Everything the pack carried."""
gold, drops = 0, {}
for foe in fight["pack"]:
more, found = roll_loot(action, rng, foe["share"])
kills = set(fight["kills"])
for i, foe in enumerate(fight["pack"]):
if f"e{i}" not in kills:
continue
more, found = roll_loot(ACTION_BY_ID[foe["loot"]] if foe.get("loot") else action, rng, foe["share"])
gold += more
for item, qty in found.items():
drops[item] = drops.get(item, 0) + qty
@ -2645,6 +2824,9 @@ def joinable(world: dict, player: dict) -> bool:
if not current or "left" in current or "order" in current:
return False
action = ACTION_BY_ID[current["id"]]
if action.get("dungeon"):
party = world["parties"].get(current.get("party"))
return not party or (not party.get("run", {}).get("pull") and len(party["members"]) < DUNGEON_SIZE)
return "enemy" in action and not action.get("special")
@ -2663,6 +2845,12 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
host = world["players"].get(uid)
if not host or host is player:
return "No one to join"
run = party_of(world, host)
if run and ACTION_BY_ID[run["action"]].get("dungeon") and run is not party_of(world, player):
if run.get("run", {}).get("pull") or (run["fight"] and now >= run["start"] + run["fight"]["rest"] / run["fight"]["pace"]):
return "The run has started"
if len(run["members"]) >= DUNGEON_SIZE:
return "The group is full"
if not joinable(world, host):
return f"{host['name']} isn't fighting"
if offline_limit(world, host) <= now:
@ -2742,7 +2930,18 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
if world["parties"].get(pid) is not party:
break
members = [world["players"][uid] for uid in party["members"]]
party["fight"] = draw_fight(world, members, action, rng)
if action.get("dungeon"):
# A run waits for anyone who's looked away: it's played together.
away = [p for p in members if p["last_active"] < party["start"] - DUNGEON_AWAY]
if away:
party["start"] = max(party["start"], now)
for player in members:
player["action"]["start"] = party["start"]
break
party["fight"] = draw_pull(world, members, action, party.setdefault("run", {"pull": 0, "wipes": 0}),
rng, party["start"])
else:
party["fight"] = draw_fight(world, members, action, rng, party["start"])
end = party["start"] + party["fight"]["duration"]
late = [player for player in members if offline_limit(world, player) < min(end, now)]
if late:
@ -2764,7 +2963,7 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
if action.get("personal_loot"):
if action.get("personal_loot") and (not action.get("dungeon") or any(foe["boss"] for foe in fight["pack"])):
shares = {uid: pack_loot(action, fight, rng) for uid in winners}
else:
shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {}
@ -2778,6 +2977,105 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
for other in members:
if other is not player:
together[other["name"]] = together.get(other["name"], 0) + 1
if action.get("dungeon") and world["parties"].get(pid) is party:
_advance_run(world, party, action, fight, members, end, rng)
def draw_pull(world: dict, players: list, action: dict, run: dict, rng: random.Random, start: float) -> dict:
"""The run's next pull, sized for the group, everyone rested first."""
spec = DUNGEONS[action["dungeon"]]
name, pack = spec["pulls"][run["pull"]]
hp, hits = dungeon_scale(len(players))
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
member = fight_member(world, player)
member.update(eaten=eaten, hp_in=member["top"])
rest = max(rest, seconds)
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": name, "members": members,
"pack": [{**foe, "hp": max(1, round(foe["hp"] * hp)), "max_hit": max(1, round(foe["max_hit"] * hits)),
"phases": [dict(phase) for phase in foe.get("phases", [])]} for foe in pack],
"stage": {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"]}}
for member, player in zip(members, players):
member["watch"] = fight_time(fight, start, player["last_active"] + combat.WATCH)
return play_fight(fight)
def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: list, now: float, rng: random.Random) -> None:
"""After a pull: on to the next, or the same again after a wipe. Clearing it or wiping too often ends it."""
spec, run = DUNGEONS[action["dungeon"]], party["run"]
names = ", ".join(p["name"] for p in members)
if fight["result"] == "won":
run["pull"] += 1
run["wipes"] = 0
if run["pull"] < len(spec["pulls"]):
return
log(world, now, "party", f"{names} cleared {spec['name']}!")
for player in members:
_clear_bonus(world, player, spec, now, rng)
reason = f"Cleared {spec['name']}"
else:
run["wipes"] += 1
if run["wipes"] < DUNGEON_WIPES:
return
reason = f"Wiped {DUNGEON_WIPES} times at {spec['pulls'][run['pull']][0]}"
log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}")
del world["parties"][party["id"]]
for player in members:
stop(player, reason)
player.setdefault("away", empty_summary())["stopped"] = reason
def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None:
"""What clearing a run pays everyone in it: gold, one of the area's trophies and two of its reagents."""
summary = player.setdefault("away", empty_summary())
player["gold"] += spec["bonus_gold"]
summary["gold"] += spec["bonus_gold"]
found = [trophy(rng.choice(list(STYLES)), spec["tier"]), *(reagent(rng.choice(list(REAGENT_NOUNS)), spec["tier"])
for _ in range(2))]
for item in found:
kept = bank_add(world, player, item, 1, now, summary)
if kept:
summary["items"][item] = summary["items"].get(item, 0) + kept
def run_of(world: dict, player: dict) -> dict | None:
"""Where your group's dungeon run stands, if you're in one."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return None
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
run = party.get("run", {"pull": 0, "wipes": 0})
away = [world["players"][uid]["name"] for uid in party["members"]
if world["players"][uid]["last_active"] < party["start"] - DUNGEON_AWAY]
return {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"],
"next": spec["pulls"][min(run["pull"], len(spec["pulls"]) - 1)][0], "away": away,
"leader": party["members"][0], "size": len(party["members"])}
def pull_now(world: dict, player: dict, now: float) -> str | None:
"""The leader cuts the rest short: everyone goes in as healed as they are by now."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return "Not in a dungeon"
if party["members"][0] != player["id"]:
return "Only the leader pulls"
fight = party["fight"]
if not fight:
return "Nothing to pull yet"
rested = (now - party["start"]) * fight["pace"]
if rested >= fight["rest"]:
return "Already fighting"
rested = math.ceil(rested / combat.STEP) * combat.STEP
share = rested / fight["rest"] if fight["rest"] else 1
for member in fight["members"]:
member["hp_in"] = member["start_hp"] + (member["hp_in"] - member["start_hp"]) * share
member["eaten"] = {food: int(count * share) for food, count in member["eaten"].items() if int(count * share)}
member["watch"] += fight["rest"] - rested
fight["rest"] = rested
play_fight(fight)
return None
def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool:
@ -4728,6 +5026,7 @@ BUILDING_GLYPHS = {
ACTION_GLYPHS = {
"fight_wolves": ("wolf-head", "#9aa1ab"), "fight_scouts": ("hooded-assassin", "#a08060"),
"fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"),
"dungeon_watchtower": ("watchtower", "#d1a46a"),
"fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"),
"fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"),
"sabotage": ("fire-bomb", "#e0703a"), "drill_militia": ("sword-brandish", "#e0a050"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"),
@ -4754,6 +5053,8 @@ ABILITY_GLYPHS = {
"heavy_slam": ("punch-blast", "#e06a6a"), "lunge": ("pierced-body", "#e06a6a"),
"hex_bolt": ("fireball", "#a77be8"), "sweep": ("spinning-blades", "#e06a6a"),
"fire_breath": ("dragon-breath", "#ff8a3a"), "tail_swipe": ("spiked-tail", "#c08850"),
"stone_curse": ("evil-hand", "#a77be8"), "ground_pound": ("earth-crack", "#c08850"),
"shatter": ("shattered-glass", "#d1a46a"), "shriek": ("sonic-shout", "#a77be8"),
}
PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"),
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
@ -4882,6 +5183,18 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-24", "title": "Active play and dungeons", "notes": [
("new", "Active play: tap AUTO on an ability to take it yourself (YOU), then press it (tap, or keys 1–9) on its "
"real cooldown instead of auto's 1.5×. Look away for 12s and it's back on auto."),
("new", "Tap an enemy to fight it, or a friend to aim your heals at them."),
("new", "Dungeon: Watchtower Depths (combat 20, 2–4 players), 8 pulls in order. Trash falls on auto; the Clay "
"colossus, Harpy matriarch and Watchtower ogre need your own hands: interrupt Stone Curse and Shriek, "
"brace for Ground Pound, beat the enrage timer. Start it and wait for others to join before the first pull."),
("new", "A wipe repeats the pull, 3 on one pull ends the run. The run waits while anyone looks away for 90s. "
"The leader can pull early."),
("new", "Dungeon rewards: every boss gives everyone their own loot (mini-bosses 5× their race's odds). "
"Clearing pays 100 gold, a trophy and 2 reagents each."),
]},
{"date": "2026-10-24", "title": "Livelier frontier", "notes": [
("balance", "A threat lands every 2 days (±2h), and the next one is rumoured as soon as the last lands. "
"Day 1: scout, drill, build. Day 2: the camp is found (intel III opens 24h out), with the exact hour."),
@ -4960,8 +5273,8 @@ def content() -> dict:
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,