forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user