landsandboat/scripts/combat/physical_hit_rate.lua
2026-07-07 20:28:23 +02:00

236 lines
7.9 KiB
Lua

-----------------------------------
-- Global, independent functions for physical hit rate calculations.
-----------------------------------
xi = xi or {}
xi.combat = xi.combat or {}
xi.combat.physicalHitRate = xi.combat.physicalHitRate or {}
-----------------------------------
xi.combat.physicalHitRate.checkAnticipated = function(attacker, defender)
-- Early Return: Defender lacks Third Eye.
if not defender:hasStatusEffect(xi.effect.THIRD_EYE) then
return false
end
-- Calculate chance to retain "Third Eye".
local thirdEyeRetentionChance = 0
local canRetainThirdEye = not defender:isPC() or defender:isPC() and defender:isWeaponTwoHanded()
if defender:hasStatusEffect(xi.effect.SEIGAN) and canRetainThirdEye then
-- Duration left.
local thirdEyeEffect = defender:getStatusEffect(xi.effect.THIRD_EYE)
local timeInEffect = thirdEyeEffect:getDuration() - thirdEyeEffect:getTimeRemaining()
-- Retention
local retentionLossPerMillisecond = 1 / 300 -- Retain 100% / 30 seconds (30000 milliseconds)
local retentionModifier = utils.clamp(1 - defender:getMod(xi.mod.THIRD_EYE_RETENTION_RATE) / 100, 0, 1) -- 50 = 0.5x reduction in loss per millisecond
-- Add in retention bonus, Kogarasumaru has a 50% reduction per JP wiki -- https://wiki.ffo.jp/html/15175.html
-- Other sources such as BG indicate it has a reduction -- https://www.bluegartr.com/threads/71538-Mythic-Weapon-Compiled-Information?p=2972086&highlight=#post2972086
-- Increase scale by 100x to give more precision to the RNG
thirdEyeRetentionChance = utils.clamp(100 - timeInEffect * retentionLossPerMillisecond * retentionModifier, 0, 100) * 100
end
-- Calculate if "Third Eye" is retained.
if
thirdEyeRetentionChance == 0 or
math.randomInt(1, 10000) > thirdEyeRetentionChance
then
defender:delStatusEffect(xi.effect.THIRD_EYE)
end
return true
end
-- https://www.bg-wiki.com/ffxi/Hit_Rate
-- This is only for melee attacks
---@param attacker CBaseEntity
---@param slot xi.attackAnimation
---@return number
xi.combat.physicalHitRate.getPhysicalHitRateCap = function(attacker, slot)
if attacker:isPet() then
return 0.99
elseif attacker:isPC() then
if attacker:isUsingH2H() then -- Kicks aren't explicitly listed as 99%, TODO: needs verification
return 0.99
elseif attacker:isWeaponTwoHanded() or slot >= xi.attackAnimation.LEFT_ATTACK then -- 1h offhand, ranged
return 0.95
end
return 0.99 -- 1h mainhand
end
return 0.95 -- mobs, charmed pets. -- Do trusts have a 99% or 95% acc cap?
end
---@param entity CBaseEntity
---@return number
xi.combat.physicalHitRate.getFlashPenalty = function(entity)
local effect = entity:getStatusEffect(xi.effect.FLASH)
if effect then
-- https://github.com/LandSandBoat/server/discussions/6926
-- milliseconds. 12s flash has a potency of 360, 360 = 0.03*(12*1000)
local timeRemaining = effect:getTimeRemaining()
local reductionPerMillisecond = 0.03
return math.floor(timeRemaining * reductionPerMillisecond)
end
return 0
end
---@param attacker CBaseEntity
---@param target CBaseEntity
---@param isWeaponskill boolean
---@return number, number
xi.combat.physicalHitRate.getHitRateModifiers = function(attacker, target, isWeaponskill, isRanged)
local accBonus = 0
local evaBonus = 0
-- Melee only
if not isRanged then
local flourishEffect = attacker:getStatusEffect(xi.effect.BUILDING_FLOURISH)
if
isWeaponskill and
flourishEffect ~= nil and
flourishEffect:getPower() >= 1
then -- 1 or more Finishing moves used.
accBonus = 40 + flourishEffect:getSubPower() * 2
end
if
attacker:hasStatusEffect(xi.effect.INNIN) and
attacker:isBehind(target, 23) -- angle needs confirmation
then
local jpValue = target:getJobPointLevel(xi.jp.INNIN_EFFECT)
-- Innin acc boost if attacker is behind target
accBonus = accBonus + attacker:getStatusEffect(xi.effect.INNIN):getPower() + jpValue
end
if attacker:isPC() and attacker:isFacing(target) then
accBonus = accBonus + attacker:getMerit(xi.merit.CLOSED_POSITION)
end
end
-- Description calls out both melee and ranged
if attacker:hasTrait(xi.trait.AMBUSH) and attacker:isBehind(target, 23) then
accBonus = accBonus + attacker:getMerit(xi.merit.AMBUSH)
end
-- Yonin evasion is likely agnostic to ranged or melee but needs confirmation
if
attacker:hasStatusEffect(xi.effect.YONIN) and
attacker:isFacing(target, 64) -- angle needs confirmation
then
local jpValue = target:getJobPointLevel(xi.jp.YONIN_EFFECT)
evaBonus = evaBonus + attacker:getStatusEffect(xi.effect.YONIN):getPower() + 2 * jpValue
end
-- target modifiers
if target:isPC() and target:isFacing(attacker) then
evaBonus = evaBonus + target:getMerit(xi.merit.CLOSED_POSITION)
end
accBonus = accBonus - xi.combat.physicalHitRate.getFlashPenalty(attacker)
return accBonus, evaBonus
end
---@param attacker CBaseEntity
---@param target CBaseEntity
---@param acc number
---@param eva number
---@return number
local function accuracyAndEvasionToHitRate(attacker, target, acc, eva)
local shouldApplyLevelCorrection = xi.data.levelCorrection.isLevelCorrectedZone(attacker)
if shouldApplyLevelCorrection then
local dlvl = attacker:getMainLvl() - target:getMainLvl()
-- cap dlvl for avatars. It's known to cap at 38
if attacker:isAvatar() then
dlvl = utils.clamp(dlvl, 0, 38)
end
-- Accuracy Bonus, doesn't apply to PCs
if not attacker:isPC() and attacker:getMainLvl() > target:getMainLvl() then
acc = acc + dlvl * 4
-- Accuracy Penalty, only applies to PCs -- TODO: does this apply to player pets?
elseif attacker:isPC() and attacker:getMainLvl() < target:getMainLvl() then
acc = acc + dlvl * 4
end
end
local hitdiff = (acc - eva) / 2
local hitrate = (75 + hitdiff) / 100
return hitrate
end
---@param attacker CBaseEntity
---@param target CBaseEntity
---@param bonus number
---@param slot xi.attackAnimation
---@param isWeaponskill boolean
---@return number
xi.combat.physicalHitRate.getPhysicalHitRate = function(attacker, target, bonus, slot, isWeaponskill)
local hitRateCap = xi.combat.physicalHitRate.getPhysicalHitRateCap(attacker, slot)
local acc = attacker:getACC(slot) -- TODO: clamp slot for 0, 1, 2 (mainhand, offhand, kick)
local eva = target:getEVA()
local accBonus, evaBonus = xi.combat.physicalHitRate.getHitRateModifiers(attacker, target, isWeaponskill, false)
if bonus == nil then
bonus = 0
end
acc = acc + bonus + accBonus
eva = eva + evaBonus
local hitrate = accuracyAndEvasionToHitRate(attacker, target, acc, eva)
-- Apply hitrate caps
hitrate = utils.clamp(hitrate, 0.2, hitRateCap)
return hitrate
end
---@param attacker CBaseEntity
---@param target CBaseEntity
---@param bonus number
---@param isWeaponskill boolean
---@return number
xi.combat.physicalHitRate.getRangedHitRate = function(attacker, target, bonus, isWeaponskill)
local distance = attacker:checkDistance(target)
-- special case
if distance > 25 then
return 0
end
local acc = attacker:getRACC()
local eva = target:getEVA()
local accBonus, evaBonus = xi.combat.physicalHitRate.getHitRateModifiers(attacker, target, isWeaponskill, true)
if bonus == nil then
bonus = 0
end
acc = acc + bonus + accBonus - xi.combat.ranged.accuracyDistancePenalty(attacker, target)
eva = eva + evaBonus
local hitrate = accuracyAndEvasionToHitRate(attacker, target, acc, eva)
-- Apply hitrate caps
hitrate = utils.clamp(hitrate, 0.05, 0.95)
return hitrate
end