landsandboat/scripts/utils/combat_utils.lua
Skold 5c6a5b3548 Update Automaton Steam Jacket
Co-authored-by: Xaver-DaRed <Xaver-DaRed@users.noreply.github.com>
2026-06-09 12:31:24 +02:00

288 lines
9.8 KiB
Lua

---@class utils
utils = utils or {}
-- A mechanic that will occasionaly reduce shadows consumed when hit by an AOE skill.
---@nodiscard
---@param actor CBaseEntity
---@param attemptedRemovals integer
---@return integer
function utils.attemptShadowMitigation(actor, attemptedRemovals)
if attemptedRemovals <= 0 then
return 0
end
-- 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
if
not isNIN or
not hasUtsusemi -- Only works with Utsusemi
then
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
if math.random(1, 100) <= procChance then
mitigated = mitigated + 1
end
end
local maxMitigatable = attemptedRemovals - 1
return math.min(mitigated, maxMitigatable)
end
-- 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().
-- Calculate shadow consumption/damage absorbtion.
---@param actor CBaseEntity
---@param damage integer
---@param shadowsToRemove integer?
---@return integer damage
---@return integer shadowsUsed
function utils.takeShadows(actor, damage, shadowsToRemove)
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
return damage, 0
end
local shadowsRemaining = shadowPower
local shadowsUsed = 0
-- Handle Blink shadow removal
if shadowType == xi.mod.BLINK then
for _ = 1, shadowsToRemove do
if shadowsRemaining > 0 and math.random(1, 100) <= 80 then
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
shadowsUsed = shadowsToRemove
damage = 0
-- 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.
shadowsUsed = shadowPower
damage = damage * ((shadowsToRemove - shadowPower) / shadowsToRemove)
shadowsRemaining = 0
end
end
actor:setMod(shadowType, shadowsRemaining)
if shadowsRemaining <= 0 then
actor:delStatusEffect(xi.effect.COPY_IMAGE)
actor:delStatusEffect(xi.effect.BLINK)
end
return damage, shadowsUsed
end
-- Calculate shadow consumption
---@param target CBaseEntity
---@param shadowsToRemove number
---@return boolean, number
function utils.shadowAbsorb(target, shadowsToRemove)
local utsusemiMod = target:getMod(xi.mod.UTSUSEMI)
local blinkMod = target:getMod(xi.mod.BLINK)
-- Early return: Target has no shadows.
if
utsusemiMod == 0 and
blinkMod == 0
then
return false, 0
end
local targetShadows = 0
local shadowsConsumed = 0
local absorbHit = false
-- 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.
local effect = target:getStatusEffect(xi.effect.COPY_IMAGE)
if effect then
if targetShadows == 0 then
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.
end
end
target:setMod(xi.mod.UTSUSEMI, targetShadows)
-- Blink has a random chance of triggering when no utsusemi is present.
elseif blinkMod > 0 then
if math.random(1, 100) <= 20 then
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.
-- 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.
end
return absorbHit, shadowsConsumed
end
-- Calculates Phalanx damage reduction.
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
---@return integer
function utils.handlePhalanx(actor, damage)
if damage <= 0 then
return damage
end
return utils.clamp(damage - actor:getMod(xi.mod.PHALANX), 0, 99999)
end
-- Returns reduced magic damage from RUN buff, 'One for All'
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
---@return integer
function utils.handleOneForAll(actor, damage)
if damage <= 0 then
return damage
end
local oneForAllEffect = actor:getStatusEffect(xi.effect.ONE_FOR_ALL)
if not oneForAllEffect then
return damage
end
return utils.clamp(damage - oneForAllEffect:getPower(), 0, 99999)
end
-- Calculates Stoneskin damage reduction.
---@nodiscard
---@param actor CBaseEntity
---@param damage integer
---@return integer
function utils.handleStoneskin(actor, damage)
if damage <= 0 then
return damage
end
local stoneskinRemaining = actor:getMod(xi.mod.STONESKIN)
if stoneskinRemaining <= 0 then
return damage
end
-- Absorb all damage
if stoneskinRemaining > damage then
actor:delMod(xi.mod.STONESKIN, damage)
return 0
-- Wear off if mitigated damage exceeds stoneskin.
else
actor:delStatusEffect(xi.effect.STONESKIN)
actor:setMod(xi.mod.STONESKIN, 0)
return damage - stoneskinRemaining
end
end
-- Handles Automaton attachment "Analyzer", which decreases damage from successive special attacks.
---@param actor CBaseEntity
---@param skill CPetSkill|CMobSkill
---@param damage integer
---@return integer
function utils.handleAutomatonAutoAnalyzer(actor, skill, damage)
local analyzerModifier = actor:getMod(xi.mod.AUTO_ANALYZER)
-- If no Analyzer equipped, return unmodified damage.
if analyzerModifier <= 0 then
return damage
end
local incomingSkill = skill:getID()
local analyzedSkillCount = math.min(analyzerModifier, 6)
-- 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
end
return damage
end