forked from phenom/healer-man
142 lines
7.3 KiB
TypeScript
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();
|