Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 14:31:15 -04:00
1 parent ab1434a3b8
commit e284f4fc76
60 files changed
+2704 -118

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
"""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()