First slice: classes, combat, questing, Deadmines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-23 22:52:42 +01:00
commit 71dd99795a
17 changed files with 4522 additions and 0 deletions

99
tools/balance.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Balance report: solo hunts per class and level, and all-auto Deadmines runs.
python3 tools/balance.py [solo|dungeon] [runs]
"""
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 combat # noqa: E402
import game # noqa: E402
from content import CLASSES, DUNGEONS, MOBS # noqa: E402
SOLO_TARGETS = {1: "young_wolf", 5: "defias_thug", 10: "riverpaw_gnoll", 15: "riverpaw_brute",
19: "defias_highwayman"}
def hero(cls: str, level: int, uid: str = "p") -> dict:
player = game.new_player(uid, cls.title(), cls, 0)
game.dev_level({"config": {}}, player, level, 0)
return player
def solo(runs: int) -> None:
print(f"{'class':9} {'lvl':>3} {'mob':20} {'win':>5} {'secs':>5} {'hp lost':>7}")
for cls in CLASSES:
for level, mob_id in SOLO_TARGETS.items():
wins, times, lost = 0, [], []
for seed in range(runs):
player = hero(cls, level)
rng = random.Random(seed)
mob_level = level
enemies = [combat.mob_unit("e0", mob_id, mob_level)]
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False), enemies, "hunt")
combat.advance(fight, 400)
me = fight["units"]["p"]
if fight["state"] == "won":
wins += 1
times.append(fight["t"])
lost.append(1 - me["hp"] / me["max_hp"])
print(f"{cls:9} {level:>3} {MOBS[mob_id]['name']:20} {wins / runs:>5.0%} "
f"{statistics.mean(times) if times else 0:>5.1f} {statistics.mean(lost) if lost else 0:>7.0%}")
def dungeon(runs: int, party: list[str], level: int) -> None:
pulls = DUNGEONS["deadmines"]["pulls"]
results = {p["name"]: {"won": 0, "t": [], "hp": [], "low": [], "mana": []} for p in pulls}
for seed in range(runs):
rng = random.Random(seed)
heroes = [hero(cls, level, f"p{i}") for i, cls in enumerate(party)]
for pull in pulls:
units = []
for h in heroes:
units += game.player_unit(h, 0, True)
hp_scale, dmg_scale = game.dungeon_scale(len(heroes))
enemies = []
for index, mob_id in enumerate(pull["mobs"]):
low, high = MOBS[mob_id]["lv"]
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale)
unit["swing"]["next"] = 0.8 + 0.4 * index
enemies.append(unit)
fight = combat.new_fight("f", rng.randrange(2 ** 31), 0, units, enemies, "dungeon", pull["name"],
(hp_scale, dmg_scale))
while fight["state"] == "on":
combat.advance(fight, fight["t"] + combat.STEP)
tank = fight["units"]["p0"]
fight["low"] = min(fight.get("low", 1), tank["hp"] / tank["max_hp"])
row = results[pull["name"]]
row["low"].append(fight.get("low", 1))
row["mana"].append(fight["units"]["p1"]["res"]["cur"] / fight["units"]["p1"]["res"]["max"])
if fight["state"] == "won":
row["won"] += 1
row["t"].append(fight["t"])
row["hp"].append(sum(1 for u in combat.players(fight) if u["dead"]))
print(f"party: {', '.join(party)} at {level}")
print(f"{'pull':20} {'win':>5} {'secs':>5} {'dead':>5} {'tank low':>8} {'heal mana':>9}")
for name, row in results.items():
print(f"{name:20} {row['won'] / runs:>5.0%} {statistics.mean(row['t']) if row['t'] else 0:>5.0f} "
f"{statistics.mean(row['hp']) if row['hp'] else 0:>5.1f} {statistics.mean(row['low']):>8.0%} "
f"{statistics.mean(row['mana']):>9.0%}")
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "solo"
runs = int(sys.argv[2]) if len(sys.argv) > 2 else 20
if mode == "solo":
solo(runs)
else:
if "perfect" in sys.argv:
combat.AUTO_CD, combat.AUTO_DELAY, combat.AUTO_REACT = 1.0, 0.0, 0.25
print("-- perfect hands --")
dungeon(runs, ["warrior", "priest", "mage", "rogue", "hunter"], 18)
dungeon(runs, ["paladin", "druid", "warlock", "shaman", "rogue"], 18)