From 9d61de2012ab311f447511f514c1cd0e1d77d9f4 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 24 Oct 2026 12:05:23 +0100 Subject: [PATCH] Active play and the Watchtower Depths dungeon Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +- app.py | 14 ++ combat.py | 137 +++++++++++++++-- game.py | 355 ++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 85 +++++++++-- static/icons.svg | 5 + static/styles.css | 21 ++- tests/test_game.py | 172 ++++++++++++++++++++++ tools/balance.py | 41 +++++- 9 files changed, 790 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 1ecb3dc..31df761 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,19 @@ Beta. The core game is in place; changes from here are mostly balance and tuning (miner: a target's resistance stops counting), Barricade (builder: the group takes less), Hearty Meal (homesteader: the group heals over time), Mark Prey (ranger: a target takes more from everyone). +- **Active play:** any ability can be switched from AUTO (a rule uses it, on a + 1.5× cooldown) to YOU: you press it (tap, or keys 1–9) on its real cooldown, + and tap frames to pick your target or aim your heals. Your hands only count + while you watch: look away for 12 seconds and it's all auto again. Presses are + kept with the fight, which is played again from its seed with them, so + nothing that already happened changes. +- **Dungeons:** Watchtower Depths (combat 20) for 2 to 4: eight pulls in order, + sized for the group, ending in the Watchtower ogre. Trash falls on auto; the + bosses (Clay colossus, Harpy matriarch, the ogre) don't: interruptible curses + and shrieks, ground pounds to brace for, adds at half health and an enrage + timer. Join before the first pull. A wipe repeats the pull, three on one pull + end the run, and the run waits while anyone looks away. Bosses give everyone + their own loot; clearing pays gold, a trophy and reagents. - **Armour:** three weights. Heavy plate (Smithing) is strong against blows and weak to magic, light cloth (Tailoring) the other way round, medium leather (Leatherworking) a bit of both. Every trade gathers its own base @@ -198,7 +211,8 @@ snapshot and exits; the NixOS module runs it nightly when - `game.py`: content tables and the rules. Pure functions of `(world, now)`. - `combat.py`: the fight engine and the abilities. -- `tools/balance.py`: an hour of fighting per trade and tier (`solo`, `party`, `bosses`). +- `tools/balance.py`: an hour of fighting per trade and tier (`solo`, `party`, `bosses`), and + each dungeon pull on auto or with `perfect` hands (`dungeon`). - `app.py`: stdlib HTTP server, persistence and the background ticker. - `static/`: the client (vanilla JS, no build step). diff --git a/app.py b/app.py index 5e7ddc5..a3cdbff 100644 --- a/app.py +++ b/app.py @@ -126,6 +126,9 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["action"] = {**player["action"], "growing": action["id"], "duration": duration} if action.get("together") and not fight: view["action"]["waiting"] = True # for a group: nothing to count down + run = game.run_of(world, player) + if run: + view["action"]["run"] = run if fight: view["action"]["fight"] = game.fight_view(fight) party = game.party_of(world, player) @@ -288,6 +291,7 @@ class Handler(BaseHTTPRequestHandler): summary = game.take_summary(player, game.empty_summary()) game.regen(world, player, now) player["last_active"] = now + game.watch_fight(world, player, now) # looking at it: your manual abilities hold self.store.save() self.send_json(self.state(uid, summary)) elif path.startswith("/api/"): @@ -319,6 +323,7 @@ class Handler(BaseHTTPRequestHandler): summary = game.take_summary(player, game.empty_summary()) game.regen(world, player, now) player["last_active"] = now + game.watch_fight(world, player, now) error = self.player_route(path, player, body, now) if error is None: game.process_player(world, player, now, self.store.rng) # a new fight is drawn up at once @@ -347,6 +352,15 @@ class Handler(BaseHTTPRequestHandler): return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once"))) if path == "/api/join": return game.join_fight(world, player, str(body.get("id")), now) + if path == "/api/press": + target = body.get("target") + return game.press(world, player, str(body.get("id")), now, str(target) if target else None) + if path == "/api/target": + return game.set_fight_target(world, player, str(body.get("id")), now) + if path == "/api/pull": + return game.pull_now(world, player, now) + if path == "/api/manual": + return game.set_manual(world, player, str(body.get("id")), bool(body.get("on")), now) if path == "/api/order": return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"), body.get("reward"), now) diff --git a/combat.py b/combat.py index 5a17e96..0e91e79 100644 --- a/combat.py +++ b/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: diff --git a/game.py b/game.py index bb6e419..1b274eb 100644 --- a/game.py +++ b/game.py @@ -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, diff --git a/static/app.js b/static/app.js index 913dee5..6e12b75 100644 --- a/static/app.js +++ b/static/app.js @@ -899,14 +899,21 @@ function combatFrame() { const encounter = a && C.actions.find((x) => x.id === a.id); const waiting = a?.waiting; const others = f ? f.members.filter((m) => m.id !== S.uid) : []; - const unit = (id, name, foe = false, extra = "") => `
+ const unit = (id, name, foe = false, extra = "") => `
${name}${extra} ${foe ? "" : HEART} ${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}
`; const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join(""); - let foe = `
${waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}
`; + const run = a?.run; + // A dungeon run: where you are in it, and (for the leader, while resting) the button to go in early. + const stage = run ? `
${esc(run.dungeon)} + ${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""} + ${f && run.leader === S.uid ? `` : ""}
` : ""; + let foe = `
${run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back` + : run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}
`; if (f) { const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage; const traits = (v) => `${v.attack.map((k) => dmgIcon(k, "attack")).join("")} @@ -918,21 +925,27 @@ function combatFrame() { } return `
${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}
-
${foe}
+
${stage}${foe}
`; } -// Your abilities, from your weapon and your trade, on auto: each lights up as it's used and shows its -// cooldown. The weapon's ones still to come are shown dimmed with the level they're learned at. +// Your abilities, from your weapon and your trade: each lights up as it's used and shows its cooldown. +// On AUTO a rule uses it on a longer cooldown; on YOU you press it (tap, or its number key) on the real +// one, for as long as you're watching. The weapon's ones still to come are dimmed with their level. function abilityBar(me) { const known = me.fighter.abilities, style = me.fighter.style || "sword"; const shield = !!me.equip.shield; const tile = (id, on) => { - const ab = C.abilities[id]; + const ab = C.abilities[id], manual = on && me.manual.includes(id); const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`; - const tip = `${ab.name}: ${ab.tip}${ab.cd ? ` · ${ab.cd * C.auto_cd}s on auto` : ""}${why ? ` · ${why}` : ""}`; - return `${ab.icon} - ${on ? "" : `${why === "needs a shield" ? "🛡️" : ab.level}`}`; + const cd = ab.cd ? ` · ${manual ? ab.cd : ab.cd * C.auto_cd}s cooldown` : ""; + const tip = `${ab.name}: ${ab.tip}${cd}${why ? ` · ${why}` : ""}`; + return ` + + ${on ? `` : ""} + `; }; const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id))); const trade = C.trade_ability[me.cls]; @@ -976,7 +989,9 @@ function fightLine(e, f) { case "cc": return `
  • ${what} ${name(tgt)} held
  • `; case "free": return `
  • ${name(tgt)} breaks free
  • `; case "die": return `
  • ${name(tgt)} falls
  • `; - case "enrage": return `
  • ${C.abilities.unstoppable.icon} ${name(src)} enrages
  • `; + case "enrage": return `
  • ${C.abilities.unstoppable.icon} ${name(src)} ${label === "hard" ? "loses patience" : "enrages"}
  • `; + case "yell": return `
  • ${name(src)}: “${esc(label)}”
  • `; + case "join": return `
  • ${name(src)} joins the fight
  • `; case "wind": return `
  • ${C.prayers.find((p) => p.id === "second_wind").icon} Second wind! ${name(src)}
  • `; case "down": return `
  • ${C.abilities.concuss.icon} ${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}
  • `; case "left": return `
  • ${name(src)} left
  • `; @@ -984,12 +999,21 @@ function fightLine(e, f) { return ""; } +// Whether a use of an ability came from a press (within the queue window before it), so it ran on its +// real cooldown. +function pressedNear(f, uid, ab, time) { + const m = f.members.find((x) => x.id === uid); + return (m.inputs || []).some((i) => i[1] === "press" && i[2] === ab && time >= (f.rest + i[0]) / f.pace - 0.01 + && time <= (f.rest + i[0] + 1.5) / f.pace + 0.01); +} + // Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not, // and when each of your abilities was last used. function fightState(f, t) { const rest = f.rest / f.pace, st = {}; - for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, cast: null, out: null, cds: {} }; - f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false }; }); + for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, + cast: null, out: null, cds: {}, manualAt: {}, pressed: new Set((m.inputs || []).filter((i) => i[1] === "press").map((i) => i[2])) }; + f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; }); let shown = -1; while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) { const [time, src, tgt, kind, , hp, label] = f.events[++shown]; @@ -1001,7 +1025,11 @@ function fightState(f, t) { who.cast = { label, from: time, until: time + spec.cast / f.pace }; } const ab = C.abilities[label]; - if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind))) who.cds[label] = time; + if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind)) && who.cds[label] !== time) { + who.cds[label] = time; + who.manualAt[label] = who.pressed.has(`${label}`) && pressedNear(f, src, label, time); + } + if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt; if (who && kind === "heal" && label === "potion") who.drank = true; if (them && (kind === "stop" || kind === "cc")) them.cast = null; if (them && kind === "cc") them.held = true; @@ -1009,6 +1037,7 @@ function fightState(f, t) { if (them && kind === "die") them.out = "dead"; if (who && (kind === "down" || kind === "left")) who.out = kind; if (who && kind === "enrage") who.enraged = true; + if (who && kind === "join") { who.hidden = false; who.from = time; } } return { st, shown }; } @@ -1032,11 +1061,17 @@ function animateFight(now) { const panel = $("[data-fight]"); if (!panel) return; panel.classList.toggle("waiting", resting); + const pull = panel.querySelector("[data-pull-btn]"); + if (pull) pull.hidden = !resting; + const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e"); + const aim = pick && st[pick[2]] && !st[pick[2]].out ? pick[2] : me.aim && st[me.aim] && !st[me.aim].out ? me.aim : null; for (const el of panel.querySelectorAll("[data-u]")) { const u = st[el.dataset.u]; if (!u) continue; + el.classList.toggle("aimed", el.dataset.u === aim && !over); const gone = !!u.out || over; el.classList.toggle("out", !!u.out); + el.hidden = !!u.hidden; el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`; el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`; const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : ""; @@ -1061,7 +1096,7 @@ function animateFight(now) { const id = el.dataset.ab; if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; } const ab = C.abilities[id], used = me.cds?.[id]; - const cd = (ab.cd || 0) * C.auto_cd / f.pace; + const cd = (ab.cd || 0) * (me.manualAt?.[id] ? 1 : C.auto_cd) / f.pace; const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t); el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`; el.classList.toggle("fired", used != null && t - used < 0.6); @@ -1181,7 +1216,8 @@ function actionCard(a) { let extra = ""; if (a.enemy) { const o = me.fight_odds[a.id], e = a.enemy; - const own = (a.together ? `👥 Group ` : "") + const own = (a.dungeon ? `🏰 Dungeon · ${C.group_size}–${C.dungeon_size} ` + : a.together ? `👥 Group ` : "") + (a.personal_loot ? `🎁 Own loot` : ""); extra = `${e.boss || own ? `
    ${e.boss ? `Boss ` : ""}${own}
    ` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; @@ -2118,7 +2154,7 @@ function loop() { // --- events --------------------------------------------------------------- document.addEventListener("click", (e) => { - const t = e.target.closest("button, [data-menu], [data-close]"); + const t = e.target.closest("button, [data-menu], [data-close], [data-aim]"); if (!t) return; const d = t.dataset; if (t.matches("[data-battle-skip]")) return playBattle.finish?.(); @@ -2137,6 +2173,14 @@ document.addEventListener("click", (e) => { render(); window.scrollTo(0, 0); return; } if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); } + if (d.press) { + if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself"); + t.closest(".ab")?.classList.add("queued"); + return act("/api/press", { id: d.press }); + } + if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) }); + if ("pull" in d) return act("/api/pull", {}); + if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); if (d.start) return act("/api/action", { id: d.start }); if (d.once) return act("/api/action", { id: d.once, once: true }); if ("stop" in d) return act("/api/stop", {}); @@ -2253,6 +2297,15 @@ document.addEventListener("change", (e) => { $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); +// Number keys press your abilities, in the bar's order (the ones you know). +document.addEventListener("keydown", (e) => { + if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return; + const n = Number(e.key); + if (!n) return; + const tile = document.querySelectorAll(".ability-bar .ab:not(.locked) [data-press]")[n - 1]; + if (tile) { e.preventDefault(); tile.click(); } +}); + document.addEventListener("submit", async (e) => { const form = e.target.closest("[data-form]"); if (!form) return; diff --git a/static/icons.svg b/static/icons.svg index 9484e96..2b18ef4 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -68,11 +68,13 @@ + + @@ -176,6 +178,7 @@ + @@ -186,6 +189,7 @@ + @@ -224,6 +228,7 @@ + diff --git a/static/styles.css b/static/styles.css index 5bbd988..95955b9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -559,6 +559,7 @@ button.sheet-skill:hover { background: var(--panel-2); } /* Packs, casts and abilities */ .pack { display: grid; gap: 12px; } .fighter.out { opacity: .4; } +.fighter[hidden] { display: none; } .traits-inline { display: inline-flex; gap: 2px; margin-left: 6px; vertical-align: middle; } .traits-inline .v-res { display: inline-flex; gap: 2px; margin-left: 4px; } .traits-inline .dmg .gi { width: 13px; height: 13px; } @@ -570,10 +571,22 @@ button.sheet-skill:hover { background: var(--panel-2); } .foe-unit .cast { color: var(--bad); } .foe-unit .cast .bar > span { background: var(--bad); } .ability-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } -.ab { position: relative; width: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; font-size: 20px; - background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; } +.ab { position: relative; width: 42px; height: 52px; display: grid; grid-template-rows: 42px 10px; gap: 0; } +.ab-press { position: relative; width: 42px; height: 42px; border-radius: 9px; display: grid; place-items: center; padding: 0; + font-size: 20px; color: inherit; background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; cursor: pointer; } +.ab.auto .ab-press { cursor: default; } +.ab.manual .ab-press { border-color: var(--gold); } +.ab.queued .ab-press { box-shadow: 0 0 0 2px var(--good); } +.ab-mode { justify-self: center; margin-top: -6px; z-index: 1; font-size: 8.5px; font-weight: 800; letter-spacing: .04em; line-height: 1; + padding: 2px 4px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; } +.ab.manual .ab-mode { color: #1a1408; background: var(--gold); border-color: var(--gold); } +.ab.potion { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 9px; background: var(--panel-2); + border: 1px solid var(--line); align-self: start; } +.fighter.aimable { cursor: pointer; border-radius: 8px; } +.fighter.aimed { outline: 2px solid var(--gold); outline-offset: 4px; } +.ab-lv .gi { width: 10px; height: 10px; } .ab.locked { opacity: .35; filter: grayscale(1); } -.ab.fired { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold); } +.ab.fired .ab-press { box-shadow: 0 0 0 2px var(--gold); } .ab.spent { opacity: .35; } .ab .gi { width: 26px; height: 26px; } .fight-log .gi, .cast .gi, [data-u-status] .gi { width: 15px; height: 15px; vertical-align: -3px; } @@ -581,6 +594,8 @@ button.sheet-skill:hover { background: var(--panel-2); } .ab-lv { position: absolute; bottom: 0; right: 2px; font-size: 10px; font-weight: 700; color: var(--muted); filter: none; } .ab-gap { width: 6px; } .fight-log .warn { color: var(--gold); } +.run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); } +.run-head .btn { margin-left: auto; } .pill { padding: 3px 10px 3px 3px; } .pill .dmg { border-radius: 50%; } diff --git a/tests/test_game.py b/tests/test_game.py index d34e7f4..457a03c 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -736,6 +736,178 @@ class AbilityTest(unittest.TestCase): self.assertIsNone(world["parties"]["1"]["fight"]) +class ActivePlayTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("ash", "miner")) + self.p = self.world["players"]["ash"] + self.p["xp"]["combat"] = game.XP_TABLE[30] + self.p["equip"].update(weapon="steel_warhammer", helmet="steel_helmet", body="steel_body", legs="steel_legs") + self.p["hp"] = game.max_hp(self.p) + self.p["last_active"] = T0 + game.start_action(self.world, self.p, "fight_stone_guardians", T0) + game.process_player(self.world, self.p, T0, random.Random(3)) # two stone guardians + self.fight = self.p["action"]["fight"] + + def at(self, t): + """The real moment `t` seconds into the fight.""" + return T0 + (self.fight["rest"] + t) / self.fight["pace"] + + def mine(self, label, kinds=("cast", "use", "hit", "crit")): + return [e for e in self.fight["events"] if e[1] == "ash" and e[6] == label and e[3] in kinds] + + def test_switching_and_pressing(self): + self.assertEqual(game.press(self.world, self.p, "crushing_blow", self.at(5)), "That one's on auto") + self.assertIsNone(game.set_manual(self.world, self.p, "crushing_blow", True, T0)) # yours from the start + self.assertEqual(self.p["manual"], ["crushing_blow"]) + watch = (self.fight["rest"] + game.member_of(self.fight, "ash")["watch"]) / self.fight["pace"] + self.assertTrue(all(e[0] >= watch for e in self.mine("crushing_blow", ("cast",)))) # back on auto once you look away + before = [e for e in self.fight["events"] if e[0] < self.at(5) - T0] + self.assertIsNone(game.press(self.world, self.p, "crushing_blow", self.at(5))) + self.assertEqual([e for e in self.fight["events"] if e[0] < self.at(5) - T0], before) # the past stands + casts = [e[0] for e in self.mine("crushing_blow", ("cast",)) if e[0] >= self.at(5) - T0] + self.assertTrue(casts) + self.assertLessEqual(casts[0], self.at(5 + combat_queue()) - T0) # as soon as it's ready + + def test_while_watching_auto_leaves_your_abilities_alone(self): + game.set_manual(self.world, self.p, "crushing_blow", True, self.at(1)) + watch = game.member_of(self.fight, "ash")["watch"] + uses = [e[0] for e in self.mine("crushing_blow", ("cast",))] + self.assertTrue(all(u >= (self.fight["rest"] + watch) / self.fight["pace"] for u in uses if u > self.at(1) - T0)) + # Look again and again: still nothing on auto until you look away. + for t in (5, 10, 15): + game.watch_fight(self.world, self.p, self.at(t)) + self.assertGreater(game.member_of(self.fight, "ash")["watch"], 25) + + def test_tapping_an_enemy_turns_you_on_it(self): + self.assertIsNone(game.set_fight_target(self.world, self.p, "e1", self.at(2))) + swings = [e for e in self.fight["events"] if e[1] == "ash" and e[3] in ("hit", "crit", "miss") + and e[6] in game.DAMAGE_TYPES and e[0] > self.at(2.5) - T0] + dead = {e[2]: e[0] for e in self.fight["events"] if e[3] == "die"} + self.assertTrue(all(e[2] == "e1" for e in swings if e[0] < dead.get("e1", 1e9))) + + def test_no_presses_while_resting_or_out_of_a_fight(self): + game.set_manual(self.world, self.p, "crushing_blow", True, T0) + self.assertEqual(game.press(self.world, self.p, "crushing_blow", T0), "Still resting") + later = T0 + self.fight["duration"] + 100 + self.p["action"] = None + self.assertEqual(game.press(self.world, self.p, "crushing_blow", later), "You're not fighting") + + def test_old_worlds_get_manual(self): + del self.p["manual"] + self.world["version"] = 27 + game.migrate(self.world, T0) + self.assertEqual(self.p["manual"], []) + + +def combat_queue(): + return game.combat.QUEUE + game.combat.GCD + + +class DungeonTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("wil", "builder"), ("kay", "homesteader"), ("ann", "miner")) + gear = {"builder": dict(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", + legs="rune_legs"), + "homesteader": dict(weapon="magic_staff", helmet="rune_helmet", body="rune_body", legs="rune_legs"), + "miner": dict(weapon="rune_warhammer", helmet="rune_helmet", body="rune_body", legs="rune_legs")} + for p in self.world["players"].values(): + p["xp"]["combat"] = game.XP_TABLE[60] # well past it: these are about the run, not the fights + p["equip"].update(gear[p["cls"]]) + p["hp"] = game.max_hp(p) + p["last_active"] = T0 + self.wil, self.kay, self.ann = (self.world["players"][uid] for uid in ("wil", "kay", "ann")) + + def online(self, now, players=None): + for p in players or self.world["players"].values(): + p["last_active"] = now + game.catch_up(self.world, now, random.Random(1)) + + def party(self): + return game.party_of(self.world, self.wil) + + def test_a_run_goes_pull_by_pull_and_pays_on_the_way_out(self): + self.assertIsNone(game.start_action(self.world, self.wil, "dungeon_watchtower", T0)) + self.online(T0 + 600) + self.assertNotIn("fight", self.wil["action"]) # alone, it waits + self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0 + 600)) + names = [] + now = T0 + 600 + while game.party_of(self.world, self.wil): + fight = self.party()["fight"] or {} + if fight.get("stage"): + names.append(fight["enemy"]) + now += 2 + self.online(now) + self.assertLess(now, T0 + 6 * 3600) + pulls = game.ACTION_BY_ID["dungeon_watchtower"]["pulls"] + self.assertEqual(list(dict.fromkeys(names)), pulls) + for p in (self.wil, self.kay): + self.assertIsNone(p["action"]) + self.assertEqual(p["stopped"], "Cleared Watchtower Depths") + self.assertGreaterEqual(p["gold"], game.DUNGEONS["dungeon_watchtower"]["bonus_gold"]) + self.assertIn("cleared Watchtower Depths", self.world["ledger"][-1]["text"]) + + def test_joining_only_before_the_first_pull_and_up_to_four(self): + game.start_action(self.world, self.wil, "dungeon_watchtower", T0) + self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0)) + self.online(T0 + 1) + self.assertIsNone(game.join_fight(self.world, self.ann, "wil", T0 + 1)) # still resting: in time + self.party()["run"]["pull"] = 1 + extra = game.new_player("bo", "Bo", "ranger", T0) + extra["xp"]["combat"] = game.XP_TABLE[60] + self.world["players"]["bo"] = extra + self.assertEqual(game.join_fight(self.world, extra, "wil", T0 + 2), "The run has started") + self.assertFalse(game.joinable(self.world, self.wil)) + + def test_a_run_waits_for_whoever_looked_away(self): + game.start_action(self.world, self.wil, "dungeon_watchtower", T0) + game.join_fight(self.world, self.kay, "wil", T0) + self.online(T0 + 300, [self.wil]) # kay went to make tea + self.online(T0 + 1200, [self.wil]) + self.assertIsNone(self.party()["fight"]) + self.assertEqual(game.run_of(self.world, self.wil)["away"], ["Kay"]) + self.online(T0 + 1210) + self.assertIsNotNone(self.party()["fight"]) + + def test_three_wipes_on_a_pull_end_the_run(self): + game.start_action(self.world, self.wil, "dungeon_watchtower", T0) + game.join_fight(self.world, self.kay, "wil", T0) + for p in (self.wil, self.kay): + p["equip"] = {slot: None for slot in p["equip"]} + p["xp"]["combat"] = game.XP_TABLE[20] + self.party()["run"] = {"pull": 7, "wipes": 0} # the ogre, naked + now = T0 + while game.party_of(self.world, self.wil): + now += 30 + self.online(now) + self.assertLess(now, T0 + 3 * 3600) + self.assertEqual(self.wil["stopped"], "Wiped 3 times at Watchtower ogre") + + def test_the_leader_can_pull_early(self): + game.start_action(self.world, self.wil, "dungeon_watchtower", T0) + game.join_fight(self.world, self.kay, "wil", T0) + self.kay["hp"] = 1 + self.online(T0 + 0.5) + fight = self.party()["fight"] + self.assertGreater(fight["rest"], 10) + self.assertEqual(game.pull_now(self.world, self.kay, T0 + 3), "Only the leader pulls") + self.assertIsNone(game.pull_now(self.world, self.wil, T0 + 3)) + self.assertLessEqual(fight["rest"], 3) + self.assertLess(game.member_of(fight, "kay")["hp_in"], game.max_hp(self.kay) / 2) + + def test_adds_join_halfway_and_never_count_if_they_dont(self): + foes = game.DUNGEONS["dungeon_watchtower"]["pulls"][7][1] + self.assertTrue(foes[1]["dormant"] and foes[2]["dormant"]) + players = [self.wil, self.kay] + fight = game.draw_pull(self.world, players, game.ACTION_BY_ID["dungeon_watchtower"], {"pull": 7, "wipes": 0}, + random.Random(2), T0) + joined = [e for e in fight["events"] if e[3] == "join"] + yell = next(e for e in fight["events"] if e[3] == "yell") + self.assertEqual(yell[6], "Crush them!") + self.assertEqual({e[1] for e in joined}, {"e1", "e2"}) + self.assertGreaterEqual(joined[0][0], yell[0]) + + class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self): world = world_with(("pia", "commoner")) diff --git a/tools/balance.py b/tools/balance.py index ba01dac..88b7a16 100644 --- a/tools/balance.py +++ b/tools/balance.py @@ -1,7 +1,13 @@ #!/usr/bin/env python3 """Balance report: an hour of fighting for each trade with its own weapon, at each tier. - python3 tools/balance.py [solo|party] [seeds] [levels, e.g. 22,52] [potions] + python3 tools/balance.py [solo|party|bosses] [seeds] [levels, e.g. 22,52] [potions] + python3 tools/balance.py dungeon [seeds] [level] [perfect] + +`dungeon` plays each pull of every dungeon for a group of four and a pair, everyone with the +tier's gear, food and health potions. `perfect` stands in for active play: real cooldowns, no +waiting on auto's rules, quick interrupts. Bosses should beat an all-auto group and fall to one +that plays. Each row is one encounter: wins and losses in the hour, XP an hour, food eaten an hour, and the share of a kill's time spent fighting (the rest is resting between fights). @@ -97,8 +103,41 @@ def report(groups: dict, seeds: int, levels: set | None = None) -> None: BOSSES = ["fight_ruins", "fight_barrow_king", "fight_green_dragon", "fight_blue_dragon", "fight_red_dragon"] +def dungeon(seeds: int, level: int | None) -> None: + for key, spec in game.DUNGEONS.items(): + tier = spec["tier"] + at = level or spec["level"] + 3 + action = game.ACTION_BY_ID[key] + for group in (["builder", "homesteader", "miner", "ranger"], ["builder", "homesteader"]): + global POTIONS + POTIONS = True + print(f"{spec['name']}: {'+'.join(group)} at {at}") + print(f"{'pull':18} {'win':>5} {'secs':>5} {'downs':>5} {'potions':>7}") + for index, (name, _pack) in enumerate(spec["pulls"]): + wins, secs, downs, drunk = 0, [], [], [] + for seed in range(seeds): + world = game.new_world(T0) + players = [fighter(world, f"p{i}", cls, at, tier) for i, cls in enumerate(group)] + fight = game.draw_pull(world, players, action, {"pull": index, "wipes": 0}, random.Random(seed), T0) + if fight["result"] == "won": + wins += 1 + secs.append(fight["duration"] - fight["rest"]) + downs.append(sum(1 for m in fight["members"] if m["down"])) + drunk.append(sum(1 for m in fight["members"] if m["drank"])) + mean = lambda xs: statistics.mean(xs) if xs else 0 + print(f"{name:18} {wins / seeds:>5.0%} {mean(secs):>5.0f} {mean(downs):>5.1f} {mean(drunk):>7.1f}") + print() + + def main() -> None: mode = sys.argv[1] if len(sys.argv) > 1 else "solo" + if mode == "dungeon": + if "perfect" in sys.argv: + game.combat.AUTO_CD, game.combat.AUTO_DELAY, game.combat.AUTO_REACT = 1.0, 0.0, 0.25 + print("-- perfect hands --") + dungeon(int(sys.argv[2]) if len(sys.argv) > 2 else 20, + int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None) + return seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3 levels = {int(level) for level in sys.argv[3].split(",")} if len(sys.argv) > 3 and sys.argv[3] != "all" else None global POTIONS