171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
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<PartyMember['bounceHeals']>[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<string, NonNullable<PartyMember['bounceHeals']>>()
|
|
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,
|
|
],
|
|
}
|
|
})
|
|
}
|