I Want To Heal 2 build v1.0.5 code

This commit is contained in:
Warren H
2026-06-28 13:29:39 -04:00
parent 8abb44ea02
commit 257c34fc8d
53 changed files with 18187 additions and 1199 deletions
+760
View File
@@ -0,0 +1,760 @@
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<SpellSlot, SpellDefinition>
manaCosts: Record<SpellSlot, number>
ranks: Record<SpellSlot, number>
schools: Record<SpellSlot, ActionSpellSchool>
targets: Record<SpellSlot, ActionSpellTarget>
descriptions: Record<SpellSlot, string>
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<HealOverTimeEffect, 'id' | 'label' | 'remaining'>
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<SpellSlot, SpellDefinition> = {
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<SpellSlot, number> = {
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<SpellSlot, ClaudeCraftPriestAbilityId> = {
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<SpellSlot, number> = {
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<SpellSlot, 'Holy' | 'Shadow'> = {
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<SpellSlot, 'Enemy target' | 'Friendly target'> = {
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<SpellSlot, string> = {
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<ActionHealerClassId, ActionSpellbook> = {
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<SpellSlot, SpellDefinition>,
manaCosts: Record<SpellSlot, number>,
ranks: Record<SpellSlot, number>,
schools: Record<SpellSlot, ActionSpellSchool>,
targets: Record<SpellSlot, ActionSpellTarget>,
descriptions: Record<SpellSlot, string>,
): 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<SpellSlot, number> {
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<SpellSlot, number> {
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<SpellSlot, ActionSpellSchool> {
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<SpellSlot, ActionSpellTarget> {
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<SpellSlot, string> {
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<SpellSlot, number> {
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<SpellSlot, number>, 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<HealOverTimeEffectId, string> = {
regrowth: 'Regrowth',
rejuvenation: 'Rejuvenation',
renew: 'Renew',
}