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 = "") => `