----------------------------------- -- Global version of onMobDeath ----------------------------------- require('scripts/globals/magic') require('scripts/globals/missions') require('scripts/globals/quests') ----------------------------------- xi = xi or {} xi.mob = xi.mob or {} -- onMobDeathEx is called from the core xi.mob.onMobDeathEx = function(mob, player, isKiller, isWeaponSkillKill) end ----------------------------------- -- placeholder / lottery NMs ----------------------------------- -- is a lottery NM in the table already spawned or primed to pop? local function lotteryPrimed(phList) local nm = nil for k, v in pairs(phList) do -- if `v` is a table, then it's a table of numbers: { id, id2 } if type(v) == 'table' then for _, innerId in pairs(v) do nm = GetMobByID(innerId) if nm ~= nil and (nm:isSpawned() or nm:getRespawnTime() ~= 0) then return true end end else -- `v` is a number nm = GetMobByID(v) if nm ~= nil and (nm:isSpawned() or nm:getRespawnTime() ~= 0) then return true end end end return false end local function getMobLuaPathObject(mob) if not mob then return nil end return xi.zones[mob:getZoneName()].mobs[mob:getName()] end -- - mobParam can either be a mobid or a mob entity object -- it either accepts a table of spawn points to randomize, or looks to the mob's cached lua object for a spawnPoints entry ---@param mobParam number|CBaseEntity? ---@param spawnPointsOverride table? xi.mob.updateNMSpawnPoint = function(mobParam, spawnPointsOverride) local origMobParam = mobParam -- sometimes we call from Zone.lua files and only have the mob id if type(mobParam) == 'number' then mobParam = GetMobByID(mobParam) end if mobParam == nil then print('[updateNMSpawnPoint] Invalid mob parameter:') print(origMobParam) return end -- if no spawnPoints table was sent, extract from mob lua object local spawnPoints = spawnPointsOverride if spawnPoints == nil then local mobObject = getMobLuaPathObject(mobParam) if mobObject and mobObject.spawnPoints then spawnPoints = mobObject.spawnPoints end end -- Special check for NMs with the same name but multiple IDs if spawnPoints and spawnPoints[mobParam:getID()] then spawnPoints = spawnPoints[mobParam:getID()] end if spawnPoints ~= nil and type(spawnPoints) == 'table' and #spawnPoints > 0 then local chosenSpawn = utils.randomEntry(spawnPoints) local randomRotation = math.random(0, 255) -- rotation does not matter -- Updates the mob's spawn point mobParam:setSpawn(chosenSpawn.x, chosenSpawn.y, chosenSpawn.z, randomRotation) end end local function getMobEntityObj(phNmId) local mobEntityObj = nil if type(phNmId) == 'number' then mobEntityObj = getMobLuaPathObject(GetMobByID(phNmId)) elseif type(phNmId) == 'table' then mobEntityObj = getMobLuaPathObject(GetMobByID(utils.randomEntry(phNmId))) end return mobEntityObj end local function getNmId(phList, phId) local nmId = nil if phList and phList[phId] then if type(phList[phId]) == 'number' then nmId = phList[phId] elseif type(phList[phId]) == 'table' then nmId = utils.randomEntry(phList[phId]) end end return nmId end -- potential lottery placeholder was killed ---@param ph CBaseEntity ---@param phNmId integer|table ---@param chance integer ---@param cooldown integer ---@param params table? xi.mob.phOnDespawn = function(ph, phNmId, chance, cooldown, params) params = params or {} --[[ params.immediate = true pop NM without waiting for next PH pop time params.dayOnly = true spawn NM only at day time params.nightOnly = true spawn NM only at night time params.noPosUpdate = true do not run xi.mob.updateNMSpawnPoint() params.spawnPoints = { {x = , y = , z = } } table of spawn points to choose from, overrides NM's lua-defined table params.doNotEnablePhSpawn = true Don't enable ph respawns after NM is killed (for chained ph systems like steelfleece) ]] local phId = ph:getID() local nmId = nil local nm = nil local phList = nil local mobEntityObj = getMobEntityObj(phNmId) if mobEntityObj then phList = mobEntityObj.phList nmId = getNmId(phList, phId) nm = nmId and GetMobByID(nmId) end -- This was not a PH for the NM if type(nmId) ~= 'number' or nm == nil or phList == nil then return false end -- ensure certain boolean params exist local paramKeys = { 'immediate', 'dayOnly', 'nightOnly', 'noPosUpdate', 'doNotEnablePhSpawn', } for _, pKey in ipairs(paramKeys) do if type(params[pKey]) ~= 'boolean' then params[pKey] = false end end if xi.settings.main.NM_LOTTERY_CHANCE then chance = xi.settings.main.NM_LOTTERY_CHANCE >= 0 and (chance * xi.settings.main.NM_LOTTERY_CHANCE) or 100 end if xi.settings.main.NM_LOTTERY_COOLDOWN then cooldown = xi.settings.main.NM_LOTTERY_COOLDOWN >= 0 and (cooldown * xi.settings.main.NM_LOTTERY_COOLDOWN) or cooldown end local pop = nm:getLocalVar('pop') chance = math.ceil(chance * 10) -- chance / 1000. if GetSystemTime() <= pop or lotteryPrimed(phList) or math.random(1, 1000) > chance then return false end local nextRepopTime = VanadielTime() + GetMobRespawnTime(phId) local nextRepopHour = math.floor((nextRepopTime % xi.vanaTime.DAY) / xi.vanaTime.HOUR) -- If the NM is day only and spawn would happen during the night, bail out if params.dayOnly and nextRepopHour < 4 and nextRepopHour >= 20 then return false -- If the NM is night only and spawn would happen during the day, bail out elseif params.nightOnly and nextRepopHour >= 4 and nextRepopHour < 20 then return false end -- on PH death, replace PH repop with NM repop -- TODO, fetch phId's spawn slot and disable respawn for all mobs in that spawn slot DisallowRespawn(phId, true) DisallowRespawn(nmId, false) -- Update mob's spawn position, if available if not params.noPosUpdate then xi.mob.updateNMSpawnPoint(nm, params.spawnPoints or nil) end -- if params.immediate is true, spawn the nm params.immediately (1ms) else use placeholder's timer nm:setRespawnTime(params.immediate and 1 or GetMobRespawnTime(phId)) nm:addListener('DESPAWN', 'DESPAWN_' .. nmId, function(m) -- on NM death, replace NM repop with PH repop DisallowRespawn(nmId, true) if not params.doNotEnablePhSpawn then DisallowRespawn(phId, false) local phMob = GetMobByID(phId) if phMob then phMob:setRespawnTime(GetMobRespawnTime(phId)) end end if m:getLocalVar('doNotInvokeCooldown') == 0 then m:setLocalVar('pop', GetSystemTime() + cooldown) end m:removeListener('DESPAWN_' .. nmId) end) return true end ----------------------------------- -- mob additional melee effects ----------------------------------- xi.mob.ae = { HP_DRAIN = 11, MP_DRAIN = 12, PETRIFY = 14, PLAGUE = 15, POISON = 16, SILENCE = 17, SLOW = 18, STUN = 19, TERROR = 20, TP_DRAIN = 21, WEIGHT = 22, ENAMNESIA = 23, BIND = 25, SLEEP = 26, DEFENSE_DOWN = 27, ATTACK_DOWN = 28, } local additionalEffects = { [xi.mob.ae.HP_DRAIN] = { chance = 10, ele = xi.element.DARK, sub = xi.subEffect.HP_DRAIN, msg = xi.msg.basic.ADD_EFFECT_HP_DRAIN, mod = xi.mod.INT, bonusAbilityParams = { bonusmab = 0, includemab = false }, code = function(mob, target, power) mob:addHP(power) end, }, [xi.mob.ae.MP_DRAIN] = { chance = 10, ele = xi.element.DARK, sub = xi.subEffect.MP_DRAIN, msg = xi.msg.basic.ADD_EFFECT_MP_DRAIN, mod = xi.mod.INT, bonusAbilityParams = { bonusmab = 0, includemab = false }, code = function(mob, target, power) local mp = math.min(power, target:getMP()) target:delMP(mp) mob:addMP(mp) end, }, [xi.mob.ae.PETRIFY] = { chance = 20, ele = xi.element.EARTH, sub = xi.subEffect.PETRIFY, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.PETRIFICATION, power = 1, duration = 30, minDuration = 1, maxDuration = 45, }, [xi.mob.ae.PLAGUE] = { chance = 25, ele = xi.element.WATER, sub = xi.subEffect.PLAGUE, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.PLAGUE, power = 1, duration = 60, minDuration = 1, maxDuration = 60, }, [xi.mob.ae.POISON] = { chance = 25, ele = xi.element.WATER, sub = xi.subEffect.POISON, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.POISON, power = 1, duration = 30, minDuration = 1, maxDuration = 30, tick = 3, }, [xi.mob.ae.SILENCE] = { chance = 25, ele = xi.element.WIND, sub = xi.subEffect.SILENCE, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.SILENCE, power = 1, duration = 30, minDuration = 1, maxDuration = 30, }, [xi.mob.ae.ENAMNESIA] = { chance = 25, ele = xi.element.FIRE, sub = xi.subEffect.AMNESIA, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.AMNESIA, power = 1, duration = 30, minDuration = 1, maxDuration = 30, }, [xi.mob.ae.SLEEP] = { chance = 25, ele = xi.element.DARK, sub = xi.subEffect.SLEEP, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.SLEEP_I, power = 20, duration = 30, minDuration = 1, maxDuration = 45, }, [xi.mob.ae.SLOW] = { chance = 25, ele = xi.element.EARTH, sub = xi.subEffect.DEFENSE_DOWN, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.SLOW, power = 1000, duration = 30, minDuration = 1, maxDuration = 45, }, [xi.mob.ae.STUN] = { chance = 20, ele = xi.element.THUNDER, sub = xi.subEffect.STUN, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.STUN, duration = 5, }, [xi.mob.ae.TERROR] = { chance = 20, sub = xi.subEffect.PARALYSIS, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.TERROR, duration = 5, code = function(mob, target, power) mob:resetEnmity(target) end, }, [xi.mob.ae.TP_DRAIN] = { chance = 25, ele = xi.element.DARK, sub = xi.subEffect.TP_DRAIN, msg = xi.msg.basic.ADD_EFFECT_TP_DRAIN, mod = xi.mod.INT, bonusAbilityParams = { bonusmab = 0, includemab = false }, code = function(mob, target, power) local tp = math.min(power, target:getTP()) target:delTP(tp) mob:addTP(tp) end, }, [xi.mob.ae.WEIGHT] = { chance = 25, ele = xi.element.WIND, sub = xi.subEffect.ATTACK_DOWN, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.WEIGHT, power = 1, duration = 30, minDuration = 1, maxDuration = 45, }, [xi.mob.ae.BIND] = { chance = 10, ele = xi.element.ICE, sub = xi.subEffect.DARKNESS_DAMAGE, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.BIND, power = 1, duration = 30, minDuration = 1, maxDuration = 90, }, [xi.mob.ae.DEFENSE_DOWN] = { chance = 20, ele = xi.element.WIND, sub = xi.subEffect.DEFENSE_DOWN, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.DEFENSE_DOWN, power = 25, duration = 30, minDuration = 1, maxDuration = 60, }, [xi.mob.ae.ATTACK_DOWN] = { chance = 20, ele = xi.element.WATER, sub = xi.subEffect.ATTACK_DOWN, msg = xi.msg.basic.ADD_EFFECT_STATUS, applyEffect = true, eff = xi.effect.ATTACK_DOWN, power = 25, duration = 30, minDuration = 1, maxDuration = 60, }, } --[[ Helper function for xi.mob.onAddEffect that applies a status effect. --]] local addEffectStatus = function(mob, target, ae, params) local resist = 1 if ae.ele then resist = applyResistanceAddEffect(mob, target, ae.ele, ae.eff) end if resist > 0.5 and not target:hasStatusEffect(ae.eff) then local power = params.power or ae.power or 0 local tick = ae.tick or 0 local duration = params.duration or ae.duration duration = utils.clamp(duration, ae.minDuration, ae.maxDuration) * resist target:addStatusEffect(ae.eff, { power = power, duration = duration, origin = mob, tick = tick }) if params.code then params.code(mob, target, power) elseif ae.code then ae.code(mob, target, power) end return ae.sub, ae.msg, ae.eff end return 0, 0, 0 end --[[ Helper function for xi.mob.onAddEffect that applies damage. --]] local addEffectImmediate = function(mob, target, damage, ae, params) local power = 0 if params.power then power = params.power elseif ae.mod then local dMod = mob:getStat(ae.mod) - target:getStat(ae.mod) if dMod > 20 then dMod = 20 + (dMod - 20) / 2 end -- This is a bad assumption, but it prevents some negative damage (healing) when there otherwise shouldn't be -- TODO: better understand damage add effects from mobs if dMod < 0 then dMod = 0 end power = dMod + target:getMainLvl() - mob:getMainLvl() + damage / 2 end -- target:printToPlayer(string.format('Initial Power: %f', power)) -- DEBUG power = addBonusesAbility(mob, ae.ele, target, power, ae.bonusAbilityParams) power = power * applyResistanceAddEffect(mob, target, ae.ele, 0) power = power * xi.spells.damage.calculateAbsorption(target, ae.ele, true) power = power * xi.spells.damage.calculateNullification(target, ae.ele, true, false) if ae.sub ~= xi.subEffect.TP_DRAIN and ae.sub ~= xi.subEffect.MP_DRAIN then power = finalMagicNonSpellAdjustments(mob, target, ae.ele, power) end -- target:printToPlayer(string.format('Adjusted Power: %f', power)) -- DEBUG local message = ae.msg if power < 0 then if ae.negMsg then message = ae.negMsg power = power * -1 -- outgoing action packets only support unsigned integers. The "negative message" will also handle healing automagically deep inside core somewhere. else power = 0 end end if power ~= 0 then if params.code then params.code(mob, target, power) elseif ae.code then ae.code(mob, target, power) end return ae.sub, message, power end return 0, 0, 0 end --[[ mob, target, and damage are passed from core into mob script's onAdditionalEffect effect should be of type xi.mob.additionalEffect (see above) params is a table that can contain any of: chance: percent chance that effect procs on hit (default 20) power: power of effect duration: duration of effect, in seconds code: additional code that will run when effect procs, of form function(mob, target, power) params will override effect's default settings --]] xi.mob.onAddEffect = function(mob, target, damage, effect, params) if type(params) ~= 'table' then params = {} end local ae = additionalEffects[effect] if ae then local chance = params.chance or ae.chance or 100 local dLevel = target:getMainLvl() - mob:getMainLvl() if dLevel > 0 then chance = chance - 5 * dLevel chance = utils.clamp(chance, 5, 95) end -- target:printToPlayer(string.format('Chance: %i', chance)) -- DEBUG if math.random(1, 100) <= chance then -- STATUS EFFECT if ae.applyEffect then return addEffectStatus(mob, target, ae, params) -- IMMEDIATE EFFECT else return addEffectImmediate(mob, target, damage, ae, params) end end else printf('invalid additional effect for mobId %i', mob:getID()) end return 0, 0, 0 end ----------------------------------- -- Centralized function for calling one or more mob "pets" -- It may be helpful to think of mobs with multiple as having "helpers" rather than explicitly pets -- Since this is a looser definition than an explicit `->PMaster` and `->PPet` relationship that exists: -- - these "pets" can have real pets of their own -- - mobs can have multiple "pets" -- - if the petId maps to the mob's actual pet (or petIds is nil and the mob has a pet mapped), -- then no ROAM listener is installed on the pet, but the animations can still be consistently managed in one place ----------------------------------- xi.mob.callPets = function(mob, petIds, params) params = params or {} -- params table: -- params.dieWithOwner: will kill pets immediately if owner dies -- params.persistOnDeath: pets persist when owner dies/disengages (default: false) -- params.superLink: mob will assist pet (pet will always assist mob) -- params.maxSpawns: stop if this many pets get spawned -- params.ignoreBusy: allow pets to get summoned even if owner is busy, interupting any action it was performing -- params.noAnimation: no animation packet from owner when calling pet -- params.inactiveTime: how long for the call pet to take (owner will be inactive during period) -- params.ignoreInactive: summoner does not become inactive while summoning a pet -- this implies using summoner start/stop entity animation packet (which most mobs use when calling either pets or additional helpers) -- if inactiveTime is zero, the following will determine an action packet to signal the mob is calling a pet -- params.callPetJob will map to a particular mobskill action packet -- if not, the function will use a generic 2-hour action packet -- optionally you can override particular action packet params with params.action.X (see that code below) -- NOTE these are not arbitrary choices, but multiple options to emulate retail behavior for any particular owner of pets/helpers if xi.combat.behavior.isEntityBusy(mob) and not params.ignoreBusy then return false end -- make sure at least one pet is available to summon if type(petIds) == 'number' then petIds = { petIds } elseif petIds == nil then -- ensure petIds is always a table so ipairs doesn't fail below petIds = mob:getPet() and { mob:getPet():getID() } or {} end local canSummonPets = false for _, petId in ipairs(petIds) do local petToSummon = GetMobByID(petId) if petToSummon and not petToSummon:isSpawned() then canSummonPets = true end end if not canSummonPets then return false end -- don't allow times so short the animations will bug out if params.inactiveTime == nil or params.inactiveTime < 1000 then params.inactiveTime = 0 end local actionParams = nil if params.inactiveTime == 0 and not params.action then -- job based action packet switch (params.callPetJob) : caseof { [xi.job.BST] = function(x) -- inject " uses Call Beast" actionParams = { finishCategory = xi.action.category.MOBABILITY_FINISH, animationID = 718, actionID = xi.mobSkill.CALL_BEAST, messageID = xi.msg.basic.USES, param = 0, } end, [xi.job.DRG] = function(x) -- inject " uses Call Wyvern" actionParams = { finishCategory = xi.action.category.MOBABILITY_FINISH, animationID = 438, actionID = xi.mobSkill.CALL_WYVERN_1, messageID = xi.msg.basic.USES, param = 0, } end, [xi.job.PUP] = function(x) -- inject " uses Activate" -- The mobskill has no action message, so we use the job ability actionParams = { finishCategory = xi.action.category.JOBABILITY_FINISH, animationID = 83, actionID = xi.jobAbility.ACTIVATE, messageID = xi.msg.basic.USES_JA, param = 0, } end, } end params.action = params.action or {} if not actionParams then -- Generic 2-hour animation with no message or options from params table actionParams = { finishCategory = params.action.finishCategory or 11, actionID = params.action.messageID or 307, animationID = params.action.animationID or 439, messageID = params.action.messageID or 0, param = params.action.param or 0, } end -- function to execute when pets are actually called (there may be an inactiveTime) local callPetFinish = function(mobArg) if mobArg:isDead() then return end -- inject action packet to indicate mob is summoning a pet if not params.noAnimation then if params.inactiveTime > 0 then mobArg:entityAnimationPacket(xi.animationString.CAST_SUMMONER_STOP) else if actionParams then -- Generic 2-hour animation with no message mobArg:injectActionPacket(mobArg:getID(), actionParams.finishCategory, actionParams.animationID, 0, 0x18, actionParams.messageID, actionParams.actionID, actionParams.param) end end end local spawnPos = mobArg:getSpawnPos() local pos = mobArg:getPos() params.maxSpawns = params.maxSpawns or #petIds local spawnedCount = 0 for _, petId in ipairs(petIds) do local petToSummon = GetMobByID(petId) if spawnedCount < params.maxSpawns and petToSummon and not petToSummon:isSpawned() then spawnedCount = spawnedCount + 1 -- spawn pet around owner petToSummon:setSpawn(pos.x + math.random(-2, 2), pos.y, pos.z + math.random(-2, 2), pos.rot) petToSummon:spawn() -- set home to be the owner's home position petToSummon:setSpawn(spawnPos.x, spawnPos.y, spawnPos.z, spawnPos.rot) local ownerRoamListenerName = fmt('OWNER_ASSIST_{}', petId) if params.superLink then mobArg:addListener('ROAM_TICK', ownerRoamListenerName, function(owner) local petToAssist = GetMobByID(petId) local assistTarg = petToAssist and petToAssist:getTarget() or nil if assistTarg then owner:updateEnmity(assistTarg) end end) end -- so they die at the same time -- even without this parameter, if the owner is dead and the pet is roaming, it will die if params.dieWithOwner then local listenerName = fmt('OWNER_DEATH_{}', petId) mobArg:addListener('DEATH', listenerName, function(owner) local petToKill = GetMobByID(petId) if petToKill and petToKill:isSpawned() then petToKill:setHP(0) end owner:removeListener(listenerName) end) end -- make pet assist with a slight delay to allow spawn to complete so animations don't get bugged local ownerID = mobArg:getID() petToSummon:stun(500) if petToSummon ~= mobArg:getPet() then local persistOnDeath = params.persistOnDeath or false if persistOnDeath then petToSummon:addListener('ROAM_TICK', 'ASSIST_OWNER', function(petArg) local owner = GetMobByID(ownerID) if not owner then return end local newTarget = owner:getTarget() or nil if newTarget then petArg:updateEnmity(newTarget) return end if owner:isAlive() and not petArg:hasFollowTarget() then petArg:follow(owner, xi.followType.ROAM) return end end) else petToSummon:addListener('ROAM_TICK', 'ASSIST_OWNER', function(petArg) local owner = GetMobByID(ownerID) if not owner then return end local newTarget = owner:getTarget() or nil if newTarget then petArg:updateEnmity(newTarget) return end if owner:isDead() then petArg:setHP(0) return end if not petArg:hasFollowTarget() then petArg:follow(owner, xi.followType.ROAM) return end end) end -- so we don't wait for the next roam tick (pet assists as soon as :stun is complete) petToSummon:queue(0, function(petArg) petArg:triggerListener('ROAM_TICK', petArg) end) end -- cleanup any listeners related to this pet when it dies -- (:removeListener quietly exits if the listener doesn't exist) petToSummon:addListener('DESPAWN', 'PET_LISTENER_CLEANUP', function(petArg) local owner = GetMobByID(ownerID) if owner then owner:removeListener(ownerRoamListenerName) end petArg:removeListener('ASSIST_OWNER') petArg:removeListener('PET_LISTENER_CLEANUP') end) end end end if params.inactiveTime > 0 then if not params.ignoreInactive then -- put owner into inactive state until the timer fires mob:stun(params.inactiveTime) end -- start call pet animation if not params.noAnimation then mob:entityAnimationPacket(xi.animationString.CAST_SUMMONER_START) end end -- regardless, call the anonymous function from above in params.inactiveTime ms (possibly zero) -- note that timers cause xi.combat.behavior.isEntityBusy to return true, and so does mob:stun(X) mob:timer(params.inactiveTime, callPetFinish) return true end