2025-12-12 05:36:23 -08:00
---@class utils
utils = utils or { }
2025-12-10 00:48:24 -08:00
2026-04-08 03:48:51 -07:00
-- A mechanic that will occasionaly reduce shadows consumed when hit by an AOE skill.
2025-12-10 00:48:24 -08:00
---@nodiscard
---@param actor CBaseEntity
2026-04-08 03:48:51 -07:00
---@param attemptedRemovals integer
2025-12-10 00:48:24 -08:00
---@return integer
2026-04-08 03:48:51 -07:00
function utils . attemptShadowMitigation ( actor , attemptedRemovals )
if attemptedRemovals <= 0 then
return 0
end
2025-12-10 00:48:24 -08:00
2026-04-08 03:48:51 -07:00
-- TODO: Does this mechanic work on players who are not NIN main or sub? If so remove NIN requirement.
-- See Yagyu Darkblade: https://www.bg-wiki.com/ffxi/Yagyu_Darkblade
local isNIN = actor : getMainJob ( ) == xi.job . NIN or actor : getSubJob ( ) == xi.job . NIN
local hasUtsusemi = actor : getMod ( xi.mod . UTSUSEMI ) > 0
2025-12-10 00:48:24 -08:00
if
2026-04-08 03:48:51 -07:00
not isNIN or
not hasUtsusemi -- Only works with Utsusemi
2025-12-10 00:48:24 -08:00
then
2026-04-08 03:48:51 -07:00
return 0
end
-- TODO: Currently unknown exactly what stats affect procChance and by how much. SE mentions Ninjutsu Skill affects this to some degree.
-- Note: 50% was calculated from data with a relatively low Ninjutsu skill (Between 50-110~ skill range vs Lv. 75+ Targets) so this will likely lean on the conservative side (Weighted against players).
local procChance = 50
local mitigated = 0
-- Through research, a skill's shadowBehavior acts as a counter for how many shadow mitigation attempts are made(attemptedRemovals).
-- Example: An AoE skill that takes 4 shadows will attempt the mitgation step below 4 times. A shadow will only be mitigated if it passes the proc chance check.
for i = 1 , attemptedRemovals do
2026-07-05 14:55:22 +01:00
if math.randomInt ( 1 , 100 ) <= procChance then
2026-04-08 03:48:51 -07:00
mitigated = mitigated + 1
end
2025-12-10 00:48:24 -08:00
end
2026-04-08 03:48:51 -07:00
local maxMitigatable = attemptedRemovals - 1
return math.min ( mitigated , maxMitigatable )
2025-12-10 00:48:24 -08:00
end
2026-04-08 03:48:51 -07:00
-- TODO: Marked for retirement. See: utils.shadowAbsorb() below.
-- Some abilities and skills still use this but will need to be slightly reworked to use utils.shadowAbsorb().
2025-12-10 00:48:24 -08:00
-- Calculate shadow consumption/damage absorbtion.
---@param actor CBaseEntity
---@param damage integer
---@param shadowsToRemove integer?
2026-02-02 20:18:54 -07:00
---@return integer damage
---@return integer shadowsUsed
2025-12-12 05:36:23 -08:00
function utils . takeShadows ( actor , damage , shadowsToRemove )
2025-12-10 00:48:24 -08:00
shadowsToRemove = shadowsToRemove or 1
-- Check for Utsusemi first, then Blink.
local shadowPower = actor : getMod ( xi.mod . UTSUSEMI )
local shadowType = xi.mod . UTSUSEMI
if shadowPower == 0 then
shadowPower = actor : getMod ( xi.mod . BLINK )
shadowType = xi.mod . BLINK
end
-- No shadows, return full damage
if shadowPower == 0 then
2026-02-02 20:18:54 -07:00
return damage , 0
2025-12-10 00:48:24 -08:00
end
local shadowsRemaining = shadowPower
local shadowsUsed = 0
-- Handle Blink shadow removal
if shadowType == xi.mod . BLINK then
for _ = 1 , shadowsToRemove do
2026-07-05 14:55:22 +01:00
if shadowsRemaining > 0 and math.randomInt ( 1 , 100 ) <= 80 then
2025-12-10 00:48:24 -08:00
shadowsRemaining = shadowsRemaining - 1
shadowsUsed = shadowsUsed + 1
end
end
if shadowsUsed >= shadowsToRemove then
damage = 0
else
damage = damage * ( ( shadowsToRemove - shadowsUsed ) / shadowsToRemove )
end
else
-- Handle Utsusemi removal
if shadowPower >= shadowsToRemove then
shadowsRemaining = shadowPower - shadowsToRemove
2026-02-02 20:18:54 -07:00
shadowsUsed = shadowsToRemove
damage = 0
2025-12-10 00:48:24 -08:00
-- Update remaining Copy Image icon
if shadowsRemaining > 0 then
local effect = actor : getStatusEffect ( xi.effect . COPY_IMAGE )
if effect then
local iconMap =
{
[ 1 ] = xi.effect . COPY_IMAGE ,
[ 2 ] = xi.effect . COPY_IMAGE_2 ,
[ 3 ] = xi.effect . COPY_IMAGE_3 ,
-- Note: 4+ use the same icon.
}
effect : setIcon ( iconMap [ shadowsRemaining ] or xi.effect . COPY_IMAGE_4 )
end
end
else
-- Partial shadow consumption, take damage.
2026-02-02 20:18:54 -07:00
shadowsUsed = shadowPower
damage = damage * ( ( shadowsToRemove - shadowPower ) / shadowsToRemove )
2025-12-10 00:48:24 -08:00
shadowsRemaining = 0
end
end
actor : setMod ( shadowType , shadowsRemaining )
if shadowsRemaining <= 0 then
actor : delStatusEffect ( xi.effect . COPY_IMAGE )
actor : delStatusEffect ( xi.effect . BLINK )
end
2026-02-02 20:18:54 -07:00
return damage , shadowsUsed
2025-12-10 00:48:24 -08:00
end
2026-04-08 03:48:51 -07:00
-- Calculate shadow consumption
---@param target CBaseEntity
---@param shadowsToRemove number
---@return boolean, number
function utils . shadowAbsorb ( target , shadowsToRemove )
2026-04-17 06:38:57 -07:00
local utsusemiMod = target : getMod ( xi.mod . UTSUSEMI )
local blinkMod = target : getMod ( xi.mod . BLINK )
2026-04-08 03:48:51 -07:00
2026-04-17 06:38:57 -07:00
-- Early return: Target has no shadows.
if
utsusemiMod == 0 and
blinkMod == 0
then
return false , 0
2026-04-08 03:48:51 -07:00
end
2026-04-17 06:38:57 -07:00
local targetShadows = 0
local shadowsConsumed = 0
local absorbHit = false
2026-04-08 03:48:51 -07:00
2026-04-17 06:38:57 -07:00
-- Utsusemi takes precedence over blink.
if utsusemiMod > 0 then
shadowsConsumed = utils.clamp ( shadowsToRemove , 0 , utsusemiMod ) -- How many shadows were consumed (Used for SHADOW_ABSORB messaging later).
targetShadows = utsusemiMod - shadowsConsumed -- How many shadows left after the attack.
absorbHit = utsusemiMod >= shadowsToRemove -- Check to see if the target had enough shadows to block the attack.
2026-04-08 03:48:51 -07:00
local effect = target : getStatusEffect ( xi.effect . COPY_IMAGE )
if effect then
2026-04-17 06:38:57 -07:00
if targetShadows == 0 then
2026-07-15 22:56:25 -07:00
effect : setIcon ( xi.effect . COPY_IMAGE )
2026-04-17 06:38:57 -07:00
target : delStatusEffect ( xi.effect . COPY_IMAGE )
elseif targetShadows == 1 then
effect : setIcon ( xi.effect . COPY_IMAGE )
elseif targetShadows == 2 then
effect : setIcon ( xi.effect . COPY_IMAGE_2 )
elseif targetShadows == 3 then
effect : setIcon ( xi.effect . COPY_IMAGE_3 )
else
effect : setIcon ( xi.effect . COPY_IMAGE_4 ) -- 4 or more shadows active use the same "4+" icon.
2026-04-08 03:48:51 -07:00
end
end
2026-04-17 06:38:57 -07:00
target : setMod ( xi.mod . UTSUSEMI , targetShadows )
-- Blink has a random chance of triggering when no utsusemi is present.
elseif blinkMod > 0 then
2026-07-05 14:55:22 +01:00
if math.randomInt ( 1 , 100 ) <= 20 then
2026-04-17 06:38:57 -07:00
absorbHit = false
return absorbHit , 0
end
shadowsConsumed = utils.clamp ( shadowsToRemove , 0 , blinkMod ) -- How many shadows were consumed by the attack (Used for SHADOW_ABSORB messaging later)
targetShadows = blinkMod - shadowsConsumed -- How many shadows left over after the attack.
absorbHit = blinkMod >= shadowsToRemove -- Check to see if the target had enough shadows to fully block the attack.
if targetShadows == 0 then
target : delStatusEffect ( xi.effect . BLINK )
end
target : setMod ( xi.mod . BLINK , targetShadows )
-- Retail Testing Notes:
-- Tested with WHM spell Blink
-- 1 hit skills took 1 shadow.
-- TODO: When hit by a 2 hit skill, it was observed to consume 2 blink shadows, however the message returned was SKILL_MISS rather than SHADOW_ABSORB.
-- Did not block 3+ hit mob skills. (Player Weaponskills untested)
-- AOE skills delete Blink.
2026-04-08 03:48:51 -07:00
2026-04-17 06:38:57 -07:00
-- TODO: Test Zephyr Mantle proc rate.
-- TODO: Test player Weapon Skills on mob/players with Blink/Zephyr Mantle.
-- Note: JPWiki/FFXIPedia repeatedly mentions that Blink can block multi hit skills, but this was not observed in testing. Needs further testing.
2026-04-08 03:48:51 -07:00
end
2026-04-17 06:38:57 -07:00
return absorbHit , shadowsConsumed
2026-04-08 03:48:51 -07:00
end
2025-12-10 00:48:24 -08:00
-- Calculates Phalanx damage reduction.
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
---@return integer
2025-12-12 05:36:23 -08:00
function utils . handlePhalanx ( actor , damage )
2026-01-26 04:35:32 +01:00
if damage <= 0 then
return damage
2025-12-10 00:48:24 -08:00
end
2026-01-26 04:35:32 +01:00
return utils.clamp ( damage - actor : getMod ( xi.mod . PHALANX ) , 0 , 99999 )
2025-12-10 00:48:24 -08:00
end
-- Returns reduced magic damage from RUN buff, 'One for All'
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
---@return integer
2025-12-12 05:36:23 -08:00
function utils . handleOneForAll ( actor , damage )
2026-01-26 04:35:32 +01:00
if damage <= 0 then
return damage
end
2025-12-10 00:48:24 -08:00
2026-01-26 04:35:32 +01:00
local oneForAllEffect = actor : getStatusEffect ( xi.effect . ONE_FOR_ALL )
if not oneForAllEffect then
return damage
2025-12-10 00:48:24 -08:00
end
2026-01-26 04:35:32 +01:00
return utils.clamp ( damage - oneForAllEffect : getPower ( ) , 0 , 99999 )
2025-12-10 00:48:24 -08:00
end
-- Calculates Stoneskin damage reduction.
2026-07-12 20:07:28 -05:00
-- Optional attackType: subType 1 = physical/ranged only, 2 = magical only (frozen_mist / hydro_wave / Rampart).
-- Omitting attackType preserves previous absorb-all behavior.
2025-12-10 00:48:24 -08:00
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
2026-07-12 20:07:28 -05:00
---@param attackType xi.attackType?
2025-12-10 00:48:24 -08:00
---@return integer
2026-07-12 20:07:28 -05:00
function utils . handleStoneskin ( actor , damage , attackType )
2026-07-13 12:27:53 +02:00
-- Early return: No damage to mitigate.
2026-01-26 04:35:32 +01:00
if damage <= 0 then
return damage
end
2025-12-10 00:48:24 -08:00
2026-07-13 12:27:53 +02:00
-- Early return: No effect present.
local effect = actor : getStatusEffect ( xi.effect . STONESKIN )
if not effect then
return damage
end
-- Early return: Stoneskin type doesn't mitigate damage type.
local stoneskinType = effect : getSubType ( )
if stoneskinType == 2 then
if
attackType == xi.attackType . PHYSICAL or
attackType == xi.attackType . RANGED
then
return damage
end
elseif stoneskinType == 1 then
if
attackType == xi.attackType . MAGICAL or
attackType == xi.attackType . BREATH or
attackType == xi.attackType . SPECIAL
then
return damage
2026-07-12 20:07:28 -05:00
end
end
2026-07-13 12:27:53 +02:00
-- Early return: Stoneskin can't mitigate any more damage.
2026-07-22 11:10:11 +02:00
local stoneskinRemaining = effect : getPower ( )
2026-01-26 04:35:32 +01:00
if stoneskinRemaining <= 0 then
return damage
end
2025-12-10 00:48:24 -08:00
2026-07-13 12:27:53 +02:00
-- Absorb all damage.
2026-01-26 04:35:32 +01:00
if stoneskinRemaining > damage then
2026-07-22 11:10:11 +02:00
effect : setPower ( stoneskinRemaining - damage )
2025-12-10 00:48:24 -08:00
2026-01-26 04:35:32 +01:00
return 0
2025-12-10 00:48:24 -08:00
2026-01-26 04:35:32 +01:00
-- Wear off if mitigated damage exceeds stoneskin.
else
actor : delStatusEffect ( xi.effect . STONESKIN )
2026-07-13 12:27:53 +02:00
return utils.clamp ( damage - stoneskinRemaining , 0 , 99999 )
2026-01-26 04:35:32 +01:00
end
2025-12-10 00:48:24 -08:00
end
2026-06-07 16:20:52 -04:00
-- Handles Automaton attachment "Analyzer", which decreases damage from successive special attacks.
---@param actor CBaseEntity
---@param skill CPetSkill|CMobSkill
---@param damage integer
---@return integer
2025-12-12 05:36:23 -08:00
function utils . handleAutomatonAutoAnalyzer ( actor , skill , damage )
2026-05-25 20:33:28 -04:00
local analyzerModifier = actor : getMod ( xi.mod . AUTO_ANALYZER )
2025-12-10 00:48:24 -08:00
2026-05-25 20:33:28 -04:00
-- If no Analyzer equipped, return unmodified damage.
if analyzerModifier <= 0 then
return damage
end
2025-12-10 00:48:24 -08:00
2026-05-25 20:33:28 -04:00
local incomingSkill = skill : getID ( )
local analyzedSkillCount = math.min ( analyzerModifier , 6 )
2025-12-10 00:48:24 -08:00
2026-05-25 20:33:28 -04:00
-- Check if the incoming skill matches any of the analyzed skills. If so, apply the damage reduction.
for i = 1 , analyzedSkillCount do
if incomingSkill == actor : getLocalVar ( ' analyzedSkill ' .. i ) then
return math.floor ( damage * 0.6 )
end
2025-12-10 00:48:24 -08:00
end
return damage
end