import type { PartyMember } from '../game' import { clamp, healAmount, memberHotEffects, tickHotEffects, } from './rules' export type MemberTickInput = { member: PartyMember party: PartyMember[] damage: number hotHealing: number hotTicks: 'effects' | 'ticks' healingMultiplier?: number damageReductionMultiplier?: number damageReductionRounding?: 'round' | 'ceil' shieldedDamageMultiplier?: number applyDebuff?: { label: string ticks: number } applyPoisonStacks?: boolean poisonDamage?: (stacks: number) => number applyMaxHealthPenaltyTicks?: number applyHealingReductionTicks?: number decrementDamageReduction?: boolean useBounceHeals?: boolean jumpTarget?: () => PartyMember | undefined } export type MemberTickResult = { member: PartyMember floatingHeal: number jumpedBounceHeals: Array<{ targetId: string heal: NonNullable[number] }> } /** * Applies one combat tick to a party member. Screen-level engines still decide * who is targeted and when fights end; this helper owns repeated health math: * damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals. */ export function advanceMemberTick({ member, party, damage, hotHealing, hotTicks, healingMultiplier = 1, damageReductionMultiplier = 0.5, damageReductionRounding = 'round', shieldedDamageMultiplier, applyDebuff, applyPoisonStacks = false, poisonDamage = () => 0, applyMaxHealthPenaltyTicks, applyHealingReductionTicks, decrementDamageReduction = false, useBounceHeals = false, jumpTarget, }: MemberTickInput): MemberTickResult { if (member.health <= 0) { return { member, floatingHeal: 0, jumpedBounceHeals: [] } } let nextDamage = damage const nextPoisonStacks = applyPoisonStacks ? Math.max(1, (member.poisonStacks ?? 0) + 1) : member.poisonStacks ?? 0 if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks) if ((member.damageReductionTicks ?? 0) > 0) { const reducedDamage = nextDamage * damageReductionMultiplier nextDamage = damageReductionRounding === 'ceil' ? Math.ceil(reducedDamage) : Math.round(reducedDamage) } if (member.shield > 0 && shieldedDamageMultiplier !== undefined) { nextDamage = Math.round(nextDamage * shieldedDamageMultiplier) } const absorbed = Math.min(member.shield, nextDamage) const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : [] let healing = hotTicks === 'effects' ? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0) : member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0 let nextBounceHeals = [...(member.bounceHeals ?? [])] const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = [] if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) { nextBounceHeals = nextBounceHeals.flatMap((effect) => { healing += healAmount(member, effect.power, healingMultiplier) const nextCharges = effect.charges - 1 if (nextCharges <= 0) return [] const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member jumpedBounceHeals.push({ targetId: target.id, heal: { ...effect, charges: nextCharges }, }) return [] }) } const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined ? applyMaxHealthPenaltyTicks : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1) const nextHealingReductionTicks = applyHealingReductionTicks !== undefined ? applyHealingReductionTicks : Math.max(0, (member.healingReductionTicks ?? 0) - 1) const nextDebuffTicks = applyDebuff ? applyDebuff.ticks : Math.max(0, (member.debuffTicks ?? 0) - 1) const nextEffectiveMaxHealth = Math.max(1, Math.round( member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1), )) return { member: { ...member, health: clamp( clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed, 0, nextEffectiveMaxHealth, ), shield: Math.max(0, member.shield - nextDamage), hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1), hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects, bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals, damageReductionTicks: decrementDamageReduction ? Math.max(0, (member.damageReductionTicks ?? 0) - 1) : member.damageReductionTicks, debuff: nextDebuffTicks > 0 ? applyDebuff?.label ?? member.debuff : undefined, debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined, poisonStacks: nextPoisonStacks, maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks, healingReductionTicks: nextHealingReductionTicks, }, floatingHeal: healing, jumpedBounceHeals, } } export function attachJumpedBounceHeals( party: PartyMember[], jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'], ) { if (jumpedBounceHeals.length === 0) return party const jumpedByTarget = new Map>() for (const jump of jumpedBounceHeals) { const current = jumpedByTarget.get(jump.targetId) if (current) current.push(jump.heal) else jumpedByTarget.set(jump.targetId, [jump.heal]) } return party.map((member) => { const jumped = jumpedByTarget.get(member.id) if (!jumped || jumped.length === 0) return member return { ...member, bounceHeals: [ ...(member.bounceHeals ?? []), ...jumped, ], } }) }