Update Healer Man server

This commit is contained in:
phenom committed 2026-09-19 21:01:51 -04:00
1 parent f8314e7de1
commit 1fedb9368d
76 files changed
+20556 -2523

No files matched your search

+163 -4
View File
@@ -13,16 +13,23 @@ import {
abilitiesForCharacter,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
type ResourceType,
} from "../../src/game/abilityCatalog";
import { aiHintsForAbility } from "../../src/game/abilityRules";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import { installCombatClock } from "../../src/game/combatClock";
import { companionResourceMaximum } from "../../src/game/combatActorAuthority";
import { subscribeCombatEvents } from "../../src/game/combatEvents";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
import { useCombatStore } from "../../src/game/combatStore";
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
import {
resolvedAverageItemLevel,
resolvedEquipmentItems,
resolvedEquipmentStats,
} from "../../src/game/heirloomEquipment";
import {
BOSS_TESTING_CATALOG,
createBossTestingRoom,
@@ -46,10 +53,18 @@ import {
} from "../../src/game/partyStore";
import { specializationsForClass } from "../../src/game/specializationCatalog";
import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import { COMBAT_ROTATION_VERSION, rotationProfileFor } from "../../src/game/combatRotation";
import { subscribeRotationDecisions } from "../../src/game/rotationTelemetry";
import {
DEFAULT_COMPANION_COOLDOWN_POLICY,
installCompanionCooldownPolicy,
type CompanionCooldownPolicy,
} from "../../src/game/companionCooldownPolicy";
import {
CANONICAL_BENCHMARK_GEAR_PRESET_ID,
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
type SelectableBenchmarkBuildCatalogEntry,
} from "../../src/game/benchmark/benchmarkBuilds";
import type {
BenchmarkBuildDefinition,
@@ -69,6 +84,7 @@ interface BalanceLabConfig {
readonly activeSlot: number;
readonly bossCategory: BossCategory;
readonly bossKeys?: readonly string[];
readonly cooldownPolicy?: CompanionCooldownPolicy;
}
interface MutableAbilityMetrics {
@@ -101,6 +117,16 @@ interface MutableMemberMetrics {
casts: number;
minimumHealthPercent: number;
readonly abilities: Map<string, MutableAbilityMetrics>;
readonly rotationDecisions: Map<string, {
profileId: string;
ruleId: string;
reason: string;
abilityId: string;
decisions: number;
}>;
gcdObservedMs: number;
gcdBusyMs: number;
readonly resourceSamples: Map<string, { samples: number; starved: number; capped: number }>;
}
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
@@ -149,6 +175,28 @@ function bossCatalogRows() {
}));
}
function resolvedGearSummary(build: SelectableBenchmarkBuildCatalogEntry) {
const specialization = specializationsForClass(build.classId)
.find((definition) => definition.displayName === build.orientation);
const context = {
ownerId: `balance-lab-catalog:${build.id}`,
ownerKind: "player" as const,
classId: build.classId,
specializationId: specialization?.id ?? null,
role: build.role,
level: build.level,
};
const items = resolvedEquipmentItems({}, [], context);
return {
presetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID,
model: "synthetic-specialization-heirloom-v1",
itemCount: items.length,
averageItemLevel: resolvedAverageItemLevel({}, [], context),
itemLevels: [...new Set(items.map((item) => item.itemLevel))].sort((left, right) => left - right),
stats: resolvedEquipmentStats({}, [], context),
};
}
async function writeJson(path: string, value: unknown): Promise<void> {
const absolute = resolve(path);
await mkdir(dirname(absolute), { recursive: true });
@@ -171,7 +219,18 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
className: classById(build.classId).name,
role: build.role,
specialization: build.orientation,
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
specializationId: build.specializationId ?? null,
readinessStatus: build.readinessStatus ?? null,
characterName: build.characterName ?? null,
talentBuildId: build.talentBuildId ?? null,
talentBuildName: build.talentBuildName ?? null,
talentBuildSource: build.talentBuildSource ?? null,
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
defaultTalentBuild: build.defaultTalentBuild ?? true,
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`,
gear: resolvedGearSummary(build),
})),
bosses: bossCatalogRows(),
};
@@ -207,7 +266,7 @@ function configuredCompanion(
const specialization = specializationsForClass(build.classId)
.find((definition) => definition.displayName === build.orientation);
return createConfiguredPartyMember({
name: `${classById(build.classId).name} ${build.orientation}`,
name: build.characterName ?? `${classById(build.classId).name} ${build.orientation}`,
classId: build.classId,
secondaryClassId: build.secondaryClassId,
specialization: build.orientation,
@@ -255,18 +314,30 @@ function currentHealth(actorId: string): readonly [number, number] {
}
function availableAbilities(build: BenchmarkBuildDefinition) {
const specialization = specializationsForClass(build.classId)
.find((definition) => definition.displayName === build.orientation);
const profile = rotationProfileFor(build.classId, specialization?.id, build.orientation);
return abilitiesForCharacter(build.classId, build.secondaryClassId)
.filter((ability) => !ability.passive)
.map((ability) => {
const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
if (!ranked) return null;
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
const hints = aiHintsForAbility(ranked);
const rotationPriority = profile?.abilityPriority.indexOf(ranked.name) ?? -1;
const conditional = hints.categories.some((category) => [
"area-damage", "control", "dispel", "emergency-heal", "execute", "interrupt",
"mitigation", "resurrection", "taunt",
].includes(category));
return {
abilityId: ranked.id,
abilityName: ranked.name,
icon: ranked.icon,
categories: aiHintsForAbility(ranked).categories,
categories: hints.categories,
cooldownSeconds: ranked.cooldownMs / 1_000,
rotationProfileId: profile?.id ?? null,
rotationPriority: rotationPriority >= 0 ? rotationPriority + 1 : null,
expectedUsage: conditional ? "conditional" : rotationPriority >= 0 ? "profile" : "available",
};
})
.filter((ability) => ability !== null)
@@ -283,6 +354,13 @@ function serializedMember(
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
actorId: metrics.actorId,
buildId: metrics.build.id,
characterName: metrics.build.characterName ?? null,
talentBuildId: metrics.build.talentBuildId ?? null,
talentBuildName: metrics.build.talentBuildName ?? null,
talentBuildSource: metrics.build.talentBuildSource ?? null,
talentBuildSourcePointCount: metrics.build.talentBuildSourcePointCount ?? null,
talentBuildSourcePointsRetained: metrics.build.talentBuildSourcePointsRetained ?? null,
talentBuildFallbackPointCount: metrics.build.talentBuildFallbackPointCount ?? null,
classId: metrics.build.classId,
className: classById(metrics.build.classId).name,
specialization: metrics.build.orientation,
@@ -309,6 +387,18 @@ function serializedMember(
maxHealth,
minimumHealthPercent: metrics.minimumHealthPercent,
casts: metrics.casts,
gcdUptimePercent: metrics.gcdObservedMs > 0 ? metrics.gcdBusyMs / metrics.gcdObservedMs * 100 : null,
resources: [...metrics.resourceSamples.entries()].map(([resource, samples]) => ({
resource,
samples: samples.samples,
starvedPercent: samples.starved / Math.max(1, samples.samples) * 100,
cappedPercent: samples.capped / Math.max(1, samples.samples) * 100,
})).sort((left, right) => left.resource.localeCompare(right.resource)),
rotationDecisions: [...metrics.rotationDecisions.values()]
.sort((left, right) => right.decisions - left.decisions
|| left.profileId.localeCompare(right.profileId)
|| left.ruleId.localeCompare(right.ruleId)
|| left.abilityId.localeCompare(right.abilityId)),
availableAbilities: availableAbilities(metrics.build),
abilities: [...metrics.abilities.entries()]
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
@@ -377,10 +467,31 @@ async function runBoss(
casts: 0,
minimumHealthPercent: 1,
abilities: new Map(),
rotationDecisions: new Map(),
gcdObservedMs: 0,
gcdBusyMs: 0,
resourceSamples: new Map(),
}));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const actorAlive = new Map<string, boolean>();
const bossAbilities = new Map<string, MutableAbilityMetrics>();
const pressureWindows = new Set<number>();
const healerThroughputWindows = new Set<number>();
const combatWindow = (occurredAt: number) => Math.max(0, Math.floor((occurredAt - 1_000_000) / 5_000));
const stopRotationDecisions = subscribeRotationDecisions((event) => {
const member = byActorId.get(event.actorId);
if (!member) return;
const key = `${event.profileId}\u0000${event.ruleId}\u0000${event.abilityId}`;
const current = member.rotationDecisions.get(key);
if (current) current.decisions += 1;
else member.rotationDecisions.set(key, {
profileId: event.profileId,
ruleId: event.ruleId,
reason: event.reason,
abilityId: event.abilityId,
decisions: 1,
});
});
// Instant resource, aura, pet, and summon actions do not produce the damage,
// healing, absorb, or cast events used by the original report collector.
// Count their completed release once so Balance Lab does not label working
@@ -420,6 +531,7 @@ async function runBoss(
ability.absorbs += event.absorbed;
ability.hits += 1;
bossAbilities.set(key, ability);
if (event.effectiveAmount > 0) pressureWindows.add(combatWindow(event.occurredAt));
}
} else if (event.kind === "healing") {
if (source) {
@@ -431,11 +543,17 @@ async function runBoss(
ability.effectiveHealing += event.effectiveAmount;
ability.overheal += event.overheal;
ability.hits += 1;
if (source.build.role === "healer" && event.effectiveAmount > 0) {
healerThroughputWindows.add(combatWindow(event.occurredAt));
}
}
if (target) target.healingReceived += event.effectiveAmount;
} else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
if (source.build.role === "healer" && event.effectiveAmount > 0) {
healerThroughputWindows.add(combatWindow(event.occurredAt));
}
}
});
@@ -500,6 +618,29 @@ async function runBoss(
combat.advanceMobCombat(now);
advanceDungeonPartyCombat(now);
controller.act(now, activeBuild.role, runScenario, false);
for (const member of usePartyStore.getState().members) {
const metrics = byActorId.get(member.id);
if (!metrics) continue;
// Dead companions cannot act or gain/spend resources. Sampling them until
// the fight ends makes an early death look like an idle/resource deadlock.
if (currentHealth(member.id)[0] <= 0) continue;
metrics.gcdObservedMs += 100;
if (member.globalCooldownEndsAt > now || member.activeCast) metrics.gcdBusyMs += 100;
const resources = new Set<ResourceType>([
...(Object.keys(member.resourcePools) as ResourceType[]),
member.resourceType,
]);
for (const resource of resources) {
const maximum = companionResourceMaximum(member, resource);
if (maximum <= 0) continue;
const value = member.resourcePools[resource] ?? (resource === member.resourceType ? member.resource : 0);
const sample = metrics.resourceSamples.get(resource) ?? { samples: 0, starved: 0, capped: 0 };
sample.samples += 1;
if (value / maximum <= 0.1) sample.starved += 1;
if (value / maximum >= 0.95) sample.capped += 1;
metrics.resourceSamples.set(resource, sample);
}
}
for (const metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId);
const alive = health > 0;
@@ -527,6 +668,11 @@ async function runBoss(
const rawIncomingDamage = members.reduce((total, member) => total + member.rawDamageTaken, 0);
const partyDamage = members.reduce((total, member) => total + member.damage, 0);
const bossDamage = members.reduce((total, member) => total + member.bossDamage, 0);
const healerResponseWindows = [...pressureWindows].filter((window) => (
healerThroughputWindows.has(window - 1)
|| healerThroughputWindows.has(window)
|| healerThroughputWindows.has(window + 1)
)).length;
return {
bossKey: boss.key,
bossName: boss.name,
@@ -547,6 +693,10 @@ async function runBoss(
incomingDamage,
rawIncomingDamage,
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
pressureWindows: pressureWindows.size,
healerThroughputWindows: healerThroughputWindows.size,
healerResponseWindows,
healerResponseRate: healerResponseWindows / Math.max(1, pressureWindows.size),
deaths: members.filter((member) => !member.alive).length,
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
bossAttacks: mob?.attackRevision ?? 0,
@@ -557,6 +707,7 @@ async function runBoss(
|| left.abilityId.localeCompare(right.abilityId)),
};
} finally {
stopRotationDecisions();
stopPresentation();
stopEvents();
restoreClock();
@@ -574,6 +725,7 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
throw new Error("activeSlot must be an integer from 0 through 4.");
}
if (!config.seeds.length) throw new Error("At least one simulation seed is required.");
installCompanionCooldownPolicy(config.cooldownPolicy ?? DEFAULT_COMPANION_COOLDOWN_POLICY);
const catalog = selectableBenchmarkBuildCatalog(config.level, classIdsForSource(config.source));
const catalogById = new Map(catalog.map((build) => [build.id, build]));
const selectedClassIds = [...new Set(config.partyBuildIds.map((id) => {
@@ -616,6 +768,13 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
slot,
activePlayer: slot === config.activeSlot,
buildId: build.id,
characterName: build.characterName ?? null,
talentBuildId: build.talentBuildId ?? null,
talentBuildName: build.talentBuildName ?? null,
talentBuildSource: build.talentBuildSource ?? null,
talentBuildSourcePointCount: build.talentBuildSourcePointCount ?? null,
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
classId: build.classId,
className: classById(build.classId).name,
specialization: build.orientation,