1842 lines
74 KiB
Lua
1842 lines
74 KiB
Lua
-----------------------------------
|
|
-- Monster TP Moves Global
|
|
-- NOTE: A lot of this is good estimating since the FFXI playerbase has not found all of info for individual moves.
|
|
-- What is known is that they roughly follow player Weaponskill calculations (pDIF, dMOD, ratio, etc) so this is what
|
|
-- this set of functions emulates.
|
|
-----------------------------------
|
|
require('scripts/globals/combat/magic_hit_rate')
|
|
require('scripts/globals/magicburst')
|
|
require('scripts/globals/magic')
|
|
require('scripts/globals/spells/damage_spell')
|
|
-----------------------------------
|
|
xi = xi or {}
|
|
xi.mobskills = xi.mobskills or {}
|
|
|
|
---@enum xi.mobskills.drainType
|
|
xi.mobskills.drainType =
|
|
{
|
|
HP = 0,
|
|
MP = 1,
|
|
TP = 2,
|
|
}
|
|
|
|
-- Shadow Behavior (Number of shadows to remove)
|
|
---@enum xi.mobskills.shadowBehavior
|
|
xi.mobskills.shadowBehavior =
|
|
{
|
|
IGNORE_SHADOWS = 0,
|
|
NUMSHADOWS_1 = 1,
|
|
NUMSHADOWS_2 = 2,
|
|
NUMSHADOWS_3 = 3,
|
|
NUMSHADOWS_4 = 4,
|
|
NUMSHADOWS_5 = 5,
|
|
NUMSHADOWS_6 = 6,
|
|
NUMSHADOWS_7 = 7,
|
|
NUMSHADOWS_8 = 8,
|
|
NUMSHADOWS_9 = 9,
|
|
WIPE_SHADOWS = 999,
|
|
}
|
|
|
|
-- TODO: Currently still used by avatar skills. Marked for deletion once they get converted.
|
|
---@enum xi.mobskills.physicalTpBonus
|
|
xi.mobskills.physicalTpBonus =
|
|
{
|
|
NO_EFFECT = 0,
|
|
ACC_VARIES = 1, -- Not implemented
|
|
ATK_VARIES = 2,
|
|
DMG_VARIES = 3, -- Damage formula incorrect
|
|
CRIT_VARIES = 4, -- Deprecated, pending removal from mob skills
|
|
}
|
|
|
|
-- TODO: Currently still used by avatar skills. Marked for deletion once they get converted.
|
|
---@enum xi.mobskills.magicalTpBonus
|
|
xi.mobskills.magicalTpBonus =
|
|
{
|
|
NO_EFFECT = 0,
|
|
MACC_BONUS = 1, -- Not implemented
|
|
MAB_BONUS = 2, -- Not implemented
|
|
DMG_BONUS = 3, -- Damage formula incorrect
|
|
}
|
|
|
|
local burstMultipliersByTier =
|
|
{
|
|
[0] = 1.0,
|
|
[1] = 1.3,
|
|
[2] = 1.35,
|
|
[3] = 1.40,
|
|
[4] = 1.45,
|
|
[5] = 1.5,
|
|
}
|
|
|
|
---@params target CBaseEntity
|
|
---@params actionElement xi.element
|
|
---@params skillChainCount integer
|
|
---@return number
|
|
local function calculateMobMagicBurst(target, actionElement, skillchainCount)
|
|
local burstMultiplier = 1.0
|
|
|
|
if actionElement > xi.element.NONE then
|
|
local resistRank = target:getMod(xi.data.element.getElementalResistanceRankModifier(actionElement))
|
|
local rankTable = { 1.15, 0.85, 0.6, 0.5, 0.4, 0.15, 0.05 } -- TODO: Confirm resist rank tier scaling.
|
|
local rankBonus = 0
|
|
|
|
if resistRank <= -3 then
|
|
rankBonus = 1.5
|
|
elseif resistRank >= 5 then
|
|
rankBonus = 0
|
|
else
|
|
rankBonus = rankTable[resistRank + 3]
|
|
end
|
|
|
|
-- https://w.atwiki.jp/bartlett3/pages/329.html
|
|
-- This page has a bullet point on pet magic bursts where avatar magic damage is discussed.
|
|
if skillchainCount >= 1 then
|
|
burstMultiplier = burstMultipliersByTier[skillchainCount] + rankBonus
|
|
end
|
|
end
|
|
|
|
-- TODO: Do pets gain bonus from Sengikori?
|
|
-- Sengikori appears to add to base mb multiplier per JP wiki https://wiki.ffo.jp/html/20051.html
|
|
-- if
|
|
-- skillchainCount >= 1 and
|
|
-- target:getMod(xi.mod.SENGIKORI_MB_DMG_DEBUFF) > 0
|
|
-- then
|
|
-- burstMultiplier = burstMultiplier + target:getMod(xi.mod.SENGIKORI_MB_DMG_DEBUFF) / 100
|
|
-- target:setMod(xi.mod.SENGIKORI_MB_DMG_DEBUFF, 0) -- Consume the "Effect" upon magic burst.
|
|
-- end
|
|
|
|
return burstMultiplier
|
|
end
|
|
|
|
-- LLS definitions for normalizePhysicalSkillParams()
|
|
--- @class physicalSkillParams
|
|
--- @field baseDamage number|nil
|
|
--- @field numHits integer
|
|
--- @field fTP number[]
|
|
--- @field fTPSubsequentHits number[]
|
|
--- @field fTPBonus number
|
|
--- @field attackMultiplier number[]
|
|
--- @field accuracyModifier number[]
|
|
--- @field guaranteedFirstHit boolean
|
|
--- @field canCrit boolean
|
|
--- @field criticalChance number[]
|
|
--- @field ignoreDefense number[]
|
|
--- @field isCannonball boolean
|
|
--- @field attackType xi.attackType
|
|
--- @field damageType xi.damageType
|
|
--- @field hybridSkill boolean
|
|
--- @field hybridSkillElement xi.element
|
|
--- @field hybridAttackType xi.attackType
|
|
--- @field hybridDamageType xi.damageType
|
|
--- @field shadowBehavior xi.mobskills.shadowBehavior
|
|
--- @field skipStoneskin boolean
|
|
--- @field skipYaegasumi boolean
|
|
--- @field skipFSTR boolean
|
|
--- @field skipPDIF boolean
|
|
--- @field skipParry boolean
|
|
--- @field skipGuard boolean
|
|
--- @field skipBlock boolean
|
|
--- @field terminateOnMiss boolean
|
|
--- @field primaryMessage xi.msg.basic
|
|
|
|
--- Table of default skill params shared by physical/ranged mobskills.
|
|
--- Sets default values if the params are not explicitly defined in the mobskill script.
|
|
--- @param skillParams physicalSkillParams
|
|
--- @return physicalSkillParams
|
|
local function normalizePhysicalSkillParams(skillParams)
|
|
local defaults =
|
|
{
|
|
baseDamage = nil, -- handled separately
|
|
numHits = 1,
|
|
fTP = { 1.00, 1.00, 1.00 },
|
|
fTPSubsequentHits = { 1.00, 1.00, 1.00 },
|
|
fTPBonus = 0,
|
|
attackMultiplier = { 1.00, 1.00, 1.00 },
|
|
accuracyModifier = { 0, 0, 0 },
|
|
guaranteedFirstHit = false,
|
|
canCrit = false,
|
|
criticalChance = { 0.00, 0.00, 0.00 },
|
|
ignoreDefense = { 0.00, 0.00, 0.00 },
|
|
isCannonball = false,
|
|
attackType = xi.attackType.PHYSICAL,
|
|
damageType = xi.damageType.SLASHING,
|
|
hybridSkill = false,
|
|
hybridSkillElement = xi.element.NONE,
|
|
hybridAttackType = xi.attackType.MAGICAL,
|
|
hybridDamageType = xi.damageType.ELEMENTAL,
|
|
shadowBehavior = xi.mobskills.shadowBehavior.NUMSHADOWS_1,
|
|
skipStoneskin = false,
|
|
skipYaegasumi = false,
|
|
skipFSTR = false,
|
|
skipPDIF = false,
|
|
skipParry = false,
|
|
skipGuard = false,
|
|
skipBlock = false,
|
|
terminateOnMiss = false,
|
|
primaryMessage = xi.msg.basic.DAMAGE,
|
|
}
|
|
|
|
local result = {}
|
|
|
|
for paramName, defaultValue in pairs(defaults) do
|
|
result[paramName] = utils.defaultIfNil(skillParams[paramName], defaultValue)
|
|
end
|
|
|
|
result.baseDamage = skillParams.baseDamage
|
|
|
|
return result
|
|
end
|
|
|
|
-- Helper function to store default physical hit information before being modified.
|
|
---@class physicalHitInfo
|
|
---@field hitNumber integer The index of the hit in a multi-hit attack.
|
|
---@field hitLanded boolean Whether the hit connected with the target.
|
|
---@field hitYaegasumi boolean Whether the hit was absorbed by Yaegasumi.
|
|
---@field hitAnticipated boolean Whether the hit was anticipated (e.g. by an ability).
|
|
---@field hitParried boolean Whether the hit was parried.
|
|
---@field hitGuarded boolean Whether the hit was guarded.
|
|
---@field hitAbsorbed boolean Whether the hit was absorbed.
|
|
---@field hitBlocked boolean Whether the hit was blocked.
|
|
---@field isCritical boolean Whether the hit was a critical strike.
|
|
---@field pDif number The pDIF multiplier used for damage calculation.
|
|
---@field missType string|nil The type of miss, or nil if the hit landed.
|
|
---@field hitDamage integer The amount of damage dealt by the hit.
|
|
---@field shadowsConsumed integer The number of Utsusemi shadows consumed.
|
|
|
|
---Creates a default HitInfo table for a physical hit before damage resolution.
|
|
---@param hitNumber integer The index of this hit in a multi-hit attack sequence.
|
|
---@return physicalHitInfo
|
|
local function defaultHitInfo(hitNumber)
|
|
return {
|
|
hitNumber = hitNumber,
|
|
hitLanded = false,
|
|
hitYaegasumi = false,
|
|
hitAnticipated = false,
|
|
hitParried = false,
|
|
hitGuarded = false,
|
|
hitAbsorbed = false,
|
|
hitBlocked = false,
|
|
isCritical = false,
|
|
pDif = 0,
|
|
missType = nil,
|
|
hitDamage = 0,
|
|
shadowsConsumed = 0,
|
|
}
|
|
end
|
|
|
|
---Tallies the results of all hits from a physical mob skill into aggregate values.
|
|
---@param hitData physicalHitInfo[] List of hit result tables from `returnInfo.hitData`
|
|
---@return number totalDamage Sum of damage dealt across all landed hits
|
|
---@return number hitsLanded Count of hits that successfully dealt damage
|
|
---@return boolean hitsYaegasumi True if any hit was evaded by Yaegasumi
|
|
---@return boolean hitsAnticipated True if any hit was blocked by Third Eye anticipation
|
|
---@return number hitsAbsorbed Count of hits absorbed by shadows (Utsusemi/Blink)
|
|
---@return number shadowsAbsorbed Total number of shadow images consumed across all absorbed hits
|
|
---@return boolean anyCrit True if any landed hit was a critical strike
|
|
local function tallyHitResults(hitData)
|
|
local totalDamage = 0
|
|
local hitsLanded = 0
|
|
local hitsYaegasumi = false
|
|
local hitsAnticipated = false
|
|
local shadowsAbsorbed = 0
|
|
local hitsAbsorbed = 0
|
|
local anyCrit = false
|
|
|
|
for _, hit in ipairs(hitData) do
|
|
if hit.hitLanded then
|
|
hitsLanded = hitsLanded + 1
|
|
totalDamage = totalDamage + hit.hitDamage
|
|
anyCrit = anyCrit or hit.isCritical
|
|
end
|
|
|
|
if hit.hitYaegasumi then
|
|
hitsYaegasumi = true
|
|
end
|
|
|
|
if hit.hitAnticipated then
|
|
hitsAnticipated = true
|
|
end
|
|
|
|
if hit.hitAbsorbed then
|
|
hitsAbsorbed = hitsAbsorbed + 1
|
|
shadowsAbsorbed = shadowsAbsorbed + hit.shadowsConsumed
|
|
end
|
|
end
|
|
|
|
return totalDamage, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, anyCrit
|
|
end
|
|
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param hitsLanded integer
|
|
---@param hitsYaegasumi boolean
|
|
---@param hitsAnticipated boolean
|
|
---@param hitsAbsorbed integer
|
|
---@param shadowsAbsorbed integer
|
|
---@param primaryMessage xi.msg.basic
|
|
---@param totalDamage integer
|
|
---@return integer
|
|
local function resolveMissMessage(skill, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, primaryMessage, totalDamage)
|
|
if hitsLanded == 0 and hitsYaegasumi then
|
|
-- Yaegasumi
|
|
-- TODO: Get captures of Yaegasumi ability and confirm.
|
|
skill:setMsg(xi.msg.basic.EVADES)
|
|
elseif hitsLanded == 0 and hitsAnticipated then
|
|
-- Third Eye
|
|
skill:setMsg(xi.msg.basic.ANTICIPATE)
|
|
elseif hitsLanded == 0 and hitsAbsorbed > 0 then
|
|
-- Utsusemi and Blink
|
|
skill:setMsg(xi.msg.basic.SHADOW_ABSORB)
|
|
totalDamage = shadowsAbsorbed
|
|
elseif hitsLanded == 0 then
|
|
if primaryMessage == xi.msg.basic.RANGED_ATTACK_HIT then
|
|
skill:setMsg(xi.msg.basic.RANGED_ATTACK_MISS)
|
|
elseif primaryMessage == xi.msg.basic.HIT_DMG then
|
|
skill:setMsg(xi.msg.basic.HIT_MISS)
|
|
elseif primaryMessage == xi.msg.basic.USES_JA_TAKE_DAMAGE then
|
|
skill:setMsg(xi.msg.basic.JA_MISS_2)
|
|
else
|
|
skill:setMsg(xi.msg.basic.SKILL_MISS)
|
|
end
|
|
|
|
totalDamage = 0
|
|
end
|
|
|
|
return totalDamage
|
|
end
|
|
|
|
-- passed to handleSinglePhysicalHit() inside xi.mobskills.mobPhysicalMove() and handleSingleRangedHit() inside xi.mobskills.mobRangedMove()
|
|
---@class physicalMobSkillHitParams
|
|
---@field hitNumber number
|
|
---@field attackType xi.attackType
|
|
---@field damageType xi.damageType
|
|
---@field shadowsToRemove number
|
|
---@field skipStoneskin boolean
|
|
---@field canCrit boolean
|
|
---@field weaponType xi.skill
|
|
---@field tpValue number
|
|
---@field attackMultiplier number
|
|
---@field critModTable table
|
|
---@field applyLevelCorrection boolean
|
|
---@field isCannonball boolean
|
|
---@field ignoreDefense boolean
|
|
---@field ignoreDefenseFactor number
|
|
---@field skipPDIF boolean
|
|
---@field skipParry boolean
|
|
---@field skipGuard boolean
|
|
---@field skipBlock boolean
|
|
|
|
-- helper function to handle a single hit and check for parrying, guarding, and blocking
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param baseHitDamage number
|
|
---@param params physicalMobSkillHitParams
|
|
---@return physicalHitInfo
|
|
local function handleSinglePhysicalHit(mob, target, baseHitDamage, params)
|
|
local hitNumber = params.hitNumber
|
|
local hitParried = xi.combat.physical.isParried(target, mob) and not params.skipParry
|
|
local hitGuarded = xi.combat.physical.isGuarded(target, mob) and not params.skipGuard
|
|
local isCritical = false
|
|
local hitBlocked = false
|
|
local blockedWithShieldMastery = false
|
|
local hitInfo = defaultHitInfo(hitNumber)
|
|
|
|
----------------------------------
|
|
-- Parry / Guard
|
|
----------------------------------
|
|
if hitParried then
|
|
hitInfo.hitParried = true
|
|
hitInfo.missType = 'Parried'
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
if hitGuarded then
|
|
hitInfo.hitGuarded = true
|
|
hitInfo.missType = 'Guarded'
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
----------------------------------
|
|
-- Critical Check
|
|
----------------------------------
|
|
if params.canCrit then
|
|
local critRate = xi.combat.physical.calculateSwingCriticalRate(mob, target, params.tpValue, xi.slot.MAIN, params.critModTable)
|
|
|
|
isCritical = math.random(1, 1000) <= critRate * 1000
|
|
end
|
|
|
|
----------------------------------
|
|
-- PDIF + Damage
|
|
----------------------------------
|
|
local pDif = 1
|
|
local hitDamage = 0
|
|
|
|
if not params.skipPDIF then
|
|
pDif = xi.combat.physical.calculateMeleePDIF(mob, target, params.weaponType, params.attackMultiplier, isCritical, params.applyLevelCorrection, params.ignoreDefense, params.ignoreDefenseFactor, false, xi.slot.MAIN, params.isCannonball)
|
|
end
|
|
|
|
hitDamage = math.floor(baseHitDamage * pDif)
|
|
|
|
if
|
|
xi.combat.physical.isBlocked(target, mob) and
|
|
not params.skipBlock
|
|
then
|
|
hitBlocked = true
|
|
|
|
hitDamage = hitDamage - xi.combat.physical.getDamageReductionForBlock(target, mob, hitDamage)
|
|
|
|
if target:getMod(xi.mod.SHIELD_MASTERY_TP) > 0 then
|
|
blockedWithShieldMastery = true
|
|
end
|
|
end
|
|
|
|
hitDamage = math.floor(hitDamage * xi.combat.damage.physicalElementSDT(target, params.damageType))
|
|
hitDamage = math.floor(hitDamage * xi.combat.damage.calculateDamageAdjustment(target, true, false, false, false))
|
|
|
|
if mob:isAvatar() then
|
|
hitDamage = math.floor(hitDamage + hitDamage * mob:getMod(xi.mod.BP_DAMAGE) / 100)
|
|
end
|
|
|
|
hitDamage = xi.automaton.handleEqualizer(target, hitDamage)
|
|
|
|
-- TODO: Need captures for different severe damage mechanics. Do they proc per hit or per skill
|
|
hitDamage = math.floor(target:handleSevereDamage(hitDamage, true))
|
|
|
|
-- TODO: Convert Damage to MP + Cover Bonus
|
|
|
|
-- TODO: Fan Dance Reduction
|
|
|
|
-- Pre phalanx check - if stoneskin breaks we can get TP from shield mastery
|
|
if
|
|
blockedWithShieldMastery and
|
|
math.max(hitDamage - target:getMod(xi.mod.STONESKIN), 0) > 0
|
|
then
|
|
target:addTP(target:getMod(xi.mod.SHIELD_MASTERY_TP))
|
|
end
|
|
|
|
hitDamage = utils.handlePhalanx(target, hitDamage)
|
|
|
|
if not params.skipStoneskin then
|
|
hitDamage = utils.handleStoneskin(target, hitDamage)
|
|
end
|
|
|
|
hitDamage = math.floor(target:checkDamageCap(hitDamage))
|
|
|
|
if hitDamage > 0 then
|
|
target:trySkillUp(xi.skill.EVASION, target:getMainLvl())
|
|
|
|
if not blockedWithShieldMastery then
|
|
target:tryHitInterrupt(mob)
|
|
end
|
|
end
|
|
|
|
----------------------------------
|
|
-- Successful Hit
|
|
----------------------------------
|
|
hitInfo.hitLanded = true
|
|
hitInfo.hitDamage = hitDamage
|
|
hitInfo.hitBlocked = hitBlocked
|
|
hitInfo.isCritical = isCritical
|
|
hitInfo.pDif = pDif
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
-- helper function to handle a single ranged hit and check for parrying, guarding, and blocking
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param baseHitDamage number
|
|
---@param params physicalMobSkillHitParams
|
|
---@return physicalHitInfo
|
|
local function handleSingleRangedHit(mob, target, baseHitDamage, params)
|
|
local hitNumber = params.hitNumber
|
|
local hitParried = xi.combat.physical.isParried(target, mob) and not params.skipParry
|
|
local hitGuarded = xi.combat.physical.isGuarded(target, mob) and not params.skipGuard
|
|
local isCritical = false
|
|
local hitBlocked = false
|
|
local blockedWithShieldMastery = false
|
|
local hitInfo = defaultHitInfo(hitNumber)
|
|
|
|
----------------------------------
|
|
-- Parry / Guard
|
|
----------------------------------
|
|
if hitParried then
|
|
hitInfo.hitParried = true
|
|
hitInfo.missType = 'Parried'
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
if hitGuarded then
|
|
hitInfo.hitGuarded = true
|
|
hitInfo.missType = 'Guarded'
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
----------------------------------
|
|
-- Critical Check
|
|
----------------------------------
|
|
if params.canCrit then
|
|
local critRate = xi.combat.physical.calculateRangedCriticalRate(mob, target, params.tpValue, xi.slot.MAIN, params.critModTable)
|
|
|
|
isCritical = math.random(1, 1000) <= critRate * 1000
|
|
end
|
|
|
|
----------------------------------
|
|
-- PDIF + Damage
|
|
----------------------------------
|
|
local pDif = 1
|
|
local hitDamage = 0
|
|
|
|
if not params.skipPDIF then
|
|
pDif = xi.combat.physical.calculateRangedPDIF(mob, target, params.weaponType, params.attackMultiplier, isCritical, params.applyLevelCorrection, params.ignoreDefense, params.ignoreDefenseFactor, false, 0)
|
|
end
|
|
|
|
hitDamage = math.floor(baseHitDamage * pDif)
|
|
|
|
if
|
|
xi.combat.physical.isBlocked(target, mob) and
|
|
not params.skipBlock
|
|
then
|
|
hitBlocked = true
|
|
|
|
hitDamage = hitDamage - xi.combat.physical.getDamageReductionForBlock(target, mob, hitDamage)
|
|
|
|
if target:getMod(xi.mod.SHIELD_MASTERY_TP) > 0 then
|
|
blockedWithShieldMastery = true
|
|
end
|
|
end
|
|
|
|
hitDamage = math.floor(hitDamage * xi.combat.damage.physicalElementSDT(target, params.damageType))
|
|
hitDamage = math.floor(hitDamage * xi.combat.damage.calculateDamageAdjustment(target, true, false, true, false))
|
|
|
|
hitDamage = xi.automaton.handleEqualizer(target, hitDamage)
|
|
|
|
-- TODO: Need captures for different severe damage mechanics. Do they proc per hit or per skill
|
|
hitDamage = math.floor(target:handleSevereDamage(hitDamage, true))
|
|
|
|
-- TODO: Convert Damage to MP + Cover Bonus
|
|
|
|
-- TODO: Fan Dance Reduction
|
|
|
|
hitDamage = math.floor(target:checkDamageCap(hitDamage))
|
|
hitDamage = utils.handlePhalanx(target, hitDamage)
|
|
|
|
if not params.skipStoneskin then
|
|
hitDamage = utils.handleStoneskin(target, hitDamage)
|
|
end
|
|
|
|
if hitDamage > 0 then
|
|
target:trySkillUp(xi.skill.EVASION, target:getMainLvl())
|
|
|
|
if not blockedWithShieldMastery then
|
|
target:tryHitInterrupt(mob)
|
|
end
|
|
end
|
|
|
|
----------------------------------
|
|
-- Successful Hit
|
|
----------------------------------
|
|
hitInfo.hitLanded = true
|
|
hitInfo.hitDamage = hitDamage
|
|
hitInfo.hitBlocked = hitBlocked
|
|
hitInfo.isCritical = isCritical
|
|
hitInfo.pDif = pDif
|
|
|
|
return hitInfo
|
|
end
|
|
|
|
---@alias rangedMobSkillRetVal { damage: number, hybridDamage: number, hitsLanded: number, attackType: xi.attackType, damageType: xi.damageType, hybridAttackType: xi.attackType, hybridDamageType: xi.damageType, isCritical: boolean, hitData: table }
|
|
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param action CAction
|
|
---@param skillParams table
|
|
---@return rangedMobSkillRetVal
|
|
xi.mobskills.mobRangedMove = function(mob, target, skill, action, skillParams)
|
|
local returnInfo = {}
|
|
|
|
-- Sanitizes skillParams and sets defaults for any params not explicitly set in mob skill scripts.
|
|
local params = normalizePhysicalSkillParams(skillParams)
|
|
|
|
local damage = params.baseDamage or mob:getWeaponDmg()
|
|
|
|
-- Initialize return structure
|
|
returnInfo.damage = 0
|
|
returnInfo.hybridDamage = 0
|
|
returnInfo.hitsLanded = 0
|
|
returnInfo.attackType = params.attackType
|
|
returnInfo.damageType = params.damageType
|
|
returnInfo.hybridAttackType = params.hybridAttackType
|
|
returnInfo.hybridDamageType = params.hybridDamageType
|
|
returnInfo.isCritical = false
|
|
returnInfo.hitData = {}
|
|
|
|
skill:setMsg(params.primaryMessage)
|
|
|
|
----------------------------------
|
|
-- Base Damage Calculation
|
|
----------------------------------
|
|
local fSTR = 0
|
|
local wscMods = xi.combat.physical.calculateWSC(mob, skillParams.str_wSC, skillParams.dex_wSC, skillParams.vit_wSC, skillParams.agi_wSC, skillParams.int_wSC, skillParams.mnd_wSC, skillParams.chr_wSC)
|
|
local bonusTP = mob:getMod(xi.mod.TP_BONUS) + params.fTPBonus
|
|
local skillTP = math.max(1000, skill:getTP())
|
|
local tpValue = math.min(skillTP + bonusTP, 3000)
|
|
local basefTP = xi.combat.physical.calculateTPfactor(tpValue, params.fTP)
|
|
local baseDamage = 0
|
|
|
|
if not params.skipFSTR then
|
|
fSTR = xi.combat.physical.calculateRangedStatFactor(mob, target)
|
|
end
|
|
|
|
baseDamage = math.max(1, math.floor((damage + fSTR + wscMods) * basefTP))
|
|
|
|
local subsequentDamage = baseDamage
|
|
if params.fTPSubsequentHits then
|
|
subsequentDamage = math.floor((damage + fSTR + wscMods) * xi.combat.physical.calculateTPfactor(tpValue, params.fTPSubsequentHits))
|
|
end
|
|
|
|
----------------------------------
|
|
-- Calculate Skill Params To Pass To hitParams.
|
|
----------------------------------
|
|
-- targetSpecialAttackEvasion gets transformed to a negative number since its a modifier on the target affecting the attacker's accuracy.
|
|
local targetSpecialAttackEvasion = target:getMod(xi.mod.SPECIAL_ATTACK_EVASION) * -1
|
|
local accuracyModifier = 0
|
|
if params.accuracyModifier then
|
|
accuracyModifier = xi.combat.physical.calculateTPfactor(tpValue, params.accuracyModifier)
|
|
end
|
|
|
|
local attackMultiplier = 1
|
|
if params.attackMultiplier then
|
|
attackMultiplier = xi.combat.physical.calculateTPfactor(tpValue, params.attackMultiplier)
|
|
end
|
|
|
|
local ignoreDefenseFactor = 0
|
|
local hitIgnoreDefense = false
|
|
if params.ignoreDefense then
|
|
ignoreDefenseFactor = xi.combat.physical.calculateTPfactor(tpValue, params.ignoreDefense)
|
|
hitIgnoreDefense = true
|
|
end
|
|
|
|
----------------------------------
|
|
-- Params for the individual hits
|
|
----------------------------------
|
|
local hitParams =
|
|
{
|
|
hitNumber = 0,
|
|
attackType = params.attackType,
|
|
damageType = params.damageType,
|
|
skipStoneskin = params.skipStoneskin,
|
|
canCrit = params.canCrit,
|
|
weaponType = xi.skill.NONE,
|
|
tpValue = tpValue,
|
|
attackMultiplier = attackMultiplier,
|
|
critModTable = params.criticalChance,
|
|
applyLevelCorrection = xi.data.levelCorrection.isLevelCorrectedZone(mob),
|
|
isCannonball = params.isCannonball,
|
|
ignoreDefense = hitIgnoreDefense,
|
|
ignoreDefenseFactor = ignoreDefenseFactor, -- If the table exists, it ignores defense
|
|
skipPDIF = params.skipPDIF,
|
|
skipParry = params.skipParry,
|
|
skipGuard = params.skipGuard,
|
|
skipBlock = params.skipBlock,
|
|
}
|
|
|
|
----------------------------------
|
|
-- Calculate the hits
|
|
----------------------------------
|
|
for hitNumber = 1, params.numHits do
|
|
local hitInfo = nil
|
|
local hitChance = 0
|
|
local shadowsConsumed = 0
|
|
local hitAbsorbed = false
|
|
local attackAnticipated = false
|
|
local attackYaegasumi = false
|
|
local attackMissed = false
|
|
|
|
----------------------------------
|
|
-- Handle Utsusemi and Blink
|
|
----------------------------------
|
|
hitAbsorbed, shadowsConsumed = xi.mobskills.handleShadowConsumption(target, skill, params, params.shadowBehavior)
|
|
|
|
----------------------------------
|
|
-- Calculate Hit Rate
|
|
----------------------------------
|
|
if params.guaranteedFirstHit and hitNumber == 1 then
|
|
hitChance = 1
|
|
elseif hitNumber == 1 then
|
|
-- First hit gets bonus accuracy. TODO: Confirm this and the amount if true.
|
|
hitChance = xi.combat.physicalHitRate.getRangedHitRate(mob, target, 100 + accuracyModifier + targetSpecialAttackEvasion, false)
|
|
else
|
|
hitChance = xi.combat.physicalHitRate.getRangedHitRate(mob, target, accuracyModifier + targetSpecialAttackEvasion, false)
|
|
end
|
|
|
|
if not hitInfo then
|
|
-- If the skill did not penetrate and deal damage through the target's shadows, record hit as absorbed.
|
|
if hitAbsorbed then
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitAbsorbed = true
|
|
hitInfo.missType = 'Shadow'
|
|
hitInfo.shadowsConsumed = shadowsConsumed or 0
|
|
elseif
|
|
not params.skipYaegasumi and
|
|
target:hasStatusEffect(xi.effect.YAEGASUMI)
|
|
-- TODO: Fully implement mechanics of this ability (TP Return to the target evading, WS damage bonus)
|
|
-- TODO: How does this interact with shadows/third eye? Do they overwrite? If they coexist, which takes priority?
|
|
then
|
|
attackYaegasumi = true -- TODO: Assuming this acts like Third Eye for now in that it blocks all hits.
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitYaegasumi = true
|
|
hitInfo.missType = 'Yaegasumi Evade'
|
|
elseif xi.combat.physicalHitRate.checkAnticipated(mob, target) then
|
|
attackAnticipated = true -- We use this below to break the attack loop since Third Eye blocks the whole skill.
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitAnticipated = true
|
|
hitInfo.missType = 'Anticipated'
|
|
elseif math.random(1, 100) <= hitChance * 100 then
|
|
hitParams.hitNumber = hitNumber
|
|
|
|
local damageForThisHit = (hitNumber == 1) and baseDamage or subsequentDamage
|
|
|
|
hitInfo = handleSingleRangedHit(mob, target, damageForThisHit, hitParams)
|
|
|
|
hitInfo.shadowsConsumed = shadowsConsumed
|
|
else
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.missType = 'Evaded / Missed'
|
|
attackMissed = true
|
|
end
|
|
end
|
|
|
|
-- Debugging
|
|
if not hitInfo then
|
|
error('hitInfo was not assigned for hit #' .. tostring(hitNumber))
|
|
end
|
|
|
|
-- Record the individual hit into hitData table.
|
|
table.insert(returnInfo.hitData, hitInfo)
|
|
|
|
-- Third Eye treats multi hit attacks as a single hit.
|
|
-- Exit early if there are remaining hits after the anticipated hit.
|
|
if
|
|
hitAbsorbed or
|
|
attackAnticipated or
|
|
attackYaegasumi or
|
|
(params.terminateOnMiss and attackMissed)
|
|
then
|
|
break
|
|
end
|
|
end
|
|
|
|
----------------------------------
|
|
-- Tally All Hit Results
|
|
----------------------------------
|
|
local totalDamage, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, anyCrit = tallyHitResults(returnInfo.hitData)
|
|
|
|
----------------------------------
|
|
-- Handle Automaton Analyzer Attachment
|
|
----------------------------------
|
|
totalDamage = math.floor(utils.handleAutomatonAutoAnalyzer(target, skill, totalDamage))
|
|
|
|
----------------------------------
|
|
-- Handle Hybrid Skill Magic Damage
|
|
----------------------------------
|
|
local magicDamage = 0
|
|
|
|
-- TODO: Need more research on hybrid skills. Is the magical damage strictly reliant on the physical hit doing damage?
|
|
-- How does xi.mod.MAGIC_DAMAGE and MAB interact with this?
|
|
if params.hybridSkill then
|
|
magicDamage = xi.mobskills.handleHybridDamage(mob, target, totalDamage, params.hybridSkillElement)
|
|
end
|
|
|
|
----------------------------------
|
|
-- Handle Miss Messaging
|
|
----------------------------------
|
|
totalDamage = resolveMissMessage(skill, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, params.primaryMessage, totalDamage)
|
|
|
|
-- Mob only gets TP for hitting the initial target. AOE hits do not count.
|
|
xi.mobskills.calculateSkillTPReturn(totalDamage, mob, skill, target, params.attackType, hitsLanded)
|
|
|
|
returnInfo.damage = totalDamage
|
|
returnInfo.hybridDamage = magicDamage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
returnInfo.isCritical = anyCrit
|
|
|
|
skill:setAttackType(xi.attackType.PHYSICAL)
|
|
skill:setCritical(anyCrit)
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
---@alias physicalMobSkillRetVal { damage: number, hybridDamage: number, hitsLanded: number, attackType: xi.attackType, damageType: xi.damageType, hybridAttackType: xi.attackType, hybridDamageType: xi.damageType, isCritical: boolean, hitData: table }
|
|
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param action CAction
|
|
---@param skillParams table
|
|
---@return physicalMobSkillRetVal
|
|
xi.mobskills.mobPhysicalMove = function(mob, target, skill, action, skillParams)
|
|
local returnInfo = {}
|
|
|
|
-- Sanitizes skillParams and sets defaults for any params not explicitly set in mob skill scripts.
|
|
local params = normalizePhysicalSkillParams(skillParams)
|
|
|
|
local damage = params.baseDamage or mob:getWeaponDmg()
|
|
|
|
-- Initialize return structure
|
|
returnInfo.damage = 0
|
|
returnInfo.hybridDamage = 0
|
|
returnInfo.hitsLanded = 0
|
|
returnInfo.attackType = params.attackType
|
|
returnInfo.damageType = params.damageType
|
|
returnInfo.hybridAttackType = params.hybridAttackType
|
|
returnInfo.hybridDamageType = params.hybridDamageType
|
|
returnInfo.isCritical = false
|
|
returnInfo.hitData = {}
|
|
|
|
skill:setMsg(params.primaryMessage)
|
|
|
|
----------------------------------
|
|
-- Base Damage Calculation
|
|
----------------------------------
|
|
local fSTR = 0
|
|
local wscMods = xi.combat.physical.calculateWSC(mob, skillParams.str_wSC, skillParams.dex_wSC, skillParams.vit_wSC, skillParams.agi_wSC, skillParams.int_wSC, skillParams.mnd_wSC, skillParams.chr_wSC)
|
|
local bonusTP = mob:getMod(xi.mod.TP_BONUS) + params.fTPBonus
|
|
local skillTP = math.max(1000, skill:getTP())
|
|
local tpValue = math.min(skillTP + bonusTP, 3000)
|
|
local basefTP = xi.combat.physical.calculateTPfactor(tpValue, params.fTP)
|
|
local baseDamage = 0
|
|
|
|
if not params.skipFSTR then
|
|
fSTR = xi.combat.physical.calculateMeleeStatFactor(mob, target)
|
|
end
|
|
|
|
baseDamage = math.max(1, math.floor((damage + fSTR + wscMods) * basefTP))
|
|
|
|
local subsequentDamage = baseDamage
|
|
|
|
if params.fTPSubsequentHits then
|
|
subsequentDamage = math.floor((damage + fSTR + wscMods) * xi.combat.physical.calculateTPfactor(tpValue, params.fTPSubsequentHits))
|
|
end
|
|
|
|
----------------------------------
|
|
-- Calculate Skill Params To Pass To hitParams.
|
|
----------------------------------
|
|
-- Mobs seem to not usually use TP moves during Mighty Strikes but if forced to by a script, the hits will crit.
|
|
if mob:hasStatusEffect(xi.effect.MIGHTY_STRIKES) then
|
|
params.canCrit = true
|
|
params.criticalChance = { 1.00, 1.00, 1.00 }
|
|
end
|
|
|
|
-- targetSpecialAttackEvasion gets transformed to a negative number since its a modifier on the target affecting the attacker's accuracy.
|
|
local targetSpecialAttackEvasion = target:getMod(xi.mod.SPECIAL_ATTACK_EVASION) * -1
|
|
local accuracyModifier = 0
|
|
if params.accuracyModifier then
|
|
accuracyModifier = xi.combat.physical.calculateTPfactor(tpValue, params.accuracyModifier)
|
|
end
|
|
|
|
local attackMultiplier = 1
|
|
if params.attackMultiplier then
|
|
attackMultiplier = xi.combat.physical.calculateTPfactor(tpValue, params.attackMultiplier)
|
|
end
|
|
|
|
local ignoreDefenseFactor = 0
|
|
local hitIgnoreDefense = false
|
|
if params.ignoreDefense then
|
|
ignoreDefenseFactor = xi.combat.physical.calculateTPfactor(tpValue, params.ignoreDefense)
|
|
hitIgnoreDefense = true
|
|
end
|
|
|
|
----------------------------------
|
|
-- Params for the individual hits
|
|
----------------------------------
|
|
local hitParams =
|
|
{
|
|
hitNumber = 0,
|
|
attackType = params.attackType,
|
|
damageType = params.damageType,
|
|
skipStoneskin = params.skipStoneskin,
|
|
canCrit = params.canCrit,
|
|
weaponType = xi.skill.NONE,
|
|
tpValue = tpValue,
|
|
attackMultiplier = attackMultiplier,
|
|
critModTable = params.criticalChance,
|
|
applyLevelCorrection = xi.data.levelCorrection.isLevelCorrectedZone(mob),
|
|
isCannonball = params.isCannonball,
|
|
ignoreDefense = hitIgnoreDefense,
|
|
ignoreDefenseFactor = ignoreDefenseFactor, -- If the table exists, it ignores defense
|
|
skipPDIF = params.skipPDIF,
|
|
skipParry = params.skipParry,
|
|
skipGuard = params.skipGuard,
|
|
skipBlock = params.skipBlock,
|
|
}
|
|
|
|
----------------------------------
|
|
-- Calculate the hits
|
|
----------------------------------
|
|
|
|
-- TODO: Implement multi attack procs with a param to enable them.
|
|
-- Need research to see if bloodpacts/pets skills can multi attack.
|
|
-- If so, do they carry fTP over from the first hit into subsequent hits?
|
|
|
|
for hitNumber = 1, params.numHits do
|
|
local hitInfo = nil
|
|
local hitChance = 0
|
|
local shadowsConsumed = 0
|
|
local hitAbsorbed = false
|
|
local attackAnticipated = false
|
|
local attackYaegasumi = false
|
|
|
|
----------------------------------
|
|
-- Handle Utsusemi and Blink
|
|
----------------------------------
|
|
hitAbsorbed, shadowsConsumed = xi.mobskills.handleShadowConsumption(target, skill, params, params.shadowBehavior)
|
|
|
|
----------------------------------
|
|
-- Calculate Hit Rate
|
|
----------------------------------
|
|
if
|
|
target:hasStatusEffect(xi.effect.PERFECT_DODGE) or
|
|
target:hasStatusEffect(xi.effect.ALL_MISS)
|
|
then
|
|
hitChance = 0
|
|
elseif params.guaranteedFirstHit and hitNumber == 1 then
|
|
hitChance = 1
|
|
elseif hitNumber == 1 then
|
|
-- First hit gets bonus accuracy. TODO: Confirm this and the amount if true.
|
|
hitChance = xi.combat.physicalHitRate.getPhysicalHitRate(mob, target, 100 + accuracyModifier + targetSpecialAttackEvasion, xi.attackAnimation.RIGHT_ATTACK, false)
|
|
else
|
|
hitChance = xi.combat.physicalHitRate.getPhysicalHitRate(mob, target, accuracyModifier + targetSpecialAttackEvasion, xi.attackAnimation.RIGHT_ATTACK, false)
|
|
end
|
|
|
|
if not hitInfo then
|
|
-- If the skill did not penetrate and deal damage through the target's shadows, record hit as absorbed.
|
|
if hitAbsorbed then
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitAbsorbed = true
|
|
hitInfo.missType = 'Shadow'
|
|
hitInfo.shadowsConsumed = shadowsConsumed or 0
|
|
elseif
|
|
not params.skipYaegasumi and
|
|
target:hasStatusEffect(xi.effect.YAEGASUMI)
|
|
-- TODO: Fully implement mechanics of this ability (TP Return to target evading, WS damage bonus)
|
|
-- TODO: How does this interact with shadows/third eye? Do they overwrite? If they coexist, which takes priority?
|
|
then
|
|
attackYaegasumi = true -- TODO: Assuming this acts like Third Eye for now in that it blocks all hits.
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitYaegasumi = true
|
|
hitInfo.missType = 'Yaegasumi Evade'
|
|
elseif xi.combat.physicalHitRate.checkAnticipated(mob, target) then
|
|
attackAnticipated = true -- We use this below to break the attack loop since Third Eye blocks the whole skill.
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.hitAnticipated = true
|
|
hitInfo.missType = 'Anticipated'
|
|
elseif math.random(1, 100) <= hitChance * 100 then
|
|
hitParams.hitNumber = hitNumber
|
|
|
|
local damageForThisHit = (hitNumber == 1) and baseDamage or subsequentDamage
|
|
|
|
hitInfo = handleSinglePhysicalHit(mob, target, damageForThisHit, hitParams)
|
|
|
|
hitInfo.shadowsConsumed = shadowsConsumed
|
|
else
|
|
hitInfo = defaultHitInfo(hitNumber)
|
|
hitInfo.missType = 'Evaded / Missed'
|
|
end
|
|
end
|
|
|
|
-- Debugging
|
|
if not hitInfo then
|
|
error('hitInfo was never assigned for hit #' .. tostring(hitNumber))
|
|
end
|
|
|
|
-- Record the individual hit into hitData table.
|
|
table.insert(returnInfo.hitData, hitInfo)
|
|
|
|
-- Third Eye treats multi hit attacks as a single hit.
|
|
-- Exit early if there are remaining hits after the anticipated hit.
|
|
if
|
|
hitAbsorbed or
|
|
attackAnticipated or
|
|
attackYaegasumi
|
|
then
|
|
break
|
|
end
|
|
end
|
|
|
|
----------------------------------
|
|
-- Tally All Hit Results
|
|
----------------------------------
|
|
local totalDamage, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, anyCrit = tallyHitResults(returnInfo.hitData)
|
|
|
|
----------------------------------
|
|
-- Handle Automaton Analyzer Attachment
|
|
----------------------------------
|
|
totalDamage = math.floor(utils.handleAutomatonAutoAnalyzer(target, skill, totalDamage))
|
|
|
|
----------------------------------
|
|
-- Handle Hybrid Skill Magic Damage
|
|
----------------------------------
|
|
local magicDamage = 0
|
|
|
|
-- TODO: Need more research on hybrid skills. Is the magical damage strictly reliant on the physical hit doing damage?
|
|
-- How does xi.mod.MAGIC_DAMAGE and MAB interact with this?
|
|
if params.hybridSkill then
|
|
magicDamage = xi.mobskills.handleHybridDamage(mob, target, totalDamage, params.hybridSkillElement)
|
|
end
|
|
|
|
----------------------------------
|
|
-- Handle Miss Messaging
|
|
----------------------------------
|
|
totalDamage = resolveMissMessage(skill, hitsLanded, hitsYaegasumi, hitsAnticipated, hitsAbsorbed, shadowsAbsorbed, params.primaryMessage, totalDamage)
|
|
|
|
----------------------------------
|
|
-- Handle TP Returns
|
|
----------------------------------
|
|
xi.mobskills.calculateSkillTPReturn(totalDamage, mob, skill, target, params.attackType, hitsLanded)
|
|
|
|
returnInfo.damage = totalDamage
|
|
returnInfo.hybridDamage = magicDamage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
returnInfo.isCritical = anyCrit
|
|
|
|
skill:setAttackType(xi.attackType.PHYSICAL)
|
|
skill:setCritical(anyCrit)
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Documentation: xi.mobskills.mobMagicalMove
|
|
-- params.baseDamage = #: Sets the skill's baseDamage.
|
|
-- params.additiveDamage = { #, #, # }: Bonus damage added after base damage multipliers. Linear scaling based on fTP.
|
|
-- params.fTP = { #, #, # }: Linear baseDamage multiplier
|
|
-- params.fTPBonus = #: Acts the same as TP_BONUS for players. Directly adds to the TP value when the skill is used.
|
|
-- params.element = element enum: Element of attack
|
|
-- params.attackType = attackType enum: The attack type of the skill
|
|
-- params.damageType = damageType enum: The damage type of the skill
|
|
-- params.shadowBehavior = How many shadows this skill consumes per hit.
|
|
-- params.mATTBonus = { #, #, # }: Flat MACC bonus/penalty (Integer)
|
|
-- params.mACCBonus = { #, #, # }: Flat MATT bonus/penalty (Integer)
|
|
-- params.skipDamageAdjustment = boolean: Ignores Target Damage Adjustment calculations.
|
|
-- params.skipMagicBonusDiff = boolean: Ignores MDB step
|
|
-- params.skipStoneSkin = boolean: skips stoneskin calculation.
|
|
-- params.resistTierOverride = float: Forces a specific resist tier.
|
|
-- params.str_wSC = float: % of STR stat added to baseDamage of skill.
|
|
-- params.dex_wSC = float: % of DEX stat added to baseDamage of skill.
|
|
-- params.vit_wSC = float: % of VIT stat added to baseDamage of skill.
|
|
-- params.agi_wSC = float: % of AGI stat added to baseDamage of skill.
|
|
-- params.int_wSC = float: % of INT stat added to baseDamage of skill.
|
|
-- params.mnd_wSC = float: % of MND stat added to baseDamage of skill.
|
|
-- params.chr_wSC = float: % of CHR stat added to baseDamage of skill.
|
|
-- params.dStatMultiplier = float: Multiplier used in dStat calculations.
|
|
-- params.dStatAttackerMod = xi.mod.<STAT ATTRIBUTE>: Defines which of the attacker's stats is used when calculating dStat.
|
|
-- params.dStatDefenderMod = xi.mod.<STAT ATTRIBUTE>: Defines which of the defender's stats is used when calculating dStat.
|
|
-- params.canMagicBurst = boolean: Determines if the skill is allowed to magic burst.
|
|
-- params.primaryMessage = xi.msg enum: Sets the default message of the skill.
|
|
-----------------------------------
|
|
---@alias magicalMobSkillRetVal { damage: number, hitsLanded: number, attackType: xi.attackType, damageType: xi.damageType }
|
|
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param action CAction
|
|
---@param skillParams table
|
|
---@return magicalMobSkillRetVal
|
|
xi.mobskills.mobMagicalMove = function(mob, target, skill, action, skillParams)
|
|
local returnInfo = {}
|
|
|
|
-- Setup Params used in mobskill's lua. Set default values if a Param is nil.
|
|
local damage = utils.defaultIfNil(skillParams.baseDamage, mob:getMainLvl() + 2)
|
|
local additiveDamage = utils.defaultIfNil(skillParams.additiveDamage, { 0, 0, 0 })
|
|
local fTPScale = utils.defaultIfNil(skillParams.fTP, { 1.00, 1.00, 1.00 })
|
|
local fTPBonus = utils.defaultIfNil(skillParams.fTPBonus, 0)
|
|
local actionElement = utils.defaultIfNil(skillParams.element, 0)
|
|
local attackType = utils.defaultIfNil(skillParams.attackType, xi.attackType.MAGICAL)
|
|
local damageType = utils.defaultIfNil(skillParams.damageType, xi.damageType.ELEMENTAL)
|
|
local shadowsToRemove = utils.defaultIfNil(skillParams.shadowBehavior, xi.mobskills.shadowBehavior.NUMSHADOWS_1)
|
|
local mATTBonusfTP = utils.defaultIfNil(skillParams.mATTBonus, { 0, 0, 0 })
|
|
local mACCBonusfTP = utils.defaultIfNil(skillParams.mACCBonus, { 0, 0, 0 })
|
|
local skipDamageAdjustment = utils.defaultIfNil(skillParams.skipDamageAdjustment and true, false)
|
|
local skipMagicBonusDiff = utils.defaultIfNil(skillParams.skipMagicBonusDiff and true, false)
|
|
local skipStoneskin = utils.defaultIfNil(skillParams.skipStoneSkin and true, false)
|
|
-- TODO: handle different types of Stoneskin(Magical, Physical, Agnostic)
|
|
local resistTierOverride = utils.defaultIfNil(skillParams.resistTierOverride, 0)
|
|
local dStatMultiplier = utils.defaultIfNil(skillParams.dStatMultiplier, 0)
|
|
local dStatAttackerMod = utils.defaultIfNil(skillParams.dStatAttackerMod, xi.mod.INT)
|
|
local dStatDefenderMod = utils.defaultIfNil(skillParams.dStatDefenderMod, xi.mod.INT)
|
|
local canMagicBurst = utils.defaultIfNil(skillParams.canMagicBurst and true, false)
|
|
local primaryMessage = utils.defaultIfNil(skillParams.primaryMessage, xi.msg.basic.DAMAGE)
|
|
|
|
-- If a stat_wSC is not specified in skill script, it will default to 0. (Sanitized in xi.combat.physical.calculateWSC)
|
|
local strWSC = skillParams.str_wSC
|
|
local dexWSC = skillParams.dex_wSC
|
|
local vitWSC = skillParams.vit_wSC
|
|
local agiWSC = skillParams.agi_wSC
|
|
local intWSC = skillParams.int_wSC
|
|
local mndWSC = skillParams.mnd_wSC
|
|
local chrWSC = skillParams.chr_wSC
|
|
|
|
-- Initialize returnInfo params
|
|
returnInfo.damage = 0
|
|
returnInfo.hitsLanded = 0
|
|
returnInfo.attackType = attackType
|
|
returnInfo.damageType = damageType
|
|
|
|
-- Set skill's default message.
|
|
skill:setMsg(primaryMessage)
|
|
|
|
if mob:hasStatusEffect(xi.effect.HYSTERIA) then
|
|
skill:setMsg(xi.msg.basic.NONE)
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
----------------------------------
|
|
-- Calculate Base Damage
|
|
----------------------------------
|
|
local wscMods = xi.combat.physical.calculateWSC(mob, strWSC, dexWSC, vitWSC, agiWSC, intWSC, mndWSC, chrWSC)
|
|
|
|
-- TODO: Do mobs benefit from Fencer job trait's TP_BONUS?
|
|
-- Best way to test will likely be to find a mob that uses a magical skill with fTP scaling and has varying jobs to compare (WAR 45 min for Fencer, 80 BST, 85 BRD).
|
|
local bonusTP = mob:getMod(xi.mod.TP_BONUS) + fTPBonus
|
|
local tpValue = math.min(skill:getTP() + bonusTP, 3000)
|
|
local baseDamagefTPMult = xi.combat.physical.calculateTPfactor(tpValue, fTPScale)
|
|
local additiveBonusDamage = math.floor(xi.combat.physical.calculateTPfactor(tpValue, additiveDamage))
|
|
|
|
-- dStat Multiplier is usually 1, 1.5, 2 depending on skill.
|
|
-- Negative dStat subtracts 0.5 from the multiplier.
|
|
-- https://docs.google.com/spreadsheets/d/1YBoveP-weMdidrirY-vPDzHyxbEI2ryECINlfCnFkLI/edit?pli=1&gid=57955395#gid=57955395&range=D8
|
|
local dStat = 0
|
|
|
|
if skillParams.dStatMultiplier then
|
|
dStat = mob:getStat(dStatAttackerMod) - target:getStat(dStatDefenderMod)
|
|
|
|
if not mob:isAvatar() then
|
|
-- TODO: Does this apply to jug pets and avatars?
|
|
if dStat < 0 then
|
|
dStatMultiplier = dStatMultiplier - 0.5
|
|
|
|
if dStatMultiplier < 1 then
|
|
dStat = -1
|
|
end
|
|
end
|
|
end
|
|
|
|
dStat = math.floor(dStat * dStatMultiplier)
|
|
dStat = utils.clamp(dStat, -65, 999)
|
|
end
|
|
|
|
damage = math.floor((damage + wscMods + mob:getMod(xi.mod.MAGIC_DAMAGE)) * baseDamagefTPMult + dStat + additiveBonusDamage)
|
|
damage = math.max(0, damage)
|
|
|
|
local hitsLanded = 1 -- Magic skills can't miss in the same way as physical skills so assume 1 hit landed for calculations.
|
|
local hitAbsorbed = false
|
|
local shadowsConsumed = 0
|
|
|
|
-- TODO: SAM Yaegasumi ability.
|
|
|
|
hitAbsorbed, shadowsConsumed = xi.mobskills.handleShadowConsumption(target, skill, skillParams, shadowsToRemove)
|
|
|
|
if hitAbsorbed then
|
|
skill:setMsg(xi.msg.basic.SHADOW_ABSORB)
|
|
|
|
returnInfo.damage = shadowsConsumed
|
|
returnInfo.hitsLanded = 0
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
-- Calculate if skill will be absorbed or nullified.
|
|
local absorbDamage = 1
|
|
local nullifyDamage = 1
|
|
|
|
if attackType == xi.attackType.BREATH then
|
|
nullifyDamage = xi.spells.damage.calculateNullification(target, actionElement, false, true)
|
|
else
|
|
nullifyDamage = xi.spells.damage.calculateNullification(target, actionElement, true, false)
|
|
end
|
|
|
|
if nullifyDamage == 0 then
|
|
-- Note: Nullification takes precedence over elemental absorption.
|
|
-- Note: We still count nullifies as a "hit" since additional status effects tied to the skill itself will still apply.
|
|
returnInfo.damage = 0
|
|
returnInfo.hitsLanded = hitsLanded
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
if attackType == xi.attackType.BREATH then
|
|
absorbDamage = xi.spells.damage.calculateAbsorption(target, actionElement, false)
|
|
else
|
|
absorbDamage = xi.spells.damage.calculateAbsorption(target, actionElement, true)
|
|
end
|
|
|
|
----------------------------------
|
|
-- Calculate MACC/Resists/Damage Adjustments
|
|
----------------------------------
|
|
local mAccuracyBonus = 0
|
|
local mAttackBonus = 0
|
|
|
|
-- Flat MACC bonus based on fTP scale
|
|
mAccuracyBonus = xi.combat.physical.calculateTPfactor(tpValue, mACCBonusfTP)
|
|
|
|
-- Flat MATT bonus based on fTP scale
|
|
mAttackBonus = xi.combat.physical.calculateTPfactor(tpValue, mATTBonusfTP)
|
|
|
|
-- Calculate bonus magic accuracy for pets
|
|
local petAccuracyBonus = xi.mobskills.calculatePetMagicAccuracyBonus(mob, target, actionElement)
|
|
|
|
-- Add all magic accuracy values together.
|
|
mAccuracyBonus = mAccuracyBonus + petAccuracyBonus
|
|
|
|
-- Damage Multipliers.
|
|
local sdt = xi.combat.damage.magicalElementSDT(target, actionElement)
|
|
local resistTier = 1
|
|
local dayAndWeather = xi.spells.damage.calculateDayAndWeather(mob, actionElement, false)
|
|
local steamJacketMultiplier = xi.combat.damage.steamJacketMultiplier(target, actionElement)
|
|
local magicBonusDiff = 1
|
|
local magicDamageAdjustment = 1
|
|
local bloodPactMultiplier = 1
|
|
local magicBurst = 1
|
|
local magicBurstBonus = 1
|
|
|
|
-- If skill was not absorbed, calculate resist and damage adjustments.
|
|
-- Note: Elemental absorb mechanics such as Liement are calculated BEFORE resist/damage adjustments (such as shell/magic bursts).
|
|
if absorbDamage > 0 then
|
|
resistTier = xi.combat.magicHitRate.calculateResistRate(mob, target, 0, 0, 0, actionElement, dStatAttackerMod, 0, mAccuracyBonus)
|
|
|
|
if mob:isAvatar() then
|
|
bloodPactMultiplier = 1 + mob:getMod(xi.mod.BP_DAMAGE) / 100
|
|
end
|
|
|
|
if
|
|
not skipDamageAdjustment and
|
|
attackType == xi.attackType.BREATH
|
|
then
|
|
-- Damage Adjustment for breath damage
|
|
magicDamageAdjustment = xi.combat.damage.calculateDamageAdjustment(target, false, false, false, true)
|
|
elseif not skipDamageAdjustment then
|
|
-- Damage Adjustment for Magical damage.
|
|
magicDamageAdjustment = xi.combat.damage.calculateDamageAdjustment(target, false, true, false, false)
|
|
end
|
|
|
|
if canMagicBurst then
|
|
local _, skillchainCount = xi.magicburst.formMagicBurst(target, actionElement)
|
|
|
|
if skillchainCount > 0 then
|
|
-- TODO: Glyphic Bracers magic burst modifiers. https://www.bg-wiki.com/ffxi/Glyphic_Bracers
|
|
magicBurst = calculateMobMagicBurst(target, actionElement, skillchainCount)
|
|
magicBurstBonus = xi.spells.damage.calculateIfMagicBurstBonus(mob, target, 0, 0, actionElement)
|
|
|
|
-- TODO: petskills currently seem to be searching for a mobskillID rather than the petskill ID which causes the magic burst to display the wrong message. Use JA_MAGIC_BURST for now.
|
|
-- skill:setMsg(xi.msg.basic.PET_MAGIC_BURST)
|
|
skill:setMsg(xi.msg.basic.JA_MAGIC_BURST)
|
|
end
|
|
end
|
|
end
|
|
|
|
if not skipMagicBonusDiff then
|
|
magicBonusDiff = xi.spells.damage.calculateMagicBonusDiff(mob, target, 0, 0, actionElement, mAttackBonus)
|
|
end
|
|
|
|
-- Force a resist tier if defined.
|
|
if skillParams.resistTierOverride then
|
|
resistTier = resistTierOverride
|
|
end
|
|
|
|
damage = math.floor(damage * sdt)
|
|
damage = math.floor(damage * resistTier)
|
|
damage = math.floor(damage * dayAndWeather)
|
|
damage = math.floor(damage * steamJacketMultiplier)
|
|
damage = math.floor(damage * magicBonusDiff)
|
|
damage = math.floor(damage * magicDamageAdjustment)
|
|
damage = math.floor(damage * bloodPactMultiplier)
|
|
damage = math.floor(damage * absorbDamage)
|
|
damage = math.floor(damage * magicBurst)
|
|
damage = math.floor(damage * magicBurstBonus)
|
|
|
|
-- If we absorbed, then return early as the rest is not needed.
|
|
if absorbDamage < 0 then
|
|
-- Messaging is handled in core. Returning a negative damage value automatically sets the absorb message.
|
|
-- Note: We still count absorbs as a "hit" since additional status effects tied to the skill itself will still apply even if absorbed.
|
|
returnInfo.damage = damage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
damage = math.floor(target:handleSevereDamage(damage, false))
|
|
damage = math.floor(target:checkDamageCap(damage))
|
|
damage = math.floor(utils.handleAutomatonAutoAnalyzer(target, skill, damage))
|
|
damage = utils.handlePhalanx(target, damage)
|
|
damage = utils.handleOneForAll(target, damage)
|
|
|
|
if not skipStoneskin then
|
|
-- TODO: Some Stoneskin effects only absorb certain damage types.
|
|
damage = utils.handleStoneskin(target, damage)
|
|
end
|
|
|
|
target:handleAfflatusMiseryDamage(damage)
|
|
|
|
-- Calculate TP return of the mob skill.
|
|
xi.mobskills.calculateSkillTPReturn(damage, mob, skill, target, attackType, hitsLanded)
|
|
|
|
returnInfo.damage = damage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Documentation: xi.mobskills.mobBreathMove
|
|
-- params.percentMultipier = # : % Multiplier on mob's current HP. Damage = mobCurrentHP * percentMultipier
|
|
-- params.damageCap = # : Maximum damage the skill can deal regardless of current HP.
|
|
-- params.bonusDamage = # : Flat damage added after multipliers.
|
|
-- params.element = element enum : Element of breath attack. Default: 1
|
|
-- params.attackType = attackType enum : attackType of the skill.
|
|
-- params.damageType = damageType enum : damageType of the skill.
|
|
-- params.shadowBehavior = shadowBehavior enum : How many shadows the skill will consume.
|
|
-- params.skipDamageAdjustment = bool : Determines whether to skip damage taken % modifiers (Shell etc.)
|
|
-- params.skipStoneSkin = bool : Determines whether the skill should bypass stoneskin.
|
|
-- params.mAccuracyBonus = { #, #, # } : Flat magic accuracy bonus or penalties based on fTP scale.
|
|
-- params.resistStat = xi.mod.<Stat> : Determines which base stat attribute is used when calculating resist. (INT, MND, etc.)
|
|
-- params.canMagicBurst = bool : Determines if the skill can perform a magic burst.
|
|
-- params.primaryMessage = msg enum : Sets the default message of the skill.
|
|
-----------------------------------
|
|
-- return value of xi.mobskills.mobBreathMove
|
|
---@alias breathMobSkillRetVal { damage: number, hitsLanded: number, attackType: xi.attackType, damageType: xi.damageType }
|
|
|
|
---@param mob CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param action CAction
|
|
---@param skillParams table
|
|
---@return breathMobSkillRetVal
|
|
xi.mobskills.mobBreathMove = function(mob, target, skill, action, skillParams)
|
|
local returnInfo = {}
|
|
|
|
local mobCurrentHP = skill:getMobHP()
|
|
|
|
local percentMultipier = skillParams.percentMultipier or 0.10
|
|
local breathSkillDamageCap = skillParams.damageCap or math.floor(mobCurrentHP / 5)
|
|
local bonusDamage = skillParams.bonusDamage or 0
|
|
local mAccuracyBonusfTP = skillParams.mAccuracyBonus or { 0, 0, 0 }
|
|
local actionElement = skillParams.element or 0
|
|
local attackType = skillParams.attackType or xi.attackType.BREATH
|
|
local damageType = skillParams.damageType or xi.damageType.ELEMENTAL
|
|
local shadowsToRemove = skillParams.shadowBehavior or xi.mobskills.shadowBehavior.IGNORE_SHADOWS
|
|
-- Note: Breath skills so far have not been found to use MAB/MDEF. If we find an outlier, add here.
|
|
local skipStoneskin = skillParams.skipStoneSkin and true or false
|
|
-- TODO: handle different types of Stoneskin(Magical, Physical, Agnostic)
|
|
local resistStat = skillParams.resistStat or xi.mod.INT
|
|
local canMagicBurst = skillParams.canMagicBurst and true or false
|
|
local primaryMessage = skillParams.primaryMessage or xi.msg.basic.DAMAGE
|
|
-- TODO: Critical Hit Param? See Magma Fan mobskill.
|
|
|
|
-- Initialize default returnInfo returns
|
|
returnInfo.damage = 0
|
|
returnInfo.hitsLanded = 0
|
|
returnInfo.attackType = attackType
|
|
returnInfo.damageType = damageType
|
|
|
|
-- Set skill's default message.
|
|
skill:setMsg(primaryMessage)
|
|
|
|
if mob:hasStatusEffect(xi.effect.HYSTERIA) then
|
|
skill:setMsg(xi.msg.basic.NONE)
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
-- Calculate base damage
|
|
local damage = math.floor(mobCurrentHP * percentMultipier + bonusDamage)
|
|
local hitsLanded = 1
|
|
local hitAbsorbed = false
|
|
local shadowsConsumed = 0
|
|
|
|
-- TODO: SAM Yaegasumi ability.
|
|
|
|
hitAbsorbed, shadowsConsumed = xi.mobskills.handleShadowConsumption(target, skill, skillParams, shadowsToRemove)
|
|
|
|
if hitAbsorbed then
|
|
skill:setMsg(xi.msg.basic.SHADOW_ABSORB)
|
|
|
|
returnInfo.damage = shadowsConsumed
|
|
returnInfo.hitsLanded = 0
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
-- Calculate if skill will be absorbed or nullified.
|
|
local absorbDamage = 1
|
|
local nullifyDamage = 1
|
|
|
|
nullifyDamage = xi.spells.damage.calculateNullification(target, actionElement, false, true)
|
|
|
|
if nullifyDamage == 0 then
|
|
-- Note: Nullification takes precedence over elemental absorption.
|
|
-- Note: We still count nullifies as a "hit" since additional status effects tied to the skill itself will still apply.
|
|
returnInfo.damage = 0
|
|
returnInfo.hitsLanded = hitsLanded
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
absorbDamage = xi.spells.damage.calculateAbsorption(target, actionElement, false)
|
|
|
|
-- Calulate TP and TP_BONUS if applicable.
|
|
-- TODO: Do mobs benefit from Fencer job trait's TP_BONUS?
|
|
-- Best way to test will likely be to find a mob that uses a magical skill with fTP scaling and has varying jobs to compare (WAR 45 min for Fencer, 80 BST, 85 BRD).
|
|
local bonusTP = mob:getMod(xi.mod.TP_BONUS)
|
|
local tpValue = math.min(skill:getTP() + bonusTP, 3000)
|
|
|
|
-- Flat MACC bonus/penalty based on fTP scale if defined.
|
|
local mAccuracyBonus = 0
|
|
|
|
mAccuracyBonus = xi.combat.physical.calculateTPfactor(tpValue, mAccuracyBonusfTP)
|
|
|
|
-- Damage Multipliers
|
|
local systemBonus = 1 -- 1 + utils.getEcosystemStrengthBonus(mob:getEcosystem(), target:getEcosystem()) / 4
|
|
local elementalSDT = xi.combat.damage.magicalElementSDT(target, actionElement)
|
|
local resistRate = 1
|
|
local dayAndWeather = xi.spells.damage.calculateDayAndWeather(mob, actionElement, false)
|
|
local steamJacketMultiplier = xi.combat.damage.steamJacketMultiplier(target, actionElement)
|
|
local breathDamageAdjustment = 1
|
|
local magicBurst = 1
|
|
local magicBurstBonus = 1
|
|
|
|
-- If skill was not absorbed, calculate resist and damage adjustments.
|
|
-- Note: Elemental absorb mechanics such as Liement are calculated BEFORE resist/damage adjustments (such as shell/magic bursts).
|
|
if absorbDamage > 0 then
|
|
if canMagicBurst then
|
|
local _, skillchainCount = xi.magicburst.formMagicBurst(target, actionElement)
|
|
|
|
if skillchainCount > 0 then
|
|
if mob:isPet() and mob:getMaster() ~= nil then
|
|
mAccuracyBonus = mAccuracyBonus + 25 -- TODO: This is based off a previous function. Would eventually like to get a capture for this.
|
|
|
|
-- TODO: Do jug pet breaths gain damage or only an accuracy bonus?
|
|
-- magicBurst = calculateMobMagicBurst(target, actionElement, skillchainCount)
|
|
-- magicBurstBonus = xi.spells.damage.calculateIfMagicBurstBonus(mob, target, 0, 0, actionElement)
|
|
|
|
skill:setMsg(xi.msg.basic.PET_MAGIC_BURST)
|
|
end
|
|
end
|
|
end
|
|
|
|
resistRate = xi.combat.magicHitRate.calculateResistRate(mob, target, 0, 0, xi.skillRank.A_PLUS, actionElement, resistStat, 0, mAccuracyBonus)
|
|
breathDamageAdjustment = xi.combat.damage.calculateDamageAdjustment(target, false, false, false, true)
|
|
end
|
|
|
|
-- TODO: Need more research about monster correlation.
|
|
-- local systemBonus = 1 + utils.getEcosystemStrengthBonus(mob:getEcosystem(), target:getEcosystem()) / 4
|
|
|
|
damage = math.floor(damage * systemBonus)
|
|
damage = math.floor(damage * elementalSDT)
|
|
damage = math.floor(damage * resistRate)
|
|
damage = math.floor(damage * dayAndWeather)
|
|
damage = math.floor(damage * steamJacketMultiplier)
|
|
damage = math.floor(damage * breathDamageAdjustment)
|
|
damage = math.floor(damage * absorbDamage)
|
|
damage = math.floor(damage * magicBurst)
|
|
damage = math.floor(damage * magicBurstBonus)
|
|
|
|
damage = utils.clamp(damage, 0, breathSkillDamageCap)
|
|
|
|
-- If we absorbed, then return early as the rest is not needed.
|
|
if absorbDamage < 0 then
|
|
-- Messaging is handled in core. Returning a negative damage value automatically sets the absorb message.
|
|
|
|
returnInfo.damage = damage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
-- Note: We still count absorbs as a "hit" since additional status effects tied to the skill itself will still apply even if absorbed.
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
damage = math.floor(target:handleSevereDamage(damage, false))
|
|
damage = math.floor(target:checkDamageCap(damage))
|
|
damage = math.floor(utils.handleAutomatonAutoAnalyzer(target, skill, damage))
|
|
damage = utils.handlePhalanx(target, damage)
|
|
damage = utils.handleOneForAll(target, damage)
|
|
|
|
if not skipStoneskin then
|
|
-- TODO: Some Stoneskin effects only absorb certain damage types.
|
|
damage = utils.handleStoneskin(target, damage)
|
|
end
|
|
|
|
target:handleAfflatusMiseryDamage(damage)
|
|
|
|
-- Calculate TP return of the mob skill.
|
|
xi.mobskills.calculateSkillTPReturn(damage, mob, skill, target, attackType, hitsLanded)
|
|
|
|
returnInfo.damage = damage
|
|
returnInfo.hitsLanded = hitsLanded
|
|
|
|
return returnInfo
|
|
end
|
|
|
|
xi.mobskills.calculateSkillTPReturn = function(damage, mob, skill, target, attackType, hitsLanded)
|
|
-- Calculate TP return of the mob skill.
|
|
if
|
|
hitsLanded > 0 and
|
|
damage > 0
|
|
then
|
|
local mobTPReturn = 0
|
|
local targetTPReturn = 0
|
|
|
|
if attackType == xi.attackType.PHYSICAL then
|
|
mobTPReturn = xi.combat.tp.getSingleMeleeHitTPReturn(mob, false)
|
|
targetTPReturn = xi.combat.tp.calculateTPGainOnPhysicalDamage(mob, target, damage, mob:getBaseDelay())
|
|
elseif attackType == xi.attackType.RANGED then
|
|
mobTPReturn = xi.combat.tp.getSingleRangedHitTPReturn(mob)
|
|
targetTPReturn = xi.combat.tp.calculateTPGainOnPhysicalDamage(mob, target, damage, mob:getBaseRangedDelay())
|
|
elseif
|
|
attackType == xi.attackType.BREATH or
|
|
attackType == xi.attackType.MAGICAL
|
|
then
|
|
mobTPReturn = xi.combat.tp.getSingleMeleeHitTPReturn(mob, false)
|
|
targetTPReturn = xi.combat.tp.calculateTPGainOnPhysicalDamage(mob, target, damage, mob:getBaseDelay())
|
|
end
|
|
|
|
-- Handle additional hit TP return for mob.
|
|
mobTPReturn = mobTPReturn + 10 * (hitsLanded - 1) -- Extra hits give 10 TP each
|
|
|
|
-- Mob gains TP if skill hit the primary target.
|
|
if skill:getPrimaryTargetID() == target:getID() then
|
|
mob:addTP(mobTPReturn)
|
|
end
|
|
|
|
-- Targets hit gain TP
|
|
target:addTP(targetTPReturn)
|
|
|
|
local saveTPModifier = mob:getMod(xi.mod.SAVETP)
|
|
if
|
|
saveTPModifier > 0 and
|
|
mob:getTP() < saveTPModifier
|
|
then
|
|
mob:setTP(saveTPModifier)
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param actor CBaseEntity
|
|
---@param target CBaseEntity
|
|
---@param skill CPetSkill|CMobSkill
|
|
---@param action CAction
|
|
---@param info table
|
|
---@return boolean
|
|
-- Used as a conditional filter for target:takeDamage so the target doesn't take chip damage through shadows.
|
|
xi.mobskills.processDamage = function(actor, target, skill, action, info)
|
|
if info.hitsLanded > 0 then
|
|
target:updateEnmityFromDamage(actor, info.damage)
|
|
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
xi.mobskills.mobDrainMove = function(mob, target, drainType, drain, attackType, damageType)
|
|
-- TODO: We clamp the drain in this function so the drain can not be more than what the target has.
|
|
-- Is this also reflected in the damage messaging on retail?
|
|
-- Currently we do not return the clamped drain afterwards so the damage messaging will not be updated to reflect this.
|
|
|
|
if not target:isUndead() then
|
|
if drainType == xi.mobskills.drainType.MP then
|
|
drain = math.min(drain, target:getMP())
|
|
|
|
target:delMP(drain)
|
|
mob:addMP(drain)
|
|
|
|
return xi.msg.basic.SKILL_DRAIN_MP
|
|
elseif drainType == xi.mobskills.drainType.TP then
|
|
drain = math.min(drain, target:getTP())
|
|
|
|
target:delTP(drain)
|
|
mob:addTP(drain)
|
|
|
|
return xi.msg.basic.SKILL_DRAIN_TP
|
|
elseif drainType == xi.mobskills.drainType.HP then
|
|
drain = math.min(drain, target:getHP())
|
|
|
|
target:takeDamage(drain, mob, attackType, damageType)
|
|
mob:addHP(drain)
|
|
|
|
return xi.msg.basic.SKILL_DRAIN_HP
|
|
end
|
|
else
|
|
drain = math.min(drain, target:getHP())
|
|
|
|
target:takeDamage(drain, mob, attackType, damageType)
|
|
return xi.msg.basic.DAMAGE
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_NO_EFFECT
|
|
end
|
|
|
|
local drainEffectCorrelation =
|
|
{
|
|
[xi.effect.STR_DOWN] = xi.effect.STR_BOOST,
|
|
[xi.effect.DEX_DOWN] = xi.effect.DEX_BOOST,
|
|
[xi.effect.AGI_DOWN] = xi.effect.AGI_BOOST,
|
|
[xi.effect.VIT_DOWN] = xi.effect.VIT_BOOST,
|
|
[xi.effect.MND_DOWN] = xi.effect.MND_BOOST,
|
|
[xi.effect.INT_DOWN] = xi.effect.INT_BOOST,
|
|
[xi.effect.CHR_DOWN] = xi.effect.CHR_BOOST,
|
|
}
|
|
|
|
xi.mobskills.mobDrainAttribute = function(mob, target, typeEffect, power, tick, duration)
|
|
if not drainEffectCorrelation[typeEffect] then
|
|
return xi.msg.basic.SKILL_NO_EFFECT
|
|
end
|
|
|
|
local results = xi.mobskills.mobStatusEffectMove(mob, target, typeEffect, power, tick, duration)
|
|
|
|
if results == xi.msg.basic.SKILL_ENFEEB_IS then
|
|
mob:addStatusEffect(drainEffectCorrelation[typeEffect], { power = power, duration = duration, origin = mob, tick = tick })
|
|
|
|
return xi.msg.basic.ATTR_DRAINED
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_MISS
|
|
end
|
|
|
|
xi.mobskills.mobDrainStatusEffectMove = function(mob, target)
|
|
-- If target has Hysteria, no message skip rest
|
|
if mob:hasStatusEffect(xi.effect.HYSTERIA) then
|
|
return xi.msg.basic.NONE
|
|
end
|
|
|
|
-- try to drain buff
|
|
local effect = mob:stealStatusEffect(target)
|
|
|
|
if effect ~= 0 then
|
|
return xi.msg.basic.EFFECT_DRAINED
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_NO_EFFECT
|
|
end
|
|
|
|
-- Adds a status effect to a target
|
|
xi.mobskills.mobStatusEffectMove = function(mob, target, typeEffect, power, tick, duration, subType, subPower, tier)
|
|
if target:canGainStatusEffect(typeEffect, power) then
|
|
-- Check immunity. TODO: We dont fetch elements.
|
|
if xi.data.statusEffect.isTargetImmune(target, typeEffect, xi.element.NONE) then
|
|
return xi.msg.basic.SKILL_MISS -- <user> uses <skill>, but misses <target>.
|
|
|
|
-- Check resist traits. TODO: We do not fetch action objects, so we cannot set action modifiers.
|
|
elseif xi.data.statusEffect.isTargetResistant(mob, target, typeEffect) then
|
|
-- action:setModifier(xi.msg.actionModifier.RESIST) -- Resist!
|
|
return xi.msg.basic.SKILL_MISS -- <user> uses <skill>, but misses <target>.
|
|
|
|
-- Check effect incompatibilities.
|
|
elseif xi.data.statusEffect.isEffectNullified(target, typeEffect, 0) then
|
|
return xi.msg.basic.SKILL_MISS -- <user> uses <skill>, but misses <target>.
|
|
end
|
|
|
|
local element = mob:getStatusEffectElement(typeEffect) -- TODO: Do something.
|
|
local resistRate = xi.combat.magicHitRate.calculateResistRate(mob, target, 0, 0, 0, element, xi.mod.INT, typeEffect, 0)
|
|
if resistRate >= 0.25 then
|
|
local totalDuration = math.floor(duration * resistRate)
|
|
target:addStatusEffect(typeEffect, { power = power, duration = totalDuration, origin = mob, tick = tick, subType = subType, subPower = subPower, tier = tier })
|
|
|
|
return xi.msg.basic.SKILL_ENFEEB_IS
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_MISS -- resist !
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_NO_EFFECT -- no effect
|
|
end
|
|
|
|
-- similar to statuseffect move except it will only take effect if facing
|
|
xi.mobskills.mobGazeMove = function(mob, target, typeEffect, power, tick, duration)
|
|
if
|
|
target:isFacing(mob) and
|
|
mob:isInfront(target)
|
|
then
|
|
return xi.mobskills.mobStatusEffectMove(mob, target, typeEffect, power, tick, duration)
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_NO_EFFECT
|
|
end
|
|
|
|
xi.mobskills.mobBuffMove = function(mob, typeEffect, power, tick, duration, subType, subPower)
|
|
if mob:addStatusEffect(typeEffect, { power = power, duration = duration, origin = mob, tick = tick, subType = subType, subPower = subPower }) then
|
|
return xi.msg.basic.SKILL_GAIN_EFFECT
|
|
end
|
|
|
|
return xi.msg.basic.SKILL_NO_EFFECT
|
|
end
|
|
|
|
xi.mobskills.mobHealMove = function(target, healAmount)
|
|
healAmount = math.min(healAmount, target:getMaxHP() - target:getHP())
|
|
|
|
target:wakeUp()
|
|
target:addHP(healAmount)
|
|
|
|
return healAmount
|
|
end
|
|
|
|
xi.mobskills.calculateDuration = function(tp, minimum, maximum)
|
|
if tp <= 1000 then
|
|
return minimum
|
|
end
|
|
|
|
return minimum + (maximum - minimum) * (tp - 1000) / 1000
|
|
end
|
|
|
|
-- Used for mobskills that remove player equipment.
|
|
-- Removes items from randomly chosen equipment slots.
|
|
---@param target CBaseEntity
|
|
---@param numberToUnequip number
|
|
xi.mobskills.unequipRandomSlots = function(target, numberToUnequip)
|
|
local slots = {}
|
|
|
|
-- Collect only slots that actually have something equipped
|
|
for i = xi.slot.MAIN, xi.slot.BACK do
|
|
local itemID = target:getEquipID(i)
|
|
if itemID and itemID ~= 0 then
|
|
table.insert(slots, i)
|
|
end
|
|
end
|
|
|
|
for _ = 1, math.min(numberToUnequip, #slots) do
|
|
local index = math.random(#slots)
|
|
target:unequipItem(table.remove(slots, index))
|
|
end
|
|
end
|
|
|
|
---@param target CBaseEntity
|
|
---@param skill CMobSkill|CPetSkill
|
|
---@param params table
|
|
---@param shadowsToRemove xi.mobskills.shadowBehavior | integer
|
|
xi.mobskills.handleShadowConsumption = function(target, skill, params, shadowsToRemove)
|
|
local shadowsConsumed = 0
|
|
local shadowsMitigated = 0
|
|
local hitAbsorbed = false
|
|
|
|
local isAoE = skill:isAoE()
|
|
local isConal = skill:isConal()
|
|
|
|
----------------------------------
|
|
-- Wipe all shadows
|
|
----------------------------------
|
|
if shadowsToRemove == xi.mobskills.shadowBehavior.WIPE_SHADOWS then
|
|
target:delStatusEffect(xi.effect.COPY_IMAGE)
|
|
target:delStatusEffect(xi.effect.BLINK)
|
|
|
|
-- Magical skills do not interact with Third Eye
|
|
if
|
|
params.attackType == xi.attackType.PHYSICAL or
|
|
params.attackType == xi.attackType.RANGED
|
|
then
|
|
target:delStatusEffect(xi.effect.THIRD_EYE)
|
|
end
|
|
end
|
|
|
|
----------------------------------
|
|
-- AoE physical skills remove Third Eye and Blink.
|
|
-- Magical skills do not interact with Third Eye.
|
|
----------------------------------
|
|
if
|
|
isAoE or
|
|
isConal
|
|
then
|
|
if
|
|
params.attackType == xi.attackType.PHYSICAL or
|
|
params.attackType == xi.attackType.RANGED
|
|
then
|
|
target:delStatusEffect(xi.effect.THIRD_EYE)
|
|
end
|
|
|
|
target:delStatusEffect(xi.effect.BLINK)
|
|
end
|
|
|
|
----------------------------------
|
|
-- Standard shadow handling
|
|
----------------------------------
|
|
if
|
|
shadowsToRemove ~= xi.mobskills.shadowBehavior.WIPE_SHADOWS and
|
|
shadowsToRemove ~= xi.mobskills.shadowBehavior.IGNORE_SHADOWS
|
|
then
|
|
local attemptedShadowRemoval = shadowsToRemove
|
|
|
|
if isAoE or isConal then
|
|
shadowsMitigated = utils.attemptShadowMitigation(target, attemptedShadowRemoval)
|
|
end
|
|
|
|
local finalRemoval = attemptedShadowRemoval - shadowsMitigated
|
|
|
|
hitAbsorbed, shadowsConsumed = utils.shadowAbsorb(target, finalRemoval)
|
|
end
|
|
|
|
return hitAbsorbed, shadowsConsumed
|
|
end
|
|
|
|
xi.mobskills.calculatePetMagicAccuracyBonus = function(mob, target, actionElement)
|
|
local petAccBonus = 0
|
|
|
|
if mob:isPet() and mob:getMaster() ~= nil then
|
|
local master = mob:getMaster()
|
|
|
|
if mob:isAvatar() then
|
|
local masterSkillLevel = master:getSkillLevel(xi.skill.SUMMONING_MAGIC)
|
|
local masterMaxSkillLevel = master:getMaxSkillLevel(mob:getMainLvl(), xi.job.SMN, xi.skill.SUMMONING_MAGIC)
|
|
|
|
petAccBonus = utils.clamp(masterSkillLevel - masterMaxSkillLevel, 0, 200)
|
|
end
|
|
|
|
local skillchainTier, _ = xi.magicburst.formMagicBurst(target, actionElement)
|
|
if
|
|
mob:getPetID() > 0 and
|
|
skillchainTier > 0
|
|
then
|
|
petAccBonus = petAccBonus + 25
|
|
end
|
|
end
|
|
|
|
return petAccBonus
|
|
end
|
|
|
|
xi.mobskills.handleHybridDamage = function(mob, target, physicalDamage, element)
|
|
local magicDamage = math.floor(physicalDamage)
|
|
|
|
-- Multipliers.
|
|
local nullifyDamage = xi.spells.damage.calculateNullification(target, element, true, false)
|
|
local absorbDamage = xi.spells.damage.calculateAbsorption(target, element, true)
|
|
local sdt = 1
|
|
local resist = 1
|
|
local magicDamageAdjustment = 1
|
|
local dayAndWeather = xi.spells.damage.calculateDayAndWeather(mob, element, false)
|
|
local magicBonusDiff = xi.spells.damage.calculateMagicBonusDiff(mob, target, 0, 0, element, 0)
|
|
local petAccBonus = xi.mobskills.calculatePetMagicAccuracyBonus(mob, target, element)
|
|
-- Note: Elemental absorb mechanics such as Liement are calculated BEFORE resist/damage adjustments (such as shell/magic bursts).
|
|
|
|
if absorbDamage > 0 then
|
|
sdt = xi.combat.damage.magicalElementSDT(target, element)
|
|
resist = xi.combat.magicHitRate.calculateResistRate(mob, target, 0, 0, 0, element, xi.mod.INT, 0, petAccBonus)
|
|
magicDamageAdjustment = xi.combat.damage.calculateDamageAdjustment(target, false, true, false, false)
|
|
end
|
|
|
|
-- Calculate final damage.
|
|
magicDamage = math.floor(magicDamage * sdt)
|
|
magicDamage = math.floor(magicDamage * resist)
|
|
magicDamage = math.floor(magicDamage * dayAndWeather)
|
|
magicDamage = math.floor(magicDamage * magicBonusDiff)
|
|
magicDamage = math.floor(magicDamage * magicDamageAdjustment)
|
|
magicDamage = math.floor(magicDamage * absorbDamage)
|
|
magicDamage = math.floor(magicDamage * nullifyDamage)
|
|
magicDamage = math.floor(magicDamage * 0.5)
|
|
|
|
magicDamage = utils.handleOneForAll(target, magicDamage)
|
|
magicDamage = utils.handleStoneskin(target, magicDamage)
|
|
|
|
return magicDamage
|
|
end
|