landsandboat/scripts/tests/systems/spells/aoe.lua
2026-02-20 18:42:33 -07:00

369 lines
16 KiB
Lua

describe('AoE', function()
---@type CClientEntityPair
local p1
---@type CClientEntityPair
local p2
before_each(function()
p1 = xi.test.world:spawnPlayer(
{
zone = xi.zone.GM_HOME,
job = xi.job.BLM,
level = 99,
})
p2 = xi.test.world:spawnPlayer(
{
zone = xi.zone.GM_HOME,
job = xi.job.BLM,
level = 99,
})
p1.actions:inviteToParty(p2)
p2.actions:acceptPartyInvite()
end)
describe('base cases', function()
it('returns base type and radius with no modifying effects', function()
end)
it('returns base type and radius for non-PC/non-Trust casters', function()
end)
end)
describe('Majesty #pld', function()
before_each(function()
p1:changeJob(xi.job.PLD)
p1:setLevel(99)
p1:addSpell(xi.magic.spell.CURE)
p1:addSpell(xi.magic.spell.PROTECT)
p1:addSpell(xi.magic.spell.SHELL)
p1.actions:useAbility(p1, xi.jobAbility.MAJESTY)
xi.test.world:tickEntity(p1)
p1.assert:hasEffect(xi.effect.MAJESTY)
end)
it('converts Cure spells to 10y AoE', function()
p1:setHP(1)
p2:setHP(1)
p1.actions:useSpell(p1, xi.magic.spell.CURE)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
assert(p1:getHP() > 1, 'p1 HP should be greater than 1')
assert(p2:getHP() > 1, 'p2 HP should be greater than 1')
end)
it('converts Protect spells to 10y AoE', function()
p1.actions:useSpell(p1, xi.magic.spell.PROTECT)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.PROTECT)
p2.assert:hasEffect(xi.effect.PROTECT)
end)
it('does not affect non-Cure/Protect spells', function()
p1.actions:useSpell(p1, xi.magic.spell.SHELL)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.SHELL)
p2.assert.no:hasEffect(xi.effect.SHELL)
end)
end)
describe('Accession #sch', function()
before_each(function()
p1:changeJob(xi.job.SCH)
p1:setLevel(99)
p1:changesJob(xi.job.RDM)
p1:setsLevel(49)
p1:addSpell(xi.magic.spell.PROTECT)
p1:addSpell(xi.magic.spell.HASTE)
p1.actions:useAbility(p1, xi.jobAbility.LIGHT_ARTS)
xi.test.world:tickEntity(p1)
p1.actions:useAbility(p1, xi.jobAbility.ACCESSION)
xi.test.world:tickEntity(p1)
p1.assert:hasEffect(xi.effect.ACCESSION)
end)
it('converts RADIAL_ACCE spells to 10y AoE', function()
p1.actions:useSpell(p1, xi.magic.spell.PROTECT)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.PROTECT)
p2.assert:hasEffect(xi.effect.PROTECT)
end)
it('does not affect spells without RADIAL_ACCE type', function()
p1.actions:useSpell(p1, xi.magic.spell.HASTE)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.HASTE)
p2.assert.no:hasEffect(xi.effect.HASTE)
end)
end)
describe('Divine Veil #whm', function()
before_each(function()
p1:changeJob(xi.job.WHM)
p1:setLevel(99)
p1:addSpell(xi.magic.spell.ERASE)
p1:addSpell(xi.magic.spell.PARALYNA)
p1:addSpell(xi.magic.spell.PROTECT)
end)
it('converts -na spells to 10y AoE with Divine Seal', function()
p2:addStatusEffect(xi.effect.PARALYSIS, { power = 1, duration = 60, origin = p2 })
p2.assert:hasEffect(xi.effect.PARALYSIS)
p1.actions:useAbility(p1, xi.jobAbility.DIVINE_SEAL)
xi.test.world:tickEntity(p1)
p1.actions:useSpell(p1, xi.magic.spell.PARALYNA)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p2.assert.no:hasEffect(xi.effect.PARALYSIS)
end)
it('converts -na spells to 10y AoE with AOE_NA mod chance', function()
p2:addStatusEffect(xi.effect.PARALYSIS, { power = 1, duration = 60, origin = p2 })
p2.assert:hasEffect(xi.effect.PARALYSIS)
p1:addItem(xi.item.YAGRUSH_75)
p1:equipItem(xi.item.YAGRUSH_75, nil, xi.slot.MAIN)
p1.actions:useSpell(p1, xi.magic.spell.PARALYNA)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p2.assert.no:hasEffect(xi.effect.PARALYSIS)
end)
it('converts Erase to 10y AoE with Divine Seal', function()
p1:addStatusEffect(xi.effect.SLOW, { power = 1, duration = 60, origin = p1 })
p2:addStatusEffect(xi.effect.SLOW, { power = 1, duration = 60, origin = p2 })
p1.assert:hasEffect(xi.effect.SLOW)
p2.assert:hasEffect(xi.effect.SLOW)
p1.actions:useAbility(p1, xi.jobAbility.DIVINE_SEAL)
xi.test.world:tickEntity(p1)
p1.actions:useSpell(p1, xi.magic.spell.ERASE)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert.no:hasEffect(xi.effect.SLOW)
p2.assert.no:hasEffect(xi.effect.SLOW)
end)
it('does not convert without Divine Veil trait', function()
p1:setLevel(49)
p2:addStatusEffect(xi.effect.PARALYSIS, { power = 1, duration = 60, origin = p2 })
p2.assert:hasEffect(xi.effect.PARALYSIS)
p1.actions:useAbility(p1, xi.jobAbility.DIVINE_SEAL)
xi.test.world:tickEntity(p1)
p1.actions:useSpell(p1, xi.magic.spell.PARALYNA)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p2.assert:hasEffect(xi.effect.PARALYSIS)
end)
it('does not convert without Divine Seal or AOE_NA mod', function()
p2:addStatusEffect(xi.effect.PARALYSIS, { power = 1, duration = 60, origin = p2 })
p2.assert:hasEffect(xi.effect.PARALYSIS)
p1.actions:useSpell(p1, xi.magic.spell.PARALYNA)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p2.assert:hasEffect(xi.effect.PARALYSIS)
end)
end)
describe('Manifestation #sch', function()
before_each(function()
p1:changeJob(xi.job.SCH)
p1:setLevel(99)
end)
it('converts RADIAL_MANI spells to 10y AoE', function()
p1:addStatusEffect(xi.effect.MANIFESTATION, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.SLEEP)
assert(spell, 'SLEEP spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] == xi.magic.aoe.RADIAL, 'Expected RADIAL type')
assert(result[2] == 10, 'Expected radius 10')
end)
it('does not affect spells without RADIAL_MANI type', function()
p1:addStatusEffect(xi.effect.MANIFESTATION, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.STONE)
assert(spell, 'STONE spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] ~= xi.magic.aoe.RADIAL or result[2] ~= 10,
'Should not convert to 10y AoE')
end)
end)
describe('Theurgic Focus #geo', function()
before_each(function()
p1:changeJob(xi.job.GEO)
p1:setLevel(99)
end)
it('halves the radius of -ra spells (Fira to Watera)', function()
p1:addStatusEffect(xi.effect.THEURGIC_FOCUS, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.FIRA)
assert(spell, 'FIRA spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] == xi.magic.aoe.RADIAL, 'Expected RADIAL type')
assert(result[2] == 5, 'Expected radius 5y')
end)
it('does not affect non -ra spells', function()
p1:addStatusEffect(xi.effect.THEURGIC_FOCUS, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.FIRE)
assert(spell, 'FIRE spell not found')
local baseRadius = spell:getRadius()
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[2] == baseRadius, 'Radius should remain unchanged')
end)
end)
describe('Diffusion #blu', function()
before_each(function()
p1:changeJob(xi.job.BLU)
p1:setLevel(99)
end)
it('converts DIFFUSION type BLU spells to 10y AoE', function()
p1:addStatusEffect(xi.effect.DIFFUSION, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.METALLIC_BODY)
assert(spell, 'METALLIC_BODY spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] == xi.magic.aoe.RADIAL, 'Expected RADIAL type')
assert(result[2] == 10, 'Expected radius 10')
end)
it('does not affect spells without DIFFUSION type', function()
p1:addStatusEffect(xi.effect.DIFFUSION, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.POLLEN)
assert(spell, 'POLLEN spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] ~= xi.magic.aoe.RADIAL or result[2] ~= 10,
'Should not convert to 10y AoE')
end)
end)
describe('Convergence #blu', function()
before_each(function()
p1:changeJob(xi.job.BLU)
p1:setLevel(99)
end)
it('forces offensive BLU magic spells to single target', function()
p1:addStatusEffect(xi.effect.CONVERGENCE, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.MAELSTROM)
assert(spell, 'MAELSTROM spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] == xi.magic.aoe.NONE, 'Expected NONE type (single target)')
assert(result[2] == 0, 'Expected radius 0')
end)
it('does not affect physical BLU spells (ELEMENT_NONE)', function()
p1:addStatusEffect(xi.effect.CONVERGENCE, { power = 1, duration = 60, origin = p1 })
local spell = GetSpell(xi.magic.spell.WHIRL_OF_RAGE)
assert(spell, 'WHIRL_OF_RAGE spell not found')
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
-- Physical BLU spells should retain their AoE
assert(result[1] ~= xi.magic.aoe.NONE, 'Physical AoE spells should not be forced to single target')
end)
end)
describe('Utsusemi AoE #nin', function()
before_each(function()
p1:changeJob(xi.job.NIN)
p1:setLevel(99)
p1:addSpell(xi.magic.spell.UTSUSEMI_NI)
p1:addItem(xi.item.SHIHEI, 99)
p1:setMod(xi.mod.UTSUSEMI_AOE, 1)
end)
it('converts Utsusemi spells to 10y AoE with UTSUSEMI_AOE mod', function()
p1.actions:useSpell(p1, xi.magic.spell.UTSUSEMI_NI)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.COPY_IMAGE)
p2.assert:hasEffect(xi.effect.COPY_IMAGE)
end)
it('does not affect Utsusemi without the mod', function()
p1:setMod(xi.mod.UTSUSEMI_AOE, 0)
p1.actions:useSpell(p1, xi.magic.spell.UTSUSEMI_NI)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p1.assert:hasEffect(xi.effect.COPY_IMAGE)
p2.assert.no:hasEffect(xi.effect.COPY_IMAGE)
end)
end)
describe('Songs #brd', function()
before_each(function()
p1:changeJob(xi.job.BRD)
p1:setLevel(99)
p1:addItem(xi.item.GJALLARHORN_99)
p1:addSpell(xi.magic.spell.MAGES_BALLAD)
end)
it('forces single target with Pianissimo effect and removes it after use', function()
p1.actions:useAbility(p1, xi.jobAbility.PIANISSIMO)
xi.test.world:tickEntity(p1)
p1.assert:hasEffect(xi.effect.PIANISSIMO)
p1.actions:useSpell(p2, xi.magic.spell.MAGES_BALLAD)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(5)
p2.assert:hasEffect(xi.effect.BALLAD)
p1.assert.no:hasEffect(xi.effect.BALLAD)
p1.assert.no:hasEffect(xi.effect.PIANISSIMO)
end)
it('returns base radius without String instrument equipped', function()
p1:setSkillLevel(xi.skill.STRING_INSTRUMENT, 424)
local s = spy('xi.combat.magicAoE.calculateTypeAndRadius')
p1.actions:useSpell(p1, xi.magic.spell.MAGES_BALLAD)
xi.test.world:tickEntity(p1)
xi.test.world:skipTime(10)
s:called(1)
assert(s.calls[1].returned[1] == xi.magic.aoe.RADIAL, 'Expected RADIAL aoe type')
assert(s.calls[1].returned[2] == 10, 'Expected base radius of 10')
end)
it('scales radius from 1.0x to 2.0x based on String skill vs song level cap', function()
p1:addItem(xi.item.MAPLE_HARP)
p1:equipItem(xi.item.MAPLE_HARP, nil, xi.slot.RANGED)
local spell = GetSpell(xi.magic.spell.MAGES_BALLAD)
assert(spell)
local testCases =
{
{ skill = 0, expectedRadius = 10 },
{ skill = 79, expectedRadius = 10 },
{ skill = 80, expectedRadius = 11 },
{ skill = 86, expectedRadius = 11 },
{ skill = 87, expectedRadius = 12 },
{ skill = 93, expectedRadius = 12 },
{ skill = 94, expectedRadius = 13 },
{ skill = 100, expectedRadius = 13 },
{ skill = 101, expectedRadius = 14 },
{ skill = 107, expectedRadius = 14 },
{ skill = 108, expectedRadius = 15 },
{ skill = 115, expectedRadius = 15 },
{ skill = 116, expectedRadius = 16 },
{ skill = 122, expectedRadius = 16 },
{ skill = 123, expectedRadius = 17 },
{ skill = 129, expectedRadius = 17 },
{ skill = 130, expectedRadius = 18 },
{ skill = 136, expectedRadius = 18 },
{ skill = 137, expectedRadius = 19 },
{ skill = 143, expectedRadius = 19 },
{ skill = 144, expectedRadius = 20 },
{ skill = 424, expectedRadius = 20 },
}
for _, tc in ipairs(testCases) do
p1:setSkillLevel(xi.skill.STRING_INSTRUMENT, tc.skill * 10)
local result = xi.combat.magicAoE.calculateTypeAndRadius(p1, spell)
assert(result[1] == xi.magic.aoe.RADIAL,
string.format('Skill %d: Expected RADIAL type', tc.skill))
assert(result[2] == tc.expectedRadius,
string.format('Skill %d: Expected radius %d, got %s', tc.skill, tc.expectedRadius, tostring(result[2])))
end
end)
end)
end)