From d6976e6ef691c3eaaad43556b85eb9e250939e21 Mon Sep 17 00:00:00 2001 From: phenom Date: Fri, 18 Sep 2026 12:32:51 -0400 Subject: [PATCH] Release Healer Man 0.1.53 --- RELEASE_RUNBOOK.md | 8 ++ docs/BALANCE_LAB.md | 19 ++- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/release_manager.py | 145 ++++++++++++++++++++-- scripts/release_manager_test.py | 33 +++++ src/game/benchmark/benchmarkController.ts | 4 + src/game/combatActorAuthority.ts | 5 +- src/game/combatRotation.test.ts | 67 ++++++++++ src/game/combatRotation.ts | 87 +++++++++++-- src/game/dungeonBossScaling.test.ts | 2 +- src/game/partyAbilityAi.ts | 12 +- src/game/partyDamageAudit.test.ts | 83 ++++++++++++- src/game/partyRuntime.ts | 64 ++++++---- src/game/partyStore.test.ts | 25 ++++ src/game/partyStore.ts | 60 +++++++-- 17 files changed, 555 insertions(+), 69 deletions(-) diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md index 19cf376f..fcbb0b39 100644 --- a/RELEASE_RUNBOOK.md +++ b/RELEASE_RUNBOOK.md @@ -18,6 +18,14 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes the Gitea release. Signing passwords and the Gitea token are passed to the child process in memory rather than on its command line. +If a content, server, normal-application, or shelved-application test command +fails, the visual manager pauses with the failed command, exit code, and recent +test output. **Stop Release** remains the safe default, but **Continue Anyway** +allows an intentional override and records that decision in the release log. +Build, content-publication, Git, signing, and APK-publication failures are not +overridable and still stop the workflow. The manual procedure below remains +fail-closed. + Before changing versions or running the longer test/build sequence, the manager runs an isolated release-candidate check. It validates tracked and untracked files without changing the real Git index, so extra blank lines at EOF and diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index ba505e31..74779c16 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -329,6 +329,21 @@ member. When a suspected class outlier could be policy-sensitive, rerun with a different `--active-slot`; do not compare active and companion results without noting that control-policy difference. +Companion builds initialize every resource pool required by their learned +abilities, not only the class's primary pool. Secondary Energy and Focus +regenerate, and any companion with a Rage pool gains Rage from landed weapon +attacks; this is what makes druid Cat and Bear rotations executable while Mana +remains the class's primary resource. A completed companion cast resolves the +member's currently learned rank before applying its effects, so cast-time spell +damage and healing cannot fall back to rank one. + +`playerbots-inspired-v2` keeps level-10-and-up non-healer builds on their +specialization's authored damage priority whenever that build knows a profile +damage action. Reactive interrupts, taunts, control, and defenses still take +precedence. Bear and Cat actions are isolated to their matching Feral specs; +Overpower, Revenge, and Victory Rush require an explicitly modeled trigger; +and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates. + ## Code map - `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic @@ -351,8 +366,8 @@ noting that control-policy difference. - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal talent allocation. The older canonical cohort remains unchanged. - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory - that preserves production stats, equipment, resources, and appearance for an - exact companion. + that preserves production stats, equipment, all learned-ability resource + pools, and appearance for an exact companion. - `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated room construction. diff --git a/package-lock.json b/package-lock.json index 3cdd12f5..5becaea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.52", + "version": "0.1.53", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.52", + "version": "0.1.53", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index d5da53b3..7905bd1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.52", + "version": "0.1.53", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 9d0489e3..02e82bde 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.52", - "versionCode": 1052 + "versionName": "0.1.53", + "versionCode": 1053 } diff --git a/scripts/release_manager.py b/scripts/release_manager.py index 28217b48..d621492a 100644 --- a/scripts/release_manager.py +++ b/scripts/release_manager.py @@ -1,4 +1,4 @@ -"""Visual, fail-closed release manager for Healer Man. +"""Visual release manager for Healer Man with explicit safety gates. The GUI intentionally leaves the TrueNAS portion as copyable shell commands: the app must be stopped and started from the TrueNAS Apps UI around that block. @@ -18,6 +18,7 @@ import sys import tempfile import threading import webbrowser +from collections import deque from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable, Sequence @@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = { ALLOWED_GENERATED_FILES = { "public/data/healer-man-loot.sqlite", } +OVERRIDABLE_TEST_LABELS = frozenset( + { + "Content tests", + "Server tests", + "Application tests", + "Shelved-mode application tests", + } +) +ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +FAILURE_OUTPUT_LINE_LIMIT = 160 +FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000 class ReleaseError(RuntimeError): @@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError): """The user cancelled an active release.""" +class ReleaseCommandError(ReleaseError): + """A release subprocess failed and retained its output for review.""" + + def __init__( + self, + label: str, + command: Sequence[str], + return_code: int, + output: str, + ) -> None: + super().__init__(f"{label} failed with exit code {return_code}.") + self.label = label + self.command = tuple(command) + self.return_code = return_code + self.output = output + + @dataclass(frozen=True) class ReleaseVersion: version_name: str @@ -88,6 +117,36 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]: ) +def can_override_release_check(label: str) -> bool: + """Return whether the interactive release flow may continue after a failure.""" + return label in OVERRIDABLE_TEST_LABELS + + +def format_failed_test_warning(failure: ReleaseCommandError) -> str: + """Build the detailed warning shown before overriding a failed test command.""" + clean_output = ANSI_ESCAPE_PATTERN.sub("", failure.output).strip() + output_lines = clean_output.splitlines() + omitted_lines = max(0, len(output_lines) - FAILURE_OUTPUT_LINE_LIMIT) + if omitted_lines: + output_lines = output_lines[-FAILURE_OUTPUT_LINE_LIMIT:] + recent_output = "\n".join(output_lines) + if len(recent_output) > FAILURE_OUTPUT_CHARACTER_LIMIT: + recent_output = recent_output[-FAILURE_OUTPUT_CHARACTER_LIMIT:] + recent_output = "… earlier output omitted …\n" + recent_output + elif omitted_lines: + recent_output = f"… {omitted_lines} earlier line(s) omitted …\n" + recent_output + if not recent_output: + recent_output = "(The command produced no output.)" + command = subprocess.list2cmdline(list(failure.command)) + return ( + f"{failure.label} failed with exit code {failure.return_code}.\n\n" + f"Command:\n{command}\n\n" + f"Recent output:\n{recent_output}\n\n" + "Continuing can publish a release with known test failures. " + "Only continue if you reviewed the failure and accept that risk." + ) + + def parse_semver(value: str) -> tuple[int, int, int]: match = SEMVER_PATTERN.fullmatch(value.strip()) if not match: @@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int: ) for command, label in full_release_checks(npm): - self._run_command(command, label) + try: + self._run_command(command, label) + except ReleaseCommandError as failure: + if not can_override_release_check(label): + raise + approved = self._request_approval( + format_failed_test_warning(failure), + title=f"Warning: {label} failed", + heading="Tests failed — continue the release?", + approve_label="Continue Anyway", + cancel_label="Stop Release", + ) + if not approved: + raise ReleaseCancelled( + f"Release stopped because {label} failed." + ) from failure + self._log_event( + f"⚠ Continuing after failed {label}; override approved by user." + ) self._run_command(["git", "add", "-A"], "Stage release") staged = True @@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int: ) self.current_process = process assert process.stdout is not None + output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT) try: for line in process.stdout: - self._log_event(line.rstrip("\n")) + rendered = line.rstrip("\n") + output_lines.append(rendered) + self._log_event(rendered) return_code = process.wait() finally: self.current_process = None if self.cancel_event.is_set(): raise ReleaseCancelled("Release cancelled.") if return_code != 0: - raise ReleaseError(f"{label} failed with exit code {return_code}.") + raise ReleaseCommandError( + label, + args, + return_code, + "\n".join(output_lines), + ) self._log_event(f"✓ {label} passed") - def _request_approval(self, review: str) -> bool: + def _request_approval( + self, + review: str, + *, + title: str = "Review staged release", + heading: str = "Inspect every staged path before publishing", + approve_label: str = "Commit, Push, and Publish", + cancel_label: str = "Cancel release", + ) -> bool: response: dict[str, bool] = {} ready = threading.Event() - self.events.put(("approval", review, response, ready)) + self.events.put( + ( + "approval", + review, + title, + heading, + approve_label, + cancel_label, + response, + ready, + ) + ) while not ready.wait(0.2): if self.cancel_event.is_set(): return False return response.get("approved", False) - def _show_staged_review(self, review: str) -> bool: + def _show_approval( + self, + review: str, + *, + title: str, + heading: str, + approve_label: str, + cancel_label: str, + ) -> bool: decision = {"approved": False} dialog = tk.Toplevel(self.root) - dialog.title("Review staged release") + dialog.title(title) dialog.geometry("820x640") dialog.minsize(640, 420) dialog.configure(bg=self.BG) @@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int: dialog.columnconfigure(0, weight=1) ttk.Label( dialog, - text="Inspect every staged path before publishing", + text=heading, style="Title.TLabel", ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) review_text = scrolledtext.ScrolledText( @@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int: dialog.grab_release() dialog.destroy() - ttk.Button(buttons, text="Cancel release", command=lambda: finish(False)).pack( + ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack( side="left" ) ttk.Button( buttons, - text="Commit, Push, and Publish", + text=approve_label, style="Accent.TButton", command=lambda: finish(True), ).pack(side="right") @@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int: if kind == "log": self._append_log(str(event[1])) elif kind == "approval": - review, response, ready = event[1], event[2], event[3] - approved = self._show_staged_review(str(review)) + review, title, heading, approve_label, cancel_label = event[1:6] + response, ready = event[6], event[7] + approved = self._show_approval( + str(review), + title=str(title), + heading=str(heading), + approve_label=str(approve_label), + cancel_label=str(cancel_label), + ) assert isinstance(response, dict) response["approved"] = approved assert isinstance(ready, threading.Event) diff --git a/scripts/release_manager_test.py b/scripts/release_manager_test.py index 98484e21..3d4ab11f 100644 --- a/scripts/release_manager_test.py +++ b/scripts/release_manager_test.py @@ -54,6 +54,39 @@ class SafetyTests(unittest.TestCase): self.assertLess(publish_index, commands.index(["npm", "run", "build"])) self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"])) + def test_only_test_failures_may_be_overridden(self): + checks = release_manager.full_release_checks("npm") + overridable = { + label for _command, label in checks + if release_manager.can_override_release_check(label) + } + self.assertEqual( + overridable, + { + "Content tests", + "Server tests", + "Application tests", + "Shelved-mode application tests", + }, + ) + self.assertFalse(release_manager.can_override_release_check("Production build")) + self.assertFalse(release_manager.can_override_release_check("Push main")) + + def test_failed_test_warning_includes_command_and_specific_output(self): + failure = release_manager.ReleaseCommandError( + "Application tests", + ["npm", "test"], + 1, + "\x1b[31mFAIL\x1b[39m src/game/example.test.ts\n" + "Error: expected 2 to be 3", + ) + warning = release_manager.format_failed_test_warning(failure) + self.assertIn("Application tests failed with exit code 1", warning) + self.assertIn("npm test", warning) + self.assertIn("FAIL src/game/example.test.ts", warning) + self.assertIn("Error: expected 2 to be 3", warning) + self.assertNotIn("\x1b", warning) + class ReleaseCandidateTests(unittest.TestCase): def test_checks_untracked_files_without_changing_the_real_index(self): diff --git a/src/game/benchmark/benchmarkController.ts b/src/game/benchmark/benchmarkController.ts index 80c1c094..8aecd37f 100644 --- a/src/game/benchmark/benchmarkController.ts +++ b/src/game/benchmark/benchmarkController.ts @@ -219,6 +219,10 @@ export class BenchmarkController { comboTargetId: combat.comboTargetId, runes: combat.deathKnightRunes, abilities: availableAbilities, + knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => { + const categories = aiHintsForAbility(ability).categories; + return categories.includes("damage") || categories.includes("execute"); + }).map((ability) => ability.name)), hostileTarget, friendlyTargets: [ { id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 }, diff --git a/src/game/combatActorAuthority.ts b/src/game/combatActorAuthority.ts index 7a0e83f9..8b84b705 100644 --- a/src/game/combatActorAuthority.ts +++ b/src/game/combatActorAuthority.ts @@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats"; export function companionResourceMaximum(member: PartyMember, id: string): number { return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId) - ? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana : 100; + ? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana + : id === "nature-power" ? 10 + : id === "psi" ? 6 + : 100; } /** diff --git a/src/game/combatRotation.test.ts b/src/game/combatRotation.test.ts index c967a3f6..48f9f353 100644 --- a/src/game/combatRotation.test.ts +++ b/src/game/combatRotation.test.ts @@ -285,6 +285,73 @@ describe("shared combat rotations", () => { .toBe("strike"); }); + it("requires modeled combat state for reactive attacks and named execute-range spells", () => { + const revenge = ability("warrior", "Revenge"); + const warrior = context({ + classId: "warrior", + specializationId: "warrior:protection", + role: "tank", + primaryResource: "rage", + resources: { rage: 100 }, + maximumResources: { rage: 100 }, + abilities: [revenge], + }); + expect(evaluateCombatRotation(warrior)).toBeNull(); + expect(evaluateCombatRotation({ + ...warrior, + availableConditionalAbilityNames: new Set(["Revenge"]), + })?.ability.id).toBe(revenge.id); + + const drainSoul = ability("warlock", "Drain Soul"); + const shadowBolt = ability("warlock", "Shadow Bolt"); + const warlock = context({ + classId: "warlock", + specializationId: "warlock:affliction", + primaryResource: "mana", + resources: { mana: 10_000 }, + maximumResources: { mana: 10_000 }, + abilities: [drainSoul, shadowBolt], + }); + expect(evaluateCombatRotation(warlock)?.ability.id).toBe(shadowBolt.id); + expect(evaluateCombatRotation({ + ...warlock, + hostileTarget: { id: "target", health: 250, maxHealth: 1_000 }, + })?.ability.id).toBe(drainSoul.id); + }); + + it("keeps available specialization actions ahead of off-profile throughput", () => { + const fireball = ability("mage", "Fireball"); + const arcaneMissiles = ability("mage", "Arcane Missiles"); + const mage = context({ + classId: "mage", + specializationId: "mage:fire", + primaryResource: "mana", + resources: { mana: 10_000 }, + maximumResources: { mana: 10_000 }, + abilities: [arcaneMissiles, fireball], + }); + expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id); + + const bearMangle = ability("druid", "Mangle (Bear)"); + const rake = ability("druid", "Rake"); + const moonfire = ability("druid", "Moonfire"); + const bear = context({ + classId: "druid", + specializationId: "druid:feral-bear", + role: "tank", + primaryResource: "mana", + resources: { mana: 10_000, rage: 100, energy: 100 }, + maximumResources: { mana: 10_000, rage: 100, energy: 100 }, + abilities: [rake, bearMangle], + }); + expect(evaluateCombatRotation(bear)?.ability.id).toBe(bearMangle.id); + expect(evaluateCombatRotation({ + ...bear, + abilities: [rake, moonfire], + knownDamagingAbilityNames: new Set([rake.name, moonfire.name, bearMangle.name]), + })).toBeNull(); + }); + it("never falls a full-health friendly maintenance heal through to a hostile target", () => { const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]); const prayer = semanticAbility("prayer", [ diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index e6f1c296..6d8fe225 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources"; import { healingAbilityCanAidOthers } from "./healingAbilityRules"; import type { PartyRole } from "./partyRoles"; -export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1"; +export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v2"; export interface RotationProfile { readonly id: string; @@ -51,6 +51,8 @@ export interface RotationContext { readonly comboTargetId: string | null; readonly runes: readonly DeathKnightRuneState[]; readonly abilities: readonly AbilityDefinition[]; + /** Learned actions capable of hostile damage, including unavailable ones. */ + readonly knownDamagingAbilityNames?: ReadonlySet; readonly hostileTarget: RotationHostileTarget | null; readonly friendlyTargets?: readonly RotationFriendlyTarget[]; readonly hostileCount?: number; @@ -62,6 +64,8 @@ export interface RotationContext { readonly activeAuraExclusiveGroups?: ReadonlySet; readonly activeSummonAbilityIds?: ReadonlySet; readonly hasActivePet?: boolean; + /** Proc/state-gated actions that the caller has positively made available. */ + readonly availableConditionalAbilityNames?: ReadonlySet; readonly canUseAbility?: (ability: AbilityDefinition) => boolean; } @@ -108,7 +112,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]), profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]), profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]), - profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Pyroblast", "Fireball", "Scorch", "Fire Blast"]), + profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast"]), profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]), profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]), profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]), @@ -123,6 +127,45 @@ const PROFILE_BY_SPECIALIZATION = new Map( CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]), ); +const CONDITIONAL_ABILITY_NAMES = new Set([ + "Overpower", + "Revenge", + "Victory Rush", +]); + +const TARGET_HEALTH_GATES = new Map([ + ["Drain Soul", 0.25], + ["Hammer of Wrath", 0.2], + ["Kill Shot", 0.2], +]); + +const BEAR_FORM_ACTION_NAMES = new Set([ + "Bash", + "Challenging Roar", + "Demoralizing Roar", + "Frenzied Regeneration", + "Growl", + "Lacerate", + "Mangle (Bear)", + "Maul", + "Swipe (Bear)", +]); + +const CAT_FORM_ACTION_NAMES = new Set([ + "Claw", + "Cower", + "Ferocious Bite", + "Mangle (Cat)", + "Pounce", + "Rake", + "Ravage", + "Rip", + "Savage Roar", + "Shred", + "Swipe (Cat)", + "Tiger's Fury", +]); + function slug(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } @@ -303,32 +346,52 @@ function scoreAbility( || (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5, ); const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1; + const knownDamagingAbilityNames = context.knownDamagingAbilityNames + ?? new Set(context.abilities.filter((candidate) => { + const candidateCategories = aiHintsForAbility(candidate).categories; + return candidateCategories.includes("damage") || candidateCategories.includes("execute"); + }).map((candidate) => candidate.name)); + const hasKnownProfileDamageAction = Boolean(rotationProfile?.abilityPriority.some((name) => ( + knownDamagingAbilityNames.has(name) + ))); + + const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name); + if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null; + if (CONDITIONAL_ABILITY_NAMES.has(ability.name) + && !context.availableConditionalAbilityNames?.has(ability.name)) return null; + if (context.classId === "druid") { + const specializationId = rotationProfile?.specializationId; + if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null; + if (specializationId === "druid:feral-cat" && BEAR_FORM_ACTION_NAMES.has(ability.name)) return null; + if (specializationId && !specializationId.startsWith("druid:feral-") + && (BEAR_FORM_ACTION_NAMES.has(ability.name) || CAT_FORM_ACTION_NAMES.has(ability.name))) return null; + } if (categories.has("resurrection")) { const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen); - return fallen ? { ability, score: 20_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null; + return fallen ? { ability, score: 1_020_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null; } if (categories.has("dispel")) { const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel); - if (dispel) return { ability, score: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id }; + if (dispel) return { ability, score: 1_019_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id }; if (!damaging) return null; } if (categories.has("interrupt")) { if (!context.hostileTarget?.activeCast?.interruptible) { if (!damaging) return null; } else { - return { ability, score: 18_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id }; + return { ability, score: 1_018_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id }; } } if (categories.has("taunt")) { if (context.role !== "tank" || !context.targetIsLoose) return null; - return { ability, score: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null }; + return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null }; } if (categories.has("threat") && !damaging && !maintenanceBuff) { if (context.role === "tank" || !context.hasDangerousThreat) return null; return { ability, - score: 15_000, + score: 1_015_000, ruleId: "reactive-threat-drop", reason: "non-tank has enemy attention", targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null, @@ -359,12 +422,14 @@ function scoreAbility( && !petSummon && !offensiveSummon) return null; if (categories.has("movement") && !damaging && !context.moving) return null; if (context.requiredControl && categories.has("control")) { - return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null }; + return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null }; } if (offensiveMajorCooldown && !durableTarget) return null; if (maintained && (categories.has("debuff") || categories.has("buff"))) return null; if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null; if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null; + if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction + && context.role !== "healer" && profileIndex < 0 && damaging) return null; if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null; if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null; @@ -374,7 +439,9 @@ function scoreAbility( const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000; const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800; if (profileIndex >= 0) { - score += 4_000 - profileIndex * 120; + score += context.role === "healer" || context.level < 10 + ? 4_000 - profileIndex * 120 + : 100_000 - profileIndex * 1_000; ruleId = `profile-${slug(ability.name)}`; reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`; } @@ -407,7 +474,7 @@ function scoreAbility( } else { if (categories.has("mitigation")) { if (!maintenanceBuff && selfHealth >= 0.55) return null; - if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_000; + if (selfHealth < 0.55) score += selfHealth < 0.35 ? 180_000 : 140_000; } if (categories.has("execute")) { if (targetHealth > 0.2) return null; diff --git a/src/game/dungeonBossScaling.test.ts b/src/game/dungeonBossScaling.test.ts index 1c38ae4e..5af67446 100644 --- a/src/game/dungeonBossScaling.test.ts +++ b/src/game/dungeonBossScaling.test.ts @@ -117,7 +117,7 @@ describe("dungeon boss durability and difficulty", () => { expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25); expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45); } - }); + }, 30000); } it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => { diff --git a/src/game/partyAbilityAi.ts b/src/game/partyAbilityAi.ts index f92ebe9c..7b5243e2 100644 --- a/src/game/partyAbilityAi.ts +++ b/src/game/partyAbilityAi.ts @@ -60,11 +60,10 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; } -function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] { +function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] { return abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) - .filter((ability): ability is AbilityDefinition => ability !== null) - .filter((ability) => partyAbilityIsReady(member, ability, now)); + .filter((ability): ability is AbilityDefinition => ability !== null); } export function choosePartyRotationDecision( @@ -85,6 +84,7 @@ export function choosePartyRotationDecision( [...new Set([...Object.keys(member.resourcePools), member.resourceType])] .map((resource) => [resource, companionResourceMaximum(member, resource)]), ); + const learned = learnedAbilities(member); return evaluateCombatRotation({ now: context.now, classId: member.classId, @@ -102,7 +102,11 @@ export function choosePartyRotationDecision( comboPoints: member.comboPoints, comboTargetId: member.comboTargetId, runes: member.runes, - abilities: candidateAbilities(member, context.now), + abilities: learned.filter((ability) => partyAbilityIsReady(member, ability, context.now)), + knownDamagingAbilityNames: new Set(learned.filter((ability) => { + const categories = aiHintsForAbility(ability).categories; + return categories.includes("damage") || categories.includes("execute"); + }).map((ability) => ability.name)), hostileTarget: context.target, friendlyTargets, hostileCount: context.hostileCount, diff --git a/src/game/partyDamageAudit.test.ts b/src/game/partyDamageAudit.test.ts index 31f1fc58..fc9ba10f 100644 --- a/src/game/partyDamageAudit.test.ts +++ b/src/game/partyDamageAudit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore"; +import { createConfiguredPartyMember, createDungeonParty, usePartyStore, type PartyMember } from "./partyStore"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; @@ -76,6 +76,87 @@ describe("NPC sustained damage audit", () => { expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000); }); + it("generates Bear-form Rage from weapon hits and regenerates Cat-form Energy", () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const bear = createConfiguredPartyMember({ + name: "Balance Bear", + classId: "druid", + specialization: "Feral (Bear)", + specializationId: "druid:feral-bear", + talentTreeId: "druid-feral-bear", + talentRanks: {}, + role: "tank", + }, 80, 0, "feral-runtime"); + usePartyStore.setState({ members: [{ ...bear, globalCooldownEndsAt: 60_000 }] }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(bear.id, [0, 0, 0]); + useCombatStore.getState().registerMob("feral-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("feral-target", [2, 0, 0]); + useCombatStore.getState().damageMob("feral-target", 1, 100); + updatePartyRuntimeTargetVisibility(bear.id, "feral-target", true); + advanceDungeonPartyCombat(1_000); + expect(usePartyStore.getState().members[0].resourcePools.rage).toBe(6); + + const cat = createConfiguredPartyMember({ + name: "Balance Cat", + classId: "druid", + specialization: "Feral (Cat)", + specializationId: "druid:feral-cat", + talentTreeId: "druid-feral-cat", + talentRanks: {}, + role: "damage", + }, 80, 1, "feral-runtime"); + usePartyStore.setState({ members: [{ + ...cat, + globalCooldownEndsAt: 60_000, + resourcePools: { ...cat.resourcePools, energy: 0 }, + }] }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(cat.id, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(cat.id, "feral-target", true); + advanceDungeonPartyCombat(2_000); + expect(usePartyStore.getState().members[0].resourcePools.energy).toBe(10); + }); + + it("finishes companion casts with the learned level rank instead of rank one", () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const mage = createConfiguredPartyMember({ + name: "Balance Frost Mage", + classId: "mage", + specialization: "Frost", + specializationId: "mage:frost", + talentTreeId: "mage-frost", + role: "damage", + }, 80, 0, "ranked-cast"); + const frostbolt = abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!; + usePartyStore.setState({ members: [{ + ...mage, + cooldowns: Object.fromEntries(abilitiesForClass("mage") + .filter((ability) => ability.id !== frostbolt.id) + .map((ability) => [ability.id, 60_000])), + }] }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(mage.id, [-12, 0, 0]); + useCombatStore.getState().registerMob("ranked-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("ranked-target", [0, 0, 0]); + useCombatStore.getState().damageMob("ranked-target", 1, 100); + updatePartyRuntimeTargetVisibility(mage.id, "ranked-target", true); + for (let now = 1_000; now <= 6_000; now += 100) { + useCombatStore.getState().tick(0.1, now); + advanceDungeonPartyCombat(now); + } + const hit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.sourceActorId === mage.id && event.abilityId === frostbolt.id + )); + expect(hit?.rawAmount).toBeGreaterThan(700); + }); + for (const level of [1, 3, 5, 20, 80]) { it(`executes damaging abilities for every party template at level ${level}`, () => { const templates = partyTemplates(level); diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index 040eed28..571b90d3 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -18,7 +18,7 @@ import { } from "./abilityRules"; import type { MobCombatState } from "./combatStore"; import { useCombatStore } from "./combatStore"; -import { memberFromCompanionActor } from "./combatActorAuthority"; +import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority"; import type { MobVector3 } from "./mobPopulation"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { getMobPosition } from "./mobRuntimeRegistry"; @@ -713,9 +713,13 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical", delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true }); } - if (member.resourceType !== "rage" || result.amount <= 0) return member; - const resource = Math.min(member.maxResource, member.resource + 6); - return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } }; + if (member.resourcePools.rage === undefined || result.amount <= 0) return member; + const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6); + return { + ...member, + resource: member.resourceType === "rage" ? rage : member.resource, + resourcePools: { ...member.resourcePools, rage }, + }; } function partyFriendlyPosition(actorId: string): MobVector3 | null { @@ -1207,26 +1211,37 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { ); members = members.map((member) => { const profile = resourceProfileForClass(member.classId); - const regeneration = (profile.type === "rage" || profile.type === "runic-power") - ? 0 - : profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; - const pool = advanceCombatResourcePool(normalizeActorResourcePool( - member.resourceType, - member.resource, - member.maxResource, - regeneration, - member.lastResourceSpentAt[member.resourceType] ?? 0, - ), { - inCombat: Boolean(targetId), - actorLevel: member.level, - now, - deltaSeconds, - manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02, - }); + const passiveRates: Partial> = { + mana: 4, + rage: 0, + energy: 10, + focus: 6, + "runic-power": 0, + }; + const resourcePools: Partial> = { ...member.resourcePools }; + for (const type of Object.keys(resourcePools) as ResourceType[]) { + const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; + const regeneration = type === member.resourceType + ? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration) + : (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0); + resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool( + type, + resourcePools[type] ?? 0, + companionResourceMaximum(member, type), + regeneration, + member.lastResourceSpentAt[type] ?? 0, + ), { + inCombat: Boolean(targetId), + actorLevel: member.level, + now, + deltaSeconds, + manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0, + }).current; + } return { ...member, - resource: pool.current, - resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current }, + resource: resourcePools[member.resourceType] ?? member.resource, + resourcePools, cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), }; @@ -1291,7 +1306,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { const currentCombat = useCombatStore.getState(); if (member.activeCast) { const activeCast = member.activeCast; - const castingAbility = abilityById(member.activeCast.abilityId); + const catalogAbility = abilityById(member.activeCast.abilityId); + const castingAbility = catalogAbility + ? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks) + : null; const castTargetId = member.activeCastTargetId; if (!castingAbility || !castTargetId) { nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null }; diff --git a/src/game/partyStore.test.ts b/src/game/partyStore.test.ts index 26d1a197..14470439 100644 --- a/src/game/partyStore.test.ts +++ b/src/game/partyStore.test.ts @@ -8,6 +8,7 @@ import { } from "../app/characterCatalog"; import { createDungeonParty, + createConfiguredPartyMember, isHealerSpecialization, isTankSpecialization, usePartyStore, @@ -74,6 +75,30 @@ describe("dungeon party composition", () => { } }); + it("creates the secondary Rage and Energy pools used by druid forms", () => { + const bear = createConfiguredPartyMember({ + name: "Balance Bear", + classId: "druid", + specialization: "Feral (Bear)", + specializationId: "druid:feral-bear", + talentTreeId: "druid-feral-bear", + talentRanks: {}, + role: "tank", + }, 80, 0, "feral-resources"); + const cat = createConfiguredPartyMember({ + name: "Balance Cat", + classId: "druid", + specialization: "Feral (Cat)", + specializationId: "druid:feral-cat", + talentTreeId: "druid-feral-cat", + talentRanks: {}, + role: "damage", + }, 80, 1, "feral-resources"); + + expect(bear.resourcePools).toMatchObject({ mana: bear.maxResource, rage: 0, energy: 100 }); + expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 }); + }); + it("is deterministic per character and scales every ally to the player level", () => { const first = createDungeonParty("character-42", 18); const second = createDungeonParty("character-42", 18); diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index 839a8340..5691a7ab 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -31,7 +31,12 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog"; import type { EnemyControlMechanic } from "./enemyAttacks"; import type { ContentCategoryId } from "../app/contentCategories"; import type { PartyRole, PartySize } from "./partyRoles"; -import { resourceProfileForClass, type ResourceType } from "./abilityCatalog"; +import { + abilitiesForCharacter, + learnedAbilityForBuild, + resourceProfileForClass, + type ResourceType, +} from "./abilityCatalog"; import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors"; export { @@ -222,6 +227,43 @@ function memberMaxResource( : profile.maximum; } +function secondaryResourceMaximum(type: ResourceType): number { + if (type === "nature-power") return 10; + if (type === "psi") return 6; + return 100; +} + +function initialSecondaryResource(type: ResourceType, maximum: number): number { + return type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi" + ? 0 + : maximum; +} + +/** Keeps every resource used by the member's learned kit available to companion AI. */ +function withMemberAbilityResourcePools(member: PartyMember): PartyMember { + const types = new Set([member.resourceType]); + for (const ability of abilitiesForCharacter(member.classId, member.secondaryClassId)) { + const learned = learnedAbilityForBuild(ability, member.level, member.talentRanks); + if (!learned) continue; + for (const cost of learned.costs?.length ? learned.costs : [learned.cost]) { + if (cost.resource !== "health") types.add(cost.resource); + } + } + const resourcePools: Partial> = {}; + for (const type of types) { + if (type === member.resourceType) { + resourcePools[type] = member.resource; + continue; + } + const maximum = secondaryResourceMaximum(type); + resourcePools[type] = Math.max(0, Math.min( + maximum, + member.resourcePools[type] ?? initialSecondaryResource(type, maximum), + )); + } + return { ...member, resourcePools: Object.freeze(resourcePools) }; +} + function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember { const maxHealth = memberMaxHealth( member.level, @@ -237,7 +279,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe const resource = Math.max(0, Math.round( member.resource / Math.max(1, member.maxResource) * maxResource, )); - return { + return withMemberAbilityResourcePools({ ...member, gearStats, health, @@ -245,7 +287,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe resource, maxResource, resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), - }; + }); } function createMember( @@ -286,7 +328,7 @@ function createMember( const resource = Math.round( maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), ); - return { + return withMemberAbilityResourcePools({ id, name: template.name, raceId: race.id, @@ -323,7 +365,7 @@ function createMember( tauntReadyAt: 0, controlledUntil: 0, controlMechanic: null, - }; + }); } /** Creates one exact companion while retaining the live party stat/equipment path. */ @@ -339,12 +381,12 @@ export function createConfiguredPartyMember( specialization: configuration.specialization, talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "", }, configuration.role, level, index, seed, configuration.talentRanks); - return Object.freeze({ + return Object.freeze(withMemberAbilityResourcePools({ ...member, secondaryClassId: configuration.secondaryClassId ?? null, specializationId: configuration.specializationId ?? member.specializationId, talentRanks: configuration.talentRanks ?? member.talentRanks, - }); + })); } /** @@ -571,7 +613,7 @@ export const usePartyStore = create((set, get) => ({ const resource = Math.max(0, Math.round( member.resource / Math.max(1, member.maxResource) * maxResource, )); - return { + return withMemberAbilityResourcePools({ ...member, level: safeLevel, health, @@ -582,7 +624,7 @@ export const usePartyStore = create((set, get) => ({ talentRanks: isRomClassId(member.classId) ? Object.freeze({}) : runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel), - }; + }); }), }; }),