import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game' import { addBounceHeal, addHotEffect } from './rules' export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction' export type SpellTargetPlan = { directTargets: Set hotTargets: Set shieldTargets: Set damageReductionTargets: Set groupTargets: Set } export type ExtraTargetMode = Partial> function hasBlockedId(blockedIds: Iterable, id: string) { if (blockedIds instanceof Set) return blockedIds.has(id) for (const blockedId of blockedIds) { if (blockedId === id) return true } return false } export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable) { let target: PartyMember | undefined let targetRatio = Number.POSITIVE_INFINITY for (const member of party) { if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue const healthRatio = member.health / member.maxHealth if (healthRatio < targetRatio) { target = member targetRatio = healthRatio } } return target } export function addLowestHealthExtraTarget( plan: SpellTargetPlan, party: PartyMember[], bucket: SpellTargetBucket, ) { const targetSet = bucket === 'direct' ? plan.directTargets : bucket === 'hot' ? plan.hotTargets : bucket === 'shield' ? plan.shieldTargets : plan.damageReductionTargets const extra = lowestHealthExtraTarget(party, targetSet) if (extra) targetSet.add(extra.id) } export function buildSpellTargetPlan({ party, spell, targetId, extraTargets, directTarget, hotTarget, shieldTarget, damageReductionTarget, groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS, extraTargetMode, beforeExtraTargetBuckets = [], }: { party: PartyMember[] spell: Spell targetId: string extraTargets: number directTarget: boolean hotTarget: boolean shieldTarget: boolean damageReductionTarget?: boolean groupTargetCount?: number extraTargetMode: ExtraTargetMode beforeExtraTargetBuckets?: SpellTargetBucket[] }): SpellTargetPlan { const plan: SpellTargetPlan = { directTargets: new Set(directTarget ? [targetId] : []), hotTargets: new Set(hotTarget ? [targetId] : []), shieldTargets: new Set(shieldTarget ? [targetId] : []), damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []), groupTargets: new Set( spell.kind === 'group' ? groupHealTargets(party, groupTargetCount).map((member) => member.id) : [], ), } beforeExtraTargetBuckets.forEach((bucket) => { addLowestHealthExtraTarget(plan, party, bucket) }) const bucket = extraTargetMode[spell.kind] ?? 'direct' for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break addLowestHealthExtraTarget(plan, party, bucket) } return plan } export function clearNegativeEffects(member: PartyMember) { return { ...member, debuff: undefined, debuffTicks: undefined, poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, } } export type FloatingHealEvent = { memberId: string value: number } export type SpellEffectProfile = { /** * Profiles keep mode-specific spell math out of screen components. PvE uses * stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium * also applies dampening through the power callbacks below. */ modeName: string heal: (member: PartyMember, power: number, multiplier: number) => number healingMultiplier: (member: PartyMember) => number power: { direct: (spell: Spell) => number cleanse: (spell: Spell) => number groupHeal: (spell: Spell) => number groupAbsorb: (spell: Spell) => number shield: (sourcePower: number, strength?: number) => number } hot: { mode: 'effects' | 'ticks' defaultTicks: number groupTicks: number radianceTicks: number merge: 'replace' | 'max' groupMerge?: 'replace' | 'max' } effects: { renewSpell?: Spell | null shieldSpell?: Spell | null groupAbsorbOnly: (spell: Spell) => boolean groupHotOnly: (spell: Spell) => boolean groupAppliesShield: (spell: Spell) => boolean groupAppliesHot: (spell: Spell) => boolean shieldAppliesHot: (spell: Spell) => boolean hotSpellForDirect: (spell: Spell) => Spell } ratios: { groupShield: number directShield: number } damageReductionTicks: number floatingHeals: { group: boolean direct: boolean cleanse: boolean } bounceHeals: boolean } function applyHot( member: PartyMember, spell: Spell, profile: SpellEffectProfile, ticks = profile.hot.defaultTicks, merge = profile.hot.merge, ) { if (profile.hot.mode === 'effects') { return { ...member, hotTicks: 0, hotEffects: addHotEffect(member, spell, ticks), } } return { ...member, hotTicks: merge === 'max' ? Math.max(member.hotTicks, ticks) : ticks, } } function recordFloatingHeal( events: FloatingHealEvent[], enabled: boolean, member: PartyMember, nextHealth: number, ) { if (enabled && nextHealth > member.health) { events.push({ memberId: member.id, value: nextHealth - member.health }) } } export function applySpellEffectProfile({ party, spell, targetId, plan, profile, }: { party: PartyMember[] spell: Spell targetId: string plan: SpellTargetPlan profile: SpellEffectProfile }) { const floatingHeals: FloatingHealEvent[] = [] const nextParty = party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { if (!plan.groupTargets.has(member.id)) return member const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell) if (profile.effects.groupHotOnly(spell)) { return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge) } const power = isGroupAbsorb ? profile.power.groupAbsorb(spell) : profile.power.groupHeal(spell) const nextHealth = isGroupAbsorb ? member.health : profile.heal(member, power, profile.healingMultiplier(member)) recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth) let nextMember: PartyMember = { ...member, health: nextHealth, shield: profile.effects.groupAppliesShield(spell) ? Math.max( member.shield, isGroupAbsorb ? power : profile.power.shield( profile.effects.shieldSpell?.power ?? spell.power, profile.ratios.groupShield, ), ) : member.shield, } if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) { nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge) } else if (profile.effects.groupAppliesHot(spell)) { nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge) } return nextMember } if ( !plan.directTargets.has(member.id) && !plan.hotTargets.has(member.id) && !plan.shieldTargets.has(member.id) && !plan.damageReductionTargets.has(member.id) && !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals) ) return member if (spell.kind === 'shield') { let nextMember: PartyMember = { ...member, shield: Math.max(member.shield, profile.power.shield(spell.power)), } if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) { nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile) } return nextMember } if (spell.kind === 'damage_reduction') { return { ...member, damageReductionTicks: profile.hot.merge === 'max' ? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks) : profile.damageReductionTicks, hotTicks: plan.hotTargets.has(member.id) ? (profile.hot.merge === 'max' ? Math.max(member.hotTicks, profile.hot.defaultTicks) : profile.hot.defaultTicks) : member.hotTicks, } } if (spell.kind === 'bounce_heal' && profile.bounceHeals) { return { ...member, bounceHeals: addBounceHeal(member, spell) } } if (spell.kind === 'cleanse') { const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member)) recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth) let nextMember: PartyMember = { ...clearNegativeEffects(member), health: nextHealth, shield: plan.shieldTargets.has(member.id) ? Math.max( member.shield, profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power), ) : member.shield, } if (plan.hotTargets.has(member.id)) { nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile) } return nextMember } const nextHealth = plan.directTargets.has(member.id) ? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member)) : member.health recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth) let nextMember: PartyMember = { ...member, health: nextHealth, shield: plan.shieldTargets.has(member.id) ? Math.max( member.shield, profile.power.shield( profile.effects.shieldSpell?.power ?? spell.power, profile.ratios.directShield, ), ) : member.shield, } if (plan.hotTargets.has(member.id)) { nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile) } return nextMember }) return { party: nextParty, floatingHeals } } export function advanceFreeCastProgress({ enabled, wasReady, castsTowardFree, }: { enabled: boolean wasReady: boolean castsTowardFree: number }) { if (!enabled) { return { castsTowardFree, freeCastReady: wasReady, } } if (wasReady) { return { castsTowardFree: 0, freeCastReady: false, } } const nextCasts = castsTowardFree + 1 return { castsTowardFree: nextCasts >= 5 ? 0 : nextCasts, freeCastReady: nextCasts >= 5, } }