guildhall/tools/balance.py

121 lines
5.3 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] [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()