Release Healer Man 0.1.49
No files matched your search
@@ -3529,3 +3529,19 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
displayed `Server runtime ready · Python 3.14.0`, submitted a real Eck run,
|
displayed `Server runtime ready · Python 3.14.0`, submitted a real Eck run,
|
||||||
generated all reports, and completed desktop/mobile QA without browser errors.
|
generated all reports, and completed desktop/mobile QA without browser errors.
|
||||||
Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`.
|
Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`.
|
||||||
|
|
||||||
|
## Party melee animation cadence (2026-09-16)
|
||||||
|
|
||||||
|
- Party character animation now follows authoritative combat-presentation
|
||||||
|
phases instead of treating every `nextActionAt` retry timestamp as an attack.
|
||||||
|
Auto-attacks produce one ordinary attack event, while real abilities retain
|
||||||
|
their cast-start, release, and cancel identities.
|
||||||
|
- Warrior and Paladin auto-attacks now prefer the normal one-handed/two-handed
|
||||||
|
attack clips before special clips. Special/spinning clips remain available to
|
||||||
|
explicitly matched abilities rather than every weapon swing.
|
||||||
|
- The focused combat-animation suite passed 10 tests, all 47 avatar tests
|
||||||
|
passed, and the production build plus texture/performance and release-bundle
|
||||||
|
audits passed. The live level-three Stockades pass completed without browser
|
||||||
|
errors; all five bodies and their equipped weapon/staff silhouettes rendered
|
||||||
|
intact. Evidence is in
|
||||||
|
`playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Combat rotation matrix
|
||||||
|
|
||||||
|
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
|
||||||
|
|
||||||
|
| Concern | Playerbots behavior translated into Healer Man |
|
||||||
|
| --- | --- |
|
||||||
|
| Core rotation | One deterministic priority profile for each of the 31 Wrath specializations. |
|
||||||
|
| Maintenance | DoTs, debuffs, buffs, pets, and summons are refreshed only after their existing effect ends. |
|
||||||
|
| Execute | Execute-category abilities move ahead of the normal profile below 20% target health. |
|
||||||
|
| Multiple targets | Cleave gains value at two hostiles; area damage moves ahead at three or more. |
|
||||||
|
| Healing | Critical, medium, and light triage bands begin below 30%, 60%, and 90% health. Area healing requires three injured allies. |
|
||||||
|
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop dangerous threat before continuing their profile. |
|
||||||
|
| Control | Interrupts are reactive. Long crowd control is used only when an encounter requests control. |
|
||||||
|
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. |
|
||||||
|
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. |
|
||||||
|
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
|
||||||
|
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
|
||||||
|
|
||||||
|
CoA and Runes of Magic builds use the same semantic rules when no classic profile applies. Runes of Magic candidates include primary, secondary-general, and primary/secondary elite skills through `abilitiesForCharacter`.
|
||||||
|
|
||||||
|
## Classic specialization profiles
|
||||||
|
|
||||||
|
The leftmost available action wins after reactive rules, learned-talent gating, resource checks, maintenance checks, execute/AoE adjustments, and major-cooldown conservation. Maintenance entries are intentionally listed first; an active aura or pet removes that entry from consideration.
|
||||||
|
|
||||||
|
| Class | Specialization | Maintenance and core priority |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
|
||||||
|
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||||
|
| Warrior | Protection | Defensive Stance; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||||
|
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
||||||
|
| Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom |
|
||||||
|
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
|
||||||
|
| Hunter | Beast Mastery | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Command; Bestial Wrath; Serpent Sting; Arcane Shot; Multi-Shot; Steady Shot |
|
||||||
|
| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot |
|
||||||
|
| Hunter | Survival | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Explosive Shot; Black Arrow; Serpent Sting; Aimed Shot; Arcane Shot; Steady Shot |
|
||||||
|
| Rogue | Assassination | Envenom; Rupture; Mutilate; Eviscerate; Sinister Strike |
|
||||||
|
| Rogue | Combat | Killing Spree; Blade Flurry; Eviscerate; Sinister Strike |
|
||||||
|
| Rogue | Subtlety | Shadow Dance; Rupture; Eviscerate; Hemorrhage; Backstab |
|
||||||
|
| Priest | Discipline | Inner Fire; Penance; Power Word: Shield; Flash Heal; Greater Heal; Renew |
|
||||||
|
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; Flash Heal; Greater Heal; Renew |
|
||||||
|
| Priest | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
|
||||||
|
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
|
||||||
|
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
|
||||||
|
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
|
||||||
|
| Shaman | Elemental | Flametongue Weapon (Rockbiter fallback); Lightning Shield; Flame Shock; Lava Burst; Chain Lightning; Lightning Bolt |
|
||||||
|
| Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt |
|
||||||
|
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
|
||||||
|
| Mage | Arcane | Mage Armor; Arcane Barrage; Arcane Blast; Arcane Missiles |
|
||||||
|
| Mage | Fire | Molten Armor; Living Bomb; Pyroblast; Fireball; Scorch; Fire Blast |
|
||||||
|
| Mage | Frost | Frost Armor; Deep Freeze; Frostbolt; Ice Lance; Cone of Cold |
|
||||||
|
| Warlock | Affliction | Fel Armor; Felhunter; Haunt; Unstable Affliction; Corruption; Curse of Agony; Drain Soul; Shadow Bolt |
|
||||||
|
| Warlock | Demonology | Fel Armor; Felguard; Metamorphosis; Immolate; Corruption; Shadow Bolt |
|
||||||
|
| Warlock | Destruction | Fel Armor; Imp; Chaos Bolt; Conflagrate; Immolate; Incinerate; Shadow Bolt |
|
||||||
|
| Druid | Balance | Moonkin Form; Starfall; Moonfire; Insect Swarm; Starfire; Wrath |
|
||||||
|
| Druid | Feral Bear | Dire Bear/Bear Form; Mangle; Lacerate; Swipe; Maul |
|
||||||
|
| Druid | Feral Cat | Cat Form; Rip; Savage Roar; Mangle; Rake; Shred; Claw |
|
||||||
|
| Druid | Restoration | Tree of Life; Wild Growth; Swiftmend; Lifebloom; Rejuvenation; Regrowth; Healing Touch |
|
||||||
|
|
||||||
|
## Ability audit classification
|
||||||
|
|
||||||
|
Active abilities fall into one of three audit buckets:
|
||||||
|
|
||||||
|
- **Conditional:** executable but selected only by a semantic trigger, such as interrupts, dispels, resurrection, threat control, movement, crowd control, executes, defensives, and long cooldowns.
|
||||||
|
- **Unsupported:** the catalog has only `unsupported`, `scripted`, or noncombat `utility` payloads, or represents a noncombat summon such as a scouting eye, ritual object, portal, or mount. These remain player-visible when valid but are deliberately excluded from autonomous rotations.
|
||||||
|
- **Rotation defect:** an executable learned ability with combat semantics that cannot be selected in its valid state. Profile-reference tests, shared-policy scenario tests, party damage audits, and the expanded-class audit treat these as failures.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.48",
|
"versionName": "0.1.49",
|
||||||
"versionCode": 1048
|
"versionCode": 1049
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
|
|||||||
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
|
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
|
||||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
|
||||||
import { targetingRulesForAbility } from "../../src/game/abilityRules";
|
import { targetingRulesForAbility } from "../../src/game/abilityRules";
|
||||||
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
|
import { resourceProfileForClass, learnedAbilityForBuild } from "../../src/game/abilityCatalog";
|
||||||
import { deriveCharacterStats } from "../../src/game/wotlkStats";
|
import { deriveCharacterStats } from "../../src/game/wotlkStats";
|
||||||
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
||||||
import { arenaDampening } from "../../server/arena-rules.mjs";
|
import { arenaDampening } from "../../server/arena-rules.mjs";
|
||||||
@@ -36,9 +36,10 @@ export function initializeArenaCharacter(actor: any, now: number) {
|
|||||||
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
|
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
|
||||||
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
|
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
|
||||||
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
|
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
|
||||||
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
|
const abilities = state.abilities.filter((ability: any) => !ability.passive)
|
||||||
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
.map((ability: any) => learnedAbilityForBuild(ability, state.level, state.talentRanks))
|
||||||
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
|
.filter((ability: any) => ability !== null)
|
||||||
|
.map((ability: any) => ({ ...ability, targeting: targetingRulesForAbility(ability) }));
|
||||||
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
|
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
|
||||||
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
|
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
|
||||||
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
|
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
} from "../../src/app/characterCatalog";
|
} from "../../src/app/characterCatalog";
|
||||||
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
||||||
import {
|
import {
|
||||||
abilitiesForClass,
|
abilitiesForCharacter,
|
||||||
abilityAtLevel,
|
learnedAbilityForBuild,
|
||||||
isAbilityUnlocked,
|
|
||||||
preloadAbilityCatalogsForClasses,
|
preloadAbilityCatalogsForClasses,
|
||||||
} from "../../src/game/abilityCatalog";
|
} from "../../src/game/abilityCatalog";
|
||||||
import { aiHintsForAbility } from "../../src/game/abilityRules";
|
import { aiHintsForAbility } from "../../src/game/abilityRules";
|
||||||
@@ -44,7 +43,8 @@ import {
|
|||||||
type PartyMember,
|
type PartyMember,
|
||||||
} from "../../src/game/partyStore";
|
} from "../../src/game/partyStore";
|
||||||
import { specializationsForClass } from "../../src/game/specializationCatalog";
|
import { specializationsForClass } from "../../src/game/specializationCatalog";
|
||||||
import { BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
||||||
|
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
|
||||||
import {
|
import {
|
||||||
loadSelectableBenchmarkBuilds,
|
loadSelectableBenchmarkBuilds,
|
||||||
selectableBenchmarkBuildCatalog,
|
selectableBenchmarkBuildCatalog,
|
||||||
@@ -157,6 +157,8 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
|
|||||||
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
|
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
policyId: BENCHMARK_POLICY_ID,
|
||||||
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||||
level: builds[0]?.level ?? level,
|
level: builds[0]?.level ?? level,
|
||||||
source,
|
source,
|
||||||
builds: builds.map((build) => ({
|
builds: builds.map((build) => ({
|
||||||
@@ -243,14 +245,11 @@ function currentHealth(actorId: string): readonly [number, number] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function availableAbilities(build: BenchmarkBuildDefinition) {
|
function availableAbilities(build: BenchmarkBuildDefinition) {
|
||||||
return abilitiesForClass(build.classId)
|
return abilitiesForCharacter(build.classId, build.secondaryClassId)
|
||||||
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level))
|
.filter((ability) => !ability.passive)
|
||||||
.map((ability) => {
|
.map((ability) => {
|
||||||
const learnedRank = ability.talentEntryId === undefined
|
const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
|
||||||
? undefined
|
if (!ranked) return null;
|
||||||
: build.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
|
||||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
|
||||||
const ranked = abilityAtLevel(ability, build.level, learnedRank);
|
|
||||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||||
return {
|
return {
|
||||||
abilityId: ranked.id,
|
abilityId: ranked.id,
|
||||||
@@ -573,6 +572,8 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
engine: "live-typescript-runtime",
|
engine: "live-typescript-runtime",
|
||||||
|
policyId: BENCHMARK_POLICY_ID,
|
||||||
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
level: builds[0]?.level ?? config.level,
|
level: builds[0]?.level ?? config.level,
|
||||||
|
|||||||
@@ -443,6 +443,8 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": raw["generatedAt"],
|
"generatedAt": raw["generatedAt"],
|
||||||
"engine": raw["engine"],
|
"engine": raw["engine"],
|
||||||
|
"policyId": raw["policyId"],
|
||||||
|
"rotationVersion": raw["rotationVersion"],
|
||||||
"config": raw["config"],
|
"config": raw["config"],
|
||||||
"party": raw["party"],
|
"party": raw["party"],
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -508,6 +510,8 @@ def markdown_report(report: dict[str, Any]) -> str:
|
|||||||
"",
|
"",
|
||||||
f"Generated: {report['generatedAt']}",
|
f"Generated: {report['generatedAt']}",
|
||||||
"",
|
"",
|
||||||
|
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
|
||||||
|
"",
|
||||||
f"Party source: **{config['source']}** · level **{config['level']}** · "
|
f"Party source: **{config['source']}** · level **{config['level']}** · "
|
||||||
f"{report['summary']['bosses']} bosses · {report['summary']['runs']} simulations",
|
f"{report['summary']['bosses']} bosses · {report['summary']['runs']} simulations",
|
||||||
"",
|
"",
|
||||||
@@ -614,7 +618,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
|
|||||||
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
||||||
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
||||||
</style></head><body><h1>Healer Man Balance Lab</h1>
|
</style></head><body><h1>Healer Man Balance Lab</h1>
|
||||||
<p class="summary">{html.escape(report['generatedAt'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p>
|
<p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p>
|
||||||
{member_table}{boss_table}
|
{member_table}{boss_table}
|
||||||
<script>
|
<script>
|
||||||
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
||||||
@@ -642,9 +646,13 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
|
|||||||
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
|
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
|
||||||
"dps", "bossDps", "hps", "dtps", "deathEvents",
|
"dps", "bossDps", "hps", "dtps", "deathEvents",
|
||||||
))
|
))
|
||||||
run_rows = [{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}} for run in raw["runs"]]
|
run_rows = [{
|
||||||
|
"policyId": raw["policyId"],
|
||||||
|
"rotationVersion": raw["rotationVersion"],
|
||||||
|
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
|
||||||
|
} for run in raw["runs"]]
|
||||||
write_csv(output_dir / "runs.csv", run_rows, (
|
write_csv(output_dir / "runs.csv", run_rows, (
|
||||||
"bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
|
"policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
|
||||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||||
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
|
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
|
||||||
@@ -787,6 +795,8 @@ def suite_markdown(report: dict[str, Any]) -> str:
|
|||||||
"",
|
"",
|
||||||
f"Generated: {report['generatedAt']}",
|
f"Generated: {report['generatedAt']}",
|
||||||
"",
|
"",
|
||||||
|
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
|
||||||
|
"",
|
||||||
f"**{summary['partyConfigurations']} party configurations**, **{summary['bosses']} bosses**, "
|
f"**{summary['partyConfigurations']} party configurations**, **{summary['bosses']} bosses**, "
|
||||||
f"and **{summary['runs']} simulations** at level **{report['level']}**.",
|
f"and **{summary['runs']} simulations** at level **{report['level']}**.",
|
||||||
"",
|
"",
|
||||||
@@ -938,7 +948,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
|
|||||||
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
||||||
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
||||||
</style></head><body><h1>Healer Man Automatic Balance Suite</h1>
|
</style></head><body><h1>Healer Man Automatic Balance Suite</h1>
|
||||||
<p class="summary">{html.escape(report['generatedAt'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p>
|
<p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p>
|
||||||
{class_table}{ability_table}{party_table}{boss_table}
|
{class_table}{ability_table}{party_table}{boss_table}
|
||||||
<script>
|
<script>
|
||||||
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
||||||
@@ -953,13 +963,19 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
|||||||
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||||
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||||
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
policy_ids: set[str] = set()
|
||||||
|
rotation_versions: set[str] = set()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
raw = json.loads(Path(entry["rawPath"]).read_text(encoding="utf-8"))
|
raw = json.loads(Path(entry["rawPath"]).read_text(encoding="utf-8"))
|
||||||
|
policy_ids.add(raw["policyId"])
|
||||||
|
rotation_versions.add(raw["rotationVersion"])
|
||||||
report = entry["report"]
|
report = entry["report"]
|
||||||
runs = raw["runs"]
|
runs = raw["runs"]
|
||||||
compact_runs.extend({
|
compact_runs.extend({
|
||||||
"partyConfigurationId": entry["id"],
|
"partyConfigurationId": entry["id"],
|
||||||
"partySource": entry["source"],
|
"partySource": entry["source"],
|
||||||
|
"policyId": raw["policyId"],
|
||||||
|
"rotationVersion": raw["rotationVersion"],
|
||||||
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
|
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
|
||||||
} for run in runs)
|
} for run in runs)
|
||||||
defeated = [run for run in runs if run["outcome"] == "defeated"]
|
defeated = [run for run in runs if run["outcome"] == "defeated"]
|
||||||
@@ -1057,10 +1073,14 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
|||||||
))
|
))
|
||||||
party_rows.sort(key=lambda row: (row["source"], row["partyConfigurationId"]))
|
party_rows.sort(key=lambda row: (row["source"], row["partyConfigurationId"]))
|
||||||
boss_by_party.sort(key=lambda row: (row["partyConfigurationId"], -row["incomingDtps"], row["bossName"]))
|
boss_by_party.sort(key=lambda row: (row["partyConfigurationId"], -row["incomingDtps"], row["bossName"]))
|
||||||
|
if len(policy_ids) != 1 or len(rotation_versions) != 1:
|
||||||
|
raise RuntimeError("Balance Lab suite mixed combat policy or rotation versions")
|
||||||
report = {
|
report = {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": datetime.now().astimezone().isoformat(),
|
"generatedAt": datetime.now().astimezone().isoformat(),
|
||||||
"engine": "live-typescript-runtime",
|
"engine": "live-typescript-runtime",
|
||||||
|
"policyId": next(iter(policy_ids)),
|
||||||
|
"rotationVersion": next(iter(rotation_versions)),
|
||||||
"level": level,
|
"level": level,
|
||||||
"summary": {
|
"summary": {
|
||||||
"partyConfigurations": len(entries),
|
"partyConfigurations": len(entries),
|
||||||
@@ -1113,7 +1133,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
|||||||
"totalDamageTaken", "deathEvents",
|
"totalDamageTaken", "deathEvents",
|
||||||
))
|
))
|
||||||
write_csv(output_dir / "suite_runs.csv", compact_runs, (
|
write_csv(output_dir / "suite_runs.csv", compact_runs, (
|
||||||
"partyConfigurationId", "partySource", "bossKey", "bossName", "bossCategory", "dungeonId",
|
"partyConfigurationId", "partySource", "policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId",
|
||||||
"dungeonTitle", "seed", "outcome",
|
"dungeonTitle", "seed", "outcome",
|
||||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||||
@@ -1223,6 +1243,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
if automatic:
|
if automatic:
|
||||||
write_json(output_dir / "suite-config.json", {
|
write_json(output_dir / "suite-config.json", {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
|
"policyId": catalog["policyId"],
|
||||||
|
"rotationVersion": catalog["rotationVersion"],
|
||||||
"mode": "automatic",
|
"mode": "automatic",
|
||||||
"source": args.source,
|
"source": args.source,
|
||||||
"level": args.level,
|
"level": args.level,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
loadCanonicalBenchmarkBuilds,
|
loadCanonicalBenchmarkBuilds,
|
||||||
} from "../../src/game/benchmark/benchmarkBuilds";
|
} from "../../src/game/benchmark/benchmarkBuilds";
|
||||||
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
||||||
|
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
|
||||||
import {
|
import {
|
||||||
renderBenchmarkMatrixMarkdown,
|
renderBenchmarkMatrixMarkdown,
|
||||||
serializeBenchmarkMatrixCsv,
|
serializeBenchmarkMatrixCsv,
|
||||||
@@ -154,6 +155,7 @@ async function main(): Promise<void> {
|
|||||||
seeds: options.seeds,
|
seeds: options.seeds,
|
||||||
durationScale: options.durationScale,
|
durationScale: options.durationScale,
|
||||||
policyId: BENCHMARK_POLICY_ID,
|
policyId: BENCHMARK_POLICY_ID,
|
||||||
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||||
},
|
},
|
||||||
scenarios,
|
scenarios,
|
||||||
builds,
|
builds,
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ const CLASS_SOURCES = Object.freeze({
|
|||||||
druid: { classMask: 1024, skillLines: [574, 134, 573] },
|
druid: { classMask: 1024, skillLines: [574, 134, 573] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CLASS_BY_TALENT_TAB = new Map([
|
||||||
|
[161, "warrior"], [164, "warrior"], [163, "warrior"],
|
||||||
|
[382, "paladin"], [383, "paladin"], [381, "paladin"],
|
||||||
|
[361, "hunter"], [363, "hunter"], [362, "hunter"],
|
||||||
|
[182, "rogue"], [181, "rogue"], [183, "rogue"],
|
||||||
|
[201, "priest"], [202, "priest"], [203, "priest"],
|
||||||
|
[398, "death-knight"], [399, "death-knight"], [400, "death-knight"],
|
||||||
|
[261, "shaman"], [263, "shaman"], [262, "shaman"],
|
||||||
|
[81, "mage"], [41, "mage"], [61, "mage"],
|
||||||
|
[302, "warlock"], [303, "warlock"], [301, "warlock"],
|
||||||
|
[283, "druid"], [281, "druid"], [282, "druid"],
|
||||||
|
]);
|
||||||
|
|
||||||
// Triggered subspells can appear on a class skill line even though the player
|
// Triggered subspells can appear on a class skill line even though the player
|
||||||
// never learns them directly. Their owning active spell/talent remains present.
|
// never learns them directly. Their owning active spell/talent remains present.
|
||||||
const TRIGGERED_ONLY_NAMES = new Set([
|
const TRIGGERED_ONLY_NAMES = new Set([
|
||||||
@@ -202,16 +215,19 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
|
|||||||
return spells;
|
return spells;
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentSpellIds(buffer) {
|
function talentNodesBySpellId(buffer) {
|
||||||
assertDbc(buffer, "Talent.dbc", 23);
|
assertDbc(buffer, "Talent.dbc", 23);
|
||||||
const count = buffer.readUInt32LE(4);
|
const count = buffer.readUInt32LE(4);
|
||||||
const recordSize = buffer.readUInt32LE(12);
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
const result = new Set();
|
const result = new Map();
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const base = 20 + index * recordSize;
|
const base = 20 + index * recordSize;
|
||||||
|
const talentNodeId = `talent-${buffer.readUInt32LE(base)}`;
|
||||||
|
const classId = CLASS_BY_TALENT_TAB.get(buffer.readUInt32LE(base + 4));
|
||||||
|
if (!classId) continue;
|
||||||
for (let rank = 0; rank < 9; rank += 1) {
|
for (let rank = 0; rank < 9; rank += 1) {
|
||||||
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
|
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
|
||||||
if (spellId) result.add(spellId);
|
if (spellId) result.set(spellId, { talentNodeId, classId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -271,7 +287,10 @@ function slimRank(spell) {
|
|||||||
castTimeMs: spell.castTimeMs,
|
castTimeMs: spell.castTimeMs,
|
||||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
||||||
gcdMs: spell.startRecoveryTimeMs || 1500,
|
gcdMs: spell.startRecoveryTimeMs || 1500,
|
||||||
durationMs: Math.abs(spell.durationMs),
|
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
|
||||||
|
// Preserve those as zero so the runtime converts them to a non-expiring aura
|
||||||
|
// instead of a one-millisecond aura that the AI immediately recasts.
|
||||||
|
durationMs: Math.max(0, spell.durationMs),
|
||||||
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
|
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
|
||||||
powerType: spell.powerType,
|
powerType: spell.powerType,
|
||||||
powerCost: spell.powerCost,
|
powerCost: spell.powerCost,
|
||||||
@@ -285,6 +304,15 @@ function slimRank(spell) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iconPath(iconBasename) {
|
||||||
|
const candidates = [
|
||||||
|
`/assets/ui/spells/${iconBasename}.png`,
|
||||||
|
`/assets/ui/talents/icons/${iconBasename}.png`,
|
||||||
|
];
|
||||||
|
return candidates.find((candidate) => fs.existsSync(path.join(projectRoot, "public", candidate.slice(1))))
|
||||||
|
?? "/assets/ui/spells/inv_misc_questionmark.png";
|
||||||
|
}
|
||||||
|
|
||||||
const archive = MpqArchive.open(archivePath);
|
const archive = MpqArchive.open(archivePath);
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -300,7 +328,8 @@ try {
|
|||||||
rangeTable(extract("SpellRange")),
|
rangeTable(extract("SpellRange")),
|
||||||
stringTable(extract("SpellIcon")),
|
stringTable(extract("SpellIcon")),
|
||||||
);
|
);
|
||||||
const talentIds = talentSpellIds(talentBuffer);
|
const talentNodeBySpellId = talentNodesBySpellId(talentBuffer);
|
||||||
|
const talentIds = new Set(talentNodeBySpellId.keys());
|
||||||
const passiveTalentNames = new Set(
|
const passiveTalentNames = new Set(
|
||||||
[...talentIds]
|
[...talentIds]
|
||||||
.map((spellId) => spells.get(spellId))
|
.map((spellId) => spells.get(spellId))
|
||||||
@@ -309,19 +338,24 @@ try {
|
|||||||
);
|
);
|
||||||
const classes = {};
|
const classes = {};
|
||||||
for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
|
for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
|
||||||
const selected = [...classSpellIds(skillLineBuffer, source)]
|
const classSpellSet = classSpellIds(skillLineBuffer, source);
|
||||||
.map((spellId) => spells.get(spellId))
|
const activeTalentSpellIds = new Set(
|
||||||
|
[...talentNodeBySpellId.entries()]
|
||||||
|
.filter(([, owner]) => owner.classId === classId)
|
||||||
|
.map(([spellId]) => spellId),
|
||||||
|
);
|
||||||
|
const selected = [...spells.values()]
|
||||||
.filter((spell) => (
|
.filter((spell) => (
|
||||||
spell
|
spell
|
||||||
&& spell.name
|
&& spell.name
|
||||||
&& spell.spellLevel > 0
|
&& (classSpellSet.has(spell.id) || activeTalentSpellIds.has(spell.id))
|
||||||
|
&& (spell.spellLevel > 0 || talentIds.has(spell.id))
|
||||||
&& spell.spellLevel <= 80
|
&& spell.spellLevel <= 80
|
||||||
&& (spell.attributes[0] & 0x40) === 0
|
&& (spell.attributes[0] & 0x40) === 0
|
||||||
));
|
));
|
||||||
const talentNames = new Set(selected.filter((spell) => talentIds.has(spell.id)).map((spell) => spell.name));
|
|
||||||
const chains = new Map();
|
const chains = new Map();
|
||||||
for (const spell of selected) {
|
for (const spell of selected) {
|
||||||
if (talentNames.has(spell.name) || TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
|
if (TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
|
||||||
const ranks = chains.get(spell.name) ?? [];
|
const ranks = chains.get(spell.name) ?? [];
|
||||||
ranks.push(spell);
|
ranks.push(spell);
|
||||||
chains.set(spell.name, ranks);
|
chains.set(spell.name, ranks);
|
||||||
@@ -334,6 +368,9 @@ try {
|
|||||||
|| left.id - right.id
|
|| left.id - right.id
|
||||||
));
|
));
|
||||||
const first = ranks[0];
|
const first = ranks[0];
|
||||||
|
const talentNodeId = chainSpells
|
||||||
|
.map((spell) => talentNodeBySpellId.get(spell.id)?.talentNodeId)
|
||||||
|
.find(Boolean);
|
||||||
// Passive talents sometimes publish a separate, non-passive proc spell
|
// Passive talents sometimes publish a separate, non-passive proc spell
|
||||||
// with the same name on the class skill line. That proc has no player
|
// with the same name on the class skill line. That proc has no player
|
||||||
// activation mechanics, so retain it for the spellbook but classify it
|
// activation mechanics, so retain it for the spellbook but classify it
|
||||||
@@ -349,8 +386,10 @@ try {
|
|||||||
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
||||||
name,
|
name,
|
||||||
iconBasename: first.iconBasename,
|
iconBasename: first.iconBasename,
|
||||||
sourceUnlockLevel: Math.min(...ranks.map((spell) => spell.spellLevel)),
|
iconPath: iconPath(first.iconBasename),
|
||||||
|
sourceUnlockLevel: Math.max(1, Math.min(...ranks.map((spell) => spell.spellLevel))),
|
||||||
...(passive ? { passive: true } : {}),
|
...(passive ? { passive: true } : {}),
|
||||||
|
...(talentNodeId ? { talentNodeId } : {}),
|
||||||
ranks: ranks.map(slimRank),
|
ranks: ranks.map(slimRank),
|
||||||
};
|
};
|
||||||
}).sort((left, right) => (
|
}).sort((left, right) => (
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
|||||||
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
|
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await capture('03-follow-selected-member');
|
await capture('03-follow-selected-member');
|
||||||
|
const beforeFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
|
await page.keyboard.down('ArrowLeft');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.up('ArrowLeft');
|
||||||
|
const afterFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
|
assert.notEqual(afterFollowArrowLook.yaw, beforeFollowArrowLook.yaw, 'ArrowLeft orbits the follow camera');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Free camera' }).click();
|
await page.getByRole('button', { name: 'Free camera' }).click();
|
||||||
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
|
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
|
||||||
@@ -110,6 +116,12 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
|||||||
await page.waitForTimeout(150);
|
await page.waitForTimeout(150);
|
||||||
const afterFlight = await captureCanvas();
|
const afterFlight = await captureCanvas();
|
||||||
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
|
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
|
||||||
|
const beforeArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
|
await page.keyboard.down('ArrowRight');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.up('ArrowRight');
|
||||||
|
const afterArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
|
assert.notEqual(afterArrowLook.yaw, beforeArrowLook.yaw, 'ArrowRight turns the free camera');
|
||||||
const canvasBounds = await page.locator('canvas').boundingBox();
|
const canvasBounds = await page.locator('canvas').boundingBox();
|
||||||
assert(canvasBounds, 'simulation canvas is visible');
|
assert(canvasBounds, 'simulation canvas is visible');
|
||||||
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
|
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const directory = path.resolve('playtest-artifacts', 'rotation-overhaul');
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const databaseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rotation-playtest-'));
|
||||||
|
const partySeed = 'rotation-boss-test-0';
|
||||||
|
const bossName = 'Lady Anacondra';
|
||||||
|
|
||||||
|
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { createGameServer } = await import('../server/server.mjs');
|
||||||
|
const runtime = createGameServer({ dataDir: databaseDirectory, enableShelvedModes: false });
|
||||||
|
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(90_000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
|
||||||
|
const moduleUrls = fs.readdirSync('dist/assets')
|
||||||
|
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
|
||||||
|
.map((file) => `/assets/${file}`);
|
||||||
|
await page.addInitScript((urls) => {
|
||||||
|
const runtimeStores = async () => {
|
||||||
|
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
|
||||||
|
const stores = values.filter((value) => value && typeof value.getState === 'function');
|
||||||
|
const partyStore = stores.find((store) => {
|
||||||
|
const state = store.getState();
|
||||||
|
return Array.isArray(state?.members) && typeof state?.initializeParty === 'function'
|
||||||
|
&& typeof state?.setCommand === 'function';
|
||||||
|
});
|
||||||
|
const combatStore = stores.find((store) => {
|
||||||
|
const state = store.getState();
|
||||||
|
return state?.mobs && state?.actors && Array.isArray(state?.combatEvents)
|
||||||
|
&& typeof state?.synchronizeActors === 'function';
|
||||||
|
});
|
||||||
|
const gameStore = stores.find((store) => {
|
||||||
|
const state = store.getState();
|
||||||
|
return 'bossTest' in (state ?? {}) && 'activeDungeonId' in (state ?? {});
|
||||||
|
});
|
||||||
|
if (!partyStore || !combatStore || !gameStore) throw new Error('Required runtime stores were not found.');
|
||||||
|
return { partyStore, combatStore, gameStore };
|
||||||
|
};
|
||||||
|
|
||||||
|
window.configureRotationBossParty = async (seed) => {
|
||||||
|
const { partyStore, combatStore } = await runtimeStores();
|
||||||
|
const combat = combatStore.getState();
|
||||||
|
partyStore.getState().initializeParty(seed, combat.level, combat.health, 'damage', 5, 'wow');
|
||||||
|
partyStore.getState().setCommand('stop');
|
||||||
|
combatStore.getState().synchronizeActors();
|
||||||
|
combatStore.getState().applyDefaultPartyBuffs();
|
||||||
|
combatStore.getState().applyPrecombatPreparation();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.startRotationBossParty = async () => {
|
||||||
|
const { partyStore } = await runtimeStores();
|
||||||
|
partyStore.getState().setCommand('attack');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.readRotationPlaytestState = async () => {
|
||||||
|
const { partyStore, combatStore, gameStore } = await runtimeStores();
|
||||||
|
const party = partyStore.getState();
|
||||||
|
const combat = combatStore.getState();
|
||||||
|
const game = gameStore.getState();
|
||||||
|
const runtimeIds = game.bossTest?.runtimeIds ?? [];
|
||||||
|
const boss = runtimeIds.map((id) => combat.mobs[id]).find(Boolean)
|
||||||
|
?? Object.values(combat.mobs).find((mob) => mob.boss);
|
||||||
|
const currentActorIds = new Set(['__player__', ...party.members.map((member) => member.id)]);
|
||||||
|
return {
|
||||||
|
activeDungeonId: game.activeDungeonId,
|
||||||
|
boss: boss ? {
|
||||||
|
id: boss.id,
|
||||||
|
name: boss.name,
|
||||||
|
health: boss.health,
|
||||||
|
maxHealth: boss.maxHealth,
|
||||||
|
engaged: boss.engaged,
|
||||||
|
dead: boss.dead,
|
||||||
|
} : null,
|
||||||
|
party: party.members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
classId: member.classId,
|
||||||
|
specialization: member.specialization,
|
||||||
|
role: member.role,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
status: member.status,
|
||||||
|
})),
|
||||||
|
auras: combat.auras.filter((aura) => currentActorIds.has(aura.targetId)).map((aura) => ({
|
||||||
|
name: aura.definition.name,
|
||||||
|
exclusiveGroup: aura.definition.exclusiveGroup ?? null,
|
||||||
|
sourceId: aura.sourceId,
|
||||||
|
targetId: aura.targetId,
|
||||||
|
expiresAt: aura.expiresAt,
|
||||||
|
})),
|
||||||
|
summons: combat.summons.filter((summon) => currentActorIds.has(summon.ownerActorId)).map((summon) => ({
|
||||||
|
name: summon.name,
|
||||||
|
kind: summon.kind,
|
||||||
|
ownerActorId: summon.ownerActorId,
|
||||||
|
sourceAbilityId: summon.sourceAbilityId,
|
||||||
|
expiresAt: summon.expiresAt,
|
||||||
|
})),
|
||||||
|
events: combat.combatEvents.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
kind: event.kind,
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
targetActorId: event.targetActorId,
|
||||||
|
abilityId: event.abilityId,
|
||||||
|
effectiveAmount: event.effectiveAmount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}, moduleUrls);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto('http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.getByLabel('Username').fill('Phenom');
|
||||||
|
await page.getByLabel('Password').fill('rotation-overhaul-boss-run');
|
||||||
|
await page.getByRole('button', { name: /Create account/ }).click();
|
||||||
|
await page.getByRole('button', { name: /Create character/ }).click();
|
||||||
|
await page.getByLabel('Specialization').getByRole('button', { name: /Shadow/ }).click();
|
||||||
|
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||||
|
await page.getByLabel('Character name').fill('Rotationwatcher');
|
||||||
|
await page.getByRole('button', { name: /Create character/ }).click();
|
||||||
|
await page.getByRole('button', { name: /Continue/ }).first().click();
|
||||||
|
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Boss Testing' }).waitFor();
|
||||||
|
await page.getByLabel('Search bosses').fill(bossName);
|
||||||
|
const bossPicker = page.locator('.boss-testing-picker select');
|
||||||
|
await bossPicker.selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: 'Test selected boss' }).click();
|
||||||
|
await page.locator('canvas').waitFor();
|
||||||
|
await page.waitForTimeout(3_000);
|
||||||
|
await page.evaluate((seed) => window.configureRotationBossParty(seed), partySeed);
|
||||||
|
|
||||||
|
const initial = await page.evaluate(() => window.readRotationPlaytestState());
|
||||||
|
assert.equal(initial.boss?.name, bossName, 'the requested boss is active in Boss Testing');
|
||||||
|
assert.equal(initial.party.length, 4, 'the player has a complete four-companion party');
|
||||||
|
assert(initial.party.some((member) => member.role === 'tank'), 'party includes a tank');
|
||||||
|
assert(initial.party.some((member) => member.role === 'healer'), 'party includes a healer');
|
||||||
|
const damage = initial.party.filter((member) => member.role === 'damage');
|
||||||
|
const ranged = damage.filter((member) => (
|
||||||
|
['hunter', 'mage', 'priest', 'warlock'].includes(member.classId)
|
||||||
|
|| (member.classId === 'druid' && member.specialization === 'Balance')
|
||||||
|
|| (member.classId === 'shaman' && member.specialization === 'Elemental')
|
||||||
|
));
|
||||||
|
const melee = damage.filter((member) => !ranged.some((candidate) => candidate.id === member.id));
|
||||||
|
assert(ranged.length > 0, `party includes ranged damage: ${JSON.stringify(damage)}`);
|
||||||
|
assert(melee.length > 0, `party includes melee damage: ${JSON.stringify(damage)}`);
|
||||||
|
const paladin = initial.party.find((member) => member.classId === 'paladin');
|
||||||
|
assert(paladin, `party includes the expected paladin setup check: ${JSON.stringify(initial.party)}`);
|
||||||
|
assert(initial.auras.some((aura) => (
|
||||||
|
aura.targetId === paladin.id && aura.exclusiveGroup === 'paladin:seal'
|
||||||
|
)), `paladin seal is active before the attack command: ${JSON.stringify(initial.auras)}`);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.startRotationBossParty());
|
||||||
|
|
||||||
|
const eventKeys = new Set(initial.events.map((event) => `${event.id}:${event.sourceActorId}:${event.abilityId}`));
|
||||||
|
const observedEvents = [];
|
||||||
|
const collect = (state) => {
|
||||||
|
for (const event of state.events) {
|
||||||
|
const key = `${event.id}:${event.sourceActorId}:${event.abilityId}`;
|
||||||
|
if (eventKeys.has(key)) continue;
|
||||||
|
eventKeys.add(key);
|
||||||
|
observedEvents.push(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let engaged = null;
|
||||||
|
const engagementDeadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < engagementDeadline) {
|
||||||
|
const state = await page.evaluate(() => window.readRotationPlaytestState());
|
||||||
|
collect(state);
|
||||||
|
if (state.boss?.engaged && !state.boss.dead && state.boss.health < state.boss.maxHealth) {
|
||||||
|
engaged = state;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
assert(engaged, 'the companion party engages Lady Anacondra');
|
||||||
|
await page.screenshot({ path: path.join(directory, 'boss-engaged.png'), fullPage: true });
|
||||||
|
const canvasImage = await page.locator('canvas').screenshot({ path: path.join(directory, 'boss-engaged-canvas.png') });
|
||||||
|
assert(canvasImage.length > 10_000, 'boss screenshot contains a rendered WebGL scene');
|
||||||
|
|
||||||
|
let completed = null;
|
||||||
|
const completionDeadline = Date.now() + 90_000;
|
||||||
|
while (Date.now() < completionDeadline) {
|
||||||
|
const state = await page.evaluate(() => window.readRotationPlaytestState());
|
||||||
|
collect(state);
|
||||||
|
if (state.boss?.dead || state.boss?.health === 0) {
|
||||||
|
completed = state;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
assert(completed, 'the companion party defeats Lady Anacondra');
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
|
||||||
|
|
||||||
|
const partyIds = new Set(initial.party.map((member) => member.id));
|
||||||
|
const partyEvents = observedEvents.filter((event) => partyIds.has(event.sourceActorId));
|
||||||
|
for (const member of [...initial.party.filter((candidate) => candidate.role === 'tank'), ...melee, ...ranged]) {
|
||||||
|
assert(partyEvents.some((event) => event.sourceActorId === member.id && event.kind === 'damage'
|
||||||
|
&& event.abilityId && event.abilityId !== 'party-auto-attack'), `${member.name} uses a damaging ability`);
|
||||||
|
}
|
||||||
|
for (const healer of initial.party.filter((member) => member.role === 'healer')) {
|
||||||
|
assert(partyEvents.some((event) => event.sourceActorId === healer.id
|
||||||
|
&& ['damage', 'healing'].includes(event.kind) && event.abilityId), `${healer.name} takes an active combat action`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
dungeon: 'Wailing Caverns — Boss Testing',
|
||||||
|
boss: bossName,
|
||||||
|
partySeed,
|
||||||
|
engagedBoss: engaged.boss,
|
||||||
|
defeatedBoss: completed.boss,
|
||||||
|
party: initial.party,
|
||||||
|
precombatAuras: initial.auras,
|
||||||
|
precombatSummons: initial.summons,
|
||||||
|
observedAbilityEvents: partyEvents.filter((event) => event.abilityId !== 'party-auto-attack'),
|
||||||
|
browserErrors: errors,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
console.log('Rotation-overhaul dungeon boss playtest passed.');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolve) => runtime.server.close(resolve));
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
normalizeManastormProgress,
|
normalizeManastormProgress,
|
||||||
type ManastormProgress,
|
type ManastormProgress,
|
||||||
} from "../game/manastormProgress";
|
} from "../game/manastormProgress";
|
||||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
import { abilitiesForClass, abilityTalentNodeId } from "../game/abilityCatalog";
|
||||||
import {
|
import {
|
||||||
normalizeSpecializationId,
|
normalizeSpecializationId,
|
||||||
specializationById,
|
specializationById,
|
||||||
@@ -576,7 +576,7 @@ export function updateCharacterSpecialization(
|
|||||||
}
|
}
|
||||||
const talentAbilityIds = resetTalents
|
const talentAbilityIds = resetTalents
|
||||||
? new Set(abilitiesForClass(character.classId)
|
? new Set(abilitiesForClass(character.classId)
|
||||||
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
|
.filter((ability) => abilityTalentNodeId(ability) !== null || ability.source === "coa-tree")
|
||||||
.map((ability) => ability.id))
|
.map((ability) => ability.id))
|
||||||
: new Set<string>();
|
: new Set<string>();
|
||||||
const actionBindings = resetTalents
|
const actionBindings = resetTalents
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function PartyBridge() {
|
|||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
combat.applyDefaultPartyBuffs();
|
combat.applyDefaultPartyBuffs();
|
||||||
|
combat.applyPrecombatPreparation();
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export function PartyBridge() {
|
|||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
combat.applyDefaultPartyBuffs();
|
combat.applyDefaultPartyBuffs();
|
||||||
|
combat.applyPrecombatPreparation();
|
||||||
}, [level]);
|
}, [level]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +67,7 @@ export function PartyBridge() {
|
|||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
combat.applyDefaultPartyBuffs();
|
combat.applyDefaultPartyBuffs();
|
||||||
|
combat.applyPrecombatPreparation();
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [resetRevision]);
|
}, [resetRevision]);
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("baseline ability catalog", () => {
|
|||||||
for (const ability of abilities) {
|
for (const ability of abilities) {
|
||||||
expect(ability.classId).toBe(characterClass.id);
|
expect(ability.classId).toBe(characterClass.id);
|
||||||
expect(ability.dbcSpellId).toBeGreaterThan(0);
|
expect(ability.dbcSpellId).toBeGreaterThan(0);
|
||||||
expect(ability.icon).toMatch(/^\/assets\/ui\/spells\/[a-z0-9_()' -]+\.png$/);
|
expect(ability.icon).toMatch(/^\/assets\/ui\/(?:spells|talents\/icons)\/[a-z0-9_()' -]+\.png$/);
|
||||||
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
||||||
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
||||||
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ export interface AbilityAiHints {
|
|||||||
| "resurrection"
|
| "resurrection"
|
||||||
| "control"
|
| "control"
|
||||||
| "debuff"
|
| "debuff"
|
||||||
|
| "buff"
|
||||||
|
| "resource"
|
||||||
|
| "summon"
|
||||||
|
| "pet"
|
||||||
|
| "movement"
|
||||||
|
| "threat"
|
||||||
|
| "area-damage"
|
||||||
)[];
|
)[];
|
||||||
readonly priority?: number;
|
readonly priority?: number;
|
||||||
}
|
}
|
||||||
@@ -293,6 +300,9 @@ export interface AbilityDefinition {
|
|||||||
/** The original client level before Healer Man's starter-kit remapping. */
|
/** The original client level before Healer Man's starter-kit remapping. */
|
||||||
readonly sourceUnlockLevel?: number;
|
readonly sourceUnlockLevel?: number;
|
||||||
readonly ranks?: readonly AbilityRankDefinition[];
|
readonly ranks?: readonly AbilityRankDefinition[];
|
||||||
|
/** Runtime talent node that must be learned before this ability is available. */
|
||||||
|
readonly talentNodeId?: string;
|
||||||
|
/** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */
|
||||||
readonly talentEntryId?: number;
|
readonly talentEntryId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,10 +963,51 @@ export function abilityRankForLevel(
|
|||||||
?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1]
|
?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1]
|
||||||
?? ability.ranks[0];
|
?? ability.ranks[0];
|
||||||
}
|
}
|
||||||
return [...ability.ranks]
|
const eligible = [...ability.ranks]
|
||||||
.filter((rank) => rank.level <= playerLevel)
|
.filter((rank) => rank.level <= playerLevel)
|
||||||
.sort((left, right) => right.rank - left.rank)[0]
|
.sort((left, right) => right.rank - left.rank);
|
||||||
?? ability.ranks[0];
|
const highestRank = eligible[0]?.rank;
|
||||||
|
if (highestRank === undefined) return ability.ranks[0];
|
||||||
|
const candidates = eligible.filter((rank) => rank.rank === highestRank);
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
// Several Wrath spell-name chains contain both the player-facing rank and a
|
||||||
|
// same-rank triggered child (for example Lightning Shield's retaliation).
|
||||||
|
// Preserve the structural effect family of the chain's root spell so a
|
||||||
|
// triggered damage child cannot replace the executable aura at later ranks.
|
||||||
|
const effectStructure = (effects: readonly AbilityEffect[]): string => effects.map((effect) => (
|
||||||
|
effect.kind === "apply-aura"
|
||||||
|
? [effect.kind, effect.sourceEffectType, effect.sourceAuraType].join(":")
|
||||||
|
: effect.kind
|
||||||
|
)).join("|");
|
||||||
|
const rootStructure = effectStructure(ability.effects);
|
||||||
|
return candidates.find((rank) => effectStructure(rank.effects) === rootStructure) ?? candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilityTalentNodeId(ability: AbilityDefinition): string | null {
|
||||||
|
return ability.talentNodeId
|
||||||
|
?? (ability.talentEntryId === undefined ? null : `coa-entry-${ability.talentEntryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function learnedAbilityRank(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
talentRanks: Readonly<Record<string, number>>,
|
||||||
|
): number | undefined {
|
||||||
|
const nodeId = abilityTalentNodeId(ability);
|
||||||
|
return nodeId ? Math.max(0, Math.trunc(talentRanks[nodeId] ?? 0)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function learnedAbilityForBuild(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
playerLevel: number,
|
||||||
|
talentRanks: Readonly<Record<string, number>>,
|
||||||
|
): AbilityDefinition | null {
|
||||||
|
if (!isAbilityUnlocked(ability, playerLevel)) return null;
|
||||||
|
const nodeId = abilityTalentNodeId(ability);
|
||||||
|
const learnedRank = learnedAbilityRank(ability, talentRanks);
|
||||||
|
if (nodeId && !learnedRank) return null;
|
||||||
|
// CoA tree entries use the allocated talent rank as the spell rank. Classic
|
||||||
|
// active talents continue to learn their later spell ranks by character level.
|
||||||
|
return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function abilityAtLevel(
|
export function abilityAtLevel(
|
||||||
@@ -1040,7 +1091,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|
|||||||
.map(({ ability }) => ability)
|
.map(({ ability }) => ability)
|
||||||
.filter((ability) => (
|
.filter((ability) => (
|
||||||
!ability.passive
|
!ability.passive
|
||||||
&& ability.source !== "coa-tree"
|
&& !abilityTalentNodeId(ability)
|
||||||
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
|
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
|
||||||
))
|
))
|
||||||
.map((ability) => ability.id);
|
.map((ability) => ability.id);
|
||||||
@@ -1065,6 +1116,7 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
|
|||||||
.filter((ability) => (
|
.filter((ability) => (
|
||||||
!ability.passive
|
!ability.passive
|
||||||
&& ability.unlockLevel <= level
|
&& ability.unlockLevel <= level
|
||||||
|
&& !abilityTalentNodeId(ability)
|
||||||
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
|
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
|
||||||
))
|
))
|
||||||
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name))
|
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name))
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker'
|
|||||||
const EMERGENCY_HEAL_ABILITIES = new Set([
|
const EMERGENCY_HEAL_ABILITIES = new Set([
|
||||||
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
||||||
]);
|
]);
|
||||||
|
const AREA_HEAL_ABILITIES = new Set([
|
||||||
|
"chain heal", "circle of healing", "divine hymn", "holy nova", "prayer of healing",
|
||||||
|
"prayer of mending", "tranquility", "wild growth",
|
||||||
|
]);
|
||||||
|
|
||||||
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
|
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
|
||||||
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
|
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
|
||||||
@@ -97,8 +101,31 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
|||||||
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
|
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
|
||||||
}
|
}
|
||||||
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
|
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
|
||||||
|
if (effect.kind === "apply-aura" && effect.aura.disposition === "buff") categories.add("buff");
|
||||||
|
if (effect.kind === "apply-aura" && (
|
||||||
|
effect.aura.absorbs?.length
|
||||||
|
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||||
|
&& modifier.direction === "taken" && modifier.value < 0)
|
||||||
|
)) categories.add("mitigation");
|
||||||
|
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
|
||||||
|
modifier.kind === "stat" && modifier.stat === "threat"
|
||||||
|
))) categories.add("threat");
|
||||||
|
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => modifier.kind === "resource")) {
|
||||||
|
categories.add("resource");
|
||||||
|
}
|
||||||
|
if (effect.kind === "resource" || effect.kind === "rune") categories.add("resource");
|
||||||
|
if (effect.kind === "summon" || effect.kind === "totem") categories.add("summon");
|
||||||
|
if (effect.kind === "pet") categories.add("pet");
|
||||||
|
if (effect.kind === "movement" || effect.kind === "pull") categories.add("movement");
|
||||||
|
if (effect.kind === "threat") categories.add("threat");
|
||||||
}
|
}
|
||||||
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
|
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
|
||||||
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal");
|
if (((ability.radius ?? 0) > 0 || AREA_HEAL_ABILITIES.has(name)) && categories.has("heal")) categories.add("area-heal");
|
||||||
|
const targeting = targetingRulesForAbility(ability);
|
||||||
|
if (categories.has("damage") && (
|
||||||
|
targeting.shape !== "unit"
|
||||||
|
|| (targeting.maximumTargets ?? 1) > 1
|
||||||
|
|| (targeting.radius ?? ability.radius ?? 0) > 0
|
||||||
|
)) categories.add("area-damage");
|
||||||
return Object.freeze({ categories: Object.freeze([...categories]) });
|
return Object.freeze({ categories: Object.freeze([...categories]) });
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CharacterProfile } from "../app/types";
|
import type { CharacterProfile } from "../app/types";
|
||||||
import {
|
import {
|
||||||
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass,
|
abilitiesForCharacter, abilityTalentNodeId, defaultActionBarForCharacter, learnedAbilityForBuild, resourceProfileForClass,
|
||||||
type AbilityDefinition, type ClassResourceProfile,
|
type AbilityDefinition, type ClassResourceProfile,
|
||||||
} from "./abilityCatalog";
|
} from "./abilityCatalog";
|
||||||
import { normalizeActionBindings } from "./actionBindings";
|
import { normalizeActionBindings } from "./actionBindings";
|
||||||
@@ -11,13 +11,13 @@ import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equip
|
|||||||
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
|
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
|
||||||
return abilitiesForCharacter(character.classId, character.secondaryClassId)
|
return abilitiesForCharacter(character.classId, character.secondaryClassId)
|
||||||
.filter((ability) => !ability.passive)
|
.filter((ability) => !ability.passive)
|
||||||
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined
|
.map((ability) => learnedAbilityForBuild(ability, level, character.talentRanks))
|
||||||
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`]));
|
.filter((ability): ability is AbilityDefinition => ability !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
|
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
|
||||||
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined
|
return ability.unlockLevel <= level && (!abilityTalentNodeId(ability)
|
||||||
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0);
|
|| learnedAbilityForBuild(ability, level, character.talentRanks) !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
|
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
|
||||||
@@ -28,7 +28,7 @@ export function createArenaCharacterKit(character: CharacterProfile, level = cha
|
|||||||
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
|
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
|
||||||
const actionBindings = normalizeActionBindings(character.actionBindings, [
|
const actionBindings = normalizeActionBindings(character.actionBindings, [
|
||||||
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
|
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
|
||||||
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id),
|
...all.filter((ability) => abilityTalentNodeId(ability) && !ability.passive).map((ability) => ability.id),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
|
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import baseStatsData from "./generated/wow335BaseStats.json";
|
|||||||
import {
|
import {
|
||||||
creatureRuntimeBaseStats,
|
creatureRuntimeBaseStats,
|
||||||
creatureTemplateReference,
|
creatureTemplateReference,
|
||||||
|
standardWowDungeonCreatureHealth,
|
||||||
} from "./azerothCoreReference";
|
} from "./azerothCoreReference";
|
||||||
|
|
||||||
interface PlayerClassRow {
|
interface PlayerClassRow {
|
||||||
@@ -110,6 +111,7 @@ describe("pinned AzerothCore reference", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("derives representative normal, elite, and boss stats at multiple levels", () => {
|
it("derives representative normal, elite, and boss stats at multiple levels", () => {
|
||||||
|
expect(standardWowDungeonCreatureHealth(5)).toBe(306);
|
||||||
expect(creatureRuntimeBaseStats(385, 5)).toMatchObject({
|
expect(creatureRuntimeBaseStats(385, 5)).toMatchObject({
|
||||||
health: 102,
|
health: 102,
|
||||||
armor: 111,
|
armor: 111,
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ function classStatsAt(level: number, unitClass: number): CreatureClassLevelRefer
|
|||||||
return REFERENCE.creature.classLevelStats[`${safe}:1`] ?? null;
|
return REFERENCE.creature.classLevelStats[`${safe}:1`] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the health of a standard same-level WoW dungeon elite. The pinned
|
||||||
|
* AzerothCore class-level table supplies the level curve while the x3 modifier
|
||||||
|
* matches the ordinary elite templates used by opening dungeons such as the
|
||||||
|
* Stockade. Expansion bands follow normal WoW character progression so this
|
||||||
|
* baseline can also be used by creatures imported from another game.
|
||||||
|
*/
|
||||||
|
export function standardWowDungeonCreatureHealth(level: number): number {
|
||||||
|
const safe = safeLevel(level);
|
||||||
|
const stats = classStatsAt(safe, 1);
|
||||||
|
if (!stats) return Math.max(1, 72 + safe * 22);
|
||||||
|
const expansion = safe > 70 ? 2 : safe > 60 ? 1 : 0;
|
||||||
|
return Math.max(1, Math.ceil(stats.baseHealth[expansion] * 3));
|
||||||
|
}
|
||||||
|
|
||||||
export function creatureTemplateReference(entry: number): CreatureTemplateReference | null {
|
export function creatureTemplateReference(entry: number): CreatureTemplateReference | null {
|
||||||
if (!Number.isInteger(entry) || entry <= 0) return null;
|
if (!Number.isInteger(entry) || entry <= 0) return null;
|
||||||
return REFERENCE.creature.templates[String(entry)] ?? null;
|
return REFERENCE.creature.templates[String(entry)] ?? null;
|
||||||
@@ -165,4 +180,3 @@ export function creatureRuntimeBaseStats(
|
|||||||
schoolImmunities: template.schoolImmunities,
|
schoolImmunities: template.schoolImmunities,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../app/characterCatalog";
|
} from "../../app/characterCatalog";
|
||||||
import {
|
import {
|
||||||
abilitiesForClass,
|
abilitiesForClass,
|
||||||
|
abilityTalentNodeId,
|
||||||
preloadAbilityCatalogsForClasses,
|
preloadAbilityCatalogsForClasses,
|
||||||
type AbilityAiHints,
|
type AbilityAiHints,
|
||||||
type AbilityDefinition,
|
type AbilityDefinition,
|
||||||
@@ -205,8 +206,8 @@ function isClassTree(tree: TalentUiTree): boolean {
|
|||||||
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
|
||||||
const mutable = new Map<string, AbilityDefinition[]>();
|
const mutable = new Map<string, AbilityDefinition[]>();
|
||||||
for (const ability of abilitiesForClass(classId)) {
|
for (const ability of abilitiesForClass(classId)) {
|
||||||
if (ability.talentEntryId === undefined) continue;
|
const nodeId = abilityTalentNodeId(ability);
|
||||||
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
if (!nodeId) continue;
|
||||||
const existing = mutable.get(nodeId);
|
const existing = mutable.get(nodeId);
|
||||||
if (existing) existing.push(ability);
|
if (existing) existing.push(ability);
|
||||||
else mutable.set(nodeId, [ability]);
|
else mutable.set(nodeId, [ability]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
abilityAtLevel,
|
learnedAbilityForBuild,
|
||||||
isAbilityUnlocked,
|
resourceProfileForClass,
|
||||||
type AbilityDefinition,
|
type AbilityDefinition,
|
||||||
} from "../abilityCatalog";
|
} from "../abilityCatalog";
|
||||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||||
@@ -15,8 +15,9 @@ import type { PartyState } from "../partyStore";
|
|||||||
import { usePartyStore } from "../partyStore";
|
import { usePartyStore } from "../partyStore";
|
||||||
import type { PartyRole } from "../partyRoles";
|
import type { PartyRole } from "../partyRoles";
|
||||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||||
|
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
|
||||||
|
|
||||||
export const BENCHMARK_POLICY_ID = "baseline-v3";
|
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
|
||||||
|
|
||||||
export interface BenchmarkPolicyDecision {
|
export interface BenchmarkPolicyDecision {
|
||||||
readonly made: boolean;
|
readonly made: boolean;
|
||||||
@@ -68,12 +69,9 @@ function friendlyTargets(combat: CombatState, party: PartyState): readonly Frien
|
|||||||
}
|
}
|
||||||
|
|
||||||
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
|
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
|
||||||
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null;
|
if (ability.passive) return null;
|
||||||
const learnedRank = ability.talentEntryId === undefined
|
const ranked = learnedAbilityForBuild(ability, combat.level, combat.talentRanks);
|
||||||
? undefined
|
if (!ranked) return null;
|
||||||
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
|
||||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
|
||||||
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
|
|
||||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||||
return ranked;
|
return ranked;
|
||||||
}
|
}
|
||||||
@@ -195,13 +193,77 @@ export class BenchmarkController {
|
|||||||
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
|
||||||
|
|
||||||
const targets = friendlyTargets(combat, party);
|
const targets = friendlyTargets(combat, party);
|
||||||
const candidates = combat.abilities
|
const availableAbilities = combat.abilities
|
||||||
.map((ability) => learnedAbility(ability, combat))
|
.map((ability) => learnedAbility(ability, combat))
|
||||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
|
||||||
|
const candidates = availableAbilities
|
||||||
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
|
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
|
||||||
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
||||||
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||||
|
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
|
||||||
|
const sharedDecision = evaluateCombatRotation({
|
||||||
|
now,
|
||||||
|
classId: combat.classId,
|
||||||
|
secondaryClassId: combat.secondaryClassId,
|
||||||
|
specializationId: combat.specializationId,
|
||||||
|
role,
|
||||||
|
level: combat.level,
|
||||||
|
health: combat.health,
|
||||||
|
maxHealth: combat.maxHealth,
|
||||||
|
selfId: PLAYER_AGGRO_ID,
|
||||||
|
resources: combat.resourcePools,
|
||||||
|
maximumResources: combat.maxResourcePools,
|
||||||
|
primaryResource: resourceProfileForClass(combat.classId).type,
|
||||||
|
comboPoints: combat.comboPoints,
|
||||||
|
comboTargetId: combat.comboTargetId,
|
||||||
|
runes: combat.deathKnightRunes,
|
||||||
|
abilities: availableAbilities,
|
||||||
|
hostileTarget,
|
||||||
|
friendlyTargets: [
|
||||||
|
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
|
||||||
|
...party.members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
fallen: member.health <= 0,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
hostileCount: scenario.hostileTargets,
|
||||||
|
targetIsLoose: role === "tank" && Boolean(hostileTarget?.targetActorId && hostileTarget.targetActorId !== PLAYER_AGGRO_ID),
|
||||||
|
hasDangerousThreat: role !== "tank" && hostileTarget?.targetActorId === PLAYER_AGGRO_ID,
|
||||||
|
moving,
|
||||||
|
maintainedAbilityIds: new Set([
|
||||||
|
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
|
||||||
|
.map((effect) => effect.sourceAbilityId),
|
||||||
|
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
|
||||||
|
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => availableAbilities
|
||||||
|
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.id === aura.definition.id))
|
||||||
|
.map((ability) => ability.id)),
|
||||||
|
]),
|
||||||
|
activeAuraExclusiveGroups: new Set(combat.auras
|
||||||
|
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
|
||||||
|
activeSummonAbilityIds: new Set(combat.summons
|
||||||
|
.filter((summon) => summon.ownerActorId === PLAYER_AGGRO_ID && summon.expiresAt > now)
|
||||||
|
.map((summon) => summon.sourceAbilityId)),
|
||||||
|
hasActivePet: combat.summons.some((summon) => {
|
||||||
|
if (summon.ownerActorId !== PLAYER_AGGRO_ID || summon.expiresAt <= now) return false;
|
||||||
|
const source = availableAbilities.find((ability) => ability.id === summon.sourceAbilityId);
|
||||||
|
return source?.effects.some((effect) => effect.kind === "pet"
|
||||||
|
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (sharedDecision) {
|
||||||
|
const friendlyTarget = sharedDecision.targetId
|
||||||
|
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
|
||||||
|
: null;
|
||||||
|
const existing = candidates.findIndex((candidate) => candidate.ability.id === sharedDecision.ability.id);
|
||||||
|
if (existing >= 0) candidates.splice(existing, 1);
|
||||||
|
candidates.unshift({ ability: sharedDecision.ability, score: Number.POSITIVE_INFINITY, friendlyTarget });
|
||||||
|
}
|
||||||
|
|
||||||
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
|
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
|
||||||
if (role === "healer" && combat.selectedTargetId
|
if (role === "healer" && combat.selectedTargetId
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
BenchmarkScenarioDefinition,
|
BenchmarkScenarioDefinition,
|
||||||
} from "./benchmarkTypes";
|
} from "./benchmarkTypes";
|
||||||
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
import { BENCHMARK_POLICY_ID } from "./benchmarkController";
|
||||||
|
import { COMBAT_ROTATION_VERSION } from "../combatRotation";
|
||||||
|
|
||||||
interface MutableAbilityMetrics {
|
interface MutableAbilityMetrics {
|
||||||
castsStarted: number;
|
castsStarted: number;
|
||||||
@@ -260,6 +261,7 @@ export class BenchmarkRecorder {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
|
||||||
policyId: BENCHMARK_POLICY_ID,
|
policyId: BENCHMARK_POLICY_ID,
|
||||||
|
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||||
seed: this.#seed,
|
seed: this.#seed,
|
||||||
build: this.#build,
|
build: this.#build,
|
||||||
scenario: this.#scenario,
|
scenario: this.#scenario,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ function matrix(): BenchmarkMatrixReport {
|
|||||||
seeds: [1, 2, 3, 4],
|
seeds: [1, 2, 3, 4],
|
||||||
durationScale: 1,
|
durationScale: 1,
|
||||||
policyId: "baseline-v1",
|
policyId: "baseline-v1",
|
||||||
|
rotationVersion: "playerbots-inspired-v1",
|
||||||
},
|
},
|
||||||
scenarios: [scenario],
|
scenarios: [scenario],
|
||||||
builds: [build],
|
builds: [build],
|
||||||
@@ -150,6 +151,7 @@ describe("benchmark reporting", () => {
|
|||||||
const csv = serializeBenchmarkMatrixCsv(matrix());
|
const csv = serializeBenchmarkMatrixCsv(matrix());
|
||||||
|
|
||||||
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
expect(csv).toContain("scenario_id,scenario_version,scenario_name");
|
||||||
|
expect(csv).toContain("policy_id,rotation_version,scenario_id");
|
||||||
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
|
||||||
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
expect(csv).toContain(",dps,3,0,12,20,28\n");
|
||||||
expect(csv.endsWith("\n")).toBe(true);
|
expect(csv.endsWith("\n")).toBe(true);
|
||||||
@@ -164,6 +166,7 @@ describe("benchmark reporting", () => {
|
|||||||
expect(markdown).toContain("DPS: 20 [12–28]");
|
expect(markdown).toContain("DPS: 20 [12–28]");
|
||||||
expect(markdown).toContain("1 invalid run(s) are excluded");
|
expect(markdown).toContain("1 invalid run(s) are excluded");
|
||||||
expect(markdown).toContain("does not calculate a composite score");
|
expect(markdown).toContain("does not calculate a composite score");
|
||||||
|
expect(markdown).toContain("rotation: `playerbots-inspired-v1`");
|
||||||
expect(markdown.endsWith("\n")).toBe(true);
|
expect(markdown.endsWith("\n")).toBe(true);
|
||||||
expect(markdown.endsWith("\n\n")).toBe(false);
|
expect(markdown.endsWith("\n\n")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
|
|||||||
"seeds",
|
"seeds",
|
||||||
"duration_scale",
|
"duration_scale",
|
||||||
"policy_id",
|
"policy_id",
|
||||||
|
"rotation_version",
|
||||||
"scenario_id",
|
"scenario_id",
|
||||||
"scenario_version",
|
"scenario_version",
|
||||||
"scenario_name",
|
"scenario_name",
|
||||||
@@ -354,6 +355,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
|
|||||||
summary.cohort.seeds.join(";"),
|
summary.cohort.seeds.join(";"),
|
||||||
summary.cohort.durationScale,
|
summary.cohort.durationScale,
|
||||||
summary.cohort.policyId,
|
summary.cohort.policyId,
|
||||||
|
summary.cohort.rotationVersion ?? null,
|
||||||
row.scenarioId,
|
row.scenarioId,
|
||||||
row.scenarioVersion,
|
row.scenarioVersion,
|
||||||
row.scenarioName,
|
row.scenarioName,
|
||||||
@@ -430,7 +432,9 @@ export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): stri
|
|||||||
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
`Generated ${summary.generatedAt} with app ${summary.appVersion}. `
|
||||||
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
|
||||||
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
|
||||||
`Policy: \`${summary.cohort.policyId}\`.`,
|
`Policy: \`${summary.cohort.policyId}\`${summary.cohort.rotationVersion
|
||||||
|
? `; rotation: \`${summary.cohort.rotationVersion}\``
|
||||||
|
: ""}.`,
|
||||||
"",
|
"",
|
||||||
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
`${summary.validRunCount}/${summary.runCount} runs were valid; `
|
||||||
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
BenchmarkRunReport,
|
BenchmarkRunReport,
|
||||||
BenchmarkScenarioDefinition,
|
BenchmarkScenarioDefinition,
|
||||||
} from "./benchmarkTypes";
|
} from "./benchmarkTypes";
|
||||||
|
import { specializationsForClass } from "../specializationCatalog";
|
||||||
|
|
||||||
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
|
||||||
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
const BENCHMARK_MOB_HEALTH = 1_000_000_000;
|
||||||
@@ -274,9 +275,12 @@ export async function runCombatBenchmark(
|
|||||||
let completed = true;
|
let completed = true;
|
||||||
try {
|
try {
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
|
const specializationId = specializationsForClass(build.classId)
|
||||||
|
.find((specialization) => specialization.displayName === build.orientation)?.id;
|
||||||
combat.initializeCharacter({
|
combat.initializeCharacter({
|
||||||
classId: build.classId,
|
classId: build.classId,
|
||||||
secondaryClassId: build.secondaryClassId,
|
secondaryClassId: build.secondaryClassId,
|
||||||
|
specializationId,
|
||||||
level: build.level,
|
level: build.level,
|
||||||
talentRanks: build.talentRanks,
|
talentRanks: build.talentRanks,
|
||||||
inventory: build.inventory,
|
inventory: build.inventory,
|
||||||
@@ -296,6 +300,8 @@ export async function runCombatBenchmark(
|
|||||||
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
const positions = partyPositions(scenario.partyLayout, party.members.length);
|
||||||
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
|
||||||
useCombatStore.getState().synchronizeActors();
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
useCombatStore.getState().applyDefaultPartyBuffs(now);
|
||||||
|
useCombatStore.getState().applyPrecombatPreparation(now);
|
||||||
|
|
||||||
const referenceTank = build.role === "tank"
|
const referenceTank = build.role === "tank"
|
||||||
? { maxHealth: initializedCombat.maxHealth }
|
? { maxHealth: initializedCombat.maxHealth }
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export interface BenchmarkRunReport {
|
|||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
readonly policyId: string;
|
readonly policyId: string;
|
||||||
|
readonly rotationVersion?: string;
|
||||||
readonly seed: number;
|
readonly seed: number;
|
||||||
readonly build: BenchmarkBuildDefinition;
|
readonly build: BenchmarkBuildDefinition;
|
||||||
readonly scenario: BenchmarkScenarioDefinition;
|
readonly scenario: BenchmarkScenarioDefinition;
|
||||||
@@ -149,6 +150,7 @@ export interface BenchmarkMatrixReport {
|
|||||||
readonly seeds: readonly number[];
|
readonly seeds: readonly number[];
|
||||||
readonly durationScale: number;
|
readonly durationScale: number;
|
||||||
readonly policyId: string;
|
readonly policyId: string;
|
||||||
|
readonly rotationVersion?: string;
|
||||||
};
|
};
|
||||||
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
readonly scenarios: readonly BenchmarkScenarioDefinition[];
|
||||||
readonly builds: readonly BenchmarkBuildDefinition[];
|
readonly builds: readonly BenchmarkBuildDefinition[];
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export async function runDungeonBossBenchmark(
|
|||||||
combat.setPlayerPosition([-2, 0, 0]);
|
combat.setPlayerPosition([-2, 0, 0]);
|
||||||
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
|
combat.applyDefaultPartyBuffs(now);
|
||||||
|
combat.applyPrecombatPreparation(now);
|
||||||
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
||||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||||
setMobPosition(bossId, [0, 0, 0]);
|
setMobPosition(bossId, [0, 0, 0]);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe("Boss Testing", () => {
|
|||||||
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
|
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
|
||||||
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
|
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
|
||||||
1, maraudon.defaultDifficultyId, 1);
|
1, maraudon.defaultDifficultyId, 1);
|
||||||
expect(campaign.maxHealth).toBe(900);
|
expect(campaign.maxHealth).toBe(950);
|
||||||
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
||||||
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
||||||
});
|
});
|
||||||
@@ -113,7 +113,7 @@ describe("Boss Testing", () => {
|
|||||||
* (entity.combat?.healthMultiplier ?? 1),
|
* (entity.combat?.healthMultiplier ?? 1),
|
||||||
540,
|
540,
|
||||||
));
|
));
|
||||||
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8));
|
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 950 * 1.8));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ describe("default difficulty CoA healer rotations", () => {
|
|||||||
for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) {
|
for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) {
|
||||||
const result = await runDungeonBossBenchmark(build, boss.id, seed, true);
|
const result = await runDungeonBossBenchmark(build, boss.id, seed, true);
|
||||||
samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result });
|
samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result });
|
||||||
if (!result.playerAlive || result.allyHealing <= 0 || result.deaths > 0) {
|
if (!result.playerAlive || result.healing <= 0 || result.deaths > 0) {
|
||||||
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.allyHealing} ally healing`);
|
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.healing} effective healing`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function companionActorFromMember(
|
|||||||
cast: actor?.cast ?? member.activeCast,
|
cast: actor?.cast ?? member.activeCast,
|
||||||
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
||||||
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
||||||
comboPoints: actor?.comboPoints ?? 0,
|
comboPoints: actor?.comboPoints ?? member.comboPoints,
|
||||||
comboTargetId: actor?.comboTargetId ?? null,
|
comboTargetId: actor?.comboTargetId ?? member.comboTargetId,
|
||||||
runes: actor?.runes ?? member.runes,
|
runes: actor?.runes ?? member.runes,
|
||||||
abilityIds: actor?.abilityIds,
|
abilityIds: actor?.abilityIds,
|
||||||
alive: (actor?.health ?? member.health) > 0,
|
alive: (actor?.health ?? member.health) > 0,
|
||||||
@@ -71,6 +71,8 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
|
|||||||
maxResource: primary?.maximum ?? member.maxResource,
|
maxResource: primary?.maximum ?? member.maxResource,
|
||||||
resourcePools,
|
resourcePools,
|
||||||
lastResourceSpentAt,
|
lastResourceSpentAt,
|
||||||
|
comboPoints: actor.comboPoints,
|
||||||
|
comboTargetId: actor.comboTargetId,
|
||||||
cooldowns: actor.cooldowns,
|
cooldowns: actor.cooldowns,
|
||||||
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||||
activeCast: actor.cast,
|
activeCast: actor.cast,
|
||||||
@@ -106,6 +108,8 @@ export function companionActorWithMemberRuntime(
|
|||||||
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
||||||
controlledUntil: member.controlledUntil,
|
controlledUntil: member.controlledUntil,
|
||||||
schoolLockedUntil: member.schoolLockedUntil,
|
schoolLockedUntil: member.schoolLockedUntil,
|
||||||
|
comboPoints: member.comboPoints,
|
||||||
|
comboTargetId: member.comboTargetId,
|
||||||
runes: member.runes,
|
runes: member.runes,
|
||||||
alive: member.health > 0,
|
alive: member.health > 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import "./abilityCatalog";
|
import "./abilityCatalog";
|
||||||
import {
|
import {
|
||||||
|
characterCombatAnimationForPresentation,
|
||||||
clipsForAnimationIds,
|
clipsForAnimationIds,
|
||||||
parseWoWAnimationName,
|
parseWoWAnimationName,
|
||||||
selectCharacterBaseClip,
|
selectCharacterBaseClip,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
selectCharacterVerticalClip,
|
selectCharacterVerticalClip,
|
||||||
selectWoWAnimationVariation,
|
selectWoWAnimationVariation,
|
||||||
} from "./combatAnimation";
|
} from "./combatAnimation";
|
||||||
|
import type { CombatPresentationEvent } from "./combatPresentation";
|
||||||
|
|
||||||
const clips = [
|
const clips = [
|
||||||
{ name: "Stand (ID 0 variation 0)" },
|
{ name: "Stand (ID 0 variation 0)" },
|
||||||
@@ -32,6 +34,28 @@ const clips = [
|
|||||||
{ name: "HoldBow (ID 109 variation 0)" },
|
{ name: "HoldBow (ID 109 variation 0)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function presentationEvent(
|
||||||
|
overrides: Partial<CombatPresentationEvent> = {},
|
||||||
|
): CombatPresentationEvent {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
sessionId: 1,
|
||||||
|
occurredAt: 1_000,
|
||||||
|
phase: "release",
|
||||||
|
actorGroup: "party",
|
||||||
|
sourceActorId: "party-1",
|
||||||
|
targetActorId: "mob-1",
|
||||||
|
abilityId: "party-auto-attack",
|
||||||
|
name: "Auto Attack",
|
||||||
|
source: "fallback",
|
||||||
|
school: "physical",
|
||||||
|
delivery: "melee",
|
||||||
|
origin: [0, 0, 0],
|
||||||
|
targetPosition: [1, 0, 0],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("WoW character animation selection", () => {
|
describe("WoW character animation selection", () => {
|
||||||
it("parses AnimationData IDs and preserves labels and variations", () => {
|
it("parses AnimationData IDs and preserves labels and variations", () => {
|
||||||
expect(parseWoWAnimationName("AttackUnarmed (ID 16 variation 1)")).toEqual({
|
expect(parseWoWAnimationName("AttackUnarmed (ID 16 variation 1)")).toEqual({
|
||||||
@@ -108,13 +132,44 @@ describe("WoW character animation selection", () => {
|
|||||||
|
|
||||||
it("maps melee, wound, and terminal death events to native clips", () => {
|
it("maps melee, wound, and terminal death events to native clips", () => {
|
||||||
expect(selectCharacterEventClip(clips, "warrior", { revision: 1, kind: "attack" }))
|
expect(selectCharacterEventClip(clips, "warrior", { revision: 1, kind: "attack" }))
|
||||||
.toMatchObject({ clip: clips[4], loop: false });
|
.toMatchObject({ clip: clips[3], loop: false });
|
||||||
expect(selectCharacterEventClip(clips, "warrior", { revision: 2, kind: "wound" }))
|
expect(selectCharacterEventClip(clips, "warrior", { revision: 2, kind: "wound" }))
|
||||||
.toMatchObject({ clip: clips[7], loop: false });
|
.toMatchObject({ clip: clips[7], loop: false });
|
||||||
expect(selectCharacterEventClip(clips, "warrior", { revision: 3, kind: "death" }))
|
expect(selectCharacterEventClip(clips, "warrior", { revision: 3, kind: "death" }))
|
||||||
.toMatchObject({ clip: clips[8], terminal: true });
|
.toMatchObject({ clip: clips[8], terminal: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reserves special melee clips for abilities instead of ordinary weapon swings", () => {
|
||||||
|
expect(selectCharacterEventClip(clips, "paladin", { revision: 1, kind: "attack" }))
|
||||||
|
.toMatchObject({ clip: clips[3], loop: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives party animation only from actual combat presentation phases", () => {
|
||||||
|
expect(characterCombatAnimationForPresentation(presentationEvent(), 7)).toEqual({
|
||||||
|
revision: 7,
|
||||||
|
kind: "attack",
|
||||||
|
});
|
||||||
|
expect(characterCombatAnimationForPresentation(presentationEvent({
|
||||||
|
phase: "cast-start",
|
||||||
|
abilityId: "paladin-holy-light",
|
||||||
|
}), 8)).toEqual({
|
||||||
|
revision: 8,
|
||||||
|
kind: "ability-start",
|
||||||
|
abilityId: "paladin-holy-light",
|
||||||
|
});
|
||||||
|
expect(characterCombatAnimationForPresentation(presentationEvent({
|
||||||
|
abilityId: "warrior-mortal-strike",
|
||||||
|
}), 9)).toEqual({
|
||||||
|
revision: 9,
|
||||||
|
kind: "ability-release",
|
||||||
|
abilityId: "warrior-mortal-strike",
|
||||||
|
});
|
||||||
|
expect(characterCombatAnimationForPresentation(presentationEvent({ phase: "impact" }), 10))
|
||||||
|
.toBeNull();
|
||||||
|
expect(characterCombatAnimationForPresentation(presentationEvent({ actorGroup: "enemy" }), 11))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses native RuneWaker spell, bow, and melee clips for RoM characters", () => {
|
it("uses native RuneWaker spell, bow, and melee clips for RoM characters", () => {
|
||||||
const runewakerClips = [
|
const runewakerClips = [
|
||||||
{ name: "Action - 1h_charge_up" },
|
{ name: "Action - 1h_charge_up" },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
|
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
|
||||||
import type { AbilityDefinition } from "./abilityCatalog";
|
import type { AbilityDefinition } from "./abilityCatalog";
|
||||||
import { abilityAnimationById } from "./abilityAnimationLookup";
|
import { abilityAnimationById } from "./abilityAnimationLookup";
|
||||||
|
import type { CombatPresentationEvent } from "./combatPresentation";
|
||||||
import type { CharacterVerticalMotion } from "./playerJump";
|
import type { CharacterVerticalMotion } from "./playerJump";
|
||||||
|
|
||||||
export type CharacterCombatAnimationKind =
|
export type CharacterCombatAnimationKind =
|
||||||
@@ -53,8 +54,8 @@ const MELEE_CLASSES = new Set<ClassId>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
|
const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
|
||||||
warrior: [57, 17, 58, 18, 16, 118],
|
warrior: [17, 18, 16, 118, 57, 58],
|
||||||
paladin: [57, 17, 59, 16, 118],
|
paladin: [17, 18, 16, 118, 59, 57],
|
||||||
hunter: [46, 47, 49, 107, 16],
|
hunter: [46, 47, 49, 107, 16],
|
||||||
rogue: [85, 88, 87, 117, 57, 17, 95, 16],
|
rogue: [85, 88, 87, 117, 57, 17, 95, 16],
|
||||||
priest: [53, 54, 32, 2, 16],
|
priest: [53, 54, 32, 2, 16],
|
||||||
@@ -65,6 +66,24 @@ const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
|
|||||||
druid: [53, 54, 32, 118, 16],
|
druid: [53, 54, 32, 118, 16],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Convert authoritative party combat presentation into one render event. */
|
||||||
|
export function characterCombatAnimationForPresentation(
|
||||||
|
event: CombatPresentationEvent,
|
||||||
|
revision: number,
|
||||||
|
): CharacterCombatAnimationEvent | null {
|
||||||
|
if (event.actorGroup !== "party") return null;
|
||||||
|
if (event.phase === "cast-start") {
|
||||||
|
return { revision, kind: "ability-start", abilityId: event.abilityId };
|
||||||
|
}
|
||||||
|
if (event.phase === "cast-cancel") {
|
||||||
|
return { revision, kind: "ability-cancel", abilityId: event.abilityId };
|
||||||
|
}
|
||||||
|
if (event.phase !== "release") return null;
|
||||||
|
return event.abilityId === "party-auto-attack"
|
||||||
|
? { revision, kind: "attack" }
|
||||||
|
: { revision, kind: "ability-release", abilityId: event.abilityId };
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse the names emitted by wow.export without depending on Three.js. */
|
/** Parse the names emitted by wow.export without depending on Three.js. */
|
||||||
export function parseWoWAnimationName(name: string): ParsedWoWAnimationName {
|
export function parseWoWAnimationName(name: string): ParsedWoWAnimationName {
|
||||||
const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
|
const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
import { applyAura, type AuraDefinition } from "./combatAuras";
|
import { applyAura, type AuraDefinition } from "./combatAuras";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { abilityById } from "./abilityCatalog";
|
||||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
import { usePartyStore } from "./partyStore";
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||||
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
|
||||||
function registerTarget(id = "target", distance = 2): void {
|
function registerTarget(id = "target", distance = 2): void {
|
||||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||||
@@ -99,6 +101,57 @@ describe("named aura combat runtime", () => {
|
|||||||
))).toHaveLength(1);
|
))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
|
||||||
|
const now = 30_000;
|
||||||
|
useCombatStore.getState().initializeCharacter({
|
||||||
|
classId: "hunter",
|
||||||
|
specializationId: "hunter:beast-mastery",
|
||||||
|
role: "damage",
|
||||||
|
level: 80,
|
||||||
|
inventory: [],
|
||||||
|
equipment: {},
|
||||||
|
});
|
||||||
|
const warlock = createConfiguredPartyMember({
|
||||||
|
name: "Demo",
|
||||||
|
classId: "warlock",
|
||||||
|
specialization: "Demonology",
|
||||||
|
specializationId: "warlock:demonology",
|
||||||
|
talentTreeId: "warlock-demonology",
|
||||||
|
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
|
||||||
|
role: "damage",
|
||||||
|
}, 80, 0, "precombat-preparation");
|
||||||
|
usePartyStore.setState({ members: [warlock], playerRole: "damage" });
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const playerResource = useCombatStore.getState().resource;
|
||||||
|
const companionResource = warlock.resource;
|
||||||
|
|
||||||
|
useCombatStore.getState().applyPrecombatPreparation(now);
|
||||||
|
const prepared = useCombatStore.getState();
|
||||||
|
expect(prepared.auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId: PLAYER_AGGRO_ID,
|
||||||
|
definition: expect.objectContaining({ exclusiveGroup: "hunter:aspect" }),
|
||||||
|
}));
|
||||||
|
expect(prepared.auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId: warlock.id,
|
||||||
|
definition: expect.objectContaining({ exclusiveGroup: "warlock:armor" }),
|
||||||
|
}));
|
||||||
|
expect(prepared.summons.some((summon) => summon.ownerActorId === PLAYER_AGGRO_ID
|
||||||
|
&& abilityById(summon.sourceAbilityId)?.name === "Call Pet")).toBe(true);
|
||||||
|
expect(prepared.summons.some((summon) => summon.ownerActorId === warlock.id
|
||||||
|
&& abilityById(summon.sourceAbilityId)?.name === "Summon Felguard")).toBe(true);
|
||||||
|
expect(prepared.resource).toBe(playerResource);
|
||||||
|
expect(prepared.globalCooldownEndsAt).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members[0]).toMatchObject({
|
||||||
|
resource: companionResource,
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||||
|
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||||
|
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
||||||
|
});
|
||||||
|
|
||||||
it("consumes a named absorb before player health", () => {
|
it("consumes a named absorb before player health", () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
expect(useCombatStore.getState().castAbility(
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BASE_CLASS_IDS } from "../app/characterCatalog";
|
||||||
|
import {
|
||||||
|
abilitiesForCharacter,
|
||||||
|
abilitiesForClass,
|
||||||
|
learnedAbilityForBuild,
|
||||||
|
resourceProfileForClass,
|
||||||
|
type AbilityDefinition,
|
||||||
|
type ResourceType,
|
||||||
|
} from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
abilityIsPrecombatPreparation,
|
||||||
|
abilityHasRotationSemantics,
|
||||||
|
CLASSIC_ROTATION_PROFILES,
|
||||||
|
COMBAT_ROTATION_VERSION,
|
||||||
|
evaluateCombatRotation,
|
||||||
|
rotationProfileFor,
|
||||||
|
type RotationContext,
|
||||||
|
} from "./combatRotation";
|
||||||
|
import { createDeathKnightRunes } from "./combatActors";
|
||||||
|
import { aiHintsForAbility } from "./abilityRules";
|
||||||
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
import { specializationsForClass } from "./specializationCatalog";
|
||||||
|
|
||||||
|
const SIGNATURES = Object.freeze({
|
||||||
|
warrior: "Mortal Strike",
|
||||||
|
paladin: "Divine Storm",
|
||||||
|
hunter: "Explosive Shot",
|
||||||
|
rogue: "Killing Spree",
|
||||||
|
priest: "Penance",
|
||||||
|
"death-knight": "Frost Strike",
|
||||||
|
shaman: "Feral Spirit",
|
||||||
|
mage: "Arcane Barrage",
|
||||||
|
warlock: "Haunt",
|
||||||
|
druid: "Starfall",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
function context(overrides: Partial<RotationContext> = {}): RotationContext {
|
||||||
|
const classId = overrides.classId ?? "rogue";
|
||||||
|
const primaryResource = resourceProfileForClass(classId).type;
|
||||||
|
return {
|
||||||
|
now: 1_000,
|
||||||
|
classId,
|
||||||
|
specializationId: classId === "rogue" ? "rogue:combat" : null,
|
||||||
|
role: "damage",
|
||||||
|
level: 80,
|
||||||
|
health: 1_000,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resources: { [primaryResource]: 100 },
|
||||||
|
maximumResources: { [primaryResource]: 100 },
|
||||||
|
primaryResource,
|
||||||
|
comboPoints: 0,
|
||||||
|
comboTargetId: null,
|
||||||
|
runes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||||
|
abilities: [],
|
||||||
|
hostileTarget: { id: "target", health: 1_000, maxHealth: 1_000 },
|
||||||
|
hostileCount: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ability(classId: RotationContext["classId"], name: string): AbilityDefinition {
|
||||||
|
const result = abilitiesForClass(classId).find((candidate) => candidate.name === name);
|
||||||
|
expect(result, `${classId}:${name}`).toBeDefined();
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAbility(
|
||||||
|
id: string,
|
||||||
|
effects: AbilityDefinition["effects"],
|
||||||
|
options: Partial<AbilityDefinition> = {},
|
||||||
|
): AbilityDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
classId: "rom-warrior",
|
||||||
|
dbcSpellId: 0,
|
||||||
|
name: id,
|
||||||
|
unlockLevel: 1,
|
||||||
|
icon: "",
|
||||||
|
sigil: "AI",
|
||||||
|
description: id,
|
||||||
|
target: "hostile",
|
||||||
|
range: { min: 0, max: 30 },
|
||||||
|
castMode: "instant",
|
||||||
|
castTimeMs: 0,
|
||||||
|
cooldownMs: 0,
|
||||||
|
gcdMs: 1_500,
|
||||||
|
cost: { resource: "rage", amount: 0 },
|
||||||
|
effects,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared combat rotations", () => {
|
||||||
|
it("registers exactly one resolvable profile for every classic specialization", () => {
|
||||||
|
const classicSpecs = BASE_CLASS_IDS.flatMap((classId) => specializationsForClass(classId));
|
||||||
|
expect(classicSpecs).toHaveLength(31);
|
||||||
|
expect(CLASSIC_ROTATION_PROFILES).toHaveLength(31);
|
||||||
|
expect(new Set(CLASSIC_ROTATION_PROFILES.map((entry) => entry.specializationId)).size).toBe(31);
|
||||||
|
for (const specialization of classicSpecs) {
|
||||||
|
const rotation = rotationProfileFor(specialization.classId, specialization.id);
|
||||||
|
expect(rotation?.specializationId, specialization.id).toBe(specialization.id);
|
||||||
|
for (const name of rotation?.abilityPriority ?? []) {
|
||||||
|
expect(abilitiesForClass(specialization.classId).some((candidate) => candidate.name === name),
|
||||||
|
`${specialization.id}:${name}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(COMBAT_ROTATION_VERSION).toMatch(/^playerbots-inspired-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares only long-lived maintenance effects and permanent pets before combat", () => {
|
||||||
|
const longBuff = semanticAbility("long-buff", [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: { id: "long-buff", name: "Long buff", disposition: "buff", durationMs: 60_000,
|
||||||
|
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
|
||||||
|
recipient: "caster",
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 22,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}], { target: "self" });
|
||||||
|
const shortBuff = semanticAbility("short-buff", [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: { id: "short-buff", name: "Short buff", disposition: "buff", durationMs: 30_000,
|
||||||
|
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
|
||||||
|
recipient: "caster",
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 22,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}], { target: "self" });
|
||||||
|
const pet = semanticAbility("call-pet", [{ kind: "pet", action: "call", creatureId: 1 }], { target: "self" });
|
||||||
|
const temporaryPet = semanticAbility("temporary-pet", [{ kind: "pet", action: "summon", creatureId: 2 }], {
|
||||||
|
target: "self",
|
||||||
|
cooldownMs: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(abilityIsPrecombatPreparation(longBuff)).toBe(true);
|
||||||
|
expect(abilityIsPrecombatPreparation(shortBuff)).toBe(false);
|
||||||
|
expect(abilityIsPrecombatPreparation(pet)).toBe(true);
|
||||||
|
expect(abilityIsPrecombatPreparation(temporaryPet)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates representative signature abilities from every class by their exact talent node", () => {
|
||||||
|
for (const [classId, name] of Object.entries(SIGNATURES)) {
|
||||||
|
const signature = ability(classId as RotationContext["classId"], name);
|
||||||
|
expect(signature.talentNodeId, `${classId}:${name}`).toMatch(/^talent-\d+$/);
|
||||||
|
expect(learnedAbilityForBuild(signature, 80, {}), `${classId}:${name}`).toBeNull();
|
||||||
|
expect(learnedAbilityForBuild(signature, 80, { [signature.talentNodeId!]: 1 }), `${classId}:${name}`).not.toBeNull();
|
||||||
|
expect(signature.effects.length).toBeGreaterThan(0);
|
||||||
|
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind))).toBe(true);
|
||||||
|
expect(signature.ranks?.length, `${classId}:${name} ranks`).toBeGreaterThan(0);
|
||||||
|
expect(signature.ranks?.every((rank) => rank.cost.amount >= 0 && rank.cooldownMs >= 0 && rank.effects.length > 0)).toBe(true);
|
||||||
|
expect(signature.cooldownMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(signature.cost.amount).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps specialization talent actions isolated", () => {
|
||||||
|
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
|
||||||
|
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
||||||
|
const bloodthirst = ability("warrior", "Bloodthirst");
|
||||||
|
const mortalStrike = ability("warrior", "Mortal Strike");
|
||||||
|
expect(learnedAbilityForBuild(bloodthirst, 80, furyTalents)).not.toBeNull();
|
||||||
|
expect(learnedAbilityForBuild(mortalStrike, 80, furyTalents)).toBeNull();
|
||||||
|
expect(learnedAbilityForBuild(mortalStrike, 80, armsTalents)).not.toBeNull();
|
||||||
|
expect(learnedAbilityForBuild(bloodthirst, 80, armsTalents)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses combo builders before finishers and actual combo points for the decision", () => {
|
||||||
|
const builder = ability("rogue", "Sinister Strike");
|
||||||
|
const finisher = ability("rogue", "Eviscerate");
|
||||||
|
const building = evaluateCombatRotation(context({ abilities: [finisher, builder] }));
|
||||||
|
expect(building?.ability.id).toBe(builder.id);
|
||||||
|
const finishing = evaluateCombatRotation(context({
|
||||||
|
abilities: [builder, finisher],
|
||||||
|
comboPoints: 5,
|
||||||
|
comboTargetId: "target",
|
||||||
|
}));
|
||||||
|
expect(finishing?.ability.id).toBe(finisher.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
|
||||||
|
const kick = ability("rogue", "Kick");
|
||||||
|
const strike = ability("rogue", "Sinister Strike");
|
||||||
|
expect(evaluateCombatRotation(context({
|
||||||
|
abilities: [strike, kick],
|
||||||
|
hostileTarget: {
|
||||||
|
id: "target",
|
||||||
|
health: 1_000,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
activeCast: { interruptible: true },
|
||||||
|
},
|
||||||
|
}))?.ability.id).toBe(kick.id);
|
||||||
|
|
||||||
|
const bestialWrath = ability("hunter", "Bestial Wrath");
|
||||||
|
const arcaneShot = ability("hunter", "Arcane Shot");
|
||||||
|
const hunter = context({
|
||||||
|
classId: "hunter",
|
||||||
|
specializationId: "hunter:beast-mastery",
|
||||||
|
primaryResource: "mana",
|
||||||
|
resources: { mana: 100 },
|
||||||
|
maximumResources: { mana: 100 },
|
||||||
|
abilities: [bestialWrath, arcaneShot],
|
||||||
|
});
|
||||||
|
expect(evaluateCombatRotation(hunter)?.ability.id).toBe(arcaneShot.id);
|
||||||
|
expect(evaluateCombatRotation({ ...hunter, hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true } })?.ability.id)
|
||||||
|
.toBe(bestialWrath.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses healer triage bands and area healing for three injured allies", () => {
|
||||||
|
const flash = ability("priest", "Flash Heal");
|
||||||
|
const prayer = ability("priest", "Prayer of Healing");
|
||||||
|
const hostileLeech = semanticAbility("hostile-leech", [
|
||||||
|
{ kind: "damage", coefficient: 1 },
|
||||||
|
{ kind: "heal", coefficient: 1, recipient: "caster" },
|
||||||
|
]);
|
||||||
|
const decision = evaluateCombatRotation(context({
|
||||||
|
classId: "priest",
|
||||||
|
specializationId: "priest:holy",
|
||||||
|
role: "healer",
|
||||||
|
primaryResource: "mana",
|
||||||
|
resources: { mana: 1_000 },
|
||||||
|
maximumResources: { mana: 1_000 },
|
||||||
|
selfId: "self",
|
||||||
|
abilities: [flash, prayer, hostileLeech],
|
||||||
|
friendlyTargets: [
|
||||||
|
{ id: "a", health: 250, maxHealth: 1_000 },
|
||||||
|
{ id: "b", health: 500, maxHealth: 1_000 },
|
||||||
|
{ id: "c", health: 700, maxHealth: 1_000 },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(decision?.ability.id).toBe(prayer.id);
|
||||||
|
expect(decision?.targetId).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes RoM secondary general and elite skills in shared-policy candidates", () => {
|
||||||
|
const primaryOnly = abilitiesForCharacter("rom-warrior", null);
|
||||||
|
const dualClass = abilitiesForCharacter("rom-warrior", "rom-scout");
|
||||||
|
expect(dualClass.length).toBeGreaterThan(primaryOnly.length);
|
||||||
|
expect(dualClass.some((candidate) => candidate.secondaryClassId === "rom-scout")).toBe(true);
|
||||||
|
const secondaryGeneral = dualClass.find((candidate) => candidate.classId === "rom-scout"
|
||||||
|
&& !candidate.secondaryClassId && !candidate.passive && abilityHasRotationSemantics(candidate)
|
||||||
|
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
|
||||||
|
const elite = dualClass.find((candidate) => candidate.secondaryClassId === "rom-scout"
|
||||||
|
&& !candidate.passive && abilityHasRotationSemantics(candidate)
|
||||||
|
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
|
||||||
|
expect(secondaryGeneral).toBeDefined();
|
||||||
|
expect(elite).toBeDefined();
|
||||||
|
const dualContext = context({
|
||||||
|
classId: "rom-warrior",
|
||||||
|
secondaryClassId: "rom-scout",
|
||||||
|
specializationId: null,
|
||||||
|
primaryResource: "rage",
|
||||||
|
resources: { rage: 100, focus: 100 },
|
||||||
|
maximumResources: { rage: 100, focus: 100 },
|
||||||
|
hostileTarget: { id: "boss", health: 20_000, maxHealth: 20_000, boss: true },
|
||||||
|
});
|
||||||
|
expect(evaluateCombatRotation({ ...dualContext, abilities: [secondaryGeneral!] })?.ability.id).toBe(secondaryGeneral!.id);
|
||||||
|
expect(evaluateCombatRotation({ ...dualContext, abilities: [elite!] })?.ability.id).toBe(elite!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles execute, cleave/AoE thresholds, and maintained effects", () => {
|
||||||
|
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||||
|
const execute = semanticAbility("execute", [{ kind: "execute", coefficient: 1 }]);
|
||||||
|
const area = semanticAbility("area", [{ kind: "damage", coefficient: 1 }], { radius: 8 });
|
||||||
|
const debuff = semanticAbility("debuff", [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: { id: "test-debuff", name: "Test", disposition: "debuff", durationMs: 10_000,
|
||||||
|
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.1 }] },
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 4,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}]);
|
||||||
|
const base = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
|
||||||
|
resources: { rage: 100 }, maximumResources: { rage: 100 } });
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute] })?.ability.id).toBe("strike");
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute],
|
||||||
|
hostileTarget: { id: "target", health: 199, maxHealth: 1_000 } })?.ability.id).toBe("execute");
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 1 })?.ability.id).toBe("strike");
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 2 })?.ability.id).toBe("area");
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff] })?.ability.id).toBe("debuff");
|
||||||
|
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff], maintainedAbilityIds: new Set([debuff.id]) })?.ability.id)
|
||||||
|
.toBe("strike");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||||
|
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||||
|
const prayer = semanticAbility("prayer", [
|
||||||
|
{ kind: "heal", coefficient: 1 },
|
||||||
|
{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: "prayer-hot",
|
||||||
|
name: "Prayer",
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: 21_000,
|
||||||
|
procs: [{ id: "prayer-proc", triggers: "periodic", chance: 1,
|
||||||
|
action: { kind: "heal", amount: 10, target: "aura-target" } }],
|
||||||
|
},
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 23,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 1,
|
||||||
|
},
|
||||||
|
], { target: "friendly" });
|
||||||
|
const base = context({ classId: "venomancer", specializationId: null, abilities: [prayer, strike] });
|
||||||
|
|
||||||
|
expect(evaluateCombatRotation(base)?.ability.id).toBe(strike.id);
|
||||||
|
expect(evaluateCombatRotation({ ...base, friendlyTargets: [{ id: "ally", health: 50, maxHealth: 100 }] }))
|
||||||
|
.toMatchObject({ ability: { id: prayer.id }, targetId: "ally" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles movement, threat recovery, defenses, resources, pets, and DK runes", () => {
|
||||||
|
const fireball = ability("mage", "Fireball");
|
||||||
|
const fireBlast = ability("mage", "Fire Blast");
|
||||||
|
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
|
||||||
|
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fireball, fireBlast] });
|
||||||
|
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
|
||||||
|
expect(evaluateCombatRotation({ ...mage, moving: true })?.ability.id).toBe(fireBlast.id);
|
||||||
|
|
||||||
|
const fade = ability("priest", "Fade");
|
||||||
|
const mindBlast = ability("priest", "Mind Blast");
|
||||||
|
const priest = context({ classId: "priest", specializationId: "priest:shadow", primaryResource: "mana",
|
||||||
|
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fade, mindBlast] });
|
||||||
|
expect(evaluateCombatRotation(priest)?.ability.id).toBe(mindBlast.id);
|
||||||
|
expect(evaluateCombatRotation({ ...priest, hasDangerousThreat: true })?.ability.id).toBe(fade.id);
|
||||||
|
|
||||||
|
const shield = semanticAbility("shield", [{ kind: "shield", coefficient: 1, durationMs: 5_000 }], { target: "self" });
|
||||||
|
const fallback = semanticAbility("fallback", [{ kind: "damage", coefficient: 1 }]);
|
||||||
|
const generator = semanticAbility("generator", [{ kind: "resource", amount: 30, resource: "rage" }], { target: "self" });
|
||||||
|
const summon = semanticAbility("summon", [{ kind: "summon", creatureId: 1, coefficient: 0.3, durationMs: 30_000 }],
|
||||||
|
{ target: "self" });
|
||||||
|
const semantic = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
|
||||||
|
resources: { rage: 10 }, maximumResources: { rage: 100 }, abilities: [fallback, shield, generator, summon],
|
||||||
|
activeSummonAbilityIds: new Set([summon.id]) });
|
||||||
|
expect(evaluateCombatRotation(semantic)?.ability.id).toBe(generator.id);
|
||||||
|
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 300,
|
||||||
|
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(shield.id);
|
||||||
|
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 1_000,
|
||||||
|
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(fallback.id);
|
||||||
|
|
||||||
|
const icyTouch = ability("death-knight", "Icy Touch");
|
||||||
|
const deathCoil = ability("death-knight", "Death Coil");
|
||||||
|
const dk = context({ classId: "death-knight", specializationId: "death-knight:unholy",
|
||||||
|
primaryResource: "runic-power", resources: { "runic-power": 100 }, maximumResources: { "runic-power": 100 },
|
||||||
|
abilities: [icyTouch, deathCoil], runes: createDeathKnightRunes(0) });
|
||||||
|
expect(evaluateCombatRotation(dk)?.ability.id).toBe(icyTouch.id);
|
||||||
|
expect(evaluateCombatRotation({ ...dk, runes: dk.runes.map((rune) => (
|
||||||
|
rune.type === "frost" ? { ...rune, readyAt: 10_000 } : rune
|
||||||
|
)) })?.ability.id).toBe(deathCoil.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Rockbiter as the low-level Shaman maintenance fallback", () => {
|
||||||
|
const rockbiter = learnedAbilityForBuild(ability("shaman", "Rockbiter Weapon"), 1, {})!;
|
||||||
|
const lightningBolt = learnedAbilityForBuild(ability("shaman", "Lightning Bolt"), 1, {})!;
|
||||||
|
const shaman = context({
|
||||||
|
classId: "shaman",
|
||||||
|
specializationId: "shaman:elemental",
|
||||||
|
level: 1,
|
||||||
|
primaryResource: "mana",
|
||||||
|
resources: { mana: 100 },
|
||||||
|
maximumResources: { mana: 100 },
|
||||||
|
abilities: [rockbiter, lightningBolt],
|
||||||
|
maintainedAbilityIds: new Set(),
|
||||||
|
activeAuraExclusiveGroups: new Set(),
|
||||||
|
});
|
||||||
|
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(rockbiter.id);
|
||||||
|
expect(evaluateCombatRotation({ ...shaman, maintainedAbilityIds: new Set([rockbiter.id]) })?.ability.id)
|
||||||
|
.toBe(lightningBolt.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters a learned Death Knight presence before attacking", () => {
|
||||||
|
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
|
||||||
|
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
|
||||||
|
expect(bloodPresence.effects.some((effect) => effect.kind === "heal")).toBe(false);
|
||||||
|
const deathKnight = context({
|
||||||
|
classId: "death-knight",
|
||||||
|
specializationId: "death-knight:blood",
|
||||||
|
role: "tank",
|
||||||
|
level: 1,
|
||||||
|
primaryResource: "runic-power",
|
||||||
|
resources: { "runic-power": 0 },
|
||||||
|
maximumResources: { "runic-power": 100 },
|
||||||
|
runes: createDeathKnightRunes(),
|
||||||
|
abilities: [bloodPresence, icyTouch],
|
||||||
|
maintainedAbilityIds: new Set(),
|
||||||
|
activeAuraExclusiveGroups: new Set(),
|
||||||
|
});
|
||||||
|
expect(evaluateCombatRotation(deathKnight)?.ability.id).toBe(bloodPresence.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summons one real pet and ignores noncombat utility summons", () => {
|
||||||
|
const shadowBolt = ability("warlock", "Shadow Bolt");
|
||||||
|
const imp = ability("warlock", "Summon Imp");
|
||||||
|
const eye = ability("warlock", "Eye of Kilrogg");
|
||||||
|
const warlock = context({
|
||||||
|
classId: "warlock",
|
||||||
|
specializationId: null,
|
||||||
|
primaryResource: "mana",
|
||||||
|
resources: { mana: 100 },
|
||||||
|
maximumResources: { mana: 100 },
|
||||||
|
abilities: [shadowBolt, imp, eye],
|
||||||
|
activeSummonAbilityIds: new Set(),
|
||||||
|
hasActivePet: false,
|
||||||
|
});
|
||||||
|
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(imp.id);
|
||||||
|
expect(evaluateCombatRotation({ ...warlock, hasActivePet: true })?.ability.id).toBe(shadowBolt.id);
|
||||||
|
expect(evaluateCombatRotation({ ...warlock, abilities: [shadowBolt, eye] })?.ability.id).toBe(shadowBolt.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is consumer-independent for equivalent normalized state", () => {
|
||||||
|
const abilities = [ability("mage", "Fireball"), ability("mage", "Fire Blast")];
|
||||||
|
const normalized = context({
|
||||||
|
classId: "mage",
|
||||||
|
specializationId: "mage:fire",
|
||||||
|
primaryResource: "mana" as ResourceType,
|
||||||
|
resources: { mana: 100 },
|
||||||
|
maximumResources: { mana: 100 },
|
||||||
|
abilities,
|
||||||
|
});
|
||||||
|
const companionDecision = evaluateCombatRotation(normalized);
|
||||||
|
const benchmarkDecision = evaluateCombatRotation({ ...normalized });
|
||||||
|
expect(benchmarkDecision).toEqual(companionDecision);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
|
||||||
|
import { aiHintsForAbility, canAbilityCastWhileMoving, runeCostForAbility } from "./abilityRules";
|
||||||
|
import type { DeathKnightRuneState } from "./combatActors";
|
||||||
|
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||||
|
import { spendDeathKnightRunes } from "./combatResources";
|
||||||
|
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||||
|
import type { PartyRole } from "./partyRoles";
|
||||||
|
|
||||||
|
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1";
|
||||||
|
|
||||||
|
export interface RotationProfile {
|
||||||
|
readonly id: string;
|
||||||
|
readonly specializationId: string;
|
||||||
|
readonly classId: ClassId;
|
||||||
|
readonly abilityPriority: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotationFriendlyTarget {
|
||||||
|
readonly id: string;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly needsDispel?: boolean;
|
||||||
|
readonly fallen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotationHostileTarget {
|
||||||
|
readonly id: string;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly boss?: boolean;
|
||||||
|
readonly healthMultiplier?: number;
|
||||||
|
readonly activeCast?: { readonly interruptible?: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotationContext {
|
||||||
|
readonly now: number;
|
||||||
|
readonly classId: ClassId;
|
||||||
|
readonly secondaryClassId?: ClassId | null;
|
||||||
|
readonly specializationId?: string | null;
|
||||||
|
readonly specialization?: string | null;
|
||||||
|
readonly role: PartyRole;
|
||||||
|
readonly level: number;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly selfId?: string;
|
||||||
|
readonly resources: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
|
readonly maximumResources: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
|
readonly primaryResource: ResourceType;
|
||||||
|
readonly comboPoints: number;
|
||||||
|
readonly comboTargetId: string | null;
|
||||||
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
|
readonly abilities: readonly AbilityDefinition[];
|
||||||
|
readonly hostileTarget: RotationHostileTarget | null;
|
||||||
|
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||||
|
readonly hostileCount?: number;
|
||||||
|
readonly targetIsLoose?: boolean;
|
||||||
|
readonly hasDangerousThreat?: boolean;
|
||||||
|
readonly requiredControl?: boolean;
|
||||||
|
readonly moving?: boolean;
|
||||||
|
readonly maintainedAbilityIds?: ReadonlySet<string>;
|
||||||
|
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||||
|
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||||
|
readonly hasActivePet?: boolean;
|
||||||
|
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotationDecision {
|
||||||
|
readonly ability: AbilityDefinition;
|
||||||
|
readonly targetId: string | null;
|
||||||
|
readonly profileId: string;
|
||||||
|
readonly ruleId: string;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = (
|
||||||
|
specializationId: string,
|
||||||
|
classId: ClassId,
|
||||||
|
abilityPriority: readonly string[],
|
||||||
|
): RotationProfile => Object.freeze({
|
||||||
|
id: `classic:${specializationId}`,
|
||||||
|
specializationId,
|
||||||
|
classId,
|
||||||
|
abilityPriority: Object.freeze(abilityPriority),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||||
|
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||||
|
profile("warrior:arms", "warrior", ["Battle Stance", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||||
|
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||||
|
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||||
|
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||||
|
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
|
||||||
|
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
|
||||||
|
profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]),
|
||||||
|
profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot"]),
|
||||||
|
profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]),
|
||||||
|
profile("rogue:assassination", "rogue", ["Envenom", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike"]),
|
||||||
|
profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]),
|
||||||
|
profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]),
|
||||||
|
profile("priest:discipline", "priest", ["Inner Fire", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew"]),
|
||||||
|
profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "Flash Heal", "Greater Heal", "Renew"]),
|
||||||
|
profile("priest:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]),
|
||||||
|
profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]),
|
||||||
|
profile("death-knight:frost", "death-knight", ["Blood Presence", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
|
||||||
|
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Scourge Strike", "Death and Decay", "Plague Strike", "Icy Touch", "Death Coil"]),
|
||||||
|
profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "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("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:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]),
|
||||||
|
profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]),
|
||||||
|
profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
|
||||||
|
profile("warlock:destruction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Imp", "Chaos Bolt", "Conflagrate", "Immolate", "Incinerate", "Shadow Bolt"]),
|
||||||
|
profile("druid:balance", "druid", ["Moonkin Form", "Starfall", "Moonfire", "Insect Swarm", "Starfire", "Wrath"]),
|
||||||
|
profile("druid:feral-bear", "druid", ["Dire Bear Form", "Bear Form", "Mangle (Bear)", "Lacerate", "Swipe (Bear)", "Maul"]),
|
||||||
|
profile("druid:feral-cat", "druid", ["Cat Form", "Rip", "Savage Roar", "Mangle (Cat)", "Rake", "Shred", "Claw"]),
|
||||||
|
profile("druid:restoration", "druid", ["Tree of Life", "Wild Growth", "Swiftmend", "Lifebloom", "Rejuvenation", "Regrowth", "Healing Touch"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PROFILE_BY_SPECIALIZATION = new Map(
|
||||||
|
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function slug(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationProfileFor(
|
||||||
|
classId: ClassId,
|
||||||
|
specializationId?: string | null,
|
||||||
|
specialization?: string | null,
|
||||||
|
): RotationProfile | null {
|
||||||
|
const explicit = specializationId ? PROFILE_BY_SPECIALIZATION.get(specializationId) : null;
|
||||||
|
if (explicit?.classId === classId) return explicit;
|
||||||
|
const inferred = specialization ? PROFILE_BY_SPECIALIZATION.get(`${classId}:${slug(specialization)}`) : null;
|
||||||
|
return inferred?.classId === classId ? inferred : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throughput(ability: AbilityDefinition, comboPoints: number): number {
|
||||||
|
return ability.effects.reduce((total, effect) => {
|
||||||
|
if (["damage", "heal", "shield", "dot", "hot", "execute"].includes(effect.kind) && "coefficient" in effect) {
|
||||||
|
const basePoints = "basePoints" in effect ? effect.basePoints ?? 0 : 0;
|
||||||
|
return total + Math.max(0, effect.coefficient) * 10 + Math.max(0, basePoints) * 0.01;
|
||||||
|
}
|
||||||
|
if (effect.kind === "finisher-damage") {
|
||||||
|
return total + (effect.baseCoefficient + effect.perComboCoefficient * comboPoints) * 10;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComboFinisher(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|
||||||
|
|| (effect.kind === "combo" && effect.amount < 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComboBuilder(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "combo" && effect.amount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPetSummon(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "pet"
|
||||||
|
&& ["summon", "call", "resurrect", "tame"].includes(effect.action));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRECOMBAT_MINIMUM_BUFF_DURATION_MS = 60_000;
|
||||||
|
|
||||||
|
function isPersistentPetPreparation(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
effect: Extract<AbilityDefinition["effects"][number], { kind: "pet" }>,
|
||||||
|
): boolean {
|
||||||
|
if (["call", "resurrect", "tame"].includes(effect.action)) return true;
|
||||||
|
return effect.action === "summon" && ability.cooldownMs < PRECOMBAT_MINIMUM_BUFF_DURATION_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-lived self maintenance and permanent pets that should exist before the first pull. */
|
||||||
|
export function precombatPreparationEffects(ability: AbilityDefinition): readonly AbilityDefinition["effects"][number][] {
|
||||||
|
return ability.effects.filter((effect) => (
|
||||||
|
effect.kind === "pet"
|
||||||
|
? isPersistentPetPreparation(ability, effect)
|
||||||
|
: effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.disposition === "buff"
|
||||||
|
&& (effect.aura.durationMs === null || effect.aura.durationMs >= PRECOMBAT_MINIMUM_BUFF_DURATION_MS)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilityIsPrecombatPreparation(ability: AbilityDefinition): boolean {
|
||||||
|
return !ability.passive && precombatPreparationEffects(ability).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffensiveSummon(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "summon" && effect.coefficient >= 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCombatMaintenanceAura(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.disposition === "buff"
|
||||||
|
&& (effect.aura.durationMs === null || effect.aura.durationMs >= 15_000)
|
||||||
|
&& Boolean(
|
||||||
|
effect.aura.modifiers?.length
|
||||||
|
|| effect.aura.absorbs?.length
|
||||||
|
|| effect.aura.procs?.length
|
||||||
|
|| effect.aura.controlImmunities?.length
|
||||||
|
|| effect.aura.reflectSpellChance
|
||||||
|
|| effect.aura.redirectDamagePercent
|
||||||
|
|| effect.aura.utilityTags?.includes("weapon-enchant"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilityHasRotationSemantics(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => {
|
||||||
|
if (effect.kind === "unsupported" || effect.kind === "scripted" || effect.kind === "utility") return false;
|
||||||
|
if (effect.kind !== "apply-aura") return true;
|
||||||
|
return Boolean(
|
||||||
|
effect.control
|
||||||
|
|| effect.aura.modifiers?.length
|
||||||
|
|| effect.aura.absorbs?.length
|
||||||
|
|| effect.aura.procs?.length
|
||||||
|
|| effect.aura.utilityTags?.length
|
||||||
|
|| effect.aura.controlImmunities?.length
|
||||||
|
|| effect.aura.reflectSpellChance
|
||||||
|
|| effect.aura.redirectDamagePercent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetForAbility(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
context: RotationContext,
|
||||||
|
lowest: RotationFriendlyTarget | null,
|
||||||
|
): string | null {
|
||||||
|
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||||
|
const casterOnly = ability.target === "self" || (ability.effects.some((effect) => (
|
||||||
|
"recipient" in effect && effect.recipient === "caster"
|
||||||
|
)) && !ability.effects.some((effect) => (
|
||||||
|
"recipient" in effect && effect.recipient === "ability-target"
|
||||||
|
)));
|
||||||
|
const hostileAction = (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
|
||||||
|
.some((category) => categories.has(category));
|
||||||
|
if (casterOnly && !hostileAction) return context.selfId ?? null;
|
||||||
|
if (ability.target === "friendly") return lowest?.id ?? null;
|
||||||
|
if (ability.target === "any" && lowest && aiHintsForAbility(ability).categories.some((category) => (
|
||||||
|
category === "heal" || category === "dispel" || category === "resurrection" || category === "mitigation"
|
||||||
|
))) return lowest.id;
|
||||||
|
// Self-centered hostile area skills (for example Thunder Clap) still need an
|
||||||
|
// enemy anchor so execution can enumerate targets around the caster.
|
||||||
|
if (ability.target === "self" && (ability.radius ?? 0) > 0
|
||||||
|
&& (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
|
||||||
|
.some((category) => categories.has(category))) {
|
||||||
|
return context.hostileTarget?.id ?? null;
|
||||||
|
}
|
||||||
|
if (ability.target === "hostile" || ability.target === "any") return context.hostileTarget?.id ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoredRotationAbility {
|
||||||
|
readonly ability: AbilityDefinition;
|
||||||
|
readonly score: number;
|
||||||
|
readonly ruleId: string;
|
||||||
|
readonly reason: string;
|
||||||
|
readonly targetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreAbility(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
context: RotationContext,
|
||||||
|
rotationProfile: RotationProfile | null,
|
||||||
|
lowest: RotationFriendlyTarget | null,
|
||||||
|
): ScoredRotationAbility | null {
|
||||||
|
const hints = aiHintsForAbility(ability);
|
||||||
|
const categories = new Set(hints.categories);
|
||||||
|
const targetHealth = context.hostileTarget
|
||||||
|
? context.hostileTarget.health / Math.max(1, context.hostileTarget.maxHealth)
|
||||||
|
: 1;
|
||||||
|
const selfHealth = context.health / Math.max(1, context.maxHealth);
|
||||||
|
const lowestHealth = lowest ? lowest.health / Math.max(1, lowest.maxHealth) : 1;
|
||||||
|
const injured = (context.friendlyTargets ?? []).filter((target) => (
|
||||||
|
!target.fallen && target.health > 0 && target.health < target.maxHealth * 0.9
|
||||||
|
));
|
||||||
|
const resource = context.resources[context.primaryResource] ?? 0;
|
||||||
|
const maximumResource = context.maximumResources[context.primaryResource] ?? 0;
|
||||||
|
const resourceFraction = maximumResource > 0 ? resource / maximumResource : 1;
|
||||||
|
const maintained = (context.maintainedAbilityIds?.has(ability.id) ?? false)
|
||||||
|
|| ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& Boolean(effect.aura.exclusiveGroup
|
||||||
|
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||||
|
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
|
||||||
|
const petSummon = isPetSummon(ability);
|
||||||
|
const offensiveSummon = isOffensiveSummon(ability);
|
||||||
|
const maintenanceBuff = isCombatMaintenanceAura(ability);
|
||||||
|
const channelAmounts = ability.castMode === "channel" ? channelAmountEffects(ability, 1) : [];
|
||||||
|
const damaging = categories.has("damage") || categories.has("execute")
|
||||||
|
|| channelAmounts.some(({ effect }) => effect.kind === "damage" || effect.kind === "dot");
|
||||||
|
const healingCategory = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
|
||||||
|
const defensive = categories.has("mitigation") || categories.has("heal");
|
||||||
|
const offensiveMajorCooldown = ability.cooldownMs >= 60_000 && !defensive
|
||||||
|
&& (damaging || categories.has("buff") || categories.has("summon") || categories.has("pet"));
|
||||||
|
const durableTarget = Boolean(
|
||||||
|
context.hostileTarget?.boss
|
||||||
|
|| (context.hostileTarget?.healthMultiplier ?? 1) > 1
|
||||||
|
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
|
||||||
|
);
|
||||||
|
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
|
||||||
|
|
||||||
|
if (categories.has("resurrection")) {
|
||||||
|
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
|
||||||
|
return fallen ? { ability, score: 20_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
|
||||||
|
}
|
||||||
|
if (categories.has("dispel")) {
|
||||||
|
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
|
||||||
|
if (dispel) return { ability, score: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
|
||||||
|
if (!damaging) return null;
|
||||||
|
}
|
||||||
|
if (categories.has("interrupt")) {
|
||||||
|
if (!context.hostileTarget?.activeCast?.interruptible) {
|
||||||
|
if (!damaging) return null;
|
||||||
|
} else {
|
||||||
|
return { ability, score: 18_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (categories.has("taunt")) {
|
||||||
|
if (context.role !== "tank" || !context.targetIsLoose) return null;
|
||||||
|
return { ability, score: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
|
||||||
|
}
|
||||||
|
if (categories.has("threat") && !damaging && !maintenanceBuff) {
|
||||||
|
if (context.role === "tank" || !context.hasDangerousThreat) return null;
|
||||||
|
return {
|
||||||
|
ability,
|
||||||
|
score: 15_000,
|
||||||
|
ruleId: "reactive-threat-drop",
|
||||||
|
reason: "non-tank has enemy attention",
|
||||||
|
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (categories.has("control") && !context.requiredControl && !damaging) return null;
|
||||||
|
// A friendly heal without an injured recipient must never fall through to
|
||||||
|
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
|
||||||
|
// forever because those auras also look like maintenance buffs.
|
||||||
|
const actionableHealing = healingCategory
|
||||||
|
&& !(maintenanceBuff && categories.has("resource"))
|
||||||
|
&& !petSummon
|
||||||
|
&& !offensiveSummon;
|
||||||
|
if (actionableHealing && !damaging) {
|
||||||
|
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
||||||
|
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||||
|
if (healingTargetHealth >= 0.9) return null;
|
||||||
|
}
|
||||||
|
// Pet stance and recall buttons require an explicit positioning/order intent;
|
||||||
|
// treating them as maintenance actions otherwise starves real attacks.
|
||||||
|
if (categories.has("pet") && !petSummon && !categories.has("summon") && !damaging
|
||||||
|
&& !categories.has("buff") && !categories.has("heal")) return null;
|
||||||
|
if (petSummon && context.hasActivePet) return null;
|
||||||
|
// Scouting objects, portals, ritual objects, and other zero-pressure DBC
|
||||||
|
// summons are valid player buttons but are not autonomous combat actions.
|
||||||
|
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
|
||||||
|
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
|
||||||
|
&& !petSummon && !offensiveSummon) return null;
|
||||||
|
if (categories.has("movement") && !damaging && !context.moving) return null;
|
||||||
|
if (context.requiredControl && categories.has("control")) {
|
||||||
|
return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
|
||||||
|
}
|
||||||
|
if (offensiveMajorCooldown && !durableTarget) return null;
|
||||||
|
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||||
|
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||||
|
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
|
||||||
|
if (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;
|
||||||
|
|
||||||
|
let score = hints.priority ?? 0;
|
||||||
|
let ruleId = "semantic-fallback";
|
||||||
|
let reason = "shared semantic priority";
|
||||||
|
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
|
||||||
|
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
|
||||||
|
if (profileIndex >= 0) {
|
||||||
|
score += 4_000 - profileIndex * 120;
|
||||||
|
ruleId = `profile-${slug(ability.name)}`;
|
||||||
|
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.role === "healer") {
|
||||||
|
const healing = actionableHealing
|
||||||
|
&& (healingAbilityCanAidOthers(ability) || lowest?.id === context.selfId);
|
||||||
|
if (!healing && !categories.has("mitigation") && !damaging && !categories.has("resource")
|
||||||
|
&& !maintenanceBuff && !petSummon && !offensiveSummon) return null;
|
||||||
|
if (healing) {
|
||||||
|
if (!lowest || lowestHealth >= 0.9) return null;
|
||||||
|
score += lowestHealth < 0.3 ? 7_000 : lowestHealth < 0.6 ? 5_000 : 3_000;
|
||||||
|
if (categories.has("emergency-heal")) score += lowestHealth < 0.3 ? 1_500 : -1_000;
|
||||||
|
if (categories.has("area-heal")) score += injured.length >= 3 ? 6_500 : -1_200;
|
||||||
|
score += Math.min(500, throughput(ability, context.comboPoints) * 10);
|
||||||
|
} else if (categories.has("mitigation")) {
|
||||||
|
if (lowestHealth >= 0.8 && selfHealth >= 0.8) return null;
|
||||||
|
score += 2_500;
|
||||||
|
} else if (maintenanceBuff) {
|
||||||
|
score += context.maintainedAbilityIds === undefined ? 1_600 : maintenanceScore;
|
||||||
|
ruleId = "maintenance-buff";
|
||||||
|
reason = "missing maintenance buff";
|
||||||
|
} else if (petSummon || offensiveSummon) {
|
||||||
|
score += context.activeSummonAbilityIds === undefined ? 1_400 : summonScore;
|
||||||
|
ruleId = "maintenance-summon";
|
||||||
|
reason = "missing pet or summon";
|
||||||
|
} else if (damaging) {
|
||||||
|
score += injured.length ? -3_000 : 300;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (categories.has("mitigation")) {
|
||||||
|
if (!maintenanceBuff && selfHealth >= 0.55) return null;
|
||||||
|
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_000;
|
||||||
|
}
|
||||||
|
if (categories.has("execute")) {
|
||||||
|
if (targetHealth > 0.2) return null;
|
||||||
|
score += 6_000;
|
||||||
|
ruleId = "execute";
|
||||||
|
reason = "target below twenty percent";
|
||||||
|
}
|
||||||
|
if (categories.has("debuff")) score += 2_000;
|
||||||
|
if (categories.has("buff")) {
|
||||||
|
score += maintenanceBuff && context.maintainedAbilityIds !== undefined
|
||||||
|
? maintenanceScore
|
||||||
|
: 1_600;
|
||||||
|
if (!damaging && !defensive && maintenanceBuff) {
|
||||||
|
ruleId = "maintenance-buff";
|
||||||
|
reason = "missing maintenance buff";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (petSummon || offensiveSummon) {
|
||||||
|
score += damaging || context.activeSummonAbilityIds === undefined
|
||||||
|
? 1_400
|
||||||
|
: summonScore;
|
||||||
|
if (!damaging) {
|
||||||
|
ruleId = "maintenance-summon";
|
||||||
|
reason = "missing pet or summon";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (categories.has("resource")) score += resourceFraction < 0.3 ? 2_600 : -1_000;
|
||||||
|
if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10;
|
||||||
|
if (isComboFinisher(ability)) score += 2_400;
|
||||||
|
if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0;
|
||||||
|
if ((context.hostileCount ?? 1) >= 3) score += categories.has("area-damage") ? 2_000 : -300;
|
||||||
|
else if ((context.hostileCount ?? 1) === 2) score += categories.has("area-damage") ? 900 : 0;
|
||||||
|
else if (categories.has("area-damage")) score -= 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score <= 0) return null;
|
||||||
|
return { ability, score, ruleId, reason, targetId: targetForAbility(ability, context, lowest) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
|
||||||
|
const rotationProfile = rotationProfileFor(
|
||||||
|
context.classId,
|
||||||
|
context.specializationId,
|
||||||
|
context.specialization,
|
||||||
|
);
|
||||||
|
const lowest = [...(context.friendlyTargets ?? [])]
|
||||||
|
.filter((target) => target.health > 0 && !target.fallen)
|
||||||
|
.sort((left, right) => (
|
||||||
|
left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth)
|
||||||
|
|| left.id.localeCompare(right.id)
|
||||||
|
))[0] ?? null;
|
||||||
|
const decision = context.abilities
|
||||||
|
.filter((ability) => !ability.passive)
|
||||||
|
.filter(abilityHasRotationSemantics)
|
||||||
|
.filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||||
|
.filter((ability) => {
|
||||||
|
if (context.classId !== "death-knight") return true;
|
||||||
|
const runeCost = runeCostForAbility(ability);
|
||||||
|
return !runeCost || spendDeathKnightRunes(context.runes, runeCost, context.now) !== null;
|
||||||
|
})
|
||||||
|
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
||||||
|
.map((ability) => scoreAbility(ability, context, rotationProfile, lowest))
|
||||||
|
.filter((candidate): candidate is ScoredRotationAbility => candidate !== null)
|
||||||
|
.sort((left, right) => right.score - left.score
|
||||||
|
|| left.ability.cooldownMs - right.ability.cooldownMs
|
||||||
|
|| left.ability.id.localeCompare(right.ability.id))[0];
|
||||||
|
return decision ? {
|
||||||
|
ability: decision.ability,
|
||||||
|
targetId: decision.targetId,
|
||||||
|
profileId: rotationProfile?.id ?? `semantic:${context.classId}`,
|
||||||
|
ruleId: decision.ruleId,
|
||||||
|
reason: decision.reason,
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { defaultActionBindings } from "./actionBindings";
|
|||||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||||
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
|
||||||
function allocateTalentRanks(name: string, ranks: number): void {
|
function allocateTalentRanks(name: string, ranks: number): void {
|
||||||
const talent = TALENT_NODES.find((node) => node.name === name);
|
const talent = TALENT_NODES.find((node) => node.name === name);
|
||||||
@@ -289,6 +290,8 @@ describe("combat store", () => {
|
|||||||
equipment: {},
|
equipment: {},
|
||||||
});
|
});
|
||||||
expect(useCombatStore.getState()).toMatchObject({
|
expect(useCombatStore.getState()).toMatchObject({
|
||||||
|
specializationId: "priest:holy",
|
||||||
|
gearRole: "healer",
|
||||||
resource: 3_285,
|
resource: 3_285,
|
||||||
maxResource: 3_285,
|
maxResource: 3_285,
|
||||||
});
|
});
|
||||||
@@ -414,6 +417,19 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt");
|
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows learned classic talent actions and clears their bindings after a talent reset", () => {
|
||||||
|
const talentRanks = runtimePartyTalentRanksForLevel("druid", "Balance", 80);
|
||||||
|
const talentAction = abilitiesForClass("druid").find((ability) => ability.talentNodeId
|
||||||
|
&& talentRanks[ability.talentNodeId] && !ability.passive)!;
|
||||||
|
expect(talentAction).toBeDefined();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "druid", specializationId: "druid:balance", level: 80, talentRanks });
|
||||||
|
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(true);
|
||||||
|
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBe(talentAction.id);
|
||||||
|
useCombatStore.getState().resetTalents();
|
||||||
|
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBeNull();
|
||||||
|
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("grants the level-ten starter kit while retaining later ability gates", () => {
|
it("grants the level-ten starter kit while retaining later ability gates", () => {
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1, xp: 0 });
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1, xp: 0 });
|
||||||
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import {
|
|||||||
abilityAtLevel,
|
abilityAtLevel,
|
||||||
abilityById,
|
abilityById,
|
||||||
abilityRankForLevel,
|
abilityRankForLevel,
|
||||||
|
abilityTalentNodeId,
|
||||||
defaultActionBarForClass,
|
defaultActionBarForClass,
|
||||||
defaultActionBarForCharacter,
|
defaultActionBarForCharacter,
|
||||||
isAbilityUnlocked,
|
isAbilityUnlocked,
|
||||||
|
learnedAbilityForBuild,
|
||||||
|
learnedAbilityRank,
|
||||||
resourceProfileForClass,
|
resourceProfileForClass,
|
||||||
triggeredAbilityBySpellId,
|
triggeredAbilityBySpellId,
|
||||||
type AbilityAmountScaling,
|
type AbilityAmountScaling,
|
||||||
@@ -218,6 +221,11 @@ import {
|
|||||||
onlinePlayerRuntimeActor,
|
onlinePlayerRuntimeActor,
|
||||||
onlinePlayerRuntimeActors,
|
onlinePlayerRuntimeActors,
|
||||||
} from "./onlineSessionRuntime";
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
abilityIsPrecombatPreparation,
|
||||||
|
precombatPreparationEffects,
|
||||||
|
rotationProfileFor,
|
||||||
|
} from "./combatRotation";
|
||||||
import {
|
import {
|
||||||
emitCombatPresentation,
|
emitCombatPresentation,
|
||||||
presentationDeliveryForAbility,
|
presentationDeliveryForAbility,
|
||||||
@@ -709,6 +717,8 @@ export interface CombatState {
|
|||||||
synchronizeActors: () => void;
|
synchronizeActors: () => void;
|
||||||
/** Grants learned long-term class stat buffs to the full party without setup casts. */
|
/** Grants learned long-term class stat buffs to the full party without setup casts. */
|
||||||
applyDefaultPartyBuffs: (now?: number) => void;
|
applyDefaultPartyBuffs: (now?: number) => void;
|
||||||
|
/** Applies specialization maintenance buffs and permanent pets before the first pull. */
|
||||||
|
applyPrecombatPreparation: (now?: number) => void;
|
||||||
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
||||||
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
||||||
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
||||||
@@ -1559,9 +1569,8 @@ function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
|
|||||||
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
|
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
|
||||||
if (!ability.passive) return false;
|
if (!ability.passive) return false;
|
||||||
if (ability.source !== "coa-tree") return true;
|
if (ability.source !== "coa-tree") return true;
|
||||||
const node = ability.talentEntryId === undefined
|
const nodeId = abilityTalentNodeId(ability);
|
||||||
? null
|
const node = nodeId ? talentNodeById(nodeId) : null;
|
||||||
: talentNodeById(`coa-entry-${ability.talentEntryId}`);
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
node?.isPassive
|
node?.isPassive
|
||||||
&& (node.abilityEssenceCost ?? 0) === 0
|
&& (node.abilityEssenceCost ?? 0) === 0
|
||||||
@@ -2161,12 +2170,9 @@ function syncPassiveAuras(
|
|||||||
now: number,
|
now: number,
|
||||||
): ActiveAura[] {
|
): ActiveAura[] {
|
||||||
const definitions = abilities.flatMap((catalogAbility) => {
|
const definitions = abilities.flatMap((catalogAbility) => {
|
||||||
if (!catalogAbility.passive || !isAbilityUnlocked(catalogAbility, level)) return [];
|
if (!catalogAbility.passive) return [];
|
||||||
const learnedRank = catalogAbility.talentEntryId === undefined
|
const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks);
|
||||||
? undefined
|
if (!ability) return [];
|
||||||
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
if (catalogAbility.talentEntryId !== undefined && !learnedRank) return [];
|
|
||||||
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
|
||||||
return ability.effects
|
return ability.effects
|
||||||
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||||
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
||||||
@@ -3163,6 +3169,20 @@ function grantDamageResourceInWork(
|
|||||||
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
|
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutUnlearnedTalentBindings(
|
||||||
|
bindings: ActionBindings,
|
||||||
|
abilities: readonly AbilityDefinition[],
|
||||||
|
talentRanks: TalentRanks,
|
||||||
|
): ActionBindings {
|
||||||
|
const unlearned = new Set(abilities
|
||||||
|
.filter((ability) => abilityTalentNodeId(ability) && !(learnedAbilityRank(ability, talentRanks) ?? 0))
|
||||||
|
.map((ability) => ability.id));
|
||||||
|
return Object.fromEntries(Object.entries(bindings).map(([id, abilityId]) => [
|
||||||
|
id,
|
||||||
|
abilityId && unlearned.has(abilityId) ? null : abilityId,
|
||||||
|
])) as ActionBindings;
|
||||||
|
}
|
||||||
|
|
||||||
function timedEffect(
|
function timedEffect(
|
||||||
kind: "dot" | "hot",
|
kind: "dot" | "hot",
|
||||||
abilityId: string,
|
abilityId: string,
|
||||||
@@ -3562,6 +3582,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
...initialState(),
|
...initialState(),
|
||||||
classId,
|
classId,
|
||||||
|
specializationId,
|
||||||
|
gearRole,
|
||||||
secondaryClassId,
|
secondaryClassId,
|
||||||
raceId,
|
raceId,
|
||||||
level: progression.level,
|
level: progression.level,
|
||||||
@@ -3579,7 +3601,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
||||||
talentRanks,
|
talentRanks,
|
||||||
talentModifiers,
|
talentModifiers,
|
||||||
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
actionBindings: withoutUnlearnedTalentBindings(
|
||||||
|
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
||||||
|
abilities,
|
||||||
|
talentRanks,
|
||||||
|
),
|
||||||
inventory,
|
inventory,
|
||||||
bossCards: normalizeBossCardCollection(profile.bossCards),
|
bossCards: normalizeBossCardCollection(profile.bossCards),
|
||||||
equipment,
|
equipment,
|
||||||
@@ -4222,6 +4248,90 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
get().synchronizeActors();
|
get().synchronizeActors();
|
||||||
},
|
},
|
||||||
|
applyPrecombatPreparation: (now = combatNow()) => {
|
||||||
|
get().synchronizeActors();
|
||||||
|
const state = get();
|
||||||
|
const partyMembers = usePartyStore.getState().members;
|
||||||
|
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
|
||||||
|
const sources = [
|
||||||
|
...(includePlayer ? [{
|
||||||
|
id: PLAYER_AURA_ENTITY_ID,
|
||||||
|
classId: state.classId,
|
||||||
|
secondaryClassId: state.secondaryClassId,
|
||||||
|
specializationId: state.specializationId,
|
||||||
|
specialization: specializationById(state.specializationId)?.displayName ?? null,
|
||||||
|
role: state.gearRole,
|
||||||
|
level: state.level,
|
||||||
|
talentRanks: state.talentRanks,
|
||||||
|
abilities: state.abilities,
|
||||||
|
}] : []),
|
||||||
|
...partyMembers.filter((member) => member.health > 0).map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
classId: member.classId,
|
||||||
|
secondaryClassId: member.secondaryClassId ?? null,
|
||||||
|
specializationId: member.specializationId,
|
||||||
|
specialization: member.specialization,
|
||||||
|
role: member.role,
|
||||||
|
level: member.level,
|
||||||
|
talentRanks: member.talentRanks,
|
||||||
|
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
for (const source of sources) {
|
||||||
|
const rotation = rotationProfileFor(source.classId, source.specializationId, source.specialization);
|
||||||
|
const priority = (ability: AbilityDefinition) => {
|
||||||
|
const index = rotation?.abilityPriority.indexOf(ability.name) ?? -1;
|
||||||
|
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
|
||||||
|
};
|
||||||
|
const abilities = source.abilities
|
||||||
|
.map((ability) => learnedAbilityForBuild(ability, source.level, source.talentRanks))
|
||||||
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
|
.filter(abilityIsPrecombatPreparation)
|
||||||
|
.filter((ability) => !rotation || priority(ability) < Number.MAX_SAFE_INTEGER)
|
||||||
|
.sort((left, right) => priority(left) - priority(right) || left.name.localeCompare(right.name));
|
||||||
|
for (const ability of abilities) {
|
||||||
|
const effects = precombatPreparationEffects(ability);
|
||||||
|
const auraEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
|
||||||
|
effect.kind === "apply-aura"
|
||||||
|
));
|
||||||
|
const current = get();
|
||||||
|
const maintained = current.auras.some((aura) => (
|
||||||
|
aura.targetId === source.id
|
||||||
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||||
|
&& auraEffects.some((effect) => effect.aura.id === aura.definition.id)
|
||||||
|
));
|
||||||
|
const blockedByExclusiveGroup = current.auras.some((aura) => (
|
||||||
|
aura.targetId === source.id
|
||||||
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||||
|
&& Boolean(aura.definition.exclusiveGroup)
|
||||||
|
&& auraEffects.some((effect) => effect.aura.exclusiveGroup === aura.definition.exclusiveGroup)
|
||||||
|
));
|
||||||
|
if (!maintained && !blockedByExclusiveGroup) {
|
||||||
|
for (const effect of auraEffects) get().applyActorAura(source.id, source.id, ability, effect, now);
|
||||||
|
}
|
||||||
|
const petEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "pet" }> => (
|
||||||
|
effect.kind === "pet"
|
||||||
|
));
|
||||||
|
if (!petEffects.length) continue;
|
||||||
|
const hasActivePet = get().summons.some((summon) => {
|
||||||
|
if (summon.ownerActorId !== source.id || summon.expiresAt <= now) return false;
|
||||||
|
const summonAbility = abilityById(summon.sourceAbilityId);
|
||||||
|
return summonAbility?.effects.some((effect) => effect.kind === "pet"
|
||||||
|
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
|
||||||
|
});
|
||||||
|
if (hasActivePet) continue;
|
||||||
|
const position = actorPosition(get(), source.id) ?? get().playerPosition;
|
||||||
|
const petEffect = petEffects[0];
|
||||||
|
get().summonActor(source.id, ability, {
|
||||||
|
kind: "summon",
|
||||||
|
creatureId: petEffect.creatureId ?? 1,
|
||||||
|
coefficient: 0.35,
|
||||||
|
durationMs: Number.MAX_SAFE_INTEGER - now,
|
||||||
|
}, position, null, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get().synchronizeActors();
|
||||||
|
},
|
||||||
commitCompanionActors: (members) => {
|
commitCompanionActors: (members) => {
|
||||||
let actors: Readonly<Record<string, CombatActorState>> = {};
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -4254,9 +4364,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
const catalogAbility = abilityById(abilityId);
|
const catalogAbility = abilityById(abilityId);
|
||||||
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
||||||
? undefined
|
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
||||||
: state.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
const ability = catalogAbility
|
const ability = catalogAbility
|
||||||
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
||||||
: null;
|
: null;
|
||||||
@@ -4287,7 +4396,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
let failure: CastFailureReason | undefined;
|
let failure: CastFailureReason | undefined;
|
||||||
if (!ability) failure = "unknown-ability";
|
if (!ability) failure = "unknown-ability";
|
||||||
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
|
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
|
||||||
else if (ability.talentEntryId !== undefined && !learnedTalentRank) failure = "not-learned";
|
else if (talentNodeId && !learnedTalentRank) failure = "not-learned";
|
||||||
else if (ability.passive) failure = "passive";
|
else if (ability.passive) failure = "passive";
|
||||||
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
|
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
|
||||||
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
|
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
|
||||||
@@ -8412,11 +8521,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const cast = get().activeCast;
|
const cast = get().activeCast;
|
||||||
if (cast?.mode === "channel") {
|
if (cast?.mode === "channel") {
|
||||||
const catalogAbility = abilityById(cast.abilityId);
|
const catalogAbility = abilityById(cast.abilityId);
|
||||||
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
|
||||||
? undefined
|
|
||||||
: get().talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
const ability = catalogAbility
|
const ability = catalogAbility
|
||||||
? abilityAtLevel(catalogAbility, get().level, learnedTalentRank)
|
? learnedAbilityForBuild(catalogAbility, get().level, get().talentRanks)
|
||||||
: null;
|
: null;
|
||||||
let ticksCompleted = cast.ticksCompleted;
|
let ticksCompleted = cast.ticksCompleted;
|
||||||
let nextTickAt = cast.nextTickAt;
|
let nextTickAt = cast.nextTickAt;
|
||||||
@@ -8775,6 +8881,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
maxResource,
|
maxResource,
|
||||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||||
auras,
|
auras,
|
||||||
|
actionBindings: withoutUnlearnedTalentBindings(state.actionBindings, state.abilities, talentRanks),
|
||||||
feedback: nextFeedback("talent", "Talent points reset."),
|
feedback: nextFeedback("talent", "Talent points reset."),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -8783,6 +8890,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (abilityId !== null && !state.abilities.some((ability) => (
|
if (abilityId !== null && !state.abilities.some((ability) => (
|
||||||
ability.id === abilityId && !ability.passive
|
ability.id === abilityId && !ability.passive
|
||||||
|
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, state.talentRanks)))
|
||||||
))) return false;
|
))) return false;
|
||||||
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
|
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRun
|
|||||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
||||||
import { subscribeCombatEvents } from "./combatEvents";
|
import { subscribeCombatEvents } from "./combatEvents";
|
||||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||||
|
import { dungeonEnemyHealthBaseline } from "./dungeonLevelScaling";
|
||||||
import { abilitiesForClass } from "./abilityCatalog";
|
import { abilitiesForClass } from "./abilityCatalog";
|
||||||
import type { ContentCategoryId } from "../app/contentCategories";
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
|
|
||||||
@@ -80,11 +81,28 @@ describe("dungeon boss durability and difficulty", () => {
|
|||||||
const stockadesBoss = stockades.bosses[0];
|
const stockadesBoss = stockades.bosses[0];
|
||||||
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
|
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
|
||||||
|
|
||||||
|
it("uses the party health curve for trash regardless of dungeon catalog", () => {
|
||||||
|
const abbey = requireDungeonDefinition("forsaken-abbey");
|
||||||
|
const trashFrom = (definition: typeof stockades) => {
|
||||||
|
const spawn = definition.staticSpawns.find((entry) => definition.entities[entry.entityId].kind === "mob")!;
|
||||||
|
return { entity: definition.entities[spawn.entityId], instanceId: spawn.id };
|
||||||
|
};
|
||||||
|
const stockadesTrash = trashFrom(stockades);
|
||||||
|
const abbeyTrash = trashFrom(abbey);
|
||||||
|
for (const source of ["wow", "rom", "coa"] as const) {
|
||||||
|
const expected = dungeonEnemyHealthBaseline(5, source);
|
||||||
|
expect(dungeonEnemyScaling(stockades, stockadesTrash.entity, stockadesTrash.instanceId,
|
||||||
|
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
|
||||||
|
expect(dungeonEnemyScaling(abbey, abbeyTrash.entity, abbeyTrash.instanceId,
|
||||||
|
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||||
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
||||||
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 });
|
expect(easy).toMatchObject({ level: 4, maxHealth: 1365 });
|
||||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
||||||
.toBe(1300 * bossDifficultyMultipliers(10).health);
|
.toBe(1365 * bossDifficultyMultipliers(10).health);
|
||||||
// The dungeon catalog must not choose the companion power curve.
|
// The dungeon catalog must not choose the companion power curve.
|
||||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
||||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
||||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
import { dungeonEnemyHealthBaseline, dungeonPartyHealthScale, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||||
import type { DungeonDefinition } from "./dungeonTypes";
|
import type { DungeonDefinition } from "./dungeonTypes";
|
||||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||||
import type { ContentCategoryId } from "../app/contentCategories";
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
|
|
||||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||||
|
// The starter anchors include enough room for authentic presence, imbue,
|
||||||
|
// disease, and shock rotations to retain the 25/45-second mechanic window.
|
||||||
|
[2, 950],
|
||||||
|
[4, 1_365],
|
||||||
|
[9, 7_000],
|
||||||
|
[21, 11_000],
|
||||||
|
[41, 23_000],
|
||||||
|
[80, 180_000],
|
||||||
|
];
|
||||||
|
const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||||
[2, 900],
|
[2, 900],
|
||||||
[4, 1_300],
|
[4, 1_300],
|
||||||
[9, 7_000],
|
[9, 7_000],
|
||||||
@@ -26,12 +36,15 @@ function interpolatedFloor(level: number, anchors: readonly (readonly [number, n
|
|||||||
return lowerHealth + (upperHealth - lowerHealth) * progress;
|
return lowerHealth + (upperHealth - lowerHealth) * progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number {
|
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId, testing: boolean): number {
|
||||||
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR);
|
if (characterSource === "wow") return interpolatedFloor(
|
||||||
|
level,
|
||||||
|
testing ? WOW_BOSS_TEST_HEALTH_FLOOR : WOW_BOSS_HEALTH_FLOOR,
|
||||||
|
);
|
||||||
const baseline = 1_000 + level * 1_200 + level * level * 22;
|
const baseline = 1_000 + level * 1_200 + level * level * 22;
|
||||||
// Each catalog is calibrated independently against its strongest canonical
|
// Each catalog is calibrated independently against its strongest canonical
|
||||||
// class-role cohort so every fight retains the 25/45-second mechanic window.
|
// class-role cohort so every fight retains the 25/45-second mechanic window.
|
||||||
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
|
return baseline * 1.15 * dungeonPartyHealthScale(characterSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */
|
/** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */
|
||||||
@@ -43,13 +56,17 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
|||||||
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
||||||
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
||||||
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
||||||
const baseline = dungeonEnemyHealthBaseline(level, dungeon.expansion);
|
const baseline = dungeonEnemyHealthBaseline(level, characterSource);
|
||||||
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
|
// Isolated testing rooms retain a packaged source boss's native health so
|
||||||
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
|
// developers can inspect it without changing campaign cross-catalog balance.
|
||||||
|
const native = testing && entity.combat?.serverEntry
|
||||||
|
? creatureRuntimeBaseStats(entity.combat.serverEntry, level)
|
||||||
|
: null;
|
||||||
|
const originalHealth = (native?.health ?? baseline) * authoredHealth;
|
||||||
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
||||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||||
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
|
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
|
||||||
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
||||||
// A test room's sole objective must not promote every boss to a campaign
|
// A test room's sole objective must not promote every boss to a campaign
|
||||||
// finale. A reduced floor still prevents high-level source creatures with
|
// finale. A reduced floor still prevents high-level source creatures with
|
||||||
|
|||||||
@@ -14,18 +14,19 @@ describe("normal dungeon level scaling", () => {
|
|||||||
expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
|
expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("grows RoM health with ability ranks and clamps invalid levels", () => {
|
it("uses source-calibrated health curves for every party", () => {
|
||||||
expect(dungeonEnemyHealthBaseline(1, "runewaker")).toBeGreaterThan(1_000);
|
expect(dungeonEnemyHealthBaseline(5, "wow")).toBe(306);
|
||||||
expect(dungeonEnemyHealthBaseline(20, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "runewaker")!);
|
expect(dungeonEnemyHealthBaseline(5, "rom")).toBe(1_700);
|
||||||
expect(dungeonEnemyHealthBaseline(70, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "runewaker")! * 5);
|
expect(dungeonEnemyHealthBaseline(5, "coa")).toBe(2_587);
|
||||||
expect(dungeonEnemyHealthBaseline(0, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
expect(dungeonEnemyHealthBaseline(20, "rom")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "rom"));
|
||||||
expect(dungeonEnemyHealthBaseline(100, "runewaker")).toBe(dungeonEnemyHealthBaseline(80, "runewaker"));
|
expect(dungeonEnemyHealthBaseline(20, "coa")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "rom"));
|
||||||
expect(dungeonEnemyHealthBaseline(Number.NaN, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retains native WoW creature health across its three expansions", () => {
|
it("clamps every party curve to the supported player-level range", () => {
|
||||||
for (const expansion of ["classic", "burning-crusade", "wrath"] as const) {
|
for (const source of ["wow", "rom", "coa"] as const) {
|
||||||
expect(dungeonEnemyHealthBaseline(20, expansion)).toBeUndefined();
|
expect(dungeonEnemyHealthBaseline(0, source)).toBe(dungeonEnemyHealthBaseline(1, source));
|
||||||
|
expect(dungeonEnemyHealthBaseline(100, source)).toBe(dungeonEnemyHealthBaseline(80, source));
|
||||||
|
expect(dungeonEnemyHealthBaseline(Number.NaN, source)).toBe(dungeonEnemyHealthBaseline(1, source));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
|
import { standardWowDungeonCreatureHealth } from "./azerothCoreReference";
|
||||||
import { clampPlayerLevel } from "./progression";
|
import { clampPlayerLevel } from "./progression";
|
||||||
import type { DungeonExpansion } from "./dungeonTypes";
|
|
||||||
|
const ROM_PARTY_HEALTH_SCALE = 1.15;
|
||||||
|
const COA_PARTY_HEALTH_SCALE = 1.75;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normal campaign dungeons scale to the character instead of retaining the
|
* Normal campaign dungeons scale to the character instead of retaining the
|
||||||
@@ -11,13 +15,21 @@ export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RoM creatures do not have packaged native health tables. Give their dungeon
|
* Enemy durability follows the party's combat catalog, not the dungeon's
|
||||||
* baseline enough growth for a full party's rank-scaled abilities; the old
|
* source game. This lets a WoW party take its familiar curve into a RoM
|
||||||
* 72 + level * 22 solo-target fallback was overwhelmed by the opening volley.
|
* dungeon while stronger RoM and CoA parties retain meaningful kill times in
|
||||||
* Authored creature and boss health multipliers are applied separately.
|
* every catalog. Authored creature and boss multipliers are applied separately.
|
||||||
*/
|
*/
|
||||||
export function dungeonEnemyHealthBaseline(level: number, expansion: DungeonExpansion): number | undefined {
|
export function dungeonEnemyHealthBaseline(level: number, partySource: ContentCategoryId): number {
|
||||||
if (expansion !== "runewaker") return undefined;
|
|
||||||
const safeLevel = clampPlayerLevel(level);
|
const safeLevel = clampPlayerLevel(level);
|
||||||
return 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
|
if (partySource === "wow") return standardWowDungeonCreatureHealth(safeLevel);
|
||||||
|
const romBaseline = 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
|
||||||
|
if (partySource === "rom") return romBaseline;
|
||||||
|
return Math.round(romBaseline * COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps trash and boss source calibration on the same relative power scale. */
|
||||||
|
export function dungeonPartyHealthScale(partySource: ContentCategoryId): number {
|
||||||
|
if (partySource === "wow" || partySource === "rom") return 1;
|
||||||
|
return COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ it("turns an actual RoM party's lethal damage to Anglo into one timed completion
|
|||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
const bossLevel = scaledDungeonEnemyLevel(1, true);
|
const bossLevel = scaledDungeonEnemyLevel(1, true);
|
||||||
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
|
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
|
||||||
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "runewaker")! * healthMultiplier,
|
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "rom") * healthMultiplier,
|
||||||
healthMultiplier, attacks: bossCombat.attacks });
|
healthMultiplier, attacks: bossCombat.attacks });
|
||||||
setMobPosition(boss.id, [0, 0, 0]);
|
setMobPosition(boss.id, [0, 0, 0]);
|
||||||
for (const member of usePartyStore.getState().members) {
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ClassId } from "../app/characterCatalog";
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
import {
|
import {
|
||||||
abilitiesForClass,
|
abilitiesForClass,
|
||||||
abilityAtLevel,
|
learnedAbilityForBuild,
|
||||||
type AbilityDefinition,
|
type AbilityDefinition,
|
||||||
} from "./abilityCatalog";
|
} from "./abilityCatalog";
|
||||||
import {
|
import {
|
||||||
@@ -82,21 +82,14 @@ const PRIMARY_LOADOUT_CONTROLS: readonly ActionBindingControl[] = [
|
|||||||
"face-right",
|
"face-right",
|
||||||
];
|
];
|
||||||
|
|
||||||
function learnedTalentRank(ability: AbilityDefinition, talentRanks: Readonly<Record<string, number>>): number {
|
|
||||||
return ability.talentEntryId === undefined
|
|
||||||
? 0
|
|
||||||
: Math.max(0, Math.trunc(talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectableClientSpell(
|
function isSelectableClientSpell(
|
||||||
ability: AbilityDefinition,
|
ability: AbilityDefinition,
|
||||||
level: number,
|
level: number,
|
||||||
talentRanks: Readonly<Record<string, number>>,
|
talentRanks: Readonly<Record<string, number>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
return !ability.passive
|
return !ability.passive
|
||||||
&& ability.unlockLevel <= level
|
|
||||||
&& CLIENT_GROUNDED_SOURCES.has(ability.source)
|
&& CLIENT_GROUNDED_SOURCES.has(ability.source)
|
||||||
&& (ability.talentEntryId === undefined || learnedTalentRank(ability, talentRanks) > 0);
|
&& learnedAbilityForBuild(ability, level, talentRanks) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function manastormActiveSpellLoadoutModel(
|
export function manastormActiveSpellLoadoutModel(
|
||||||
@@ -108,7 +101,7 @@ export function manastormActiveSpellLoadoutModel(
|
|||||||
const options = abilitiesForClass(classId)
|
const options = abilitiesForClass(classId)
|
||||||
.filter((ability) => isSelectableClientSpell(ability, level, talentRanks))
|
.filter((ability) => isSelectableClientSpell(ability, level, talentRanks))
|
||||||
.map((ability) => {
|
.map((ability) => {
|
||||||
const resolved = abilityAtLevel(ability, level, learnedTalentRank(ability, talentRanks));
|
const resolved = learnedAbilityForBuild(ability, level, talentRanks)!;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
id: ability.id,
|
id: ability.id,
|
||||||
abilityId: ability.id,
|
abilityId: ability.id,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
abilitiesForClass,
|
abilitiesForCharacter,
|
||||||
abilityAtLevel,
|
learnedAbilityForBuild,
|
||||||
isAbilityUnlocked,
|
|
||||||
type AbilityDefinition,
|
type AbilityDefinition,
|
||||||
type AbilityEffect,
|
type AbilityEffect,
|
||||||
type ResourceType,
|
type ResourceType,
|
||||||
} from "./abilityCatalog";
|
} from "./abilityCatalog";
|
||||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||||
|
import {
|
||||||
|
evaluateCombatRotation,
|
||||||
|
type RotationDecision,
|
||||||
|
type RotationFriendlyTarget,
|
||||||
|
} from "./combatRotation";
|
||||||
import { spendDeathKnightRunes } from "./combatResources";
|
import { spendDeathKnightRunes } from "./combatResources";
|
||||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||||
import type { PartyMember } from "./partyStore";
|
import type { PartyMember } from "./partyStore";
|
||||||
@@ -17,11 +21,18 @@ export interface PartyAbilityContext {
|
|||||||
readonly now: number;
|
readonly now: number;
|
||||||
readonly target: MobCombatState | null;
|
readonly target: MobCombatState | null;
|
||||||
readonly targetIsLoose?: boolean;
|
readonly targetIsLoose?: boolean;
|
||||||
|
readonly hasDangerousThreat?: boolean;
|
||||||
readonly requiredControl?: boolean;
|
readonly requiredControl?: boolean;
|
||||||
readonly allyNeedsDispel?: boolean;
|
readonly allyNeedsDispel?: boolean;
|
||||||
readonly fallenAlly?: boolean;
|
readonly fallenAlly?: boolean;
|
||||||
readonly injuredAllies?: number;
|
readonly injuredAllies?: number;
|
||||||
readonly lowestHealthFraction?: number;
|
readonly lowestHealthFraction?: number;
|
||||||
|
readonly hostileCount?: number;
|
||||||
|
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||||
|
readonly maintainedAbilityIds?: ReadonlySet<string>;
|
||||||
|
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||||
|
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||||
|
readonly hasActivePet?: boolean;
|
||||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +50,7 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||||
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
||||||
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
|
|
||||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||||
@@ -51,62 +61,67 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
|||||||
}
|
}
|
||||||
|
|
||||||
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||||
return abilitiesForClass(member.classId)
|
return abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||||
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||||
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
export function choosePartyRotationDecision(
|
||||||
const categories = new Set(aiHintsForAbility(ability).categories);
|
member: PartyMember,
|
||||||
const damaging = categories.has("damage") || categories.has("execute");
|
context: PartyAbilityContext,
|
||||||
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
|
): RotationDecision | null {
|
||||||
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
|
const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel
|
||||||
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
|
|| (context.lowestHealthFraction ?? 1) < 1
|
||||||
if (!damaging && control && !context.requiredControl) return 0;
|
? [{
|
||||||
let score = ability.ai?.priority ?? 0;
|
id: "rotation-friendly-target",
|
||||||
if (member.role === "tank") {
|
health: (context.lowestHealthFraction ?? 1) * 100,
|
||||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
maxHealth: 100,
|
||||||
if (context.targetIsLoose && categories.has("taunt")) score += 900;
|
fallen: context.fallenAlly,
|
||||||
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760;
|
needsDispel: context.allyNeedsDispel,
|
||||||
if (categories.has("control") && context.requiredControl) score += 700;
|
}]
|
||||||
if (categories.has("debuff")) score += 300;
|
: []);
|
||||||
if (categories.has("damage")) score += 180;
|
const maximumResources = Object.fromEntries(
|
||||||
} else if (member.role === "healer") {
|
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
||||||
if (context.fallenAlly && categories.has("resurrection")) score += 1_200;
|
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||||
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050;
|
);
|
||||||
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
|
return evaluateCombatRotation({
|
||||||
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
|
now: context.now,
|
||||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
|
classId: member.classId,
|
||||||
if (categories.has("mitigation") && ((context.lowestHealthFraction ?? 1) < 0.8 || member.health / member.maxHealth < 0.8)) score += 350;
|
secondaryClassId: member.secondaryClassId,
|
||||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) {
|
specializationId: member.specializationId,
|
||||||
score += Math.min(150, ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
specialization: member.specialization,
|
||||||
? partyAbilityAmount(member, ability, effect) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0) / member.maxHealth * 100);
|
role: member.role,
|
||||||
}
|
level: member.level,
|
||||||
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
|
health: member.health,
|
||||||
if (categories.has("damage")) score += 80;
|
maxHealth: member.maxHealth,
|
||||||
} else {
|
selfId: member.id,
|
||||||
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
resources: member.resourcePools,
|
||||||
if (context.requiredControl && categories.has("control")) score += 900;
|
maximumResources,
|
||||||
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800;
|
primaryResource: member.resourceType,
|
||||||
if (categories.has("debuff")) score += 520;
|
comboPoints: member.comboPoints,
|
||||||
if (categories.has("damage")) score += 300;
|
comboTargetId: member.comboTargetId,
|
||||||
}
|
runes: member.runes,
|
||||||
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
|
abilities: candidateAbilities(member, context.now),
|
||||||
return score;
|
hostileTarget: context.target,
|
||||||
|
friendlyTargets,
|
||||||
|
hostileCount: context.hostileCount,
|
||||||
|
targetIsLoose: context.targetIsLoose,
|
||||||
|
hasDangerousThreat: context.hasDangerousThreat,
|
||||||
|
requiredControl: context.requiredControl,
|
||||||
|
maintainedAbilityIds: context.maintainedAbilityIds,
|
||||||
|
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
|
||||||
|
activeSummonAbilityIds: context.activeSummonAbilityIds,
|
||||||
|
hasActivePet: context.hasActivePet,
|
||||||
|
canUseAbility: context.canUseAbility,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function choosePartyAbility(
|
export function choosePartyAbility(
|
||||||
member: PartyMember,
|
member: PartyMember,
|
||||||
context: PartyAbilityContext,
|
context: PartyAbilityContext,
|
||||||
): AbilityDefinition | null {
|
): AbilityDefinition | null {
|
||||||
return candidateAbilities(member, context.now)
|
return choosePartyRotationDecision(member, context)?.ability ?? null;
|
||||||
.filter((ability) => context.canUseAbility?.(ability) ?? true)
|
|
||||||
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
|
||||||
.filter((candidate) => candidate.score > 0)
|
|
||||||
.sort((left, right) => right.score - left.score
|
|
||||||
|| left.ability.cooldownMs - right.ability.cooldownMs
|
|
||||||
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commitPartyAbilityUse(
|
export function commitPartyAbilityUse(
|
||||||
@@ -155,7 +170,7 @@ export function partyAbilityAmount(
|
|||||||
const coefficient = "coefficient" in effect
|
const coefficient = "coefficient" in effect
|
||||||
? effect.coefficient
|
? effect.coefficient
|
||||||
: effect.kind === "finisher-damage"
|
: effect.kind === "finisher-damage"
|
||||||
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
|
||||||
: 0;
|
: 0;
|
||||||
return equipmentAdjustedCombatAmount(
|
return equipmentAdjustedCombatAmount(
|
||||||
"coefficient" in effect ? effect : coefficient,
|
"coefficient" in effect ? effect : coefficient,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
abilitiesForClass,
|
abilitiesForClass,
|
||||||
|
abilitiesForCharacter,
|
||||||
abilityAtLevel,
|
abilityAtLevel,
|
||||||
abilityById,
|
abilityById,
|
||||||
|
learnedAbilityForBuild,
|
||||||
isAbilityUnlocked,
|
isAbilityUnlocked,
|
||||||
resourceProfileForClass,
|
resourceProfileForClass,
|
||||||
triggeredAbilityBySpellId,
|
triggeredAbilityBySpellId,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
type ResourceType,
|
type ResourceType,
|
||||||
} from "./abilityCatalog";
|
} from "./abilityCatalog";
|
||||||
import {
|
import {
|
||||||
|
aiHintsForAbility,
|
||||||
canAbilityCastWhileMoving,
|
canAbilityCastWhileMoving,
|
||||||
targetingRulesForAbility,
|
targetingRulesForAbility,
|
||||||
} from "./abilityRules";
|
} from "./abilityRules";
|
||||||
@@ -50,6 +53,7 @@ import {
|
|||||||
} from "./combatPresentation";
|
} from "./combatPresentation";
|
||||||
import {
|
import {
|
||||||
choosePartyAbility,
|
choosePartyAbility,
|
||||||
|
choosePartyRotationDecision,
|
||||||
commitPartyAbilityUse,
|
commitPartyAbilityUse,
|
||||||
partyAbilityAmount,
|
partyAbilityAmount,
|
||||||
} from "./partyAbilityAi";
|
} from "./partyAbilityAi";
|
||||||
@@ -231,9 +235,9 @@ export function partyUsesRangedAttacks(
|
|||||||
|
|
||||||
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
|
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
|
||||||
return Math.max(0, ...abilitiesForClass(member.classId)
|
return Math.max(0, ...abilitiesForClass(member.classId)
|
||||||
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
|
.filter((ability) => !ability.passive)
|
||||||
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
.map((ability) => learnedAbilityForBuild(ability, member.level ?? 80, member.talentRanks ?? {}))
|
||||||
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
|
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
|
||||||
.map((ability) => ability.range.max));
|
.map((ability) => ability.range.max));
|
||||||
}
|
}
|
||||||
@@ -367,11 +371,11 @@ function partyHealAmount(level: number, spellPower = 0): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
|
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
|
||||||
const definition = abilitiesForClass(member.classId)
|
return abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||||
.filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any"))
|
.filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any"))
|
||||||
.filter((ability) => isAbilityUnlocked(ability, member.level))
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||||
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
return definition ? abilityAtLevel(definition, member.level) : null;
|
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function partyResurrectionPresentation(
|
function partyResurrectionPresentation(
|
||||||
@@ -913,6 +917,11 @@ function choosePartySupportIntent(
|
|||||||
target: null,
|
target: null,
|
||||||
injuredAllies: injured.length,
|
injuredAllies: injured.length,
|
||||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||||
|
friendlyTargets: injured.map((candidate) => ({
|
||||||
|
id: candidate.id,
|
||||||
|
health: candidate.health,
|
||||||
|
maxHealth: candidate.maxHealth,
|
||||||
|
})),
|
||||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
||||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||||
@@ -975,6 +984,9 @@ function executePartyAbility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (effect.kind === "finisher-damage") {
|
||||||
|
runtimeMember = { ...runtimeMember, comboPoints: 0, comboTargetId: null };
|
||||||
|
}
|
||||||
} else if (effect.kind === "heal") {
|
} else if (effect.kind === "heal") {
|
||||||
for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
||||||
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
|
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
|
||||||
@@ -1074,6 +1086,14 @@ function executePartyAbility(
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (effect.kind === "combo") {
|
||||||
|
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
||||||
|
const comboPoints = Math.max(0, Math.min(5, current + effect.amount));
|
||||||
|
runtimeMember = {
|
||||||
|
...runtimeMember,
|
||||||
|
comboPoints,
|
||||||
|
comboTargetId: comboPoints > 0 ? targetId : null,
|
||||||
|
};
|
||||||
} else if (effect.kind === "summon") {
|
} else if (effect.kind === "summon") {
|
||||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
|
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
|
||||||
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
||||||
@@ -1451,25 +1471,64 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
nextMembers[index] = runtimeMember;
|
nextMembers[index] = runtimeMember;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const selectedAbility = choosePartyAbility(runtimeMember, {
|
const selectedDecision = choosePartyRotationDecision(runtimeMember, {
|
||||||
now,
|
now,
|
||||||
target: targetMob ?? null,
|
target: targetMob ?? null,
|
||||||
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
|
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
|
||||||
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
|
hasDangerousThreat: member.role !== "tank" && targetMob?.targetActorId === member.id,
|
||||||
|
hostileCount: Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead).length,
|
||||||
|
maintainedAbilityIds: new Set([
|
||||||
|
...currentCombat.effects.filter((effect) => effect.sourceActorId === member.id && effect.remainingTicks > 0)
|
||||||
|
.map((effect) => effect.sourceAbilityId),
|
||||||
|
...currentCombat.auras.filter((aura) => aura.sourceId === member.id
|
||||||
|
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||||
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||||
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
|
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.id === aura.definition.id))
|
||||||
|
.map((ability) => ability.id)),
|
||||||
|
]),
|
||||||
|
activeAuraExclusiveGroups: new Set(currentCombat.auras
|
||||||
|
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
|
||||||
|
activeSummonAbilityIds: new Set(currentCombat.summons
|
||||||
|
.filter((summon) => summon.ownerActorId === member.id && summon.expiresAt > now)
|
||||||
|
.map((summon) => summon.sourceAbilityId)),
|
||||||
|
hasActivePet: currentCombat.summons.some((summon) => {
|
||||||
|
if (summon.ownerActorId !== member.id || summon.expiresAt <= now) return false;
|
||||||
|
const source = abilityById(summon.sourceAbilityId);
|
||||||
|
return source?.effects.some((effect) => effect.kind === "pet"
|
||||||
|
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
|
||||||
|
}),
|
||||||
|
canUseAbility: (ability) => {
|
||||||
|
const hints = new Set(aiHintsForAbility(ability).categories);
|
||||||
|
const hostileSelfArea = ability.target === "self" && (ability.radius ?? 0) > 0
|
||||||
|
&& (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
|
||||||
|
.some((category) => hints.has(category));
|
||||||
|
const abilityTargetId = (ability.target === "self" && !hostileSelfArea) || ability.target === "friendly"
|
||||||
|
? member.id
|
||||||
|
: memberTargetId;
|
||||||
|
return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId)
|
||||||
// Let maintained effects tick instead of resetting their first tick on every GCD.
|
// Let maintained effects tick instead of resetting their first tick on every GCD.
|
||||||
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
|
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
|
||||||
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
|
&& effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0)
|
||||||
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
|
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
|
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
|
||||||
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
|
&& aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const selectedAbility = selectedDecision?.ability ?? null;
|
||||||
if (!selectedAbility) {
|
if (!selectedAbility) {
|
||||||
const inAttackRange = targetVisible && targetPosition
|
const inAttackRange = targetVisible && targetPosition
|
||||||
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
|
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
|
||||||
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
|
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
|
const abilityTargetId = selectedDecision?.targetId
|
||||||
|
?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId);
|
||||||
|
const inRange = (selectedAbility.target === "self" || targetVisible)
|
||||||
|
&& partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId);
|
||||||
if (!inRange) {
|
if (!inRange) {
|
||||||
nextMembers[index] = runtimeMember.status === "advancing"
|
nextMembers[index] = runtimeMember.status === "advancing"
|
||||||
? runtimeMember
|
? runtimeMember
|
||||||
@@ -1494,14 +1553,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
originalDurationMs: selectedAbility.castTimeMs,
|
originalDurationMs: selectedAbility.castTimeMs,
|
||||||
pushbackHits: 0,
|
pushbackHits: 0,
|
||||||
origin: memberPosition,
|
origin: memberPosition,
|
||||||
targetId: memberTargetId,
|
targetId: abilityTargetId,
|
||||||
},
|
},
|
||||||
activeCastTargetId: memberTargetId,
|
activeCastTargetId: abilityTargetId,
|
||||||
nextActionAt: now + selectedAbility.castTimeMs,
|
nextActionAt: now + selectedAbility.castTimeMs,
|
||||||
};
|
};
|
||||||
emitPartyAbility(member, selectedAbility, memberTargetId, "cast-start", now);
|
emitPartyAbility(member, selectedAbility, abilityTargetId, "cast-start", now);
|
||||||
} else {
|
} else {
|
||||||
const execution = executePartyAbility(committed, selectedAbility, memberTargetId, now, nextMembers);
|
const execution = executePartyAbility(committed, selectedAbility, abilityTargetId, now, nextMembers);
|
||||||
nextMembers[index] = {
|
nextMembers[index] = {
|
||||||
...execution.member,
|
...execution.member,
|
||||||
status: desiredStatus,
|
status: desiredStatus,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface PartyMember {
|
|||||||
readonly maxResource: number;
|
readonly maxResource: number;
|
||||||
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
|
readonly comboPoints: number;
|
||||||
|
readonly comboTargetId: string | null;
|
||||||
readonly cooldowns: Readonly<Record<string, number>>;
|
readonly cooldowns: Readonly<Record<string, number>>;
|
||||||
readonly globalCooldownEndsAt: number;
|
readonly globalCooldownEndsAt: number;
|
||||||
readonly activeCast: CombatActorCastState | null;
|
readonly activeCast: CombatActorCastState | null;
|
||||||
@@ -307,6 +309,8 @@ function createMember(
|
|||||||
maxResource,
|
maxResource,
|
||||||
resourcePools: Object.freeze({ [resourceProfile.type]: resource }),
|
resourcePools: Object.freeze({ [resourceProfile.type]: resource }),
|
||||||
lastResourceSpentAt: Object.freeze({}),
|
lastResourceSpentAt: Object.freeze({}),
|
||||||
|
comboPoints: 0,
|
||||||
|
comboTargetId: null,
|
||||||
cooldowns: Object.freeze({}),
|
cooldowns: Object.freeze({}),
|
||||||
globalCooldownEndsAt: 0,
|
globalCooldownEndsAt: 0,
|
||||||
activeCast: null,
|
activeCast: null,
|
||||||
|
|||||||
@@ -497,6 +497,30 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
|||||||
const specialized = specializedEffects(row);
|
const specialized = specializedEffects(row);
|
||||||
if (specialized) return specialized;
|
if (specialized) return specialized;
|
||||||
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
||||||
|
const outputStrength = (effect: AbilityEffect): number => (
|
||||||
|
"coefficient" in effect ? effect.coefficient : 0
|
||||||
|
) + ("coefficientPerLevel" in effect ? effect.coefficientPerLevel ?? 0 : 0)
|
||||||
|
+ ("basePoints" in effect ? effect.basePoints ?? 0 : 0)
|
||||||
|
+ ("pointsPerLevel" in effect ? effect.pointsPerLevel ?? 0 : 0);
|
||||||
|
const oneOutput = (kind: "damage" | "dot", strongest: boolean): readonly AbilityEffect[] => translated
|
||||||
|
.filter((effect) => effect.kind === kind)
|
||||||
|
.sort((left, right) => (strongest ? -1 : 1) * (outputStrength(left) - outputStrength(right)))
|
||||||
|
.slice(0, 1);
|
||||||
|
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
|
||||||
|
// Model it through the combat engine's combo authority so both players and
|
||||||
|
// companions build seals before Punishment instead of casting the payoff on
|
||||||
|
// an unsealed target. Source components contain one alternative payload per
|
||||||
|
// seal count; executing every branch at once greatly overstates the skills.
|
||||||
|
if (row.sourceSkillId === 490148) return [
|
||||||
|
...oneOutput("damage", true), ...oneOutput("dot", false), { kind: "combo", amount: 1 },
|
||||||
|
];
|
||||||
|
if (row.sourceSkillId === 490149) return [
|
||||||
|
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: 4 },
|
||||||
|
];
|
||||||
|
if (row.sourceSkillId === 490150) return [...translated, { kind: "combo", amount: 2 }];
|
||||||
|
if (row.sourceSkillId === 490151) return [
|
||||||
|
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: -5 },
|
||||||
|
];
|
||||||
if (row.sourceSkillId === 491169) {
|
if (row.sourceSkillId === 491169) {
|
||||||
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
|
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,4 +148,23 @@ describe("executable RuneWaker ability mappings", () => {
|
|||||||
expect.objectContaining({ kind: "utility", action: "class-script" }),
|
expect.objectContaining({ kind: "utility", action: "class-script" }),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("models the Knight's Holy Seal build-and-spend loop", () => {
|
||||||
|
expect(named("Holy Strike").effects).toEqual(expect.arrayContaining([
|
||||||
|
{ kind: "combo", amount: 1 },
|
||||||
|
]));
|
||||||
|
expect(named("Holy Seal").effects).toEqual(expect.arrayContaining([
|
||||||
|
{ kind: "combo", amount: 4 },
|
||||||
|
]));
|
||||||
|
expect(named("Charge").effects).toEqual(expect.arrayContaining([
|
||||||
|
{ kind: "combo", amount: 2 },
|
||||||
|
]));
|
||||||
|
expect(named("Punishment").effects).toEqual(expect.arrayContaining([
|
||||||
|
{ kind: "combo", amount: -5 },
|
||||||
|
]));
|
||||||
|
expect(named("Holy Strike").effects.filter((effect) => effect.kind === "dot")).toHaveLength(1);
|
||||||
|
expect(named("Holy Seal").effects.filter((effect) => effect.kind === "dot")).toHaveLength(1);
|
||||||
|
expect(named("Punishment").effects.filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
||||||
|
expect(named("Punishment").effects.filter((effect) => effect.kind === "dot")).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -81,7 +81,7 @@ describe("RoM dungeon enemy durability", () => {
|
|||||||
for (let seed = 0; seed < 12; seed += 1) {
|
for (let seed = 0; seed < 12; seed += 1) {
|
||||||
const partySeed = `rom-health-${seed}`;
|
const partySeed = `rom-health-${seed}`;
|
||||||
const before = measurePull(level, partySeed, 72 + level * 22);
|
const before = measurePull(level, partySeed, 72 + level * 22);
|
||||||
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "runewaker")!);
|
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "rom"));
|
||||||
report.push({ level, seed: partySeed, before, after });
|
report.push({ level, seed: partySeed, before, after });
|
||||||
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
|
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
|
||||||
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
|
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
|
||||||
@@ -93,7 +93,7 @@ describe("RoM dungeon enemy durability", () => {
|
|||||||
|
|
||||||
it("retains Barren Caves' authored boss health multiplier", () => {
|
it("retains Barren Caves' authored boss health multiplier", () => {
|
||||||
const level = 8;
|
const level = 8;
|
||||||
const maxHealth = dungeonEnemyHealthBaseline(scaledDungeonEnemyLevel(level, true), "runewaker")!
|
const maxHealth = dungeonEnemyHealthBaseline(scaledDungeonEnemyLevel(level, true), "rom")
|
||||||
* boss.combat.healthMultiplier;
|
* boss.combat.healthMultiplier;
|
||||||
const result = measurePull(level, "rom-health-0", maxHealth, true);
|
const result = measurePull(level, "rom-health-0", maxHealth, true);
|
||||||
report.push({ level, boss: boss.name, ...result });
|
report.push({ level, boss: boss.name, ...result });
|
||||||
@@ -104,7 +104,7 @@ describe("RoM dungeon enemy durability", () => {
|
|||||||
|
|
||||||
it("allows enemy attacks and Priest healing before an opening pull ends", () => {
|
it("allows enemy attacks and Priest healing before an opening pull ends", () => {
|
||||||
const level = 1;
|
const level = 1;
|
||||||
initializePull(level, "rom-health-0", dungeonEnemyHealthBaseline(level, "runewaker")!);
|
initializePull(level, "rom-health-0", dungeonEnemyHealthBaseline(level, "rom"));
|
||||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||||
let incomingDamage = 0;
|
let incomingDamage = 0;
|
||||||
let healing = 0;
|
let healing = 0;
|
||||||
@@ -134,8 +134,8 @@ describe("RoM dungeon enemy durability", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates loaded enemies on level-up while retaining wounds and fallen enemies", () => {
|
it("updates loaded enemies on level-up while retaining wounds and fallen enemies", () => {
|
||||||
const initial = dungeonEnemyHealthBaseline(1, "runewaker")!;
|
const initial = dungeonEnemyHealthBaseline(1, "rom");
|
||||||
const scaled = dungeonEnemyHealthBaseline(8, "runewaker")!;
|
const scaled = dungeonEnemyHealthBaseline(8, "rom");
|
||||||
initializePull(1, "rom-health-level-up", initial);
|
initializePull(1, "rom-health-level-up", initial);
|
||||||
useCombatStore.getState().damageMob("pull", initial / 2 - 1, 1_000);
|
useCombatStore.getState().damageMob("pull", initial / 2 - 1, 1_000);
|
||||||
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
||||||
@@ -145,7 +145,7 @@ describe("RoM dungeon enemy durability", () => {
|
|||||||
expect(useCombatStore.getState().mobs.pull.health).toBe(scaled / 2);
|
expect(useCombatStore.getState().mobs.pull.health).toBe(scaled / 2);
|
||||||
useCombatStore.getState().damageMob("pull", scaled, 2_000);
|
useCombatStore.getState().damageMob("pull", scaled, 2_000);
|
||||||
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 20,
|
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 20,
|
||||||
maxHealth: dungeonEnemyHealthBaseline(20, "runewaker") });
|
maxHealth: dungeonEnemyHealthBaseline(20, "rom") });
|
||||||
expect(useCombatStore.getState().mobs.pull).toMatchObject({ dead: true, health: 0 });
|
expect(useCombatStore.getState().mobs.pull).toMatchObject({ dead: true, health: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
spectatorCameraBoosted,
|
spectatorCameraBoosted,
|
||||||
spectatorCameraTranslation,
|
spectatorCameraTranslation,
|
||||||
spectatorKeyboardAxes,
|
spectatorKeyboardAxes,
|
||||||
|
spectatorKeyboardLookDelta,
|
||||||
} from "./spectatorCameraMath";
|
} from "./spectatorCameraMath";
|
||||||
|
|
||||||
describe("dungeon spectator free camera", () => {
|
describe("dungeon spectator free camera", () => {
|
||||||
@@ -27,6 +28,21 @@ describe("dungeon spectator free camera", () => {
|
|||||||
expect(pitched[2]).toBeCloseTo(Math.sqrt(3) / 2, 6);
|
expect(pitched[2]).toBeCloseTo(Math.sqrt(3) / 2, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strafes A left and D right in screen space", () => {
|
||||||
|
expect(spectatorCameraTranslation(0, 0, { forward: 0, right: 1, vertical: 0 }, 0.1, false))
|
||||||
|
.toEqual([-1, 0, 0]);
|
||||||
|
expect(spectatorCameraTranslation(0, 0, { forward: 0, right: -1, vertical: 0 }, 0.1, false))
|
||||||
|
.toEqual([1, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns with held arrow keys using bounded frame deltas", () => {
|
||||||
|
expect(spectatorKeyboardLookDelta(new Set(["ArrowRight", "ArrowUp"]), 0.1))
|
||||||
|
.toEqual([0.225, -0.15500000000000003]);
|
||||||
|
expect(spectatorKeyboardLookDelta(new Set(["ArrowLeft", "ArrowDown"]), 1))
|
||||||
|
.toEqual([-0.225, 0.15500000000000003]);
|
||||||
|
expect(spectatorKeyboardLookDelta(new Set(["ArrowLeft"]), Number.NaN)).toEqual([-0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes diagonal speed and clamps long render-frame gaps", () => {
|
it("normalizes diagonal speed and clamps long render-frame gaps", () => {
|
||||||
const diagonal = spectatorCameraTranslation(0, 0, { forward: 1, right: 1, vertical: 1 }, 1, false);
|
const diagonal = spectatorCameraTranslation(0, 0, { forward: 1, right: 1, vertical: 1 }, 1, false);
|
||||||
expect(Math.hypot(...diagonal)).toBeCloseTo(1, 6);
|
expect(Math.hypot(...diagonal)).toBeCloseTo(1, 6);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface SpectatorCameraAxes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CAMERA_FRAME_SECONDS = 0.1;
|
const MAX_CAMERA_FRAME_SECONDS = 0.1;
|
||||||
|
const KEYBOARD_YAW_RADIANS_PER_SECOND = 2.25;
|
||||||
|
const KEYBOARD_PITCH_RADIANS_PER_SECOND = 1.55;
|
||||||
|
|
||||||
function held(codes: ReadonlySet<string>, ...candidates: readonly string[]): number {
|
function held(codes: ReadonlySet<string>, ...candidates: readonly string[]): number {
|
||||||
return Number(candidates.some((code) => codes.has(code)));
|
return Number(candidates.some((code) => codes.has(code)));
|
||||||
@@ -24,6 +26,20 @@ export function spectatorCameraBoosted(codes: ReadonlySet<string>): boolean {
|
|||||||
return codes.has("ShiftLeft") || codes.has("ShiftRight");
|
return codes.has("ShiftLeft") || codes.has("ShiftRight");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns frame-bounded arrow-key look deltas in the same orientation as mouse look. */
|
||||||
|
export function spectatorKeyboardLookDelta(
|
||||||
|
codes: ReadonlySet<string>,
|
||||||
|
deltaSeconds: number,
|
||||||
|
): readonly [horizontal: number, vertical: number] {
|
||||||
|
const seconds = Number.isFinite(deltaSeconds)
|
||||||
|
? Math.max(0, Math.min(MAX_CAMERA_FRAME_SECONDS, deltaSeconds))
|
||||||
|
: 0;
|
||||||
|
return [
|
||||||
|
(held(codes, "ArrowRight") - held(codes, "ArrowLeft")) * seconds * KEYBOARD_YAW_RADIANS_PER_SECOND,
|
||||||
|
(held(codes, "ArrowDown") - held(codes, "ArrowUp")) * seconds * KEYBOARD_PITCH_RADIANS_PER_SECOND,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns a bounded, normalized free-flight translation for one render frame. */
|
/** Returns a bounded, normalized free-flight translation for one render frame. */
|
||||||
export function spectatorCameraTranslation(
|
export function spectatorCameraTranslation(
|
||||||
yaw: number,
|
yaw: number,
|
||||||
@@ -36,8 +52,10 @@ export function spectatorCameraTranslation(
|
|||||||
const forwardX = Math.sin(yaw) * Math.cos(pitch);
|
const forwardX = Math.sin(yaw) * Math.cos(pitch);
|
||||||
const forwardY = Math.sin(pitch);
|
const forwardY = Math.sin(pitch);
|
||||||
const forwardZ = Math.cos(yaw) * Math.cos(pitch);
|
const forwardZ = Math.cos(yaw) * Math.cos(pitch);
|
||||||
const rightX = Math.cos(yaw);
|
// With Three's +Z-forward camera convention, screen-right is -X at zero
|
||||||
const rightZ = -Math.sin(yaw);
|
// yaw. Keep this basis aligned with cameraRelativeMovement.
|
||||||
|
const rightX = -Math.cos(yaw);
|
||||||
|
const rightZ = Math.sin(yaw);
|
||||||
let x = forwardX * axes.forward + rightX * axes.right;
|
let x = forwardX * axes.forward + rightX * axes.right;
|
||||||
let y = forwardY * axes.forward + axes.vertical;
|
let y = forwardY * axes.forward + axes.vertical;
|
||||||
let z = forwardZ * axes.forward + rightZ * axes.right;
|
let z = forwardZ * axes.forward + rightZ * axes.right;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
if (ability.passive) continue;
|
if (ability.passive) continue;
|
||||||
for (const rank of ability.ranks ?? []) {
|
for (const rank of ability.ranks ?? []) {
|
||||||
expect(rank.effects.length, `${ability.name} rank ${rank.rank}`).toBeGreaterThan(0);
|
expect(rank.effects.length, `${ability.name} rank ${rank.rank}`).toBeGreaterThan(0);
|
||||||
expect(rank.effects.some((effect) => {
|
const hasExecutableSemantics = rank.effects.some((effect) => {
|
||||||
if (["trigger-spell", "scripted", "unsupported"].includes(effect.kind)) return false;
|
if (["trigger-spell", "scripted", "unsupported"].includes(effect.kind)) return false;
|
||||||
if (effect.kind !== "apply-aura") return true;
|
if (effect.kind !== "apply-aura") return true;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -49,7 +49,13 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
|| effect.aura.reflectSpellChance
|
|| effect.aura.reflectSpellChance
|
||||||
|| effect.aura.redirectDamagePercent,
|
|| effect.aura.redirectDamagePercent,
|
||||||
);
|
);
|
||||||
}), `${ability.name} rank ${rank.rank}`).toBe(true);
|
});
|
||||||
|
if (!hasExecutableSemantics && ability.talentNodeId) {
|
||||||
|
expect(rank.effects.some((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported"),
|
||||||
|
`${ability.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(hasExecutableSemantics, `${ability.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,6 +64,10 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
expect(byId("wow335-warrior-execute").ranks?.map((rank) => rank.spellId)).not.toContain(20647);
|
expect(byId("wow335-warrior-execute").ranks?.map((rank) => rank.spellId)).not.toContain(20647);
|
||||||
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
|
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
|
||||||
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
|
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
|
||||||
|
expect(byId("wow335-death-knight-death-coil")).toMatchObject({
|
||||||
|
target: "hostile",
|
||||||
|
effects: expect.arrayContaining([expect.objectContaining({ kind: "damage" })]),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("freezes mana costs per learned rank while keeping form resources source-correct", () => {
|
it("freezes mana costs per learned rank while keeping form resources source-correct", () => {
|
||||||
@@ -103,6 +113,22 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
|
||||||
|
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
|
||||||
|
expect(lightningShield.dbcSpellId).toBe(49281);
|
||||||
|
expect(effectsOfKind(lightningShield, "damage")).toEqual([]);
|
||||||
|
expect(effectsOfKind(lightningShield, "apply-aura")[0]?.aura).toMatchObject({
|
||||||
|
exclusiveGroup: "shaman:elemental-shield",
|
||||||
|
procs: expect.arrayContaining([expect.objectContaining({ triggers: "damage-taken", charges: 3 })]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const waterShield = abilityAtLevel(byId("wow335-shaman-water-shield"), 80);
|
||||||
|
expect(waterShield.dbcSpellId).toBe(57960);
|
||||||
|
expect(effectsOfKind(waterShield, "apply-aura").every((effect) => (
|
||||||
|
effect.aura.exclusiveGroup === "shaman:elemental-shield"
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates source-named stat and avoidance auras", () => {
|
it("creates source-named stat and avoidance auras", () => {
|
||||||
const fortitude = effectsOfKind(byId("wow335-priest-power-word-fortitude"), "apply-aura")[0];
|
const fortitude = effectsOfKind(byId("wow335-priest-power-word-fortitude"), "apply-aura")[0];
|
||||||
expect(fortitude).toMatchObject({
|
expect(fortitude).toMatchObject({
|
||||||
@@ -126,6 +152,18 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves permanent DBC toggles as non-expiring caster auras", () => {
|
||||||
|
const devotion = effectsOfKind(byId("wow335-paladin-devotion-aura"), "apply-aura")[0];
|
||||||
|
expect(devotion).toMatchObject({
|
||||||
|
recipient: "caster",
|
||||||
|
aura: {
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: null,
|
||||||
|
exclusiveGroup: "paladin:aura",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("represents absorbs as named aura capacity instead of a generic shield", () => {
|
it("represents absorbs as named aura capacity instead of a generic shield", () => {
|
||||||
const shield = byId("wow335-priest-power-word-shield");
|
const shield = byId("wow335-priest-power-word-shield");
|
||||||
const aura = effectsOfKind(shield, "apply-aura").find((effect) => effect.sourceAuraType === 69);
|
const aura = effectsOfKind(shield, "apply-aura").find((effect) => effect.sourceAuraType === 69);
|
||||||
|
|||||||
@@ -67,8 +67,10 @@ interface Wow335Chain {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly iconBasename: string;
|
readonly iconBasename: string;
|
||||||
|
readonly iconPath?: string;
|
||||||
readonly sourceUnlockLevel: number;
|
readonly sourceUnlockLevel: number;
|
||||||
readonly passive?: boolean;
|
readonly passive?: boolean;
|
||||||
|
readonly talentNodeId?: string;
|
||||||
readonly ranks: readonly Wow335Rank[];
|
readonly ranks: readonly Wow335Rank[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +311,7 @@ function controlFromAura(auraType: number): AbilityAuraControl | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): string | undefined {
|
function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): string | undefined {
|
||||||
|
if (["Arcane Intellect", "Arcane Brilliance"].includes(abilityName)) return "mage:intellect";
|
||||||
if (/\bStance$/i.test(abilityName)) return `${classId}:stance`;
|
if (/\bStance$/i.test(abilityName)) return `${classId}:stance`;
|
||||||
if (/^Seal of\b/i.test(abilityName)) return "paladin:seal";
|
if (/^Seal of\b/i.test(abilityName)) return "paladin:seal";
|
||||||
if (/\bAura$/i.test(abilityName)) return `${classId}:aura`;
|
if (/\bAura$/i.test(abilityName)) return `${classId}:aura`;
|
||||||
@@ -318,6 +321,7 @@ function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): st
|
|||||||
if (/\b(?:Bear|Cat|Travel|Aquatic|Flight|Tree of Life) Form$/i.test(abilityName)) return "druid:form";
|
if (/\b(?:Bear|Cat|Travel|Aquatic|Flight|Tree of Life) Form$/i.test(abilityName)) return "druid:form";
|
||||||
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
||||||
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
||||||
|
if (["Lightning Shield", "Water Shield"].includes(abilityName)) return "shaman:elemental-shield";
|
||||||
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -758,6 +762,11 @@ function classScriptEffects(
|
|||||||
case "rogue:Cloak of Shadows": return [{
|
case "rogue:Cloak of Shadows": return [{
|
||||||
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
||||||
}];
|
}];
|
||||||
|
// Both spells place a channel aura whose actual pulses live in non-player
|
||||||
|
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
||||||
|
// missing executable payload.
|
||||||
|
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
|
||||||
|
case "priest:Penance": return [{ kind: "damage", coefficient: 0.8 }];
|
||||||
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
||||||
case "priest:Prayer of Mending": return [{
|
case "priest:Prayer of Mending": return [{
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
@@ -815,6 +824,11 @@ function classScriptEffects(
|
|||||||
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||||
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||||
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
|
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
|
||||||
|
// Arcane Missiles stores its per-tick payload in a server-triggered child spell
|
||||||
|
// that is not a player-facing SkillLine entry. Model the channel payload here so
|
||||||
|
// both players and shared-rotation consumers execute the imported spell instead
|
||||||
|
// of completing an inert channel.
|
||||||
|
case "mage:Arcane Missiles": return [{ kind: "damage", coefficient: 1.2 }];
|
||||||
case "mage:Arcane Blast": return [{
|
case "mage:Arcane Blast": return [{
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
recipient: "caster",
|
recipient: "caster",
|
||||||
@@ -855,6 +869,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
effects.push({
|
effects.push({
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
aura,
|
aura,
|
||||||
|
...(effect.implicitTargetA === 1 ? { recipient: "caster" as const } : {}),
|
||||||
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
|
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
|
||||||
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
||||||
sourceEffectType: effect.effectType,
|
sourceEffectType: effect.effectType,
|
||||||
@@ -1108,8 +1123,12 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
label: `DBC combat effect ${effect.effectType}${effect.auraType ? ` / aura ${effect.auraType}` : ""}`,
|
label: `DBC combat effect ${effect.effectType}${effect.auraType ? ` / aura ${effect.auraType}` : ""}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
const auraOnlyStance = classId === "death-knight"
|
||||||
if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) {
|
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||||
|
if (!auraOnlyStance && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||||
|
if (classId === "death-knight" && abilityName === "Death Coil") {
|
||||||
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||||
|
} else if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) {
|
||||||
effects.unshift({ kind: "heal", coefficient: 1 });
|
effects.unshift({ kind: "heal", coefficient: 1 });
|
||||||
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
||||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||||
@@ -1265,7 +1284,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
name: chain.name,
|
name: chain.name,
|
||||||
unlockLevel: starterUnlockLevel(classId, chain.sourceUnlockLevel, chain.name),
|
unlockLevel: starterUnlockLevel(classId, chain.sourceUnlockLevel, chain.name),
|
||||||
sourceUnlockLevel: chain.sourceUnlockLevel,
|
sourceUnlockLevel: chain.sourceUnlockLevel,
|
||||||
icon: `/assets/ui/spells/${chain.iconBasename}.png`,
|
icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`,
|
||||||
sigil: sigil(chain.name),
|
sigil: sigil(chain.name),
|
||||||
description: first.description,
|
description: first.description,
|
||||||
target,
|
target,
|
||||||
@@ -1282,6 +1301,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
effects: first.effects,
|
effects: first.effects,
|
||||||
passive: chain.passive,
|
passive: chain.passive,
|
||||||
source: "wow335-progression",
|
source: "wow335-progression",
|
||||||
|
talentNodeId: chain.talentNodeId,
|
||||||
ranks,
|
ranks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1296,6 +1316,7 @@ function abilityNameRadius(abilityName: string): number {
|
|||||||
|
|
||||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||||
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||||
|
if (abilityName === "Death Coil") return "hostile";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-08-01T22:30:51.388Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 130. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11551,"rank":6,"level":52,"sourceLevel":52,"maxScalingLevel":61,"description":"The warrior shouts, increasing attack power of all raid and party membersLine truncated
|
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-09-17T04:24:22.392Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","iconPath":"/assets/ui/spells/ability_warrior_battleshout.png","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 130. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11551,"rank":6,"level":52,"sourceLevel":52,"maxScalingLevel":61,"description":"The warrioLine truncated
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type ActionBindingControl,
|
type ActionBindingControl,
|
||||||
type ActionBindingLayer,
|
type ActionBindingLayer,
|
||||||
} from "../game/actionBindings";
|
} from "../game/actionBindings";
|
||||||
import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../game/abilityCatalog";
|
import { abilityAtLevel, learnedAbilityRank, resourceProfileForClass, type AbilityTarget } from "../game/abilityCatalog";
|
||||||
import type { DungeonId } from "../game/dungeonCatalog";
|
import type { DungeonId } from "../game/dungeonCatalog";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||||
@@ -227,9 +227,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
|||||||
? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null
|
? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null
|
||||||
: null;
|
: null;
|
||||||
if (!catalogAbility) return [];
|
if (!catalogAbility) return [];
|
||||||
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
const learnedTalentRank = learnedAbilityRank(catalogAbility, combat.talentRanks);
|
||||||
? undefined
|
|
||||||
: combat.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
const ability = abilityAtLevel(catalogAbility, combat.level, learnedTalentRank);
|
const ability = abilityAtLevel(catalogAbility, combat.level, learnedTalentRank);
|
||||||
const costs = resolvedAbilityCosts(ability, combat.talentModifiers, combat);
|
const costs = resolvedAbilityCosts(ability, combat.talentModifiers, combat);
|
||||||
const displayCost = costs[0] ?? { resource: ability.cost.resource, amount: 0 };
|
const displayCost = costs[0] ?? { resource: ability.cost.resource, amount: 0 };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
spectatorCameraBoosted,
|
spectatorCameraBoosted,
|
||||||
spectatorCameraTranslation,
|
spectatorCameraTranslation,
|
||||||
spectatorKeyboardAxes,
|
spectatorKeyboardAxes,
|
||||||
|
spectatorKeyboardLookDelta,
|
||||||
} from "../game/spectatorCameraMath";
|
} from "../game/spectatorCameraMath";
|
||||||
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||||
import { DUNGEON_SIGHT_QUERY_GROUPS } from "./dungeonLineOfSight";
|
import { DUNGEON_SIGHT_QUERY_GROUPS } from "./dungeonLineOfSight";
|
||||||
@@ -24,9 +25,10 @@ const CAMERA_COLLISION_RADIUS = 0.24;
|
|||||||
const CAMERA_WALL_GAP = 0.08;
|
const CAMERA_WALL_GAP = 0.08;
|
||||||
const CAMERA_PULLBACK_DAMPING = 7;
|
const CAMERA_PULLBACK_DAMPING = 7;
|
||||||
const LOOK_SENSITIVITY = 0.0021;
|
const LOOK_SENSITIVITY = 0.0021;
|
||||||
const SPECTATOR_FLIGHT_KEYS = new Set([
|
const SPECTATOR_CAMERA_KEYS = new Set([
|
||||||
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
|
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
|
||||||
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
|
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
|
||||||
|
"ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function DungeonSpectatorCamera() {
|
export function DungeonSpectatorCamera() {
|
||||||
@@ -51,11 +53,14 @@ export function DungeonSpectatorCamera() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!SPECTATOR_FLIGHT_KEYS.has(event.code)) return;
|
if (!SPECTATOR_CAMERA_KEYS.has(event.code)) return;
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
||||||
pressedKeysRef.current.add(event.code);
|
pressedKeysRef.current.add(event.code);
|
||||||
if (useDungeonSimulationStore.getState().cameraMode === "free-fly") event.preventDefault();
|
if (
|
||||||
|
event.code.startsWith("Arrow")
|
||||||
|
|| useDungeonSimulationStore.getState().cameraMode === "free-fly"
|
||||||
|
) event.preventDefault();
|
||||||
};
|
};
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
pressedKeysRef.current.delete(event.code);
|
pressedKeysRef.current.delete(event.code);
|
||||||
@@ -76,6 +81,9 @@ export function DungeonSpectatorCamera() {
|
|||||||
const simulation = useDungeonSimulationStore.getState();
|
const simulation = useDungeonSimulationStore.getState();
|
||||||
if (simulation.sessionMode === "play") return;
|
if (simulation.sessionMode === "play") return;
|
||||||
const [mouseX, mouseY] = consumeMouseLook();
|
const [mouseX, mouseY] = consumeMouseLook();
|
||||||
|
const [keyboardLookX, keyboardLookY] = spectatorKeyboardLookDelta(pressedKeysRef.current, delta);
|
||||||
|
const lookX = mouseX * LOOK_SENSITIVITY + keyboardLookX;
|
||||||
|
const lookY = mouseY * LOOK_SENSITIVITY + keyboardLookY;
|
||||||
|
|
||||||
if (simulation.cameraMode === "free-fly") {
|
if (simulation.cameraMode === "free-fly") {
|
||||||
if (!initializedFreeRef.current) {
|
if (!initializedFreeRef.current) {
|
||||||
@@ -85,8 +93,8 @@ export function DungeonSpectatorCamera() {
|
|||||||
freePitchRef.current = Math.asin(Math.max(-1, Math.min(1, forward.y)));
|
freePitchRef.current = Math.asin(Math.max(-1, Math.min(1, forward.y)));
|
||||||
initializedFreeRef.current = true;
|
initializedFreeRef.current = true;
|
||||||
}
|
}
|
||||||
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, lookX);
|
||||||
freePitchRef.current = Math.max(-1.35, Math.min(1.15, freePitchRef.current - mouseY * LOOK_SENSITIVITY));
|
freePitchRef.current = Math.max(-1.35, Math.min(1.15, freePitchRef.current - lookY));
|
||||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||||
followedMemberRef.current = null;
|
followedMemberRef.current = null;
|
||||||
forward.set(
|
forward.set(
|
||||||
@@ -115,8 +123,8 @@ export function DungeonSpectatorCamera() {
|
|||||||
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, -freePitchRef.current));
|
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, -freePitchRef.current));
|
||||||
}
|
}
|
||||||
initializedFreeRef.current = false;
|
initializedFreeRef.current = false;
|
||||||
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, lookX);
|
||||||
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, orbitPitchRef.current + mouseY * LOOK_SENSITIVITY));
|
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, orbitPitchRef.current + lookY));
|
||||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
|
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import { CharacterModel } from "../avatar/CharacterModel";
|
|||||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
||||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
import {
|
||||||
|
characterCombatAnimationForPresentation,
|
||||||
|
type CharacterCombatAnimationEvent,
|
||||||
|
} from "../game/combatAnimation";
|
||||||
|
import { subscribeCombatPresentation } from "../game/combatPresentation";
|
||||||
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
||||||
@@ -351,7 +355,6 @@ function PartyActors({
|
|||||||
const movingSignalsRef = useRef(new Map<string, { current: boolean }>());
|
const movingSignalsRef = useRef(new Map<string, { current: boolean }>());
|
||||||
const animationEventSignalsRef = useRef(new Map<string, { current: CharacterCombatAnimationEvent | null }>());
|
const animationEventSignalsRef = useRef(new Map<string, { current: CharacterCombatAnimationEvent | null }>());
|
||||||
const animationRevisionRef = useRef(0);
|
const animationRevisionRef = useRef(0);
|
||||||
const lastNextActionAtRef = useRef(new Map<string, number>());
|
|
||||||
const lastHealthRef = useRef(new Map<string, number>());
|
const lastHealthRef = useRef(new Map<string, number>());
|
||||||
const routeRef = useRef<PartyRouteState>(emptyPartyRouteState());
|
const routeRef = useRef<PartyRouteState>(emptyPartyRouteState());
|
||||||
const combatRoutesRef = useRef(new Map<string, PartyRouteState>());
|
const combatRoutesRef = useRef(new Map<string, PartyRouteState>());
|
||||||
@@ -476,9 +479,6 @@ function PartyActors({
|
|||||||
for (const id of animationEventSignalsRef.current.keys()) {
|
for (const id of animationEventSignalsRef.current.keys()) {
|
||||||
if (!activeIds.has(id)) animationEventSignalsRef.current.delete(id);
|
if (!activeIds.has(id)) animationEventSignalsRef.current.delete(id);
|
||||||
}
|
}
|
||||||
for (const id of lastNextActionAtRef.current.keys()) {
|
|
||||||
if (!activeIds.has(id)) lastNextActionAtRef.current.delete(id);
|
|
||||||
}
|
|
||||||
for (const id of lastHealthRef.current.keys()) {
|
for (const id of lastHealthRef.current.keys()) {
|
||||||
if (!activeIds.has(id)) lastHealthRef.current.delete(id);
|
if (!activeIds.has(id)) lastHealthRef.current.delete(id);
|
||||||
}
|
}
|
||||||
@@ -502,9 +502,6 @@ function PartyActors({
|
|||||||
const cleanups = members.map((member, index) => {
|
const cleanups = members.map((member, index) => {
|
||||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
positionsRef.current.set(member.id, position);
|
positionsRef.current.set(member.id, position);
|
||||||
if (!lastNextActionAtRef.current.has(member.id)) {
|
|
||||||
lastNextActionAtRef.current.set(member.id, member.nextActionAt);
|
|
||||||
}
|
|
||||||
if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health);
|
if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health);
|
||||||
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
|
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
|
||||||
const runtimeCleanup = registerPartyRuntimePosition(member.id, position);
|
const runtimeCleanup = registerPartyRuntimePosition(member.id, position);
|
||||||
@@ -529,6 +526,23 @@ function PartyActors({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [memberIdKey]);
|
}, [memberIdKey]);
|
||||||
|
|
||||||
|
useEffect(() => subscribeCombatPresentation((message) => {
|
||||||
|
if (message.kind === "reset") {
|
||||||
|
for (const signal of animationEventSignalsRef.current.values()) {
|
||||||
|
animationRevisionRef.current += 1;
|
||||||
|
signal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signal = animationEventSignalsRef.current.get(message.event.sourceActorId);
|
||||||
|
if (!signal) return;
|
||||||
|
const revision = animationRevisionRef.current + 1;
|
||||||
|
const animationEvent = characterCombatAnimationForPresentation(message.event, revision);
|
||||||
|
if (!animationEvent) return;
|
||||||
|
animationRevisionRef.current = revision;
|
||||||
|
signal.current = animationEvent;
|
||||||
|
}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
|
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
|
||||||
@@ -1061,14 +1075,6 @@ function PartyActors({
|
|||||||
const actor = actorRefs.current[index];
|
const actor = actorRefs.current[index];
|
||||||
const body = bodyRefs.current[index];
|
const body = bodyRefs.current[index];
|
||||||
const current = positionsRef.current.get(member.id);
|
const current = positionsRef.current.get(member.id);
|
||||||
const previousNextActionAt = lastNextActionAtRef.current.get(member.id)
|
|
||||||
?? member.nextActionAt;
|
|
||||||
if (member.nextActionAt > 0 && member.nextActionAt !== previousNextActionAt) {
|
|
||||||
const signal = animationEventSignalsRef.current.get(member.id);
|
|
||||||
animationRevisionRef.current += 1;
|
|
||||||
if (signal) signal.current = { revision: animationRevisionRef.current, kind: "attack" };
|
|
||||||
}
|
|
||||||
lastNextActionAtRef.current.set(member.id, member.nextActionAt);
|
|
||||||
const previousHealth = lastHealthRef.current.get(member.id) ?? member.health;
|
const previousHealth = lastHealthRef.current.get(member.id) ?? member.health;
|
||||||
if (previousHealth > 0 && member.health <= 0) {
|
if (previousHealth > 0 && member.health <= 0) {
|
||||||
const signal = animationEventSignalsRef.current.get(member.id);
|
const signal = animationEventSignalsRef.current.get(member.id);
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
type ActionBindingLayer,
|
type ActionBindingLayer,
|
||||||
} from "../game/actionBindings";
|
} from "../game/actionBindings";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
import {
|
||||||
|
abilityAtLevel,
|
||||||
|
abilityTalentNodeId,
|
||||||
|
defaultActionBarForCharacter,
|
||||||
|
learnedAbilityRank,
|
||||||
|
type AbilityDefinition,
|
||||||
|
} from "../game/abilityCatalog";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
||||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
@@ -110,14 +116,14 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
|||||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||||
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
|
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
|
||||||
if (!definition) return null;
|
if (!definition) return null;
|
||||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
const talentRank = learnedAbilityRank(definition, talentRanks);
|
||||||
const ability = abilityAtLevel(definition, level, talentRank);
|
const ability = abilityAtLevel(definition, level, talentRank);
|
||||||
return {
|
return {
|
||||||
name: ability.name, description: ability.description, target: ability.target,
|
name: ability.name, description: ability.description, target: ability.target,
|
||||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
|
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
|
||||||
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
talentLocked: Boolean(abilityTalentNodeId(ability)) && !talentRank,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const bindings = useCombatStore((state) => state.actionBindings);
|
const bindings = useCombatStore((state) => state.actionBindings);
|
||||||
@@ -132,7 +138,7 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
|||||||
const isWow = layout === "wow";
|
const isWow = layout === "wow";
|
||||||
const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow);
|
const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow);
|
||||||
const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level
|
const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level
|
||||||
&& (ability.talentEntryId === undefined || Boolean(talentRanks[`coa-entry-${ability.talentEntryId}`]))
|
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, talentRanks)))
|
||||||
&& !assignedIds.has(ability.id);
|
&& !assignedIds.has(ability.id);
|
||||||
const assignment = (control: BindingControl) => typeof control === "number"
|
const assignment = (control: BindingControl) => typeof control === "number"
|
||||||
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
|
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useShellStore } from "../app/shellStore";
|
|||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { useArenaStore } from "../game/arenaStore";
|
import { useArenaStore } from "../game/arenaStore";
|
||||||
import { abilitiesForCharacter, abilityAtLevel } from "../game/abilityCatalog";
|
import { abilitiesForCharacter, abilityAtLevel, learnedAbilityRank } from "../game/abilityCatalog";
|
||||||
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot, ArenaStageManifest } from "../game/arenaTypes";
|
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot, ArenaStageManifest } from "../game/arenaTypes";
|
||||||
import { SpellbookPanel } from "./SpellbookPanel";
|
import { SpellbookPanel } from "./SpellbookPanel";
|
||||||
import { EquipmentPanel } from "./EquipmentPanel";
|
import { EquipmentPanel } from "./EquipmentPanel";
|
||||||
@@ -26,7 +26,7 @@ export function ArenaPanels({ character, actor, snapshot, stage, cast }: {
|
|||||||
const close = () => useGameStore.getState().closeOverlay();
|
const close = () => useGameStore.getState().closeOverlay();
|
||||||
const color = classById(character.classId).color;
|
const color = classById(character.classId).color;
|
||||||
const abilities = useMemo(() => abilitiesForCharacter(character.classId, character.secondaryClassId).map((definition) => {
|
const abilities = useMemo(() => abilitiesForCharacter(character.classId, character.secondaryClassId).map((definition) => {
|
||||||
const rank = definition.talentEntryId === undefined ? undefined : actor?.combat?.talentRanks[`coa-entry-${definition.talentEntryId}`];
|
const rank = learnedAbilityRank(definition, actor?.combat?.talentRanks ?? character.talentRanks);
|
||||||
const ability = abilityAtLevel(definition, actor?.level ?? character.level, rank);
|
const ability = abilityAtLevel(definition, actor?.level ?? character.level, rank);
|
||||||
return { id: ability.id, name: ability.name, description: ability.description, level: ability.unlockLevel,
|
return { id: ability.id, name: ability.name, description: ability.description, level: ability.unlockLevel,
|
||||||
color, sigil: ability.sigil, iconUrl: ability.icon, known: ability.passive || Boolean(actor?.abilityIds?.includes(ability.id)),
|
color, sigil: ability.sigil, iconUrl: ability.icon, known: ability.passive || Boolean(actor?.abilityIds?.includes(ability.id)),
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export function DungeonSimulationHud() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p>{simulation.cameraMode === "free-fly"
|
<p>{simulation.cameraMode === "free-fly"
|
||||||
? "Right-drag to look · WASD fly · E/Space up · Q/Ctrl down · Shift boost"
|
? "Right-drag/Arrows look · WASD fly · E/Space up · Q/Ctrl down · Shift boost"
|
||||||
: "Choose a party member to follow · Right-drag to orbit"}</p>
|
: "Choose a party member to follow · Right-drag/Arrows orbit"}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{simulation.sessionMode === "spectate-replay" && (
|
{simulation.sessionMode === "spectate-replay" && (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useCombatStore } from "../game/combatStore";
|
|||||||
import {
|
import {
|
||||||
abilityAtLevel,
|
abilityAtLevel,
|
||||||
abilityRankForLevel,
|
abilityRankForLevel,
|
||||||
|
abilityTalentNodeId,
|
||||||
|
learnedAbilityRank,
|
||||||
} from "../game/abilityCatalog";
|
} from "../game/abilityCatalog";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { OptionsPanel } from "./OptionsPanel";
|
import { OptionsPanel } from "./OptionsPanel";
|
||||||
@@ -61,9 +63,7 @@ export function GameplayPanels() {
|
|||||||
const spellbookAbilities = useMemo(() => [...abilities]
|
const spellbookAbilities = useMemo(() => [...abilities]
|
||||||
.sort((left, right) => left.unlockLevel - right.unlockLevel)
|
.sort((left, right) => left.unlockLevel - right.unlockLevel)
|
||||||
.map((catalogAbility) => {
|
.map((catalogAbility) => {
|
||||||
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
const learnedTalentRank = learnedAbilityRank(catalogAbility, talentRanks);
|
||||||
? undefined
|
|
||||||
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
||||||
const activeRank = abilityRankForLevel(catalogAbility, level, learnedTalentRank);
|
const activeRank = abilityRankForLevel(catalogAbility, level, learnedTalentRank);
|
||||||
return {
|
return {
|
||||||
@@ -80,8 +80,8 @@ export function GameplayPanels() {
|
|||||||
cooldownSeconds: ability.cooldownMs / 1_000,
|
cooldownSeconds: ability.cooldownMs / 1_000,
|
||||||
castSeconds: ability.castTimeMs / 1_000,
|
castSeconds: ability.castTimeMs / 1_000,
|
||||||
range: ability.range.max,
|
range: ability.range.max,
|
||||||
known: catalogAbility.talentEntryId === undefined || Boolean(learnedTalentRank),
|
known: !abilityTalentNodeId(catalogAbility) || Boolean(learnedTalentRank),
|
||||||
talentLocked: catalogAbility.talentEntryId !== undefined,
|
talentLocked: Boolean(abilityTalentNodeId(catalogAbility)),
|
||||||
passive: ability.passive,
|
passive: ability.passive,
|
||||||
rank: activeRank?.rank,
|
rank: activeRank?.rank,
|
||||||
rankCount: catalogAbility.ranks?.length,
|
rankCount: catalogAbility.ranks?.length,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { useProgress } from "@react-three/drei";
|
import { useProgress } from "@react-three/drei";
|
||||||
import { classById, raceById } from "../app/characterCatalog";
|
import { classById, raceById } from "../app/characterCatalog";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
import { abilityAtLevel, abilityTalentNodeId, learnedAbilityRank, resourceProfileForClass } from "../game/abilityCatalog";
|
||||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||||
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||||
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||||
@@ -260,11 +260,9 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
|
|||||||
return abilities.filter((ability) => used.has(ability.id));
|
return abilities.filter((ability) => used.has(ability.id));
|
||||||
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
|
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
|
||||||
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
|
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
|
||||||
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
const learnedTalentRank = learnedAbilityRank(catalogAbility, talentRanks);
|
||||||
? undefined
|
|
||||||
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
||||||
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
||||||
const talentLocked = ability.talentEntryId !== undefined && !learnedTalentRank;
|
const talentLocked = Boolean(abilityTalentNodeId(ability)) && !learnedTalentRank;
|
||||||
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
||||||
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
||||||
const remaining = Math.max(personalRemaining, globalRemaining);
|
const remaining = Math.max(personalRemaining, globalRemaining);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
import { abilityAtLevel, abilityTalentNodeId, defaultActionBarForCharacter, learnedAbilityRank } from "../game/abilityCatalog";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import {
|
import {
|
||||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
|
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
|
||||||
@@ -190,14 +190,14 @@ export function UiLayoutEditor() {
|
|||||||
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
||||||
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
|
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
|
||||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
const talentRank = learnedAbilityRank(definition, talentRanks);
|
||||||
const ability = abilityAtLevel(definition, level, talentRank);
|
const ability = abilityAtLevel(definition, level, talentRank);
|
||||||
return <AbilityTooltipTarget key={ability.id} ability={{
|
return <AbilityTooltipTarget key={ability.id} ability={{
|
||||||
name: ability.name, description: ability.description, target: ability.target,
|
name: ability.name, description: ability.description, target: ability.target,
|
||||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
|
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
|
||||||
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
talentLocked: Boolean(abilityTalentNodeId(ability)) && !talentRank,
|
||||||
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
|
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
|
||||||
})}</div>
|
})}</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||