diff --git a/README.md b/README.md index 1cbe58a..dfd8202 100644 --- a/README.md +++ b/README.md @@ -14,31 +14,48 @@ Beta. The core game is in place; changes from here are mostly balance and tuning firekeeping, combat, prayer and reconnaissance. Smiths also make hardware for builders, one kind per metal (copper rivets to rune locks), which fittings and buildings need. -- **Combat:** every fight is played out swing by swing: you and the enemy - each have HP, a swing timer, accuracy and a max hit, and swings hit, miss - or crit. Every blow has a damage type (slashing, blunt, piercing, magic); - each enemy is vulnerable to some (+25%) and resists others (-25%), shown - as tinted icons. Armour stops physical blows, ward stops magic. Below half - health you eat from your bank, and each bite holds back your next swing; - out of food you retreat, losing nothing but the fight. HP recovers 25% - an hour. The Combat page replays the fight live. Fights are split into - the Frontier (bandits, sabotage, drilling, the city's troubles) and - expeditions: Greenwood to start, then one area per - tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's - Spine), each with four races and a boss (the dragons at the top). Every - kill is one of the race's variants (a brute, a skirmisher, a caster, now - and then a rare elite), so each encounter mixes damage types: spirit races - lean on magic, golems and trolls on blows. Races drop trophies (10%, - 20% from bosses) for stronger weapons and the area's armour reagents (10%, - far more from dragons). Each kill is followed by a 2-second pause. +- **Combat:** a fight is a party against a pack, played out in quarter-second + slices (`combat.py`). Everyone swings on their weapon's timer, and swings hit, + miss or crit. Every blow has a damage type (slashing, blunt, piercing, magic); + each enemy is vulnerable to some (+25%) and resists others (-25%), shown as + tinted icons. Armour stops physical blows, ward stops magic. On top of the + swings, your weapon gives four abilities (learned at combat 1, 15, 30 and 45) + and your trade one more; for now they're all on auto. Enemies use their own: + brutes wind up a Heavy Slam, skirmishers lunge at whoever isn't tanking, + casters cast a Hex Bolt you can interrupt, elites and bosses enrage, dragons + breathe fire. Enemies pick targets by threat. A pack is as many enemies as + there are of you, often one more (each weaker); a rare elite comes alone. + Between fights you rest back to full: food is only eaten then, and better food + rests you faster. In a fight the only heal you carry is a health potion + (Herblore), drunk once a fight under 35% HP. At 0 HP you're knocked out: you + lose that fight and come round at 30%; three knockouts in a row and you stop. + HP recovers 25% an hour away from fights. The Combat page replays the fight + live. Fights are split into the Frontier (bandits, sabotage, drilling, the + city's troubles) and expeditions: Greenwood to start, then one area per tier + (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's Spine), each + with four races and a boss (the dragons at the top). Races drop trophies + (10%, 20% from bosses) for stronger weapons and the area's armour reagents + (10%, far more from dragons). +- **Bosses and dragons** are group fights: two or more. Alone you wait at it + for someone to tag in. They grow with the group, and everyone who brings one + down gets their own loot. - **Multiplayer Combat:** tag into anyone's fight from the encounter card or the guild roster. -- **Weapons:** four styles, one per trade, and anyone can use any of them. +- **Weapons:** four styles, one per trade, and anyone can use any of them; the + weapon decides your abilities and your role. Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits; swords from Smithing, shields from Construction), warhammer (blunt, Smithing; slow, heavy, twice the crits), staff (magic, Herblore; accurate) and bow (piercing, Leatherworking; fast, strikes first). The last three take both hands and carry a shield's power. Your trade's weapon does 15% more damage. Past copper, each weapon needs its tier's trophies. +- **Abilities:** sword and shield tanks (Shield Bash, Challenge, Sweeping Cut, + Shield Wall; the shield ones need a shield), warhammer hits hard and stuns + (Crushing Blow, Concuss, Earthshaker, Unstoppable), staff heals and holds + (Bolt, Mend, Ward, Thornbind), bow shoots from afar (Aimed Shot, Pinning + Shot, Barbed Arrow, Volley, which takes 5 arrows). Trades: Find the Seam + (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). - **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 @@ -119,7 +136,8 @@ Beta. The core game is in place; changes from here are mostly balance and tuning logs → enchanted logs, copper bar → rune bar…), at Herblore 1, 15, 30, 45, 60 and 75. Gathering and Construction potions are 10–35% faster, crafting potions a 10–35% chance to keep materials, Prayer more points from bones, - and combat has Strength and Defence ladders. Potions last 50 uses, one per + and combat has Strength and Defence ladders. Health potions (I–VI, the tier's + herb and cooked fish) are the one heal you take into a fight. Potions last 50 uses, one per skill at a time; an empty one is replaced from the bank automatically. - **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen fire (cooking), the kiln (bricks) and the still (potions). Every craft at a @@ -178,6 +196,8 @@ snapshot and exits; the NixOS module runs it nightly when ## Layout - `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`). - `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 a3d2939..5e7ddc5 100644 --- a/app.py +++ b/app.py @@ -106,7 +106,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["ward_reduction"] = round(game.reduction(game.magic_ward(player)), 3) view["edible"] = game.edible(player) view["blessing"] = game.blessing(player) - view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a} + view["fight_odds"] = {a["id"]: game.fight_odds(player, a, world) for a in game.ACTIONS if "enemy" in a} view["pp"] = int(player["pp"]) view["labour_per_action"] = game.labour_per_action(player) view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} @@ -116,20 +116,24 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["power"] = game.combat_power(player) me = game.fighter(player, game.blessing(player), game.potion_effect(player, "combat")) view["fighter"] = {"max_hit": me["max_hit"], "accuracy": round(me["accuracy"]), "evasion": me["evasion"], - "crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"]} + "crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"], + "role": me["role"], "abilities": me["abilities"], "style": me["style"]} + view["health_potion"] = game.health_potion(player) view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} if player["action"]: action, fight = game.growing(player), game.current_fight(world, player) duration = fight["duration"] if fight else game.action_time(world, player, action) 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 if fight: view["action"]["fight"] = game.fight_view(fight) - view["action"]["respawn"] = game.RESPAWN / game.speed(world) party = game.party_of(world, player) if party: view["action"]["with"] = [uid for uid in party["members"] if uid != player["id"]] - # A fight takes as long as it takes: show the average time to a kill. - view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + game.RESPAWN) / game.speed(world) + # A fight takes as long as it takes: show the average time to win one and rest after it. + view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + view["fight_odds"][action["id"]]["rest"]) + / game.speed(world) if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} view["drill_per_action"] = game.drill_per_action(player) diff --git a/combat.py b/combat.py new file mode 100644 index 0000000..5d4d700 --- /dev/null +++ b/combat.py @@ -0,0 +1,729 @@ +"""The fight engine: a party against a pack, stepped in fixed slices of game time. + +A fight is played out whole as soon as it's drawn up, and again whenever someone tags in or +steps out, so the page can replay it. Each slice draws from a random seeded by the fight and +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. +""" + +from __future__ import annotations + +import random + +STEP = 0.25 # game seconds per slice +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 +CRIT_MULT = 1.5 +ENEMY_CRIT = 0.05 +SPREAD = 0.1 # abilities land within 10% of their size +HEAL_THREAT = 0.5 +STICKY = 1.1 # an enemy only turns when someone has 10% more threat than its target +TANK_THREAT = 2.0 +POTION_BELOW = 0.35 # a health potion is drunk under this share of HP, once a fight +MAX_FIGHT = 600 # a fight that drags on longer is broken off +UNLOCK = [1, 15, 30, 45] + + +def hit_chance(accuracy: float, evasion: float) -> float: + return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15)) + + +# --- abilities --------------------------------------------------------------- +# `c` is a share of the user's average hit (healing too). Rules (`auto`) say when auto uses it. + +def _dmg(c): + return {"t": "dmg", "c": c} + + +def _aura(kind, dur, on, **extra): + return {"t": "aura", "kind": kind, "dur": dur, "on": on, **extra} + + +ABILITIES = { + # Sword and shield: hold the line. + "shield_bash": {"name": "Shield Bash", "icon": "🛡️", "needs": "shield", "target": "enemy", "cd": 10, + "offgcd": True, "threat": 2, "effects": [_dmg(1.2), {"t": "stun", "dur": 2}], + "auto": {"r": "stop", "else": True}, + "tip": "Hit, stun 2s, stop a cast. Bosses only lose the cast."}, + "challenge": {"name": "Challenge", "icon": "😤", "target": "enemy", "cd": 8, "offgcd": True, + "effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"}, + "tip": "An enemy turns on you for 3s."}, + "sweeping_cut": {"name": "Sweeping Cut", "icon": "🌀", "target": "aoe", "cd": 10, "threat": 2, + "effects": [{"t": "aoe", "c": 0.8}], "auto": {"r": "aoe", "n": 2}, + "tip": "Hits every enemy. Double threat."}, + "shield_wall": {"name": "Shield Wall", "icon": "🧱", "needs": "shield", "target": "self", "cd": 30, "offgcd": True, + "effects": [_aura("taken", 6, "self", mult=0.5)], "auto": {"r": "tanking", "pct": 0.6}, + "tip": "Half damage taken for 6s."}, + # Warhammer: slow, heavy, and hard to stand up to. + "crushing_blow": {"name": "Crushing Blow", "icon": "🔨", "target": "enemy", "cd": 16, "cast": 1.5, + "effects": [_dmg(1.5)], "auto": {"r": "always"}, "tip": "Wind up 1.5s, hit for 1.5×."}, + "concuss": {"name": "Concuss", "icon": "💫", "target": "enemy", "cd": 15, "offgcd": True, + "effects": [{"t": "stun", "dur": 3}], "auto": {"r": "stop", "else": True}, + "tip": "Stun 3s, stop a cast. Bosses only lose the cast."}, + "earthshaker": {"name": "Earthshaker", "icon": "🌋", "target": "aoe", "cd": 12, + "effects": [{"t": "aoe", "c": 0.5}, {"t": "delay", "by": 1.5}], "auto": {"r": "aoe", "n": 2}, + "tip": "Hits every enemy and puts their next swing back 1.5s."}, + "unstoppable": {"name": "Unstoppable", "icon": "😡", "target": "self", "cd": 45, "offgcd": True, + "effects": [_aura("done", 12, "self", mult=1.2)], "auto": {"r": "big"}, + "tip": "+20% damage for 12s."}, + # Staff: magic, and keeping everyone standing. + "bolt": {"name": "Bolt", "icon": "✨", "target": "enemy", "cd": 10, "cast": 1.5, + "effects": [_dmg(1.1)], "auto": {"r": "always"}, "tip": "Cast 1.5s, magic hit for 1.1×."}, + "mend": {"name": "Mend", "icon": "💚", "target": "ally", "cd": 10, "cast": 2, + "effects": [{"t": "heal", "c": 2.0}], "auto": {"r": "heal", "pct": 0.5}, + "tip": "Cast 2s, heal someone for 2× your hit."}, + "ward": {"name": "Ward", "icon": "🔮", "target": "ally", "cd": 20, + "effects": [{"t": "shield", "c": 2.0, "dur": 20}], "auto": {"r": "ward", "pct": 0.8}, + "tip": "Soaks 2× your hit for someone, for up to 20s."}, + "thornbind": {"name": "Thornbind", "icon": "🌿", "target": "enemy", "cd": 20, + "effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"}, + "tip": "Holds an enemy 15s. Damage breaks it. Not bosses."}, + # Bow: fast, first, and from a distance. + "aimed_shot": {"name": "Aimed Shot", "icon": "🎯", "target": "enemy", "cd": 10, "cast": 2, + "effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."}, + "pinning_shot": {"name": "Pinning Shot", "icon": "📌", "target": "enemy", "cd": 12, "offgcd": True, + "effects": [_dmg(0.5), {"t": "interrupt"}], "auto": {"r": "stop"}, + "tip": "Hit and stop a cast."}, + "barbed_arrow": {"name": "Barbed Arrow", "icon": "🩸", "target": "enemy", "cd": 12, + "effects": [_dmg(0.4), {"t": "dot", "c": 0.25, "every": 2, "dur": 12}], "auto": {"r": "dot"}, + "tip": "Hit, then bleeds 6× 0.25 over 12s."}, + "volley": {"name": "Volley", "icon": "🏹", "target": "aoe", "cd": 10, "cost": 5, + "effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2}, + "tip": "Hits every enemy. 5 arrows."}, + # Trades: one each, whatever the weapon. + "find_the_seam": {"name": "Find the Seam", "icon": "⛏️", "target": "enemy", "cd": 30, "offgcd": True, + "effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6}, + "tip": "8s: its resistance doesn't count and its weakness counts double, for everyone."}, + "barricade": {"name": "Barricade", "icon": "🚧", "target": "party", "cd": 40, "offgcd": True, + "effects": [_aura("taken", 8, "party", mult=0.75)], "auto": {"r": "danger", "pct": 0.6}, + "tip": "Everyone takes 25% less damage for 8s."}, + "hearty_meal": {"name": "Hearty Meal", "icon": "🍲", "target": "party", "cd": 30, + "effects": [{"t": "hot", "c": 0.3, "every": 2, "dur": 10}], "auto": {"r": "party_hot", "pct": 0.75}, + "tip": "Everyone heals 5× 0.3 of your hit over 10s."}, + "mark_prey": {"name": "Mark Prey", "icon": "🐾", "target": "enemy", "cd": 30, "offgcd": True, + "effects": [_aura("vuln", 15, "target", mult=1.15)], "auto": {"r": "debuff", "pct": 0.6}, + "tip": "It takes 15% more damage from everyone for 15s."}, +} +WEAPON_KITS = { + "sword": ["shield_bash", "challenge", "sweeping_cut", "shield_wall"], + "hammer": ["crushing_blow", "concuss", "earthshaker", "unstoppable"], + "staff": ["bolt", "mend", "ward", "thornbind"], + "bow": ["aimed_shot", "pinning_shot", "barbed_arrow", "volley"], +} +TRADE_ABILITY = {"miner": "find_the_seam", "builder": "barricade", "homesteader": "hearty_meal", "ranger": "mark_prey"} +# The order auto looks down: stopping casts and staying alive before damage. +PRIORITY = ["pinning_shot", "shield_bash", "concuss", "challenge", "shield_wall", "barricade", "unstoppable", + "mend", "ward", "hearty_meal", "thornbind", "find_the_seam", "mark_prey", "barbed_arrow", + "earthshaker", "sweeping_cut", "volley", "crushing_blow", "aimed_shot", "bolt"] +for _id, _ability in ABILITIES.items(): + _ability["id"] = _id + for _style, _kit in WEAPON_KITS.items(): + if _id in _kit: + _ability.update(style=_style, level=UNLOCK[_kit.index(_id)]) + for _cls, _trade in TRADE_ABILITY.items(): + if _trade == _id: + _ability.update(trade=_cls, level=1) + + +def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str]: + """What you can use with this weapon at this combat level, in the order auto tries them.""" + kit = [a for a in WEAPON_KITS[style] if ABILITIES[a]["level"] <= level and (shield or not ABILITIES[a].get("needs"))] + if cls in TRADE_ABILITY: + kit.append(TRADE_ABILITY[cls]) + return sorted(kit, key=PRIORITY.index) + + +def role(style: str, shield: bool) -> str: + return "tank" if style == "sword" and shield else "healer" if style == "staff" else "damage" + + +# --- enemy abilities --------------------------------------------------------- +# Every archetype has its own, so every area's races use them. `effect` is (kind, share of +# the enemy's average hit, damage type: None for its own). Telegraphed casts always land. + +ENEMY_ABILITIES = { + "heavy_slam": {"name": "Heavy Slam", "icon": "💥", "cast": 2.0, "every": 14, "first": 5, "interrupt": False, + "target": "target", "effect": ("hit", 1.6, "blunt")}, + "lunge": {"name": "Lunge", "icon": "🗡️", "cast": 0, "every": 10, "first": 4, "interrupt": False, + "target": "soft", "effect": ("hit", 1.2, None)}, + "hex_bolt": {"name": "Hex Bolt", "icon": "🔥", "cast": 2.5, "every": 12, "first": 3, "interrupt": True, + "target": "target", "effect": ("hit", 1.8, "magic")}, + "sweep": {"name": "Sweep", "icon": "🌀", "cast": 1.5, "every": 16, "first": 8, "interrupt": False, + "target": "all", "effect": ("aoe", 0.8, "slash")}, + "fire_breath": {"name": "Fire Breath", "icon": "🐉", "cast": 3.0, "every": 18, "first": 6, "interrupt": False, + "target": "all", "effect": ("aoe", 1.4, "magic")}, + "tail_swipe": {"name": "Tail Swipe", "icon": "💢", "cast": 0, "every": 12, "first": 7, "interrupt": False, + "target": "target", "effect": ("hit", 1.2, "blunt")}, +} +ARCHETYPE_ABILITIES = {"brute": ["heavy_slam"], "skirmisher": ["lunge"], "caster": ["hex_bolt"], + "elite": ["heavy_slam"], "boss": ["heavy_slam", "sweep"], "dragon": ["fire_breath", "tail_swipe"]} +ENRAGE = {"elite": (0.3, 1.4), "boss": (0.3, 1.4), "dragon": (0.25, 1.5)} # under this share of HP, hits this much harder +VULNERABLE, RESISTANT = 1.25, 0.75 + + +# --- setting up -------------------------------------------------------------- + +def _member_unit(member: dict) -> dict: + me = member["fighter"] + return {"id": member["id"], "side": "party", "name": member["name"], "top": member["top"], "hp": member["hp_in"], + "at": member["at"], "left": member["left"], "in": False, "out": None, "role": me["role"], + "damage": me["damage"], "interval": me["interval"], "accuracy": me["accuracy"], "evasion": me["evasion"], + "crit": me["crit"], "block": me["block"], "taken": me["taken"], "base": me["base"], + "abilities": list(me["abilities"]), "cds": {}, "busy": 0.0, "cast": None, "auras": [], + "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} + + +def _enemy_unit(index: int, foe: dict) -> dict: + return {"id": f"e{index}", "side": "enemy", "name": foe["name"], "archetype": foe["archetype"], + "boss": foe["archetype"] in ("boss", "dragon") or foe.get("boss", False), + "top": foe["hp"], "hp": foe["hp"], "interval": foe["interval"], "accuracy": foe["accuracy"], + "evasion": foe["evasion"], "max_hit": foe["max_hit"], "attack": foe["attack"], + "vulnerable": foe["vulnerable"], "resists": foe["resists"], + "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"], [])]} + + +def run(fight: dict) -> dict: + """Play the fight out from the start. `fight` carries `seed`, `pace`, `rest` (game seconds before + it starts), `pack` (the enemies) and `members` (each with `fighter`, `at` and `left` in seconds of + the fight). Fills in `events`, the `kills`, each member's outcome, `result`, `end` and `duration`.""" + units = {} + for member in fight["members"]: + units[member["id"]] = _member_unit(member) + for index, foe in enumerate(fight["pack"]): + unit = _enemy_unit(index, foe) + units[unit["id"]] = unit + state = {"units": units, "events": [], "kills": [], "pace": fight["pace"], "rest": fight["rest"]} + t, n, result = 0.0, 0, None + while result is None: + t = round(t + STEP, 4) + rng = random.Random(f"{fight['seed']}:{n}") + n += 1 + result = _step(state, t, rng) + for member in fight["members"]: + unit = units[member["id"]] + won = result == "won" and unit["out"] in (None, "down") and unit["in"] + member.update(hp=max(0, unit["hp"]), damage=unit["hurt"], dealt=unit["dealt"], wind_used=unit["wind_used"], + drank=unit["drank"], shot=unit["shot"], down=unit["down"], + result="won" if won else unit["out"] or ("left" if not unit["in"] else result)) + fight.update(events=state["events"], kills=state["kills"], result=result, + end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"]) + return fight + + +def _real(state: dict, t: float) -> float: + """A moment of the fight in real seconds since its action started (rest included).""" + return round((state["rest"] + t) / state["pace"], 2) + + +def _event(state: dict, t: float, src: str | None, tgt: str | None, kind: str, amount: float = 0, + label: str = "") -> None: + target = state["units"].get(tgt or "") + hp = max(0, round(target["hp"])) if target else None + state["events"].append([_real(state, t), src, tgt, kind, round(amount), hp, label]) + + +def _party(state: dict) -> list[dict]: + return [u for u in state["units"].values() if u["side"] == "party" and u["in"] and not u["out"]] + + +def _enemies(state: dict) -> list[dict]: + return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"]] + + +# --- a slice ------------------------------------------------------------------- + +def _step(state: dict, t: float, rng: random.Random) -> str | None: + units = state["units"] + for unit in units.values(): + if unit["side"] != "party" or unit["out"]: + continue + if not unit["in"] and unit["at"] <= t + 1e-9: + unit["in"] = True + for enemy in _enemies(state): + enemy["threat"].setdefault(unit["id"], 10.0 if unit["role"] == "tank" else 0.0) + if unit["in"] and unit["left"] is not None and unit["left"] <= t + 1e-9: + _out(state, unit, "left", t) + for unit in list(units.values()): + if _alive(unit): + _tick(state, unit, t, rng) + for unit in list(units.values()): + if _alive(unit) and unit["cast"] and unit["cast"]["end"] <= t + 1e-9: + _finish_cast(state, unit, t, rng) + for unit in _party(state): + _think(state, unit, t, rng) + for enemy in _enemies(state): + if enemy["cc"] and enemy["cc"] <= t + 1e-9: + enemy["cc"] = 0 # the hold wears off + _event(state, t, None, enemy["id"], "free") + _enemy_think(state, enemy, t, rng) + for unit in list(units.values()): + if _alive(unit): + _swing(state, unit, t, rng) + for enemy in _enemies(state): + rule = ENRAGE.get(enemy["archetype"]) + 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 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()): + return "lost" + if t >= MAX_FIGHT: + for unit in _party(state): + unit["out"] = "fled" + return "fled" + return None + + +def _alive(unit: dict) -> bool: + return not unit["dead"] if unit["side"] == "enemy" else unit["in"] and not unit["out"] + + +def _tick(state: dict, unit: dict, t: float, rng: random.Random) -> None: + if unit["side"] == "party" and unit["mend"]: + unit["hp"] = min(unit["top"], unit["hp"] + unit["mend"] * unit["top"] * STEP) # Rapid Heal + for aura in list(unit["auras"]): + if aura["kind"] in ("dot", "hot") and t >= aura["next"] - 1e-9: + aura["next"] += aura["every"] + source = state["units"].get(aura["by"]) + if aura["kind"] == "dot": + _hurt(state, source, unit, aura["amount"], aura["school"], t, rng, label=aura["id"], crit=False, tick=True) + else: + _heal(state, source, unit, aura["amount"], t, aura["id"], tick=True) + if not _alive(unit): + return + if t >= aura["until"] - 1e-9 and aura in unit["auras"]: + unit["auras"].remove(aura) + + +# --- your side ----------------------------------------------------------------- + +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 + _heal(state, None, unit, unit["potion"], t, "potion") + if unit["stun"] > t or unit["cast"]: + return + _pick_target(state, unit, t) + 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"]: + continue + target = _rule(state, unit, ab, t) + if target is False: + continue + _use(state, unit, ab, target, t, rng) + if unit["cast"] or not ab.get("offgcd"): + 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 + if ab.get("cost"): + unit["arrows"] -= ab["cost"] + unit["shot"] += ab["cost"] + if not ab.get("offgcd"): + unit["busy"] = t + max(GCD, ab.get("cast", 0)) + if ab.get("cast"): + unit["cast"] = {"ab": ab["id"], "target": target, "start": t, "end": t + ab["cast"]} + _event(state, t, unit["id"], target, "cast", 0, ab["id"]) + else: + _apply(state, unit, ab, target, t, rng) + + +def _finish_cast(state: dict, unit: dict, t: float, rng: random.Random) -> None: + cast, unit["cast"] = unit["cast"], None + if unit["side"] == "party": + _apply(state, unit, ABILITIES[cast["ab"]], cast["target"], t, rng) + else: + _enemy_effect(state, unit, cast["ab"], cast["target"], t, rng) + + +def _avg(unit: dict, enemy: dict | None = None) -> float: + """The unit's average weapon hit, against this enemy if there is one.""" + top = max(2, round(unit["base"] * (_affinity(enemy, unit["damage"]) if enemy else 1))) + return (top + 1) / 2 + + +def _affinity(enemy: dict, damage: str) -> float: + seam = any(a["kind"] == "seam" for a in enemy["auras"]) + if damage in enemy["vulnerable"]: + return 1 + (VULNERABLE - 1) * (2 if seam else 1) + if damage in enemy["resists"]: + return 1 if seam else RESISTANT + return 1 + + +def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.Random) -> None: + units = state["units"] + target = units.get(target_id or "") + if ab["target"] in ("enemy", "ally") and (not target or not _alive(target)): + return + threat = ab.get("threat", 1) + if ab["target"] != "enemy" or not any(e["t"] == "dmg" for e in ab["effects"]): + _event(state, t, unit["id"], target_id, "use", 0, ab["id"]) + spread = lambda: 1 + rng.uniform(-SPREAD, SPREAD) + for eff in ab["effects"]: + kind = eff["t"] + if kind == "dmg": + _hurt(state, unit, target, eff["c"] * _avg(unit, target) * spread(), unit["damage"], t, rng, + threat=threat, label=ab["id"]) + elif kind == "aoe": + for enemy in _enemies(state): + if enemy["cc"] <= t: + _hurt(state, unit, enemy, eff["c"] * _avg(unit, enemy) * spread(), unit["damage"], t, rng, + threat=threat, label=ab["id"]) + elif kind == "dot": + if target and _alive(target): + _add_aura(target, {"id": ab["id"], "kind": "dot", "by": unit["id"], "school": unit["damage"], + "amount": eff["c"] * _avg(unit, target), "every": eff["every"], + "next": t + eff["every"], "until": t + eff["dur"] + 1e-6}) + elif kind == "heal": + _heal(state, unit, target, eff["c"] * _avg(unit) * spread(), t, ab["id"]) + elif kind == "hot": + for ally in _party(state): + _add_aura(ally, {"id": ab["id"], "kind": "hot", "by": unit["id"], "amount": eff["c"] * _avg(unit), + "every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6}) + elif kind == "shield": + _add_aura(target, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit), + "until": t + eff["dur"]}) + _heal_threat(state, unit, eff["c"] * _avg(unit)) + elif kind == "aura": + holders = _party(state) if eff["on"] == "party" else [target] if eff["on"] == "target" else [unit] + spec = {k: v for k, v in eff.items() if k not in ("t", "dur", "on")} + for holder in holders: + if holder and _alive(holder): + _add_aura(holder, {**spec, "id": ab["id"], "by": unit["id"], "until": t + eff["dur"]}) + elif kind == "taunt": + top = max(target["threat"].values(), default=0) + target["threat"][unit["id"]] = top * STICKY + 1 + target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]} + target["target"] = unit["id"] + elif kind == "stun": + if not _alive(target): + continue + _stop_cast(state, target, unit, t, force=not target["boss"]) + if not target["boss"]: + target["stun"] = max(target["stun"], t + eff["dur"]) + target["next"] = max(target["next"], t + eff["dur"]) + elif kind == "interrupt": + if _alive(target): + _stop_cast(state, target, unit, t) + elif kind == "cc": + if target["boss"]: + continue + _stop_cast(state, target, unit, t, force=True, quiet=True) + target["cc"] = t + eff["dur"] + _event(state, t, unit["id"], target["id"], "cc", 0, ab["id"]) + for other in _party(state): + if other["target"] == target["id"]: + other["target"] = None + elif kind == "delay": + for enemy in _enemies(state): + enemy["next"] += eff["by"] + + +def _stop_cast(state: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None: + cast = enemy.get("cast") + if not cast or (not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]): + return + enemy["cast"] = None + if not quiet: + _event(state, t, by["id"], enemy["id"], "stop", 0, cast["ab"]) + + +def _add_aura(unit: dict, aura: dict) -> None: + unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])] + unit["auras"].append(aura) + + +def _has(unit: dict, ab_id: str, by: str | None = None) -> bool: + return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"]) + + +def _share(unit: dict) -> float: + return unit["hp"] / unit["top"] + + +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: + return + enemies = _enemies(state) + free = [e for e in enemies if e["cc"] <= t] or enemies + if not free: + unit["target"] = None + return + if unit["role"] == "tank": + loose = [e for e in free if e["target"] != unit["id"]] + unit["target"] = (loose or sorted(free, key=_share))[0]["id"] + return + tank = next((u for u in _party(state) if u["role"] == "tank" and u["target"]), None) + focus = state["units"].get(tank["target"]) if tank else None + if focus and not focus["dead"] and focus["cc"] <= t: + unit["target"] = focus["id"] + else: + unit["target"] = min(free, key=_share)["id"] + + +def _stoppable(cast: dict, ab: dict, enemy: dict) -> bool: + spec = ENEMY_ABILITIES[cast["ab"]] + stuns = any(e["t"] == "stun" for e in ab["effects"]) + return spec["interrupt"] or (stuns and not enemy["boss"]) + + +def _rule(state: dict, unit: dict, ab: dict, t: float): + """Auto's decision: the target to use this on, None for no target, False for not now.""" + rule = ab["auto"] + r = rule["r"] + enemies = _enemies(state) + party = _party(state) + target = state["units"].get(unit["target"] or "") + if target and target["dead"]: + target = None + on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t] + if r == "always": + return target["id"] if target else False + if r == "stop": + casting = [e for e in enemies if e["cast"] and t - e["cast"]["start"] >= AUTO_REACT - 1e-9 + and _stoppable(e["cast"], ab, e)] + if casting: + return casting[0]["id"] + return target["id"] if rule.get("else") and target else False + if r == "taunt": + if unit["role"] != "tank": + return False + loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"] + and t - e["since"] >= AUTO_REACT] + return loose[0]["id"] if loose else False + if r == "aoe": + return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False + if r == "tanking": + big = any(e["boss"] or e["archetype"] == "elite" or e["cast"] and e["cast"]["target"] in (unit["id"], None) + for e in on_me) + return None if on_me and (big or _share(unit) < rule["pct"]) else False + if r == "big": + return None if target and (target["boss"] or target["archetype"] == "elite" or len(enemies) >= 2) else False + if r == "danger": + threatening = len([e for e in enemies if e["cc"] <= t]) >= 2 or any(e["boss"] for e in enemies) + return None if threatening or any(_share(u) < rule["pct"] for u in party) else False + if r == "heal": + low = min(party, key=_share, default=None) + return low["id"] if low and _share(low) < rule["pct"] else False + if r == "ward": + targeted = {e["target"] for e in enemies} + bare = [u for u in party if not any(a["kind"] == "shield" for a in u["auras"]) + and (u["id"] in targeted or _share(u) < rule["pct"])] + return min(bare, key=_share)["id"] if bare else False + if r == "party_hot": + return None if any(_share(u) < rule["pct"] and not _has(u, ab["id"], unit["id"]) for u in party) else False + if r == "cc": + free = [e for e in enemies if e["cc"] <= t] + if len(free) < 2: + return False + focus = {u["target"] for u in party if u["role"] == "tank"} | {unit["target"]} + spare = [e for e in free if not e["boss"] and e["id"] not in focus and _share(e) > 0.5] + return max(spare, key=lambda e: e["hp"])["id"] if spare else False + if r == "dot": + return target["id"] if target and not _has(target, ab["id"], unit["id"]) and _share(target) > 0.3 else False + if r == "debuff": + return target["id"] if target and not _has(target, ab["id"]) and _share(target) > rule["pct"] else False + return False + + +# --- their side ---------------------------------------------------------------- + +def _enemy_think(state: dict, enemy: dict, t: float, rng: random.Random) -> None: + if enemy["stun"] > t or enemy["cc"] > t: + enemy["cast"] = None + return + _enemy_target(state, enemy, t) + if enemy["cast"] or not enemy["target"]: + return + for slot in enemy["abil"]: + if t < slot["next"]: + continue + spec = ENEMY_ABILITIES[slot["id"]] + slot["next"] = t + spec["every"] + target = enemy["target"] + if spec["target"] == "soft": + soft = [u for u in _party(state) if u["role"] != "tank"] + target = rng.choice(soft)["id"] if soft else target + elif spec["target"] == "all": + target = None + if spec["cast"]: + enemy["cast"] = {"ab": slot["id"], "target": target, "start": t, "end": t + spec["cast"]} + _event(state, t, enemy["id"], target, "cast", 0, slot["id"]) + else: + _enemy_effect(state, enemy, slot["id"], target, t, rng) + return + + +def _enemy_target(state: dict, enemy: dict, t: float) -> None: + units = state["units"] + taunt = enemy["taunt"] + if taunt and taunt["until"] > t and _alive(units[taunt["by"]]): + enemy["target"] = taunt["by"] + return + table = {uid: v for uid, v in enemy["threat"].items() if _alive(units[uid])} + if not table: + party = _party(state) + enemy["target"] = party[0]["id"] if party else None + if party: + enemy["threat"][party[0]["id"]] = 1.0 + enemy["since"] = t + return + top = max(table, key=table.get) + current = enemy["target"] + if current in table and table[top] <= table[current] * STICKY: + return + enemy["target"], enemy["since"] = top, t + + +def _enemy_effect(state: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None: + spec = ENEMY_ABILITIES[ab_id] + kind, c, school = spec["effect"] + amount = c * (enemy["max_hit"] + 1) / 2 * enemy["enraged"] + if kind == "hit": + target = state["units"].get(target_id or "") + if target and _alive(target): + _hurt(state, enemy, target, amount * (1 + rng.uniform(-SPREAD, SPREAD)), + school or enemy["attack"][0], t, rng, label=ab_id, crit=False) + else: + _event(state, t, enemy["id"], None, "use", 0, ab_id) + for unit in _party(state): + _hurt(state, enemy, unit, amount * (1 + rng.uniform(-SPREAD, SPREAD)), school, t, rng, + label=ab_id, crit=False) + + +# --- blows --------------------------------------------------------------------- + +def _swing(state: dict, unit: dict, t: float, rng: random.Random) -> None: + if t < unit["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit.get("cc", 0) > t: + return + target = state["units"].get(unit["target"] or "") + if not target or not _alive(target): + return + unit["next"] = t + unit["interval"] + if unit["side"] == "party": + top = max(2, round(unit["base"] * _affinity(target, unit["damage"]))) + if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]): + _event(state, t, unit["id"], target["id"], "miss") + return + crit = rng.random() < unit["crit"] + amount = top * CRIT_MULT if crit else rng.randint(1, top) + _hurt(state, unit, target, amount, unit["damage"], t, rng, crit=False, critted=crit, swing=True) + return + attack = unit["attack"][unit["swings"] % len(unit["attack"])] + unit["swings"] += 1 + if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]): + _event(state, t, unit["id"], target["id"], "miss", 0, attack) + return + if rng.random() < target["block"]: + _event(state, t, unit["id"], target["id"], "block", 0, attack) + return + crit = rng.random() < ENEMY_CRIT + amount = (unit["max_hit"] * CRIT_MULT if crit else rng.randint(1, unit["max_hit"])) * unit["enraged"] + _hurt(state, unit, target, amount, attack, t, rng, crit=False, critted=crit, swing=True) + + +def _hurt(state: dict, source: dict | None, target: dict, amount: float, school: str, t: float, + rng: random.Random, threat: float = 1.0, crit: bool = True, critted: bool = False, + swing: bool = False, label: str = "", tick: bool = False) -> None: + if not _alive(target) or amount <= 0: + return + if crit and source and source["side"] == "party" and rng.random() < source["crit"]: + amount, critted = amount * CRIT_MULT, True + if source: + for aura in source["auras"]: + if aura["kind"] == "done": + amount *= aura["mult"] + for aura in target["auras"]: + if aura["kind"] in ("taken", "vuln"): + amount *= aura["mult"] + if target["side"] == "party": + amount *= target["taken"]["magic" if school == "magic" else "physical"] + amount = max(1, round(amount)) + dealt = amount + for aura in list(target["auras"]): + if aura["kind"] == "shield" and amount: + soak = min(amount, round(aura["amount"])) + aura["amount"] -= soak + amount -= soak + if aura["amount"] < 1: + target["auras"].remove(aura) + target["hp"] -= amount + if source and source["side"] == "party": + source["dealt"] += dealt + if target["side"] == "party": + target["hurt"] += amount + kind = "absorb" if not amount else "tick" if tick else "crit" if critted else "hit" + _event(state, t, source["id"] if source else None, target["id"], kind, dealt, label or school) + if target["side"] == "enemy" and source: + boost = TANK_THREAT if source.get("role") == "tank" else 1 + target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + dealt * threat * boost + if target["cc"] > t: + target["cc"] = 0 + _event(state, t, source["id"], target["id"], "free") + if target["hp"] <= 0: + _fall(state, target, t) + + +def _heal(state: dict, source: dict | None, target: dict, amount: float, t: float, label: str, + tick: bool = False) -> None: + if not target or not _alive(target): + return + gain = max(0, min(round(amount), target["top"] - target["hp"])) + target["hp"] += gain + _event(state, t, source["id"] if source else target["id"], target["id"], "mend" if tick else "heal", gain, label) + if source and gain: + _heal_threat(state, source, gain) + + +def _heal_threat(state: dict, source: dict, amount: float) -> None: + enemies = _enemies(state) + for enemy in enemies: + enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies) + + +def _fall(state: dict, unit: dict, t: float) -> None: + if unit["side"] == "enemy": + unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, [] + state["kills"].append(unit["id"]) + _event(state, t, None, unit["id"], "die") + for other in _party(state): + if other["target"] == unit["id"]: + other["target"] = None + return + if unit["wind"]: + # Second Wind: the knockout that was coming turns into half health, once per outing. + unit["wind"], unit["wind_used"] = False, True + unit["hp"] = unit["top"] / 2 + _event(state, t, unit["id"], unit["id"], "wind") + return + unit["down"] = True + _out(state, unit, "down", t) + + +def _out(state: dict, unit: dict, why: str, t: float) -> None: + unit["out"], unit["cast"], unit["auras"] = why, None, [] + _event(state, t, unit["id"], unit["id"], why) + for enemy in _enemies(state): + enemy["threat"].pop(unit["id"], None) + if enemy["target"] == unit["id"]: + enemy["target"] = None diff --git a/flake.nix b/flake.nix index bb7d2de..8ec29b3 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,7 @@ chmod -R u+w source cd source python3 -m unittest discover -s tests -v - python3 -m py_compile app.py game.py + python3 -m py_compile app.py game.py combat.py node --check static/app.js touch $out ''; diff --git a/game.py b/game.py index 5ad2019..ea78704 100644 --- a/game.py +++ b/game.py @@ -12,6 +12,8 @@ import math import random from datetime import date +import combat + HOUR = 3600 DAY = 24 * HOUR OFFLINE_CAP = 24 * HOUR @@ -22,8 +24,10 @@ LUCKY_CHANCE = 0.1 OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose MAX_LOG = 200 REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour -EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max -TOO_HURT = 0.3 # won't start a fight below this share without food +REST_RATE = 0.01 # share of max HP back per game second resting between fights... +BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster +KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out +KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting def _xp_table(max_level: int = 99) -> list[int]: @@ -57,7 +61,7 @@ ITEMS = { "bricks": {"name": "Bricks", "icon": "🧱", "value": 3}, "cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5}, "beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25}, - # `food` feeds that many townsfolk for two days; `heal` restores HP in a fight. + # `food` feeds that many townsfolk for two days; `heal` restores HP resting between fights. "bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8}, "cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12}, "stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20}, @@ -467,6 +471,14 @@ for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS: "value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"], "potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}} +# Health potions: the one heal you take into a fight, drunk once a fight when you're low. +# Each tier is the tier's herb and its cooked fish. +HEALTH_HEAL = [20, 40, 65, 90, 115, 140] +HEALTH_POTIONS = [(f"health_potion_{_tier + 1}", f"Health potion {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0], + FISH[_tier][1][0], HEALTH_HEAL[_tier]) for _tier in range(len(HEALTH_HEAL))] +for _id, _name, _level, _herb, _fish, _heal in HEALTH_POTIONS: + ITEMS[_id] = {"name": _name, "icon": "❤️", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_fish]["value"], "drink": _heal} + # What the city eats first: plain food before the good stuff. FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"])) EQUIP_SLOTS = (["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket", "storage", "bag"] @@ -624,11 +636,14 @@ def _tiered_actions() -> list[dict]: # expeditions: one area per tier, each with four races (each feeding one weapon # line with its trophy, and every armour line with the area's reagents) and a boss. CRIT_CHANCE = 0.05 -CRIT_MULT = 1.5 -RESPAWN = 2 # seconds after a kill before the next enemy turns up -MAX_FIGHT = 600 # a fight that drags on longer is broken off -FLEE_BELOW = 0.2 # out of food and under this share of HP: get out -EAT_DELAY = 1.5 # seconds each bite holds back your next swing +CRIT_MULT = combat.CRIT_MULT +RESPAWN = 2 # the least rest between one fight and the next +GROUP_SIZE = 2 # bosses and dragons are only fought this many together... +BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter... +BOSS_HITS = (1.3, 0.15) # ...and hit this hard against a pair, and this much harder for each fighter more +MAX_FIGHT = combat.MAX_FIGHT +PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you... +OUTNUMBERED = (0.65, 0.65) # ...each of them then with this share of the HP and the hits DAMAGE_TYPES = { "slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"}, @@ -663,14 +678,16 @@ def _slug(name: str) -> str: return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_") +TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP + # Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up. ARCHETYPES = { "brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4), "skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4), "caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3), "elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1), - "boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1), - "dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 1), + "boss": (1.4, 3.0, 1.0, 1.0, 1.2, 1), + "dragon": (2.5, 3.0, 1.1, 1.0, 1.9, 1), } @@ -682,7 +699,7 @@ def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resist hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype] hp = round((20 + 6 * level) * hp * (2.5 if boss else 1)) return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight, - "hp": hp, "interval": interval, "xp": round(hp * 0.13 + level * 0.5), + "hp": round(hp * TOUGHER), "interval": interval, "xp": round(hp * 0.13 + level * 0.5), "accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion), "max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))), "attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)} @@ -704,9 +721,10 @@ def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: i def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict: - """One big enemy: everyone who brings it down gets their own loot.""" + """One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings + it down gets their own loot.""" return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)], - boss=extra.pop("is_boss", True), personal_loot=True, **extra) + boss=extra.pop("is_boss", True), personal_loot=True, together=GROUP_SIZE, **extra) # Rosters: (variant, archetype, attacks with, vulnerable to, resists). @@ -941,7 +959,7 @@ COMBAT_ACTIONS = [ _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4, drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), *_area_fights(), - # Area bosses open at the area's level + 5, halfway through it: hard alone, comfortable together. + # Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights. _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"), ("blunt", "pierce"), gold=25, req=20, drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]), @@ -974,6 +992,9 @@ ACTIONS = [ _fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}", fire="still", fuel=POTION_TIER[potion]) for potion, name, level, herb, second, skill, _effect in POTIONS], + *[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6), + _fuelled({herb: 1, fish: 1}, tier), {potion: 1}, group=f"Tier {ROMAN[tier]}", fire="still", fuel=tier) + for tier, (potion, name, level, herb, fish, _heal) in enumerate(HEALTH_POTIONS)], _action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}), _action("hunt_deer", "hunting", "Hunt deer", 1, 25, 6, outputs={"raw_meat": 2, "hide": 1}), _action("hunt_boar", "hunting", "Hunt boar", 15, 35, 15, outputs={"raw_meat": 2, "boar_hide": 1}), @@ -1140,7 +1161,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 25 +WORLD_VERSION = 26 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1337,6 +1358,14 @@ def migrate(world: dict, now: float | None = None) -> dict: if player["action"]: player["action"].pop("fight", None) world["version"] = 25 + if world["version"] < 26: + # Packs and abilities: fights in progress are drawn up again. + for player in world["players"].values(): + if player["action"]: + player["action"].pop("fight", None) + for party in world["parties"].values(): + party["fight"] = None + world["version"] = 26 return world @@ -1722,13 +1751,17 @@ def max_hp(player: dict) -> int: return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp")) +def regen_boost(world: dict | None, player: dict) -> float: + """How much faster than usual you heal: the enchanted hearths, and amulets of regeneration.""" + boost = 2 if world and fitted(world, "enchanted_hearths") else 1 + return boost * (1 + gear_bonus(player, "regen") / 100) + + def regen(world: dict, player: dict, now: float) -> None: """Heal for the time since HP was last settled.""" if now > player["hp_at"]: top = max_hp(player) - boost = 2 if fitted(world, "enchanted_hearths") else 1 - boost *= 1 + gear_bonus(player, "regen") / 100 - per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR + per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * speed(world) / HOUR player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second) player["hp_at"] = now @@ -1746,20 +1779,23 @@ def affinity(enemy: dict, damage: str) -> float: def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict: """Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows, - ward soaks magic, both help you dodge, and a shield blocks.""" + ward soaks magic, both help you dodge, and a shield blocks. The weapon and your trade give your + abilities; a sword and shield hold the line, a staff heals.""" style_id = weapon_style(player) style = STYLES[style_id] power = combat_power(player) + blessed["power"] + brew.get("power", 0) - damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1) + base = (2 + power / 3) * style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1) lands = affinity(enemy, style["damage"]) if enemy else 1 taken = blessed["taken"] * (1 - brew.get("reduce", 0)) - return {"style": style_id, "damage": style["damage"], + shield = bool(player["equip"].get("shield")) + return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield), + "abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield), "interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2), "accuracy": (10 + 2 * power) * style["accuracy"], - "max_hit": max(2, round((2 + power / 3) * damage * lands)), + "base": base, "max_hit": max(2, round(base * lands)), "crit": style["crit"] + gear_bonus(player, "crit") / 100, "first": style.get("first", False), "affinity": lands, - "block": BLOCK_CHANCE if player["equip"].get("shield") else 0, + "block": BLOCK_CHANCE if shield else 0, "evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)), "taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}} @@ -1768,62 +1804,91 @@ def _taken(me: dict, damage: str) -> float: return me["taken"]["magic" if damage == "magic" else "physical"] -def hit_chance(accuracy: float, evasion: float) -> float: - return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15)) +hit_chance = combat.hit_chance def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float: return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT -def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, bite: int = 0) -> dict: - """`bite` is what the next food heals: eating holds back your swings, so a fight you have - to eat through runs longer.""" +def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]: + """Resting back to full before a fight: how many game seconds, and the food eaten on the way. + A bite is only taken when it's quicker than resting, so better food makes for shorter rests.""" + top = max_hp(player) + missing = top - (player["hp"] if hp is None else hp) + rate = top * REST_RATE * regen_boost(world, player) + seconds, eaten = 0.0, {} + for food in edible(player): + heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0) + while missing > 0 and left and min(heal, missing) / rate > BITE: + eaten[food] = eaten.get(food, 0) + 1 + left -= 1 + missing -= heal + seconds += BITE + return max(RESPAWN, seconds + max(0.0, missing) / rate), eaten + + +def health_potion(player: dict) -> str | None: + """The strongest health potion in the bank: the one you'd drink in a fight.""" + potions = [item for item in player["bank"] if ITEMS[item].get("drink")] + return max(potions, key=lambda item: ITEMS[item]["drink"]) if potions else None + + +# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on +# average (from tools/balance.py). Only the encounter cards' estimates use them. +ABILITY_SHARE = {"sword": 1.15, "hammer": 1.45, "staff": 1.35, "bow": 1.3} +ENEMY_SHARE = {"brute": 1.4, "skirmisher": 1.3, "caster": 1.6, "elite": 1.5, "boss": 1.6, "dragon": 1.8} +GUARD_SHARE = {"sword": 0.73, "hammer": 0.85, "staff": 0.33, "bow": 0.72} # what your abilities keep off you +PACK_SHARE = 1 + PACK_EXTRA * (2 * OUTNUMBERED[0] - 1) # a pack's HP, for one of you, against a lone enemy's + + +def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, pack: float = 1.0) -> dict: me = fighter(player, blessed, brew, foe) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) - kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"]) + dps = hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"] * ABILITY_SHARE[me["style"]] + kill = min(foe["hp"] * pack / dps, MAX_FIGHT) taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"]) per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] + per_second *= ENEMY_SHARE[foe["archetype"]] * GUARD_SHARE[me["style"]] * pack per_second = max(0, per_second - blessed["mend"] * max_hp(player)) - if bite: - eating = per_second * EAT_DELAY / bite # seconds spent eating per second of fighting - kill = kill / (1 - eating) if eating < 1 else MAX_FIGHT - kill = min(kill, MAX_FIGHT) - hurt = per_second * kill return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"], "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))), - "kill_time": kill, "damage": hurt} + "kill_time": kill, "damage": per_second * kill} -def fight_odds(player: dict, action: dict) -> dict: - """What a fight in this encounter looks like on average, for the cards, and against each variant.""" +def fight_odds(player: dict, action: dict, world: dict | None = None) -> dict: + """What a fight in this encounter looks like on average, for the cards, and against each variant: + the time to win it, the damage it costs, and the rest after it.""" blessed, brew = blessing(player), potion_effect(player, "combat") variants = action["enemy"]["variants"] - pantry = edible(player) - bite = ITEMS[pantry[0]]["heal"] if pantry else 0 - rows = [_variant_odds(player, foe, blessed, brew, bite) for foe in variants] + pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair + rows = [_variant_odds(player, foe, blessed, brew, pack) for foe in variants] total = sum(foe["weight"] for foe in variants) mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total + top = max_hp(player) + rest = rest_plan(world, player, top - min(top, mean("damage")))[0] return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player), "max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows), - "kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), + "kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), "rest": round(rest, 1), "variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1), "damage": round(row["damage"], 1)} for row in rows]} -def fight_member(world: dict, player: dict, foe: dict, at: float = 0.0) -> dict: +def fight_member(world: dict, player: dict, at: float = 0.0) -> dict: """One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the fight, so playing it out again later goes the same way.""" blessed, brew = blessing(player), potion_effect(player, "combat") - me = fighter(player, blessed, brew, foe) + me = fighter(player, blessed, brew) + potion = health_potion(player) return {"id": player["id"], "name": player["name"], "at": at, "left": None, - "start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"], - "interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"], - "pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"], + "start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"], + "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"]} -FIGHT_PRIVATE = ("fighter", "hp_in", "pantry", "mend", "wind") +FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows") def fight_view(fight: dict) -> dict: @@ -1836,120 +1901,60 @@ def member_of(fight: dict, uid: str) -> dict: return next(member for member in fight["members"] if member["id"] == uid) -def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict: - """Pick the enemy and play the fight out for everyone in it.""" +def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]: + """Who you're up against: one of the encounter's variants, as many as there are of you and + often one more, weaker each when they outnumber you. A rare elite or a boss comes alone.""" variants = action["enemy"]["variants"] - variant = rng.choices(range(len(variants)), [v["weight"] for v in variants])[0] - foe = variants[variant] - pace = speed(world) - fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"], - "xp": foe["xp"], "enemy_interval": foe["interval"] / pace, - "foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")}, - "members": [fight_member(world, player, foe) for player in players]} - return play_fight(action, fight) + weights = [foe["weight"] for foe in variants] + first = rng.choices(range(len(variants)), weights)[0] + picks, (hp, hits) = [first], (1.0, 1.0) + if action.get("together"): + hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE)) + if len(variants) > 1 and variants[first]["archetype"] != "elite": + count = size + (rng.random() < PACK_EXTRA) + common = [i for i, foe in enumerate(variants) if foe["archetype"] != "elite"] + picks += rng.choices(common, [weights[i] for i in common], k=count - 1) + if count > size: + hp, hits = OUTNUMBERED + share = 1.0 if action.get("together") else hp # a boss grown for its group still pays each of them one boss + return [{**{key: variants[i][key] for key in ("name", "archetype", "level", "boss", "interval", "accuracy", + "evasion", "attack", "vulnerable", "resists")}, + "variant": i, "hp": max(1, round(variants[i]["hp"] * hp)), "max_hit": max(1, round(variants[i]["max_hit"] * hits)), + "xp": variants[i]["xp"] * share, "share": share} for i in picks] + + +def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict: + """Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the + fight out.""" + 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": action["enemy"]["name"], + "pack": draw_pack(action, len(players), rng), "members": members} + 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 play_fight(action: dict, fight: dict) -> dict: - """Play out a fight swing by swing, every swing timer starting empty (a latecomer's from when they - tag in). Nothing changes until it's over: the result is applied then. The dice come from the fight's - own seed, so playing it again with someone tagging in or stepping out changes nothing before then.""" - rng = random.Random(fight["seed"]) - foe = action["enemy"]["variants"][fight["variant"]] - pace = fight["pace"] - sides = [] - for member in fight["members"]: - me = member["fighter"] - # A bow looses its first arrow before the enemy closes in. - sides.append({"member": member, "me": me, "hp": member["hp_in"], "pantry": dict(member["pantry"]), - "next": member["at"] + (0.5 if me["first"] else me["interval"]), "eaten": {}, "hurt": 0, - "dealt": 0, "wind": member["wind"], "out": None, - "hit": hit_chance(me["accuracy"], foe["evasion"]), "their_hit": hit_chance(foe["accuracy"], me["evasion"])}) - foe_hp, theirs, events, t, won = foe["hp"], foe["interval"], [], 0.0, False - swings = 0 # an enemy with more than one attack takes turns with them - while True: - # The next thing to happen: someone stepping out, someone's swing (in order of who came first), the enemy's. - moves = [(theirs, 2, -1)] - for i, side in enumerate(sides): - if not side["out"]: - if side["member"]["left"] is not None: - moves.append((side["member"]["left"], 0, i)) - moves.append((side["next"], 1, i)) - if len(moves) == 1: - break - t, kind, i = min(moves) - if t > MAX_FIGHT: - for side in sides: - side["out"] = side["out"] or "fled" - break - if kind == 0: - sides[i]["out"] = "left" - continue - if kind == 1: - side, me = sides[i], sides[i]["me"] - top = side["member"]["top"] - side["next"] += me["interval"] - side["hp"] = min(top, side["hp"] + side["member"]["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing - crit = rng.random() < me["crit"] - landed = rng.random() < side["hit"] - dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0 - foe_hp = max(0, foe_hp - dealt) - side["dealt"] += dealt - events.append([round(t / pace, 2), side["member"]["id"], ("crit" if crit else "hit") if landed else "miss", - dealt, round(side["hp"]), foe_hp, me["damage"]]) - if not foe_hp: - won = True - break - continue - theirs += foe["interval"] - targets = [side for side in sides if not side["out"] and side["member"]["at"] <= t] - if not targets: - continue - side = targets[0] if len(targets) == 1 else rng.choice(targets) - me, uid, top = side["me"], side["member"]["id"], side["member"]["top"] - attack = foe["attack"][swings % len(foe["attack"])] - swings += 1 - crit = rng.random() < CRIT_CHANCE - landed = rng.random() < side["their_hit"] - if landed and rng.random() < me["block"]: - events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(side["hp"])), foe_hp, attack, uid]) - continue - dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, attack))) \ - if landed else 0 - side["hp"] -= dealt - side["hurt"] += dealt - events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, - max(0, round(side["hp"])), foe_hp, attack, uid]) - pantry = side["pantry"] - while side["hp"] < top * EAT_BELOW and any(pantry.values()): - food = next(item for item, left in pantry.items() if left) - pantry[food] -= 1 - side["eaten"][food] = side["eaten"].get(food, 0) + 1 - side["hp"] = min(top, side["hp"] + ITEMS[food]["heal"]) - side["next"] += EAT_DELAY - events.append([round(t / pace, 2), uid, "eat", ITEMS[food]["heal"], round(side["hp"]), foe_hp, food]) - beaten, starving = side["hp"] <= 0, side["hp"] < top * FLEE_BELOW and not any(pantry.values()) - if (beaten or starving) and side["wind"]: - # Second Wind: the retreat that was coming turns into half health, once per outing. - side["wind"] = False - side["hp"] = top / 2 - events.append([round(t / pace, 2), uid, "wind", 0, round(side["hp"]), foe_hp]) - elif beaten: - side["hp"], side["out"] = 1, "lost" - elif starving: - side["out"] = "fled" - for side in sides: - member = side["member"] - member.update(hp=side["hp"], eaten=side["eaten"], damage=side["hurt"], dealt=side["dealt"], - wind_used=member["wind"] and not side["wind"], - result=side["out"] or ("won" if won and member["at"] <= t else "left")) - standing = [side for side in sides if side["out"] not in ("left", None)] - fight.update(events=events, end=round(t / pace, 2), duration=t / pace, - result="won" if won else standing[-1]["out"] if standing else "left") - return fight +def play_fight(fight: dict) -> dict: + """Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over: + the result is applied then. The dice come from the fight's own seed, so playing it again with + someone tagging in or stepping out changes nothing before then.""" + return combat.run(fight) + + +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"]) + 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"])) # --- prayer ------------------------------------------------------------------- @@ -2296,8 +2301,6 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: return "No raid to prepare for" if threat.get("drilled", 0) >= drill_needed(threat): return "The militia is as drilled as it can be" - if "enemy" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): - return "Too hurt to fight. Rest, or bring food" return None @@ -2352,9 +2355,8 @@ 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) - outing.pop("carry", None) # from a short-lived version that ran the swing timer on - xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met mine = member_of(fight, player["id"]) + xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"] if player["prayers"] and not mine["pp"]: summary["prayer_out"] = True paid = min(player["pp"], mine["pp"]) @@ -2362,26 +2364,33 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R player["pp"] -= paid summary["pp_spent"] += paid gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary) - for food, count in mine["eaten"].items(): - count = min(count, player["bank"].get(food, 0)) - if count: - bank_take(player, food, count) - summary["eaten"][food] = summary["eaten"].get(food, 0) + count - player["hp"], player["hp_at"] = mine["hp"], now + used = {**({mine["potion_item"]: 1} if mine["drank"] else {}), **({"arrows": mine["shot"]} if mine["shot"] else {})} + for key, spent in (("eaten", mine["eaten"]), ("used", used)): + for item, count in spent.items(): + count = min(count, player["bank"].get(item, 0)) + if count: + bank_take(player, item, count) + summary[key][item] = summary[key].get(item, 0) + count + player["hp"] = max_hp(player) * KNOCKED_OUT_HP if mine["down"] else mine["hp"] + player["hp_at"] = now summary["damage"] += mine["damage"] if mine["wind_used"]: outing["wind_used"] = True summary["second_wind"] += 1 if mine["result"] != "won": - # Beaten or out of food: fall back with nothing but your life. + # Knocked out, or the fight dragged on: nothing but some XP, and back to resting. summary["losses"] += 1 - reason = "Retreated: ran out of food" if not edible(player) else "Retreated" - drop_out(world, player, now, reason) - summary["stopped"] = reason - gain_xp(world, player, "combat", xp // 3, now, summary) + 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 \ + 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) + summary["stopped"] = reason return + outing.pop("downs", None) summary["wins"] += 1 - gold, drops = loot or roll_loot(action, rng) + gold, drops = loot or pack_loot(action, fight, rng) player["gold"] += gold summary["gold"] += gold for item, qty in drops.items(): @@ -2472,7 +2481,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar def empty_summary() -> dict: - return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, + return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0, "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None, "sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0, "kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0} @@ -2495,6 +2504,11 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N if not current: return summary limit = min(now, offline_limit(world, player)) + if ACTION_BY_ID[current["id"]].get("together"): + # A group fight with no group: wait at it for someone to tag in. + current.pop("fight", None) + current["start"] = max(current["start"], limit) + return summary while player["action"]: action = growing(player) if current.get("plot2") and can_start(world, player, action): @@ -2512,15 +2526,6 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N break burn_fires(world, current["start"] + duration) reason = can_start(world, player, action) - if reason and reason.startswith("Too hurt"): - if blessing(player)["wind"] and not current.get("wind_used"): - # Second Wind: the retreat that was coming turns into half health. - current["wind_used"] = True - player["hp"] = max_hp(player) / 2 - summary["second_wind"] += 1 - reason = None - else: - reason = "Retreated: hurt and out of food" if reason: stop(player, reason) summary["stopped"] = reason @@ -2533,8 +2538,6 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N _complete(world, player, action, current["start"] + duration, rng, summary) summary["done"] += 1 current["start"] += duration - if "enemy" in action and player["action"] is current: - current["start"] += RESPAWN / speed(world) # a moment before the next enemy turns up if current.get("plot2"): current["turn"] = 1 - current.get("turn", 0) if player["action"] is current and "left" in current: @@ -2574,12 +2577,23 @@ def drop_out(world: dict, player: dict, now: float, reason: str | None = None) - stop(player, reason) -def roll_loot(action: dict, rng: random.Random) -> tuple[int, dict]: - """A kill's gold and drops.""" - gold = rng.randint(*action["gold"]) if action.get("gold") else 0 +def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]: + """A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often.""" + gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0 drops = {} for item, qty, odds in action.get("drops", []): - if rng.random() < odds: + if rng.random() < odds * share: + drops[item] = drops.get(item, 0) + qty + return gold, drops + + +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"]) + gold += more + for item, qty in found.items(): drops[item] = drops.get(item, 0) + qty return gold, drops @@ -2651,6 +2665,8 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None: pid = str(world["next_party_id"]) world["next_party_id"] += 1 solo = host["action"] + if action.get("together"): + solo["start"] = now # waiting for a group: it starts as you arrive party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]], "start": solo["start"], "fight": solo.pop("fight", None)} solo["party"] = pid @@ -2659,10 +2675,11 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None: player["stopped"] = None fight = party["fight"] if fight and now < party["start"] + fight["duration"]: - at = max(0.0, (now - party["start"]) * fight["pace"]) - foe = action["enemy"]["variants"][fight["variant"]] - fight["members"].append(fight_member(world, player, foe, at)) - play_fight(action, fight) + # Straight in, as you are: the others may still be resting, or already in the thick of it. + regen(world, player, now) + at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"]) + fight["members"].append(fight_member(world, player, at)) + play_fight(fight) log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"]) return None @@ -2674,13 +2691,13 @@ def leave_party(world: dict, player: dict, pid: str, now: float) -> None: party["members"].remove(player["id"]) fight = party["fight"] if fight and now < party["start"] + fight["duration"]: - action = ACTION_BY_ID[party["action"]] member = member_of(fight, player["id"]) - if now <= party["start"] or len(fight["members"]) == 1: - fight["members"].remove(member) + left = (now - party["start"]) * fight["pace"] - fight["rest"] + if left <= member["at"] or len(fight["members"]) == 1: + fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought else: - member["left"] = max(member["at"], (now - party["start"]) * fight["pace"]) - party["fight"] = play_fight(action, fight) if party["members"] and fight["members"] else None + member["left"] = left + party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None if len(party["members"]) <= 1: del world["parties"][pid] for uid in party["members"]: @@ -2703,15 +2720,6 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None for player in members: regen(world, player, party["start"]) reason = can_start(world, player, action) - if reason and reason.startswith("Too hurt"): - if blessing(player)["wind"] and not player["action"].get("wind_used"): - # Second Wind: the retreat that was coming turns into half health. - player["action"]["wind_used"] = True - player["hp"] = max_hp(player) / 2 - player.setdefault("away", empty_summary())["second_wind"] += 1 - reason = None - else: - reason = "Retreated: hurt and out of food" if reason: drop_out(world, player, party["start"], reason) player.setdefault("away", empty_summary())["stopped"] = reason @@ -2735,15 +2743,15 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None break burn_fires(world, end) fight, party["fight"] = party["fight"], None - party["start"] = end + RESPAWN / speed(world) # a moment before the next enemy turns up + party["start"] = end for player in members: 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"): - shares = {uid: roll_loot(action, rng) for uid in winners} + shares = {uid: pack_loot(action, fight, rng) for uid in winners} else: - shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {} + shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {} for player in members: if results.get(player["id"], "left") == "left": continue @@ -3101,6 +3109,8 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo leave_party(world, player, current["party"], now) player["action"] = entry player["stopped"] = None + if action.get("together"): + log(world, now, "party", f"{player['name']} is looking for a group: {action['name']}", player["id"]) return None @@ -4711,6 +4721,8 @@ def _assign_glyphs() -> None: for potion, *_rest, skill, effect in POTIONS: tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1] ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint) + for tier, (potion, *_rest) in enumerate(HEALTH_POTIONS): + ITEMS[potion].update(glyph=flasks[tier], tint="#e0506e") for skill in SKILLS: skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]] for key, spec in CLASSES.items(): @@ -4752,6 +4764,27 @@ 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": "Combat rework", "notes": [ + ("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). " + "A rare elite still comes alone."), + ("new", "Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield " + "tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: " + "hover them on the Combat page."), + ("new", "Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn't tanking, casters " + "cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack " + "whoever has the most threat, so a sword and shield draws them away from the rest."), + ("new", "Food is only eaten resting between fights: you rest back to full before each one, and better food " + "rests you faster. No food just means longer rests."), + ("new", "Health potions I–VI (Herblore 1–75, the tier's herb and cooked fish): the one heal you take into a " + "fight, drunk once a fight under 35% HP."), + ("new", "Knocked out at 0 HP: you lose that fight and come round at 30% HP. Three in a row and you stop. " + "Retreating is gone."), + ("new", "Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. " + "They grow with the group: +75% HP for each fighter."), + ("balance", "Enemies have 20% more HP for the same XP, since everyone has abilities now."), + ("balance", "Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level's max hit, then ×1.3 " + "against a pair."), + ]}, {"date": "2026-10-23", "title": "City needs", "notes": [ ("new", "City needs are now indicated in profession menus."), ]}, @@ -4793,7 +4826,9 @@ def content() -> dict: "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "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, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, + "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, "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/package.nix b/package.nix index fd59f04..b248d14 100644 --- a/package.nix +++ b/package.nix @@ -10,7 +10,7 @@ stdenvNoCC.mkDerivation { installPhase = '' runHook preInstall mkdir -p $out/share/guildhall $out/bin - cp app.py game.py $out/share/guildhall/ + cp app.py game.py combat.py $out/share/guildhall/ cp -r static $out/share/guildhall/static makeWrapper ${python3}/bin/python3 $out/bin/guildhall \ --add-flags "$out/share/guildhall/app.py" diff --git a/static/app.js b/static/app.js index 919706c..d21e6cd 100644 --- a/static/app.js +++ b/static/app.js @@ -192,6 +192,7 @@ function awayModal(sum) {
You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.
${Object.keys(sum.with || {}).length ? `⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}
` : ""} ${Object.keys(sum.eaten || {}).length ? `Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.
` : ""} + ${Object.keys(sum.used || {}).length ? `Used ${Object.entries(sum.used).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.
` : ""} ${items ? `+${fmt(sum.gold)} gold
` : ""} ${xp ? `