194 lines
8.8 KiB
Python
194 lines
8.8 KiB
Python
"""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()
|