364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
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<string>
|
|
hotTargets: Set<string>
|
|
shieldTargets: Set<string>
|
|
damageReductionTargets: Set<string>
|
|
groupTargets: Set<string>
|
|
}
|
|
|
|
export type ExtraTargetMode = Partial<Record<Spell['kind'], SpellTargetBucket>>
|
|
|
|
function hasBlockedId(blockedIds: Iterable<string>, 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<string>) {
|
|
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,
|
|
}
|
|
}
|