Compare commits

..
1 Commits
Author SHA1 Message Date
phenom d6976e6ef6 Release Healer Man 0.1.53 2026-09-18 12:32:51 -04:00
17 changed files with 555 additions and 69 deletions

No files matched your search

+8
View File
@@ -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 the Gitea release. Signing passwords and the Gitea token are passed to the
child process in memory rather than on its command line. 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 Before changing versions or running the longer test/build sequence, the manager
runs an isolated release-candidate check. It validates tracked and untracked 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 files without changing the real Git index, so extra blank lines at EOF and
+17 -2
View File
@@ -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 different `--active-slot`; do not compare active and companion results without
noting that control-policy difference. 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 ## Code map
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic - `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 - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged. talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
that preserves production stats, equipment, resources, and appearance for an that preserves production stats, equipment, all learned-ability resource
exact companion. pools, and appearance for an exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated - `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction. room construction.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.52", "version": "0.1.53",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.52", "version": "0.1.53",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.52", "version": "0.1.53",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.52", "versionName": "0.1.53",
"versionCode": 1052 "versionCode": 1053
} }
+132 -13
View File
@@ -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 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. the app must be stopped and started from the TrueNAS Apps UI around that block.
@@ -18,6 +18,7 @@ import sys
import tempfile import tempfile
import threading import threading
import webbrowser import webbrowser
from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable, Sequence from typing import Callable, Iterable, Sequence
@@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = {
ALLOWED_GENERATED_FILES = { ALLOWED_GENERATED_FILES = {
"public/data/healer-man-loot.sqlite", "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): class ReleaseError(RuntimeError):
@@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError):
"""The user cancelled an active release.""" """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) @dataclass(frozen=True)
class ReleaseVersion: class ReleaseVersion:
version_name: str 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]: def parse_semver(value: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.fullmatch(value.strip()) match = SEMVER_PATTERN.fullmatch(value.strip())
if not match: if not match:
@@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
for command, label in full_release_checks(npm): 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") self._run_command(["git", "add", "-A"], "Stage release")
staged = True staged = True
@@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
self.current_process = process self.current_process = process
assert process.stdout is not None assert process.stdout is not None
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
try: try:
for line in process.stdout: 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() return_code = process.wait()
finally: finally:
self.current_process = None self.current_process = None
if self.cancel_event.is_set(): if self.cancel_event.is_set():
raise ReleaseCancelled("Release cancelled.") raise ReleaseCancelled("Release cancelled.")
if return_code != 0: 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") 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] = {} response: dict[str, bool] = {}
ready = threading.Event() 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): while not ready.wait(0.2):
if self.cancel_event.is_set(): if self.cancel_event.is_set():
return False return False
return response.get("approved", 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} decision = {"approved": False}
dialog = tk.Toplevel(self.root) dialog = tk.Toplevel(self.root)
dialog.title("Review staged release") dialog.title(title)
dialog.geometry("820x640") dialog.geometry("820x640")
dialog.minsize(640, 420) dialog.minsize(640, 420)
dialog.configure(bg=self.BG) dialog.configure(bg=self.BG)
@@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.columnconfigure(0, weight=1) dialog.columnconfigure(0, weight=1)
ttk.Label( ttk.Label(
dialog, dialog,
text="Inspect every staged path before publishing", text=heading,
style="Title.TLabel", style="Title.TLabel",
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
review_text = scrolledtext.ScrolledText( review_text = scrolledtext.ScrolledText(
@@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.grab_release() dialog.grab_release()
dialog.destroy() 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" side="left"
) )
ttk.Button( ttk.Button(
buttons, buttons,
text="Commit, Push, and Publish", text=approve_label,
style="Accent.TButton", style="Accent.TButton",
command=lambda: finish(True), command=lambda: finish(True),
).pack(side="right") ).pack(side="right")
@@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if kind == "log": if kind == "log":
self._append_log(str(event[1])) self._append_log(str(event[1]))
elif kind == "approval": elif kind == "approval":
review, response, ready = event[1], event[2], event[3] review, title, heading, approve_label, cancel_label = event[1:6]
approved = self._show_staged_review(str(review)) 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) assert isinstance(response, dict)
response["approved"] = approved response["approved"] = approved
assert isinstance(ready, threading.Event) assert isinstance(ready, threading.Event)
+33
View File
@@ -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", "build"]))
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"])) 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): class ReleaseCandidateTests(unittest.TestCase):
def test_checks_untracked_files_without_changing_the_real_index(self): def test_checks_untracked_files_without_changing_the_real_index(self):
@@ -219,6 +219,10 @@ export class BenchmarkController {
comboTargetId: combat.comboTargetId, comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes, runes: combat.deathKnightRunes,
abilities: availableAbilities, 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, hostileTarget,
friendlyTargets: [ friendlyTargets: [
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 }, { id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
+4 -1
View File
@@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number { export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId) 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;
} }
/** /**
+67
View File
@@ -285,6 +285,73 @@ describe("shared combat rotations", () => {
.toBe("strike"); .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", () => { it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]); const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [ const prayer = semanticAbility("prayer", [
+77 -10
View File
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules"; import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles"; import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1"; export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v2";
export interface RotationProfile { export interface RotationProfile {
readonly id: string; readonly id: string;
@@ -51,6 +51,8 @@ export interface RotationContext {
readonly comboTargetId: string | null; readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[]; readonly runes: readonly DeathKnightRuneState[];
readonly abilities: readonly AbilityDefinition[]; readonly abilities: readonly AbilityDefinition[];
/** Learned actions capable of hostile damage, including unavailable ones. */
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
readonly hostileTarget: RotationHostileTarget | null; readonly hostileTarget: RotationHostileTarget | null;
readonly friendlyTargets?: readonly RotationFriendlyTarget[]; readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly hostileCount?: number; readonly hostileCount?: number;
@@ -62,6 +64,8 @@ export interface RotationContext {
readonly activeAuraExclusiveGroups?: ReadonlySet<string>; readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>; readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean; readonly hasActivePet?: boolean;
/** Proc/state-gated actions that the caller has positively made available. */
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean; 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: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("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: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("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: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"]), 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]), CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
); );
const CONDITIONAL_ABILITY_NAMES = new Set([
"Overpower",
"Revenge",
"Victory Rush",
]);
const TARGET_HEALTH_GATES = new Map<string, number>([
["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 { function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
} }
@@ -303,32 +346,52 @@ function scoreAbility(
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5, || (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
); );
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1; 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")) { if (categories.has("resurrection")) {
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen); 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")) { if (categories.has("dispel")) {
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel); 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 (!damaging) return null;
} }
if (categories.has("interrupt")) { if (categories.has("interrupt")) {
if (!context.hostileTarget?.activeCast?.interruptible) { if (!context.hostileTarget?.activeCast?.interruptible) {
if (!damaging) return null; if (!damaging) return null;
} else { } 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 (categories.has("taunt")) {
if (context.role !== "tank" || !context.targetIsLoose) return null; 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 (categories.has("threat") && !damaging && !maintenanceBuff) {
if (context.role === "tank" || !context.hasDangerousThreat) return null; if (context.role === "tank" || !context.hasDangerousThreat) return null;
return { return {
ability, ability,
score: 15_000, score: 1_015_000,
ruleId: "reactive-threat-drop", ruleId: "reactive-threat-drop",
reason: "non-tank has enemy attention", reason: "non-tank has enemy attention",
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null, targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
@@ -359,12 +422,14 @@ function scoreAbility(
&& !petSummon && !offensiveSummon) return null; && !petSummon && !offensiveSummon) return null;
if (categories.has("movement") && !damaging && !context.moving) return null; if (categories.has("movement") && !damaging && !context.moving) return null;
if (context.requiredControl && categories.has("control")) { 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 (offensiveMajorCooldown && !durableTarget) return null;
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null; if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null; if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) 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 (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; 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 maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800; const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
if (profileIndex >= 0) { 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)}`; ruleId = `profile-${slug(ability.name)}`;
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`; reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
} }
@@ -407,7 +474,7 @@ function scoreAbility(
} else { } else {
if (categories.has("mitigation")) { if (categories.has("mitigation")) {
if (!maintenanceBuff && selfHealth >= 0.55) return null; 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 (categories.has("execute")) {
if (targetHealth > 0.2) return null; if (targetHealth > 0.2) return null;
+1 -1
View File
@@ -117,7 +117,7 @@ describe("dungeon boss durability and difficulty", () => {
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25); expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45); 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", () => { it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
+8 -4
View File
@@ -60,11 +60,10 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; 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) return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability): ability is AbilityDefinition => ability !== null);
.filter((ability) => partyAbilityIsReady(member, ability, now));
} }
export function choosePartyRotationDecision( export function choosePartyRotationDecision(
@@ -85,6 +84,7 @@ export function choosePartyRotationDecision(
[...new Set([...Object.keys(member.resourcePools), member.resourceType])] [...new Set([...Object.keys(member.resourcePools), member.resourceType])]
.map((resource) => [resource, companionResourceMaximum(member, resource)]), .map((resource) => [resource, companionResourceMaximum(member, resource)]),
); );
const learned = learnedAbilities(member);
return evaluateCombatRotation({ return evaluateCombatRotation({
now: context.now, now: context.now,
classId: member.classId, classId: member.classId,
@@ -102,7 +102,11 @@ export function choosePartyRotationDecision(
comboPoints: member.comboPoints, comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId, comboTargetId: member.comboTargetId,
runes: member.runes, 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, hostileTarget: context.target,
friendlyTargets, friendlyTargets,
hostileCount: context.hostileCount, hostileCount: context.hostileCount,
+82 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; 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); 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]) { for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => { it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level); const templates = partyTemplates(level);
+41 -23
View File
@@ -18,7 +18,7 @@ import {
} from "./abilityRules"; } from "./abilityRules";
import type { MobCombatState } from "./combatStore"; import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { memberFromCompanionActor } from "./combatActorAuthority"; import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
import type { MobVector3 } from "./mobPopulation"; import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry"; 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", targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true }); delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
} }
if (member.resourceType !== "rage" || result.amount <= 0) return member; if (member.resourcePools.rage === undefined || result.amount <= 0) return member;
const resource = Math.min(member.maxResource, member.resource + 6); const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6);
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } }; return {
...member,
resource: member.resourceType === "rage" ? rage : member.resource,
resourcePools: { ...member.resourcePools, rage },
};
} }
function partyFriendlyPosition(actorId: string): MobVector3 | null { function partyFriendlyPosition(actorId: string): MobVector3 | null {
@@ -1207,26 +1211,37 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
); );
members = members.map((member) => { members = members.map((member) => {
const profile = resourceProfileForClass(member.classId); const profile = resourceProfileForClass(member.classId);
const regeneration = (profile.type === "rage" || profile.type === "runic-power") const passiveRates: Partial<Record<ResourceType, number>> = {
? 0 mana: 4,
: profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; rage: 0,
const pool = advanceCombatResourcePool(normalizeActorResourcePool( energy: 10,
member.resourceType, focus: 6,
member.resource, "runic-power": 0,
member.maxResource, };
regeneration, const resourcePools: Partial<Record<ResourceType, number>> = { ...member.resourcePools };
member.lastResourceSpentAt[member.resourceType] ?? 0, for (const type of Object.keys(resourcePools) as ResourceType[]) {
), { const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
inCombat: Boolean(targetId), const regeneration = type === member.resourceType
actorLevel: member.level, ? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration)
now, : (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0);
deltaSeconds, resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02, 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 { return {
...member, ...member,
resource: pool.current, resource: resourcePools[member.resourceType] ?? member.resource,
resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current }, resourcePools,
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > 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(); const currentCombat = useCombatStore.getState();
if (member.activeCast) { if (member.activeCast) {
const activeCast = 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; const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) { if (!castingAbility || !castTargetId) {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null }; nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
+25
View File
@@ -8,6 +8,7 @@ import {
} from "../app/characterCatalog"; } from "../app/characterCatalog";
import { import {
createDungeonParty, createDungeonParty,
createConfiguredPartyMember,
isHealerSpecialization, isHealerSpecialization,
isTankSpecialization, isTankSpecialization,
usePartyStore, 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", () => { it("is deterministic per character and scales every ally to the player level", () => {
const first = createDungeonParty("character-42", 18); const first = createDungeonParty("character-42", 18);
const second = createDungeonParty("character-42", 18); const second = createDungeonParty("character-42", 18);
+51 -9
View File
@@ -31,7 +31,12 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks"; import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories"; import type { ContentCategoryId } from "../app/contentCategories";
import type { PartyRole, PartySize } from "./partyRoles"; 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"; import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
export { export {
@@ -222,6 +227,43 @@ function memberMaxResource(
: profile.maximum; : 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<ResourceType>([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<Record<ResourceType, number>> = {};
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 { function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
const maxHealth = memberMaxHealth( const maxHealth = memberMaxHealth(
member.level, member.level,
@@ -237,7 +279,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
const resource = Math.max(0, Math.round( const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource, member.resource / Math.max(1, member.maxResource) * maxResource,
)); ));
return { return withMemberAbilityResourcePools({
...member, ...member,
gearStats, gearStats,
health, health,
@@ -245,7 +287,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
resource, resource,
maxResource, maxResource,
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
}; });
} }
function createMember( function createMember(
@@ -286,7 +328,7 @@ function createMember(
const resource = Math.round( const resource = Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
); );
return { return withMemberAbilityResourcePools({
id, id,
name: template.name, name: template.name,
raceId: race.id, raceId: race.id,
@@ -323,7 +365,7 @@ function createMember(
tauntReadyAt: 0, tauntReadyAt: 0,
controlledUntil: 0, controlledUntil: 0,
controlMechanic: null, controlMechanic: null,
}; });
} }
/** Creates one exact companion while retaining the live party stat/equipment path. */ /** Creates one exact companion while retaining the live party stat/equipment path. */
@@ -339,12 +381,12 @@ export function createConfiguredPartyMember(
specialization: configuration.specialization, specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "", talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
}, configuration.role, level, index, seed, configuration.talentRanks); }, configuration.role, level, index, seed, configuration.talentRanks);
return Object.freeze({ return Object.freeze(withMemberAbilityResourcePools({
...member, ...member,
secondaryClassId: configuration.secondaryClassId ?? null, secondaryClassId: configuration.secondaryClassId ?? null,
specializationId: configuration.specializationId ?? member.specializationId, specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks, talentRanks: configuration.talentRanks ?? member.talentRanks,
}); }));
} }
/** /**
@@ -571,7 +613,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
const resource = Math.max(0, Math.round( const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource, member.resource / Math.max(1, member.maxResource) * maxResource,
)); ));
return { return withMemberAbilityResourcePools({
...member, ...member,
level: safeLevel, level: safeLevel,
health, health,
@@ -582,7 +624,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
talentRanks: isRomClassId(member.classId) talentRanks: isRomClassId(member.classId)
? Object.freeze({}) ? Object.freeze({})
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel), : runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
}; });
}), }),
}; };
}), }),