----------------------------------- -- 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