924 lines
32 KiB
Lua
924 lines
32 KiB
Lua
-----------------------------------
|
|
-- Garrison
|
|
-----------------------------------
|
|
require('scripts/globals/garrison_data')
|
|
require('scripts/globals/mobs')
|
|
require('scripts/globals/npc_util')
|
|
require('scripts/globals/pathfind')
|
|
-----------------------------------
|
|
xi = xi or {}
|
|
xi.garrison = xi.garrison or {}
|
|
|
|
local enableDebugPrints = false -- If true, garrison will print out debug messages in logs as well as players as smes.
|
|
|
|
local nationName =
|
|
{
|
|
[xi.nation.SANDORIA] = 'San d\'Oria',
|
|
[xi.nation.BASTOK ] = 'Bastok',
|
|
[xi.nation.WINDURST] = 'Windurst',
|
|
[xi.nation.BEASTMEN] = 'Beastmen',
|
|
[xi.nation.OTHER ] = 'Other',
|
|
}
|
|
|
|
-----------------------------------
|
|
-- Debug Utilities.
|
|
-----------------------------------
|
|
|
|
-- Prints the given message if DEBUG_GARRISON is enabled
|
|
local function debugLog(msg)
|
|
if enableDebugPrints then
|
|
print('[Garrison]: ' .. msg)
|
|
end
|
|
end
|
|
|
|
-- Prints the given message with printf if DEBUG_GARRISON is enabled
|
|
local function debugLogf(msg, ...)
|
|
if enableDebugPrints then
|
|
printf('[Garrison]: ' .. msg, ...)
|
|
end
|
|
end
|
|
|
|
-- Shows the given server message to all players if DEBUG_GARRISON is enabled
|
|
local function debugPrintToPlayers(players, msg)
|
|
if enableDebugPrints then
|
|
for _, player in pairs(players) do
|
|
player:printToPlayer(msg)
|
|
end
|
|
end
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Local Utilities.
|
|
-----------------------------------
|
|
|
|
-- Sends a message packet to all players
|
|
local function messagePlayers(npc, players, msg)
|
|
for _, player in ipairs(players) do
|
|
player:messageText(npc, msg)
|
|
end
|
|
end
|
|
|
|
-- Utility function to get the number of parties in the player's alliance.
|
|
local function getNumPartiesInAlliance(player)
|
|
local alliance = player:getAlliance()
|
|
local numLeaders = 0
|
|
local leaders = {}
|
|
|
|
for _, member in pairs(alliance) do
|
|
local leader = member:getPartyLeader()
|
|
|
|
if leader ~= nil and not leaders[leader:getName()] then
|
|
numLeaders = numLeaders + 1
|
|
leaders[leader:getName()] = true
|
|
end
|
|
end
|
|
|
|
return numLeaders
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Global Utilities.
|
|
-----------------------------------
|
|
|
|
-- Add level restriction effect
|
|
-- If a party member is KO'd during the Garrison, they're out.
|
|
-- Giving this the CONFRONTATION flag hooks into the target validation system and stops outsiders participating, for mobs, allies, and players.
|
|
xi.garrison.addLevelCap = function(entity, definedCap)
|
|
local cap = definedCap
|
|
|
|
-- If this Garrison is uncapped, use the server max.
|
|
if definedCap == 99 then
|
|
cap = xi.settings.main.MAX_LEVEL
|
|
end
|
|
|
|
-- Note the level restriction does not wear on death.
|
|
entity:addStatusEffect(xi.effect.LEVEL_RESTRICTION, { power = cap, origin = entity, flag = xi.effectFlag.ON_ZONE + xi.effectFlag.CONFRONTATION })
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison NPC Ally handling.
|
|
-----------------------------------
|
|
|
|
xi.garrison.getAllyInfo = function(zoneID, zoneData, nationID)
|
|
-- Get zone garrison data
|
|
local allyName = xi.garrison.allyNames[zoneData.levelCap][nationID]
|
|
local allyLooks = xi.garrison.allyLooks[zoneData.levelCap][nationID]
|
|
local allyGroupId = xi.garrison.allyGroupInfo[zoneData.levelCap][1] -- Defaulted to 1 inside GM_HOME
|
|
local allyMinLvl = xi.garrison.allyGroupInfo[zoneData.levelCap][2]
|
|
local allyMaxLvl = xi.garrison.allyGroupInfo[zoneData.levelCap][3]
|
|
local pos = xi.garrison.zoneData[zoneID].pos
|
|
|
|
if
|
|
allyName == nil or
|
|
allyMinLvl == nil or
|
|
allyMaxLvl == nil or
|
|
allyLooks == nil or
|
|
#allyLooks == 0 or
|
|
pos == nil
|
|
then
|
|
local zone = GetZone(zoneID)
|
|
debugLogf('Garrison NPC data missing for %s level %u (%s).', nationName[nationID], zoneData.levelCap, zone and zone:getName() or 'UNKNOWN')
|
|
return nil
|
|
end
|
|
|
|
return {
|
|
name = allyName,
|
|
looks = allyLooks,
|
|
groupId = allyGroupId, -- Defaults to groupID 1. This group ID is defaulted inside GM_HOME
|
|
minLevel = allyMinLvl,
|
|
maxLevel = allyMaxLvl,
|
|
pos = pos,
|
|
}
|
|
end
|
|
|
|
xi.garrison.rollNPCs = function(zone, allyInfo, quantity)
|
|
local xPos = allyInfo.pos[1]
|
|
local yPos = allyInfo.pos[2]
|
|
local zPos = allyInfo.pos[3]
|
|
local rot = allyInfo.pos[4]
|
|
local zoneID = zone:getID()
|
|
local zoneData = xi.garrison.zoneData[zoneID]
|
|
local dTableNPCs = {}
|
|
|
|
debugLogf('Spawning %d npcs. GroupId: %d', quantity, allyInfo.groupId)
|
|
|
|
for i = 1, quantity do
|
|
table.insert(dTableNPCs, {
|
|
name = allyInfo.name,
|
|
look = utils.randomEntry(allyInfo.looks),
|
|
pos = { xPos, yPos, zPos, rot },
|
|
})
|
|
|
|
if i == 6 then
|
|
xPos = allyInfo.pos[1] - zoneData.xSecondLine
|
|
zPos = allyInfo.pos[3] - zoneData.zSecondLine
|
|
elseif i == 12 then
|
|
xPos = allyInfo.pos[1] - zoneData.xThirdLine
|
|
zPos = allyInfo.pos[3] - zoneData.zThirdLine
|
|
else
|
|
xPos = xPos - zoneData.xChange
|
|
zPos = zPos - zoneData.zChange
|
|
end
|
|
end
|
|
|
|
return dTableNPCs
|
|
end
|
|
|
|
-- Spawns and npc for the given zone and with the given name, look, pose. Uses dynamic entities
|
|
xi.garrison.spawnNPC = function(zone, zoneData, pos, name, groupId, look, minLevel, maxLevel)
|
|
local mob = zone:insertDynamicEntity({
|
|
objtype = xi.objType.MOB,
|
|
allegiance = xi.allegiance.PLAYER,
|
|
name = name,
|
|
x = pos[1],
|
|
y = pos[2],
|
|
z = pos[3],
|
|
rotation = pos[4],
|
|
look = look,
|
|
groupId = groupId,
|
|
minLevel = minLevel,
|
|
maxLevel = maxLevel,
|
|
groupZoneId = xi.zone.GM_HOME,
|
|
releaseIdOnDisappear = true,
|
|
specialSpawnAnimation = true,
|
|
})
|
|
|
|
debugLogf('NPC: %s (%d), Level: %d, HP: %d', mob:getName(), mob:getID(), mob:getMainLvl(), mob:getHP())
|
|
|
|
-- Use the mob object as you normally would
|
|
mob:setSpawn(pos[1], pos[2], pos[3], pos[4])
|
|
mob:setMobMod(xi.mobMod.NO_DROPS, 1)
|
|
mob:setRoamFlags(xi.roamFlag.SCRIPTED)
|
|
|
|
mob:spawn()
|
|
|
|
DisallowRespawn(mob:getID(), true)
|
|
mob:setBaseSpeed(25)
|
|
mob:setAllegiance(1)
|
|
|
|
-- NPCs don't cast spells or use TP skills
|
|
mob:setMagicCastingEnabled(false)
|
|
mob:setMobAbilityEnabled(false)
|
|
|
|
-- Death listener for tracking win/lose condition
|
|
mob:addListener('DEATH', 'GARRISON_NPC_DEATH', function(mobArg)
|
|
zoneData.deadNPCCount = zoneData.deadNPCCount + 1
|
|
end)
|
|
|
|
return mob
|
|
end
|
|
|
|
-- Spawns all npcs for the zone in the given garrison starting npc
|
|
xi.garrison.spawnNPCs = function(zone, zoneData)
|
|
local zoneID = zone:getID()
|
|
local nationID = GetRegionOwner(zone:getRegionID())
|
|
|
|
if nationID > 2 then
|
|
debugLogf('Region is beastman controlled. Unable to start Garrison.')
|
|
return false
|
|
end
|
|
|
|
local allyInfo = xi.garrison.getAllyInfo(zoneID, zoneData, nationID)
|
|
|
|
-- If info is missing, a debug message will be logged and NPCs will not be spawned
|
|
if allyInfo == nil then
|
|
return false
|
|
end
|
|
|
|
-- Spawn 1 npc per player in alliance
|
|
local npcs = xi.garrison.rollNPCs(zone, allyInfo, #zoneData.players)
|
|
|
|
if #npcs == 0 then
|
|
debugLogf('No NPC allies rolled. Unable to start Garrison.')
|
|
return false
|
|
end
|
|
|
|
for _, npcData in pairs(npcs) do
|
|
local mob = xi.garrison.spawnNPC(zone, zoneData, npcData.pos, npcData.name, allyInfo.groupId, npcData.look, allyInfo.minLevel, allyInfo.maxLevel)
|
|
-- Note: This does change the mob level because ally npcs are of type mob, and
|
|
-- level_restriction is only applied to PCs. However, we need the status to validate that the
|
|
-- npcs are part of the garrison.
|
|
-- Because the npcs are not level capped, group ids should be used to define min / max level.
|
|
xi.garrison.addLevelCap(mob, zoneData.levelCap)
|
|
table.insert(zoneData.npcs, mob:getID())
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison Mob handling.
|
|
-----------------------------------
|
|
|
|
-- Spawns a mob with the given id for the given zone.
|
|
xi.garrison.spawnMob = function(mobID, zoneData)
|
|
local mob = SpawnMob(mobID)
|
|
if mob == nil then
|
|
return nil
|
|
end
|
|
|
|
xi.garrison.addLevelCap(mob, zoneData.levelCap)
|
|
mob:setRoamFlags(xi.roamFlag.SCRIPTED)
|
|
table.insert(zoneData.mobs, mobID)
|
|
|
|
-- Death listener for tracking win/lose condition
|
|
mob:addListener('DEATH', 'GARRISON_MOB_DEATH', function(mobArg)
|
|
zoneData.deadMobCount = zoneData.deadMobCount + 1
|
|
end)
|
|
|
|
-- A wave is considered complete when all mobs are done despawning
|
|
-- and not just dead. This matters a lot because of spawn timings.
|
|
-- I.e: If mob A dies on wave 1, and another instance of mob A is supposed
|
|
-- to spawn on wave 2, it will not spawn as long as the previous mob is still
|
|
-- despawning
|
|
-- For this reason, we track both death and despawn as separate events
|
|
mob:addListener('DESPAWN', 'GARRISON_MOB_DESPAWN', function(mobArg)
|
|
zoneData.despawnedMobCount = zoneData.despawnedMobCount + 1
|
|
end)
|
|
|
|
return mob
|
|
end
|
|
|
|
-- Given a starting mobID, return the list of randomly selected mob ids. The amount of mobs selected is determined by numMobs.
|
|
-- The ids in the given excludedMobs table will not be included in the result.
|
|
-- This method assumes that the mob pool is composed by mobIDs that are sequential between firstMobID and lastMobID.
|
|
-- e.g: If firstMobId = 1, lastMobID = 4 and numMobs is 2,
|
|
-- Then 2 ids randomly selected between { 1, 2, 3, 4 } will be returned without repetitions.
|
|
xi.garrison.pickMobsFromPool = function(firstMobID, lastMobID, numMobs, excludedMobIDs)
|
|
-- Create dynamic table with valid mobs.
|
|
local unfilteredPool = utils.range(firstMobID, lastMobID)
|
|
local excludedSet = set(excludedMobIDs)
|
|
local dTableMobPool = {}
|
|
local dTableMobs = {}
|
|
|
|
for _, v in ipairs(unfilteredPool) do
|
|
if not excludedSet[v] then
|
|
table.insert(dTableMobPool, v)
|
|
end
|
|
end
|
|
|
|
-- Validate input.
|
|
if numMobs > #dTableMobPool then
|
|
printf('[warning] pickMobsFromPool called with numMobs > mobIds. Num Mobs: %i. Pool size: %i', numMobs, #dTableMobPool)
|
|
numMobs = #dTableMobPool
|
|
end
|
|
|
|
if numMobs <= 0 then
|
|
printf('[error] Invalid numMobs picked. Should be > 0.')
|
|
return {}
|
|
end
|
|
|
|
-- Now we can apply a common algorithm used to 'shuffle a deck of cards'
|
|
for i = 1, numMobs do
|
|
-- Pick random index from J to pool end. Add the picked element to result
|
|
local pickedIndex = math.random(i, #dTableMobPool)
|
|
|
|
table.insert(dTableMobs, dTableMobPool[pickedIndex])
|
|
|
|
-- Now swap the picked element with the first element of the array.
|
|
-- This effectively makes the picked element not elegible for future picks.
|
|
dTableMobPool[pickedIndex], dTableMobPool[i] = dTableMobPool[i], dTableMobPool[pickedIndex]
|
|
end
|
|
|
|
return dTableMobs
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison Reward handling.
|
|
-----------------------------------
|
|
|
|
-- Distributes loot amongst all players
|
|
xi.garrison.handleLootRolls = function(levelCap, players)
|
|
local lootGroup = xi.garrison.loot[levelCap]
|
|
local firstPlayer = players[1]
|
|
|
|
-- completion of garrison gives a random item from the pool per player
|
|
lootGroup.quantity = #players
|
|
local lootTable = { lootGroup }
|
|
local selectedLoot = utils.selectFromLootGroups(firstPlayer, lootTable)
|
|
for _, entry in ipairs(selectedLoot) do
|
|
-- garrison completion loot has no gil
|
|
firstPlayer:addTreasure(entry.itemId) -- nil npc parameter for addTreasure gives no message, on purpose
|
|
end
|
|
end
|
|
|
|
xi.garrison.handleGilPayout = function(levelCap, players)
|
|
-- We have two captures at level 30 being rewarded a total of 3k gil.
|
|
-- This is an assumption of how the rest of tiers work.
|
|
local payout = xi.settings.main.GIL_RATE * levelCap * 100 * #players
|
|
|
|
debugLog('Payout: ' .. payout)
|
|
|
|
if #players > 0 then
|
|
for _, player in ipairs(players) do
|
|
if player ~= nil then
|
|
local gil = payout / #players
|
|
|
|
player:addGil(gil)
|
|
player:messageSpecial(zones[player:getZoneID()].text.GIL_OBTAINED, gil)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison Progression handling.
|
|
-----------------------------------
|
|
-- Randomly assigns aggro between the given groups of entity IDs.
|
|
local function aggroGroups(group1, group2)
|
|
for _, entityId1 in pairs(group1) do
|
|
for _, entityId2 in pairs(group2) do
|
|
local entity1 = GetMobByID(entityId1)
|
|
local entity2 = GetMobByID(entityId2)
|
|
|
|
if entity1 == nil or entity2 == nil then
|
|
printf('[warning] Could not apply aggro because either %i or %i are not valid entities', entityId1, entityId2)
|
|
else
|
|
debugLogf('Applying enmity: %i <-> %i', entityId1, entityId2)
|
|
entity1:addEnmity(entity2, math.random(1, 5), math.random(1, 5))
|
|
entity2:addEnmity(entity1, math.random(1, 5), math.random(1, 5))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Main tick that will run the state machine for garrison logic
|
|
xi.garrison.tick = function(npc)
|
|
local zone = npc:getZone()
|
|
local zoneData = xi.garrison.zoneData[zone:getID()]
|
|
local ID = zones[npc:getZoneID()]
|
|
|
|
local entityMapper = function(_, entityId)
|
|
return GetPlayerByID(entityId)
|
|
end
|
|
|
|
local players = utils.map(zoneData.players, entityMapper)
|
|
|
|
switch (zoneData.state) : caseof
|
|
{
|
|
[xi.garrison.state.SPAWN_NPCS] = function()
|
|
debugLog('State: Spawn NPCs')
|
|
zoneData.stateTime = GetSystemTime()
|
|
|
|
if xi.garrison.spawnNPCs(zone, zoneData) then
|
|
zoneData.state = xi.garrison.state.BATTLE
|
|
else
|
|
debugPrintToPlayers(players, 'Unable to spawn NPCs')
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
end
|
|
end,
|
|
|
|
[xi.garrison.state.BATTLE] = function()
|
|
debugLog('State: Battle')
|
|
|
|
-- We do not cache player entity state as they can reraise or DC,
|
|
-- making caching more error prone
|
|
local isAliveFn = function(_, player)
|
|
return player ~= nil and player:isAlive()
|
|
end
|
|
|
|
local allPlayersDead = not utils.any(players, isAliveFn)
|
|
|
|
-- This caching works because the same mob ID is never used to respawn a mob
|
|
-- within the same wave.
|
|
local allMobsDead = zoneData.deadMobCount == #zoneData.mobs
|
|
local allMobsDespawned = zoneData.despawnedMobCount == #zoneData.mobs
|
|
|
|
-- Case 1: Either npcs or players are dead. End event.
|
|
local allNPCsDead = #zoneData.npcs == zoneData.deadNPCCount
|
|
|
|
if allNPCsDead or allPlayersDead then
|
|
-- You fought hard, and you proved yourself worthy...
|
|
debugPrintToPlayers(players, 'Mission failed by death')
|
|
messagePlayers(npc, players, ID.text.GARRISON_BASE + 39)
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
|
|
return
|
|
end
|
|
|
|
-- Case 2: More mobs to spawn in this wave, and past next spawn time. Spawn Mobs.
|
|
local shouldSpawnMobs = GetSystemTime() >= zoneData.nextSpawnTime
|
|
local numGroups = #zoneData.spawnSchedule[zoneData.waveIndex]
|
|
local isLastGroup = zoneData.groupIndex > numGroups
|
|
|
|
if shouldSpawnMobs and not isLastGroup then
|
|
zoneData.state = xi.garrison.state.SPAWN_MOBS
|
|
|
|
return
|
|
end
|
|
|
|
-- Case 3: All mobs spawned for last wave. Spawn boss
|
|
local numWaves = #zoneData.spawnSchedule
|
|
local isLastWave = zoneData.waveIndex == numWaves
|
|
|
|
if
|
|
shouldSpawnMobs and
|
|
isLastWave and
|
|
isLastGroup and
|
|
not zoneData.bossSpawned
|
|
then
|
|
zoneData.state = xi.garrison.state.SPAWN_BOSS
|
|
|
|
return
|
|
end
|
|
|
|
-- Case 4: All Mobs despawned and this was last group. Check if we advance to next wave.
|
|
if
|
|
allMobsDespawned and
|
|
isLastGroup and
|
|
not isLastWave
|
|
then
|
|
zoneData.state = xi.garrison.state.ADVANCE_WAVE
|
|
|
|
return
|
|
end
|
|
|
|
-- Case 5: All mobs are dead and this was last group and last wave. Grant loot.
|
|
if allMobsDead and isLastGroup and isLastWave and zoneData.bossSpawned then
|
|
zoneData.state = xi.garrison.state.GRANT_LOOT
|
|
|
|
return
|
|
end
|
|
|
|
-- Case 6: Timeout
|
|
if GetSystemTime() > zoneData.endTime then
|
|
-- You fought hard, and you proved yourself worthy...
|
|
debugPrintToPlayers(players, 'Mission failed by timeout')
|
|
messagePlayers(npc, players, ID.text.GARRISON_BASE + 39)
|
|
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
end
|
|
end,
|
|
|
|
[xi.garrison.state.SPAWN_BOSS] = function()
|
|
debugLog('State: Spawn Boss')
|
|
debugPrintToPlayers(players, 'Spawning boss')
|
|
|
|
local bossID = zone:queryEntitiesByName(zoneData.mobBoss)[1]:getID()
|
|
local mob = xi.garrison.spawnMob(bossID, zoneData)
|
|
|
|
if mob == nil then
|
|
print('[error] Could not spawn boss (%i). Ending garrison.', bossID)
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
|
|
return
|
|
end
|
|
|
|
aggroGroups({ bossID }, zoneData.npcs)
|
|
|
|
zoneData.bossSpawned = true
|
|
zoneData.state = xi.garrison.state.BATTLE
|
|
end,
|
|
|
|
[xi.garrison.state.ADVANCE_WAVE] = function()
|
|
debugLog('State: Advance Wave')
|
|
debugLogf('Wave Idx: %i. Waves: %i', zoneData.waveIndex, #zoneData.spawnSchedule)
|
|
debugLogf('Next wave: %i', zoneData.waveIndex)
|
|
debugPrintToPlayers(players, 'Wave ' .. zoneData.waveIndex .. ' cleared')
|
|
|
|
zoneData.waveIndex = zoneData.waveIndex + 1
|
|
zoneData.groupIndex = 1
|
|
zoneData.nextSpawnTime = GetSystemTime() + xi.garrison.waves.delayBetweenGroups
|
|
zoneData.state = xi.garrison.state.BATTLE
|
|
zoneData.mobs = {}
|
|
|
|
-- reset mob state cache, but not npc since they dont respawn each wave
|
|
zoneData.deadMobCount = 0
|
|
zoneData.despawnedMobCount = 0
|
|
end,
|
|
|
|
[xi.garrison.state.SPAWN_MOBS] = function()
|
|
debugLog('State: Spawn Mobs')
|
|
|
|
-- There are always at most 8 mobs + 1 boss for Garrison, so we will look up the
|
|
-- boss's ID using their name and then subtract 8 to get the starting mob ID
|
|
local firstMobID = zone:queryEntitiesByName(zoneData.mobBoss)[1]:getID() - 8
|
|
|
|
if zoneData.spawnSchedule[zoneData.waveIndex] == nil then
|
|
printf('[error] No spawn schedule for wave: %d. Num Parties: %d', zoneData.waveIndex, zoneData.numParties)
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
|
|
return
|
|
end
|
|
|
|
local poolSize = utils.sum(zoneData.spawnSchedule[zoneData.waveIndex], function(_, v)
|
|
return v
|
|
end)
|
|
|
|
local numMobs = zoneData.spawnSchedule[zoneData.waveIndex][zoneData.groupIndex]
|
|
local lastMobID = firstMobID + poolSize - 1
|
|
|
|
-- Pick mobs randomly and spawn them
|
|
local mobIDs = xi.garrison.pickMobsFromPool(firstMobID, lastMobID, numMobs, zoneData.mobs)
|
|
|
|
for _, mobID in ipairs(mobIDs) do
|
|
xi.garrison.spawnMob(mobID, zoneData)
|
|
end
|
|
|
|
-- Once the mobs are spawned, make them aggro whatever NPCs are already up
|
|
-- This method should work fine even if some of these mobs or npcs are invalid / dead
|
|
aggroGroups(mobIDs, zoneData.npcs)
|
|
|
|
debugPrintToPlayers(players, 'Spawn: ' .. #zoneData.mobs .. '/' .. poolSize .. '. Wave: ' .. zoneData.waveIndex)
|
|
|
|
zoneData.nextSpawnTime = GetSystemTime() + xi.garrison.waves.delayBetweenGroups
|
|
zoneData.state = xi.garrison.state.BATTLE
|
|
zoneData.groupIndex = zoneData.groupIndex + 1
|
|
end,
|
|
|
|
[xi.garrison.state.GRANT_LOOT] = function()
|
|
debugLog('State: Grant Loot')
|
|
debugPrintToPlayers(players, 'Mission success')
|
|
messagePlayers(npc, players, ID.text.GARRISON_BASE + 36)
|
|
|
|
xi.garrison.handleLootRolls(zoneData.levelCap, players)
|
|
xi.garrison.handleGilPayout(zoneData.levelCap, players)
|
|
|
|
zoneData.state = xi.garrison.state.ENDED
|
|
end,
|
|
|
|
[xi.garrison.state.ENDED] = function()
|
|
debugLog('State: Ended')
|
|
|
|
xi.garrison.stop(zone)
|
|
end,
|
|
}
|
|
|
|
-- Updates last tick so watchdog knows we are ok
|
|
zoneData.lastTick = GetSystemTime()
|
|
|
|
-- Keep running tick until done
|
|
if zoneData.isRunning then
|
|
npc:timer(1000, function(npcArg)
|
|
xi.garrison.tick(npcArg)
|
|
end)
|
|
end
|
|
end
|
|
|
|
-- Gets the spawn schedule for the player starting garrison.
|
|
xi.garrison.getSpawnSchedule = function(player)
|
|
local numParties = getNumPartiesInAlliance(player)
|
|
local spawnSchedule = xi.garrison.waves.spawnSchedule[numParties]
|
|
|
|
if spawnSchedule == nil then
|
|
-- Leave the log there even if valid most times, because it may help us cause bad use cases
|
|
debugLogf('[warning] Spawn schedule not found for number of parties: %d. Ignore if player has no party.', numParties)
|
|
spawnSchedule = xi.garrison.waves.spawnSchedule[1]
|
|
end
|
|
|
|
return spawnSchedule
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison Entry and Locking.
|
|
-----------------------------------
|
|
|
|
-- Returns true if server setting is enabled and player has done Garrison before Tally happens.
|
|
local function isPlayerOnTallyLockout(player)
|
|
if
|
|
xi.settings.main.GARRISON_ONCE_PER_WEEK and
|
|
player:getCharVar('[Garrison]NextEntryTime') ~= 0
|
|
then
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Returns true if the given zone is still on lockout.
|
|
local function isZoneOnLockout(zone)
|
|
local nextValidAttemptTime = GetServerVariable('[Garrison]NextEntryTime_' .. zone:getID())
|
|
|
|
if GetSystemTime() < nextValidAttemptTime then
|
|
debugLogf('Zone lockout time remaining: %d', nextValidAttemptTime - GetSystemTime())
|
|
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Stores the next valid entry time on trading player based on next conquest tally.
|
|
local function saveTallyLockout(player)
|
|
if xi.settings.main.GARRISON_ONCE_PER_WEEK then
|
|
player:setCharVar('[Garrison]NextEntryTime', 1, NextConquestTally())
|
|
end
|
|
end
|
|
|
|
-- Stores the next valid entry time for the given zone, based on lockout.
|
|
local function saveZoneLockout(zone)
|
|
local nextEntryTime = GetSystemTime() + xi.settings.main.GARRISON_LOCKOUT
|
|
|
|
SetServerVariable('[Garrison]NextEntryTime_' .. zone:getID(), nextEntryTime)
|
|
end
|
|
|
|
-- Validates that the player meets the requirements to enter garrison:
|
|
-- * Another garrison is not currently active
|
|
-- * Outpost is controlled by the player's nation, or GARRISON_NATION_BYPASS setting is true
|
|
-- * No player in the alliance (in zone) is level synced
|
|
-- * Player hasn't entered garrison since last tally, or GARRISON_ONCE_PER_WEEK setting is false
|
|
-- * Player has not finished a garrison since in the last GARRISON_LOCKOUT seconds
|
|
-- * Player does not have more than GARRISON_PARTY_LIMIT alliance memebrs
|
|
-- * Player is at least at GARRISON_RANK rank level
|
|
xi.garrison.validateEntry = function(zoneData, player, npc, guardNation)
|
|
local ID = zones[player:getZoneID()]
|
|
|
|
if zoneData.isRunning then
|
|
player:messageText(npc, ID.text.GARRISON_BASE + 1)
|
|
|
|
return false
|
|
end
|
|
|
|
local sameZone = function(_, v)
|
|
return (
|
|
v ~= nil and
|
|
v:getZoneID() == player:getZoneID()
|
|
)
|
|
end
|
|
|
|
local membersInZone = utils.filterArray(player:getAlliance(), sameZone)
|
|
|
|
-- This assumes that only the player trading the item has to be from the right nation
|
|
if
|
|
not xi.settings.main.GARRISON_NATION_BYPASS and
|
|
guardNation ~= player:getNation()
|
|
then
|
|
player:messageSpecial(ID.text.GARRISON_BASE + player:getNation(), zoneData.itemReq)
|
|
|
|
return false
|
|
end
|
|
|
|
local membersLevelRestricted = utils.any(membersInZone, function(_, v)
|
|
return v:hasStatusEffect(xi.effect.LEVEL_RESTRICTION) or v:hasStatusEffect(xi.effect.LEVEL_SYNC)
|
|
end)
|
|
|
|
if membersLevelRestricted then
|
|
-- Your party is unable to participate because certain members' levels are restricted
|
|
player:messageText(npc, ID.text.MEMBERS_LEVELS_ARE_RESTRICTED, false)
|
|
|
|
return false
|
|
end
|
|
|
|
if #membersInZone > xi.settings.main.GARRISON_PARTY_LIMIT then
|
|
-- This is a custom message. I don't believe retail has this limitation
|
|
debugLogf('Alliance exceeds member limit: %d', xi.settings.main.GARRISON_PARTY_LIMIT)
|
|
debugPrintToPlayers({ player }, 'Maximum garrison alliance size is ' .. xi.settings.main.GARRISON_PARTY_LIMIT)
|
|
|
|
return false
|
|
end
|
|
|
|
-- Only trading player needs to be required rank
|
|
if player:getRank(player:getNation()) < xi.settings.main.GARRISON_RANK then
|
|
-- These young participants are quite spirited, but they lack valuable battle experience.
|
|
player:messageText(npc, ID.text.GARRISON_BASE + 4)
|
|
|
|
return false
|
|
end
|
|
|
|
-- Check tally cooldown for trading player.
|
|
if isPlayerOnTallyLockout(player) then
|
|
-- We commend you on your services in helping us avert the beastmen's attack. I do not see them attacking us again any time soon
|
|
player:messageText(npc, ID.text.GARRISON_BASE + 40)
|
|
|
|
return false
|
|
end
|
|
|
|
-- Check 'per zone' lockout, applied to all players for the same zone.
|
|
if isZoneOnLockout(player:getZone()) then
|
|
-- We commend you on your services in helping us avert the beastmen's attack. I do not see them attacking us again any time soon
|
|
player:messageText(npc, ID.text.GARRISON_BASE + 40)
|
|
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Overseer NPC global functions.
|
|
-----------------------------------
|
|
xi.garrison.onTrade = function(player, npc, trade, guardNation)
|
|
if not xi.settings.main.ENABLE_GARRISON then
|
|
debugLog('Garrison not enabled. Set ENABLE_GARRISON if desired.')
|
|
|
|
return false
|
|
end
|
|
|
|
-- Get zone information
|
|
local zone = player:getZone()
|
|
local zoneID = zone:getID()
|
|
local zoneData = xi.garrison.zoneData[zoneID]
|
|
|
|
-- If called outside of Garrison areas
|
|
if not zoneData then
|
|
return
|
|
end
|
|
|
|
local nationID = GetRegionOwner(zone:getRegionID())
|
|
|
|
-- If info is missing, a debug message will be logged and Garrison will not begin
|
|
if xi.garrison.getAllyInfo(zoneID, zoneData, nationID) == nil then
|
|
player:printToPlayer('Garrison is currently unavailable for this region.', xi.msg.channel.SYSTEM_3)
|
|
debugLog('Garrison was cancelled due to missing data.')
|
|
|
|
return false
|
|
end
|
|
|
|
if npcUtil.tradeHasExactly(trade, zoneData.itemReq) then
|
|
if not xi.garrison.validateEntry(zoneData, player, npc, guardNation) then
|
|
debugLog('Player does not meet entry requirements')
|
|
|
|
return false
|
|
end
|
|
|
|
-- Start CS
|
|
player:startEvent(32753 + player:getNation())
|
|
player:setLocalVar('GARRISON_NPC', npc:getID())
|
|
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
xi.garrison.onTrigger = function(player, npc)
|
|
if not xi.settings.main.ENABLE_GARRISON then
|
|
return false
|
|
end
|
|
|
|
return false -- ?
|
|
end
|
|
|
|
xi.garrison.onEventFinish = function(player, csid, option, guardNation, guardType, guardRegion)
|
|
if xi.settings.main.ENABLE_GARRISON then
|
|
if csid == 32753 + player:getNation() and option == 0 then
|
|
player:confirmTrade()
|
|
|
|
local npc = GetNPCByID(player:getLocalVar('GARRISON_NPC'))
|
|
xi.garrison.start(player, npc)
|
|
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-----------------------------------
|
|
-- Garrison Start and End.
|
|
-----------------------------------
|
|
-- Watchdock tick that guarantees our main tick is always running. If not, stops garrison and clears the invalid state.
|
|
local function garrisonWatchdog(npc)
|
|
npc:timer(5000, function(npcArg)
|
|
local zoneData = xi.garrison.zoneData[npcArg:getZoneID()]
|
|
local tickInterval = 2
|
|
|
|
if
|
|
zoneData.isRunning and
|
|
GetSystemTime() - zoneData.lastTick > tickInterval
|
|
then
|
|
local zone = npcArg:getZone()
|
|
debugLogf('[error] Invalid garrison state detected for zone: %s. Stopping it now.', zone:getName())
|
|
xi.garrison.stop(zone)
|
|
end
|
|
|
|
if zoneData.isRunning then
|
|
garrisonWatchdog(npcArg)
|
|
end
|
|
end)
|
|
end
|
|
|
|
xi.garrison.start = function(player, npc)
|
|
local zone = player:getZone()
|
|
local zoneData = xi.garrison.zoneData[zone:getID()]
|
|
zoneData.players = {}
|
|
zoneData.spawnSchedule = xi.garrison.getSpawnSchedule(player)
|
|
zoneData.npcs = {}
|
|
zoneData.mobs = {}
|
|
zoneData.state = xi.garrison.state.SPAWN_NPCS
|
|
zoneData.isRunning = true
|
|
zoneData.stateTime = GetSystemTime()
|
|
zoneData.waveIndex = 1
|
|
zoneData.groupIndex = 1
|
|
zoneData.bossSpawned = false
|
|
-- First mob spawn takes xi.garrison.waves.delayBetweenGroups to start
|
|
zoneData.nextSpawnTime = GetSystemTime() + xi.garrison.waves.delayBetweenGroups
|
|
zoneData.endTime = GetSystemTime() + xi.settings.main.GARRISON_TIME_LIMIT
|
|
zoneData.deadNPCCount = 0
|
|
zoneData.deadMobCount = 0
|
|
zoneData.despawnedMobCount = 0
|
|
zoneData.lastTick = GetSystemTime()
|
|
|
|
-- Register lockout for the player
|
|
-- Only the trading player is locked out per tally
|
|
saveTallyLockout(player)
|
|
-- We register zone lockout at the beginning and end, in case of zone crashes
|
|
saveZoneLockout(zone)
|
|
|
|
-- Adds level cap / registers lockout for the player / zone
|
|
for _, member in pairs(player:getAlliance()) do
|
|
if member:getZoneID() == player:getZoneID() then
|
|
table.insert(zoneData.players, member:getID())
|
|
xi.garrison.addLevelCap(member, zoneData.levelCap)
|
|
end
|
|
end
|
|
|
|
-- The starting NPC is the 'anchor' for all timers and logic for this Garrison
|
|
-- Kick off the watchdog to guarantee state consistency
|
|
garrisonWatchdog(npc)
|
|
-- Kick off the main tick that drives garrison logic
|
|
xi.garrison.tick(npc)
|
|
end
|
|
|
|
-- Stops and cleans up the current garrison event (if any) on the given zone
|
|
-- Can be called externally from GM commands
|
|
xi.garrison.stop = function(zone)
|
|
local zoneData = xi.garrison.zoneData[zone:getID()]
|
|
|
|
-- Save lockout based off of garrison end time.
|
|
saveZoneLockout(zone)
|
|
|
|
local playerIDs = { unpack(zoneData.players) } -- make a copy
|
|
zoneData.players = {} -- players table must be empty before level restriction status wears off for latent effects
|
|
|
|
for _, entityId in pairs(playerIDs or {}) do
|
|
local entity = GetPlayerByID(entityId)
|
|
|
|
if entity ~= nil then
|
|
entity:delStatusEffect(xi.effect.LEVEL_RESTRICTION)
|
|
end
|
|
end
|
|
|
|
for _, entityId in pairs(zoneData.npcs or {}) do
|
|
DespawnMob(entityId, zone)
|
|
end
|
|
|
|
for _, entityId in pairs(zoneData.mobs or {}) do
|
|
DespawnMob(entityId, zone)
|
|
end
|
|
|
|
zoneData.spawnSchedule = {}
|
|
zoneData.npcs = {}
|
|
zoneData.mobs = {}
|
|
zoneData.isRunning = false
|
|
end
|
|
|
|
xi.garrison.win = function(zone)
|
|
local zoneData = xi.garrison.zoneData[zone:getID()]
|
|
zoneData.state = xi.garrison.state.GRANT_LOOT
|
|
end
|
|
|
|
xi.garrison.isInGarrison = function(player)
|
|
local zoneID = player:getZoneID()
|
|
if not zoneID then
|
|
return false
|
|
end
|
|
|
|
local zoneData = xi.garrison.zoneData[zoneID]
|
|
if not zoneData then
|
|
return false
|
|
end
|
|
|
|
local players = zoneData.players or {}
|
|
return utils.contains(player:getID(), players)
|
|
end
|