Files
i-want-to-heal/src/combat/spellEffects.ts
T
2026-06-29 22:35:49 -04:00

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,
}
}