diablo2-web/src/game/engine/summon-manager.ts

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
}
}