"""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()