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 ? `` : ""} ${sum.gold ? `

+${fmt(sum.gold)} gold

` : ""} ${xp ? `` : ""} @@ -888,38 +889,58 @@ function variantRows(e, o) { }).join("")}`; } -// Everything about fighting in one frame. Left: you, your prayers (switch them mid-fight) and your -// gear (swap it mid-fight). Right: what you're fighting, what it attacks with, what it's weak to and -// resists (your weapon's type outlined), and the fight as it plays out. +// Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto), +// your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with +// what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as +// it plays out. function combatFrame() { const me = S.me, a = me.action, f = a?.fight; - const encounter = f && C.actions.find((x) => x.id === a.id); - const allies = f ? f.members.filter((m) => m.id !== S.uid) : []; - const bars = (who) => `
-
`; - const you = `
-
${esc(me.name)}${HEART} ${fmt(me.hp)} / ${me.max_hp}
- ${bars("you")} -
${allies.map((m, i) => `
-
${esc(m.name)}${HEART}
-
-
`).join("")}`; - let foe = `
Not fighting.
`; + 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 = "") => `
+
${name}${extra} ${foe ? "" : HEART} ${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}
+
+
+
+
`; + const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join(""); + let foe = `
${waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}
`; if (f) { - const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage; - const pills = (label, kinds, how) => kinds?.length ? `
${label} - ${kinds.map((k) => `${dmgIcon(k, how)}${C.damage_types[k].name}`).join("")}
` : ""; - foe = `
${encounter?.icon || ""} -
${esc(f.enemy)}
${bars("foe")}
- ${f.foe ? `
${pills("Attacks with", f.foe.attack, "attack")}${pills("Weak to", f.foe.vulnerable, "vuln")}${pills("Resists", f.foe.resists, "resist")}
` : ""} + const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage; + const traits = (v) => `${v.attack.map((k) => dmgIcon(k, "attack")).join("")} + ${v.vulnerable.map((k) => `${dmgIcon(k, "vuln")}`).join("")}${v.resists.map((k) => `${dmgIcon(k, "resist")}`).join("")}`; + foe = `
${encounter?.icon || ""}${esc(f.enemy)}
+
${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" ? ` · rare` : ""}`, true, + ` ${traits(v)}`)).join("")}
`; } return `
-
${you}${prayerBar(me)}${paperDoll(me, true)}
+
${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}
${foe}
`; } +// Your abilities, from your weapon and your trade, on auto: each lights up as it's used and shows its +// cooldown. The weapon's ones still to come are shown dimmed with the level they're learned at. +function abilityBar(me) { + const known = me.fighter.abilities, style = me.fighter.style || "sword"; + const shield = !!me.equip.shield; + const tile = (id, on) => { + const ab = C.abilities[id]; + const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`; + const tip = `${ab.name}: ${ab.tip}${ab.cd ? ` · ${ab.cd * C.auto_cd}s on auto` : ""}${why ? ` · ${why}` : ""}`; + return `${ab.icon} + ${on ? "" : `${why === "needs a shield" ? "🛡️" : ab.level}`}`; + }; + const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id))); + const trade = C.trade_ability[me.cls]; + const potion = me.health_potion; + return `
${kit.join("")}${trade ? `${tile(trade, true)}` : ""} + ❤️${potion ? `${fmt(me.bank[potion])}` : ""}
`; +} + // Prayers you can switch on and off between swings, and the points left to pay for them. function prayerBar(me) { const level = me.levels.prayer; @@ -930,72 +951,129 @@ function prayerBar(me) { return `
${skillOf("prayer").icon} ${fmt(me.pp)}${buttons}
`; } -// A line of the fight: yours as "You", everyone else's by name. -function fightLine(e, enemy) { - const [, who, kind, amount, , , food, target] = e; - const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : ""; - const foe = esc(enemy); - const subject = who === "foe" ? target : who, mine = subject === S.uid; - const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s"; - if (kind === "eat") return `
  • ${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})
  • `; - if (kind === "wind") return `
  • Second wind! ${mine ? "Back on your feet" : `${name} is back up`}
  • `; - if (kind === "block") return `
  • ${name} block${s} ${foe}'s blow
  • `; - if (who !== "foe") return kind === "miss" ? `
  • ${name} miss${mine ? "" : "es"}
  • ` : `${name} ${kind === "crit" ? `crit${s}` : `hit${s}`} for ${amount}${type}`; - return kind === "miss" ? `
  • ${foe} misses
  • ` : `
  • ${foe} ${kind === "crit" ? "crits" : "hits"} ${mine ? "you" : name} for ${amount}${type}
  • `; +const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]); + +// A line of the fight, terse: who, to whom, how much, with what. +function fightLine(e, f) { + const [, src, tgt, kind, n, , label] = e; + const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id)); + const ab = C.abilities[label] || C.enemy_abilities[label]; + const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : ""; + const hurtsMe = tgt === S.uid && src?.[0] === "e"; + const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted"; + switch (kind) { + case "hit": case "crit": case "tick": + return `
  • ${name(src)} → ${name(tgt)} ${n}${kind === "crit" ? " crit" : ""}${what}
  • `; + case "miss": return `
  • ${name(src)} → ${name(tgt)} miss
  • `; + case "block": return `
  • ${name(tgt)} block${tgt === S.uid ? "" : "s"}${what}
  • `; + case "absorb": return `
  • 🔮 ${name(tgt)} soak${tgt === S.uid ? "" : "s"} ${n}
  • `; + case "cast": return `
  • ${name(src)}${what}…
  • `; + case "use": return `
  • ${name(src)}${what}${tgt && tgt !== src ? ` → ${name(tgt)}` : ""}
  • `; + case "stop": return `
  • ${name(src)} ✋ ${name(tgt)}${what}
  • `; + case "heal": return `
  • ${label === "potion" ? `${name(tgt)} ❤️ potion` : `${name(src)}${what} → ${name(tgt)}`} +${n}
  • `; + case "mend": return `
  • ${name(tgt)} +${n}${what}
  • `; + case "cc": return `
  • ${what} ${name(tgt)} held
  • `; + case "free": return `
  • ${name(tgt)} breaks free
  • `; + case "die": return `
  • ${name(tgt)} falls
  • `; + case "enrage": return `
  • 😡 ${name(src)} enrages
  • `; + case "wind": return `
  • 🌬️ Second wind! ${name(src)}
  • `; + case "down": return `
  • ${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}
  • `; + case "left": return `
  • ${name(src)} left
  • `; + } + return ""; +} + +// Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not, +// and when each of your abilities was last used. +function fightState(f, t) { + const rest = f.rest / f.pace, st = {}; + for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, cast: null, out: null, cds: {} }; + f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false }; }); + let shown = -1; + while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) { + const [time, src, tgt, kind, , hp, label] = f.events[++shown]; + const who = st[src], them = st[tgt]; + if (them && hp != null) them.hp = hp; + if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time; + if (who && kind === "cast") { + const spec = src[0] === "e" ? C.enemy_abilities[label] : C.abilities[label]; + who.cast = { label, from: time, until: time + spec.cast / f.pace }; + } + const ab = C.abilities[label]; + if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind))) who.cds[label] = time; + if (who && kind === "heal" && label === "potion") who.drank = true; + if (them && (kind === "stop" || kind === "cc")) them.cast = null; + if (them && kind === "cc") them.held = true; + if (them && (kind === "free" || kind === "die")) them.held = false; + if (them && kind === "die") them.out = "dead"; + if (who && (kind === "down" || kind === "left")) who.out = kind; + if (who && kind === "enrage") who.enraged = true; + } + return { st, shown }; } function animateFight(now) { const a = S.me?.action, f = a?.fight; if (!f) return; - const t = now - a.start; + const t = now - a.start, rest = f.rest / f.pace; const over = t >= f.end; + const resting = t < rest; + const { st, shown } = fightState(f, t); const mine = f.members.find((m) => m.id === S.uid) || f.members[0]; - // From the kill until the next enemy turns up (the next fight's start, once the server sends it), - // the panel is the same quiet countdown: no enemy, every swing timer empty. - const waiting = t < 0 || (over && f.result === "won" && mine.result === "won"); - const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t; - let shown = -1; - while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; - const last = f.events[shown]; - // Each fighter's HP is in their own swings and bites, and in the enemy's blows at them. - const about = (e, m) => e[1] === m.id || (e[1] === "foe" && e[7] === m.id); - const hpOf = (m) => { for (let i = shown; i >= 0; i--) if (about(f.events[i], m)) return f.events[i][4]; return m.start_hp; }; - const swungAt = (id) => { for (let i = shown; i >= 0; i--) if (f.events[i][1] === id && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") return f.events[i][0]; return null; }; - const hp = hpOf(mine), foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp; - let fell = Infinity; // beaten or out of food while the others fight on - if (mine.result === "lost" || mine.result === "fled") for (let i = f.events.length - 1; i >= 0; i--) if (about(f.events[i], mine)) { fell = f.events[i][0]; break; } - for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`; + const me = st[mine.id]; + // Resting: everyone's HP climbs back to full while they eat. + if (resting) for (const m of f.members) st[m.id].hp = m.start_hp + (m.hp_in - m.start_hp) * Math.min(1, t / rest); + const enemies = f.pack.map((_, i) => st[`e${i}`]); + const left = enemies.reduce((sum, e) => sum + e.hp, 0), total = enemies.reduce((sum, e) => sum + e.top, 0); + for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${(resting ? 1 : left / total) * 100}%`; + for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(me.hp); + for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, me.hp / me.top * 100)}%`; const panel = $("[data-fight]"); if (!panel) return; - panel.classList.toggle("waiting", waiting); - // Your HP everywhere follows the fight as it plays out. - for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp); - for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / mine.top * 100)}%`; - const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); }; - set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / mine.top * 100)}%`; }); - set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; }); - set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(mine.top)}`; }); - set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; }); - const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => { - el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / every * 100)}%`; - }); - swing("you", swungAt(mine.id), mine.interval, mine.at / f.pace, t >= fell); - swing("foe", swungAt("foe"), f.enemy_interval); - f.members.filter((m) => m.id !== S.uid).forEach((m, i) => { - const ally = hpOf(m), left = m.left != null && t >= m.left / f.pace; - set(`ally${i}-bar`, (el) => { el.style.width = `${Math.max(0, ally / m.top * 100)}%`; }); - set(`ally${i}-hp`, (el) => { el.textContent = t < m.at / f.pace ? "joining…" : left ? "left" : `${fmt(ally)} / ${fmt(m.top)}`; }); - swing(`ally${i}`, swungAt(m.id), m.interval, m.at / f.pace, left); - }); - set("log", (el) => { - const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`; - if (el.dataset.key === key) return; - el.dataset.key = key; - const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy)); - if ((over && !waiting) || t >= fell) lines.unshift(`
  • You fall back.
  • `); - el.innerHTML = waiting ? `
  • Victory! ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}
  • ` - : lines.join("") || `
  • Squaring up…
  • `; - }); + panel.classList.toggle("waiting", resting); + for (const el of panel.querySelectorAll("[data-u]")) { + const u = st[el.dataset.u]; + if (!u) continue; + const gone = !!u.out || over; + el.classList.toggle("out", !!u.out); + el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`; + el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`; + el.querySelector("[data-u-status]").textContent = u.out === "dead" ? "💀" : u.out === "down" ? "💫" : u.held ? "🌿" : u.enraged ? "😡" : ""; + el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`; + const cast = !gone && u.cast && t < u.cast.until ? u.cast : null; + const spec = cast && (el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities)[cast.label]; + const castEl = el.querySelector("[data-u-cast]"); + const key = cast ? `${cast.label}:${cast.from}` : ""; + if (castEl.dataset.key !== key) { + castEl.dataset.key = key; + castEl.innerHTML = cast ? `${spec.icon} ${esc(spec.name)}` : ""; + } + if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`; + } + // Your abilities: the shade is the cooldown left, a flash when one's just been used. + for (const el of panel.querySelectorAll("[data-ab]")) { + const id = el.dataset.ab; + if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; } + const ab = C.abilities[id], used = me.cds?.[id]; + const cd = (ab.cd || 0) * C.auto_cd / f.pace; + const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t); + el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`; + el.classList.toggle("fired", used != null && t - used < 0.6); + } + const log = panel.querySelector('[data-f="log"]'); + if (log) { + const key = `${a.start}:${shown}:${over}:${resting ? Math.ceil(rest - t) : ""}`; + if (log.dataset.key !== key) { + log.dataset.key = key; + const eaten = Object.entries(mine.eaten || {}); + const lines = f.events.slice(Math.max(0, shown - 6), shown + 1).reverse().map((e) => fightLine(e, f)).filter(Boolean); + if (over) lines.unshift(f.result === "won" ? `
  • Victory! Next fight coming…
  • ` + : `
  • ${f.result === "fled" ? "It dragged on: you fall back." : "Beaten."}
  • `); + log.innerHTML = resting ? `
  • Resting · ${Math.ceil(rest - t)}s
  • ${eaten.length + ? `
  • ${HEART} ${eaten.map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}
  • ` : ""}` + : lines.join("") || `
  • Squaring up…
  • `; + } + } } // A piece of jewellery's bonus, e.g. "+8% accuracy". @@ -1052,6 +1130,7 @@ function itemStats(id) { const it = item(id); const parts = []; if (it.heal) parts.push(`❤️ Heals ${it.heal}`); + if (it.drink) parts.push(`❤️ +${it.drink} in a fight`); if (it.food) parts.push(`feeds ${it.food} for 2 days`); if (it.power) parts.push(`+${it.power} power`); if (it.style) { @@ -1096,7 +1175,8 @@ function actionCard(a) { let extra = ""; if (a.enemy) { const o = me.fight_odds[a.id], e = a.enemy; - const own = a.personal_loot ? `👥 Own loot` : ""; + const own = (a.together ? `👥 Group ` : "") + + (a.personal_loot ? `🎁 Own loot` : ""); extra = `${e.boss || own ? `
    ${e.boss ? `Boss ` : ""}${own}
    ` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; if (a.drops) io += a.drops.map(chance).join(""); @@ -1228,8 +1308,9 @@ function itemPanel(id) { ${it.potion ? `
    ${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.
    ${S.me.potions[it.potion.skill] && S.me.potions[it.potion.skill].item !== id ? `
    Replaces your ${esc(item(S.me.potions[it.potion.skill].item).name.toLowerCase())} (${S.me.potions[it.potion.skill].charges} uses left)
    ` : ""}
    ` : ""} - ${it.heal ? `
    Heals ${it.heal} in fights.
    - ${S.me.food === id ? `Your fighting food` : ``}
    ` : ""} + ${it.heal ? `
    Heals ${it.heal} resting between fights.
    + ${S.me.food === id ? `Your fighting food` : ``}
    ` : ""} + ${it.drink ? `
    +${it.drink} HP in a fight, drunk once a fight under ${Math.round(C.potion_below * 100)}% HP.
    ` : ""} ${it.food ? `
    Feeds ${it.food} townsfolk for 2 days.
    ` : ""} ${C.city_uses[id].length ? `
    Donate to the city · +${it.value} favour each
    ${qtyBtns("data-donate")}
    @@ -1919,7 +2000,7 @@ function slot2(slot, p) { return slot in p.equip || (slot === "food" && "food" in p) ? slot : null; } -// Food for fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it. +// Food for resting between fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it. function foodSlot(p, picked) { if (!("food" in p)) return ``; const id = p.food && p.bank[p.food] ? p.food : null; @@ -2002,7 +2083,7 @@ function animate() { const now = serverNow(); const a = S?.me?.action; animateFight(now); - if (a) { + if (a && !a.waiting) { const pct = Math.min(1, (now - a.start) / a.duration); // In a fight the bar is the enemy's health (set by animateFight), not a timer. if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; @@ -2010,7 +2091,7 @@ function animate() { if (pct >= 1 && refreshedFor !== key) { refreshedFor = key; setTimeout(refresh, 150); - } else if (a.fight && now - a.start > a.duration + (a.respawn || 1) && now - retriedAt > 2) { + } else if (a.fight && now - a.start > a.duration + 1 && now - retriedAt > 2) { retriedAt = now; // the next enemy hasn't turned up yet: ask again refresh(); } diff --git a/static/styles.css b/static/styles.css index 4aec257..5f94509 100644 --- a/static/styles.css +++ b/static/styles.css @@ -553,9 +553,32 @@ button.sheet-skill:hover { background: var(--panel-2); } @media (max-width: 900px) { .combat-frame { display: flex; flex-direction: column; gap: 16px; } .you-side { display: contents; } - .you-side > .fighter { order: 1; } .foe-side { order: 2; } .prayer-bar { order: 3; } .you-side > .doll-wrap { order: 4; } + .you-side > .fighter { order: 1; } .foe-side { order: 2; } .ability-bar { order: 3; } .prayer-bar { order: 3; } .you-side > .doll-wrap { order: 4; } } .bar.mini.foe > span { background: var(--bad); } +/* Packs, casts and abilities */ +.pack { display: grid; gap: 12px; } +.fighter.out { opacity: .4; } +.traits-inline { display: inline-flex; gap: 2px; margin-left: 6px; vertical-align: middle; } +.traits-inline .v-res { display: inline-flex; gap: 2px; margin-left: 4px; } +.traits-inline .dmg .gi { width: 13px; height: 13px; } +.traits-inline .yours .dmg { box-shadow: 0 0 0 1.5px var(--gold); border-radius: 50%; } +.cast { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: center; min-height: 16px; color: var(--gold); } +.cast:empty { display: none; } +.cast .bar { height: 4px; } +.cast .bar > span { background: var(--gold); transition: none; } +.foe-unit .cast { color: var(--bad); } +.foe-unit .cast .bar > span { background: var(--bad); } +.ability-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.ab { position: relative; width: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; font-size: 20px; + background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; } +.ab.locked { opacity: .35; filter: grayscale(1); } +.ab.fired { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold); } +.ab.spent { opacity: .35; } +.ab-cd { position: absolute; left: 0; right: 0; bottom: 0; height: 0; background: rgba(0, 0, 0, .55); pointer-events: none; } +.ab-lv { position: absolute; bottom: 0; right: 2px; font-size: 10px; font-weight: 700; color: var(--muted); filter: none; } +.ab-gap { width: 6px; } +.fight-log .warn { color: var(--gold); } .pill { padding: 3px 10px 3px 3px; } .pill .dmg { border-radius: 50%; } diff --git a/tests/test_game.py b/tests/test_game.py index 9a49126..6fb97c7 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -170,6 +170,7 @@ class ActionTest(unittest.TestCase): world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["bank"] = {"bread": 500} + p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs") game.start_action(world, p, "fight_wolves", T0) summary = game.process_player(world, p, T0 + 3600, random.Random(5)) self.assertGreater(summary["done"], 30) # a fight lasts as long as it takes @@ -505,31 +506,58 @@ class CombatTest(unittest.TestCase): self.assertEqual(p["bank"].get("bread", 0), 100 - summary["eaten"]["bread"]) self.assertGreaterEqual(p["hp"], 1) - def test_out_of_food_means_retreat_and_nothing_lost(self): + def test_three_knockouts_in_a_row_stop_you_and_nothing_is_lost(self): world, p = self.fighter(level=10, weapon="copper_sword") p["bank"] = {"stone": 10} game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1)) self.assertIsNone(p["action"]) - self.assertTrue(summary["stopped"].startswith("Retreated")) + self.assertEqual(summary["stopped"], "Knocked out 3 times in a row") + self.assertEqual(summary["losses"], 3) self.assertEqual(p["equip"]["weapon"], "copper_sword") self.assertEqual(p["bank"]["stone"], 10) - self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT) - self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food") + self.assertAlmostEqual(p["hp"], game.max_hp(p) * game.KNOCKED_OUT_HP) + self.assertIsNone(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600)) # you can always go again - def test_eating_holds_back_your_swings(self): - world, p = self.fighter(level=15) - p["bank"] = {"cooked_shrimp": 100} - p["hp"] = 1 - fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3)) - mine = [event for event in fight["events"] if event[1] == p["id"]] - bite = next(i for i, event in enumerate(mine) if event[2] == "eat") - swing = next(event for event in mine[bite:] if event[2] != "eat") - self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY) - scouts = game.ACTION_BY_ID["fight_scouts"] - eating = game.fight_odds(p, scouts)["kill_time"] + def test_a_win_resets_the_knockouts(self): + world, p = self.fighter(level=1, weapon="copper_sword") + game.start_action(world, p, "fight_wolves", T0) + summary = game.process_player(world, p, T0 + 3600, random.Random(1)) + self.assertGreater(summary["losses"], 2) # knocked out now and then, never three in a row + self.assertEqual(p["action"]["id"], "fight_wolves") + self.assertGreater(summary["wins"], 20) + + def test_food_shortens_the_rest_between_fights(self): + world, p = self.fighter(level=30) + top = game.max_hp(p) p["bank"] = {} - self.assertGreater(eating, game.fight_odds(p, scouts)["kill_time"]) + resting, eaten = game.rest_plan(world, p, top / 2) + self.assertEqual(eaten, {}) + self.assertAlmostEqual(resting, top / 2 / (top * game.REST_RATE)) + p["bank"] = {"bread": 100} + bread, eaten = game.rest_plan(world, p, top / 2) + self.assertLess(bread, resting) + self.assertEqual(eaten, {"bread": 10}) # 80 HP back in 8 HP bites + p["bank"] = {"cooked_salmon": 100} + salmon, eaten = game.rest_plan(world, p, top / 2) + self.assertLess(salmon, bread) # better food, shorter rest + self.assertEqual(eaten, {"cooked_salmon": 3}) + self.assertEqual(game.rest_plan(world, p, top)[0], game.RESPAWN) # at full: a breather + p["xp"]["combat"] = game.XP_TABLE[99] + p["bank"] = {"bread": 100} + self.assertEqual(game.rest_plan(world, p, 100)[1], {}) # a bite of bread is no faster than resting + + def test_food_is_eaten_resting_not_fighting(self): + world, p = self.fighter(level=15, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs") + p["bank"] = {"cooked_trout": 100} + p["hp"] = 10 + fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3)) + mine = game.member_of(fight, p["id"]) + self.assertGreater(sum(mine["eaten"].values()), 0) + self.assertGreater(fight["rest"], game.RESPAWN) + self.assertEqual(mine["hp_in"], game.max_hp(p)) # every fight starts rested + self.assertTrue(all(e[0] >= fight["rest"] for e in fight["events"])) + self.assertFalse(any(e[3] == "eat" for e in fight["events"])) def test_rest_heals(self): world, p = self.fighter() @@ -547,20 +575,24 @@ class CombatTest(unittest.TestCase): self.assertLess(hurt(veteran), hurt(bare)) self.assertGreater(game.fight_odds(veteran, scouts)["hit"], game.fight_odds(bare, scouts)["hit"]) - def test_a_fight_is_played_out_swing_by_swing(self): - world, p = self.fighter(level=10, weapon="iron_sword") + def test_a_fight_is_played_out_slice_by_slice(self): + world, p = self.fighter(level=20, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs") p["bank"] = {"bread": 50} game.start_action(world, p, "fight_scouts", T0) game.process_player(world, p, T0 + 1, random.Random(2)) fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay self.assertEqual(fight["result"], "won") - self.assertTrue(all(e[1] in (p["id"], "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"])) - self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it + kinds = {"hit", "crit", "tick", "miss", "block", "absorb", "heal", "mend", "cast", "use", "stop", "cc", "free", + "die", "enrage", "wind", "down", "left"} + self.assertTrue(all(e[3] in kinds for e in fight["events"])) + self.assertTrue(all(e[1] in (p["id"], None) or e[1].startswith("e") for e in fight["events"])) + dead = [e[2] for e in fight["events"] if e[3] == "die"] + self.assertEqual(sorted(dead), sorted(f"e{i}" for i in range(len(fight["pack"])))) # the last of them finished it self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2)) self.assertEqual(summary["wins"], 1) self.assertAlmostEqual(p["hp"], game.member_of(fight, p["id"])["hp"], delta=0.1) - self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause + self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"]) # straight on to the next rest def test_outclassing_an_enemy_kills_faster(self): kills = {} @@ -570,15 +602,17 @@ class CombatTest(unittest.TestCase): kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"] self.assertGreater(kills[90], kills[20] * 1.3) - def test_each_enemy_starts_with_both_swing_timers_empty(self): + def test_each_fight_starts_with_every_swing_timer_empty(self): world, p = self.fighter(level=60, weapon="rune_sword") game.start_action(world, p, "fight_wolves", T0) game.process_player(world, p, T0 + 0.1, random.Random(1)) first = p["action"]["fight"] game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1)) - self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill + self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"]) # the rest starts at the kill second = p["action"]["fight"] - self.assertEqual(next(e[0] for e in second["events"] if e[1] == p["id"]), 2.4) # a full swing, from scratch + swing = next(e[0] for e in second["events"] if e[1] == p["id"] and e[6] in game.DAMAGE_TYPES) + self.assertAlmostEqual(swing, second["rest"] + 2.5) # a full swing from scratch, to the slice + def test_preferred_food_is_eaten_first(self): world, p = self.fighter() p["bank"] = {"bread": 5, "cooked_salmon": 5} @@ -587,6 +621,99 @@ class CombatTest(unittest.TestCase): self.assertEqual(game.edible(p), ["cooked_salmon", "bread"]) +class AbilityTest(unittest.TestCase): + def fighter(self, cls, level, tier=1, **equip): + world = world_with(("ash", cls)) + p = world["players"]["ash"] + p["xp"]["combat"] = game.XP_TABLE[level] + p["equip"].update(equip) + p["hp"] = game.max_hp(p) + return world, p + + def test_the_weapon_gives_four_abilities_and_the_trade_one(self): + known = lambda style, cls, level, shield=False: game.combat.known_abilities(style, cls, level, shield) + self.assertEqual(set(known("hammer", "miner", 1)), {"crushing_blow", "find_the_seam"}) + self.assertEqual(set(known("hammer", "miner", 45)), {"crushing_blow", "concuss", "earthshaker", "unstoppable", + "find_the_seam"}) + self.assertEqual(set(known("bow", "miner", 15)), {"aimed_shot", "pinning_shot", "find_the_seam"}) # any weapon + self.assertEqual(set(known("sword", "builder", 45)), {"challenge", "sweeping_cut", "barricade"}) # no shield + self.assertEqual(set(known("sword", "builder", 45, True)), {"shield_bash", "challenge", "sweeping_cut", + "shield_wall", "barricade"}) + self.assertEqual(known("staff", "homesteader", 30), ["mend", "ward", "hearty_meal", "bolt"]) # auto's order + self.assertEqual(game.combat.role("sword", True), "tank") + self.assertEqual(game.combat.role("sword", False), "damage") + self.assertEqual(game.combat.role("staff", False), "healer") + + def test_abilities_are_used_in_the_fight(self): + world, p = self.fighter("ranger", 30, weapon="willow_bow") + fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(1)) + used = {e[6] for e in fight["events"] if e[1] == p["id"] and e[6] in game.combat.ABILITIES} + self.assertIn("aimed_shot", used) + casts = [e for e in fight["events"] if e[1] == p["id"] and e[3] == "cast"] + self.assertTrue(casts) + + def test_casters_get_stopped(self): + world, p = self.fighter("ranger", 30, weapon="willow_bow") + harpies = game.ACTION_BY_ID["fight_harpies"] + stops = 0 + for seed in range(20): + fight = game.simulate_fight(world, p, harpies, random.Random(seed)) + stops += sum(1 for e in fight["events"] if e[3] == "stop" and e[6] == "hex_bolt") + self.assertGreater(stops, 0) # Pinning Shot stops a windcaster's hex + + def test_a_health_potion_once_a_fight_when_low(self): + world, p = self.fighter("miner", 10, weapon="copper_warhammer") + p["bank"] = {"health_potion_1": 5, "health_potion_2": 5} + self.assertEqual(game.health_potion(p), "health_potion_2") + game.start_action(world, p, "fight_scouts", T0) + summary = game.process_player(world, p, T0 + 3600, random.Random(1)) + drunk = summary["used"].get("health_potion_2", 0) + self.assertGreater(drunk, 0) + self.assertLessEqual(drunk, summary["done"]) + self.assertEqual(p["bank"].get("health_potion_2", 0), 5 - drunk) + fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(3)) + self.assertLessEqual(sum(1 for e in fight["events"] if e[3] == "heal" and e[6] == "potion"), 1) + + def test_health_potions_are_brewed(self): + brew = game.ACTION_BY_ID["brew_health_potion_3"] + self.assertEqual((brew["skill"], brew["level"]), ("herblore", 30)) + self.assertEqual(brew["outputs"], {"health_potion_3": 1}) + self.assertIn("tarromin", brew["inputs"]) + self.assertIn("cooked_salmon", brew["inputs"]) + self.assertEqual(game.ITEMS["health_potion_3"]["drink"], game.HEALTH_HEAL[2]) + self.assertNotIn("health_potion_3", game.edible({"bank": {"health_potion_3": 1}, "food": None})) + + def test_volley_spends_arrows(self): + world, p = self.fighter("ranger", 45, weapon="maple_bow") + p["bank"] = {"arrows": 100} + game.start_action(world, p, "fight_goblins", T0) + summary = game.process_player(world, p, T0 + 1800, random.Random(2)) + shot = summary["used"].get("arrows", 0) + self.assertGreater(shot, 0) + self.assertEqual(shot % 5, 0) + self.assertEqual(p["bank"].get("arrows", 0), 100 - shot) + + def test_a_party_rests_as_long_as_its_slowest(self): + world = world_with(("wil", "builder"), ("kay", "builder")) + wil, kay = world["players"]["wil"], world["players"]["kay"] + for p in (wil, kay): + p["xp"]["combat"] = game.XP_TABLE[20] + wil["hp"], kay["hp"] = game.max_hp(wil), 10 + fight = game.draw_fight(world, [wil, kay], game.ACTION_BY_ID["fight_scouts"], random.Random(1)) + self.assertAlmostEqual(fight["rest"], game.rest_plan(world, kay)[0]) + + def test_fights_in_progress_are_drawn_again_after_the_update(self): + world = world_with(("wil", "builder")) + wil = world["players"]["wil"] + game.start_action(world, wil, "fight_wolves", T0) + wil["action"]["fight"] = {"old": True} + world["parties"]["1"] = {"id": "1", "action": "fight_wolves", "members": [], "start": T0, "fight": {"old": True}} + world["version"] = 25 + game.migrate(world, T0) + self.assertNotIn("fight", wil["action"]) + self.assertIsNone(world["parties"]["1"]["fight"]) + + class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self): world = world_with(("pia", "commoner")) @@ -789,14 +916,18 @@ class TierTest(unittest.TestCase): self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) def test_dragons_drop_their_areas_reagents(self): - world = world_with(("wil", "commoner")) + world = world_with(("wil", "commoner"), ("kay", "commoner")) + for p in world["players"].values(): + p["xp"]["combat"] = game.XP_TABLE[80] + p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", + legs="rune_legs") + p["hp"] = game.max_hp(p) + p["bank"] = {"cooked_swordfish": 200} + p["last_active"] = T0 + 3600 * 2 p = world["players"]["wil"] - p["xp"]["combat"] = game.XP_TABLE[80] - p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs") - p["hp"] = game.max_hp(p) - p["bank"] = {"cooked_swordfish": 200} - game.start_action(world, p, "fight_green_dragon", T0) - game.process_player(world, p, T0 + 3600 * 2, random.Random(3)) + game.start_action(world, p, "fight_green_dragon", T0) # a group fight + game.join_fight(world, world["players"]["kay"], "wil", T0) + game.catch_up(world, T0 + 3600 * 2, random.Random(3)) self.assertGreater(sum(p["bank"].get(game.reagent(weight, 3), 0) for weight in game.WEIGHTS), 30) @@ -843,27 +974,26 @@ class PrayerTest(unittest.TestCase): self.assertTrue(summary["prayer_out"]) self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[10] + 4 * game.PRAYER_XP_PER_POINT) - def test_second_wind_saves_one_retreat(self): + def test_second_wind_saves_one_knockout(self): world, p = self.priest(level=40, combat=10) game.set_prayers(p, ["second_wind"]) p["pp"] = 1000 game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1)) self.assertEqual(summary["second_wind"], 1) - self.assertTrue(summary["stopped"].startswith("Retreated")) + self.assertEqual(summary["stopped"], "Knocked out 3 times in a row") def test_rapid_heal_mends_during_fights(self): world, p = self.priest(level=10, combat=10) scouts = game.ACTION_BY_ID["fight_scouts"] - p["pp"], p["hp"] = 1000, 50 + p["pp"] = 1000 + p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs") plain = game.simulate_fight(world, p, scouts, random.Random(2)) game.set_prayers(p, ["rapid_heal"]) mended = game.simulate_fight(world, p, scouts, random.Random(2)) - self.assertEqual(plain["end"], mended["end"]) # the same fight, only the mending differs - swings = sum(1 for e in plain["events"] if e[1] == p["id"]) - top = game.max_hp(p) - plain, mended = game.member_of(plain, p["id"]), game.member_of(mended, p["id"]) - self.assertAlmostEqual(mended["hp"] - plain["hp"], swings * game.MEND * top * plain["interval"], places=6) + self.assertEqual([e[:4] for e in plain["events"] if e[1] == p["id"]][:5], + [e[:4] for e in mended["events"] if e[1] == p["id"]][:5]) # the same fight + self.assertGreater(game.member_of(mended, p["id"])["hp"], game.member_of(plain, p["id"])["hp"]) self.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"]) def test_offerings_ward_the_city_until_the_raid(self): @@ -1452,11 +1582,27 @@ class WeaponStyleTest(unittest.TestCase): self.assertLess(hurt(cloth, shaper), hurt(plate, shaper)) self.assertLess(hurt(plate, brute), hurt(cloth, brute)) - def test_each_kill_is_one_of_the_variants(self): + def test_each_fight_is_a_pack_of_the_variants(self): world, p = self.armed("miner", "iron_warhammer", level=30) - seen = {game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(seed))["enemy"] - for seed in range(60)} - self.assertEqual(seen, {"Goblin brute", "Goblin rogue", "Goblin warlock"}) + goblins = game.ACTION_BY_ID["fight_goblins"] + packs = [game.simulate_fight(world, p, goblins, random.Random(seed))["pack"] for seed in range(60)] + self.assertEqual({foe["name"] for pack in packs for foe in pack}, {"Goblin brute", "Goblin rogue", "Goblin warlock"}) + self.assertEqual({len(pack) for pack in packs}, {1, 2}) + for pack in packs: + share = game.OUTNUMBERED[0] if len(pack) == 2 else 1 + self.assertTrue(all(foe["share"] == share for foe in pack)) # two of them, each weaker + scouts = game.ACTION_BY_ID["fight_scouts"] + self.assertEqual({len(game.draw_pack(scouts, 3, random.Random(seed))) for seed in range(60)}, {3, 4}) + + def test_a_boss_comes_alone(self): + ogre = game.ACTION_BY_ID["fight_ruins"] + foe = ogre["enemy"]["variants"][0] + pair, four = game.draw_pack(ogre, 2, random.Random(1)), game.draw_pack(ogre, 4, random.Random(1)) + self.assertEqual((len(pair), len(four)), (1, 1)) + self.assertEqual(pair[0]["hp"], round(foe["hp"] * game.BOSS_HP * 2)) # tougher the more of you there are + self.assertEqual(four[0]["hp"], round(foe["hp"] * game.BOSS_HP * 4)) + self.assertEqual(pair[0]["max_hit"], round(foe["max_hit"] * game.BOSS_HITS[0])) + self.assertGreater(four[0]["max_hit"], pair[0]["max_hit"]) def test_shield_smiths_stop_after_the_move(self): world = world_with(("kay", "miner")) @@ -2059,7 +2205,7 @@ class PartyTest(unittest.TestCase): for p in world["players"].values(): p["xp"]["combat"] = game.XP_TABLE[level] p["hp"] = game.max_hp(p) - p["equip"]["weapon"] = "iron_sword" + p["equip"].update(weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs") p["bank"] = {"bread": 300} return world, world["players"]["wil"], world["players"]["kay"] @@ -2096,14 +2242,12 @@ class PartyTest(unittest.TestCase): self.assertEqual(together["done"], kay["away"]["done"]) self.assertEqual(together["with"], {"Kay": together["done"]}) self.assertEqual(wil["xp"]["combat"], kay["xp"]["combat"]) # the same kills, the full XP each - low, high = game.ACTION_BY_ID["fight_scouts"]["gold"] - self.assertTrue(together["wins"] * low <= wil["gold"] + kay["gold"] <= together["wins"] * high) - self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"]) # split evenly, odd coins aside - self.assertEqual(wil["bank"].get("bones", 0) + kay["bank"].get("bones", 0), together["wins"]) # one each kill + self.assertGreater(wil["gold"] + kay["gold"], 0) + self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"] * 4) # split evenly, odd coins aside solo, alone = self.pair()[0:2] game.start_action(solo, alone, "fight_scouts", T0) self.online(solo, T0 + 3600, random.Random(2)) - self.assertGreater(together["done"], alone["away"]["done"] * 1.5) + self.assertGreater(together["xp"]["combat"], alone["away"]["xp"]["combat"] * 1.3) def test_leaving_lets_the_other_carry_on(self): world, wil, kay = self.pair() @@ -2117,7 +2261,7 @@ class PartyTest(unittest.TestCase): self.assertNotIn("party", wil["action"]) left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her self.assertEqual(left["result"], "left") - self.online(world, T0 + 600, rng) + self.online(world, T0 + 1200, rng) self.assertGreater(wil["away"]["wins"], 3) def test_away_too_long_steps_out(self): @@ -2134,17 +2278,42 @@ class PartyTest(unittest.TestCase): self.assertNotIn("party", kay["action"]) self.assertGreater(wil["away"]["done"], kay["away"]["done"]) # wil carried on alone for the last hour - def test_a_retreat_leaves_the_rest_fighting(self): + def test_knocked_out_in_a_won_fight_still_shares_the_win(self): world, wil, kay = self.pair() - kay["bank"] = {} - kay["hp"] = game.max_hp(kay) * 0.35 game.start_action(world, wil, "fight_scouts", T0) - self.assertIsNone(game.join_fight(world, kay, "wil", T0)) - self.online(world, T0 + 600, random.Random(5)) - self.assertIsNone(kay["action"]) - self.assertTrue(kay["stopped"].startswith("Retreated")) - self.assertEqual(wil["action"]["id"], "fight_scouts") - self.assertEqual(world["parties"], {}) + self.online(world, T0 + 0.1, random.Random(0)) + kay["hp"] = 1 # tags in as she is + self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 0.1)) + fight = game.current_fight(world, wil) + mine = game.member_of(fight, "kay") + self.assertTrue(mine["down"]) + self.assertEqual((fight["result"], mine["result"]), ("won", "won")) + self.online(world, T0 + fight["duration"] + 0.01, random.Random(5)) + self.assertEqual(kay["away"]["wins"], 1) + self.assertAlmostEqual(kay["hp"], game.max_hp(kay) * game.KNOCKED_OUT_HP) + self.assertEqual(kay["action"]["id"], "fight_scouts") + + def test_a_boss_needs_a_group(self): + world, wil, kay = self.pair(level=30) + for p in (wil, kay): + p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body", + legs="steel_legs") + self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0)) + self.assertIn("Wil is looking for a group", world["ledger"][-1]["text"]) + self.online(world, T0 + 3600, random.Random(1)) + self.assertNotIn("fight", wil["action"]) # alone, nothing happens + self.assertEqual(wil["away"]["done"], 0) if "away" in wil else None + self.assertEqual(wil["action"]["start"], T0 + 3600) + self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 3600)) + self.online(world, T0 + 7200, random.Random(1)) + self.assertGreater(wil["away"]["wins"], 0) + self.assertEqual(wil["away"]["wins"], kay["away"]["wins"]) + self.assertIsNone(game.start_action(world, kay, "mine_stone", T0 + 7200)) + self.online(world, T0 + 9000, random.Random(1)) + wins = wil["away"]["wins"] + self.online(world, T0 + 12600, random.Random(1)) + self.assertEqual(wil["away"]["wins"], wins) # back to waiting + self.assertEqual(wil["action"]["id"], "fight_ruins") def test_who_can_join(self): world, wil, kay = self.pair() @@ -2162,7 +2331,8 @@ class PartyTest(unittest.TestCase): def test_bosses_give_everyone_their_own_loot(self): world, wil, kay = self.pair(level=30) for p in (wil, kay): - p["equip"].update(weapon="steel_sword", helmet="steel_helmet", body="steel_body", legs="steel_legs") + p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body", + legs="steel_legs") p["bank"] = {"cooked_salmon": 5000} game.start_action(world, wil, "fight_ruins", T0) self.assertIsNone(game.join_fight(world, kay, "wil", T0)) diff --git a/tools/balance.py b/tools/balance.py new file mode 100644 index 0000000..ba01dac --- /dev/null +++ b/tools/balance.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Balance report: an hour of fighting for each trade with its own weapon, at each tier. + + python3 tools/balance.py [solo|party] [seeds] [levels, e.g. 22,52] [potions] + +Each row is one encounter: wins and losses in the hour, XP an hour, food eaten an hour, +and the share of a kill's time spent fighting (the rest is resting between fights). +""" + +from __future__ import annotations + +import random +import statistics +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import game # noqa: E402 + +T0 = 1_800_000_000.0 +WEIGHT = {"miner": "heavy", "builder": "heavy", "homesteader": "light", "ranger": "medium"} +# (combat level, the tier of gear worn, the encounters to try) +CHECKPOINTS = [ + (10, 0, ["fight_wolves", "fight_goblins", "fight_scouts"]), + (22, 1, ["fight_clay_golems", "fight_cave_trolls", "fight_restless_dead", "fight_harpies", "fight_ruins"]), + (37, 2, ["fight_stone_guardians", "fight_lizardfolk", "fight_bog_wraiths", "fight_giant_spiders", "fight_barrow_king"]), + (52, 3, ["fight_iron_golems", "fight_orcs", "fight_mine_ghosts", "fight_wyverns", "fight_green_dragon"]), + (67, 4, ["fight_ice_elementals", "fight_frost_trolls", "fight_banshees", "fight_griffins", "fight_blue_dragon"]), + (82, 5, ["fight_magma_constructs", "fight_dragonkin", "fight_shades", "fight_rocs", "fight_red_dragon"]), +] +FOODS = ["cooked_shrimp", "cooked_trout", "cooked_salmon", "smoked_eel", "cooked_lobster", "cooked_swordfish"] + + +def gear(cls: str, tier: int) -> dict: + """The trade's own weapon and its usual armour weight, all of one tier (and a shield for a sword).""" + style = game.CLASS_STYLE[cls] + equip = {} + for item, spec in game.ITEMS.items(): + if spec.get("tier") != tier or spec.get("req") != ["combat", game.TIER_LEVELS[tier]]: + continue + if spec.get("style") == style or (style == "sword" and spec.get("slot") == "shield") or \ + spec.get("weight") == WEIGHT[cls] and spec["slot"] in ("helmet", "body", "legs"): + equip[spec["slot"]] = item + return equip + + +POTIONS = False + + +def fighter(world: dict, uid: str, cls: str, level: int, tier: int) -> dict: + player = game.new_player(uid, uid.title(), cls, T0) + world["players"][uid] = player + player["xp"]["combat"] = game.XP_TABLE[level] + player["equip"].update(gear(cls, tier)) + player["hp"] = game.max_hp(player) + player["bank"] = {FOODS[tier]: 50_000, "arrows": 10_000} + if POTIONS: + player["bank"][f"health_potion_{tier + 1}"] = 10_000 + return player + + +def hour(classes: list, level: int, tier: int, fight: str, seed: int) -> dict: + world = game.new_world(T0) + players = [fighter(world, f"p{i}", cls, level, tier) for i, cls in enumerate(classes)] + game.start_action(world, players[0], fight, T0) + for other in players[1:]: + game.join_fight(world, other, players[0]["id"], T0) + rng = random.Random(seed) + end = T0 + 3600 + for now in range(int(T0) + 60, int(end) + 1, 60): + for player in players: + player["last_active"] = now + game.catch_up(world, now, rng) + away = players[0].get("away") or game.empty_summary() + eaten = sum(away["eaten"].values()) + return {"wins": away["wins"], "losses": away["losses"], "xp": away["xp"].get("combat", 0), "eaten": eaten, + "stopped": away["stopped"]} + + +def report(groups: dict, seeds: int, levels: set | None = None) -> None: + print(f"{'who':24} {'lvl':>3} {'encounter':22} {'wins':>5} {'lost':>4} {'xp/h':>6} {'food/h':>6} {'stop':>4}") + for name, classes in groups.items(): + for level, tier, fights in CHECKPOINTS: + if levels and level not in levels: + continue + for fight in fights: + rows = [hour(classes, level, tier, fight, seed) for seed in range(seeds)] + mean = lambda key: statistics.mean(row[key] for row in rows) + stops = sum(1 for row in rows if row["stopped"]) + label = game.ACTION_BY_ID[fight]["name"][:22] + print(f"{name:24} {level:>3} {label:22} {mean('wins'):>5.0f} {mean('losses'):>4.1f} " + f"{mean('xp'):>6.0f} {mean('eaten'):>6.0f} {stops:>4}") + print() + + +BOSSES = ["fight_ruins", "fight_barrow_king", "fight_green_dragon", "fight_blue_dragon", "fight_red_dragon"] + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "solo" + seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3 + levels = {int(level) for level in sys.argv[3].split(",")} if len(sys.argv) > 3 and sys.argv[3] != "all" else None + global POTIONS + POTIONS = "potions" in sys.argv[4:] + if mode == "bosses": + global CHECKPOINTS + CHECKPOINTS = [(level, tier, [f for f in fights if f in BOSSES]) for level, tier, fights in CHECKPOINTS] + report({"miner+ranger": ["miner", "ranger"], "builder+homesteader": ["builder", "homesteader"], + "homesteader+ranger": ["homesteader", "ranger"], "all four": ["builder", "homesteader", "miner", "ranger"]}, + seeds, levels) + elif mode == "party": + report({"builder+homesteader": ["builder", "homesteader"], + "all four": ["builder", "homesteader", "miner", "ranger"]}, seeds, levels) + else: + report({cls: [cls] for cls in game.CLASS_STYLE}, seeds, levels) + + +if __name__ == "__main__": + main()