Update Healer Man server
This commit is contained in:
1 parent
f8314e7de1
commit
1fedb9368d
76 files changed
+20556
-2523
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user