Files
healer-man/scripts/bite-sized-balance/plot.py
T
2026-09-27 14:31:15 -04:00

194 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Render measured Bite-Sized boss fight times and five-seat damage as PNGs."""
import argparse
import json
import statistics
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
SEAT_COLORS = ("#5a9bd5", "#8c76c7", "#e2a34f", "#d96276", "#68b596")
def font(size):
for name in ("arial.ttf", "DejaVuSans.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()
def label(boss):
dungeon = boss["dungeonId"].replace("bite-sized-", "").replace("-", " ").title()
return f"{dungeon} · {boss['name']}"
def time_color(seconds):
if seconds is None:
return "#353c48"
if seconds < 25:
return "#5388bf"
if seconds <= 45:
return "#54aa79"
if seconds <= 60:
return "#e2b34f"
return "#d96460"
def render_times(data, output):
bosses = data["bosses"]
levels = data["levels"]
rows = {(row["dungeonId"], row["bossId"], row["level"]): row for row in data["results"]}
x0, y0, cell_w, cell_h = 370, 105, 14, 19
width = x0 + len(levels) * cell_w + 130
height = y0 + len(bosses) * cell_h + 90
image = Image.new("RGB", (width, height), "#f7f7f5")
draw = ImageDraw.Draw(image)
draw.text((20, 12), "Bite-Sized boss kill times by level", font=font(24), fill="#17212b")
draw.text((20, 46), "Measured 5-seat Discipline Priest party · normal difficulty · isolated boss, no travel or boss attacks",
font=font(13), fill="#4a5562")
for x in levels:
if x == 1 or x % 10 == 0 or x == 80:
cx = x0 + levels.index(x) * cell_w
draw.text((cx - 4, y0 - 25), str(x), font=font(11), fill="#334155")
draw.line((cx, y0 - 3, cx, y0 + len(bosses) * cell_h), fill="#d8dce0", width=1)
for index, boss in enumerate(bosses):
y = y0 + index * cell_h
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
times = []
for level_index, level in enumerate(levels):
row = rows.get((boss["dungeonId"], boss["id"], level))
x = x0 + level_index * cell_w
seconds = row["seconds"] if row else None
draw.rectangle((x + 1, y + 1, x + cell_w - 1, y + cell_h - 2),
fill=time_color(seconds) if row else "#dce0e4")
if row and seconds is not None:
times.append(seconds)
if times:
draw.text((x0 + len(levels) * cell_w + 12, y + 2), f"{min(times):.0f}–{max(times):.0f}s",
font=font(11), fill="#334155")
y = y0 + len(bosses) * cell_h + 18
for index, (color, caption) in enumerate((
("#5388bf", "<25s"), ("#54aa79", "25–45s target"),
("#e2b34f", "45–60s"), ("#d96460", ">60s"),
("#353c48", "timeout"), ("#dce0e4", "unmeasured"),
)):
x = 20 + index * 175
draw.rectangle((x, y, x + 18, y + 15), fill=color)
draw.text((x + 25, y), caption, font=font(12), fill="#334155")
image.save(output)
def render_seats(data, output, level=60):
bosses = data["bosses"]
rows = {(row["dungeonId"], row["bossId"]): row for row in data["results"] if row["level"] == level}
x0, y0, bar_w, cell_h = 370, 125, 820, 21
width, height = x0 + bar_w + 150, y0 + len(bosses) * cell_h + 70
image = Image.new("RGB", (width, height), "#f7f7f5")
draw = ImageDraw.Draw(image)
draw.text((20, 12), f"Bite-Sized boss damage by party seat · level {level}", font=font(24), fill="#17212b")
draw.text((20, 46), "Measured effective boss damage · fixed Ready Discipline Priest party · each row totals 100%",
font=font(13), fill="#4a5562")
for index, name in enumerate(("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue")):
x = 20 + index * 225
draw.rectangle((x, 80, x + 16, 95), fill=SEAT_COLORS[index])
draw.text((x + 22, 79), name, font=font(12), fill="#334155")
for index, boss in enumerate(bosses):
y = y0 + index * cell_h
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
row = rows.get((boss["dungeonId"], boss["id"]))
if not row:
continue
damages = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
total = max(sum(damages), 1)
offset = x0
for seat_index, damage in enumerate(damages):
segment = round(bar_w * damage / total)
if segment:
draw.rectangle((offset, y + 2, offset + segment - 1, y + cell_h - 3),
fill=SEAT_COLORS[seat_index])
offset += segment
draw.text((x0 + bar_w + 12, y + 2),
f"{row['seconds']:.1f}s" if row["seconds"] is not None else "timeout",
font=font(11), fill="#334155")
image.save(output)
def render_summary(data, output):
rows = data["results"]
levels = data["levels"]
bosses = data["bosses"]
measured = [row for row in rows if row["seconds"] is not None]
timeouts = [row for row in rows if row["seconds"] is None]
low = [row for row in measured if row["seconds"] < 25]
high = [row for row in measured if row["seconds"] > 45]
middle = [row for row in measured if 25 <= row["seconds"] <= 45]
lines = [
"# Bite-Sized boss balance sweep",
"",
f"- Measured fights: {len(rows)} of {len(levels) * len(bosses)} ({len(bosses)} bosses × {len(levels)} levels).",
f"- Target window (25–45 s): {len(middle)}; under 25 s: {len(low)}; over 45 s: {len(high)}; timeouts: {len(timeouts)}.",
"- Method: fixed Ready Discipline Priest party, normal difficulty, live companion AI and deterministic player rotation.",
"- Limits: isolated stationary boss, no boss attacks, no travel, and one party seed. These are measured simulations, not live clear times.",
"",
"## Outlier levels",
"",
"| Level | Under 25 s | Over 45 s | Timeouts | Fastest | Slowest |",
"| ---: | ---: | ---: | ---: | ---: | ---: |",
]
for level in levels:
level_rows = [row for row in rows if row["level"] == level]
level_times = [row["seconds"] for row in level_rows if row["seconds"] is not None]
under = sum(row["seconds"] is not None and row["seconds"] < 25 for row in level_rows)
over = sum(row["seconds"] is not None and row["seconds"] > 45 for row in level_rows)
timed_out = sum(row["seconds"] is None for row in level_rows)
if under or over or timed_out:
lines.append(f"| {level} | {under} | {over} | {timed_out} | "
f"{min(level_times):.1f}s | {max(level_times):.1f}s |")
level_60 = [row for row in measured if row["level"] == 60]
if level_60:
shares = [[] for _ in range(5)]
for row in level_60:
amounts = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
total = max(sum(amounts), 1)
for index, amount in enumerate(amounts):
shares[index].append(amount / total * 100)
lines += [
"",
"## Level 60",
"",
f"- Kill-time range: {min(row['seconds'] for row in level_60):.1f}–{max(row['seconds'] for row in level_60):.1f} s; median {statistics.median(row['seconds'] for row in level_60):.1f} s.",
"- Mean per-boss effective damage share: "
+ ", ".join(f"{name} {statistics.mean(share):.1f}%" for name, share in zip(
("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue"), shares)) + ".",
]
output.write_text("\n".join(lines).rstrip() + "\n", encoding="utf8")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--seat-level", type=int, default=60)
arguments = parser.parse_args()
data = json.loads(arguments.input.read_text(encoding="utf8"))
if arguments.seat_level not in data["levels"]:
parser.error("--seat-level must be one of the measured levels in the report")
output = arguments.output or arguments.input.parent
output.mkdir(parents=True, exist_ok=True)
render_times(data, output / "boss-kill-times-levels-1-80.png")
render_seats(data, output / f"boss-damage-shares-level-{arguments.seat_level}.png",
arguments.seat_level)
render_summary(data, output / "summary.md")
print(output)
if __name__ == "__main__":
main()