Release Healer Man 0.1.51

This commit is contained in:
phenom committed 2026-09-17 18:01:29 -04:00
1 parent 79153ca40d
commit dd583023ac
13 files changed
+405 -31

No files matched your search

+155 -3
View File
@@ -195,6 +195,92 @@ def automatic_parties(
return configurations
def coverage_parties(
builds: Sequence[dict[str, Any]],
sources: Sequence[str],
selected_build_ids: Sequence[str] | None,
seed: int,
) -> 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
offsets = {"wow": 100_000, "coa": 200_000, "rom": 300_000}
configurations: list[dict[str, Any]] = []
for source in sources:
source_builds = [build for build in builds if build["source"] == source]
targets = source_builds if requested is None else [
build for build in source_builds if build["id"] in requested
]
if not targets:
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:
available_by_role[build["role"]].append(build)
for build in targets:
targets_by_role[build["role"]].append(build)
if not available_by_role["tank"] or not available_by_role["healer"] or len(available_by_role["damage"]) < 3:
raise SystemExit(
f"The {source} catalog does not expose enough tank, healer, and damage builds for a 1/1/3 party."
)
party_count = max(
1,
len(targets_by_role["tank"]),
len(targets_by_role["healer"]),
math.ceil(len(targets_by_role["damage"]) / 3),
)
generator = random.Random(seed + offsets[source])
for rows in (*available_by_role.values(), *targets_by_role.values()):
rows.sort(key=lambda build: build["id"])
generator.shuffle(rows)
def filler(role: str, party_index: int, slot_index: int, used: set[str]) -> dict[str, Any]:
pool = available_by_role[role]
start = (party_index * (3 if role == "damage" else 1) + slot_index) % len(pool)
for avoid_requested in (True, False):
for offset in range(len(pool)):
candidate = pool[(start + offset) % len(pool)]
if candidate["id"] in used:
continue
if avoid_requested and requested is not None and candidate["id"] in requested:
continue
return candidate
raise SystemExit(f"Could not fill a unique {role} slot for a {source} coverage party.")
for party_index in range(party_count):
used: set[str] = set()
tank = targets_by_role["tank"][party_index] if party_index < len(targets_by_role["tank"]) else filler(
"tank", party_index, 0, used
)
used.add(tank["id"])
healer = targets_by_role["healer"][party_index] if party_index < len(targets_by_role["healer"]) else filler(
"healer", party_index, 0, used
)
used.add(healer["id"])
damage = list(targets_by_role["damage"][party_index * 3:(party_index + 1) * 3])
used.update(build["id"] for build in damage)
while len(damage) < 3:
build = filler("damage", party_index, len(damage), used)
damage.append(build)
used.add(build["id"])
party = [tank, healer, *damage]
configurations.append({
"id": f"{source}-party-{party_index + 1:03d}",
"source": source,
"party": party,
"activeSlot": 1,
})
if not configurations:
raise SystemExit("No class/spec builds matched the requested coverage selection.")
return configurations
def search_builds(query: str, builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query)
return [build for build in builds if needle in normalized(
f"{build['id']} {build['label']} {build['source']} {build['role']}"
)]
def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query)
return [boss for boss in bosses if needle in normalized(
@@ -273,6 +359,43 @@ def prompt_specific_bosses(bosses: Sequence[dict[str, Any]]) -> list[str]:
print(f"Selected bosses: {len(selected)}")
def prompt_specific_builds(builds: Sequence[dict[str, Any]]) -> list[str]:
selected: dict[str, dict[str, Any]] = {}
print("\nClass/spec picker: search by class, specialization, role, source, or build ID.")
while True:
suffix = f" ({len(selected)} selected; type 'done' to finish)" if selected else ""
query = input(f"Class/spec search{suffix}: ").strip()
if query.casefold() == "done":
if selected:
return list(selected)
print("Select at least one class/spec first.")
continue
matches = search_builds(query, builds)
if not matches:
print("No class/spec builds matched that search.")
continue
if len(matches) > 50:
print(f"{len(matches)} builds matched. Use a more specific search (50 maximum per result page).")
continue
for index, build in enumerate(matches, 1):
marker = " [selected]" if build["id"] in selected else ""
print(f" {index:>2}. {console_text(build['label'])} [{build['source']}]{marker}")
raw = input("Add numbers separated by commas, or 'all': ").strip().casefold()
if raw == "all":
indexes = list(range(len(matches)))
else:
try:
indexes = [int(entry.strip()) - 1 for entry in raw.split(",") if entry.strip()]
except ValueError:
indexes = []
if not indexes or any(index < 0 or index >= len(matches) for index in indexes):
print("Choose valid result numbers.")
continue
for index in indexes:
selected[matches[index]["id"]] = matches[index]
print(f"Selected class/spec builds: {len(selected)}")
def interactive_arguments(
args: argparse.Namespace,
workspace: Path,
@@ -288,7 +411,11 @@ def interactive_arguments(
if args.party_mode == "random":
args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647)
elif args.party_mode == "automatic":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
args.spec_selection = prompt_choice("Class/spec selection", ("random", "all", "specific"), "all")
if args.spec_selection == "random":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
elif args.spec_selection == "specific":
args.spec = prompt_specific_builds(catalog["builds"])
args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647)
else:
print("\nAvailable members:")
@@ -1159,6 +1286,10 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode")
result.add_argument("--level", type=int, default=60, help="party level (default: 60)")
result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random")
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="random",
help="automatic party strategy (default: random)")
result.add_argument("--spec", action="append", default=[],
help="required build ID or class:spec selector for --spec-selection specific")
result.add_argument("--party-seed", type=int, default=1, help="random party seed")
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")
@@ -1215,7 +1346,25 @@ def main(argv: Sequence[str] | None = None) -> int:
seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
if args.party_mode == "automatic":
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
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)
selected_build_ids: list[str] = []
else:
if args.spec_selection == "specific" and not args.spec:
session.close()
raise SystemExit("--spec-selection specific requires at least one --spec value")
selected_build_ids = list(dict.fromkeys(
resolve_build(selector, builds)["id"] for selector in args.spec
))
configurations = coverage_parties(
builds,
sources,
None if args.spec_selection == "all" else selected_build_ids,
args.party_seed,
)
if args.active_slot is not None:
if not 1 <= args.active_slot <= 5:
session.close()
@@ -1258,10 +1407,13 @@ def main(argv: Sequence[str] | None = None) -> int:
"policyId": catalog["policyId"],
"rotationVersion": catalog["rotationVersion"],
"mode": "automatic",
"specSelection": args.spec_selection,
"selectedBuildIds": selected_build_ids,
"source": args.source,
"level": args.level,
"partySeed": args.party_seed,
"partyConfigurationsPerSource": args.party_configurations,
"partyConfigurationsPerSource": args.party_configurations if args.spec_selection == "random" else None,
"partyConfigurations": len(configurations),
"simulationsPerParty": len(seeds),
"seeds": seeds,
"durationSeconds": args.duration,
+7 -2
View File
@@ -56,7 +56,12 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20');
await page.getByLabel('Parties per source').fill('1');
await page.getByLabel('Class/spec selection').selectOption('all');
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
await page.getByLabel('Class/spec selection').selectOption('specific');
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
await page.getByLabel('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific');
@@ -75,7 +80,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
await page.locator('.balance-results').waitFor();
assert.equal(await page.locator('.balance-results-chart').count(), 5);
assert.equal(await page.locator('.balance-results-chart').count(), 7);
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
await capture('04-run-analytics');
+39
View File
@@ -4,6 +4,7 @@ from scripts.balance_lab import (
aggregate_class_abilities,
aggregate_class_specs,
automatic_parties,
coverage_parties,
random_party,
search_bosses,
tuning_label,
@@ -57,6 +58,44 @@ class BalanceLabControllerTests(unittest.TestCase):
"tank", "healer", "damage", "damage", "damage",
])
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
]
first = coverage_parties(builds, ("wow",), None, 19)
second = coverage_parties(builds, ("wow",), None, 19)
self.assertEqual(first, second)
self.assertEqual(len(first), 3)
covered = {member["id"] for configuration in first for member in configuration["party"]}
self.assertEqual(covered, {build["id"] for build in builds})
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
self.assertEqual(len(signatures), 3)
for configuration in first:
self.assertEqual([member["role"] for member in configuration["party"]], [
"tank", "healer", "damage", "damage", "damage",
])
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
]
selected = ["tank-a", "tank-b", "damage-4"]
configurations = coverage_parties(builds, ("wow",), selected, 7)
self.assertEqual(len(configurations), 2)
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
covered = set(observed)
self.assertTrue(set(selected).issubset(covered))
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},