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,31 @@
|
||||
# Bite-Sized balance sweep
|
||||
|
||||
Run the fixed Ready Discipline Priest party against every selectable boss in the
|
||||
12 authored Bite-Sized dungeons at each supported level:
|
||||
|
||||
```powershell
|
||||
npx vite-node --mode balance-lab scripts/bite-sized-balance/run.ts --all-levels --output artifacts/bite-sized-balance/final-measured-levels-1-80.json
|
||||
```
|
||||
|
||||
Add `--resume` to continue a partially completed output file. Use `--levels 60`
|
||||
or `--dungeons bite-sized-hoto-teeth` for a focused check. Add `--trash` to also
|
||||
measure one compact 4–6 mob pull per selected dungeon; `--trash-only` skips
|
||||
bosses.
|
||||
|
||||
The report records every boss's kill time, HP, outcome, ability damage, and
|
||||
five-seat damage totals. It uses the live companion AI and a deterministic
|
||||
baseline Priest rotation with a fixed party seed. Boss attacks, travel, and
|
||||
real scene navigation are excluded. The trash diagnostic clusters imported
|
||||
mobs into an ideal visible pull, so it cannot validate live pathing.
|
||||
|
||||
Render the report with the Codex bundled Python, which includes Pillow:
|
||||
|
||||
```powershell
|
||||
$python = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'
|
||||
& $python scripts/bite-sized-balance/plot.py artifacts/bite-sized-balance/final-measured-levels-1-80.json --output artifacts/bite-sized-balance/final
|
||||
```
|
||||
|
||||
The two PNGs show measured boss times across levels 1–80 and the five-seat
|
||||
effective damage share for every boss at level 60. Pass `--seat-level 40` (or
|
||||
any other measured level) to render that level's per-boss five-seat chart. The
|
||||
source JSON preserves the five-seat breakdown at every measured level.
|
||||
@@ -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()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { loadCanonicalBenchmarkBuilds } from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
||||
import { runBiteSizedTrashBenchmark, runDungeonBossBenchmark } from "../../src/game/benchmark/dungeonBossBenchmark";
|
||||
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
|
||||
BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
|
||||
BITE_SIZED_HOTO_DUNGEON_ID,
|
||||
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
|
||||
BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
|
||||
dungeonContentSourceId,
|
||||
} from "../../src/game/dungeonContentSource";
|
||||
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
|
||||
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "../../src/game/dungeons/biteSizedStockades";
|
||||
import { seededBossIndex } from "../../src/game/dungeons/seededBossSelection";
|
||||
|
||||
const ids = [
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
|
||||
BITE_SIZED_BARREN_CAVES_DUNGEON_ID, BITE_SIZED_HOTO_DUNGEON_ID,
|
||||
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID, BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
|
||||
] as const;
|
||||
|
||||
function valueAfter(flag: string): string | null {
|
||||
const index = process.argv.indexOf(flag);
|
||||
return index < 0 ? null : process.argv[index + 1] ?? null;
|
||||
}
|
||||
|
||||
function selectedLevels(): number[] {
|
||||
if (process.argv.includes("--all-levels")) return Array.from({ length: 80 }, (_, index) => index + 1);
|
||||
const requested = valueAfter("--levels");
|
||||
const levels = requested ? requested.split(",").map(Number) : [1, 10, 20, 30, 40, 50, 60, 70, 80];
|
||||
if (levels.some((level) => !Number.isInteger(level) || level < 1 || level > 80)) {
|
||||
throw new Error("Levels must be integers from 1 through 80.");
|
||||
}
|
||||
return [...new Set(levels)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function bossCatalog(dungeonId: string) {
|
||||
const candidates = dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
? BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => ({ id: boss.sourceSpawnId, name: boss.name }))
|
||||
: requireDungeonDefinition(dungeonContentSourceId(dungeonId)).bosses;
|
||||
const found = new Map<number, string>();
|
||||
for (let index = 0; index < 2_000 && found.size < candidates.length; index += 1) {
|
||||
const seed = `bite-sized-balance-${index}`;
|
||||
const candidateIndex = seededBossIndex(seed, candidates.length);
|
||||
if (!found.has(candidateIndex)) found.set(candidateIndex, seed);
|
||||
}
|
||||
if (found.size !== candidates.length) {
|
||||
throw new Error(`${dungeonId}: ${candidates.length - found.size} source bosses could not be selected.`);
|
||||
}
|
||||
return candidates.map((boss, index) => ({ dungeonId, id: boss.id, name: boss.name, seed: found.get(index)! }));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const selectedDungeons = (valueAfter("--dungeons")?.split(",") ?? ids).filter((id) => ids.includes(id as typeof ids[number]));
|
||||
const bosses = selectedDungeons.flatMap(bossCatalog);
|
||||
if (process.argv.includes("--catalog")) {
|
||||
console.log(JSON.stringify(bosses, null, 2));
|
||||
return;
|
||||
}
|
||||
const levels = selectedLevels();
|
||||
const output = resolve(valueAfter("--output") ?? "artifacts/bite-sized-balance/ready-priest-boss-sweep.json");
|
||||
const previous = process.argv.includes("--resume") && existsSync(output)
|
||||
? JSON.parse(readFileSync(output, "utf8")) as {
|
||||
results?: Record<string, unknown>[]; trashResults?: Record<string, unknown>[];
|
||||
}
|
||||
: null;
|
||||
const results: Record<string, unknown>[] = previous?.results ?? [];
|
||||
const trashResults: Record<string, unknown>[] = previous?.trashResults ?? [];
|
||||
const includeBosses = !process.argv.includes("--trash-only");
|
||||
const includeTrash = process.argv.includes("--trash") || process.argv.includes("--trash-only");
|
||||
const writeReport = () => {
|
||||
mkdirSync(resolve(output, ".."), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify({
|
||||
method: "Deterministic isolated boss simulation with live stats, companion AI, and baseline Discipline Priest rotation; no boss attacks or travel time.",
|
||||
scope: "All authored Bite-Sized dungeon boss choices; one fixed party seed; normal difficulty; 150-second timeout.",
|
||||
policy: BENCHMARK_POLICY_ID, levels, bosses, results, trashResults,
|
||||
}, null, 2)}\n`);
|
||||
};
|
||||
for (const level of levels) {
|
||||
if (results.filter((result) => result.level === level).length >= (includeBosses ? bosses.length : 0)
|
||||
&& trashResults.filter((result) => result.level === level).length >= (includeTrash ? selectedDungeons.length : 0)) {
|
||||
console.log(`Level ${level}: resumed from checkpoint.`);
|
||||
continue;
|
||||
}
|
||||
const build = (await loadCanonicalBenchmarkBuilds(level)).find((candidate) => (
|
||||
candidate.classId === "priest" && candidate.role === "healer"
|
||||
));
|
||||
if (!build) throw new Error(`No Ready discipline priest build exists at level ${level}.`);
|
||||
for (const boss of includeBosses ? bosses : []) {
|
||||
if (results.some((result) => result.level === level && result.bossId === boss.id
|
||||
&& result.dungeonId === boss.dungeonId)) continue;
|
||||
const result = await runDungeonBossBenchmark(build, boss.id, boss.seed, false, {
|
||||
dungeonId: boss.dungeonId, durationMs: 150_000, partySeed: "bite-sized-balance-party",
|
||||
});
|
||||
const seats = [
|
||||
{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
|
||||
...result.party.map((label, index) => ({
|
||||
id: result.partyActorIds[index],
|
||||
label: `Companion ${index + 1} · ${label}`,
|
||||
})),
|
||||
];
|
||||
results.push({ level, dungeonId: boss.dungeonId, bossId: boss.id, bossName: boss.name,
|
||||
seconds: result.seconds, outcome: result.outcome, elapsedSeconds: result.elapsedSeconds,
|
||||
maxHealth: result.maxHealth, remainingHealth: "remainingHealth" in result ? result.remainingHealth : 0,
|
||||
damageByActor: result.damageByActor, seats, damageByAbility: result.damageByAbility,
|
||||
party: result.party });
|
||||
}
|
||||
if (includeTrash) {
|
||||
for (const dungeonId of selectedDungeons) {
|
||||
if (trashResults.some((result) => result.level === level && result.dungeonId === dungeonId)) continue;
|
||||
const sample = bosses.find((boss) => boss.dungeonId === dungeonId)!;
|
||||
const result = await runBiteSizedTrashBenchmark(build, dungeonId, sample.seed, {
|
||||
partySeed: "bite-sized-balance-party", durationMs: 120_000,
|
||||
});
|
||||
trashResults.push({ level, dungeonId, ...result,
|
||||
seats: [{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
|
||||
...result.party.map((label, index) => ({ id: result.partyActorIds[index],
|
||||
label: `Companion ${index + 1} · ${label}` }))] });
|
||||
}
|
||||
}
|
||||
writeReport();
|
||||
console.log(`Level ${level}: ${includeBosses ? bosses.length : 0} bosses and ${includeTrash ? selectedDungeons.length : 0} trash pulls measured.`);
|
||||
}
|
||||
writeReport();
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user