Files
healer-man/scripts/bite-sized-balance/run.ts
T
2026-09-27 14:31:15 -04:00

142 lines
7.3 KiB
TypeScript

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