Release Healer Man 0.1.41
This commit is contained in:
1 parent
197a64117c
commit
c3f8a69b51
42 files changed
+886
-127
No files matched your search
+105
-36
@@ -3,6 +3,7 @@ import { combatNow } from "./combatClock";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
type RaceId,
|
||||
} from "../app/characterCatalog";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
resourceProfileForClass,
|
||||
triggeredAbilityBySpellId,
|
||||
type AbilityAmountScaling,
|
||||
type AbilityDamageResource,
|
||||
type AbilityDefinition,
|
||||
type AbilityEffect,
|
||||
type MobStatusKind,
|
||||
@@ -155,6 +157,7 @@ import {
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
import {
|
||||
baseResourcesFor,
|
||||
damageAfterEquipmentMitigation,
|
||||
deriveCharacterStats,
|
||||
equipmentAttackPower,
|
||||
@@ -164,6 +167,7 @@ import {
|
||||
type CharacterStats,
|
||||
type WotlkAttackKind,
|
||||
} from "./wotlkStats";
|
||||
import { healingRecipientIds } from "./healingAbilityRules";
|
||||
import {
|
||||
combatAttackerFromCharacterStats,
|
||||
combatDefenderFromCharacterStats,
|
||||
@@ -417,7 +421,7 @@ export interface ActiveCast {
|
||||
readonly nextTickAt: number;
|
||||
}
|
||||
|
||||
export interface TimedCombatEffect {
|
||||
export interface TimedCombatEffect extends AbilityDamageResource {
|
||||
readonly id: string;
|
||||
readonly kind: "dot" | "hot";
|
||||
readonly sourceAbilityId: string;
|
||||
@@ -431,6 +435,8 @@ export interface TimedCombatEffect {
|
||||
readonly pointsPerLevel?: number;
|
||||
readonly coefficientPerLevel?: number;
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly attackPowerCoefficient?: number;
|
||||
readonly spiritCoefficient?: number;
|
||||
readonly sourceLevel?: number;
|
||||
readonly maxScalingLevel?: number;
|
||||
readonly remainingTicks: number;
|
||||
@@ -1193,7 +1199,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
));
|
||||
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
||||
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||
if (healing) return "spell";
|
||||
if (healing && ability.target !== "hostile") return "spell";
|
||||
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
||||
return "spell";
|
||||
@@ -1253,7 +1259,11 @@ export function equipmentAdjustedCombatAmount(
|
||||
0,
|
||||
scaling.bonusPowerCoefficient ?? coefficient,
|
||||
);
|
||||
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
|
||||
const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient
|
||||
? deriveCharacterStats(classId, level, gearStats) : null;
|
||||
const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0)
|
||||
+ (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0);
|
||||
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower));
|
||||
}
|
||||
|
||||
function playerCharacterStats(
|
||||
@@ -1609,6 +1619,7 @@ function initialResourcePools(
|
||||
classId: ClassId,
|
||||
abilities: readonly AbilityDefinition[],
|
||||
activeMaximum: number,
|
||||
manaMaximum = 100,
|
||||
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
|
||||
const activeProfile = resourceProfileForClass(classId);
|
||||
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
|
||||
@@ -1617,7 +1628,7 @@ function initialResourcePools(
|
||||
const pools: Partial<Record<ResourceType, number>> = {};
|
||||
for (const type of resourceTypes) {
|
||||
if (type === "health") continue;
|
||||
const maximum = type === activeProfile.type ? activeMaximum : defaultResourceMaximum(type);
|
||||
const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type);
|
||||
maximums[type] = maximum;
|
||||
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
||||
? 0
|
||||
@@ -1629,12 +1640,14 @@ function initialResourcePools(
|
||||
export function resolvedAbilityCosts(
|
||||
ability: AbilityDefinition,
|
||||
modifiers: readonly TalentModifier[],
|
||||
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
||||
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras" | "level">>,
|
||||
): readonly { resource: ResourceType; amount: number }[] {
|
||||
const aggregated = new Map<ResourceType, number>();
|
||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
||||
const maximum = cost.resource === "health"
|
||||
const maximum = cost.percentageBasis === "base-mana"
|
||||
? baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana
|
||||
: cost.resource === "health"
|
||||
? state.maxHealth
|
||||
: cost.resource === activeResourceType
|
||||
? state.maxResource
|
||||
@@ -2887,7 +2900,7 @@ function executeTriggeredSpellInWork(
|
||||
ability,
|
||||
);
|
||||
for (const mobId of mobTargets) {
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
work.classId,
|
||||
work,
|
||||
mobId,
|
||||
@@ -2899,6 +2912,7 @@ function executeTriggeredSpellInWork(
|
||||
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
||||
ability,
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now);
|
||||
}
|
||||
} else if (effect.kind === "heal") {
|
||||
const rawAmount = talentAdjustedAmount(
|
||||
@@ -2907,17 +2921,21 @@ function executeTriggeredSpellInWork(
|
||||
"healing-percent",
|
||||
ability,
|
||||
);
|
||||
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
||||
const amount = Math.round(resolveAuraHealingValue(
|
||||
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
||||
aurasForEntity(work.auras, recipient),
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
const amount = Math.round(resolveAuraHealingValue(
|
||||
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
||||
aurasForEntity(work.auras, recipient),
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "status") {
|
||||
for (const mobId of mobTargets) {
|
||||
@@ -2972,10 +2990,14 @@ function executeTriggeredSpellInWork(
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "hot") {
|
||||
const recipient = targetId && !work.mobs[targetId] ? targetId : null;
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "shield") {
|
||||
const amount = equipmentAdjustedCombatAmount(
|
||||
effect, work.progression.level, work.gearStats, work.classId, ability,
|
||||
@@ -3106,12 +3128,43 @@ function executeTriggeredSpellInWork(
|
||||
}
|
||||
}
|
||||
|
||||
function grantDamageResourceInWork(
|
||||
work: MutableCastWork, effect: AbilityDamageResource, damage: number,
|
||||
sourceActorId: string, abilityId: string, now: number,
|
||||
): void {
|
||||
const gain = effect.onDamageResource;
|
||||
if (!gain || damage <= 0 || gain.amount <= 0) return;
|
||||
const player = sourceActorId === PLAYER_AURA_ENTITY_ID;
|
||||
const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId];
|
||||
if (player ? work.health <= 0 : !actor?.alive) return;
|
||||
const pool = actor?.resources[gain.resource];
|
||||
const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource)
|
||||
: pool?.maximum ?? defaultResourceMaximum(gain.resource);
|
||||
const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0;
|
||||
const auras = aurasForEntity(work.auras, sourceActorId);
|
||||
const requested = Math.max(0, resolveAuraResourceValue(
|
||||
resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation",
|
||||
));
|
||||
const restored = Math.max(0, Math.min(requested, maximum - current));
|
||||
if (player) {
|
||||
work.resourcePools[gain.resource] = current + restored;
|
||||
if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored;
|
||||
} else if (actor) {
|
||||
const next = { ...actor, resources: { ...actor.resources,
|
||||
[gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } };
|
||||
work.actors[sourceActorId] = next;
|
||||
mirrorCompanionActor(next);
|
||||
}
|
||||
recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId,
|
||||
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
|
||||
}
|
||||
|
||||
function timedEffect(
|
||||
kind: "dot" | "hot",
|
||||
abilityId: string,
|
||||
sourceActorId: AggroActorId,
|
||||
targetId: string | null,
|
||||
scaling: AbilityAmountScaling & { readonly coefficient: number },
|
||||
scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean },
|
||||
ticks: number,
|
||||
intervalMs: number,
|
||||
now: number,
|
||||
@@ -3132,11 +3185,14 @@ function timedEffect(
|
||||
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
|
||||
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
|
||||
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
|
||||
...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }),
|
||||
...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }),
|
||||
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
|
||||
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
|
||||
...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}),
|
||||
remainingTicks: ticks,
|
||||
intervalMs,
|
||||
nextTickAt: now + intervalMs,
|
||||
nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs),
|
||||
...(tag ? { tag } : {}),
|
||||
...(damageSchool ? { damageSchool } : {}),
|
||||
...(spellPenetration ? { spellPenetration } : {}),
|
||||
@@ -3495,7 +3551,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId,
|
||||
secondaryClassId,
|
||||
);
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
||||
// RoM secondary pools use native resource limits, not the mapped WoW class's mana.
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource,
|
||||
isRomClassId(classId) ? defaultResourceMaximum("mana")
|
||||
: deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana);
|
||||
set((state) => ({
|
||||
...initialState(),
|
||||
classId,
|
||||
@@ -4500,7 +4559,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
summons: [...state.summons],
|
||||
areaEffects: [...state.areaEffects],
|
||||
combatEvents: [...state.combatEvents],
|
||||
actors: { ...state.actors },
|
||||
actors: { ...combatActorSnapshots(state) },
|
||||
cooldowns: { ...state.cooldowns },
|
||||
health: healthAfterCosts,
|
||||
maxHealth: state.maxHealth,
|
||||
@@ -4606,7 +4665,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
ability,
|
||||
);
|
||||
for (const id of affectedMobIds) {
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
state.classId,
|
||||
work,
|
||||
id,
|
||||
@@ -4618,6 +4677,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||
ability,
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
|
||||
}
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
|
||||
@@ -4723,6 +4783,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
|
||||
let directHealing = 0;
|
||||
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
||||
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
|
||||
for (const healingTargetId of [...new Set(healingTargetIds)]) {
|
||||
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
|
||||
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
|
||||
@@ -5029,14 +5091,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (effect.kind === "hot") {
|
||||
if (targeting?.shape === "ground") continue;
|
||||
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
|
||||
work.effects = work.effects.filter((entry) => !(
|
||||
entry.kind === "hot"
|
||||
&& entry.sourceAbilityId === ability.id
|
||||
&& entry.targetId === hotTargetId
|
||||
));
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, castThreatSource.actorId, hotTargetId, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient;
|
||||
work.effects = work.effects.filter((entry) => !(
|
||||
entry.kind === "hot"
|
||||
&& entry.sourceAbilityId === ability.id
|
||||
&& entry.targetId === normalizedTarget
|
||||
));
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "taunt") {
|
||||
const friendlyAttackers = effectiveTarget === "friendly" && targetId
|
||||
? Object.values(work.mobs)
|
||||
@@ -7849,7 +7917,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: baseAmount;
|
||||
if (effect.kind === "dot" && effect.targetId) {
|
||||
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
state.classId,
|
||||
work,
|
||||
effect.targetId,
|
||||
@@ -7869,6 +7937,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
canBlock: false,
|
||||
},
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt);
|
||||
} else if (effect.kind === "hot") {
|
||||
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||
const modifiedHealing = Math.round(resolveAuraHealingValue(
|
||||
|
||||
Reference in new issue
Block a user