landsandboat/scripts/globals/monstrosity.lua
2026-02-20 18:42:33 -07:00

1611 lines
44 KiB
Lua

-----------------------------------
-- Monstrosity (MON)
--
-- === How does it work? ===
--
-- Monstrosity is enabled through two mechanisms: setting your job to JOB_MON (23) and zoning.
-- Currently, there are some details that seemingly can only be populated at zone-time, so switching in/out
-- of MON mode is reliant on zoning.
--
-- When you zone your job will be checked, and if it is JOB_MON, then PChar->m_PMonstrosity will get
-- populated with your relevant Monstrosity data from table defined in char_monstrosity.sql. If you don't have
-- this information yet, it'll be created and saved for you with the defaults (the starting 3 MONs and the basic instincts).
--
-- Most other logic for determining stats, exp, exp ranges, traits, etc. will check you are either JOB_MON
-- or have m_PMonstrosity populated, and then look up what main/sub job your current species is, and then
-- forward that information into the relevant code for working out stats, etc.
--
-- IT IS VITAL that m_PMonstrosity is managed correctly, or that it's existance is constantly checked.
--
-- There is _a lot_ of client-side validation for MON, but we have all the information available server-side,
-- so we make sure to validate everything that comes through the zone_in and MON equip packets. It's also important
-- to validate all things for MON, because if they're invalid the client will get stuck in a state where they can't change
-- jobs, species, instincts, or names without GM intervention.
--
-- MONs main and subjob are in lock-step, so if you are a MNK15/NIN, the NIN will also be Lv15, and you'll get all the abilities,
-- traits, and stat contributions (TODO?) from both - except for the 2H which comes from the main job.
-----------------------------------
require('scripts/globals/npc_util')
require('scripts/globals/quests')
-----------------------------------
xi = xi or {}
xi.monstrosity = xi.monstrosity or {}
-----------------------------------
-- Enums
-----------------------------------
xi.monstrosity.species =
{
RABBIT = 1,
BEHEMOTH = 2,
TIGER = 3,
SHEEP = 4,
RAM = 5,
DHALMEL = 6,
COEURL = 7,
OPO_OPO = 8,
MANTICORE = 9,
BUFFALO = 10,
MARID = 11,
CERBERUS = 12,
GNOLE = 13,
FUNGUAR = 15,
TREANT_SAPLING = 16,
MORBOL = 17,
MANDRAGORA = 18,
SABOTENDER = 19,
FLYTRAP = 20,
GOOBBUE = 21,
RAFFLESIA = 22,
PANOPT = 23,
BEE = 27,
BEETLE = 28,
CRAWLER = 29,
FLY = 30,
SCORPION = 31,
SPIDER = 32,
ANTLION = 33,
DIREMITE = 34,
CHIGOE = 35,
WAMOURACAMPA = 36,
LADYBUG = 37,
GNAT = 38,
LIZARD = 43,
RAPTOR = 44,
ADAMANTOISE = 45,
BUGARD = 46,
EFT = 47,
WIVRE = 48,
PEISTE = 49,
SLIME = 52,
HECTEYES = 53,
FLAN = 54,
SLUG = 56,
SANDWORM = 57,
LEECH = 58,
CRAB = 60,
PUGIL = 61,
SEA_MONK = 62,
URAGNITE = 63,
OROBON = 64,
RUSZOR = 65,
TOAD = 66,
BIRD = 69,
COCKATRICE = 70,
ROC = 71,
BAT = 72,
HIPPOGRYPH = 73,
APKALLU = 74,
COLIBRI = 75,
AMPHIPTERE = 76,
ASTOLTIAN_SLIME = 126,
EORZEAN_SPRIGGAN = 127,
}
xi.monstrosity.variants =
{
-- Rabbit
ONYX_RABBIT = 0,
ALABASTER_RABBIT = 1,
LAPINION = 2,
-- Behemoth
ELASMOTH = 3,
-- Tiger
LEGENDARY_TIGER = 5,
SMILODON = 6,
-- Sheep
KARAKUL = 7,
-- Coeurl
LYNX = 10,
COLLARED_LYNX = 11,
-- Manticore
LEGENDARY_MANTICORE = 12,
-- Cerberus
ORTHRUS = 13,
-- Gnole
BIPEDAL_GNOLE = 14,
-- Funguar
COPPERCAP = 15,
-- Treant Sapling
TREANT = 16,
FLOWERING_TREANT = 17,
SCARLET_TINGED_TREANT = 18,
BARREN_TREANT = 19,
NECKLACED_TREANT = 20,
-- Morbol
PYGMY_MORBOL = 21,
SCARE_MORBOL = 22,
AMERETAT = 23,
PURBOL = 24,
-- Mandragora
KORRIGAN = 25,
LYCOPODIUM = 26,
PYGMY_MANDRAGORA = 27,
ADENIUM = 28,
PACHYPODIUM = 29,
ENLIGHTENED_MANDRAGORA = 30,
NEW_YEAR_MANDRAGORA = 31,
-- Sabotender
SABOTENDER_FLORIDO = 32,
-- Rafflesia
MITRASTEMA = 33,
-- Bee
VERMILLION_AND_ONYX_BEE = 34,
ZAFFRE_BEE = 35,
-- Beetle
ONYX_BEETLE = 36,
GAMBOGE_BEETLE = 37,
-- Crawler
ERUCA = 38,
EMERALD_CRAWLER = 39,
PYGMY_EMERALD_CRAWLER = 40,
-- Fly
VERMILLION_FLY = 41,
-- Scorpion
SCOLOPENDRID = 42,
UNUSUAL_SCOLOPENDRID = 43,
-- Spider
RETICULATED_SPIDER = 44,
VERMILLION_AND_ONYX_SPIDER = 45,
-- Antlion
ONYX_ANTLION = 46,
FORMICEROS = 47,
-- Diremite
ARUNDIMITE = 48,
-- Chigoe
AZURE_CHIGOE = 49,
-- Wamouracampa
COILED_WAMOURACAMPA = 50,
-- Wamoura
WAMOURA = 51,
CORAL_WAMOURA = 52,
-- Ladybug
GOLD_LADYBUG = 53,
-- Gnat
MIDGE = 54,
-- Lizard
ASHEN_LIZARD = 59,
-- Raptor
EMERALD_RAPTOR = 60,
VERMILLION_RAPTOR = 61,
-- Adamantoise
PYGMY_ADAMANTOISE = 62,
LEGENDARY_ADAMANTOISE = 63,
FERROMANTOISE = 64,
-- Bugard
ABYSSOBUGARD = 65,
-- Eft
TARICHUK = 66,
-- Wivre
UNUSUAL_WIVRE = 67,
-- Peiste
SIBILUS = 68,
-- Slime
CLOT = 73,
GOLD_SLIME = 74,
BOIL = 75,
-- Flan
GOLD_FLAN = 76,
BLANCMANGE = 77,
-- Sandworm
PYGMY_SANDWORM = 78,
GIGAWORM = 79,
-- Leech
AZURE_LEECH = 80,
OBDELLA = 81,
-- Crab
VERMILLION_CRAB = 84,
BASKET_BURDENED_CRAB = 85,
VERMILLION_BASKET_BURDENED_CRAB = 86,
PORTER_CRAB = 87,
-- Pugil
JAGIL = 88,
-- Sea Monk
AZURE_SEA_MONK = 89,
-- Uragnite
LIMASCABRA = 90,
-- Orobon
PYGMY_OROBON = 91,
OGREBON = 92,
-- Toad
AZURE_TOAD = 93,
VERMILLION_TOAD = 94,
-- Bird
ONYX_BIRD = 95,
-- Cockatrice
ZIZ = 96,
-- Roc
LEGENDARY_ROC = 97,
GAGANA = 98,
-- Bat
BATS = 99,
VERMILLION_BAT = 100,
VERMILLION_BATS = 101,
-- Apkallu
INGUZA = 102,
-- Colibri
TOUCALIBRI = 103,
-- Amphiptere
SANGUIPTERE = 104,
-- Slime
SHE_SLIME = 252,
METAL_SLIME = 253,
-- Spriggan
SPRIGGAN_C = 254,
SPRIGGAN_G = 255,
}
xi.monstrosity.purchasableInstincts =
{
-- Default (0x1F)
HUME_I = 0,
ELVAAN_I = 1,
TARU_I = 2,
MITHRA_I = 3,
GALKA_I = 4,
HUME_II = 5,
ELVAAN_II = 6,
TARU_II = 7,
MITHRA_II = 8,
GALKA_II = 9,
WAR = 10,
MNK = 11,
WHM = 12,
BLM = 13,
RDM = 14,
THF = 15,
PLD = 16,
DRK = 17,
BST = 18,
BRD = 19,
RNG = 20,
SAM = 21,
NIN = 22,
DRG = 23,
SMN = 24,
BLU = 25,
COR = 26,
PUP = 27,
DNC = 28,
SCH = 29,
GEO = 30,
RUN = 31,
}
local limitBreakQuests =
{
[xi.job.BLU] = { xi.questLog.AHT_URHGAN, xi.quest.id.ahtUrhgan.THE_BEAST_WITHIN },
[xi.job.COR] = { xi.questLog.AHT_URHGAN, xi.quest.id.ahtUrhgan.BREAKING_THE_BONDS_OF_FATE },
[xi.job.PUP] = { xi.questLog.BASTOK, xi.quest.id.bastok.ACHIEVING_TRUE_POWER },
[xi.job.DNC] = { xi.questLog.JEUNO, xi.quest.id.jeuno.A_FURIOUS_FINALE },
[xi.job.SCH] = { xi.questLog.OTHER_AREAS, xi.quest.id.otherAreas.SURVIVAL_OF_THE_WISEST },
[xi.job.GEO] = { xi.questLog.ADOULIN, xi.quest.id.adoulin.ELEMENTARY_MY_DEAR_SYLVIE },
[xi.job.RUN] = { xi.questLog.ADOULIN, xi.quest.id.adoulin.ENDEAVORING_TO_AWAKEN },
}
-- NOTE: Cost and granted species/variant are hardcoded into Terynon's event; however, the requirements
-- to get each of these purchasable MONs is not displayed, and can be modified to a different set or
-- level. The requirements are limited to species!
local terynonMonData =
{
[0] = -- Beasts
{
[0] =
{
monVariant = xi.monstrosity.variants.LAPINION,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.RABBIT, 90 },
},
},
[1] =
{
monSpecies = xi.monstrosity.species.SHEEP,
infamyCost = 3000,
},
[2] =
{
monSpecies = xi.monstrosity.species.BEHEMOTH,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.RABBIT, 75 },
{ xi.monstrosity.species.OPO_OPO, 75 },
{ xi.monstrosity.species.GNOLE, 75 },
},
},
[3] =
{
monVariant = xi.monstrosity.variants.ELASMOTH,
infamyCost = 25000,
requirements =
{
{ xi.monstrosity.species.BEHEMOTH, 50 },
},
},
[4] =
{
monSpecies = xi.monstrosity.species.CERBERUS,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.BUFFALO, 60 },
{ xi.monstrosity.species.MANTICORE, 60 },
{ xi.monstrosity.species.MARID, 60 },
{ xi.monstrosity.species.SHEEP, 60 },
{ xi.monstrosity.species.DHALMEL, 60 },
},
},
[5] =
{
monVariant = xi.monstrosity.variants.ORTHRUS,
infamyCost = 25000,
requirements =
{
{ xi.monstrosity.species.CERBERUS, 50 },
},
},
},
[1] = -- Plantoids
{
[0] =
{
monVariant = xi.monstrosity.variants.PYGMY_MANDRAGORA,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.MANDRAGORA, 45 },
},
},
[1] =
{
monSpecies = xi.monstrosity.species.TREANT,
infamyCost = 3000,
},
[2] =
{
monVariant = xi.monstrosity.variants.PYGMY_MORBOL,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.MORBOL, 1 },
},
},
[3] =
{
monVariant = xi.monstrosity.variants.PURBOL,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.MORBOL, 75 },
},
},
},
[2] = -- Vermin
{
[0] =
{
monVariant = xi.monstrosity.variants.GOLD_LADYBUG,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.LADYBUG, 50 },
},
},
[1] =
{
monSpecies = xi.monstrosity.species.BEETLE,
infamyCost = 3000,
},
[2] =
{
monVariant = xi.monstrosity.variants.UNUSUAL_SCOLOPENDRID,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.SCORPION, 60 },
},
},
[3] =
{
monSpecies = xi.monstrosity.species.ANTLION,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.SCORPION, 60 },
},
},
[4] =
{
monVariant = xi.monstrosity.variants.FORMICEROS,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.ANTLION, 60 },
},
},
[5] =
{
monVariant = xi.monstrosity.variants.PYGMY_EMERALD_CRAWLER,
infamyCost = 6000,
requirements =
{
{ xi.monstrosity.species.CRAWLER, 60 },
},
},
[6] =
{
monVariant = xi.monstrosity.variants.CORAL_WAMOURA,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.WAMOURACAMPA, 60 },
},
},
[7] =
{
monSpecies = xi.monstrosity.species.GNAT,
infamyCost = 5000,
requirements =
{
{ xi.monstrosity.species.LADYBUG, 50 },
{ xi.monstrosity.species.WAMOURACAMPA, 50 },
},
},
},
[3] = -- Lizards
{
[0] =
{
monVariant = xi.monstrosity.species.UNUSUAL_WIVRE,
infamyCost = 7500,
requirements =
{
{ xi.monstrosity.species.WIVRE, 60 },
},
},
[1] =
{
monSpecies = xi.monstrosity.species.ADAMANTOISE,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.BUGARD, 60 },
{ xi.monstrosity.species.LIZARD, 60 },
{ xi.monstrosity.species.WIVRE, 60 },
},
},
[2] =
{
monVariant = xi.monstrosity.variants.FERROMANTOISE,
infamyCost = 20000,
requirements =
{
{ xi.monstrosity.species.ADAMANTOISE, 70 },
},
},
[3] =
{
monSpecies = xi.monstrosity.species.RAPTOR,
infamyCost = 3000,
},
[4] =
{
monSpecies = xi.monstrosity.species.PEISTE,
infamyCost = 8000,
requirements =
{
{ xi.monstrosity.species.EFT, 50 },
{ xi.monstrosity.species.RAPTOR, 50 },
},
},
[5] =
{
monVariant = xi.monstrosity.variants.SIBILUS,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.PEISTE, 50 },
},
},
},
[4] = -- Amorphs
{
[0] =
{
monSpecies = xi.monstrosity.species.SLIME,
infamyCost = 3000,
},
[1] =
{
monVariant = xi.monstrosity.variants.BOIL,
infamyCost = 25000,
requirements =
{
{ xi.monstrosity.species.SLIME, 50 },
},
},
[2] =
{
monVariant = xi.monstrosity.variants.PYGMY_SANDWORM,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.SANDWORM, 1 },
},
},
[3] =
{
monVariant = xi.monstrosity.variants.GIGAWORM,
infamyCost = 25000,
requirements =
{
{ xi.monstrosity.species.SANDWORM, 60 },
},
},
[4] =
{
monSpecies = xi.monstrosity.species.LEECH,
infamyCost = 2000,
},
},
[5] = -- Aquans
{
[0] =
{
monSpecies = xi.monstrosity.species.CRAB,
infamyCost = 2000,
},
[1] =
{
monVariant = xi.monstrosity.variants.BASKET_BURDENED_CRAB,
infamyCost = 20000,
requirements =
{
{ xi.monstrosity.species.CRAB, 1 },
},
},
[2] =
{
monVariant = xi.monstrosity.variants.VERMILLION_BASKET_BURDENED_CRAB,
infamyCost = 20000,
requirements =
{
{ xi.monstrosity.species.CRAB, 15 },
},
},
[3] =
{
monVariant = xi.monstrosity.variants.PORTER_CRAB,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.CRAB, 60 },
},
},
[4] =
{
monSpecies = xi.monstrosity.species.PUGIL,
infamyCost = 3000,
},
[5] =
{
monVariant = xi.monstrosity.variants.LIMASCABRA,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.URAGNITE, 50 },
},
},
[6] =
{
monVariant = xi.monstrosity.variants.PYGMY_OROBON,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.OROBON, 1 },
},
},
[7] =
{
monVariant = xi.monstrosity.variants.OGREBON,
infamyCost = 18000,
requirements =
{
{ xi.monstrosity.species.OROBON, 50 },
},
},
[8] =
{
monSpecies = xi.monstrosity.species.RUSZOR,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.OROBON, 75 },
{ xi.monstrosity.species.URAGNITE, 75 },
},
},
},
[6] = -- Birds
{
[0] =
{
monSpecies = xi.monstrosity.species.COCKATRICE,
infamyCost = 3000,
},
[1] =
{
monVariant = xi.monstrosity.variants.GAGANA,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.ROC, 75 },
},
},
[2] =
{
monSpecies = xi.monstrosity.species.BAT,
infamyCost = 2000,
},
[3] =
{
monVariant = xi.monstrosity.variants.INGUZA,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.APKALLU, 50 },
},
},
[4] =
{
monSpecies = xi.monstrosity.species.COLIBRI,
infamyCost = 5000,
requirements =
{
{ xi.monstrosity.species.BAT, 50 },
{ xi.monstrosity.species.BIRD, 45 },
},
},
[5] =
{
monVariant = xi.monstrosity.variants.TOUCALIBRI,
infamyCost = 15000,
requirements =
{
{ xi.monstrosity.species.COLIBRI, 50 },
},
},
[6] =
{
monSpecies = xi.monstrosity.species.AMPHIPTERE,
infamyCost = 10000,
requirements =
{
{ xi.monstrosity.species.COCKATRICE, 75 },
{ xi.monstrosity.species.ROC, 75 },
{ xi.monstrosity.species.HIPPOGRYPH, 75 },
},
},
[7] =
{
monVariant = xi.monstrosity.variants.SANGUIPTERE,
infamyCost = 20000,
requirements =
{
{ xi.monstrosity.species.AMPHIPTERE, 50 },
},
},
},
}
xi.monstrosity.teleports =
{
[xi.zone.EAST_RONFAURE] =
{
{ 120, 0.5, -530, 192 },
{ 115, -59.684, 247, 16 },
},
[xi.zone.QUFIM_ISLAND] =
{
{ -2, -20.001, 324, 64 },
{ 161, -20, 37, 192 },
},
[xi.zone.SOUTH_GUSTABERG] =
{
{ -115, -0.136, -165, 64 },
},
[xi.zone.VALKURM_DUNES] =
{
{ 838, 0, -162, 64 },
},
[xi.zone.WESTERN_ALTEPA_DESERT] =
{
{ 685.548, -1.744, -50.395, 128 },
},
}
-- NOTE: The zones in this list are not customisable, but the level caps are!
xi.monstrosity.belligerencyCaps =
{
[xi.zone.BUBURIMU_PENINSULA] = 30,
[xi.zone.XARCABARD] = 60,
[xi.zone.ULEGUERAND_RANGE] = 90,
}
-----------------------------------
-- Helpers
-----------------------------------
-- Use xi.monstrosity.species
xi.monstrosity.unlockStartingMONs = function(player, choice)
local data =
{
monstrosityId = choice,
species = choice,
}
player:setMonstrosityData(data)
end
-- Use xi.monstrosity.species
xi.monstrosity.getSpeciesLevel = function(player, species)
local data = player:getMonstrosityData()
return data['levels'][species]
end
-- Use xi.monstrosity.species
xi.monstrosity.hasUnlockedSpecies = function(player, species)
return xi.monstrosity.getSpeciesLevel(player, species) > 0
end
-- Use xi.monstrosity.species
xi.monstrosity.setSpeciesLevel = function(player, species, level)
local data = player:getMonstrosityData()
data.levels[species] = level
player:setMonstrosityData(data)
end
-- Use xi.monstrosity.species
xi.monstrosity.unlockSpecies = function(player, species)
if not xi.monstrosity.hasUnlockedSpecies(player, species) then
xi.monstrosity.setSpeciesLevel(player, species, 1)
end
end
-- Use xi.monstrosity.variants
xi.monstrosity.hasUnlockedVariant = function(player, variant)
local data = player:getMonstrosityData()
local byteOffset = math.floor(variant / 8)
local shiftAmount = variant % 8
if byteOffset < 32 then
return bit.band(data.variants[byteOffset] or 0, bit.lshift(0x01, shiftAmount)) > 0
end
return false
end
-- Use xi.monstrosity.variants
xi.monstrosity.unlockVariant = function(player, variant)
if not xi.monstrosity.hasUnlockedVariant(player, variant) then
local data = player:getMonstrosityData()
local byteOffset = math.floor(variant / 8)
local shiftAmount = variant % 8
if byteOffset < 32 then
data.variants[byteOffset] = bit.bor(data.variants[byteOffset] or 0, bit.lshift(0x01, shiftAmount))
else
print('byteOffset out of range')
end
player:setMonstrosityData(data)
end
end
local function hasPurchasedInstinct(player, purchasableInstinctId)
local data = player:getMonstrosityData()
local byteOffset = 20 + math.floor(purchasableInstinctId / 8)
local shiftAmount = purchasableInstinctId % 8
if byteOffset >= 20 and byteOffset < 24 then
return bit.band(data.instincts[byteOffset], bit.lshift(1, shiftAmount)) > 0
else
print('byteOffset out of range')
end
end
local function getPurchasedInstinctsMask(player)
local instinctMask = 0
for _, purchasableInstinctId in pairs(xi.monstrosity.purchasableInstincts) do
if
purchasableInstinctId >= xi.monstrosity.purchasableInstincts.HUME_II and
hasPurchasedInstinct(player, purchasableInstinctId)
then
instinctMask = utils.mask.setBit(instinctMask, purchasableInstinctId - xi.monstrosity.purchasableInstincts.HUME_II, true)
end
end
return instinctMask
end
local function addPurchasedInstinct(player, purchasableInstinctId)
local data = player:getMonstrosityData()
local byteOffset = 20 + math.floor(purchasableInstinctId / 8)
local shiftAmount = purchasableInstinctId % 8
if byteOffset >= 20 and byteOffset < 24 then
data.instincts[byteOffset] = bit.bor(data.instincts[byteOffset] or 0, bit.lshift(0x01, shiftAmount))
else
print('byteOffset out of range')
end
player:setMonstrosityData(data)
end
-- When generating Terynon's mask for discounts, we need a bitmask for
-- specific jobs. Since only one quest exists for pre-ToAU jobs, use
-- Maat's Cap tracking for those.
local function hasCompletedLimitBreak(player, jobId)
if jobId <= xi.job.SMN then
local maatsCap = player:getCharVar('maatsCap')
return utils.mask.getBit(maatsCap, jobId - 1)
else
return player:hasCompletedQuest(unpack(limitBreakQuests[jobId]))
end
end
local function getLimitBreakMask(player)
local limitMask = 0
for jobId = xi.job.WAR, xi.job.RUN do
if hasCompletedLimitBreak(player, jobId) then
limitMask = utils.mask.setBit(limitMask, jobId - 1, true)
end
end
return limitMask
end
local function hasPurchaseRequirements(player, monCategory, selectedMon)
local selectedMonData = terynonMonData[monCategory][selectedMon]
local eligibleSpecies = selectedMonData.monSpecies and xi.monstrosity.getSpeciesLevel(player, selectedMonData.monSpecies) == 0
local eligibleVariant = selectedMonData.monVariant and not xi.monstrosity.hasUnlockedVariant(player, selectedMonData.monVariant)
if
eligibleSpecies or
eligibleVariant
then
if selectedMonData.requirements then
for _, reqTable in ipairs(selectedMonData.requirements) do
if xi.monstrosity.getSpeciesLevel(player, reqTable[1]) < reqTable[2] then
return false
end
end
end
return true
end
return false
end
local function getMonPageMask(player, monCategory)
local pageMask = 0
if terynonMonData[monCategory] then
local categoryTable = terynonMonData[monCategory]
for bitPos, _ in pairs(categoryTable) do
if hasPurchaseRequirements(player, monCategory, bitPos) then
pageMask = utils.mask.setBit(pageMask, bitPos, true)
end
end
end
return pageMask
end
-----------------------------------
-- Bound by C++ (DO NOT CHANGE SIGNATURE)
-----------------------------------
xi.monstrosity.onMonstrosityUpdate = function(player, data)
-- Tap level-based unlocks
-- Instincts by MON level
-- NOTE: Since this is a bitfield, it's zero-indexed!
for _, val in pairs(xi.monstrosity.species) do
local speciesKey = val
local speciesLevel = data.levels[val]
local byteOffset = math.floor(speciesKey / 4)
local unlockAmount = math.floor(speciesLevel / 30)
local shiftAmount = (speciesKey * 2) % 8
-- Special case for writing Slime & Spriggan data at the end of the 64-byte array
if byteOffset == 31 then
byteOffset = 63
end
if byteOffset < 64 then
data.instincts[byteOffset] = bit.bor(data.instincts[byteOffset] or 0, bit.lshift(unlockAmount, shiftAmount))
else
print('byteOffset out of range')
end
end
-- TODO: Handle level-based variants here
end
xi.monstrosity.onMonstrosityReturnToEntrance = function(player)
local data = player:getMonstrosityData()
local x = data.entry_x
local y = data.entry_y
local z = data.entry_z
local rot = data.entry_rot
local zoneId = data.entry_zone_id
local mjob = data.entry_mjob
local sjob = data.entry_sjob
-- TODO: Sanity check
for _, effect in pairs(player:getStatusEffects()) do
player:delStatusEffectSilent(effect:getEffectType())
end
if xi.settings.main.MONSTROSITY_TELEPORT_TO_FERETORY == 1 then
if player:getZoneID() ~= xi.zone.FERETORY then
player:setPos(-358, -3.4, -440, 64, xi.zone.FERETORY)
return
end
-- Otherwise fallthrough and exit as normal
end
player:changeJob(mjob)
player:changesJob(sjob)
player:setPos(x, y, z, rot, zoneId)
end
-----------------------------------
-- Relinquish
-----------------------------------
xi.monstrosity.relinquishSteps =
{
[0] = function(player)
player:messageBasic(xi.msg.basic.FERETORY_COUNTDOWN, 0, 4)
end,
[1] = function(player)
player:messageBasic(xi.msg.basic.FERETORY_COUNTDOWN, 0, 3)
end,
[2] = function(player)
player:messageBasic(xi.msg.basic.FERETORY_COUNTDOWN, 0, 2)
end,
[3] = function(player)
player:messageBasic(xi.msg.basic.FERETORY_COUNTDOWN, 0, 1)
end,
[4] = function(player)
xi.monstrosity.onMonstrosityReturnToEntrance(player)
end,
}
xi.monstrosity.relinquishFuncBody = function(player)
-- TODO: Make this countdown interruptable
player:timer(1000, function(playerArg)
local step = utils.clamp(playerArg:getLocalVar('RELINQUISH_COUNTDOWN'), 0, 4)
xi.monstrosity.relinquishSteps[step](playerArg)
playerArg:setLocalVar('RELINQUISH_COUNTDOWN', step + 1)
xi.monstrosity.relinquishFuncBody(playerArg)
end)
end
xi.monstrosity.relinquishOnAbility = function(player, target, ability)
xi.monstrosity.relinquishFuncBody(player)
end
-----------------------------------
-- Debug
-----------------------------------
xi.monstrosity.unlockAll = function(player)
-- Complete quest
local logId = xi.questLog.OTHER_AREAS
player:completeQuest(logId, xi.quest.id[xi.quest.area[logId]].MONSTROSITY)
-- Add Monstrosity key item
player:addKeyItem(xi.keyItem.RING_OF_SUPERNAL_DISJUNCTION)
local data = player:getMonstrosityData()
-- Set all levels to 99
for _, val in pairs(xi.monstrosity.species) do
data.levels[val] = 99
end
-- Instincts by MON level
-- NOTE: Since this is a bitfield, it's zero-indexed!
for _, val in pairs(xi.monstrosity.species) do
local speciesKey = val
local speciesLevel = data.levels[val]
local byteOffset = math.floor(speciesKey / 4)
local unlockAmount = math.floor(speciesLevel / 30)
local shiftAmount = (speciesKey * 2) % 8
-- Special case for writing Slime & Spriggan data at the end of the 64-byte array
if byteOffset == 31 then
byteOffset = 63
end
if byteOffset < 64 then
data.instincts[byteOffset] = bit.bor(data.instincts[byteOffset] or 0, bit.lshift(unlockAmount, shiftAmount))
else
print('byteOffset out of range')
end
end
-- Instincts (Purchasable)
for _, val in pairs(xi.monstrosity.purchasableInstincts) do
local byteOffset = 20 + math.floor(val / 8)
local shiftAmount = val % 8
if byteOffset >= 20 and byteOffset < 24 then
data.instincts[byteOffset] = bit.bor(data.instincts[byteOffset] or 0, bit.lshift(0x01, shiftAmount))
else
print('byteOffset out of range')
end
end
-- Variants
-- Force unlock all
for _, val in pairs(xi.monstrosity.variants) do
local speciesKey = val
local byteOffset = math.floor(speciesKey / 8)
local shiftAmount = speciesKey % 8
if byteOffset < 32 then
data.variants[byteOffset] = bit.bor(data.variants[byteOffset] or 0, bit.lshift(0x01, shiftAmount))
else
print('byteOffset out of range')
end
end
-- Set data
player:setMonstrosityData(data)
end
-----------------------------------
-- Odyssean Passage (Feretory Only)
-----------------------------------
xi.monstrosity.odysseanPassageOnTrade = function(player, npc, trade)
end
xi.monstrosity.odysseanPassageOnTrigger = function(player, npc)
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return
end
local monSize = player:getMonstrositySize()
local hasBelligerency = player:getBelligerencyFlag() and 1 or 0
-- Show the full menu, not the restricted one
if xi.settings.main.MONSTROSITY_PVP_ZONE_BYPASS == 1 then
hasBelligerency = 0
end
-- NOTE: The list of available zones is built from the char's list of
-- visited zones. If you haven't visited any zones in a category it'll back
-- out immediately.
-- NOTE: Param5 is not consistent, Bee has seen 0, 1, and 2 so far
-- player:startEvent(5, 0, 0, 0, 0, 2, 0, 0, 0) -- Bee
player:startEvent(5, 0, monSize, hasBelligerency, 0, 0, 0, 0, 0)
end
xi.monstrosity.odysseanPassageOnEventUpdate = function(player, csid, option, npc)
local zoneSelected = bit.rshift(option, 4)
player:updateEvent(xi.monstrosity.belligerencyCaps[zoneSelected], 0, 0, 0, 1, 0, 0, 0)
end
xi.monstrosity.odysseanPassageOnEventFinish = function(player, csid, option, npc)
local eventOption = bit.band(option, 0xF)
local zoneSelected = bit.rshift(option, 4)
if eventOption == 1 then
if zoneSelected == 0 then
xi.monstrosity.onMonstrosityReturnToEntrance(player)
else
if xi.monstrosity.teleports[zoneSelected] then
local teleportPos = xi.monstrosity.teleports[zoneSelected][math.random(1, #xi.monstrosity.teleports[zoneSelected])]
player:setPos(teleportPos[1],
teleportPos[2],
teleportPos[3],
teleportPos[4],
zoneSelected
)
else
print('Monstrosity Teleport - No Valid Entries for Zone ' .. zoneSelected .. '. Setting pos to (0, 0, 0)!')
player:setPos(0, 0, 0, 0, zoneSelected)
end
end
end
end
-----------------------------------
-- Feretory
-----------------------------------
xi.monstrosity.feretoryOnZoneIn = function(player, prevZone)
local cs = -1
if
player:getXPos() == 0 and
player:getYPos() == 0 and
player:getZPos() == 0
then
player:setPos(-358.000, -3.400, -440.00, 63)
end
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return cs
end
if player:getMainJob() ~= xi.job.MON then
player:changeJob(xi.job.MON)
end
for _, effect in pairs(player:getStatusEffects()) do
player:delStatusEffectSilent(effect:getEffectType())
end
return cs
end
xi.monstrosity.feretoryOnZoneOut = function(player)
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return
end
-- Mark all status effects so they'll survive zoning
-- (there are some routines that will force them off anyway)
for _, effect in pairs(player:getStatusEffects()) do
effect:delEffectFlag(xi.effectFlag.ON_ZONE)
effect:delEffectFlag(xi.effectFlag.LOGOUT)
end
end
xi.monstrosity.feretoryOnEventUpdate = function(player, csid, option, npc)
end
xi.monstrosity.feretoryOnEventFinish = function(player, csid, option, npc)
end
-----------------------------------
-- Aengus (Feretory NPC)
-----------------------------------
xi.monstrosity.aengusOnTrade = function(player, npc, trade)
end
xi.monstrosity.aengusOnTrigger = function(player, npc)
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return
end
local inBelligerency = player:getBelligerencyFlag() and 1 or 0
player:startEvent(13, inBelligerency, player:getCurrency('infamy'), 0, 0, 0, 0, 0, 0)
end
xi.monstrosity.aengusOnEventUpdate = function(player, csid, option, npc)
end
xi.monstrosity.aengusOnEventFinish = function(player, csid, option, npc)
if csid == 13 and option == 1 then
-- Toggle
player:setBelligerencyFlag(not player:getBelligerencyFlag())
end
end
-----------------------------------
-- Teyrnon (Feretory NPC)
-----------------------------------
xi.monstrosity.teyrnonOnTrade = function(player, npc, trade)
end
xi.monstrosity.teyrnonOnTrigger = function(player, npc)
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return
end
player:startEvent(7, player:getCurrency('infamy'), 0, 0, 0, 0, 0, 0, 0)
end
xi.monstrosity.teyrnonOnEventUpdate = function(player, csid, option, npc)
if csid == 7 then
local optionType = bit.band(option, 0xFF)
if optionType == 0 then
-- Monsters
local monPage = bit.rshift(option, 16)
local availableMons = getMonPageMask(player, monPage)
player:updateEvent(availableMons, 0, 0, 0, 0, 0, 0, 0)
elseif optionType == 1 then
-- Instincts
local purchasedInstincts = getPurchasedInstinctsMask(player)
local completedLimits = getLimitBreakMask(player)
player:updateEvent(purchasedInstincts, completedLimits, 0, 0, 0, 0, 0, 0)
end
end
end
xi.monstrosity.teyrnonOnEventFinish = function(player, csid, option, npc)
local optionType = bit.band(option, 0xFF)
if optionType == 1 then
local selectedCategory = bit.band(bit.rshift(option, 8), 0xF) - 1
local selectedMon = bit.rshift(option, 16)
local monData = terynonMonData[selectedCategory][selectedMon]
if not monData then
print(string.format('Invalid Event Finish Option received by Terynon! (%s:%d)', player:getName(), option))
return
end
if player:getCurrency('infamy') >= monData.infamyCost then
player:delCurrency('infamy', monData.infamyCost)
if monData.monSpecies then
xi.monstrosity.unlockSpecies(player, monData.monSpecies)
elseif monData.monVariant then
xi.monstrosity.unlockVariant(player, monData.monVariant)
end
player:messageSpecial(zones[xi.zone.FERETORY].text.MAY_POSSESS_BEASTS + 3 * selectedCategory, 0, selectedMon)
else
player:messageSpecial(zones[xi.zone.FERETORY].text.THY_BRAZEN_DISREGARD)
end
elseif optionType == 2 then
-- Instincts: Costs are hardcoded, and adjusted based on having completed certain
-- prerequisites. This data is not tabled with Terynon, as it cannot be controlled.
local selectedInstinct = bit.band(bit.rshift(option, 8), 0xFF)
local instinctPrice = selectedInstinct > xi.monstrosity.purchasableInstincts.GALKA_II and 10000 or 500
local checkValue = bit.rshift(option, 16)
if checkValue ~= 119 then
print(string.format('Invalid Event Finish Option received by Terynon! (%s:%d)', player:getName(), option))
return
end
if
selectedInstinct > xi.monstrosity.purchasableInstincts.GALKA_II and
hasCompletedLimitBreak(player, selectedInstinct - xi.monstrosity.purchasableInstincts.GALKA_II)
then
instinctPrice = instinctPrice / 2
end
if player:getCurrency('infamy') >= instinctPrice then
player:delCurrency('infamy', instinctPrice)
addPurchasedInstinct(player, selectedInstinct)
-- NOTE: The offset below is the beginning parameter for purchased instincts used by this message, and
-- lower values will result in an item being placed in the message. Base offset for all instincts
-- is 29696 (29696 + 3 -> Rabbit Instinct I)
player:messageSpecial(zones[xi.zone.FERETORY].text.YOU_LEARNED_INSTINCT, 30464 + selectedInstinct)
else
player:messageSpecial(zones[xi.zone.FERETORY].text.THY_BRAZEN_DISREGARD)
end
elseif optionType == 3 then
-- TODO: The casting effects and animations
local tryPayCost = function(playerArg, cost)
if playerArg:getCurrency('infamy') < cost then
playerArg:messageSpecial(zones[xi.zone.FERETORY].text.THY_BRAZEN_DISREGARD)
return false
end
playerArg:delCurrency('infamy', cost)
return true
end
local selectedEffect = bit.rshift(option, 8)
switch(selectedEffect): caseof
{
-- 0: Dedication 1
-- 50% experience bonus 60 minutes or until a maximum bonus of 10,000 EXP is gained
[0] = function()
if not tryPayCost(player, 3000) then
return
end
local effect = xi.effect.DEDICATION
local power = 50
local duration = utils.minutes(60)
local subpower = 10000
player:delStatusEffectSilent(power)
xi.itemUtils.addItemExpEffect(player, effect, power, duration, subpower)
end,
-- 1: Dedication 2
-- 100% experience bonus 60 minutes or until a maximum bonus of 2,000 EXP is gained
[1] = function()
if not tryPayCost(player, 400) then
return
end
local effect = xi.effect.DEDICATION
local power = 100
local duration = utils.minutes(60)
local subpower = 2000
player:delStatusEffectSilent(power)
xi.itemUtils.addItemExpEffect(player, effect, power, duration, subpower)
end,
-- 2: Regen
[2] = function()
if not tryPayCost(player, 10) then
return
end
player:delStatusEffectSilent(xi.effect.REGEN)
player:addStatusEffect(xi.effect.REGEN, { power = 1, duration = 3600, origin = player, tick = 3 })
end,
-- 3: Refresh
[3] = function()
if not tryPayCost(player, 10) then
return
end
player:delStatusEffectSilent(xi.effect.REFRESH)
player:addStatusEffect(xi.effect.REFRESH, { power = 1, duration = 3600, origin = player, tick = 3 }) -- Does indeed get overwriten by regular refresh.
end,
-- 4: Protect
[4] = function()
if not tryPayCost(player, 100) then
return
end
local mLvl = player:getMainLvl()
local power = 220
local tier = 5
if mLvl < 27 then
power = 20
tier = 1
elseif mLvl < 47 then
power = 50
tier = 2
elseif mLvl < 63 then
power = 90
tier = 3
elseif mLvl < 76 then
power = 140
tier = 4
end
local bonus = 0
if player:getMod(xi.mod.ENHANCES_PROT_SHELL_RCVD) > 0 then
bonus = 2 -- 2x Tier from MOD
end
power = power + (bonus * tier)
player:delStatusEffectSilent(xi.effect.PROTECT)
player:addStatusEffect(xi.effect.PROTECT, { power = power, duration = 1800, origin = player, tier = tier })
end,
-- 5: Shell
[5] = function()
if not tryPayCost(player, 100) then
return
end
local mLvl = player:getMainLvl()
-- Shell V (75/256)
local power = 2930
local tier = 5
if mLvl < 37 then
power = 1055 -- Shell I (27/256)
tier = 1
elseif mLvl < 57 then
power = 1641 -- Shell II (42/256)
tier = 2
elseif mLvl < 68 then
power = 2188 -- Shell III (56/256)
tier = 3
elseif mLvl < 76 then
power = 2617 -- Shell IV (67/256)
tier = 4
end
local bonus = 0
if player:getMod(xi.mod.ENHANCES_PROT_SHELL_RCVD) > 0 then
bonus = 39 -- (1/256 bonus buff per tier of spell)
end
power = power + (bonus * tier)
player:delStatusEffectSilent(xi.effect.SHELL)
player:addStatusEffect(xi.effect.SHELL, { power = power, duration = 1800, origin = player, tier = tier })
end,
-- 6: Haste
[6] = function()
player:delStatusEffectSilent(xi.effect.HASTE)
player:addStatusEffect(xi.effect.HASTE, { power = 1000, duration = 600, origin = player })
end,
}
end
end
-----------------------------------
-- Maccus (Feretory NPC)
-----------------------------------
xi.monstrosity.maccusOnTrade = function(player, npc, trade)
end
xi.monstrosity.maccusOnTrigger = function(player, npc)
if xi.settings.main.ENABLE_MONSTROSITY ~= 1 then
return
end
player:startEvent(9, 285, 2, 2, 0, 0, 0, 0, 0)
end
xi.monstrosity.maccusOnEventUpdate = function(player, csid, option, npc)
-- print('update', csid, option)
end
xi.monstrosity.maccusOnEventFinish = function(player, csid, option, npc)
-- print('finish', csid, option)
end