import { getEmptyClaudeCraftAbilityModifier, type TalentModifiers } from './claudeCraftTalents' import { getArenaAbility, getArenaClassVitals, type ArenaClassId } from './actionClassKits' export type PartyRole = 'healer' | 'tank' | 'melee' | 'ranged' export type SpellSlot = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 export type SpellDefinition = { slot: SpellSlot abilityId: ClaudeCraftHealerAbilityId name: string cooldown: number castTime: number } export type ActionHealerClassId = 'priest' | 'paladin' | 'shaman' | 'druid' export type ClaudeCraftHealerAbilityId = | 'smite' | 'lesser_heal' | 'power_word_fortitude' | 'shadow_word_pain' | 'power_word_shield' | 'renew' | 'mind_blast' | 'heal' | 'mind_flay' | 'flash_heal' | 'judgement' | 'holy_light' | 'flash_of_light' | 'blessing_of_might' | 'consecration' | 'divine_protection' | 'exorcism' | 'lay_on_hands' | 'seal_of_righteousness' | 'lightning_bolt' | 'healing_wave' | 'lightning_shield' | 'earth_shock' | 'flame_shock' | 'frost_shock' | 'healing_touch' | 'rejuvenation' | 'regrowth' | 'barkskin' | 'wrath' | 'moonfire' | 'insect_swarm' | 'starfire' | 'faerie_fire' export type ClaudeCraftPriestAbilityId = Extract< ClaudeCraftHealerAbilityId, | 'smite' | 'lesser_heal' | 'power_word_fortitude' | 'shadow_word_pain' | 'power_word_shield' | 'renew' | 'mind_blast' | 'heal' | 'mind_flay' | 'flash_heal' > export type ActionSpellTarget = 'Enemy target' | 'Friendly target' export type ActionSpellSchool = 'Holy' | 'Shadow' | 'Nature' | 'Fire' | 'Frost' | 'Arcane' | 'Physical' export type ActionSpellbook = { classId: ActionHealerClassId iconFolder: string spells: Record manaCosts: Record ranks: Record schools: Record targets: Record descriptions: Record bar: SpellSlot[] } export type CastState = { spell: SpellSlot targetId: string remaining: number total: number } export type HealOverTimeEffectId = 'renew' | 'rejuvenation' | 'regrowth' export type HealOverTimeEffect = { id: HealOverTimeEffectId label: string remaining: number tickHeal: number tickSeconds: number tickTimer: number } export type HealOverTimeFrame = Pick export type SpellResourceState = { mana: number maxMana: number storedMomentumCasts: number storedMomentumReady: boolean } export type ActionRangePoint = { x: number y: number radius?: number } export type ActionSpellReachBlocker = 'range' | 'lineOfSight' export type ActionSpellReachResult = { distance: number effectiveRange: number ok: true } | { distance: number effectiveRange: number ok: false reason: ActionSpellReachBlocker } export type ShieldedHealthState = { hp: number maxHp: number shield: number invulnerableTimer?: number } export type RenewState = { healOverTimes?: HealOverTimeEffect[] renewTimer: number renewTickTimer: number } export const ACTION_SQUARE_SIZE = 48 export const HEAL_RANGE = ACTION_SQUARE_SIZE * 6 export const RANGED_ATTACK_RANGE = ACTION_SQUARE_SIZE * 6 export const ACTION_MOVEMENT_RULES = { baseSpeed: 245, } as const export const ACTION_CONTROL_RULES = { stunSeconds: 0.92, invulnerableSeconds: 0.7, } as const export const HEALING_SPELL_RULES = { mendHeal: 34, renewSeconds: 15, renewTickSeconds: 3, renewHeal: 28, radianceHeal: 250, sunWardShield: 145, purifyHeal: 131, dungeonShieldCap: 60, arenaShieldCapRatio: 0.72, } as const export const SPELLS: Record = { 1: { slot: 1, abilityId: 'smite', name: 'Smite', cooldown: 0, castTime: 2.5 }, 2: { slot: 2, abilityId: 'lesser_heal', name: 'Lesser Heal', cooldown: 0, castTime: 2 }, 3: { slot: 3, abilityId: 'power_word_fortitude', name: 'Power Word: Fortitude', cooldown: 0, castTime: 0 }, 4: { slot: 4, abilityId: 'shadow_word_pain', name: 'Shadow Word: Pain', cooldown: 0, castTime: 0 }, 5: { slot: 5, abilityId: 'power_word_shield', name: 'Power Word: Shield', cooldown: 6, castTime: 0 }, 6: { slot: 6, abilityId: 'renew', name: 'Renew', cooldown: 0, castTime: 0 }, 7: { slot: 7, abilityId: 'mind_blast', name: 'Mind Blast', cooldown: 8, castTime: 1.5 }, 8: { slot: 8, abilityId: 'heal', name: 'Heal', cooldown: 0, castTime: 2.5 }, 9: { slot: 9, abilityId: 'mind_flay', name: 'Mind Flay', cooldown: 0, castTime: 0 }, 10: { slot: 10, abilityId: 'flash_heal', name: 'Flash Heal', cooldown: 0, castTime: 1.5 }, } export const SPELL_MANA_COSTS: Record = { 1: 70, 2: 65, 3: 80, 4: 55, 5: 100, 6: 75, 7: 95, 8: 130, 9: 45, 10: 75, } const ALL_SPELL_SLOTS: SpellSlot[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] export const CLAUDECRAFT_PRIEST_ACTION_BAR: SpellSlot[] = ALL_SPELL_SLOTS export const DUNGEON_PRIEST_SPELL_BAR: SpellSlot[] = CLAUDECRAFT_PRIEST_ACTION_BAR export const ARENA_PRIEST_SPELL_BAR: SpellSlot[] = CLAUDECRAFT_PRIEST_ACTION_BAR export const SPELL_GLOBAL_COOLDOWN_SECONDS = 1.5 export const SPELL_RESOURCE_MAX = 900 export const SPELL_RESOURCE_REGEN = 10 export const PRIEST_SPELL_ICONS: Record = { 1: 'smite', 2: 'lesser_heal', 3: 'power_word_fortitude', 4: 'shadow_word_pain', 5: 'power_word_shield', 6: 'renew', 7: 'mind_blast', 8: 'heal', 9: 'mind_flay', 10: 'flash_heal', } export const PRIEST_SPELL_RANKS: Record = { 1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 3, 7: 3, 8: 2, 9: 1, 10: 1, } export const PRIEST_SPELL_SCHOOLS: Record = { 1: 'Holy', 2: 'Holy', 3: 'Holy', 4: 'Shadow', 5: 'Holy', 6: 'Holy', 7: 'Shadow', 8: 'Holy', 9: 'Shadow', 10: 'Holy', } export const PRIEST_SPELL_TARGETS: Record = { 1: 'Enemy target', 2: 'Friendly target', 3: 'Friendly target', 4: 'Enemy target', 5: 'Friendly target', 6: 'Friendly target', 7: 'Enemy target', 8: 'Friendly target', 9: 'Enemy target', 10: 'Friendly target', } export const PRIEST_SPELL_DESCRIPTIONS: Record = { 1: 'Smites the enemy for 64 to 78 Holy damage.', 2: 'Heals a friendly target for 110 to 132.', 3: 'Increases the target Stamina by 12 for 30 min.', 4: 'A word of darkness causes 84 Shadow damage over 18 sec.', 5: 'Shields the target, absorbing 145 damage for 30 sec.', 6: 'Heals the target for 140 over 15 sec.', 7: 'Blasts the target mind for 86 to 94 Shadow damage.', 8: 'A slow but powerful prayer that heals a friendly target for 230 to 270.', 9: 'Assaults the target mind, causing 12 Shadow damage each second for 3 sec.', 10: 'A fast prayer that heals a friendly target for 120 to 142.', } export const ACTION_HEALER_SPELLBOOKS: Record = { priest: createSpellbook('priest', 'priest', SPELLS, SPELL_MANA_COSTS, PRIEST_SPELL_RANKS, PRIEST_SPELL_SCHOOLS, PRIEST_SPELL_TARGETS, PRIEST_SPELL_DESCRIPTIONS), paladin: createSpellbook( 'paladin', 'paladin', { 1: spell(1, 'judgement', 'Judgement', 8, 0), 2: spell(2, 'holy_light', 'Holy Light', 0, 2.5), 3: spell(3, 'flash_of_light', 'Flash of Light', 0, 1.5), 4: spell(4, 'blessing_of_might', 'Blessing of Might', 0, 0), 5: spell(5, 'consecration', 'Consecration', 8, 0), 6: spell(6, 'divine_protection', 'Divine Protection', 30, 0), 7: spell(7, 'exorcism', 'Exorcism', 15, 0), 8: spell(8, 'lay_on_hands', 'Lay on Hands', 60, 0), 9: spell(9, 'seal_of_righteousness', 'Seal of Righteousness', 0, 0), 10: spell(10, 'flash_of_light', 'Flash of Light', 0, 1.5), }, costs(70, 130, 75, 80, 105, 100, 95, 220, 45, 75), ranks(4, 3, 3, 3, 3, 2, 2, 1, 2, 3), schools('Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy'), targets('Enemy target', 'Friendly target', 'Friendly target', 'Friendly target', 'Enemy target', 'Friendly target', 'Enemy target', 'Friendly target', 'Enemy target', 'Friendly target'), descriptions( 'Judges the enemy for Holy damage.', 'Heals a friendly target with a large burst of Holy light.', 'A fast, efficient Holy heal.', 'Blesses an ally, represented here as a small protective ward.', 'Consecrates nearby ground under the enemy for Holy damage.', 'Wraps the target in divine protection.', 'Strikes an enemy with Holy force.', 'A long-cooldown emergency heal.', 'Seals your weapon with Holy damage against the enemy.', 'A fast, efficient Holy heal.', ), ), shaman: createSpellbook( 'shaman', 'shaman', { 1: spell(1, 'lightning_bolt', 'Lightning Bolt', 0, 2.5), 2: spell(2, 'healing_wave', 'Healing Wave', 0, 2.5), 3: spell(3, 'lightning_shield', 'Lightning Shield', 10, 0), 4: spell(4, 'earth_shock', 'Earth Shock', 6, 0), 5: spell(5, 'flame_shock', 'Flame Shock', 6, 0), 6: spell(6, 'frost_shock', 'Frost Shock', 6, 0), 7: spell(7, 'healing_wave', 'Healing Wave', 0, 2.5), 8: spell(8, 'lightning_shield', 'Lightning Shield', 10, 0), 9: spell(9, 'lightning_bolt', 'Lightning Bolt', 0, 2.5), 10: spell(10, 'healing_wave', 'Healing Wave', 0, 2.5), }, costs(70, 125, 90, 75, 80, 80, 125, 90, 70, 125), ranks(4, 3, 3, 3, 3, 3, 3, 3, 4, 3), schools('Nature', 'Nature', 'Nature', 'Nature', 'Fire', 'Frost', 'Nature', 'Nature', 'Nature', 'Nature'), targets('Enemy target', 'Friendly target', 'Friendly target', 'Enemy target', 'Enemy target', 'Enemy target', 'Friendly target', 'Friendly target', 'Enemy target', 'Friendly target'), descriptions( 'Hurls lightning at the enemy.', 'Heals a friendly target with a steady wave.', 'Surrounds an ally with a lightning shield.', 'Shocks an enemy with earth power.', 'Burns an enemy with flame shock damage over time.', 'Shocks an enemy with frost.', 'Heals a friendly target with a steady wave.', 'Surrounds an ally with a lightning shield.', 'Hurls lightning at the enemy.', 'Heals a friendly target with a steady wave.', ), ), druid: createSpellbook( 'druid', 'druid', { 1: spell(1, 'wrath', 'Wrath', 0, 2), 2: spell(2, 'rejuvenation', 'Rejuvenation', 0, 0), 3: spell(3, 'regrowth', 'Regrowth', 0, 2), 4: spell(4, 'healing_touch', 'Healing Touch', 0, 2.5), 5: spell(5, 'barkskin', 'Barkskin', 12, 0), 6: spell(6, 'moonfire', 'Moonfire', 0, 0), 7: spell(7, 'insect_swarm', 'Insect Swarm', 0, 0), 8: spell(8, 'starfire', 'Starfire', 0, 3), 9: spell(9, 'faerie_fire', 'Faerie Fire', 6, 0), 10: spell(10, 'regrowth', 'Regrowth', 0, 2), }, costs(70, 75, 105, 130, 100, 55, 65, 120, 50, 105), ranks(4, 3, 3, 3, 2, 3, 2, 2, 2, 3), schools('Nature', 'Nature', 'Nature', 'Nature', 'Nature', 'Arcane', 'Nature', 'Arcane', 'Nature', 'Nature'), targets('Enemy target', 'Friendly target', 'Friendly target', 'Friendly target', 'Friendly target', 'Enemy target', 'Enemy target', 'Enemy target', 'Enemy target', 'Friendly target'), descriptions( 'Calls nature damage down on the enemy.', 'Heals the target over time.', 'Heals immediately and leaves a rejuvenating bloom.', 'A slow, powerful nature heal.', 'Protects the target with barkskin.', 'Burns the enemy with arcane lunar damage over time.', 'Sends insects to wear down the enemy.', 'A slow, heavy arcane strike.', 'Weakens the enemy with faerie fire.', 'Heals immediately and leaves a rejuvenating bloom.', ), ), } function spell(slot: SpellSlot, abilityId: ClaudeCraftHealerAbilityId, name: string, cooldown: number, castTime: number): SpellDefinition { return { abilityId, castTime, cooldown, name, slot } } function createSpellbook( classId: ActionHealerClassId, iconFolder: string, spells: Record, manaCosts: Record, ranks: Record, schools: Record, targets: Record, descriptions: Record, ): ActionSpellbook { return { bar: ALL_SPELL_SLOTS, classId, descriptions, iconFolder, manaCosts, ranks, schools, spells, targets, } } function costs( one: number, two: number, three: number, four: number, five: number, six: number, seven: number, eight: number, nine: number, ten: number, ): Record { return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten } } function ranks( one: number, two: number, three: number, four: number, five: number, six: number, seven: number, eight: number, nine: number, ten: number, ): Record { return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten } } function schools( one: ActionSpellSchool, two: ActionSpellSchool, three: ActionSpellSchool, four: ActionSpellSchool, five: ActionSpellSchool, six: ActionSpellSchool, seven: ActionSpellSchool, eight: ActionSpellSchool, nine: ActionSpellSchool, ten: ActionSpellSchool, ): Record { return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten } } function targets( one: ActionSpellTarget, two: ActionSpellTarget, three: ActionSpellTarget, four: ActionSpellTarget, five: ActionSpellTarget, six: ActionSpellTarget, seven: ActionSpellTarget, eight: ActionSpellTarget, nine: ActionSpellTarget, ten: ActionSpellTarget, ): Record { return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten } } function descriptions( one: string, two: string, three: string, four: string, five: string, six: string, seven: string, eight: string, nine: string, ten: string, ): Record { return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten } } function normalizeHealerClassId(classId: string | undefined): ActionHealerClassId { if (classId === 'paladin' || classId === 'shaman' || classId === 'druid') return classId return 'priest' } export function createSpellCooldowns(): Record { return { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, } } export function createSpellResourceState(classId?: string): SpellResourceState { const vitals = classId ? getArenaClassVitals(classId as ArenaClassId) : null const maxMana = vitals?.maxMana ?? SPELL_RESOURCE_MAX return { mana: maxMana, maxMana, storedMomentumCasts: 0, storedMomentumReady: false, } } export function tickSpellCooldowns(cooldowns: Record, deltaSeconds: number) { for (const slot of ALL_SPELL_SLOTS) { cooldowns[slot] = Math.max(0, cooldowns[slot] - deltaSeconds) } } export function tickSpellResource(resource: SpellResourceState, deltaSeconds: number) { resource.mana = Math.min(resource.maxMana, resource.mana + SPELL_RESOURCE_REGEN * deltaSeconds) } export function getSpellManaCost(spell: SpellSlot, options?: { costMultiplier?: number, freeCast?: boolean }) { if (options?.freeCast) return 0 return Math.max(1, Math.round(SPELL_MANA_COSTS[spell] * (options?.costMultiplier ?? 1))) } export function getActionSpellbook(classId: string | undefined): ActionSpellbook { return ACTION_HEALER_SPELLBOOKS[normalizeHealerClassId(classId)] } export function getActionSpellDefinition(classId: string | undefined, spell: SpellSlot, talentMods?: TalentModifiers) { const definition = getActionSpellbook(classId).spells[spell] const modifier = getActionTalentAbilityModifier(talentMods, definition.abilityId) return { ...definition, castTime: Math.max(0, roundTenths(definition.castTime * (1 + modifier.castPct))), cooldown: Math.max(0, roundTenths(definition.cooldown * (1 + modifier.cooldownPct))), } } export function getActionSpellRange(classId: string | undefined, spell: SpellSlot, talentMods?: TalentModifiers) { return getArenaAbility(getActionSpellDefinition(classId, spell, talentMods).abilityId).range } export function getActionSpellReach(options: { caster: ActionRangePoint target: ActionRangePoint classId: string | undefined spell: SpellSlot talentMods?: TalentModifiers includeTargetRadius?: boolean hasLineOfSight?: (from: ActionRangePoint, to: ActionRangePoint) => boolean }): ActionSpellReachResult { const baseRange = getActionSpellRange(options.classId, options.spell, options.talentMods) const effectiveRange = baseRange + (options.includeTargetRadius ? options.target.radius ?? 0 : 0) const distance = Math.hypot(options.caster.x - options.target.x, options.caster.y - options.target.y) if (distance > effectiveRange) return { ok: false, reason: 'range', distance, effectiveRange } if (options.hasLineOfSight && !options.hasLineOfSight(options.caster, options.target)) { return { ok: false, reason: 'lineOfSight', distance, effectiveRange } } return { ok: true, distance, effectiveRange } } export function getActionSpellManaCost(classId: string | undefined, spell: SpellSlot, options?: { costMultiplier?: number, freeCast?: boolean, talentMods?: TalentModifiers }) { if (options?.freeCast) return 0 const definition = getActionSpellbook(classId).spells[spell] const modifier = getActionTalentAbilityModifier(options?.talentMods, definition.abilityId) return Math.max(1, Math.round(getActionSpellbook(classId).manaCosts[spell] * (1 + modifier.costPct) * (options?.costMultiplier ?? 1))) } export function getActionTalentAdjustedAmount(classId: string | undefined, spell: SpellSlot, baseAmount: number, kind: 'damage' | 'heal' | 'shield', talentMods?: TalentModifiers) { const definition = getActionSpellbook(classId).spells[spell] const abilityModifier = getActionTalentAbilityModifier(talentMods, definition.abilityId) const globalMultiplier = kind === 'heal' ? talentMods?.global.healPct ?? 0 : kind === 'damage' ? talentMods?.global.spellDmgPct ?? 0 : 0 return Math.max(0, Math.round((baseAmount + abilityModifier.flatDmg) * (1 + abilityModifier.dmgPct + globalMultiplier))) } export function getActionSpellTarget(classId: string | undefined, spell: SpellSlot) { return getActionSpellbook(classId).targets[spell] } export function getActionSpellIconUrl(classId: string | undefined, spell: SpellSlot) { const book = getActionSpellbook(classId) return `/ui/skills/${book.iconFolder}/${book.spells[spell].abilityId}.png` } export function getActionSpellTooltip(classId: string | undefined, spell: SpellSlot) { const book = getActionSpellbook(classId) return { description: book.descriptions[spell], rank: book.ranks[spell], school: book.schools[spell], target: book.targets[spell], } } export function getDungeonSpellManaCost(spell: SpellSlot, options?: { freeCast?: boolean }) { if (options?.freeCast) return 0 return getSpellManaCost(spell, options) } export function spendSpellMana(resource: SpellResourceState, spell: SpellSlot, options?: { costMultiplier?: number }) { const cost = getSpellManaCost(spell, { costMultiplier: options?.costMultiplier, freeCast: resource.storedMomentumReady, }) if (!resource.storedMomentumReady && resource.mana < cost) { return { ok: false, cost } } if (resource.storedMomentumReady) resource.storedMomentumReady = false else resource.mana = Math.max(0, resource.mana - cost) return { ok: true, cost } } export function spendActionSpellMana(resource: SpellResourceState, classId: string | undefined, spell: SpellSlot, options?: { costMultiplier?: number, talentMods?: TalentModifiers }) { const cost = getActionSpellManaCost(classId, spell, { costMultiplier: options?.costMultiplier, freeCast: resource.storedMomentumReady, talentMods: options?.talentMods, }) if (!resource.storedMomentumReady && resource.mana < cost) { return { ok: false, cost } } if (resource.storedMomentumReady) resource.storedMomentumReady = false else resource.mana = Math.max(0, resource.mana - cost) return { ok: true, cost } } function getActionTalentAbilityModifier(talentMods: TalentModifiers | undefined, abilityId: ClaudeCraftHealerAbilityId) { return talentMods?.abilities[abilityId] ?? getEmptyClaudeCraftAbilityModifier() } function roundTenths(value: number) { return Math.round(value * 10) / 10 } export function spendDungeonSpellMana(resource: SpellResourceState, spell: SpellSlot) { const cost = getDungeonSpellManaCost(spell, { freeCast: resource.storedMomentumReady }) if (!resource.storedMomentumReady && resource.mana < cost) { return { ok: false, cost } } if (resource.storedMomentumReady) resource.storedMomentumReady = false else resource.mana = Math.max(0, resource.mana - cost) return { ok: true, cost } } export function recordSpellCast(resource: SpellResourceState, options?: { storedMomentum?: boolean }) { if (!options?.storedMomentum) return resource.storedMomentumCasts += 1 if (resource.storedMomentumCasts >= 5) { resource.storedMomentumCasts = 0 resource.storedMomentumReady = true } } export function applyShieldedDamage(target: ShieldedHealthState, amount: number) { if ((target.invulnerableTimer ?? 0) > 0) return 0 const absorbed = Math.min(target.shield, amount) target.shield -= absorbed const damage = amount - absorbed target.hp = Math.max(0, target.hp - damage) return damage } export function addShield(target: ShieldedHealthState, amount: number, options?: { cap?: number, capRatio?: number }) { const cap = options?.cap ?? target.maxHp * (options?.capRatio ?? HEALING_SPELL_RULES.arenaShieldCapRatio) target.shield = Math.min(cap, target.shield + amount) } export function dampenHealing(amount: number, dampeningPercent = 0) { return amount * Math.max(0, 1 - dampeningPercent / 100) } export function applyRenewTimers(target: RenewState, durationSeconds: number = HEALING_SPELL_RULES.renewSeconds) { applyHealOverTimeTimers(target, 'renew', { durationSeconds }) } export function applyHealOverTimeTimers( target: RenewState, id: HealOverTimeEffectId, options: { durationSeconds?: number tickHeal?: number } = {}, ) { const durationSeconds = options.durationSeconds ?? HEALING_SPELL_RULES.renewSeconds const tickSeconds = HEALING_SPELL_RULES.renewTickSeconds const healOverTimes = target.healOverTimes ?? [] const existing = healOverTimes.find((effect) => effect.id === id) if (existing) { existing.remaining = Math.max(existing.remaining, durationSeconds) existing.tickHeal = options.tickHeal ?? existing.tickHeal existing.tickTimer = tickSeconds } else { healOverTimes.push({ id, label: HEAL_OVER_TIME_LABELS[id], remaining: durationSeconds, tickHeal: options.tickHeal ?? HEALING_SPELL_RULES.renewHeal, tickSeconds, tickTimer: tickSeconds, }) } target.healOverTimes = healOverTimes syncRenewCompatibilityTimers(target) } export function tickHealOverTimes(target: RenewState, deltaSeconds: number, onTick: (effect: HealOverTimeEffect) => void) { const healOverTimes = target.healOverTimes if (!healOverTimes || healOverTimes.length === 0) { tickLegacyRenew(target, deltaSeconds, onTick) return } for (const effect of healOverTimes) { effect.remaining = Math.max(0, effect.remaining - deltaSeconds) effect.tickTimer -= deltaSeconds while (effect.remaining > 0 && effect.tickTimer <= 0) { onTick(effect) effect.tickTimer += effect.tickSeconds } } target.healOverTimes = healOverTimes.filter((effect) => effect.remaining > 0) syncRenewCompatibilityTimers(target) } export function getActiveHealOverTimeFrames(target: RenewState): HealOverTimeFrame[] { const healOverTimes = target.healOverTimes?.filter((effect) => effect.remaining > 0) ?? [] if (healOverTimes.length > 0) { return healOverTimes.map(({ id, label, remaining }) => ({ id, label, remaining })) } return target.renewTimer > 0 ? [{ id: 'renew', label: HEAL_OVER_TIME_LABELS.renew, remaining: target.renewTimer }] : [] } export function getHealOverTimeEffectId(abilityId: string): HealOverTimeEffectId | null { if (abilityId === 'renew' || abilityId === 'rejuvenation' || abilityId === 'regrowth') return abilityId return null } function tickLegacyRenew(target: RenewState, deltaSeconds: number, onTick: (effect: HealOverTimeEffect) => void) { if (target.renewTimer <= 0) return target.renewTimer = Math.max(0, target.renewTimer - deltaSeconds) target.renewTickTimer -= deltaSeconds if (target.renewTickTimer <= 0) { onTick({ id: 'renew', label: HEAL_OVER_TIME_LABELS.renew, remaining: target.renewTimer, tickHeal: HEALING_SPELL_RULES.renewHeal, tickSeconds: HEALING_SPELL_RULES.renewTickSeconds, tickTimer: target.renewTickTimer, }) target.renewTickTimer += HEALING_SPELL_RULES.renewTickSeconds } } function syncRenewCompatibilityTimers(target: RenewState) { const renew = target.healOverTimes?.find((effect) => effect.id === 'renew') target.renewTimer = renew?.remaining ?? 0 target.renewTickTimer = renew?.tickTimer ?? 0 } const HEAL_OVER_TIME_LABELS: Record = { regrowth: 'Regrowth', rejuvenation: 'Rejuvenation', renew: 'Renew', }