guildhall/tools/balance.py

160 lines
7.4 KiB
Python
Raw Normal View History

#!/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|bosses] [seeds] [levels, e.g. 22,52] [potions]
python3 tools/balance.py dungeon [seeds] [level] [perfect]
`dungeon` plays each pull of every dungeon for a group of four and a pair, everyone with the
tier's gear, food and health potions. `perfect` stands in for active play: real cooldowns, no
waiting on auto's rules, quick interrupts. Bosses should beat an all-auto group and fall to one
that plays.
Each row is one encounter: wins and losses in the hour, XP an hour, food eaten an hour,
and the share of a kill's time spent fighting (the rest is resting between fights).
"""
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 dungeon(seeds: int, level: int | None) -> None:
for key, spec in game.DUNGEONS.items():
tier = spec["tier"]
at = level or spec["level"] + 3
action = game.ACTION_BY_ID[key]
for group in (["builder", "homesteader", "miner", "ranger"], ["builder", "homesteader"]):
global POTIONS
POTIONS = True
print(f"{spec['name']}: {'+'.join(group)} at {at}")
print(f"{'pull':18} {'win':>5} {'secs':>5} {'downs':>5} {'potions':>7}")
for index, (name, _pack) in enumerate(spec["pulls"]):
wins, secs, downs, drunk = 0, [], [], []
for seed in range(seeds):
world = game.new_world(T0)
players = [fighter(world, f"p{i}", cls, at, tier) for i, cls in enumerate(group)]
fight = game.draw_pull(world, players, action, {"pull": index, "wipes": 0}, random.Random(seed), T0)
if fight["result"] == "won":
wins += 1
secs.append(fight["duration"] - fight["rest"])
downs.append(sum(1 for m in fight["members"] if m["down"]))
drunk.append(sum(1 for m in fight["members"] if m["drank"]))
mean = lambda xs: statistics.mean(xs) if xs else 0
print(f"{name:18} {wins / seeds:>5.0%} {mean(secs):>5.0f} {mean(downs):>5.1f} {mean(drunk):>7.1f}")
print()
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "solo"
if mode == "dungeon":
if "perfect" in sys.argv:
game.combat.AUTO_CD, game.combat.AUTO_DELAY, game.combat.AUTO_REACT = 1.0, 0.0, 0.25
print("-- perfect hands --")
dungeon(int(sys.argv[2]) if len(sys.argv) > 2 else 20,
int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None)
return
seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3
levels = {int(level) for level in sys.argv[3].split(",")} if len(sys.argv) > 3 and sys.argv[3] != "all" else None
global POTIONS
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()