landsandboat/scripts/globals/summon.lua
2026-04-14 19:43:10 -07:00

418 lines
14 KiB
Lua

-----------------------------------
-- Avatar Global Functions
-----------------------------------
require('scripts/globals/combat/physical_utilities')
-----------------------------------
xi = xi or {}
xi.summon = xi.summon or {}
local function getDexCritRate(source, target)
-- https://www.bg-wiki.com/bg/Critical_Hit_Rate
local dDex = source:getStat(xi.mod.DEX) - target:getStat(xi.mod.AGI)
local dDexAbs = math.abs(dDex)
local sign = 1
if dDex < 0 then
-- target has higher AGI so this will be a decrease to crit rate
sign = -1
end
-- default to +0 crit rate for a delta of 0-6
local critRate = 0
if dDexAbs > 39 then
-- 40-50: (dDEX-35)
critRate = dDexAbs - 35
elseif dDexAbs > 29 then
-- 30-39: +4
critRate = 4
elseif dDexAbs > 19 then
-- 20-29: +3
critRate = 3
elseif dDexAbs > 13 then
-- 14-19: +2
critRate = 2
elseif dDexAbs > 6 then
-- 7-13: +1
critRate = 1
end
-- Crit rate from stats caps at +-15
return math.min(critRate, 15) * sign
end
local function getRandRatio(wRatio)
local maxRatio = 4.25 -- 4.25 for Avatars, they count as 1H but same as mobs don't have a non-crit cap
local upperLimit = math.min(wRatio + 0.375, maxRatio)
if wRatio < 0.5 then
upperLimit = math.max(wRatio + 0.5, 0.5)
elseif wRatio < 0.7 then
upperLimit = 1
elseif wRatio < 1.2 then
upperLimit = wRatio + 0.3
elseif wRatio < 1.5 then
upperLimit = wRatio * 1.25
end
local lowerLimit = math.min(wRatio - 0.375, maxRatio)
if wRatio < 0.38 then
lowerLimit = math.max(wRatio, 0.5)
elseif wRatio < 1.25 then
lowerLimit = (wRatio * (1176 / 1024)) - (448 / 1024)
elseif wRatio < 1.51 then
lowerLimit = 1
elseif wRatio < 2.44 then
lowerLimit = (wRatio * (1176 / 1024)) - (755 / 1024)
end
-- Randomly pick a value between lower and upper limits for qRatio
local qRatio = lowerLimit + (math.random() * (upperLimit - lowerLimit))
return qRatio
end
local function avatarFTP(tp, ftp1, ftp2, ftp3)
if tp < 1000 then
tp = 1000
end
if tp >= 1000 and tp < 2000 then
return ftp1 + (ftp2 - ftp1) / 100 * (tp - 1000)
elseif tp >= 2000 and tp <= 3000 then
-- generate a straight line between ftp2 and ftp3 and find point @ tp
return ftp2 + (ftp3 - ftp2) / 100 * (tp - 2000)
end
return 1 -- no ftp mod
end
local function avatarHitDmg(weaponDmg, fSTR, pDif)
-- https://www.bg-wiki.com/bg/Physical_Damage
-- Physical Damage = Base Damage * pDIF
-- where Base Damange is defined as Weapon Damage + fSTR
return (weaponDmg + fSTR) * pDif
end
xi.summon.getSummoningSkillOverCap = function(avatar)
local summoner = avatar:getMaster()
local summoningSkill = summoner:getSkillLevel(xi.skill.SUMMONING_MAGIC)
local maxSkill = summoner:getMaxSkillLevel(avatar:getMainLvl(), xi.job.SMN, xi.skill.SUMMONING_MAGIC)
return math.max(summoningSkill - maxSkill, 0)
end
---@alias physicalAvatarSkillRetVal { damage: number, hitslanded: number, isCritical: boolean}
---@param avatar CBaseEntity
---@param target CBaseEntity
---@param skill CPetSkill|CMobSkill
---@param numberofhits number
---@param accmod number
---@param dmgmod number
---@param dmgmodsubsequent number
---@param tpeffect xi.mobskills.magicalTpBonus|xi.mobskills.physicalTpBonus
---@param mtp100 number
---@param mtp200 number
---@param mtp300 number
---@return physicalAvatarSkillRetVal
xi.summon.avatarPhysicalMove = function(avatar, target, skill, numberofhits, accmod, dmgmod, dmgmodsubsequent, tpeffect, mtp100, mtp200, mtp300)
local returninfo = {}
-- I have never read a limit on accuracy bonus from summoning skill which can currently go far past 200 over cap
-- current retail is over +250 skill so I am removing the cap, my SMN is at 695 total skill
local bonusAcc = xi.summon.getSummoningSkillOverCap(avatar)
-- Handle DA/TA/QA
-- TODO: handle Nirvana
local bonusHits = 0
local doubleRate = avatar:getMod(xi.mod.DOUBLE_ATTACK)
local tripleRate = avatar:getMod(xi.mod.TRIPLE_ATTACK)
local quadRate = avatar:getMod(xi.mod.QUAD_ATTACK)
if math.random(1, 100) <= quadRate then
bonusHits = bonusHits + 3
elseif math.random(1, 100) <= tripleRate then
bonusHits = bonusHits + 2
elseif math.random(1, 100) <= doubleRate then
bonusHits = bonusHits + 1
end
-- Normal hits computed first
local hitrateFirst = xi.combat.physicalHitRate.getPhysicalHitRate(avatar, target, bonusAcc + 100, xi.attackAnimation.RIGHT_ATTACK, false)
local hitrateSubsequent = xi.combat.physicalHitRate.getPhysicalHitRate(avatar, target, bonusAcc, xi.attackAnimation.RIGHT_ATTACK, false)
-- Compute hits first so we can exit early
local firstHitLanded = false
local numHitsLanded = 0
local numHitsProcessed = 1
local finaldmg = 0
local didCrit = false
if math.random() < hitrateFirst then
firstHitLanded = true
numHitsLanded = numHitsLanded + 1
end
while numHitsProcessed < (numberofhits + bonusHits) do
if math.random() < hitrateSubsequent then
numHitsLanded = numHitsLanded + 1
end
numHitsProcessed = numHitsProcessed + 1
end
if numHitsLanded == 0 then
-- Missed everything we can exit early
finaldmg = 0
skill:setMsg(xi.msg.basic.SKILL_MISS)
else
-- https://www.bg-wiki.com/bg/Critical_Hit_Rate
-- Crit rate has a base of 5% and no cap, 0-100% are valid
-- Dex contribution to crit rate is capped and works in tiers
local baseCritRate = 5
local maxCritRate = 1 -- 100%
local minCritRate = 0 -- 0%
local critRate = baseCritRate + getDexCritRate(avatar, target) + avatar:getMod(xi.mod.CRITHITRATE)
critRate = critRate / 100
critRate = utils.clamp(critRate, minCritRate, maxCritRate)
local weaponDmg = avatar:getWeaponDmg()
local fSTR = xi.combat.physical.calculateMeleeStatFactor(avatar, target)
-- https://www.bg-wiki.com/bg/PDIF
-- https://www.bluegartr.com/threads/127523-pDIF-Changes-(Feb.-10th-2016)
local ratio = avatar:getStat(xi.mod.ATT) / target:getStat(xi.mod.DEF)
local cRatio = ratio
local shouldApplyLevelCorrection = xi.data.levelCorrection.isLevelCorrectedZone(avatar)
local levelDiff = math.min(avatar:getMainLvl() - target:getMainLvl(), 38) -- Max level diff is 38
if shouldApplyLevelCorrection then
-- Mobs, Avatars and pets only get bonuses, no penalties (or they are calculated differently)
if levelDiff > 0 then
local correction = levelDiff * 0.05
local cappedCorrection = math.min(correction, 1.9)
cRatio = cRatio + cappedCorrection
end
end
--Everything past this point is randomly computed per hit
numHitsProcessed = 0
local critAttackBonus = 1 + ((avatar:getMod(xi.mod.CRIT_DMG_INCREASE) - target:getMod(xi.mod.CRIT_DEF_BONUS)) / 100)
if firstHitLanded then
local wRatio = cRatio
local isCrit = math.random() < critRate
if isCrit then
wRatio = wRatio + 1
didCrit = true
end
local qRatio = getRandRatio(wRatio) -- Get a random ratio from min and max
local pDif = qRatio * (1 + (math.random() * 0.05)) -- Final pDif is qRatio randomized with a 1-1.05 multiplier
if isCrit then
pDif = pDif * critAttackBonus
end
finaldmg = avatarHitDmg(weaponDmg, fSTR, pDif) * dmgmod
numHitsProcessed = 1
end
while numHitsProcessed < numHitsLanded do
local wRatio = cRatio
local isCrit = math.random() < critRate
if isCrit then
wRatio = wRatio + 1
didCrit = true
end
local qRatio = getRandRatio(wRatio) -- Get a random ratio from min and max.
local pDif = qRatio * (1 + math.random() * 0.05) -- Final pDif is qRatio randomized with a 1-1.05 multiplier.
if isCrit then
pDif = pDif * critAttackBonus
end
finaldmg = finaldmg + (avatarHitDmg(weaponDmg, fSTR, pDif) * dmgmodsubsequent)
numHitsProcessed = numHitsProcessed + 1
end
-- apply ftp bonus
if tpeffect == xi.mobskills.physicalTpBonus.DMG_VARIES then
finaldmg = finaldmg * avatarFTP(avatar:getTP(), mtp100, mtp200, mtp300)
end
end
returninfo.damage = finaldmg
returninfo.hitslanded = numHitsLanded
skill:setAttackType(xi.attackType.PHYSICAL)
skill:setCritical(didCrit)
return returninfo
end
local attackTypeShields =
{
[xi.attackType.PHYSICAL] = xi.effect.PHYSICAL_SHIELD,
[xi.attackType.RANGED ] = xi.effect.ARROW_SHIELD,
[xi.attackType.MAGICAL ] = xi.effect.MAGIC_SHIELD,
}
---@param info magicalMobSkillRetVal|physicalAvatarSkillRetVal
---@param mob CBaseEntity
---@param skill CPetSkill|CMobSkill
---@param target CBaseEntity
---@param skilltype xi.attackType
---@param damagetype xi.damageType
---@param shadowbehav xi.mobskills.shadowBehavior?
---@return number
xi.summon.avatarFinalAdjustments = function(info, mob, skill, target, skilltype, damagetype, shadowbehav)
local missMessage = xi.msg.basic.SKILL_MISS
if mob:getCurrentAction() == xi.action.category.PET_MOBABILITY_FINISH then
missMessage = xi.msg.basic.JA_MISS_2
end
local dmg = info.damage
-- Physical Attack Missed
if
skilltype == xi.attackType.PHYSICAL and
dmg == 0
then
skill:setMsg(missMessage)
return 0
end
-- set message to damage
-- this is for AoE because its only set once
if mob:getCurrentAction() == xi.action.category.PET_MOBABILITY_FINISH then
if skill:getMsg() ~= xi.msg.basic.JA_MAGIC_BURST then
skill:setMsg(xi.msg.basic.USES_JA_TAKE_DAMAGE)
end
else
skill:setMsg(xi.msg.basic.DAMAGE)
end
-- Handle shadows depending on shadow behavior / skilltype
local preShadowDmg = dmg
local shadowsUsed = 0
dmg, shadowsUsed = utils.takeShadows(target, dmg, shadowbehav)
if preShadowDmg > 0 and dmg == 0 then
skill:setMsg(xi.msg.basic.SHADOW_ABSORB)
return shadowsUsed
end
-- handle Third Eye using shadowbehav as a guide
local teye = target:getStatusEffect(xi.effect.THIRD_EYE)
-- T.Eye only procs when active with PHYSICAL stuff
if
teye ~= nil and
skilltype == xi.attackType.PHYSICAL
then
if shadowbehav == xi.mobskills.shadowBehavior.WIPE_SHADOWS then -- e.g. aoe moves
target:delStatusEffect(xi.effect.THIRD_EYE)
elseif shadowbehav ~= xi.mobskills.shadowBehavior.IGNORE_SHADOWS then -- it can be absorbed by shadows
-- third eye doesnt care how many shadows, so attempt to anticipate, but reduce
-- chance of anticipate based on previous successful anticipates.
local prevAnt = teye:getPower()
if prevAnt == 0 then
-- 100% proc
teye:setPower(1)
skill:setMsg(xi.msg.basic.ANTICIPATE)
return 0
end
if math.random() * 10 < 8 - prevAnt then
-- anticipated!
teye:setPower(prevAnt + 1)
skill:setMsg(xi.msg.basic.ANTICIPATE)
return 0
end
target:delStatusEffect(xi.effect.THIRD_EYE)
end
end
-- TODO: Handle anything else (e.g. if you have Magic Shield and its a Magic skill, then do 0 damage.
for attackType, shieldEffect in pairs(attackTypeShields) do
if skilltype == attackType and target:hasStatusEffect(shieldEffect) then
return 0
end
end
-- handle invincible
if
target:hasStatusEffect(xi.effect.INVINCIBLE) and
skilltype == xi.attackType.PHYSICAL
then
return 0
end
-- handle pd
if
(target:hasStatusEffect(xi.effect.PERFECT_DODGE) or
target:hasStatusEffect(xi.effect.ALL_MISS)) and
skilltype == xi.attackType.PHYSICAL
then
skill:setMsg(missMessage)
return 0
end
-- Calculate Blood Pact Damage before stoneskin
dmg = math.floor(dmg + dmg * mob:getMod(xi.mod.BP_DAMAGE) / 100)
if dmg < 0 then
return dmg
end
-- handle One For All, Liement
if skilltype == xi.attackType.MAGICAL then
dmg = utils.handleOneForAll(target, dmg)
end
dmg = utils.handlePhalanx(target, dmg)
dmg = utils.handleStoneskin(target, dmg)
-- Check if the mob has a damage cap
dmg = target:checkDamageCap(dmg)
return dmg
end
-- returns true if mob attack hit
-- used to stop tp move status effects
xi.summon.avatarPhysicalHit = function(skill, dmg)
-- if message is not the default. Then there was a miss, shadow taken etc
return skill:getMsg() == xi.msg.basic.DAMAGE
end
local trialSizeBattles = set{
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_WIND,
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_LIGHTNING,
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_ICE,
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_FIRE,
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_EARTH,
xi.battlefield.id.TRIAL_SIZE_TRIAL_BY_WATER,
}
-- Checks if the summoner is in a Trial Size Avatar Mini Fight (only carbuncle is allowed)
xi.summon.avatarMiniFightCheck = function(caster, spellID)
local result = 0
local effect = caster:getStatusEffect(xi.effect.BATTLEFIELD)
if spellID ~= xi.magic.spell.CARBUNCLE and effect then
local bcnmid = effect:getPower()
if trialSizeBattles[bcnmid] then -- Mini Avatar Fights
result = 40 -- Cannot use <spell> in this area.
end
end
return result
end