forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6976e6ef6 |
No files matched your search
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.52",
|
"versionName": "0.1.53",
|
||||||
"versionCode": 1052
|
"versionCode": 1053
|
||||||
}
|
}
|
||||||
+131
-12
@@ -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):
|
||||||
|
try:
|
||||||
self._run_command(command, label)
|
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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+35
-17
@@ -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,
|
};
|
||||||
|
const resourcePools: Partial<Record<ResourceType, number>> = { ...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,
|
regeneration,
|
||||||
member.lastResourceSpentAt[member.resourceType] ?? 0,
|
member.lastResourceSpentAt[type] ?? 0,
|
||||||
), {
|
), {
|
||||||
inCombat: Boolean(targetId),
|
inCombat: Boolean(targetId),
|
||||||
actorLevel: member.level,
|
actorLevel: member.level,
|
||||||
now,
|
now,
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02,
|
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 };
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
};
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in new issue
Block a user