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

+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0\.."
call npm run balance:gui
+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,
+164 -16
View File
@@ -118,7 +118,7 @@ def build_aliases(build: dict[str, Any]) -> set[str]:
class_name = build["className"]
role = build["role"]
specialization = build["specialization"]
return {
aliases = {
normalized(build["id"]),
normalized(f"{class_id}:{specialization}"),
normalized(f"{class_name}:{specialization}"),
@@ -126,6 +126,11 @@ def build_aliases(build: dict[str, Any]) -> set[str]:
normalized(f"{class_name}:{role}:{specialization}"),
normalized(build["label"]),
}
for value in (build.get("characterName"), build.get("talentBuildId"), build.get("talentBuildName")):
if value:
aliases.add(normalized(str(value)))
aliases.add(normalized(f"{class_id}:{specialization}:{value}"))
return aliases
def resolve_build(selector: str, builds: Sequence[dict[str, Any]]) -> dict[str, Any]:
@@ -135,6 +140,9 @@ def resolve_build(selector: str, builds: Sequence[dict[str, Any]]) -> dict[str,
return matches[0]
if not matches:
raise SystemExit(f"No party member matches '{selector}'. Use --list-members to see valid choices.")
defaults = [match for match in matches if match.get("defaultTalentBuild")]
if len(defaults) == 1:
return defaults[0]
labels = ", ".join(match["label"] for match in matches)
raise SystemExit(f"Party member selector '{selector}' is ambiguous: {labels}")
@@ -156,6 +164,11 @@ def resolve_boss(selector: str, bosses: Sequence[dict[str, Any]]) -> dict[str, A
def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str, Any]]:
generator = random.Random(seed)
# Named alternatives remain directly selectable and are covered by "all",
# but ordinary random/filler seats should use the declared default profile.
defaults = [build for build in builds if build.get("defaultTalentBuild")]
if defaults:
builds = defaults
by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
for build in builds:
by_role[build["role"]].append(build)
@@ -165,6 +178,27 @@ def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str,
return [generator.choice(by_role["tank"]), generator.choice(by_role["healer"]), *damage]
def readiness_eligible_builds(
builds: Sequence[dict[str, Any]],
policy: dict[str, Any] | None,
include_beta_specs: bool,
) -> list[dict[str, Any]]:
"""Filter only WoW NPC builds; CoA and RoM catalogs remain unchanged."""
published = policy or {}
config = published.get("config", published)
statuses = config.get("statuses", {}) if isinstance(config, dict) else {}
eligible: list[dict[str, Any]] = []
for build in builds:
if build.get("source") != "wow":
eligible.append(build)
continue
specialization_id = build.get("specializationId")
status = statuses.get(specialization_id, build.get("readinessStatus", "needs-testing"))
if status == "ready" or (include_beta_specs and status == "beta"):
eligible.append(build)
return eligible
def automatic_parties(
builds: Sequence[dict[str, Any]],
sources: Sequence[str],
@@ -206,6 +240,7 @@ def coverage_parties(
sources: Sequence[str],
selected_build_ids: Sequence[str] | None,
seed: int,
filler_builds: Sequence[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Build the minimum deterministic party set that covers every requested build."""
requested = set(selected_build_ids) if selected_build_ids is not None else None
@@ -220,7 +255,9 @@ def coverage_parties(
continue
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
for build in source_builds:
source_fillers = [build for build in (filler_builds or builds) if build["source"] == source]
default_builds = [build for build in source_fillers if build.get("defaultTalentBuild")]
for build in default_builds or source_fillers:
available_by_role[build["role"]].append(build)
for build in targets:
targets_by_role[build["role"]].append(build)
@@ -794,7 +831,8 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
"policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
"incomingDtps", "pressureWindows", "healerThroughputWindows", "healerResponseWindows",
"healerResponseRate", "deaths", "deathEvents", "bossAttacks",
))
member_abilities = flattened_abilities(raw, "members")
write_csv(output_dir / "member_abilities.csv", member_abilities, (
@@ -822,6 +860,13 @@ def aggregate_class_specs(
"className": class_name,
"specialization": specialization,
"role": role,
"characterName": observations[0].get("characterName"),
"talentBuildId": observations[0].get("talentBuildId"),
"talentBuildName": observations[0].get("talentBuildName"),
"talentBuildSource": observations[0].get("talentBuildSource"),
"talentBuildSourcePointCount": observations[0].get("talentBuildSourcePointCount"),
"talentBuildSourcePointsRetained": observations[0].get("talentBuildSourcePointsRetained"),
"talentBuildFallbackPointCount": observations[0].get("talentBuildFallbackPointCount"),
"partyConfigurations": len({row["partyConfigurationId"] for row in observations}),
"runs": len(observations),
"dps": weighted_rate(observations, "damage"),
@@ -829,6 +874,10 @@ def aggregate_class_specs(
"hps": weighted_rate(observations, "healing"),
"hpsReceived": weighted_rate(observations, "healingReceived"),
"dtps": weighted_rate(observations, "damageTaken"),
"gcdUptimePercent": mean(
float(row["gcdUptimePercent"]) for row in observations
if row.get("gcdUptimePercent") is not None
),
"totalDamage": sum(row["damage"] for row in observations),
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
"totalHealing": sum(row["healing"] for row in observations),
@@ -878,9 +927,19 @@ def aggregate_class_abilities(
available_runs = sum(bool(row["available"]) for row in observations)
class_damage = sum(float(row["damage"]) for row in class_observations)
metadata = next((row for row in observations if row["available"]), observations[0])
rotation_decisions = sum(int(row.get("rotationDecisions", 0)) for row in observations)
effective_healing = sum(float(row["effectiveHealing"]) for row in observations)
absorbs = sum(float(row["absorbs"]) for row in observations)
if active_runs == 0 and available_runs > 0:
# A profile entry is often an alternative, execute, or lower-priority
# fallback and is not required to appear in every short fight. Flag it
# only when the AI repeatedly selected it without any committed cast or
# effect; one terminal decision per run may simply be interrupted by
# the boss dying or the duration limit.
if (active_runs == 0 and available_runs > 0
and rotation_decisions > available_runs
and metadata.get("expectedUsage") == "profile"):
status = "missing-profile-action"
elif active_runs == 0 and available_runs > 0:
status = "unused"
elif effective_damage > 0:
status = "damage"
@@ -900,6 +959,10 @@ def aggregate_class_abilities(
"icon": metadata.get("icon") or "",
"categories": ", ".join(metadata.get("categories") or []),
"cooldownSeconds": metadata.get("cooldownSeconds"),
"rotationProfileId": metadata.get("rotationProfileId"),
"rotationPriority": metadata.get("rotationPriority"),
"expectedUsage": metadata.get("expectedUsage", "available"),
"rotationDecisions": rotation_decisions,
"partyConfigurations": len({row["partyConfigurationId"] for row in class_observations}),
"bosses": len({row["bossKey"] for row in class_observations}),
"runs": len(class_observations),
@@ -920,7 +983,8 @@ def aggregate_class_abilities(
"overheal": sum(float(row["overheal"]) for row in observations),
"absorbs": absorbs,
"status": status,
"unusedAvailable": status == "unused" and available_runs > 0,
"unusedAvailable": status in {"unused", "missing-profile-action"} and available_runs > 0,
"missingProfileAction": status == "missing-profile-action",
})
rows.sort(key=lambda row: (
row["source"], row["className"], row["specialization"],
@@ -1107,6 +1171,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
npc_class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
npc_class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
policy_ids: set[str] = set()
rotation_versions: set[str] = set()
for entry in entries:
@@ -1157,10 +1223,15 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)
class_groups[key].append(observation)
class_boss_groups[(*key, run["bossKey"], run["bossName"], run["dungeonTitle"])].append(observation)
if not member.get("activePlayer"):
npc_class_groups[key].append(observation)
available = {
ability["abilityId"]: ability for ability in member.get("availableAbilities", [])
}
observed = {ability["abilityId"]: ability for ability in member.get("abilities", [])}
decisions_by_ability: dict[str, int] = defaultdict(int)
for decision in member.get("rotationDecisions", []):
decisions_by_ability[decision["abilityId"]] += int(decision.get("decisions", 0))
for ability_id in sorted((set(available) | set(observed)) - {"balance-lab-pull"}):
expected = available.get(ability_id, {})
metrics = observed.get(ability_id, {})
@@ -1168,7 +1239,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"casts", "hits", "rawDamage", "effectiveDamage", "rawHealing",
"effectiveHealing", "overheal", "absorbs",
))
class_ability_groups[(*key, ability_id)].append({
ability_observation = {
"partyConfigurationId": entry["id"],
"bossKey": run["bossKey"],
"elapsedSeconds": run["elapsedSeconds"],
@@ -1176,6 +1247,10 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"icon": expected.get("icon"),
"categories": expected.get("categories", []),
"cooldownSeconds": expected.get("cooldownSeconds"),
"rotationProfileId": expected.get("rotationProfileId"),
"rotationPriority": expected.get("rotationPriority"),
"expectedUsage": expected.get("expectedUsage", "available"),
"rotationDecisions": decisions_by_ability[ability_id],
"available": ability_id in available,
"used": used,
"casts": metrics.get("casts", 0),
@@ -1186,10 +1261,15 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"effectiveHealing": metrics.get("effectiveHealing", 0),
"overheal": metrics.get("overheal", 0),
"absorbs": metrics.get("absorbs", 0),
})
}
class_ability_groups[(*key, ability_id)].append(ability_observation)
if not member.get("activePlayer"):
npc_class_ability_groups[(*key, ability_id)].append(ability_observation)
bosses, pressure_median = aggregate_boss_runs(compact_runs)
class_specs = aggregate_class_specs(class_groups)
class_abilities = aggregate_class_abilities(class_ability_groups, class_groups)
npc_class_specs = aggregate_class_specs(npc_class_groups)
npc_class_abilities = aggregate_class_abilities(npc_class_ability_groups, npc_class_groups)
class_spec_by_boss = []
for key, observations in class_boss_groups.items():
source, build_id, class_id, class_name, specialization, role, boss_key, boss_name, dungeon_title = key
@@ -1233,10 +1313,14 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"runs": len(compact_runs),
"classSpecs": len(class_specs),
"classAbilities": len(class_abilities),
"npcClassSpecs": len(npc_class_specs),
"npcClassAbilities": len(npc_class_abilities),
"bossPressureMedianDtps": pressure_median,
},
"classSpecs": class_specs,
"classAbilities": class_abilities,
"npcClassSpecs": npc_class_specs,
"npcClassAbilities": npc_class_abilities,
"partyConfigurations": party_rows,
"bosses": bosses,
"bossByParty": boss_by_party,
@@ -1250,16 +1334,34 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"defeatRate", "wipeRate", "averageDeaths", "reportPath",
))
write_csv(output_dir / "class_specs_across_parties.csv", class_specs, (
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag",
"source", "buildId", "characterName", "talentBuildId", "talentBuildName", "talentBuildSource",
"talentBuildSourcePointCount", "talentBuildSourcePointsRetained", "talentBuildFallbackPointCount",
"classId", "className", "specialization", "role", "partyConfigurations",
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "gcdUptimePercent", "damageVsSourceRoleMedian", "damageFlag",
"totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
))
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
"source", "buildId", "classId", "className", "specialization", "role", "abilityId",
"abilityName", "categories", "cooldownSeconds", "partyConfigurations", "bosses", "runs",
"abilityName", "categories", "cooldownSeconds", "rotationProfileId", "rotationPriority",
"expectedUsage", "rotationDecisions", "partyConfigurations", "bosses", "runs",
"availableRuns", "activeRuns", "usageRate", "casts", "castsPerMinute", "hits", "hitsPerRun",
"rawDamage", "effectiveDamage", "dps", "damageShare", "averageDamagePerHit", "rawHealing",
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable",
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable", "missingProfileAction",
))
write_csv(output_dir / "npc_class_specs_across_parties.csv", npc_class_specs, (
"source", "buildId", "characterName", "talentBuildId", "talentBuildName", "talentBuildSource",
"talentBuildSourcePointCount", "talentBuildSourcePointsRetained", "talentBuildFallbackPointCount",
"classId", "className", "specialization", "role", "partyConfigurations",
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "gcdUptimePercent", "damageVsSourceRoleMedian", "damageFlag",
"totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
))
write_csv(output_dir / "npc_class_abilities_across_parties.csv", npc_class_abilities, (
"source", "buildId", "classId", "className", "specialization", "role", "abilityId",
"abilityName", "categories", "cooldownSeconds", "rotationProfileId", "rotationPriority",
"expectedUsage", "rotationDecisions", "partyConfigurations", "bosses", "runs",
"availableRuns", "activeRuns", "usageRate", "casts", "castsPerMinute", "hits", "hitsPerRun",
"rawDamage", "effectiveDamage", "dps", "damageShare", "averageDamagePerHit", "rawHealing",
"effectiveHealing", "overheal", "absorbs", "status", "unusedAvailable", "missingProfileAction",
))
write_csv(output_dir / "bosses_across_parties.csv", bosses, (
"bossKey", "bossName", "category", "dungeonId", "dungeonTitle", "runs", "incomingDtps",
@@ -1282,7 +1384,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"dungeonTitle", "seed", "outcome",
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
"incomingDtps", "pressureWindows", "healerThroughputWindows", "healerResponseWindows",
"healerResponseRate", "deaths", "deathEvents", "bossAttacks",
))
return report
@@ -1300,12 +1403,20 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
result.add_argument("--active-slot", type=int, help="1-based member controlled by the benchmark policy; defaults to healer")
result.add_argument("--seat-sweep", action="store_true",
help="run every party five times so each member is active once and an NPC four times")
result.add_argument("--boss-category", choices=("all", "wow", "rom"), default="all")
result.add_argument("--boss", action="append", default=[], help="boss key/name selector; repeat as needed; default is every boss")
result.add_argument("--simulations-per-party", type=int, default=3,
help="deterministic runs for each party/boss pairing")
result.add_argument("--seeds", type=parse_seeds, help="explicit comma-separated seeds; overrides --simulations-per-party")
result.add_argument("--duration", type=int, default=60, help="maximum fight duration in seconds")
result.add_argument("--cooldown-policy", type=Path,
help="JSON companion cooldown policy; defaults to the built-in generic rules")
result.add_argument("--companion-readiness-policy", type=Path,
help="published companion readiness policy JSON; defaults to shipped catalog statuses")
result.add_argument("--include-beta-specs", action="store_true",
help="include Beta WoW specs in automatic, random, and filler NPC seats")
result.add_argument("--output-dir", type=Path, help="report directory")
result.add_argument("--list-members", action="store_true")
result.add_argument("--list-bosses", action="store_true")
@@ -1349,14 +1460,32 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.simulations_per_party < 1:
session.close()
raise SystemExit("--simulations-per-party must be at least 1")
if args.seat_sweep and args.active_slot is not None:
session.close()
raise SystemExit("--seat-sweep and --active-slot cannot be used together")
seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
cooldown_policy = None
if args.cooldown_policy:
try:
cooldown_policy = json.loads(args.cooldown_policy.resolve().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
session.close()
raise SystemExit(f"Could not read --cooldown-policy: {error}") from error
readiness_policy = None
if args.companion_readiness_policy:
try:
readiness_policy = json.loads(args.companion_readiness_policy.resolve().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
session.close()
raise SystemExit(f"Could not read --companion-readiness-policy: {error}") from error
eligible_builds = readiness_eligible_builds(builds, readiness_policy, args.include_beta_specs)
if args.party_mode == "automatic":
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
if args.spec_selection != "specific" and args.spec:
session.close()
raise SystemExit("--spec values require --spec-selection specific")
if args.spec_selection == "random":
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
configurations = automatic_parties(eligible_builds, sources, args.party_configurations, args.party_seed)
selected_build_ids: list[str] = []
else:
if args.spec_selection == "specific" and not args.spec:
@@ -1366,10 +1495,11 @@ def main(argv: Sequence[str] | None = None) -> int:
resolve_build(selector, builds)["id"] for selector in args.spec
))
configurations = coverage_parties(
builds,
eligible_builds if args.spec_selection == "all" else builds,
sources,
None if args.spec_selection == "all" else selected_build_ids,
args.party_seed,
eligible_builds,
)
if args.active_slot is not None:
if not 1 <= args.active_slot <= 5:
@@ -1387,7 +1517,7 @@ def main(argv: Sequence[str] | None = None) -> int:
configurations = [{"id": f"{args.source}-party-001", "source": args.source,
"party": party, "activeSlot": active_slot - 1}]
else:
party = random_party(builds, args.party_seed)
party = random_party(eligible_builds, args.party_seed)
active_slot = args.active_slot or next(
(index + 1 for index, build in enumerate(party) if build["role"] == "healer"), 1
)
@@ -1396,6 +1526,17 @@ def main(argv: Sequence[str] | None = None) -> int:
if any(not 0 <= configuration["activeSlot"] < 5 for configuration in configurations):
session.close()
raise SystemExit("--active-slot must be from 1 through 5")
if args.seat_sweep:
configurations = [
{
**configuration,
"id": f"{configuration['id']}-seat-{slot + 1}",
"seatSweepGroupId": configuration["id"],
"activeSlot": slot,
}
for configuration in configurations
for slot in range(5)
]
bosses = [resolve_boss(selector, catalog["bosses"]) for selector in args.boss]
boss_count = len(bosses) if bosses else sum(
args.boss_category == "all" or boss["category"] == args.boss_category for boss in catalog["bosses"]
@@ -1406,13 +1547,16 @@ def main(argv: Sequence[str] | None = None) -> int:
f"x {len(seeds)} runs per party/boss)..."
)
entries = []
automatic = args.party_mode == "automatic"
automatic = args.party_mode == "automatic" or args.seat_sweep
if automatic:
write_json(output_dir / "suite-config.json", {
"schemaVersion": 1,
"policyId": catalog["policyId"],
"rotationVersion": catalog["rotationVersion"],
"mode": "automatic",
"seatSweep": args.seat_sweep,
"includeBetaSpecs": args.include_beta_specs,
"companionReadinessPolicy": readiness_policy,
"specSelection": args.spec_selection,
"selectedBuildIds": selected_build_ids,
"source": args.source,
@@ -1428,6 +1572,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"parties": [{
"id": configuration["id"],
"source": configuration["source"],
"seatSweepGroupId": configuration.get("seatSweepGroupId"),
"activeSlot": configuration["activeSlot"],
"partyBuildIds": [build["id"] for build in configuration["party"]],
} for configuration in configurations],
@@ -1445,6 +1590,9 @@ def main(argv: Sequence[str] | None = None) -> int:
"partyBuildIds": [build["id"] for build in party],
"activeSlot": configuration["activeSlot"],
"bossCategory": args.boss_category,
**({"cooldownPolicy": cooldown_policy} if cooldown_policy else {}),
**({"seatSweepGroupId": configuration["seatSweepGroupId"]}
if configuration.get("seatSweepGroupId") else {}),
**({"bossKeys": [boss["key"] for boss in bosses]} if bosses else {}),
}
config_path = party_dir / "config.json"
+533
View File
@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Run and compare repeatable local Balance Lab level brackets."""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Sequence
ROOT = Path(__file__).resolve().parents[1]
BALANCE_LAB = ROOT / "scripts" / "balance_lab.py"
def write_text(path: Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value.rstrip() + "\n", encoding="utf-8", newline="\n")
def write_json(path: Path, value: Any) -> None:
write_text(path, json.dumps(value, indent=2, ensure_ascii=False))
def write_csv(path: Path, rows: Sequence[dict[str, Any]], fields: Sequence[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
def bracket_levels(step: int) -> list[int]:
if step not in (1, 5, 10):
raise ValueError("Bracket step must be 1, 5, or 10.")
if step == 1:
return list(range(1, 81))
return [1, *range(step, 81, step)]
def parse_levels(value: str) -> list[int]:
try:
levels = sorted({int(part.strip()) for part in value.split(",") if part.strip()})
except ValueError as error:
raise argparse.ArgumentTypeError("Levels must be comma-separated integers.") from error
if not levels or levels[0] < 1 or levels[-1] > 80:
raise argparse.ArgumentTypeError("Levels must be between 1 and 80.")
return levels
def build_balance_lab_command(args: argparse.Namespace, level: int, output_dir: Path) -> list[str]:
command = [
sys.executable,
str(BALANCE_LAB),
"--source", args.source,
"--level", str(level),
"--party-mode", "automatic",
"--spec-selection", args.spec_selection,
"--party-seed", str(args.party_seed),
"--party-configurations", str(args.party_configurations),
"--simulations-per-party", str(args.simulations_per_party),
"--duration", str(args.duration),
"--boss-category", args.boss_category,
"--output-dir", str(output_dir),
]
if args.seeds:
command.extend(("--seeds", args.seeds))
if args.active_slot is not None:
command.extend(("--active-slot", str(args.active_slot)))
if getattr(args, "seat_sweep", False):
command.append("--seat-sweep")
if getattr(args, "cooldown_policy", None):
command.extend(("--cooldown-policy", str(args.cooldown_policy.resolve())))
if getattr(args, "companion_readiness_policy", None):
command.extend(("--companion-readiness-policy", str(args.companion_readiness_policy.resolve())))
if getattr(args, "include_beta_specs", False):
command.append("--include-beta-specs")
for selector in args.spec:
command.extend(("--spec", selector))
for selector in args.boss:
command.extend(("--boss", selector))
return command
def run_level(args: argparse.Namespace, level: int, output_dir: Path) -> tuple[int, subprocess.CompletedProcess[str]]:
result = subprocess.run(
build_balance_lab_command(args, level, output_dir),
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
return level, result
def weighted_mean(rows: Iterable[dict[str, Any]], field: str, weight: str = "runs") -> float:
materialized = list(rows)
total_weight = sum(float(row.get(weight, 0)) for row in materialized)
if total_weight <= 0:
return 0.0
return sum(float(row.get(field, 0) or 0) * float(row.get(weight, 0)) for row in materialized) / total_weight
def raw_run_totals(report_path: Path) -> dict[str, Any] | None:
raw_paths = sorted(report_path.parent.glob("parties/*/raw-runs.json"))
if not raw_paths:
return None
totals: dict[str, Any] = {
"runs": 0,
"seconds": 0.0,
"partyDamage": 0.0,
"bossDamage": 0.0,
"incomingDamage": 0.0,
"rawIncomingDamage": 0.0,
"defeats": 0,
"wipes": 0,
"incomingRuns": 0,
"healingRequiredRuns": 0,
"minimumHealthPercent": 0.0,
"pressureWindows": 0,
"healerThroughputWindows": 0,
"healerResponseWindows": 0,
"windowMetricsAvailable": False,
}
minimums = []
for raw_path in raw_paths:
raw = json.loads(raw_path.read_text(encoding="utf-8"))
for run in raw["runs"]:
totals["runs"] += 1
totals["seconds"] += float(run["elapsedSeconds"])
totals["partyDamage"] += float(run["partyDamage"])
totals["bossDamage"] += float(run["bossDamage"])
totals["incomingDamage"] += float(run["incomingDamage"])
totals["rawIncomingDamage"] += float(run["rawIncomingDamage"])
totals["defeats"] += run["outcome"] == "defeated"
totals["wipes"] += run["outcome"] == "wipe"
totals["incomingRuns"] += float(run["incomingDamage"]) > 0
healer_throughput = sum(
float(member.get("healing", 0)) + float(member.get("absorbs", 0))
for member in run["members"] if member["role"] == "healer"
)
totals["healingRequiredRuns"] += healer_throughput > 0
if "pressureWindows" in run:
totals["windowMetricsAvailable"] = True
totals["pressureWindows"] += int(run["pressureWindows"])
totals["healerThroughputWindows"] += int(run["healerThroughputWindows"])
totals["healerResponseWindows"] += int(run["healerResponseWindows"])
minimums.append(min(float(member["minimumHealthPercent"]) for member in run["members"]))
totals["minimumHealthPercent"] = sum(minimums) / len(minimums) if minimums else 1.0
return totals
def csv_run_totals(report_path: Path) -> dict[str, Any] | None:
candidates = [
report_path.with_name("suite_runs.csv"),
report_path.parent / f"level-{json.loads(report_path.read_text(encoding='utf-8'))['level']}-suite_runs.csv",
]
run_path = next((path for path in candidates if path.exists()), None)
if run_path is None:
return None
with run_path.open("r", encoding="utf-8", newline="") as stream:
rows = list(csv.DictReader(stream))
return {
"runs": len(rows),
"seconds": sum(float(row["elapsedSeconds"]) for row in rows),
"partyDamage": sum(float(row["partyDamage"]) for row in rows),
"bossDamage": sum(float(row["bossDamage"]) for row in rows),
"incomingDamage": sum(float(row["incomingDamage"]) for row in rows),
"rawIncomingDamage": sum(float(row["rawIncomingDamage"]) for row in rows),
"defeats": sum(row["outcome"] == "defeated" for row in rows),
"wipes": sum(row["outcome"] == "wipe" for row in rows),
"incomingRuns": None,
"healingRequiredRuns": None,
"minimumHealthPercent": None,
"pressureWindows": None,
"healerThroughputWindows": None,
"healerResponseWindows": None,
"windowMetricsAvailable": False,
}
def summarize_report(report_path: Path) -> dict[str, Any]:
report = json.loads(report_path.read_text(encoding="utf-8"))
bosses = report["bosses"]
totals = raw_run_totals(report_path) or csv_run_totals(report_path)
if totals is None:
raise ValueError(f"No raw-runs.json or suite_runs.csv found for {report_path}")
seconds = max(0.1, float(totals["seconds"]))
runs = max(1, int(totals["runs"]))
healers = [row for row in report["classSpecs"] if row["role"] == "healer"]
healer_healing = sum(float(row["totalHealing"]) for row in healers)
healer_absorbs = sum(float(row["totalAbsorbs"]) for row in healers)
party_dps = float(totals["partyDamage"]) / seconds
boss_dps = float(totals["bossDamage"]) / seconds
incoming_dtps = float(totals["incomingDamage"]) / seconds
raw_incoming_dtps = float(totals["rawIncomingDamage"]) / seconds
boss_health = weighted_mean(bosses, "bossMaxHealth")
defeated_runs = sum(float(row["runs"]) * float(row["defeatRate"]) for row in bosses)
defeated_seconds = sum(
float(row["runs"]) * float(row["defeatRate"]) * float(row["averageTimeToDefeat"] or 0)
for row in bosses
)
return {
"level": int(report["level"]),
"reportPath": str(report_path),
"partyConfigurations": int(report["summary"]["partyConfigurations"]),
"bosses": int(report["summary"]["bosses"]),
"runs": int(totals["runs"]),
"bossHealth": boss_health,
"partyDps": party_dps,
"bossDps": boss_dps,
"healthToDpsSeconds": boss_health / max(0.1, boss_dps),
"incomingDtps": incoming_dtps,
"rawIncomingDtps": raw_incoming_dtps,
"mitigationRate": 1 - incoming_dtps / raw_incoming_dtps if raw_incoming_dtps > 0 else 0,
"healerHps": healer_healing / seconds,
"healerAbsorbsPerSecond": healer_absorbs / seconds,
"healingCoverageRate": (healer_healing + healer_absorbs) / max(0.1, float(totals["incomingDamage"])),
"incomingRunRate": (
float(totals["incomingRuns"]) / runs if totals["incomingRuns"] is not None else None
),
"healingRequiredRunRate": (
float(totals["healingRequiredRuns"]) / runs if totals["healingRequiredRuns"] is not None else None
),
"healerResponseRate": (
float(totals["healerResponseWindows"]) / max(1, int(totals["pressureWindows"]))
if totals["windowMetricsAvailable"] else None
),
"healerWindowsPerPressureWindow": (
float(totals["healerThroughputWindows"]) / max(1, int(totals["pressureWindows"]))
if totals["windowMetricsAvailable"] else None
),
"averageMinimumHealthPercent": totals["minimumHealthPercent"],
"defeatRate": float(totals["defeats"]) / runs,
"wipeRate": float(totals["wipes"]) / runs,
"averageTimeToDefeat": defeated_seconds / defeated_runs if defeated_runs > 0 else None,
}
def add_growth_metrics(rows: list[dict[str, Any]]) -> None:
rows.sort(key=lambda row: row["level"])
previous = None
for row in rows:
if previous is None:
row["previousHealthToDpsSeconds"] = None
row["bossHealthGrowth"] = None
row["partyDpsGrowth"] = None
row["durabilityGrowthVsDps"] = None
else:
row["previousHealthToDpsSeconds"] = previous["healthToDpsSeconds"]
health_growth = row["bossHealth"] / max(0.1, previous["bossHealth"])
dps_growth = row["bossDps"] / max(0.1, previous["bossDps"])
row["bossHealthGrowth"] = health_growth
row["partyDpsGrowth"] = dps_growth
row["durabilityGrowthVsDps"] = health_growth / max(0.01, dps_growth)
previous = row
def evaluate_rows(rows: Sequence[dict[str, Any]], args: argparse.Namespace) -> bool:
passed = True
for row in rows:
flags = []
if row["healthToDpsSeconds"] < args.min_health_to_dps_seconds:
flags.append("boss durability below limit")
if row["healthToDpsSeconds"] > args.max_health_to_dps_seconds:
flags.append("boss durability above limit")
growth = row["durabilityGrowthVsDps"]
if (
growth is not None
and growth > args.max_durability_growth
and row["previousHealthToDpsSeconds"] >= args.min_health_to_dps_seconds
):
flags.append("boss health outgrew party DPS")
if row["incomingDtps"] <= 0:
flags.append("boss dealt no effective damage")
if row["healerHps"] <= 0 and row["incomingDtps"] > 0:
flags.append("healer produced no effective healing")
if row["incomingRunRate"] is not None and row["incomingRunRate"] < args.min_incoming_run_rate:
flags.append("too many runs had no incoming damage")
if (
row["healingRequiredRunRate"] is not None
and row["healingRequiredRunRate"] < args.min_healing_required_run_rate
):
flags.append("too many runs required no healer throughput")
if (
row.get("healerResponseRate") is not None
and row["healerResponseRate"] < args.min_healer_response_rate
):
flags.append("healer did not answer enough pressure windows")
row["flags"] = flags
row["passed"] = not flags
passed = passed and not flags
return passed
def discover_reports(directory: Path) -> list[Path]:
candidates = [*directory.glob("level-*/suite-report.json"), *directory.glob("level-*.json")]
reports = []
for path in sorted(set(candidates)):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if "level" in value and "bosses" in value and "classSpecs" in value:
reports.append(path)
return reports
def weapon_dps(weapon: dict[str, Any] | None) -> float:
if not weapon:
return 0.0
return (float(weapon["damageMin"]) + float(weapon["damageMax"])) / 2 / max(
0.1, float(weapon["speedMs"]) / 1_000
)
def collect_heirlooms(report_paths: Sequence[Path]) -> list[dict[str, Any]]:
rows = []
for report_path in report_paths:
catalog_path = report_path.parent / "catalog.json"
if not catalog_path.exists():
continue
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
for build in catalog["builds"]:
gear = build.get("gear")
if not gear:
continue
stats = gear["stats"]
melee_weapon_dps = weapon_dps(stats.get("meleeWeapon"))
ranged_weapon_dps = weapon_dps(stats.get("rangedWeapon"))
rows.append({
"level": catalog["level"],
"source": build["source"],
"classId": build["classId"],
"className": build["className"],
"specialization": build["specialization"],
"role": build["role"],
"presetId": gear["presetId"],
"model": gear["model"],
"itemCount": gear["itemCount"],
"averageItemLevel": gear["averageItemLevel"],
"strength": stats["strength"],
"agility": stats["agility"],
"stamina": stats["stamina"],
"intellect": stats["intellect"],
"spirit": stats["spirit"],
"attackPower": stats["attackPower"],
"rangedAttackPower": stats["rangedAttackPower"],
"spellPower": stats["spellPower"],
"criticalStrikeRating": stats["criticalStrikeRating"],
"hitRating": stats["hitRating"],
"hasteRating": stats["hasteRating"],
"expertiseRating": stats["expertiseRating"],
"armorPenetrationRating": stats["armorPenetrationRating"],
"defenseRating": stats["defenseRating"],
"dodgeRating": stats["dodgeRating"],
"parryRating": stats["parryRating"],
"blockRating": stats["blockRating"],
"blockValue": stats["blockValue"],
"mp5": stats["mp5"],
"armor": stats["armor"],
"meleeWeaponDps": melee_weapon_dps,
"rangedWeaponDps": ranged_weapon_dps,
"weaponDps": max(melee_weapon_dps, ranged_weapon_dps),
})
return rows
def percent(value: Any) -> str:
return "—" if value is None else f"{float(value) * 100:.1f}%"
def ratio(value: Any) -> str:
return "—" if value is None else f"{float(value):.2f}×"
def markdown_report(rows: Sequence[dict[str, Any]]) -> str:
lines = [
"# Balance Lab local bracket comparison",
"",
"| Level | Boss HP | Boss DPS taken | HP / DPS | Incoming DTPS | Healer HPS | Heal coverage | Pressure windows answered | Defeat | Wipe | HP growth / DPS growth | Result |",
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
]
for row in rows:
result = "pass" if row["passed"] else "; ".join(row["flags"])
lines.append(
f"| {row['level']} | {row['bossHealth']:.0f} | {row['bossDps']:.1f} | "
f"{row['healthToDpsSeconds']:.1f}s | {row['incomingDtps']:.1f} | {row['healerHps']:.1f} | "
f"{percent(row['healingCoverageRate'])} | {percent(row['healerResponseRate'])} | {percent(row['defeatRate'])} | "
f"{percent(row['wipeRate'])} | {ratio(row['durabilityGrowthVsDps'])} | {result} |"
)
lines.extend([
"",
"`HP / DPS` uses damage applied directly to the boss. `Heal coverage` is effective healer healing plus absorbs divided by effective incoming party damage. Pressure-window response counts a five-second incoming-damage window when healer throughput occurs in that window or an adjacent one. Growth compares each row with the preceding tested level.",
])
return "\n".join(lines)
SUMMARY_FIELDS = (
"level", "partyConfigurations", "bosses", "runs", "bossHealth", "partyDps", "bossDps",
"healthToDpsSeconds", "incomingDtps", "rawIncomingDtps", "mitigationRate", "healerHps",
"healerAbsorbsPerSecond", "healingCoverageRate", "incomingRunRate", "healingRequiredRunRate",
"healerResponseRate", "healerWindowsPerPressureWindow", "averageMinimumHealthPercent",
"defeatRate", "wipeRate", "averageTimeToDefeat",
"previousHealthToDpsSeconds", "bossHealthGrowth",
"partyDpsGrowth", "durabilityGrowthVsDps", "passed", "flags", "reportPath",
)
HEIRLOOM_FIELDS = (
"level", "source", "classId", "className", "specialization", "role", "presetId", "model",
"itemCount", "averageItemLevel", "strength", "agility", "stamina", "intellect", "spirit",
"attackPower", "rangedAttackPower", "spellPower", "criticalStrikeRating", "hitRating",
"hasteRating", "expertiseRating", "armorPenetrationRating", "defenseRating", "dodgeRating",
"parryRating", "blockRating", "blockValue", "mp5", "armor", "meleeWeaponDps",
"rangedWeaponDps", "weaponDps",
)
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description="Run and compare local Balance Lab level brackets.")
result.add_argument("--step", type=int, choices=(1, 5, 10), default=10,
help="level bracket step; includes level 1 (default: 10)")
result.add_argument("--levels", type=parse_levels, help="explicit comma-separated levels; overrides --step")
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), default="wow")
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="all")
result.add_argument("--spec", action="append", default=[])
result.add_argument("--party-seed", type=int, default=1)
result.add_argument("--party-configurations", type=int, default=10)
result.add_argument("--simulations-per-party", type=int, default=3)
result.add_argument("--seeds", help="explicit comma-separated seeds")
result.add_argument("--duration", type=int, default=80)
result.add_argument("--active-slot", type=int)
result.add_argument("--seat-sweep", action="store_true",
help="run every party with each of its five active-seat assignments")
result.add_argument("--cooldown-policy", type=Path)
result.add_argument("--companion-readiness-policy", type=Path)
result.add_argument("--include-beta-specs", action="store_true")
result.add_argument("--boss-category", choices=("all", "wow", "rom"), default="all")
result.add_argument("--boss", action="append", default=[])
result.add_argument("--jobs", type=int, default=min(4, os.cpu_count() or 1),
help="level processes to run concurrently (default: up to 4)")
result.add_argument("--output-dir", type=Path)
result.add_argument("--analyze-existing", type=Path,
help="skip simulations and compare reports already under this directory")
result.add_argument("--check", action="store_true", help="exit nonzero when a regression limit is exceeded")
result.add_argument("--min-health-to-dps-seconds", type=float, default=20)
result.add_argument("--max-health-to-dps-seconds", type=float, default=70)
result.add_argument("--max-durability-growth", type=float, default=1.5)
result.add_argument("--min-incoming-run-rate", type=float, default=0.95)
result.add_argument("--min-healing-required-run-rate", type=float, default=0.8)
result.add_argument("--min-healer-response-rate", type=float, default=0.6)
return result
def main(argv: Sequence[str] | None = None) -> int:
args = parser().parse_args(argv)
if args.spec_selection == "specific" and not args.spec:
raise SystemExit("--spec-selection specific requires at least one --spec value")
if args.jobs < 1:
raise SystemExit("--jobs must be at least 1")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
output_dir = (args.analyze_existing or args.output_dir
or ROOT / "artifacts" / "balance-lab-brackets" / timestamp).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if args.analyze_existing is None:
levels = args.levels or bracket_levels(args.step)
print(f"Running {len(levels)} local brackets with up to {args.jobs} concurrent level processes...")
failures = []
with ThreadPoolExecutor(max_workers=min(args.jobs, len(levels))) as executor:
futures = {
executor.submit(run_level, args, level, output_dir / f"level-{level}"): level
for level in levels
}
for future in as_completed(futures):
level, result = future.result()
if result.returncode == 0:
print(f" level {level}: complete")
else:
failures.append(level)
print(f" level {level}: failed", file=sys.stderr)
if result.stdout.strip():
print(result.stdout.rstrip(), file=sys.stderr)
if result.stderr.strip():
print(result.stderr.rstrip(), file=sys.stderr)
if failures:
raise SystemExit(f"Balance Lab failed at levels: {', '.join(map(str, sorted(failures)))}")
report_paths = discover_reports(output_dir)
if not report_paths:
raise SystemExit(f"No suite reports found under {output_dir}")
rows = [summarize_report(path) for path in report_paths]
add_growth_metrics(rows)
passed = evaluate_rows(rows, args)
serializable_rows = [{**row, "flags": list(row["flags"])} for row in rows]
write_json(output_dir / "bracket-summary.json", {
"schemaVersion": 1,
"generatedAt": datetime.now().astimezone().isoformat(),
"passed": passed,
"levels": [row["level"] for row in rows],
"thresholds": {
"minHealthToDpsSeconds": args.min_health_to_dps_seconds,
"maxHealthToDpsSeconds": args.max_health_to_dps_seconds,
"maxDurabilityGrowth": args.max_durability_growth,
"minIncomingRunRate": args.min_incoming_run_rate,
"minHealingRequiredRunRate": args.min_healing_required_run_rate,
"minHealerResponseRate": args.min_healer_response_rate,
},
"brackets": serializable_rows,
})
write_csv(output_dir / "bracket-summary.csv", [
{**row, "flags": "; ".join(row["flags"])} for row in rows
], SUMMARY_FIELDS)
write_text(output_dir / "bracket-summary.md", markdown_report(rows))
heirlooms = collect_heirlooms(report_paths)
if heirlooms:
write_json(output_dir / "heirloom-summary.json", heirlooms)
write_csv(output_dir / "heirloom-summary.csv", heirlooms, HEIRLOOM_FIELDS)
print(f"Comparison: {output_dir / 'bracket-summary.md'}")
if heirlooms:
print(f"Resolved heirlooms: {output_dir / 'heirloom-summary.csv'}")
return 0 if passed or not args.check else 1
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -151,7 +151,7 @@ async function main(): Promise<void> {
appVersion: packageJson.version,
cohort: {
level: options.level,
gearPresetId: builds[0]?.gearPresetId ?? "coefficient-empty",
gearPresetId: builds[0]?.gearPresetId ?? "virtual-heirloom",
seeds: options.seeds,
durationScale: options.durationScale,
policyId: BENCHMARK_POLICY_ID,
+265 -9
View File
@@ -1,22 +1,278 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import type { BaseClassId } from "../src/app/characterCatalog";
import {
PARTY_TALENT_SPECIALIZATIONS,
partyTalentAllocationPlan,
} from "../src/game/partyTalents";
import {
allocateTalentPoint,
talentNodesForTree,
talentTreesForClass,
type TalentNodeDefinition,
type TalentRanks,
} from "../src/game/talentCatalog";
const outputPath = resolve("src/game/generated/partyTalentRuntimePlans.json");
const plans = Object.fromEntries(PARTY_TALENT_SPECIALIZATIONS.map(({ classId, specialization }) => [
`${classId}:${specialization}`,
partyTalentAllocationPlan(classId, specialization).map((selection) => selection.nodeId),
const playerbotsConfigPath = resolve(
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
);
const PLAYERBOTS_COMMIT = "531282e";
interface PlayerbotsSpecReference {
readonly classId: BaseClassId;
readonly classNumber: number;
readonly specNumber: number;
readonly specialization: string;
}
interface PlayerbotsMilestone {
readonly level: number;
readonly link: string;
}
interface GeneratedLevelPlan {
readonly minimumLevel: number;
readonly sourceLevels: readonly number[];
readonly sourcePointCount: number;
readonly sourcePointFlags: readonly boolean[];
readonly plan: readonly string[];
}
const PLAYERBOTS_PVE_SPECS: readonly PlayerbotsSpecReference[] = [
["warrior", 1, 0, "Arms"], ["warrior", 1, 1, "Fury"], ["warrior", 1, 2, "Protection"],
["paladin", 2, 0, "Holy"], ["paladin", 2, 1, "Protection"], ["paladin", 2, 2, "Retribution"],
["hunter", 3, 0, "Beast Mastery"], ["hunter", 3, 1, "Marksmanship"], ["hunter", 3, 2, "Survival"],
["rogue", 4, 0, "Assassination"], ["rogue", 4, 1, "Combat"], ["rogue", 4, 2, "Subtlety"],
["priest", 5, 0, "Discipline"], ["priest", 5, 1, "Holy"], ["priest", 5, 2, "Shadow"],
["death-knight", 6, 0, "Blood"], ["death-knight", 6, 1, "Frost"], ["death-knight", 6, 2, "Unholy"],
["shaman", 7, 0, "Elemental"], ["shaman", 7, 1, "Enhancement"], ["shaman", 7, 2, "Restoration"],
["mage", 8, 0, "Arcane"], ["mage", 8, 1, "Fire"], ["mage", 8, 2, "Frost"],
["warlock", 9, 0, "Affliction"], ["warlock", 9, 1, "Demonology"], ["warlock", 9, 2, "Destruction"],
["druid", 11, 0, "Balance"], ["druid", 11, 1, "Feral (Bear)"], ["druid", 11, 2, "Restoration"], ["druid", 11, 3, "Feral (Cat)"],
].map(([classId, classNumber, specNumber, specialization]) => ({
classId: classId as BaseClassId,
classNumber: classNumber as number,
specNumber: specNumber as number,
specialization: specialization as string,
}));
// Stable identities make a build recognizable in logs and leave room for
// multiple builds of the same class/spec without silently replacing one.
const CHARACTER_NAMES = [
"Aldren", "Briala", "Caelen", "Darian", "Elowen", "Faelar", "Gavran", "Helia",
"Ilyra", "Jorren", "Kaelis", "Liora", "Marek", "Nerys", "Orin", "Phaela",
"Quorin", "Rielle", "Soren", "Talia", "Ulric", "Vaela", "Wrenna", "Xavian",
"Ysara", "Zoren", "Arwyn", "Brenna", "Corvin", "Delia", "Edrin", "Fiora",
"Galen", "Hestia", "Isolde", "Jarek", "Kiera", "Lucan", "Mira", "Nolan",
"Oriana", "Perrin", "Quilla", "Ronan", "Selene", "Theron", "Una", "Varen",
"Willow", "Xara", "Yorin", "Zella", "Ansel", "Brynn", "Cyrus", "Dessa",
"Evren", "Freya", "Garrick", "Halen", "Ilara", "Jessa",
] as const;
function specKey(classId: BaseClassId, specialization: string): string {
return `${classId}:${specialization}`;
}
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function parsePlayerbotsConfig(source: string): {
names: ReadonlyMap<string, string>;
links: ReadonlyMap<string, readonly PlayerbotsMilestone[]>;
} {
const names = new Map<string, string>();
const links = new Map<string, PlayerbotsMilestone[]>();
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim();
const name = /^AiPlayerbot\.PremadeSpecName\.(\d+)\.(\d+)\s*=\s*(.+)$/.exec(line);
if (name) names.set(`${name[1]}:${name[2]}`, name[3].trim());
const link = /^AiPlayerbot\.PremadeSpecLink\.(\d+)\.(\d+)\.(\d+)\s*=\s*([0-9-]+)$/.exec(line);
if (!link) continue;
const key = `${link[1]}:${link[2]}`;
const existing = links.get(key) ?? [];
existing.push({ level: Number(link[3]), link: link[4] });
links.set(key, existing);
}
for (const milestones of links.values()) milestones.sort((left, right) => left.level - right.level);
return { names, links };
}
function orderedTreeNodes(treeId: string): readonly TalentNodeDefinition[] {
return [...talentNodesForTree(treeId)].sort((left, right) => (
left.row - right.row || left.column - right.column || left.index - right.index
));
}
function ranksFromPlayerbotsLink(classId: BaseClassId, link: string): TalentRanks {
const ranks: Record<string, number> = {};
const treeLinks = link.split("-");
for (const [treeIndex, tree] of talentTreesForClass(classId).entries()) {
const digits = treeLinks[treeIndex] ?? "";
for (const [nodeIndex, node] of orderedTreeNodes(tree.id).entries()) {
const digit = Number(digits[nodeIndex] ?? "0");
if (Number.isInteger(digit) && digit > 0) ranks[node.id] = Math.min(node.maxRank, digit);
}
}
return ranks;
}
function desiredNodeOrder(classId: BaseClassId, target: TalentRanks): readonly string[] {
const trees = talentTreesForClass(classId);
const treeScore = new Map(trees.map((tree) => [
tree.id,
orderedTreeNodes(tree.id).filter((node) => (target[node.id] ?? 0) > 0).length,
]));
const treeIndex = new Map(trees.map((tree, index) => [tree.id, index]));
return trees.flatMap((tree) => orderedTreeNodes(tree.id))
.filter((node) => (target[node.id] ?? 0) > 0)
.sort((left, right) => (
(treeScore.get(right.treeId) ?? 0) - (treeScore.get(left.treeId) ?? 0)
|| (treeIndex.get(left.treeId) ?? 0) - (treeIndex.get(right.treeId) ?? 0)
|| left.row - right.row
|| left.column - right.column
|| left.index - right.index
))
.map((node) => node.id);
}
function playerbotsAllocationPlan(
reference: PlayerbotsSpecReference,
milestones: readonly PlayerbotsMilestone[],
): {
readonly plan: readonly string[];
readonly sourcePointCount: number;
readonly sourcePointsRetained: number;
readonly fallbackPointCount: number;
readonly levelPlans: readonly GeneratedLevelPlan[];
} {
if (milestones.length === 0) throw new Error(`No Playerbots links for ${specKey(reference.classId, reference.specialization)}.`);
const targets = milestones.map((milestone) => ({
...milestone,
ranks: ranksFromPlayerbotsLink(reference.classId, milestone.link),
}));
const fallback = partyTalentAllocationPlan(reference.classId, reference.specialization).map((entry) => entry.nodeId);
const levelPlans = targets.map((_, startIndex): GeneratedLevelPlan => {
// PlayerbotFactory walks backward to the closest configured link, then
// applies that link and each later link until the bot runs out of points.
// Level 80 starts at the 80 link directly. A plan variant per configured
// starting link preserves that behavior without storing 80 duplicate plans.
const appliedTargets = targets.slice(startIndex);
const sourceCapacity: Record<string, number> = {};
for (const target of appliedTargets) {
for (const [nodeId, rank] of Object.entries(target.ranks)) {
sourceCapacity[nodeId] = Math.max(sourceCapacity[nodeId] ?? 0, rank);
}
}
const plan: string[] = [];
const sourcePointFlags: boolean[] = [];
let ranks: TalentRanks = {};
for (let point = 1; point <= 71; point += 1) {
const level = point + 9;
let allocated = false;
for (const target of appliedTargets) {
for (const nodeId of desiredNodeOrder(reference.classId, target.ranks)) {
if ((ranks[nodeId] ?? 0) >= (target.ranks[nodeId] ?? 0)) continue;
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
if (!result.ok) continue;
ranks = result.ranks;
plan.push(nodeId);
sourcePointFlags.push(true);
allocated = true;
break;
}
if (allocated) break;
}
if (!allocated) {
for (const nodeId of fallback) {
const result = allocateTalentPoint(ranks, reference.classId, nodeId, level);
if (!result.ok) continue;
ranks = result.ranks;
plan.push(nodeId);
sourcePointFlags.push(false);
allocated = true;
break;
}
}
if (!allocated) throw new Error(`Could not allocate Playerbots point ${point} for ${specKey(reference.classId, reference.specialization)}.`);
}
return {
minimumLevel: startIndex === 0 ? 1 : targets[startIndex].level,
sourceLevels: appliedTargets.map((target) => target.level),
sourcePointCount: Object.values(sourceCapacity).reduce((total, rank) => total + rank, 0),
sourcePointFlags,
plan,
};
});
const final = levelPlans[levelPlans.length - 1];
const sourcePointsRetained = final.sourcePointFlags.filter(Boolean).length;
return {
plan: final.plan,
sourcePointCount: final.sourcePointCount,
sourcePointsRetained,
fallbackPointCount: Math.max(0, 71 - sourcePointsRetained),
levelPlans,
};
}
const config = parsePlayerbotsConfig(readFileSync(playerbotsConfigPath, "utf8"));
const playerbotsBySpec = new Map(PLAYERBOTS_PVE_SPECS.map((reference) => [
specKey(reference.classId, reference.specialization),
reference,
]));
const profiles = PARTY_TALENT_SPECIALIZATIONS.flatMap(({ classId, specialization }, index) => {
const key = specKey(classId, specialization);
const reference = playerbotsBySpec.get(key);
if (!reference) throw new Error(`No Playerbots PvE mapping for ${key}.`);
const configKey = `${reference.classNumber}:${reference.specNumber}`;
const milestones = config.links.get(configKey) ?? [];
const specSlug = `${classId}-${slug(specialization)}`;
const playerbotsPlan = playerbotsAllocationPlan(reference, milestones);
return [
{
id: `playerbots-pve-${PLAYERBOTS_COMMIT}:${specSlug}`,
classId,
specialization,
name: `${specialization} Playerbots PvE`,
characterName: CHARACTER_NAMES[index],
source: "azerothcore-playerbots",
sourceVersion: PLAYERBOTS_COMMIT,
defaultForSpecialization: true,
playerbotsSpecName: config.names.get(configKey) ?? null,
milestones,
...playerbotsPlan,
},
{
id: `healer-man-executable-v1:${specSlug}`,
classId,
specialization,
name: `${specialization} Executable`,
characterName: CHARACTER_NAMES[index + PARTY_TALENT_SPECIALIZATIONS.length],
source: "healer-man-generated",
sourceVersion: "v1",
defaultForSpecialization: false,
playerbotsSpecName: null,
milestones: [],
sourcePointCount: null,
sourcePointsRetained: null,
fallbackPointCount: 0,
plan: partyTalentAllocationPlan(classId, specialization).map((selection) => selection.nodeId),
levelPlans: [],
},
];
});
const output = {
schemaVersion: 1,
generatedFrom: "src/game/partyTalents.ts",
plans,
schemaVersion: 2,
generatedFrom: [
"src/game/partyTalents.ts",
"../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots/conf/playerbots.conf.dist",
],
playerbotsCommit: PLAYERBOTS_COMMIT,
profiles,
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(`Wrote ${Object.keys(plans).length} party talent plans to ${outputPath}`);
console.log(`Wrote ${profiles.length} named party talent profiles to ${outputPath}`);
+103
View File
@@ -0,0 +1,103 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve(process.env.COMPANION_READINESS_PLAYTEST_DIR || 'playtest-artifacts/companion-readiness');
const port = Number(process.env.COMPANION_READINESS_PLAYTEST_PORT || 4192);
const base = `http://127.0.0.1:${port}`;
fs.mkdirSync(directory, { recursive: true });
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-readiness-ui-'));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(120_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const capture = async (name, fullPage = true) => {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
console.log(`Captured ${name}`);
};
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('readiness-playtest');
await page.getByRole('button', { name: /Create account/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('ReadinessQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Companion Readiness/ }).click();
await page.locator('.companion-readiness').waitFor();
const totals = page.getByLabel('Readiness totals');
assert.match(await totals.innerText(), /5\s+Ready/);
assert.match(await totals.innerText(), /0\s+Beta/);
assert.match(await totals.innerText(), /26\s+Needs Testing/);
assert.equal(await page.locator('.companion-readiness__spec').count(), 31);
assert.match(await page.locator('.companion-readiness__spec').filter({ hasText: 'Aldren' }).innerText(), /Protection/);
assert.match(await page.locator('.companion-readiness__spec').filter({ hasText: 'Quorin' }).innerText(), /Discipline/);
await capture('01-readiness-desktop');
await page.setViewportSize({ width: 480, height: 900 });
await page.waitForTimeout(250);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await page.getByLabel('Status').selectOption('ready');
assert.equal(await page.locator('.companion-readiness__spec').count(), 5);
await capture('02-readiness-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByLabel('Status').selectOption('all');
await page.getByLabel('Arms readiness').selectOption('beta');
await page.getByRole('button', { name: 'Publish policy' }).click();
await page.getByText('Published policy is active').waitFor();
assert.match(await totals.innerText(), /1\s+Beta/);
assert.match(await totals.innerText(), /25\s+Needs Testing/);
await page.getByRole('button', { name: 'Back' }).click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
const beta = page.getByRole('checkbox', { name: /Include Beta Specs/ });
assert.equal(await beta.isDisabled(), false);
assert.equal(await beta.isChecked(), false);
assert.match(await page.locator('.companion-pool-controls').innerText(), /1 beta spec available/);
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText('Wailing Caverns', { exact: true }).waitFor();
await capture('03-dungeon-ready-pool');
await page.getByRole('button', { name: /Choose role/ }).click();
const roleButton = page.locator('.dungeon-role-option').first();
await roleButton.click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.party-frame').nth(4).waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
assert.equal(await page.locator('.party-frame').count(), 5);
const npcFrames = await page.locator('.party-frame:not(.party-frame--player):not(.party-frame--online)').allInnerTexts();
assert.equal(npcFrames.length, 4);
for (const frame of npcFrames) {
assert.match(frame, /Protection|Discipline|Marksmanship|Fire|Assassination/i);
assert.doesNotMatch(frame, /Arms/i);
}
await capture('04-live-dungeon-ready-party', false);
assert.deepEqual(pageErrors, []);
console.log(`Companion Readiness playtest passed with NPCs: ${npcFrames.map((frame) => frame.split('\n')[0]).join(', ')}`);
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(runtimeData, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+78
View File
@@ -6,6 +6,8 @@ from scripts.balance_lab import (
automatic_parties,
coverage_parties,
random_party,
readiness_eligible_builds,
resolve_build,
runtime_session_command,
search_bosses,
tuning_label,
@@ -13,6 +15,15 @@ from scripts.balance_lab import (
class BalanceLabControllerTests(unittest.TestCase):
def test_class_spec_selector_prefers_the_named_default_build(self) -> None:
builds = [
{"id": "fire-default", "classId": "mage", "className": "Mage", "role": "damage",
"specialization": "Fire", "label": "Mage Fire Playerbots", "defaultTalentBuild": True},
{"id": "fire-alt", "classId": "mage", "className": "Mage", "role": "damage",
"specialization": "Fire", "label": "Mage Fire Executable", "defaultTalentBuild": False},
]
self.assertEqual(resolve_build("mage:fire", builds)["id"], "fire-default")
def test_headless_runtime_uses_the_no_watch_vite_mode(self) -> None:
command = runtime_session_command("wow", 40)
self.assertEqual(command[1:3], ["--mode", "balance-lab"])
@@ -34,6 +45,20 @@ class BalanceLabControllerTests(unittest.TestCase):
])
self.assertEqual(len({member["id"] for member in first[2:]}), 3)
def test_random_party_uses_named_default_profiles(self) -> None:
builds = [
{"id": "tank-default", "role": "tank", "defaultTalentBuild": True},
{"id": "tank-alt", "role": "tank", "defaultTalentBuild": False},
{"id": "healer-default", "role": "healer", "defaultTalentBuild": True},
{"id": "healer-alt", "role": "healer", "defaultTalentBuild": False},
*({"id": f"damage-default-{index}", "role": "damage", "defaultTalentBuild": True}
for index in range(3)),
*({"id": f"damage-alt-{index}", "role": "damage", "defaultTalentBuild": False}
for index in range(3)),
]
party = random_party(builds, 17)
self.assertTrue(all(member["defaultTalentBuild"] for member in party))
def test_tuning_flags_keep_both_sides_of_the_median(self) -> None:
self.assertEqual(tuning_label(1.51), "very high")
self.assertEqual(tuning_label(1.25), "high")
@@ -102,6 +127,43 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertTrue(set(selected).issubset(covered))
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
def test_readiness_filters_only_wow_and_beta_requires_opt_in(self) -> None:
builds = [
{"id": "ready-tank", "source": "wow", "specializationId": "warrior:protection"},
{"id": "beta-damage", "source": "wow", "specializationId": "paladin:retribution"},
{"id": "testing-damage", "source": "wow", "specializationId": "priest:shadow"},
{"id": "rom-damage", "source": "rom", "specializationId": "mage:primary"},
]
policy = {"config": {"statuses": {
"warrior:protection": "ready",
"paladin:retribution": "beta",
"priest:shadow": "needs-testing",
}}}
self.assertEqual(
[build["id"] for build in readiness_eligible_builds(builds, policy, False)],
["ready-tank", "rom-damage"],
)
self.assertEqual(
[build["id"] for build in readiness_eligible_builds(builds, policy, True)],
["ready-tank", "beta-damage", "rom-damage"],
)
def test_targeted_needs_testing_build_uses_ready_fillers(self) -> None:
builds = [
{"id": "ready-tank", "source": "wow", "role": "tank", "specializationId": "warrior:protection", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "ready-healer", "source": "wow", "role": "healer", "specializationId": "priest:discipline", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "ready-hunter", "source": "wow", "role": "damage", "specializationId": "hunter:marksmanship", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "ready-mage", "source": "wow", "role": "damage", "specializationId": "mage:fire", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "ready-rogue", "source": "wow", "role": "damage", "specializationId": "rogue:assassination", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "testing-shadow", "source": "wow", "role": "damage", "specializationId": "priest:shadow", "readinessStatus": "needs-testing", "defaultTalentBuild": True},
]
eligible = readiness_eligible_builds(builds, None, False)
parties = coverage_parties(builds, ("wow",), ["testing-shadow"], 13, eligible)
self.assertEqual(len(parties), 1)
ids = [member["id"] for member in parties[0]["party"]]
self.assertIn("testing-shadow", ids)
self.assertTrue(all(build_id == "testing-shadow" or build_id.startswith("ready-") for build_id in ids))
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
@@ -164,6 +226,22 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertEqual(by_id["mage-fireball"]["damageShare"], 0.5)
self.assertTrue(by_id["mage-unused-blast"]["unusedAvailable"])
def test_missing_profile_action_requires_repeated_uncommitted_decisions(self) -> None:
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
class_runs = [{"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10, "damage": 0}]
base = {
"abilityName": "Fireball", "icon": "", "categories": ["damage"],
"cooldownSeconds": 0, "rotationProfileId": "mage:fire", "rotationPriority": 5,
"expectedUsage": "profile", "available": True, "used": False,
"casts": 0, "hits": 0, "rawDamage": 0, "effectiveDamage": 0,
"rawHealing": 0, "effectiveHealing": 0, "overheal": 0, "absorbs": 0,
"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10,
}
once = aggregate_class_abilities({(*class_key, "fireball"): [{**base, "rotationDecisions": 1}]}, {class_key: class_runs})[0]
repeated = aggregate_class_abilities({(*class_key, "fireball"): [{**base, "rotationDecisions": 3}]}, {class_key: class_runs})[0]
self.assertEqual(once["status"], "unused")
self.assertEqual(repeated["status"], "missing-profile-action")
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
import argparse
import json
import sys
import tempfile
import unittest
from pathlib import Path
from scripts.balance_lab_brackets import (
add_growth_metrics,
bracket_levels,
build_balance_lab_command,
evaluate_rows,
summarize_report,
)
class BalanceLabBracketTests(unittest.TestCase):
def test_bracket_levels_match_the_gm_schedules(self) -> None:
self.assertEqual(bracket_levels(10), [1, 10, 20, 30, 40, 50, 60, 70, 80])
self.assertEqual(bracket_levels(5)[:4], [1, 5, 10, 15])
self.assertEqual(bracket_levels(1), list(range(1, 81)))
def test_command_uses_the_existing_live_balance_lab_controller(self) -> None:
args = argparse.Namespace(
source="wow", spec_selection="all", party_seed=7, party_configurations=10,
simulations_per_party=3, duration=80, boss_category="wow", seeds="1,2,3",
active_slot=None, spec=[], boss=["stormwind-stockade/entry-1717"],
seat_sweep=True, cooldown_policy=None,
companion_readiness_policy=Path("policy.json"), include_beta_specs=True,
)
command = build_balance_lab_command(args, 40, Path("output/level-40"))
self.assertEqual(command[0], sys.executable)
self.assertTrue(command[1].endswith("scripts\\balance_lab.py") or command[1].endswith("scripts/balance_lab.py"))
self.assertIn("automatic", command)
self.assertEqual(command[command.index("--level") + 1], "40")
self.assertEqual(command[command.index("--seeds") + 1], "1,2,3")
self.assertIn("--seat-sweep", command)
self.assertIn("--include-beta-specs", command)
self.assertEqual(
command[command.index("--companion-readiness-policy") + 1],
str(Path("policy.json").resolve()),
)
def test_summary_measures_healer_requirement_from_raw_runs(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
report_path = root / "suite-report.json"
report = {
"level": 20,
"summary": {"partyConfigurations": 1, "bosses": 1},
"bosses": [{
"runs": 1, "bossMaxHealth": 1000, "defeatRate": 1,
"averageTimeToDefeat": 10,
}],
"classSpecs": [{
"role": "healer", "totalHealing": 150, "totalAbsorbs": 25,
}],
}
report_path.write_text(json.dumps(report), encoding="utf-8")
raw_path = root / "parties" / "wow-party-001" / "raw-runs.json"
raw_path.parent.mkdir(parents=True)
raw_path.write_text(json.dumps({"runs": [{
"elapsedSeconds": 10, "partyDamage": 1200, "bossDamage": 1000,
"incomingDamage": 200, "rawIncomingDamage": 250, "outcome": "defeated",
"pressureWindows": 2, "healerThroughputWindows": 2, "healerResponseWindows": 2,
"members": [
{"role": "healer", "healing": 150, "absorbs": 25, "minimumHealthPercent": 0.6},
{"role": "tank", "healing": 0, "absorbs": 0, "minimumHealthPercent": 0.4},
],
}]}), encoding="utf-8")
summary = summarize_report(report_path)
self.assertEqual(summary["healthToDpsSeconds"], 10)
self.assertEqual(summary["incomingDtps"], 20)
self.assertEqual(summary["healerHps"], 15)
self.assertEqual(summary["healingCoverageRate"], 0.875)
self.assertEqual(summary["incomingRunRate"], 1)
self.assertEqual(summary["healingRequiredRunRate"], 1)
self.assertEqual(summary["healerResponseRate"], 1)
self.assertEqual(summary["averageMinimumHealthPercent"], 0.4)
def test_growth_check_flags_health_that_outpaces_damage(self) -> None:
rows = [
{"level": 10, "bossHealth": 1000, "bossDps": 100},
{"level": 20, "bossHealth": 2500, "bossDps": 150},
]
for row in rows:
row.update({
"healthToDpsSeconds": row["bossHealth"] / row["bossDps"],
"incomingDtps": 10, "healerHps": 8, "incomingRunRate": 1,
"healingRequiredRunRate": 1,
})
add_growth_metrics(rows)
args = argparse.Namespace(
min_health_to_dps_seconds=5, max_health_to_dps_seconds=70, max_durability_growth=1.4,
min_incoming_run_rate=0.95, min_healing_required_run_rate=0.8,
min_healer_response_rate=0.6,
)
self.assertFalse(evaluate_rows(rows, args))
self.assertIn("boss health outgrew party DPS", rows[1]["flags"])
if __name__ == "__main__":
unittest.main()