Compare commits

..
1 Commits
Author SHA1 Message Date
phenom 38b3d4d160 Release Healer Man 0.1.55 2026-09-19 12:23:33 -04:00
14 changed files with 279 additions and 41 deletions

No files matched your search

+11 -4
View File
@@ -282,6 +282,10 @@ under `parties/<party-id>/` and adds these suite-level files:
`totalHealing` and `totalHealingReceived` are the corresponding absolute `totalHealing` and `totalHealingReceived` are the corresponding absolute
values across the included runs. Reports created before target attribution values across the included runs. Reports created before target attribution
was added remain readable but require a rerun to populate healing received. was added remain readable but require a rerun to populate healing received.
- Member and ability `absorbs` count shield capacity granted by both direct
shield effects and named absorb auras such as Power Word: Shield. Member
`deathEvents` counts each alive-to-dead transition, so a member that is
resurrected and defeated again contributes another death.
- Ability `activeRuns` counts simulations with at least one cast, completed - Ability `activeRuns` counts simulations with at least one cast, completed
non-throughput release, hit, damage, healing, overheal, or absorb event. non-throughput release, hit, damage, healing, overheal, or absorb event.
Completed resource, aura, pet, and summon actions are recorded as casts even Completed resource, aura, pet, and summon actions are recorded as casts even
@@ -341,15 +345,18 @@ remains the class's primary resource. A completed companion cast resolves the
member's currently learned rank before applying its effects, so cast-time spell member's currently learned rank before applying its effects, so cast-time spell
damage and healing cannot fall back to rank one. damage and healing cannot fall back to rank one.
`playerbots-inspired-v3` keeps level-10-and-up non-healer builds on their `playerbots-inspired-v4` keeps level-10-and-up non-healer builds on their
specialization's authored damage priority whenever that build knows a profile specialization's authored damage priority whenever that build knows a profile
damage action. Reactive interrupts, taunts, control, and defenses still take damage action. Reactive interrupts, taunts, control, and defenses still take
precedence. Bear and Cat actions are isolated to their matching Feral specs; precedence. Bear and Cat actions are isolated to their matching Feral specs;
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger; Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates. and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
Incidental DBC aura categories cannot outrank explicit profile actions. Rage and Off-profile control, interrupt, and debuff buttons cannot consume ordinary
Runic Power generated during an action persist without erasing that action's rotation GCDs after their reactive conditions have been checked. Incidental DBC
cooldown/GCD, and short-range melee abilities use companion combat reach. aura categories cannot outrank explicit profile actions. Rage and Runic Power
generated during an action persist without erasing that action's cooldown/GCD;
short-range melee abilities use companion combat reach; and pets move every
combat tick while retaining their own attack cadence.
## Code map ## Code map
+2 -2
View File
@@ -18,7 +18,7 @@ The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` fo
| Combo points | Builders run below four points; finishers require at least four points on the current target. | | 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. | | Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
`playerbots-inspired-v3` also generates legal level-scaled talent plans for all `playerbots-inspired-v4` also generates legal level-scaled talent plans for all
31 classic specializations. Each level-80 plan spends 51 points in the selected 31 classic specializations. Each level-80 plan spends 51 points in the selected
tree and 20 in a compatible secondary tree, prefers executable active talents tree and 20 in a compatible secondary tree, prefers executable active talents
and their prerequisites, and sanitizes obsolete imported prerequisite links. and their prerequisites, and sanitizes obsolete imported prerequisite links.
@@ -34,7 +34,7 @@ The leftmost available action wins after reactive rules, learned-talent gating,
| Class | Specialization | Maintenance and core priority | | Class | Specialization | Maintenance and core priority |
| --- | --- | --- | | --- | --- | --- |
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Overpower; Execute; Slam; Heroic Strike | | Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike | | Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike | | Warrior | Protection | Defensive/Battle Stance; Bloodrage; 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 | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.54", "version": "0.1.55",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.54", "version": "0.1.55",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.54", "version": "0.1.55",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.54", "versionName": "0.1.55",
"versionCode": 1054 "versionCode": 1055
} }
+7 -2
View File
@@ -379,6 +379,7 @@ async function runBoss(
abilities: new Map(), abilities: new Map(),
})); }));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics])); const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const actorAlive = new Map<string, boolean>();
const bossAbilities = new Map<string, MutableAbilityMetrics>(); const bossAbilities = new Map<string, MutableAbilityMetrics>();
// Instant resource, aura, pet, and summon actions do not produce the damage, // Instant resource, aura, pet, and summon actions do not produce the damage,
// healing, absorb, or cast events used by the original report collector. // healing, absorb, or cast events used by the original report collector.
@@ -435,8 +436,6 @@ async function runBoss(
} else if (event.kind === "absorb" && source) { } else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount; source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount; abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
} else if (event.kind === "death" && target) {
target.deathEvents += 1;
} }
}); });
@@ -470,6 +469,9 @@ async function runBoss(
memberTargetIds: {}, memberTargetIds: {},
}); });
combat.synchronizeActors(); combat.synchronizeActors();
for (const metrics of memberMetrics) {
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
}
combat.registerMob(bossActorId, { combat.registerMob(bossActorId, {
name: entity.name, name: entity.name,
boss: true, boss: true,
@@ -500,6 +502,9 @@ async function runBoss(
controller.act(now, activeBuild.role, runScenario, false); controller.act(now, activeBuild.role, runScenario, false);
for (const metrics of memberMetrics) { for (const metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId); const [health, maxHealth] = currentHealth(metrics.actorId);
const alive = health > 0;
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
actorAlive.set(metrics.actorId, alive);
metrics.minimumHealthPercent = Math.min( metrics.minimumHealthPercent = Math.min(
metrics.minimumHealthPercent, metrics.minimumHealthPercent,
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0, maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
+17
View File
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
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 });
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
const summonIds = prepared.summons.map((summon) => summon.id); const summonIds = prepared.summons.map((summon) => summon.id);
useCombatStore.getState().applyPrecombatPreparation(now + 1_000); useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds); expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
registerTarget("pet-target");
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
useCombatStore.getState().tick(0.1, now + 2_100);
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
}); });
it("consumes a named absorb before player health", () => { it("consumes a named absorb before player health", () => {
const now = Date.now(); const now = Date.now();
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
expect(useCombatStore.getState().castAbility( expect(useCombatStore.getState().castAbility(
"wow335-priest-power-word-shield", "wow335-priest-power-word-shield",
undefined, undefined,
now, now,
).ok).toBe(true); ).ok).toBe(true);
stop();
const beforeHealth = useCombatStore.getState().health; const beforeHealth = useCombatStore.getState().health;
const beforeAbsorb = useCombatStore.getState().auras const beforeAbsorb = useCombatStore.getState().auras
.flatMap((aura) => aura.absorbs) .flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0); .reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0); expect(beforeAbsorb).toBeGreaterThan(0);
expect(events).toContainEqual(expect.objectContaining({
kind: "absorb",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AGGRO_ID,
abilityId: "wow335-priest-power-word-shield",
effectiveAmount: beforeAbsorb,
}));
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe( expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => ( useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield" ability.id === "wow335-priest-power-word-shield"
+26
View File
@@ -374,6 +374,32 @@ describe("shared combat rotations", () => {
})).toBeNull(); })).toBeNull();
}); });
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
const demoralizingShout = ability("warrior", "Demoralizing Shout");
const pummel = ability("warrior", "Pummel");
const mortalStrike = ability("warrior", "Mortal Strike");
const arms = context({
classId: "warrior",
specializationId: "warrior:arms",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [demoralizingShout, pummel],
knownDamagingAbilityNames: new Set([mortalStrike.name]),
});
expect(evaluateCombatRotation(arms)).toBeNull();
expect(evaluateCombatRotation({
...arms,
hostileTarget: {
id: "caster",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
})?.ability.id).toBe(pummel.id);
});
it("never falls a full-health friendly maintenance heal through to a hostile target", () => { it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]); const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [ const prayer = semanticAbility("prayer", [
+10 -2
View File
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules"; import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles"; import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3"; export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
export interface RotationProfile { export interface RotationProfile {
readonly id: string; readonly id: string;
@@ -90,7 +90,7 @@ const profile = (
/** Playerbots priorities translated to the executable Healer Man spell model. */ /** Playerbots priorities translated to the executable Healer Man spell model. */
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]), profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]), profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]), profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "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: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"]),
@@ -430,6 +430,14 @@ function scoreAbility(
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null; if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null; if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null; if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
// A specialization profile is also an allow-list for utility GCDs once the
// build has a real damage action. Reactive interrupts, taunts, threat drops,
// and encounter-requested control already returned above; letting any other
// imported debuff/control action through here makes characters spam buttons
// such as Expose Armor and Demoralizing Shout instead of their rotation.
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0
&& (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null;
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0 && damaging) return null; && context.role !== "healer" && profileIndex < 0 && damaging) return null;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null; if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
+64 -22
View File
@@ -5286,12 +5286,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
for (const recipientId of recipients) { for (const recipientId of recipients) {
const previousAuras = work.auras; const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue; if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
work.auras = [...applyCombatAura(previousAuras, { const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition, definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID, sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId, targetId: recipientId,
now, now,
}).auras]; });
work.auras = [...auraResult.auras];
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
if (grantedAbsorb > 0) {
recordCombatEventInWork(work, {
occurredAt: now,
kind: "absorb",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: recipientId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras); applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) { if (auraDefinition.controlImmunities?.length) {
if ( if (
@@ -6278,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId, targetId: targetActorId,
now, now,
}); });
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
let health = state.health; let health = state.health;
let maxHealth = state.maxHealth; let maxHealth = state.maxHealth;
const actors = { ...state.actors }; const actors = { ...state.actors };
@@ -6316,24 +6334,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
} }
} }
let combatEvents = recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
});
if (grantedAbsorb > 0) {
combatEvents = recordCombatEvent(combatEvents, {
occurredAt: now,
kind: "absorb",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
set({ set({
auras: result.auras, auras: result.auras,
health, health,
maxHealth, maxHealth,
actors, actors,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents,
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
}),
}); });
if (companionActor) mirrorCompanionActor(companionActor); if (companionActor) mirrorCompanionActor(companionActor);
return true; return true;
@@ -8330,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
if ( if (
summon.health <= 0 summon.health <= 0
|| summon.controlledUntil > now || summon.controlledUntil > now
|| summon.nextActionAt > now
|| summon.globalCooldownEndsAt > now
|| summon.cast || summon.cast
|| summon.attackDamage <= 0 || summon.attackDamage <= 0
) return summon; ) return summon;
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
const summonAbility = abilityById(summon.sourceAbilityId); const summonAbility = abilityById(summon.sourceAbilityId);
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) { if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
if (!readyToAct) return summon;
const partyMembers = usePartyStore.getState().members; const partyMembers = usePartyStore.getState().members;
const injured = [ const injured = [
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition }, { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
@@ -8374,12 +8408,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
} }
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
? summon.targetId ? summon.targetId
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead : state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
? state.selectedTargetId ? state.selectedTargetId
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; : Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; if (!targetId) return readyToAct
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
: summon;
const targetPosition = getMobPosition(targetId); const targetPosition = getMobPosition(targetId);
let position = summon.position; let position = summon.position;
if (targetPosition) { if (targetPosition) {
@@ -8392,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
position = projectCombatGroundPoint(requested, step, position) ?? position; position = projectCombatGroundPoint(requested, step, position) ?? position;
} }
if ( if (
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5) readyToAct
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
&& combatHasLineOfSight(position, targetPosition) && combatHasLineOfSight(position, targetPosition)
) { ) {
damageMobInWork( damageMobInWork(
@@ -8406,7 +8443,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
); );
} }
} }
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; return {
...summon,
targetId,
position,
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
};
}); });
for (const [id, mob] of Object.entries(work.mobs)) { for (const [id, mob] of Object.entries(work.mobs)) {
+97
View File
@@ -22,7 +22,9 @@ import {
import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats"; import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void { function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -138,6 +140,101 @@ describe("party combat runtime", () => {
expect(useCombatStore.getState().summons.filter((summon) => ( expect(useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
))).toHaveLength(2); ))).toHaveLength(2);
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
useCombatStore.getState().tick(0.1, 10_000);
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
});
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const hunter = createConfiguredPartyMember({
name: "Beast Mastery",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
role: "damage",
}, 80, 0, "pet-target-runtime");
const warlock = createConfiguredPartyMember({
name: "Demonology",
classId: "warlock",
specialization: "Demonology",
specializationId: "warlock:demonology",
talentTreeId: "warlock-demonology",
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
role: "damage",
}, 80, 1, "pet-target-runtime");
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [hunter, warlock] });
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("pet-runtime-boss", 100_000);
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
for (let now = 1_000; now <= 30_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
const pets = useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
));
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
expect(events.filter((event) => event.kind === "damage"
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
]));
});
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const tank = createConfiguredPartyMember({
name: "Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 0, "discipline-shield-runtime");
const healer = createConfiguredPartyMember({
name: "Discipline",
classId: "priest",
specialization: "Discipline",
specializationId: "priest:discipline",
talentTreeId: "priest-discipline",
role: "healer",
}, 80, 1, "discipline-shield-runtime");
const priestAbilities = abilitiesForClass("priest");
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({
members: [
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
{
...healer,
resource: 100_000,
maxResource: 100_000,
resourcePools: { mana: 100_000 },
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
.map((ability) => [ability.id, 60_000])),
},
],
});
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("discipline-shield-boss", 100_000);
advanceDungeonPartyCombat(1_000);
const applied = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === healer.id
&& aura.targetId === tank.id
&& aura.definition.name.includes("Power Word: Shield")
));
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
}); });
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => { it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
+14 -3
View File
@@ -972,7 +972,8 @@ function choosePartySupportIntent(
health: candidate.health, health: candidate.health,
maxHealth: candidate.maxHealth, 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)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (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)
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5)) && (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
@@ -1162,7 +1163,12 @@ function executePartyAbility(
comboTargetId: comboPoints > 0 ? targetId : null, 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); const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
? targetId
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
? combat.selectedTargetId
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, 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)) {
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = { const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
kind: "summon", kind: "summon",
@@ -1170,7 +1176,12 @@ function executePartyAbility(
coefficient: 0.35, coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now, durationMs: Number.MAX_SAFE_INTEGER - now,
}; };
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now); const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
? targetId
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
? combat.selectedTargetId
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") { } else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId); const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") { if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
+10
View File
@@ -117,6 +117,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect.objectContaining({ coefficient: 1.8 }), expect.objectContaining({ coefficient: 1.8 }),
); );
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]); expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
]);
expect(byId("wow335-warrior-bladestorm")).toMatchObject({ target: "self", radius: 8 });
expect(effectsOfKind(byId("wow335-paladin-hammer-of-the-righteous"), "damage")).toEqual([
expect.objectContaining({ coefficient: 1.2 }),
]);
expect(effectsOfKind(byId("wow335-druid-swiftmend"), "heal")).toEqual([
expect.objectContaining({ coefficient: 2.2 }),
]);
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual( expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
expect.objectContaining({ coefficient: 2 }), expect.objectContaining({ coefficient: 2 }),
); );
+16 -1
View File
@@ -744,6 +744,11 @@ function classScriptEffects(
// The player spell delegates its weapon hit to a triggered child spell; // The player spell delegates its weapon hit to a triggered child spell;
// retain the imported self-heal aura and restore the missing attack. // retain the imported self-heal aura and restore the missing attack.
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }]; case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
// The player-facing aura delegates each weapon strike to server-only child
// spells. Model the six one-second swings as a periodic damage payload.
case "warrior:Bladestorm": return [{
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
}];
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }]; case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }]; case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }]; case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
@@ -764,6 +769,9 @@ function classScriptEffects(
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }]; case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }]; case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }]; case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
// a child spell that is not present in the player-facing progression rows.
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
case "paladin:Divine Intervention": return [{ case "paladin:Divine Intervention": return [{
kind: "apply-aura", kind: "apply-aura",
aura: { aura: {
@@ -881,6 +889,9 @@ function classScriptEffects(
triggerSpellId: 0, triggerSpellId: 0,
}]; }];
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }]; case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
// Swiftmend's DBC row exposes only the one-point consume marker; supply the
// direct heal while leaving HoT consumption out until aura choice is modeled.
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }]; case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
default: return []; default: return [];
} }
@@ -1178,6 +1189,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|| (classId === "priest" && abilityName === "Penance" || (classId === "priest" && abilityName === "Penance"
&& (effect.kind === "damage" || effect.kind === "heal")) && (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon") || (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|| (classId === "warrior" && abilityName === "Bloodrage" || (classId === "warrior" && abilityName === "Bloodrage"
&& (effect.kind === "resource" || effect.kind === "heal")) && (effect.kind === "resource" || effect.kind === "heal"))
)) ))
@@ -1351,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
} }
function abilityNameRadius(abilityName: string): number { function abilityNameRadius(abilityName: string): number {
if (abilityName === "Bladestorm") return 8;
if (abilityName === "Hellfire") return 10; if (abilityName === "Hellfire") return 10;
if (abilityName === "Mind Sear") return 10; if (abilityName === "Mind Sear") return 10;
if (abilityName === "Pestilence") return 10; if (abilityName === "Pestilence") return 10;
@@ -1359,7 +1374,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 (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
if (abilityName === "Penance") return "friendly"; if (abilityName === "Penance") return "friendly";
if (abilityName === "Death Coil") return "hostile"; if (abilityName === "Death Coil") return "hostile";
return undefined; return undefined;