Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -0,0 +1,435 @@
|
||||
import { preloadAbilityCatalogsForClasses, type MobStatusKind } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "../aggro";
|
||||
import type { AzerothCoreMechanic } from "../azerothCoreReference";
|
||||
import { installCombatClock } from "../combatClock";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import type { EnemyAttackDefinition } from "../enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
getPartyRuntimePosition,
|
||||
registerPartyRuntimePosition,
|
||||
} from "../partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { CombatPosition } from "../mobRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BenchmarkRecorder } from "./benchmarkRecorder";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkRunOptions,
|
||||
BenchmarkRunReport,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "./benchmarkTypes";
|
||||
|
||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||
const BENCHMARK_BOSS_CONTROL_IMMUNITIES: readonly AzerothCoreMechanic[] = Object.freeze([
|
||||
"charm",
|
||||
"disarm",
|
||||
"disorient",
|
||||
"fear",
|
||||
"freeze",
|
||||
"grip",
|
||||
"horror",
|
||||
"knockout",
|
||||
"pacify",
|
||||
"polymorph",
|
||||
"root",
|
||||
"sap",
|
||||
"shackle",
|
||||
"silence",
|
||||
"sleep",
|
||||
"snare",
|
||||
"stun",
|
||||
"turn",
|
||||
]);
|
||||
const BENCHMARK_CONTROL_STATUS_KINDS: ReadonlySet<MobStatusKind> = new Set([
|
||||
"blind",
|
||||
"charm",
|
||||
"confuse",
|
||||
"disarm",
|
||||
"fear",
|
||||
"freeze",
|
||||
"knockdown",
|
||||
"pacify",
|
||||
"silence",
|
||||
"sleep",
|
||||
"stun",
|
||||
]);
|
||||
|
||||
function scaledDuration(scenario: BenchmarkScenarioDefinition, scale: number): number {
|
||||
const requested = scenario.durationMs * Math.max(0.001, scale);
|
||||
return Math.max(scenario.tickMs, Math.round(requested / scenario.tickMs) * scenario.tickMs);
|
||||
}
|
||||
|
||||
function partyPositions(layout: BenchmarkScenarioDefinition["partyLayout"], count: number): readonly CombatPosition[] {
|
||||
if (layout === "spread") {
|
||||
const spread: readonly CombatPosition[] = [
|
||||
[0, 0, 1],
|
||||
[-12, 0, -3],
|
||||
[12, 0, -3],
|
||||
[0, 0, -12],
|
||||
];
|
||||
return spread.slice(0, count);
|
||||
}
|
||||
if (layout === "stacked") {
|
||||
const stacked: readonly CombatPosition[] = [
|
||||
[-1, 0, 0.5],
|
||||
[1, 0, 0.5],
|
||||
[-0.5, 0, -1],
|
||||
[0.5, 0, -1],
|
||||
];
|
||||
return stacked.slice(0, count);
|
||||
}
|
||||
const melee: readonly CombatPosition[] = [
|
||||
[-1.5, 0, 1],
|
||||
[1.5, 0, 1],
|
||||
[-2.5, 0, -1],
|
||||
[2.5, 0, -1],
|
||||
];
|
||||
return melee.slice(0, count);
|
||||
}
|
||||
|
||||
function mobPosition(index: number, count: number, ringRadius = 2): CombatPosition {
|
||||
if (index === 0 || count <= 1) return [0, 0, 2.5];
|
||||
const angle = (index - 1) / Math.max(1, count - 1) * Math.PI * 2;
|
||||
return [Math.sin(angle) * ringRadius, 0, 2.5 + Math.cos(angle) * ringRadius];
|
||||
}
|
||||
|
||||
function benchmarkMobRingRadius(scenario: BenchmarkScenarioDefinition): number {
|
||||
return scenario.id === "damage-aoe-eight" ? 6 : 2;
|
||||
}
|
||||
|
||||
/** Keep scenario geometry fixed so pulls and knockbacks cannot change the pressure schedule. */
|
||||
function restoreBenchmarkMobPositions(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
mobIds: readonly string[],
|
||||
): void {
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => setMobPosition(
|
||||
id,
|
||||
mobPosition(index, mobIds.length, ringRadius),
|
||||
));
|
||||
}
|
||||
|
||||
function referenceAttackDamage(level: number): number {
|
||||
return 12 + level * 2;
|
||||
}
|
||||
|
||||
function enemyAttacks(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
level: number,
|
||||
referenceHealth: number,
|
||||
): readonly EnemyAttackDefinition[] {
|
||||
const baseDamage = referenceAttackDamage(level);
|
||||
return scenario.attacks.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
delivery: profile.target === "primary" && profile.school === "physical" ? "melee" : "area",
|
||||
target: profile.target,
|
||||
school: profile.school,
|
||||
animation: profile.school === "physical" ? "attack" : "cast",
|
||||
range: profile.target === "primary" ? 5.5 : 45,
|
||||
cooldownMs: profile.intervalMs,
|
||||
initialCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
repeatCooldownMs: [profile.intervalMs, profile.intervalMs],
|
||||
damageMultiplier: profile.damageMultiplier * referenceHealth / Math.max(1, baseDamage),
|
||||
...(profile.radius === undefined ? {} : { radius: profile.radius }),
|
||||
}));
|
||||
}
|
||||
|
||||
function movementPosition(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
elapsedMs: number,
|
||||
): { readonly position: CombatPosition; readonly moving: boolean } {
|
||||
const movement = scenario.movement;
|
||||
if (!movement || elapsedMs < movement.startsAtMs) return { position: [0, 0, 0], moving: false };
|
||||
const cycle = (elapsedMs - movement.startsAtMs) % movement.periodMs;
|
||||
if (cycle < movement.outboundMs) {
|
||||
return {
|
||||
position: [movement.distance * cycle / Math.max(1, movement.outboundMs), 0, 0],
|
||||
moving: true,
|
||||
};
|
||||
}
|
||||
const returnStart = movement.outboundMs;
|
||||
if (cycle < returnStart + movement.returnMs) {
|
||||
const progress = (cycle - returnStart) / Math.max(1, movement.returnMs);
|
||||
return { position: [movement.distance * (1 - progress), 0, 0], moving: true };
|
||||
}
|
||||
return { position: [0, 0, 0], moving: false };
|
||||
}
|
||||
|
||||
function distance(left: CombatPosition, right: CombatPosition): number {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function stackCompliant(scenario: BenchmarkScenarioDefinition): boolean | null {
|
||||
if (scenario.partyLayout !== "stacked") return null;
|
||||
const combat = useCombatStore.getState();
|
||||
return usePartyStore.getState().members
|
||||
.filter((member) => member.health > 0)
|
||||
.every((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position !== null && distance(combat.playerPosition, position) <= 5;
|
||||
});
|
||||
}
|
||||
|
||||
function primaryThreatSource(role: PartyRole): ThreatSource {
|
||||
if (role === "tank") return { actorId: PLAYER_AGGRO_ID, role: "tank", abilityId: "benchmark-pull" };
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
return {
|
||||
actorId: tank?.id ?? usePartyStore.getState().members[0]?.id ?? PLAYER_AGGRO_ID,
|
||||
role: "tank",
|
||||
abilityId: "benchmark-pull",
|
||||
};
|
||||
}
|
||||
|
||||
function seedThreat(mobIds: readonly string[], role: PartyRole, now: number): void {
|
||||
const source = primaryThreatSource(role);
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(id, 1, now, source, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
function applyTankThreatCompetition(mobIds: readonly string[], now: number): void {
|
||||
const damageMembers = usePartyStore.getState().members.filter((member) => member.role === "damage").slice(0, 2);
|
||||
for (const member of damageMembers) {
|
||||
for (const id of mobIds) {
|
||||
useCombatStore.getState().damageMob(
|
||||
id,
|
||||
Math.max(1, Math.round(member.level * 0.9)),
|
||||
now,
|
||||
{ actorId: member.id, role: "damage", abilityId: "benchmark-reference-dps" },
|
||||
"physical",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceExecuteWindow(
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
priorityTargetId: string,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
if (scenario.id !== "damage-single-sustained" || elapsedMs < durationMs * 0.8) return;
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[priorityTargetId];
|
||||
if (!target || target.dead || target.health / target.maxHealth <= 0.15) return state;
|
||||
return {
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[priorityTargetId]: { ...target, health: Math.max(1, Math.round(target.maxHealth * 0.15)) },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference bosses ignore player hard control so every tank receives the same attack schedule. */
|
||||
function clearBenchmarkControlStatuses(): void {
|
||||
useCombatStore.setState((state) => {
|
||||
let changed = false;
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => {
|
||||
const statuses = mob.statuses.filter((status) => !BENCHMARK_CONTROL_STATUS_KINDS.has(status.kind));
|
||||
if (statuses.length !== mob.statuses.length) changed = true;
|
||||
return [id, statuses.length === mob.statuses.length ? mob : { ...mob, statuses }];
|
||||
}));
|
||||
return changed ? { mobs } : state;
|
||||
});
|
||||
}
|
||||
|
||||
function anyInjuredBelow(percent: number): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 && combat.health / Math.max(1, combat.maxHealth) < percent) return true;
|
||||
return usePartyStore.getState().members.some((member) => (
|
||||
member.health > 0 && member.health / Math.max(1, member.maxHealth) < percent
|
||||
));
|
||||
}
|
||||
|
||||
/** Runs one build/scenario pair against the real authoritative player combat path. */
|
||||
export async function runCombatBenchmark(
|
||||
build: BenchmarkBuildDefinition,
|
||||
scenario: BenchmarkScenarioDefinition,
|
||||
options: BenchmarkRunOptions = {},
|
||||
): Promise<BenchmarkRunReport> {
|
||||
if (!scenario.roles.includes(build.role)) {
|
||||
throw new Error(`Scenario ${scenario.id} does not support the ${build.role} role.`);
|
||||
}
|
||||
const seed = Math.trunc(options.seed ?? 1);
|
||||
const durationMs = scaledDuration(scenario, options.durationScale ?? 1);
|
||||
const classIds = build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId];
|
||||
await preloadAbilityCatalogsForClasses(classIds);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = BENCHMARK_CLOCK_ORIGIN;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let recorder: BenchmarkRecorder | null = null;
|
||||
let simulatedDurationMs = 0;
|
||||
let completed = true;
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
level: build.level,
|
||||
talentRanks: build.talentRanks,
|
||||
inventory: build.inventory,
|
||||
equipment: build.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([0, 0, 0]);
|
||||
const initializedCombat = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty(
|
||||
`benchmark:${build.id}:${scenario.id}:${seed}`,
|
||||
build.level,
|
||||
initializedCombat.maxHealth,
|
||||
build.role,
|
||||
5,
|
||||
build.source,
|
||||
);
|
||||
const party = usePartyStore.getState();
|
||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const referenceTank = build.role === "tank"
|
||||
? { maxHealth: initializedCombat.maxHealth }
|
||||
: party.members.find((member) => member.role === "tank") ?? { maxHealth: initializedCombat.maxHealth };
|
||||
const attacks = enemyAttacks(scenario, build.level, referenceTank.maxHealth);
|
||||
const mobIds = Array.from({ length: scenario.hostileTargets }, (_, index) => `benchmark-${scenario.id}-${seed}-${index + 1}`);
|
||||
const ringRadius = benchmarkMobRingRadius(scenario);
|
||||
mobIds.forEach((id, index) => {
|
||||
setMobPosition(id, mobPosition(index, mobIds.length, ringRadius));
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: index === 0 ? "Benchmark Boss" : `Benchmark Target ${index + 1}`,
|
||||
boss: index === 0,
|
||||
level: build.level,
|
||||
maxHealth: BENCHMARK_MOB_HEALTH,
|
||||
xpReward: 0,
|
||||
hasLoot: false,
|
||||
moveSpeed: 0,
|
||||
aggroRange: 45,
|
||||
leashRange: 60,
|
||||
attacks,
|
||||
mechanicImmunities: BENCHMARK_BOSS_CONTROL_IMMUNITIES,
|
||||
});
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
seedThreat(mobIds, build.role, now);
|
||||
useCombatStore.getState().selectMob(mobIds[0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
recorder = new BenchmarkRecorder(build, scenario, seed, now, mobIds[0]);
|
||||
const controller = new BenchmarkController();
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs: 0,
|
||||
combat: useCombatStore.getState(),
|
||||
party: usePartyStore.getState(),
|
||||
movedDistance: 0,
|
||||
moving: false,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: 0,
|
||||
totalThreatMobs: 0,
|
||||
});
|
||||
|
||||
let resourceFailureDecisions = 0;
|
||||
let previousPosition: CombatPosition = [0, 0, 0];
|
||||
let nextThreatCompetitionAt = 2_000;
|
||||
while (simulatedDurationMs < durationMs) {
|
||||
const deltaMs = Math.min(scenario.tickMs, durationMs - simulatedDurationMs);
|
||||
simulatedDurationMs += deltaMs;
|
||||
now = BENCHMARK_CLOCK_ORIGIN + simulatedDurationMs;
|
||||
const movement = movementPosition(scenario, simulatedDurationMs);
|
||||
useCombatStore.getState().setPlayerPosition(movement.position);
|
||||
const movedDistance = distance(previousPosition, movement.position);
|
||||
previousPosition = movement.position;
|
||||
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
useCombatStore.getState().tick(deltaMs / 1_000, now);
|
||||
enforceExecuteWindow(scenario, mobIds[0], simulatedDurationMs, durationMs);
|
||||
restoreBenchmarkMobPositions(scenario, mobIds);
|
||||
if (scenario.attacks.length) {
|
||||
clearBenchmarkControlStatuses();
|
||||
useCombatStore.getState().advanceMobCombat(now, movement.position);
|
||||
}
|
||||
if (scenario.id === "tank-multi-target" && simulatedDurationMs >= nextThreatCompetitionAt) {
|
||||
applyTankThreatCompetition(mobIds, now);
|
||||
nextThreatCompetitionAt += 2_000;
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const decision = controller.act(now, build.role, scenario, movement.moving);
|
||||
for (let index = 0; index < decision.invalidFailures; index += 1) recorder.recordInvalidCast();
|
||||
if (scenario.resourceFailureGcds && decision.made && anyInjuredBelow(0.8)) {
|
||||
resourceFailureDecisions = decision.cast || !decision.resourceBlocked
|
||||
? 0
|
||||
: resourceFailureDecisions + 1;
|
||||
if (resourceFailureDecisions >= scenario.resourceFailureGcds) recorder.markResourceFailure(now);
|
||||
}
|
||||
|
||||
const currentCombat = useCombatStore.getState();
|
||||
const currentParty = usePartyStore.getState();
|
||||
const activeMobs = Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead);
|
||||
recorder.sample({
|
||||
now,
|
||||
deltaMs,
|
||||
combat: currentCombat,
|
||||
party: currentParty,
|
||||
movedDistance,
|
||||
moving: movement.moving,
|
||||
stackCompliant: stackCompliant(scenario),
|
||||
heldMobs: build.role === "tank"
|
||||
? activeMobs.filter((mob) => mob.targetActorId === PLAYER_AGGRO_ID).length
|
||||
: 0,
|
||||
totalThreatMobs: build.role === "tank" ? activeMobs.length : 0,
|
||||
});
|
||||
|
||||
const playerDied = currentCombat.health <= 0 && (scenario.stopOnPlayerDeath || build.role === "tank");
|
||||
const partyDied = currentParty.members.some((member) => member.health <= 0)
|
||||
&& (scenario.stopOnPartyDeath || build.role === "healer");
|
||||
const resourceFailed = scenario.resourceFailureGcds !== undefined
|
||||
&& resourceFailureDecisions >= scenario.resourceFailureGcds;
|
||||
if (playerDied || partyDied || resourceFailed) {
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.finish(simulatedDurationMs, completed);
|
||||
} catch (error: unknown) {
|
||||
if (!recorder) throw error;
|
||||
const partial = recorder.finish(simulatedDurationMs, false);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Object.freeze({
|
||||
...partial,
|
||||
completed: false,
|
||||
validation: Object.freeze({
|
||||
valid: false,
|
||||
reasons: Object.freeze([...partial.validation.reasons, `Runtime error: ${message}`]),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
recorder?.dispose();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects only scenario/build combinations that share a declared role. */
|
||||
export function compatibleBenchmarkPairs(
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
scenarios: readonly BenchmarkScenarioDefinition[],
|
||||
): readonly (readonly [BenchmarkBuildDefinition, BenchmarkScenarioDefinition])[] {
|
||||
return builds.flatMap((build) => scenarios
|
||||
.filter((scenario) => scenario.roles.includes(build.role))
|
||||
.map((scenario) => [build, scenario] as const));
|
||||
}
|
||||
Reference in new issue
Block a user