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

+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"