Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions scripts/globals/combat/magic_interrupt.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-----------------------------------
-- Global file for spell interrupt
-----------------------------------
xi = xi or {}
xi.combat = xi.combat or {}
xi.combat.magic = xi.combat.magic or {}

---Return whether a spell should be interrupted.
---@params attacker CBaseEntity
---@params defender CBaseEntity
---@params spell CSpell
---@return boolean
xi.combat.magic.shouldInterruptSpell = function(attacker, defender, spell)
-- Exceptions.
if
defender:getObjType() == xi.objType.TRUST or -- Caster is a trust.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get a recheck on this? I don't believe Trusts are immune to spell interruption.

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure the only non interrupted casting action is with movement and no action to prevent casting.

defender:hasStatusEffect(xi.effect.MANAFONT) or -- Caster has Manafont
spell:getSkillType() == xi.skill.SINGING -- Spell is a song.
then
return false
end

-- Calculate level ratio. BaseRate + Attacker main level - Defender main level.
local levelRatio = ((defender:getObjType() == xi.objType.MOB and 5 or 50) + attacker:getMainLvl() - defender:getMainLvl()) / 100.0

if levelRatio < 0.01 then
levelRatio = 0.01
end

-- Calculate skill ratio.
local skillRatio = 1.0
local meritReduction = 0

if defender:getObjType() == xi.objType.PC then
local skillType = spell:getSkillType()
local skillCap = defender:getMaxSkillLevel(defender:getMainLvl(), defender:getMainJob(), skillType)
local skillLevel = defender:getSkillLevel(skillType)

-- If skill cap is 0, player may be using a spell from their subjob.
if skillCap == 0 then
skillCap = defender:getMaxSkillLevel(defender:getMainLvl(), defender:getSubJob(), skillType)
end

-- If skill level is 0, set ratio to 10.
if skillLevel <= 0 then
skillRatio = 10.0
else
skillRatio = skillCap / skillLevel
end

-- Fetch player-only interruption rate reduction from merits.
meritReduction = defender:getMerit(xi.merit.SPELL_INTERUPTION_RATE)
end

-- SIRD reduces the interrupt after all the calculations are done -- as evidenced by the infamous "102% SIRD" builds.
-- Anything less than 102% interrupt results in the ability to be interrupted.
-- Note: the 102% is probably an x/256 x/1024 nonsense -- sometimes 101% works.
local sirdRatio = (100.0 - meritReduction - defender:getMod(xi.mod.SPELLINTERRUPT)) / 100.0
local chance = math.random()

-- These are all ratios.
-- levelRatio : 0.01 to infinity.
-- skillRatio: 1.0 to infinity.
-- SIRDRatio: No limits. Can be negative. A negative value will guarantee NOT being interrupted.
local finalRatio = levelRatio * skillRatio * sirdRatio -- TL;DR Higher = Worse = More chances to get interrupted.

-- You get interrupted. Handle aquaveil.
if chance < finalRatio then
if defender:hasStatusEffect(xi.effect.AQUAVEIL) then
local aquaCount = defender:getStatusEffect(xi.effect.AQUAVEIL):getPower()

-- Removes the status but still prevents the interrupt.
if aquaCount - 1 == 0 then
defender:delStatusEffect(xi.effect.AQUAVEIL)
else
defender:getStatusEffect(xi.effect.AQUAVEIL):setPower(aquaCount - 1)
end

return false
end

return true
end

return false
end

@Xaver-DaRed Xaver-DaRed Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposal:

xi.combat.magic.shouldInterruptSpell = function(attacker, defender, spell)
    -- Early return: Manafont prevents casting interruption.
    if defender:hasStatusEffect(xi.effect.MANAFONT) then
        return false
    end

    -- Early return: Trusts can't be interrupted.
    local entityType = defender:getObjType()
    if entityType == xi.objType.TRUST then
        return false
    end

    -- Early return: Songs can't be interrupted.
    local skillType = spell:getSkillType()
    if skillType == xi.skill.SINGING then
        return false
    end

    -- Llevel factor.
    local baseInterruptionRate = entityType == xi.objType.MOB and 5 or 50
    local levelRatio           = utils.clamp((baseInterruptionRate + attacker:getMainLvl() - defender:getMainLvl()) / 100, 0.01, 1.02)

    -- Skill factor.
    local skillRatio = 1

    if entityType == xi.objType.PC then
        local skillCap   = defender:getMaxSkillLevel(defender:getMainLvl(), defender:getMainJob(), skillType)
        local skillLevel = defender:getSkillLevel(skillType)

        -- If skill cap is 0, player may be using a spell from their subjob.
        if skillCap == 0 then
            skillCap = defender:getMaxSkillLevel(defender:getMainLvl(), defender:getSubJob(), skillType)
        end

        -- If skill level is 0, set ratio to 10.
        if skillLevel <= 0 then
            skillRatio = 10
        else
            skillRatio = skillCap / skillLevel
        end
    end

    -- SIRD reduces the interrupt after all the calculations are done -- as evidenced by the infamous "102% SIRD" builds.
    -- Anything less than 102% interrupt results in the ability to be interrupted.
    -- Note: the 102% is probably an x/256 x/1024 nonsense -- sometimes 101% works.
    local sirdRatio = (100 - defender:getMerit(xi.merit.SPELL_INTERUPTION_RATE) - defender:getMod(xi.mod.SPELLINTERRUPT)) / 100

    -- levelRatio : 0.01 to infinity.
    -- skillRatio : 1 to infinity.
    -- SIRDRatio  : No limits. Can be negative. A negative value will guarantee NOT being interrupted.
    local finalRatio = levelRatio * skillRatio * sirdRatio -- TL;DR Higher = Worse = More chances to get interrupted.

    -- Early return: Caster doesn't get interrupted.
    if math.random() >= finalRatio then
        return false
    end

    -- Early return: Caster can't prevent interruption via aquaveil.
    if not defender:hasStatusEffect(xi.effect.AQUAVEIL) then
        return true
    end

    -- Handle aquaveil and prevent interruption.
    local aquaveilPower = defender:getStatusEffect(xi.effect.AQUAVEIL):getPower() - 1
    if aquaveilPower == 0 then
        defender:delStatusEffect(xi.effect.AQUAVEIL)
    else
        defender:getStatusEffect(xi.effect.AQUAVEIL):setPower(aquaveilPower)
    end

    return false
end

@Xaver-DaRed Xaver-DaRed Jun 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the sake of this being a learning experience, some notes here:

  • Lua and C++ don't work the same. You don't need .0 floats in there. They don't hurt, but they also don't provide extra precision in Lua. So they are extra characters that aren't needed.
  • If you find yourself fetching entity type, or other kinds of info, more than once, it may be wroth just storing them in a variable.
  • Long operations can be divided in multiple steps, for the sake of readability. I'm refering concretely when fetching the base interruption rate, which varies between players and other entities.
  • I didn't do it in my proposal, but, defender could be named caster to make it crystal clear which entity is the one that can be interrupted. Minor, but everything helps for the sake of making the code as easy to follow as possible.
  • And this is just a personal preference, but I find that using an "early return" structure helps streamlining logic and makes understanding the flow of the code easier.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having said all of this, we have to decide if we really want this in lua, now, or in the future, or if a setting is the better approach.

But nontheless, its a good job.

1 change: 1 addition & 0 deletions scripts/globals/magic.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require('scripts/globals/combat/magic_hit_rate')
require('scripts/globals/combat/magic_interrupt')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unneeded

require('scripts/globals/jobpoints')
require('scripts/globals/spells/damage_spell')
-----------------------------------
Expand Down
81 changes: 1 addition & 80 deletions src/map/utils/battleutils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1897,86 +1897,7 @@ auto CalculateTPFromDamageTaken(CBattleEntity* PAttacker, CBattleEntity* PDefend

bool TryInterruptSpell(CBattleEntity* PAttacker, CBattleEntity* PDefender, CSpell* PSpell)
{
// Exceptions.
if (PDefender->objtype == TYPE_TRUST || // Caster is a trust.
PDefender->StatusEffectContainer->HasStatusEffect(EFFECT_MANAFONT) || // Caster has Manafont.
(SKILLTYPE)PSpell->getSkillType() == SKILL_SINGING) // Spell is a song.
{
return false;
}

// Calculate level ratio.
int baseRate = (PDefender->objtype == TYPE_MOB) ? 5 : 50;
float levelRatio = (float)(baseRate + PAttacker->GetMLevel() - PDefender->GetMLevel()) / 100.0f;

if (levelRatio < 0.01)
{
levelRatio = 0.01f;
}

// Calculate skill ratio.
float skillRatio = 1.0f;
uint8 meritReduction = 0;

if (PDefender->objtype == TYPE_PC)
{
CCharEntity* PChar = (CCharEntity*)PDefender;
float skillCap = GetMaxSkill((SKILLTYPE)PSpell->getSkillType(), PChar->GetMJob(), PChar->GetMLevel());
float skillLevel = PChar->GetSkill(PSpell->getSkillType());

// If skill cap is 0, player may be using a spell from their subjob.
if (skillCap == 0)
{
skillCap = GetMaxSkill((SKILLTYPE)PSpell->getSkillType(), PChar->GetSJob(), PChar->GetMLevel()); // This may need to be re-investigated in the future.
}

// If skill level is 0, set ratio to 10.
if (skillLevel <= 0)
{
skillRatio = 10.0f;
}
else
{
skillRatio = skillCap / skillLevel;
}

// Fetch player-only interruption rate reduction from merits.
meritReduction = ((CCharEntity*)PDefender)->PMeritPoints->GetMeritValue(MERIT_SPELL_INTERUPTION_RATE, (CCharEntity*)PDefender);
}

// SIRD reduces the interrupt after all the calculations are done -- as evidenced by the infamous "102% SIRD" builds.
// Anything less than 102% interrupt results in the ability to be interrupted.
// Note: the 102% is probably an x/256 x/1024 nonsense -- sometimes 101% works.
float SIRDRatio = (100.0f - meritReduction - (float)PDefender->getMod(Mod::SPELLINTERRUPT)) / 100.0f;
float chance = xirand::GetRandomNumber<float>(1.0f);

// This are all ratios.
// levelRatio : 0.01 to infinity.
// skillRatio: 1.0 to infinity.
// SIRDRatio: No limits. Can be negative. A negative value will guarantee NOT being interrupted.
float finalRatio = levelRatio * skillRatio * SIRDRatio; // TL;DR Higher = Worse = More chances to get interrupted.

// You get interrupted. Handle aquaveil.
if (chance < finalRatio)
{
if (PDefender->StatusEffectContainer->HasStatusEffect(EFFECT_AQUAVEIL))
{
auto aquaCount = PDefender->StatusEffectContainer->GetStatusEffect(EFFECT_AQUAVEIL)->GetPower();
if (aquaCount - 1 == 0) // removes the status, but still prevents the interrupt
{
PDefender->StatusEffectContainer->DelStatusEffect(EFFECT_AQUAVEIL);
}
else
{
PDefender->StatusEffectContainer->GetStatusEffect(EFFECT_AQUAVEIL)->SetPower(aquaCount - 1);
}
return false;
}

return true;
}

return false;
return luautils::callGlobal<bool>("xi.combat.magic.shouldInterruptSpell", PAttacker, PDefender, PSpell);
}

/************************************************************************
Expand Down
Loading