603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c `SUMMON_CreatePet` Unit Manager (`SummonManager`).
|
|
*
|
|
* Implements:
|
|
* - `PetType.txt` group exclusivity (`group=1` Druid Spirit Wolf / Dire Wolf / Grizzly;
|
|
* `golem` `idx=3` Clay/Blood/Iron/Fire Golem; `spirit` `idx=11` Oak/Wolverine/Barbs;
|
|
* `vine` `idx=12` Poison/Carrion/Solar Vine; `shadowwarrior` `group=4` Shadow Warrior/Master).
|
|
* - `assassintrap` (`idx=17`) 5-trap cap with FIFO eviction when a 6th trap is laid.
|
|
* - Dynamic `petmax` formula evaluation (`Raise Skeleton`, `Raise Skeletal Mage`, `Revive`,
|
|
* `Raven`, `Summon Spirit Wolf`, `Summon Fenris`, `Hydra`).
|
|
* - Corpse consumption requirement for `Raise Skeleton` (`70`), `Raise Skeletal Mage` (`80`),
|
|
* and `Revive` (`95`).
|
|
* - Execution of all 17 summoned pet & trap AI sub-skills (`IDs 281..338`).
|
|
*/
|
|
import type { D2DataRegistry } from './data-registry.ts'
|
|
import { evaluateCalc } from './calc-ast.ts'
|
|
import { FIXED_ONE, UnitStatList } from './stat-list.ts'
|
|
import { StateBus } from './state-bus.ts'
|
|
import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts'
|
|
import type { MissileEngine } from './missile-engine.ts'
|
|
import type { AuraScanner } from './aura-scanner.ts'
|
|
import {
|
|
calculateSummonResistPct,
|
|
calculateSkeletonMasteryStats,
|
|
calculateGolemMasteryStats,
|
|
calculateClayGolemStats,
|
|
calculateBloodGolemStats,
|
|
calculateIronGolemStats,
|
|
calculateFireGolemStats,
|
|
calculateReviveStats,
|
|
type IronGolemItemProps,
|
|
} from './missile-engine.ts'
|
|
|
|
export interface SummonedPetUnit {
|
|
readonly uid: string
|
|
readonly monId: string
|
|
readonly code: string
|
|
readonly petType: string
|
|
readonly petGroup: number
|
|
readonly sourceSkillId: number
|
|
readonly slvl: number
|
|
readonly unsummonable: boolean
|
|
readonly subSkillIds: readonly number[]
|
|
readonly statList: UnitStatList
|
|
readonly stateBus: StateBus
|
|
x: number
|
|
y: number
|
|
hp: number
|
|
maxHp: number
|
|
shotsRemaining: number
|
|
rageBuffActive: boolean
|
|
// M4 Necromancer Summoning Properties
|
|
kind?: 'skeleton' | 'skeleton_mage' | 'clay_golem' | 'blood_golem' | 'iron_golem' | 'fire_golem' | 'revive' | undefined
|
|
mageElement?: 'fire' | 'cold' | 'lightning' | 'poison' | undefined
|
|
lifespanTicks?: number | undefined
|
|
slowPct?: number | undefined
|
|
lifeLink?: { healCasterPct: number; shareDamagePct: number } | undefined
|
|
thornsReturnPct?: number | undefined
|
|
fireAbsorbPct?: number | undefined
|
|
deathExplosionDmg?: number | undefined
|
|
inheritedAuras?: number[] | undefined
|
|
}
|
|
|
|
export interface CreatePetOutcome {
|
|
readonly created: boolean
|
|
readonly reason?: 'missing_corpse' | 'invalid_skill' | 'invalid_item'
|
|
readonly pet?: SummonedPetUnit
|
|
readonly evictedPetUids: string[]
|
|
readonly activeCountForType: number
|
|
}
|
|
|
|
export class SummonManager {
|
|
readonly registry: D2DataRegistry
|
|
private readonly pets: SummonedPetUnit[] = []
|
|
private uidCounter = 1
|
|
|
|
constructor(registry: D2DataRegistry) {
|
|
this.registry = registry
|
|
}
|
|
|
|
/**
|
|
* Create a summoned pet, trap, totem, or hydra (`SUMMON_CreatePet`).
|
|
*/
|
|
createPet(params: {
|
|
owner: CombatUnitContext
|
|
skillId: number
|
|
slvl: number
|
|
x?: number
|
|
y?: number
|
|
corpses?: { id: string; consumed: boolean; baseMaxHp?: number; isBoss?: boolean }[]
|
|
ironGolemItem?: IronGolemItemProps
|
|
}): CreatePetOutcome {
|
|
const skill = this.registry.getSkillById(params.skillId)
|
|
if (!skill) {
|
|
return { created: false, reason: 'invalid_skill', evictedPetUids: [], activeCountForType: 0 }
|
|
}
|
|
|
|
// Corpse-consuming Necromancer summons (`Raise Skeleton` 70, `Raise Skeletal Mage` 80, `Revive` 95)
|
|
const requiresCorpse = params.skillId === 70 || params.skillId === 80 || params.skillId === 95
|
|
let consumedCorpse: { id: string; consumed: boolean; baseMaxHp?: number; isBoss?: boolean } | undefined
|
|
if (requiresCorpse && params.corpses) {
|
|
// 1.13c Invariant: Boss corpses cannot be used for summons
|
|
const availCorpse = params.corpses.find(c => !c.consumed && !c.isBoss)
|
|
if (!availCorpse) {
|
|
return { created: false, reason: 'missing_corpse', evictedPetUids: [], activeCountForType: 0 }
|
|
}
|
|
availCorpse.consumed = true
|
|
consumedCorpse = availCorpse
|
|
}
|
|
|
|
const petTypeName = (
|
|
skill.petType ||
|
|
(params.skillId === 261 || params.skillId === 262 || params.skillId === 271 || params.skillId === 272 || params.skillId === 276
|
|
? 'assassintrap'
|
|
: params.skillId === 62
|
|
? 'hydra'
|
|
: 'summon')
|
|
).toLowerCase()
|
|
|
|
const petTypeRec = this.registry.getPetType(petTypeName)
|
|
const isGolemSkill = [75, 85, 90, 94].includes(params.skillId)
|
|
const petGroup =
|
|
isGolemSkill
|
|
? 3 // Golem Group 3 exclusivity
|
|
: (petTypeRec?.group ??
|
|
([227, 237, 247].includes(params.skillId)
|
|
? 1 // Druid Spirit Wolf / Dire Wolf / Grizzly group
|
|
: [226, 236, 246].includes(params.skillId)
|
|
? 2 // Druid Oak Sage / Wolverine / Barbs spirit group
|
|
: [222, 231, 241].includes(params.skillId)
|
|
? 3 // Druid Vine group
|
|
: [268, 279].includes(params.skillId)
|
|
? 4 // Assassin Shadow Warrior / Master group
|
|
: 0))
|
|
|
|
const evictedPetUids: string[] = []
|
|
|
|
// 1. Evict any existing pets in the same `petGroup` (`group > 0`) that have a different `petType`
|
|
// (e.g. summoning Grizzly evicts Spirit Wolves / Dire Wolves; summoning Shadow Master evicts Shadow Warrior)
|
|
// For Golems (Group 3): Any new Golem evicts any existing Golem!
|
|
if (petGroup > 0) {
|
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
|
const p = this.pets[i]!
|
|
if (p.petGroup === petGroup && (p.petType !== petTypeName || isGolemSkill)) {
|
|
evictedPetUids.push(p.uid)
|
|
this.pets.splice(i, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Determine max cap (`petmax` calc from `Skills.txt`, or `PetType.txt` `basemax`, or `5` for Assassin traps)
|
|
const calcCtx = {
|
|
registry: this.registry,
|
|
activeSkill: skill,
|
|
slvl: params.slvl,
|
|
blvl: params.owner.statList.getBaseSkillLevel(params.skillId),
|
|
getBaseSkillLevel: (id: number) => params.owner.statList.getBaseSkillLevel(id),
|
|
getEffectiveSkillLevel: (id: number) => params.owner.statList.getEffectiveSkillLevel(id),
|
|
}
|
|
|
|
let maxPets = 1
|
|
if (petTypeName === 'assassintrap') {
|
|
maxPets = 5 // 1.13c Assassin trap cap = 5 active traps
|
|
} else if (isGolemSkill) {
|
|
maxPets = 1 // Golems: exactly 1 active golem
|
|
} else if (params.skillId === 70 || params.skillId === 80) {
|
|
maxPets = params.slvl < 4 ? params.slvl : 2 + Math.floor(params.slvl / 3)
|
|
} else if (params.skillId === 95) {
|
|
maxPets = Math.max(1, params.slvl)
|
|
} else if (skill.petMax) {
|
|
maxPets = Math.max(1, evaluateCalc(skill.petMax, calcCtx))
|
|
} else if (petTypeRec && petTypeRec.baseMax > 0) {
|
|
maxPets = petTypeRec.baseMax
|
|
}
|
|
|
|
// 3. Enforce FIFO eviction when `activeCount >= maxPets`
|
|
const existingOfType = this.pets.filter(p => p.petType === petTypeName)
|
|
while (existingOfType.length >= maxPets) {
|
|
const oldest = existingOfType.shift()!
|
|
const idx = this.pets.findIndex(p => p.uid === oldest.uid)
|
|
if (idx !== -1) {
|
|
evictedPetUids.push(oldest.uid)
|
|
this.pets.splice(idx, 1)
|
|
}
|
|
}
|
|
|
|
// 4. Resolve MonStats record and sub-skills (`IDs 281..338`)
|
|
const monId = (skill.summon || skill.name).toLowerCase()
|
|
const monRec = this.registry.getMonStats(monId)
|
|
const code = monRec?.code || (petTypeName === 'assassintrap' ? 'lg' : 'sk')
|
|
|
|
// Compute 1.13c Skeleton Mastery (`69`) / Golem Mastery (`79`) / Summon Resist (`89`) / Synergies
|
|
const skelMasteryLvl = params.owner.statList.getEffectiveSkillLevel(69)
|
|
const golemMasteryLvl = params.owner.statList.getEffectiveSkillLevel(79)
|
|
const summonResLvl = params.owner.statList.getEffectiveSkillLevel(89)
|
|
const smStats = calculateSkeletonMasteryStats(skelMasteryLvl)
|
|
const gmStats = calculateGolemMasteryStats(golemMasteryLvl)
|
|
const sumResPct = calculateSummonResistPct(summonResLvl)
|
|
|
|
let kind: SummonedPetUnit['kind']
|
|
let mageElement: SummonedPetUnit['mageElement']
|
|
let lifespanTicks: number | undefined
|
|
let slowPct: number | undefined
|
|
let lifeLink: SummonedPetUnit['lifeLink']
|
|
let thornsReturnPct: number | undefined
|
|
let fireAbsorbPct: number | undefined
|
|
let deathExplosionDmg: number | undefined
|
|
let inheritedAuras: number[] | undefined
|
|
|
|
let baseHp = (monRec?.maxHP || 150) + params.slvl * 35
|
|
|
|
if (params.skillId === 70) {
|
|
kind = 'skeleton'
|
|
// Skeletons: (base + 8*skelMastery) * (1 + 10%*skelMastery)
|
|
const baseSkelHp = (monRec?.maxHP || 21) + params.slvl * 15 + smStats.skeletonHp
|
|
baseHp = Math.floor(baseSkelHp * (1 + smStats.damagePct / 100))
|
|
} else if (params.skillId === 80) {
|
|
kind = 'skeleton_mage'
|
|
const baseMageHp = (monRec?.maxHP || 21) + params.slvl * 12 + smStats.skeletonHp
|
|
baseHp = Math.floor(baseMageHp * (1 + smStats.damagePct / 100))
|
|
const elements: ('fire' | 'cold' | 'lightning' | 'poison')[] = ['fire', 'cold', 'lightning', 'poison']
|
|
mageElement = elements[this.uidCounter % 4]
|
|
} else if (params.skillId === 75) {
|
|
kind = 'clay_golem'
|
|
const bloodGolemSyn = params.owner.statList.getBaseSkillLevel(85)
|
|
const clayStats = calculateClayGolemStats(params.slvl, golemMasteryLvl, bloodGolemSyn)
|
|
slowPct = clayStats.slowPct
|
|
baseHp = Math.floor(clayStats.baseHp * (1 + clayStats.hpBonusPct / 100))
|
|
} else if (params.skillId === 85) {
|
|
kind = 'blood_golem'
|
|
const bgStats = calculateBloodGolemStats(params.slvl, golemMasteryLvl)
|
|
lifeLink = { healCasterPct: bgStats.lifeLeechPct, shareDamagePct: bgStats.damageSharePct }
|
|
baseHp = Math.floor(bgStats.baseHp * (1 + bgStats.hpBonusPct / 100))
|
|
} else if (params.skillId === 90) {
|
|
kind = 'iron_golem'
|
|
const igStats = calculateIronGolemStats(params.slvl, params.ironGolemItem, golemMasteryLvl)
|
|
thornsReturnPct = igStats.thornsReturnPct
|
|
inheritedAuras = igStats.inheritedAuras
|
|
baseHp = Math.floor(igStats.baseHp * (1 + igStats.hpBonusPct / 100))
|
|
} else if (params.skillId === 94) {
|
|
kind = 'fire_golem'
|
|
const fgStats = calculateFireGolemStats(params.slvl, golemMasteryLvl)
|
|
fireAbsorbPct = fgStats.fireAbsorbPct
|
|
deathExplosionDmg = fgStats.deathExplosionDmg
|
|
baseHp = Math.floor(fgStats.baseHp * (1 + fgStats.hpBonusPct / 100))
|
|
} else if (params.skillId === 95) {
|
|
kind = 'revive'
|
|
const revStats = calculateReviveStats(params.slvl, skelMasteryLvl)
|
|
lifespanTicks = revStats.durationFrames
|
|
const corpseBaseHp = consumedCorpse?.baseMaxHp ?? 1000
|
|
baseHp = Math.floor(corpseBaseHp * revStats.hpMultiplier)
|
|
}
|
|
|
|
const petStatList = new UnitStatList(this.registry, {
|
|
level: params.owner.statList.getAccruedStat('level'),
|
|
maxhp: baseHp * FIXED_ONE,
|
|
hitpoints: baseHp * FIXED_ONE,
|
|
fireresist: sumResPct,
|
|
lightresist: sumResPct,
|
|
coldresist: sumResPct,
|
|
poisonresist: sumResPct,
|
|
...(slowPct ? { clay_golem_slow: slowPct } : {}),
|
|
...(thornsReturnPct ? { thorns_return_pct: thornsReturnPct } : {}),
|
|
...(fireAbsorbPct ? { fire_golem_absorb: fireAbsorbPct } : {}),
|
|
})
|
|
const petStateBus = new StateBus(petStatList, this.registry)
|
|
|
|
// Map pet/trap AI sub-skills (`IDs 281..338`)
|
|
const subSkillIds: number[] = []
|
|
if (params.skillId === 262) subSkillIds.push(281) // Wake Of Destruction Sentry
|
|
if (params.skillId === 268 || params.skillId === 279) subSkillIds.push(292) // Teleport 2
|
|
if (params.skillId === 222) subSkillIds.push(294) // Vine Attack
|
|
if (params.skillId === 246) subSkillIds.push(296) // Barbs Aura
|
|
if (params.skillId === 236) subSkillIds.push(297) // Wolverine Aura
|
|
if (params.skillId === 226) subSkillIds.push(298) // Oak Sage Aura
|
|
if (params.skillId === 261) subSkillIds.push(306) // BoltSentry
|
|
if (params.skillId === 231) subSkillIds.push(307) // CorpseCycler
|
|
if (params.skillId === 272) subSkillIds.push(311) // mon inferno sentry
|
|
if (params.skillId === 276) subSkillIds.push(312, 324) // mon death sentry + death sentry ltng
|
|
if (params.skillId === 271) subSkillIds.push(313) // sentry lightning
|
|
if (params.skillId === 237) subSkillIds.push(314) // fenris rage
|
|
if (params.skillId === 241) subSkillIds.push(325) // VineCycler
|
|
if (params.skillId === 247) subSkillIds.push(326) // BearSmite
|
|
if (params.skillId === 62) subSkillIds.push(337) // HydraMissile
|
|
if (params.skillId === 80) subSkillIds.push(338) // NecromageMissile
|
|
|
|
const pet: SummonedPetUnit = {
|
|
uid: `pet-${String(this.uidCounter++)}`,
|
|
monId,
|
|
code,
|
|
petType: petTypeName,
|
|
petGroup,
|
|
sourceSkillId: params.skillId,
|
|
slvl: params.slvl,
|
|
unsummonable: petTypeRec?.unsummon ?? true,
|
|
subSkillIds,
|
|
statList: petStatList,
|
|
stateBus: petStateBus,
|
|
x: (params.x ?? 0) + (this.pets.length % 5) * 18 - 36,
|
|
y: (params.y ?? 60),
|
|
hp: baseHp,
|
|
maxHp: baseHp,
|
|
shotsRemaining: petTypeName === 'assassintrap' ? (params.skillId === 262 ? 5 : 10) : 999,
|
|
rageBuffActive: false,
|
|
kind,
|
|
mageElement,
|
|
lifespanTicks,
|
|
slowPct,
|
|
lifeLink,
|
|
thornsReturnPct,
|
|
fireAbsorbPct,
|
|
deathExplosionDmg,
|
|
inheritedAuras,
|
|
}
|
|
|
|
this.pets.push(pet)
|
|
const activeCountForType = this.pets.filter(p => p.petType === petTypeName).length
|
|
|
|
return {
|
|
created: true,
|
|
pet,
|
|
evictedPetUids,
|
|
activeCountForType,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsummon (`skillId = 3`): dismisses the most recent unsummonable pet.
|
|
*/
|
|
unsummonPet(targetUid?: string): SummonedPetUnit | null {
|
|
if (targetUid) {
|
|
const idx = this.pets.findIndex(p => p.uid === targetUid && p.unsummonable)
|
|
if (idx !== -1) {
|
|
return this.pets.splice(idx, 1)[0] ?? null
|
|
}
|
|
}
|
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
|
if (this.pets[i]!.unsummonable) {
|
|
return this.pets.splice(i, 1)[0] ?? null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Step all active pets, traps, vines, totems, and hydras (`17` sub-skills `281..338`).
|
|
*/
|
|
tickPets(params: {
|
|
currentTick: number
|
|
owner: CombatUnitContext
|
|
enemies: readonly CombatUnitContext[]
|
|
enemyPositions: ReadonlyMap<string, { x: number; y: number }>
|
|
corpses?: { id: string; consumed: boolean; baseMaxHp?: number; isBoss?: boolean }[]
|
|
missileEngine: MissileEngine
|
|
auraScanner: AuraScanner
|
|
}): {
|
|
petAttacks: number
|
|
subSkillsExecuted: number[]
|
|
damageDealt: number
|
|
corpsesConsumedByPets: number
|
|
} {
|
|
let petAttacks = 0
|
|
const subSkillsExecuted: number[] = []
|
|
let damageDealt = 0
|
|
let corpsesConsumedByPets = 0
|
|
|
|
const aliveEnemies = params.enemies.filter(e => e.statList.getHp256() > 0)
|
|
const primaryTarget = aliveEnemies[0]
|
|
const primaryPos = primaryTarget ? (params.enemyPositions.get(primaryTarget.id) ?? { x: 0, y: 120 }) : { x: 0, y: 120 }
|
|
|
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
|
const pet = this.pets[i]!
|
|
if (pet.hp <= 0 || pet.shotsRemaining <= 0) continue
|
|
|
|
// Revive lifespan countdown (180s / 4500 frames)
|
|
if (pet.lifespanTicks !== undefined) {
|
|
pet.lifespanTicks -= 1
|
|
if (pet.lifespanTicks <= 0) {
|
|
this.pets.splice(i, 1)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Fire Golem Holy Fire pulse every 50 ticks (2.0s)
|
|
if (pet.kind === 'fire_golem' && params.currentTick % 50 === 0) {
|
|
const pulseDmg = 2 + pet.slvl * 2
|
|
for (const enemy of aliveEnemies) {
|
|
const ePos = params.enemyPositions.get(enemy.id) ?? { x: 0, y: 120 }
|
|
const dx = ePos.x - pet.x
|
|
const dy = ePos.y - pet.y
|
|
// 2:1 isometric ground geometry
|
|
if (dx * dx + 4 * dy * dy <= 160000) { // 400px radius
|
|
const out = executeSUnitDmg(params.owner, enemy, {
|
|
skillId: 94,
|
|
attackKind: 'spell',
|
|
elemType: 'fire',
|
|
elemMin256: pulseDmg * FIXED_ONE,
|
|
elemMax256: pulseDmg * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
damageDealt += out.totalDamage
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute pet's AI sub-skills (`IDs 281..338`)
|
|
for (const subId of pet.subSkillIds) {
|
|
const subSkill = this.registry.getSkillById(subId)
|
|
if (!subSkill) continue
|
|
|
|
// Totem Auras (`296` Barbs Aura, `297` Wolverine Aura, `298` Oak Sage Aura)
|
|
if (subId === 296 || subId === 297 || subId === 298) {
|
|
const auraSrc = params.auraScanner.setActiveAura(params.owner, subId, pet.slvl, params.currentTick)
|
|
if (auraSrc) {
|
|
params.auraScanner.pulseAura(auraSrc, params.currentTick, [params.owner], aliveEnemies)
|
|
subSkillsExecuted.push(subId)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Corpse-consuming vines & dire wolves (`307` CorpseCycler, `325` VineCycler, `314` fenris rage)
|
|
if ((subId === 307 || subId === 325 || subId === 314) && params.corpses) {
|
|
const corpse = params.corpses.find(c => !c.consumed && !c.isBoss)
|
|
if (corpse) {
|
|
corpse.consumed = true
|
|
corpsesConsumedByPets += 1
|
|
subSkillsExecuted.push(subId)
|
|
if (subId === 307) {
|
|
// Carrion Vine heals owner % max HP
|
|
const heal = Math.trunc((params.owner.statList.getMaxHp256() * (4 + pet.slvl)) / 100)
|
|
params.owner.statList.setHp256(params.owner.statList.getHp256() + heal)
|
|
} else if (subId === 325) {
|
|
// Solar Creeper restores owner % max Mana
|
|
const mana = Math.trunc((params.owner.statList.getMaxMana256() * (4 + pet.slvl)) / 100)
|
|
params.owner.statList.setMana256(params.owner.statList.getMana256() + mana)
|
|
} else if (subId === 314) {
|
|
// Dire Wolf (`fenris rage`) gains +100% damage buff after eating corpse
|
|
pet.rageBuffActive = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Death Sentry (`312` `mon death sentry`): explodes an available corpse if present!
|
|
if (subId === 312 && params.corpses) {
|
|
const corpse = params.corpses.find(c => !c.consumed && !c.isBoss)
|
|
if (corpse && primaryTarget) {
|
|
corpse.consumed = true
|
|
corpsesConsumedByPets += 1
|
|
subSkillsExecuted.push(subId)
|
|
pet.shotsRemaining -= 1
|
|
const ceDmg = Math.trunc(((corpse.baseMaxHp ?? 1000) * 60) / 100) * FIXED_ONE
|
|
const out = executeSUnitDmg(params.owner, primaryTarget, {
|
|
skillId: 312,
|
|
attackKind: 'spell',
|
|
flatPhysMin256: Math.trunc(ceDmg / 2),
|
|
flatPhysMax256: Math.trunc(ceDmg / 2),
|
|
elemType: 'fire',
|
|
elemMin256: Math.trunc(ceDmg / 2),
|
|
elemMax256: Math.trunc(ceDmg / 2),
|
|
autoHit: true,
|
|
})
|
|
damageDealt += out.totalDamage
|
|
petAttacks += 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Missile-firing traps, hydras, and skeletal mages (`281`, `306`, `311`, `313`, `324`, `337`, `338`)
|
|
if (
|
|
primaryTarget &&
|
|
(subId === 281 ||
|
|
subId === 306 ||
|
|
subId === 311 ||
|
|
subId === 313 ||
|
|
subId === 324 ||
|
|
subId === 337 ||
|
|
subId === 338)
|
|
) {
|
|
const elem =
|
|
pet.kind === 'skeleton_mage'
|
|
? (pet.mageElement === 'lightning' ? 'ltng' : pet.mageElement === 'poison' ? 'pois' : pet.mageElement ?? 'fire')
|
|
: subId === 281 || subId === 311 || subId === 337
|
|
? 'fire'
|
|
: 'ltng'
|
|
|
|
const mslName =
|
|
pet.kind === 'skeleton_mage'
|
|
? (pet.mageElement === 'cold'
|
|
? 'icebolt'
|
|
: pet.mageElement === 'lightning'
|
|
? 'chargedbolt'
|
|
: pet.mageElement === 'poison'
|
|
? 'poisonexplosioncloud'
|
|
: 'firebolt')
|
|
: subSkill.srvMissile ||
|
|
subSkill.srvMissileA ||
|
|
(subId === 281
|
|
? 'wake of destruction maker'
|
|
: subId === 306
|
|
? 'chargedbolt'
|
|
: subId === 311
|
|
? 'inferno1'
|
|
: 'sentrylightningbolt')
|
|
|
|
const skelMastery = calculateSkeletonMasteryStats(params.owner.statList.getEffectiveSkillLevel(69))
|
|
const mageBonusDmg = pet.kind === 'skeleton_mage' ? skelMastery.skeletonDamage : 0
|
|
const minElem = (25 + pet.slvl * 12 + mageBonusDmg) * FIXED_ONE
|
|
const maxElem = (45 + pet.slvl * 18 + mageBonusDmg) * FIXED_ONE
|
|
|
|
params.missileEngine.spawnMissile({
|
|
missileNameOrId: mslName,
|
|
sourceSkillId: pet.sourceSkillId,
|
|
slvl: pet.slvl,
|
|
owner: params.owner,
|
|
startX: pet.x,
|
|
startY: pet.y,
|
|
targetX: primaryPos.x,
|
|
targetY: primaryPos.y,
|
|
dmgPacket: {
|
|
skillId: pet.sourceSkillId,
|
|
attackKind: 'spell',
|
|
elemType: elem,
|
|
elemMin256: minElem,
|
|
elemMax256: maxElem,
|
|
autoHit: true,
|
|
},
|
|
})
|
|
subSkillsExecuted.push(subId)
|
|
petAttacks += 1
|
|
if (pet.petType === 'assassintrap') {
|
|
pet.shotsRemaining -= 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Standard melee attack for non-totem combat pets (`Skeleton`, `Golem`, `Wolf`, `Grizzly`, `Valkyrie`, `Shadow`)
|
|
if (
|
|
primaryTarget &&
|
|
pet.petGroup !== 2 &&
|
|
pet.petType !== 'assassintrap' &&
|
|
pet.petType !== 'hydra' &&
|
|
params.currentTick % 10 === 0
|
|
) {
|
|
const skelMastery = calculateSkeletonMasteryStats(params.owner.statList.getEffectiveSkillLevel(69))
|
|
const flatBonus = pet.kind === 'skeleton' ? skelMastery.skeletonDamage : 0
|
|
const pctBonus = pet.kind === 'skeleton' ? skelMastery.damagePct : 0
|
|
const basePetPhys = Math.floor(
|
|
((20 + pet.slvl * 10 + flatBonus) * (pet.rageBuffActive ? 2 : 1) * (1 + pctBonus / 100)) * FIXED_ONE,
|
|
)
|
|
|
|
const out = executeSUnitDmg(params.owner, primaryTarget, {
|
|
skillId: pet.sourceSkillId,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: basePetPhys,
|
|
flatPhysMax256: basePetPhys,
|
|
autoHit: true,
|
|
})
|
|
damageDealt += out.totalDamage
|
|
petAttacks += 1
|
|
|
|
// Clay Golem Slow-on-hit effect against defender
|
|
if (pet.kind === 'clay_golem' && pet.slowPct) {
|
|
primaryTarget.stateBus.applyState({
|
|
stateNameOrId: 'slowed',
|
|
slvl: pet.slvl,
|
|
durationFrames: 150,
|
|
stats: { item_slow: pet.slowPct },
|
|
})
|
|
}
|
|
|
|
// Blood Golem Life Link healing to player
|
|
if (pet.kind === 'blood_golem' && pet.lifeLink && out.totalDamage > 0) {
|
|
const healAmount256 = Math.floor((out.totalDamage * FIXED_ONE * pet.lifeLink.healCasterPct) / 100)
|
|
const curHp256 = params.owner.statList.getHp256()
|
|
const maxHp256 = params.owner.statList.getMaxHp256()
|
|
const newHp256 = Math.min(maxHp256, curHp256 + healAmount256)
|
|
params.owner.statList.setHp256(newHp256)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove exhausted traps
|
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
|
if (this.pets[i]!.shotsRemaining <= 0) {
|
|
this.pets.splice(i, 1)
|
|
}
|
|
}
|
|
|
|
return {
|
|
petAttacks,
|
|
subSkillsExecuted,
|
|
damageDealt,
|
|
corpsesConsumedByPets,
|
|
}
|
|
}
|
|
|
|
getActivePets(): readonly SummonedPetUnit[] {
|
|
return this.pets
|
|
}
|
|
}
|
|
|