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

+31
View File
@@ -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.
+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()
+141
View File
@@ -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();