forked from phenom/healer-man
Release Healer Man 0.1.51
This commit is contained in:
1 parent
79153ca40d
commit
dd583023ac
13 files changed
+405
-31
No files matched your search
+155
-3
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user