diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ca275db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "Lua.diagnostics.globals": [ + "missionCommands", + "env", + "Airbase", + "world", + "trigger", + "StaticObject", + "Group", + "Unit", + "Object", + "coalition", + "timer", + "AI" + ] +} \ No newline at end of file diff --git a/SPEARHEADDEV.miz b/SPEARHEADDEV.miz new file mode 100644 index 0000000..0d09e53 Binary files /dev/null and b/SPEARHEADDEV.miz differ diff --git a/SPEARHEADDEV.zip b/SPEARHEADDEV.zip new file mode 100644 index 0000000..a0f304f Binary files /dev/null and b/SPEARHEADDEV.zip differ diff --git a/SpearheadCompile.py b/SpearheadCompile.py new file mode 100644 index 0000000..86f2317 --- /dev/null +++ b/SpearheadCompile.py @@ -0,0 +1,39 @@ +import os +import sys +import glob + +Order = [ + "spearhead_defaults.lua", + "spearhead_base.lua", + "spearhead_events.lua", + "spearhead_dcsbase.lua", + "spearhead_routes.lua", + "spearhead_mission.lua", + "spearhead_db.lua", + "spearhead_stage.lua", + "spearhead_cap.lua", + "spearhead.lua" +] + +def compile(root, target): + compiled = "" + for filename in Order: + path = os.path.join(root, filename) + with open(path,'r') as file: + fileContents = file.read() + compiled += fileContents + print(path) + + with open(target, "w") as targetFile: + targetFile.write(compiled) + + +if __name__ == "__main__" : + args = sys.argv[1:] + root = args[0] + target = args[1] + + print(f"Source: {root}") + print(f"target: {target}") + + compile(root, target) \ No newline at end of file diff --git a/SpearheadConfig.lua b/SpearheadConfig.lua new file mode 100644 index 0000000..b2771f1 --- /dev/null +++ b/SpearheadConfig.lua @@ -0,0 +1,2 @@ +---KEEP THIS LINE +if not SpearheadConfig then SpearheadConfig = {} end diff --git a/capClasses/CapAirbase.lua b/capClasses/CapAirbase.lua new file mode 100644 index 0000000..7bb616f --- /dev/null +++ b/capClasses/CapAirbase.lua @@ -0,0 +1,200 @@ + +local CapBase = {} + +---comment +---@param airbaseId number +---@param database table +---@param logger table +---@param capConfig table +---@param stageConfig table +---@return table +function CapBase:new(airbaseId, database, logger, capConfig, stageConfig) + local o = {} + setmetatable(o, { __index = self }) + + o.groupNames = database:getCapGroupsAtAirbase(airbaseId) + o.database = database + o.airbaseId = airbaseId + o.logger = logger + o.activeStage = 0 + o.capConfig = capConfig + o.spawned = false + + if capConfig == nil then + capConfig = {} + table.insert(Spearhead.MissionEditingWarnings,"CapConfig is nil") + else + if capConfig.minSpeed == nil then Spearhead.MissionEditingWarnings("CapConfig.minSpeed is nil") end + if capConfig.maxSpeed == nil then Spearhead.MissionEditingWarnings("CapConfig.maxSpeed is nil") end + if capConfig.minAlt == nil then Spearhead.MissionEditingWarnings("CapConfig.minAlt is nil") end + if capConfig.maxAlt == nil then Spearhead.MissionEditingWarnings("CapConfig.maxAlt is nil") end + if capConfig.minDurationOnStation == nil then Spearhead.MissionEditingWarnings("CapConfig.minDurationOnStation is nil") end + if capConfig.maxDurationOnStation == nil then Spearhead.MissionEditingWarnings("CapConfig.maxDurationOnStation is nil") end + if capConfig.rearmDelay == nil then Spearhead.MissionEditingWarnings("CapConfig.rearmDelay is nil") end + if capConfig.deathDelay == nil then Spearhead.MissionEditingWarnings("CapConfig.deathDelay is nil") end + end + + o.activeCapStages = (stageConfig or {}).capActiveStages or 10 + + o.lastStatesByName = {} + o.groupsByName = {} + o.PrimaryGroups = {} + o.BackupGroups = {} + + local CheckReschedulingAsync = function(self, time) + self:CheckAndScheduleCAP() + end + + o.OnGroupStateUpdated = function (self, capGroup) + --[[ + There is no update needed for INTRANSIT, ONSTATION or REARMING as the PREVIOUS state already was checked and nothing changes in the actual overal state. + ]]-- + if capGroup.state == Spearhead.internal.CapGroup.GroupState.INTRANSIT + or capGroup.state == Spearhead.internal.CapGroup.GroupState.ONSTATION + or capGroup.state == Spearhead.internal.CapGroup.GroupState.REARMING + then + return + end + timer.scheduleFunction(CheckReschedulingAsync, self, timer.getTime() + 1) + end + + for key, name in pairs(o.groupNames) do + local capGroup = Spearhead.internal.CapGroup:new(name, airbaseId, logger, database, capConfig) + if capGroup then + o.groupsByName[name] = capGroup + + if capGroup.isBackup ==true then + table.insert(o.BackupGroups, capGroup) + else + table.insert(o.PrimaryGroups, capGroup) + end + + capGroup:AddOnStateUpdatedListener(o) + end + end + + o.SpawnIfApplicable = function(self) + self.logger:debug("Check spawns for airbase " .. self.airbaseId ) + for groupName, capGroup in pairs(self.groupsByName) do + + local activeStage = tostring(self.activeStage) + local targetStage = capGroup:GetTargetZone(activeStage) + + if targetStage ~= nil and capGroup.state == Spearhead.internal.CapGroup.GroupState.UNSPAWNED then + capGroup:SpawnOnTheRamp() + self.spawned = true + end + end + end + + o.CheckAndScheduleCAP = function (self) + + self.logger:debug("Check taskings for airbase " .. self.airbaseId ) + + local countPerStage = {} + local requiredPerStage = {} + + --Count back up groups that are active or reassign to the new zone if that's needed + for _, backupGroup in pairs(self.BackupGroups) do + if backupGroup.state == Spearhead.internal.CapGroup.GroupState.INTRANSIT or backupGroup.state == Spearhead.internal.CapGroup.GroupState.ONSTATION then + local supposedTargetStage = backupGroup:GetTargetZone(self.activeStage) + if supposedTargetStage then + if supposedTargetStage ~= backupGroup.assignedStageNumber then + backupGroup:SendToStage(supposedTargetStage) + end + + if countPerStage[supposedTargetStage] == nil then + countPerStage[supposedTargetStage] = 0 + end + countPerStage[supposedTargetStage] = countPerStage[supposedTargetStage] + 1 + else + backupGroup:SendRTB() + end + elseif backupGroup.state == Spearhead.internal.CapGroup.GroupState.RTBINTEN and backupGroup:GetTargetZone(self.activeStage) ~= backupGroup.assignedStageNumber then + backupGroup:SendRTB() + end + end + + --Schedule or reassign primary units if applicable + for _, primaryGroup in pairs(self.PrimaryGroups) do + local supposedTargetStage = primaryGroup:GetTargetZone(self.activeStage) + if supposedTargetStage then + if requiredPerStage[supposedTargetStage] == nil then + requiredPerStage[supposedTargetStage] = 0 + end + + if countPerStage[supposedTargetStage] == nil + then + countPerStage[supposedTargetStage] = 0 + end + + requiredPerStage[supposedTargetStage] = requiredPerStage[supposedTargetStage] + 1 + + if primaryGroup.state == Spearhead.internal.CapGroup.GroupState.READYONRAMP then + if countPerStage[supposedTargetStage] < requiredPerStage[supposedTargetStage] then + primaryGroup:SendToStage(supposedTargetStage) + countPerStage[supposedTargetStage] = countPerStage[supposedTargetStage] + 1 + end + elseif primaryGroup.state == Spearhead.internal.CapGroup.GroupState.INTRANSIT or primaryGroup.state == Spearhead.internal.CapGroup.GroupState.ONSTATION then + if supposedTargetStage ~= primaryGroup.assignedStageNumber then + if countPerStage[supposedTargetStage] < requiredPerStage[supposedTargetStage] then + primaryGroup:SendToStage(supposedTargetStage) + else + countPerStage[supposedTargetStage] = countPerStage[supposedTargetStage] + 1 + primaryGroup:SendRTB() + end + end + countPerStage[supposedTargetStage] = countPerStage[supposedTargetStage] + 1 + elseif primaryGroup.state == Spearhead.internal.CapGroup.GroupState.RTBINTEN and primaryGroup:GetTargetZone(self.activeStage) ~= primaryGroup.assignedStageNumber then + primaryGroup:SendRTB() + end + else + if primaryGroup.state == Spearhead.internal.CapGroup.GroupState.INTRANSIT or primaryGroup.state == Spearhead.internal.CapGroup.GroupState.ONSTATION then + primaryGroup:SendRTB() + end + end + end + + for _, backupGroup in pairs(self.BackupGroups) do + if backupGroup.state == Spearhead.internal.CapGroup.GroupState.READYONRAMP then + local supposedTargetStage = backupGroup:GetTargetZone(self.activeStage) + if supposedTargetStage then + if countPerStage[supposedTargetStage] == nil then + countPerStage[supposedTargetStage] = 0 + end + + if countPerStage[supposedTargetStage] < requiredPerStage[supposedTargetStage] then + backupGroup:SendToStage(supposedTargetStage) + countPerStage[supposedTargetStage] = countPerStage[supposedTargetStage] + 1 + end + end + end + end + end + + o.OnStageNumberChanged = function (self, number) + self.activeStage = number + self:SpawnIfApplicable() + timer.scheduleFunction(CheckReschedulingAsync, self, timer.getTime() + 5) + end + + ---Check if any CAP is active when a certain stage is active + ---@param self table + ---@param stageNumber number + ---@return boolean + o.IsBaseActiveWhenStageIsActive = function (self, stageNumber) + for _, group in pairs(self.PrimaryGroups) do + local target = group:GetTargetZone(stageNumber) + if target ~= nil then + return true + end + end + return false + end + + Spearhead.Events.AddStageNumberChangedListener(o) + return o +end + +if not Spearhead.internal then Spearhead.internal = {} end +Spearhead.internal.CapAirbase = CapBase \ No newline at end of file diff --git a/capClasses/CapGroup.lua b/capClasses/CapGroup.lua new file mode 100644 index 0000000..751db8a --- /dev/null +++ b/capClasses/CapGroup.lua @@ -0,0 +1,465 @@ +--[[ + +#### Why? +For Spearhead there's a lot of stages and states the mission can be in.
+To make sure CAP units will be at the place where the mission maker expects them to be there's a naming convention that should help.
+You as a mission maker will have full controll over where they are supposed to be, the script will take care of getting them there. +The CAP manager's goal is to provide dedicated aircraft scheduling that doesn't reset every stage reset. + +Naming: CAP\_\<"A" | B"\>\_\ +#### Config: +``` +1 at x: [] +n and n at x: [,] +n till n at x: [-] +n till n and n at x: [-,] +n till n at Active: [-]A + +divider: | + +examples: + +CAP_A[1-4,6]7|[5,7]8_SomeName => Will fly CAP at stage 7 when stages 1 through 4 and 6 are active and will fly CAP at 8 when 5 and 7 are active +CAP_A[2-5]5|[6]6_SomeName => Will fly CAP at stage 5 when stages 2 through 5 active and will fly CAP at 6 when 6 is active +CAP_A[1-5]A|[6]7_SomeName => Will fly CAP at the ACTIVE stage if Stages 1-5 are active. Basically following the active stages. Then when 6 is active it will fly in 7 + +CAP_B[1-5]A|[6]7_SomeName => Will fly BACKUP CAP for the active zones 1 through 5 and back up for 7 when 6 is active. + +``` + +### How many? And how to add backups? + +To fascilitate a nice flow of the mission and also make sure it doesn't oversaturate the zones with aircraft the script works with a Active/Backup system in the naming.
+This really doesn't mean much per se once the mission runs, but most importantly is that the A units define how many groups there should be max in a zone at a time.
+The B units will simply be used to fill that amount if the A units can't due to RTB, Death, Rearming etc.
+ +#### Example + +Take the units: +``` +CAP_A[1-5]A_SomeName1 +CAP_A[1-5]A_SomeName2 +CAP_B[1-3,5]A_SomeName +``` +`CAP_A...` units are primary units where the `CAP_B...` units are the backups.
+In this case the CAP manager sees that for stages 1 through 5 this configuration requires 2 groups in the active zone.
+If one of those 2 groups dies or is going back to base the B group will be used to top up the CAP units at that zone.
+After scheduling the B units the A units that are back at base ready on the ramp will also not be scheduled until the CAP units that are active in the zone (inlcuding B units) drop below the required CAP unit (of 2 in this example) + +In this example there is no Backup unit for zone 4. This might quiet down the CAP a little as the Active groups will have to rearm and refuel without there being any backup. + +### What the cap manager does: +- Spawn aircraft on the ramp (or despawn when they are not needed anymore for culling) +- Send out aircraft based on where they are supposed to be +- Send Aircraft RTB after X time.
+ RTB in this sense means back to its base of origin. Not the closest friendly base like DCS does. +- Simulates Rearming and then sending them out when needed. +- Delays aircraft for X amount of time before spawning and rearming after a groups demise. +- Aircraft are spawned on the ramp so OCA does have effect. (Be sure to also take a look at the Airbase and SAM spawning for defences) + +### Future Ideas + +- Aircraft rearm hubs with finite spawns on other airbases that get replenished by aircraft flying in. + + +]] -- + +local CapHelper = {} +do + ---comment + ---@param groupName string + ---@return table? + CapHelper.ParseGroupName = function(groupName) + local split_string = Spearhead.Util.split_string(groupName, "_") + local partCount = Spearhead.Util.tableLength(split_string) + if partCount >= 3 then + local result = {} + result.zonesConfig = {} + + -- CAP_[1-5]5|[6]6|[7]7_Sukhoi + -- CAP_[1-5,7]A|[6]7_Sukhoi + + local configPart = split_string[2] + local first = configPart:sub(1, 1) + if first == "A" then + result.isBackup = false + configPart = string.sub(configPart, 2, #configPart) + elseif first == "B" then + configPart = string.sub(configPart, 2, #configPart) + result.isBackup = true + elseif first == "[" then + result.isBackup = false + else + table.insert(Spearhead.MissionEditingWarnings, "Could not parse the CAP config for group: " .. groupName) + return nil + end + + local subsplit = Spearhead.Util.split_string(configPart, "|") + if subsplit then + for key, value in pairs(subsplit) do + local keySplit = Spearhead.Util.split_string(value, "]") + local targetZone = keySplit[2] + local allActives = string.sub(keySplit[1], 2, #keySplit[1]) + local commaSeperated = Spearhead.Util.split_string(allActives, ",") + for _, value in pairs(commaSeperated) do + local dashSeperated = Spearhead.Util.split_string(value, "-") + if Spearhead.Util.tableLength(dashSeperated) > 1 then + local from = tonumber(dashSeperated[1]) + local till = tonumber(dashSeperated[2]) + + for i = from, till do + if targetZone == "A" then + result.zonesConfig[tostring(i)] = tostring(i) + else + result.zonesConfig[tostring(i)] = tostring(targetZone) + end + end + else + if targetZone == "A" then + result.zonesConfig[tostring(dashSeperated[1])] = tostring(dashSeperated[1]) + else + result.zonesConfig[tostring(dashSeperated[1])] = tostring(targetZone) + end + end + end + end + end + return result + else + table.insert(Spearhead.MissionEditingWarnings, + "CAP Group with name: " .. groupName .. "should have at least 3 parts, but has " .. partCount) + return nil + end + end +end + +---comment +---@param input table { groupName, task, logger } +---@param time number +---@return nil +local function setTaskAsync(input, time) + local task = input.task + local groupName = input.groupName + local group = Group.getByName(groupName) + + if task then + group:getController():setTask(task) + if input.logger ~= nil then + input.logger:debug("task set succesfully to group " .. groupName) + end + end + return nil +end + +local CapGroup = {} + +CapGroup.GroupState = { + UNSPAWNED = 0, + READYONRAMP = 1, + INTRANSIT = 2, + ONSTATION = 3, + RTBINTEN = 4, + RTB = 5, + DEAD = 6, + REARMING = 7, + DESPAWNED = 8 +} + +local function SetReadyOnRampAsync(self, time) + self:SetState(CapGroup.GroupState.READYONRAMP) +end + +---comment +---@param groupName string +---@param airbaseId number +---@param logger table logger dependency injection +---@param database table database dependency injection +---@param capConfig table config dependency injection +---@return table? o +function CapGroup:new(groupName, airbaseId, logger, database, capConfig) + local o = {} + setmetatable(o, { __index = self }) + + local RESPAWN_AFTER_TOUCHDOWN_SECONDS = 180 + + -- initials + o.groupName = groupName + o.airbaseId = airbaseId + o.logger = logger + o.database = database + + local parsed = CapHelper.ParseGroupName(groupName) + if parsed == nil then return nil end + o.capZonesConfig = parsed.zonesConfig + o.isBackup = parsed.isBackup + + --vars + o.assignedStageName = nil + o.assignedStageNumber = nil + + o.state = CapGroup.GroupState.UNSPAWNED + o.aliveUnits = {} + o.landedUnits = {} + o.unitCount = 0 + o.onStationSince = 0 + o.currentCapTaskingDuration = 0 + + --config + o.capConfig = {} + + if not capConfig then capConfig = {} end + o.capConfig.maxDeviationRange = capConfig.maxDeviationRange + o.capConfig.minSpeed = (capConfig.minSpeed or 400) * 0.514444 + o.capConfig.maxSpeed = (capConfig.maxSpeed or 500) * 0.514444 + o.capConfig.minAlt = (capConfig.minAlt or 8000) * 0.3048 + o.capConfig.maxAlt = (capConfig.maxAlt or 27000) * 0.3048 + o.capConfig.minDurationOnStation = capConfig.minDurationOnStation or 600 + o.capConfig.maxDurationOnstation = capConfig.maxDurationOnStation or 1800 + o.capConfig.rearmDelay = capConfig.rearmDelay or 300 + + ---comment + ---@param self table + ---@param currentActive number + ---@return table + o.GetTargetZone = function (self, currentActive) + return self.capZonesConfig[tostring(currentActive)] + end + + o.SetState = function(self, state) + self.state = state + self:PublishUnitUpdatedEvent() + end + + o.StartRearm = function(self) + self:SpawnOnTheRamp() + self:SetState(CapGroup.GroupState.REARMING) + timer.scheduleFunction(SetReadyOnRampAsync, self, timer.getTime() + self.capConfig.rearmDelay - RESPAWN_AFTER_TOUCHDOWN_SECONDS) + end + + o.SpawnOnTheRamp = function(self) + self.aliveUnits = {} + self.landedUnits = {} + self.onStationSince = 0 + + local group = Spearhead.DcsUtil.SpawnGroupTemplate(self.groupName, nil, nil, true) + if group then + self.unitCount = group:getInitialSize() + + if self.state == CapGroup.GroupState.UNSPAWNED then + self:SetState(CapGroup.GroupState.READYONRAMP) + end + + for _, unit in pairs(group:getUnits()) do + local name = unit:getName() + self.aliveUnits[name] = true + self.landedUnits[name] = false + end + end + end + + o.Despawn = function(self) + Spearhead.DcsUtil.DestroyGroup(self.groupName) + end + + o.SendRTB = function(self) + local group = Group.getByName(self.groupName) + if group and group:isExist() then + local speed = math.random(self.capConfig.minSpeed, self.capConfig.maxSpeed) + local rtbTask, errormessage = Spearhead.RouteUtil.CreateRTBMission(self.groupName, self.airbaseId, speed) + if rtbTask then + timer.scheduleFunction(setTaskAsync, { task = rtbTask, groupName = self.groupName, logger = self.logger }, timer.getTime() + 3) + else + self.logger:error("No RTB task could be created for group: " .. self.groupName .. " due to " .. errormessage) + end + end + end + + ---Starts and send this group to perform CAP at a stage + ---@param self any + ---@param stageZoneNumber string + o.SendToStage = function(self, stageZoneNumber) + if self.state == CapGroup.GroupState.DEAD or self.state == CapGroup.GroupState.RTB then + return --Can't task a unit that's dead or RTB + end + + local stageZoneName = self.database:getStageZoneByStageNumber(stageZoneNumber) + self.assignedStageNumber = stageZoneNumber + self.assignedStageName = stageZoneName + local group = Group.getByName(self.groupName) + if group and group:isExist() then + local zone = Spearhead.DcsUtil.getZoneByName(stageZoneName) + if zone then + self.logger:debug("Sending group out " .. self.groupName) + local controller = group:getController() + local capPoints = database:getCapRouteInZone(stageZoneName, self.airbaseId) or { point1 = { x = zone.x, z = zone.z }, point2 = nil } + + local altitude = math.random(self.capConfig.minAlt, self.capConfig.maxAlt) + local speed = math.random(self.capConfig.minSpeed, self.capConfig.maxSpeed) + local attackHelos = false + local deviationDistance = self.capConfig.maxDeviationRange + local capTask + if self.state == CapGroup.GroupState.ONRAMP or self.onStationSince == 0 then + controller:setCommand({ + id = 'Start', + params = {} + }) + local duration = math.random(self.capConfig.minDurationOnStation, self.capConfig + .maxDurationOnstation) + self.logger:debug("random schedule min: " .. + tostring(self.capConfig.minDurationOnStation or "nil") .. + " max: " .. tostring(self.capConfig.maxDurationOnstation or "nil") .. " actual " .. duration) + self.currentCapTaskingDuration = duration + + + capTask = Spearhead.RouteUtil.createCapMission(self.groupName, self.airbaseId, capPoints.point1, capPoints.point2, altitude, speed, duration, attackHelos, deviationDistance) + else + local duration = self.currentCapTaskingDuration - (timer.getTime() - o.onStationSince) + capTask = Spearhead.RouteUtil.createCapMission(self.groupName, self.airbaseId, capPoints.point1, capPoints.point2, altitude, speed, duration, attackHelos, deviationDistance) + end + + if capTask then + timer.scheduleFunction(setTaskAsync, + { task = capTask, groupName = self.groupName, logger = self.logger }, timer.getTime() + 3) + end + self:SetState(CapGroup.GroupState.INTRANSIT) + end + end + end + + ---Starts and send a unit to another airbase + ---@param self table + ---@param airdomeId any + o.SendToAirbase = function(self, airdomeId) + self.airbaseId = airdomeId + local speed = math.random(self.capConfig.minSpeed, self.capConfig.maxSpeed) + local rtbTask = Spearhead.RouteUtil.CreateRTBMission(self.groupName, airdomeId, speed) + local group = Group.getByName(self.groupName) + local controller = group:getController() + controller:setCommand({ + id = 'Start', + params = {} + }) + timer.scheduleFunction(setTaskAsync, { task = rtbTask, groupName = self.groupName, logger = self.logger }, + timer.getTime() + 5) + end + + o.OnGroupRTB = function(self, groupName) + if groupName == self.groupName then + self.logger:debug("Setting group " .. + groupName .. + " to state RTB after a total of " .. + timer.getTime() - self.onStationSince .. "s of the " .. self.currentCapTaskingDuration .. "s") + self:SetState(CapGroup.GroupState.RTB) + end + end + + o.OnGroupRTBInTen = function(self, groupName) + if groupName == self.groupName then + self:SetState(CapGroup.GroupState.RTBINTEN) + end + end + + o.OnGroupOnStation = function(self, groupName) + if groupName == self.groupName then + self.onStationSince = timer.getTime() + self.logger:debug("Setting group " .. groupName .. " to state Onstation") + self:SetState(CapGroup.GroupState.ONSTATION) + end + end + + ---comment + ---@param self table + ---@param proActive boolean Will check all units in group for aliveness + o.UpdateState = function(self, proActive) + local landed = false + local landedCount = 0 + for name, landedBool in pairs(self.landedUnits) do + if landedBool == true then + landedCount = landedCount + 1 + landed = true + end + end + + local deadCount = 0 + for name, isAlive in pairs(self.aliveUnits) do + if isAlive == false then + deadCount = deadCount + 1 + end + end + + local function DelayedStartRearm(input, time) + local capGroup = input.self + capGroup:StartRearm() + end + + if landedCount + deadCount == self.unitCount then + if landed then + timer.scheduleFunction(DelayedStartRearm, { self = self }, timer.getTime() + RESPAWN_AFTER_TOUCHDOWN_SECONDS) + else + local delay = self.capConfig.deathDelay - self.capConfig.rearmDelay + RESPAWN_AFTER_TOUCHDOWN_SECONDS + timer.scheduleFunction(DelayedStartRearm, { self = self }, timer.getTime() + delay) + end + end + end + + o.eventListeners = {} + ---comment + ---@param self table + ---@param listener table object with function OnGroupStateUpdated(capGroupTable) + o.AddOnStateUpdatedListener = function(self, listener) + if type(listener) ~= "table" then + self.logger:error("Listener not of type table for AddOnStateUpdatedListener") + return + end + + if listener.OnGroupStateUpdated == nil then + self.logger:error("Listener does not implement OnGroupStateUpdated") + return + end + table.insert(self.eventListeners, listener) + end + + o.PublishUnitUpdatedEvent = function(self) + for _, callable in pairs(self.eventListeners) do + local _, error = pcall(function() + callable:OnGroupStateUpdated(self) + end) + if error then + self.logger:error(error) + end + end + end + + o.OnUnitLanded = function(self, initiatorUnit, airbase) + if airbase then + local airdomeId = airbase:getID() + self.airbaseId = airdomeId + end + local name = initiatorUnit:getName() + self.logger:debug("Received unit land event for unit " .. name .. " of group " .. self.groupName) + + self.landedUnits[name] = true + self:UpdateState(false) + end + + o.OnUnitLost = function(self, initiatorUnit) + self.logger:debug("Received unit lost event for group " .. self.groupName) + if initiatorUnit then + self.aliveUnits[initiatorUnit:getName()] = false + end + self:UpdateState(false) + end + + Spearhead.Events.addOnGroupRTBListener(o.groupName, o) + Spearhead.Events.addOnGroupRTBInTenListener(o.groupName, o) + Spearhead.Events.addOnGroupOnStationListener(o.groupName, o) + local units = Group.getByName(groupName):getUnits() + for key, unit in pairs(units) do + Spearhead.Events.addOnUnitLandEventListener(unit:getName(), o) + Spearhead.Events.addOnUnitLostEventListener(unit:getName(), o) + end + return o +end + +if not Spearhead.internal then Spearhead.internal = {} end +Spearhead.internal.CapGroup = CapGroup diff --git a/capClasses/GlobalCapManager.lua b/capClasses/GlobalCapManager.lua new file mode 100644 index 0000000..500671a --- /dev/null +++ b/capClasses/GlobalCapManager.lua @@ -0,0 +1,61 @@ +local GlobalCapManager = {} +do + local airbasesPerStage = {} + local allAirbasesByName = {} + local activeAirbasesPerActiveStage = {} + local unitsPerzonePerStage = {} + + local initiated = false + + function GlobalCapManager.start(database, capConfig, stageConfig) + if initiated == true then return end + + local logger = Spearhead.LoggerTemplate:new("AirbaseManager", Spearhead.config.logLevel) + + local zones = database:getStagezoneNames() + if zones then + for key, stageName in pairs(zones) do + if airbasesPerStage[stageName] == nil then + airbasesPerStage[stageName] = {} + end + + local airbaseIds = database:getAirbaseIdsInStage(stageName) + if airbaseIds then + for _, id in pairs(airbaseIds) do + local airbaseName = Spearhead.DcsUtil.getAirbaseName(id) + if airbaseName then + local airbaseSpecificLogger = Spearhead.LoggerTemplate:new("CAP_" .. airbaseName, Spearhead.config.logLevel) + local airbase = Spearhead.internal.CapAirbase:new(id, database, airbaseSpecificLogger, capConfig, stageConfig) + if airbase then + table.insert(airbasesPerStage[stageName], airbase) + allAirbasesByName[airbaseName] = airbase + end + end + end + end + end + end + + logger:info("Initiated " .. Spearhead.Util.tableLength(allAirbasesByName) .. " airbases for cap") + initiated = true + + local InfoFunctions = {} + + ---returns if there is CAP active + ---@param zoneName any + ---@param activeZoneNumber number + ---@return boolean + InfoFunctions.IsCapActiveWhenZoneIsActive = function(zoneName, activeZoneNumber) + for _, airbase in pairs(airbasesPerStage[zoneName]) do + if airbase:IsBaseActiveWhenStageIsActive(activeZoneNumber) == true then + return true + end + end + return false + end + + Spearhead.capInfo = InfoFunctions + end +end + +Spearhead.internal.GlobalCapManager = GlobalCapManager diff --git a/spearhead.lua b/spearhead.lua new file mode 100644 index 0000000..16d2ff6 --- /dev/null +++ b/spearhead.lua @@ -0,0 +1,162 @@ +--[[ + The Mission Manager creates a way to create missions in a stage like manner with missions without having to worry about monitoring and triggering of said missions. + + The Mission manager assumes players are BLUE and are fighting RED. (Which countries and spawns, that's up to you) + + Mission Naming TriggerZone: + Stage: MISSIONSTAGE__Name + Mission: MISSION__Name + Random Mission: RANDOMMISSION___ + + IMPORTANT NOTES + - DO NOT put mission zones inside of mission zones. + - DO NOT let stage zones overlap (or without there being anything indide of said overlap, this includes airbases) + + + Stage Naming: MISSIONSTAGE__Name + NOTE: Multiple stages can have an order number. + This gives you the opportunity to add multiple zones in a stage. + All stages need to be completed for the next stage order to start. + + Player SPAWNS + Airbases + It is assumed all player spawns are Dynamic slots. + These work relatively nicely nowadays and with a dynamic mission it can provide the best experience. + FARPS + Need to be in TriggerZone with name convention: "FARP_" + will be removed at start and activated inside of the active stage only, so be wary of where you place them. + + Mission Types and their logic: + Random Missions: + To maximise replayability randomisation is directly supported. + There is 2 ways. First you can randomise the units inside of the mission zone the second way is to randomise the mission zone altogether. + + 1. Randomising the units in the mission. + You can use the "Chance" function for groups to spawn or not spawn groups inside of a mission zone. + The framework will only take control over the units after the initial spawn and will therefore not spawn units that did not get spawned on initial creation. + NOTE: This however gives the least predictable outcome and can easily lead to empty missions. + + 2. Randomised mission zones + The best way to randomise it to create X amount of trigger zones with the same mission and let the framework pick 1 on initialisation. + Naming convention: RANDOMMISSION___ (eg. RANDOMMISSION_BAI_BYRON_1 and RANDOMMISSION_BAI_BYRON_2) + RANDOMMISSION: Recogniser + tasking: the tasking just like any other mission + NAME: Codename of the mission. (Use single word only for commands later on) + number: can be any number. Only intention it to make it unique for the editor to not freak out. + + The framework will recognise that RANDOMMISSION_BAI_BYRON_1 and RANDOMMISSION_BAI_BYRON_2 compete against each other and will select a random one and add that to the stage. + After that a random mission will act just like any other mission. + + TIP: If you want a mission that doesn't always spawn: You can do something like the following example: + - RANDOMMISSION_BAI_BYRON_1 => The mission you want to spawn about 1 every 4 times with the units and description + - RANDOMMISSION_BAI_BYRON_2 => Empty trigger zone + - RANDOMMISSION_BAI_BYRON_3 => Empty trigger zone + - RANDOMMISSION_BAI_BYRON_4 => Empty trigger zone + + Special Types: + SAM + All SAM missions will be spawned during the stage, so there's no random pop-ups + SAM missions will be have slightly different ways of briefing and will be shown in the overview of a stage as "known air defenses". + SAMS however do not count towards the completion of the zone and will be despawned once all other missions are done. + SAM missions of the NEXT 2 stages (by order) will also be spawned. + This makes it so you can create defenses of airfields where CAP units are spawned and add long range defenses without having to make the stage huge. + Eg. If MISSIONSTAGE_1_Name is active then all stages with numbers 2 and 3 will also have active SAMS. + + SAM vs DEAD + Generally best practive: Use SAM missions for long range sams that need to be active for longer. + Use DEAD for shorter range popup sams like moving SHORADS. + + + AIRBASES + Airbases have a special logic to them. This is to make sure that it's manageable which bases are used by friendly forces after pushing along. + Capturable bases can be selected and units on airbases are managed. + + Logic is based on the starting coalition of the base. + RED + The base will be used for CAP of the enemy. + On Capture the base will turn NEUTRAL. + Units inside of the airport circle will despawn on the Stage completion. + NEUTRAL + Nothing will be done. It will not be used and units around the airport will not be specifically managed. + BLUE + Airbase will be set to "RED" on intialisation. + All blue units will be despawned and red units spawned on activation of the zone. + When the zone is captured by blue (by finishing the missions), all red units will be removed and all blue units inside it will spawn. + + Airbase Units + All units inside of the circle of the airbase (shown in the me) and not in a mission zone will be regarded as a airbase unit and spawned when the airbase becomes active. + + Missions on airbases. + Missions at airbases are perfectly possible. Any unit that is part of that mission will not be regarded as an "Airbase unit" + + AWACS + ENEMY + An enemy awacs will be spawned in the active stage + 2 unless it's disabled with Config.DisableAwacs. + TODO: AWACS logic + FRIENDLY + Friendly AWACS will be spawned at the ACTIVE stage - 2. Which means that at the start there will be no awacs. + There is one awacs spawned per stage with a delay of 15 minutes delay for respawn per default. + If there is enough blue fighters a red fighter group will be spawned randomly to try and intercept the AWACS. + A message will pop up and players are expected to defend it. + This can be disabled wth Config.DisableAwacsInterceptTask + + + SCRIPTERS + This area of the documentation is for mission makers that want to hook into the framework from their own scripts. + This script will expose flags of it's state, but there are no public methods to alter the framework (at this time). + + FLAGS: + TODO: Expose flags for stage and other metrics +]] -- + +--[[ + TODOLIST: + - FARPS and Airbases V + - RANDOM missions + + - CAP Manager + - Mission Activation + - OPTIONAL Drawings + +]] -- + + +local dbLogger = Spearhead.LoggerTemplate:new("database", Spearhead.config.logLevel) +local databaseManager = Spearhead.DB:new(dbLogger) + +local capConfig = { + maxDeviationRange = 32186, --20NM -- sets max deviation before flight starts pulling back, + minSpeed = 400, + maxSpeed = 500, + minAlt = 18000, + maxAlt = 28000, + minDurationOnStation = 1800, + maxDurationOnStation = 2700, + rearmDelay = 600, + deathDelay = 1800 +} + +local stageConfig = { + preActivatedStages = 3, + capActiveStages = 4 +} + +Spearhead.internal.GlobalCapManager.start(databaseManager, capConfig, stageConfig) +Spearhead.internal.GlobalStageManager.start(databaseManager) + +local activateStage = function (number) + local succ, err = pcall( function () + Spearhead.Events.PublishStageNumberChanged(number) + end) + env.error(err) +end + +missionCommands.addCommand("stage1", {}, activateStage, 1) +missionCommands.addCommand("stage2", {}, activateStage, 2) +missionCommands.addCommand("stage3", {}, activateStage, 3) +missionCommands.addCommand("stage4", {}, activateStage, 4) + +Spearhead.LoadingDone() +--Check lines of code in directory per file: +-- Get-ChildItem . -Include *.lua -Recurse | foreach {""+(Get-Content $_).Count + " => " + $_.name } +-- find . -name '*.lua' | xargs wc -l \ No newline at end of file diff --git a/spearhead_base.lua b/spearhead_base.lua new file mode 100644 index 0000000..c2872f5 --- /dev/null +++ b/spearhead_base.lua @@ -0,0 +1,1560 @@ +--1 + +--- DEFAULT Values +if Spearhead == nil then Spearhead = {} end + +Spearhead.config = {} +Spearhead.config.logLevel = 0 + +--Set SpearheadConfig if nil without values +if not SpearheadConfig then SpearheadConfig = {} end + +if not SpearheadConfig.CAP then SpearheadConfig.CAP = {} end +Spearhead.config.CAP = { + + --- Enables or disables the cap altogether. + enable = SpearheadConfig.CAP.enable or true, + + --- Sets the AI cap units + engageHelos = SpearheadConfig.CAP.engageHelos or false, + + --- sets the tech type. { 0 = custom, 1 = Modern , 2 = WW2 } + technologyType = SpearheadConfig.CAP.technologyType or 1, + + bingoRatio = SpearheadConfig.CAP.bingoRatio or 0.20, + +} + +if not SpearheadConfig.Stages then SpearheadConfig.Stages = {} end + +Spearhead.config.Stages = { + preActivatedStages = SpearheadConfig.Stages.maxPreActivated or 3, + capActiveStages = SpearheadConfig.Stages.capActiveStages or 4, +} + +local UTIL = {} +do -- INIT UTIL + ---splits a string in sub parts by seperator + ---@param input string + ---@param seperator string + ---@return table result list of strings + function UTIL.split_string(input, seperator) + if seperator == nil then + seperator = " " + end + + local result = {} + if input == nil then + return result + end + + for str in string.gmatch(input, "([^" .. seperator .. "]+)") do + table.insert(result, str) + end + return result + end + + ---comment + ---@param table any + ---@return number + function UTIL.tableLength(table) + if table == nil then return 0 end + + local count = 0 + for _ in pairs(table) do count = count + 1 end + return count + end + + ---Gets a random from the list + ---@param list table + function UTIL.randomFromList(list) + local max = #list + + if max == 0 or max == nil then + return nil + end + + local random = math.random(0, max) + if random == 0 then random = 1 end + + return list[random] + end + + local function table_print(tt, indent, done) + done = done or {} + indent = indent or 0 + if type(tt) == "table" then + local sb = {} + for key, value in pairs(tt) do + table.insert(sb, string.rep(" ", indent)) -- indent it + if type(value) == "table" and not done[value] then + done[value] = true + table.insert(sb, key .. " = {\n"); + table.insert(sb, table_print(value, indent + 2, done)) + table.insert(sb, string.rep(" ", indent)) -- indent it + table.insert(sb, "}\n"); + elseif "number" == type(key) then + table.insert(sb, string.format("\"%s\"\n", tostring(value))) + else + table.insert(sb, string.format( + "%s = \"%s\"\n", tostring(key), tostring(value))) + end + end + return table.concat(sb) + else + return tt .. "\n" + end + end + + ---comment + ---@param str string + ---@param findable string + ---@return boolean + UTIL.startswith = function(str, findable) + return str:find('^' .. findable) ~= nil + end + + ---comment + ---@param str string + ---@param findableTable table + ---@return boolean + UTIL.startswithAny = function(str, findableTable) + for key, value in pairs(findableTable) do + if type(value) == "string" and UTIL.startswith(str, value) then return true end + end + return false + end + + function UTIL.toString(something) + if something == nil then + return "nil" + elseif "table" == type(something) then + return table_print(something) + elseif "string" == type(something) then + return something + else + return tostring(something) + end + end +end +Spearhead.Util = UTIL + +---DCS UTIL Takes inspiration from MIST but only takes the things it needs, changes for DCS updates and different vision for advanced mission scripting stuff. +---It also adds functions that make the other TDCS scripts easier without taking too much "control" away like MOOSE can sometimes. +local DCS_UTIL = {} +do -- INIT DCS_UTIL + do -- local databases + --[[ + groupdata = { + category, + country_id, + group_template + } + ]] -- + DCS_UTIL.__miz_groups = {} + DCS_UTIL.__groupNames = {} + DCS_UTIL.__blueGroupNames = {} + DCS_UTIL.__redGroupNames = {} + --[[ + zone = { + name, + + zone_type, + x, + z, + radius + verts, + + } + ]] -- + DCS_UTIL.__trigger_zones = {} + end + + DCS_UTIL.Coalition = + { + NEUTRAL = 0, + RED = 1, + BLUE = 2 + } + + DCS_UTIL.ZoneType = { + Cilinder = 0, + Polygon = 2 + } + + DCS_UTIL.GroupCategory = { + AIRPLANE = 0, + HELICOPTER = 1, + GROUND = 2, + SHIP = 3, + TRAIN = 4, + STATIC = 5 --CUSTOM CATEGORY + } + + DCS_UTIL.__airbaseNamesById = {} + + DCS_UTIL.__airportsStartingCoalition = {} + DCS_UTIL.__warehouseStartingCoalition = {} + function DCS_UTIL.__INIT() + do -- INITS ALL TABLES WITH DATA THAT's from the MIZ environment + do -- init group tables + for coalition_name, coalition_data in pairs(env.mission.coalition) do + local coalition_nr = DCS_UTIL.stringToCoalition(coalition_name) + if coalition_data.country then + for country_index, country_data in pairs(coalition_data.country) do + for category_name, categorydata in pairs(country_data) do + local category_id = DCS_UTIL.stringToGroupCategory(category_name) + if category_id ~= nil and type(categorydata) == "table" and categorydata.group ~= nil and type(categorydata.group) == "table" then + for group_index, group in pairs(categorydata.group) do + local name = group.name + if category_id == DCS_UTIL.GroupCategory.STATIC then + local unit = group.units[1] + name = unit.name + local staticObj = { + heading = unit.heading, + name = unit.name, + x = unit.x, + y = unit.y, + type = unit.type, + dead = group.dead + } + + if string.lower(unit.category) == "planes" then + staticObj.livery_id = unit.livery_id + end + + group = staticObj + end + + table.insert(DCS_UTIL.__groupNames, name) + DCS_UTIL.__miz_groups[name] = + { + category = category_id, + country_id = country_data.id, + group_template = group + } + + if coalition_nr == 1 then + table.insert(DCS_UTIL.__redGroupNames, name) + elseif coalition_nr == 2 then + table.insert(DCS_UTIL.__blueGroupNames, name) + end + end + end + end + end + end + end + end + + do --init trigger zones + for i, trigger_zone in pairs(env.mission.triggers.zones) do + local verticies = {} + if trigger_zone.verticies and type(trigger_zone.verticies) == "table" then + for ii, vert in pairs(trigger_zone.verticies) do + table.insert(verticies, { x = vert.x, z = vert.y }) + end + end + + --see if this works. Swap 3 and 4 to make sure points are ordered and edges can be created + local p4 = verticies[4] + local p3 = verticies[3] + verticies[3] = p4 + verticies[4] = p3 + + local zone = { + name = trigger_zone.name, + zone_type = trigger_zone.type, + x = trigger_zone.x, + z = trigger_zone.y, + radius = trigger_zone.radius, + verts = verticies + } + + DCS_UTIL.__trigger_zones[zone.name] = zone + end + end + + do -- init airports and warehouses + if env.warehouses.airports then + for warehouse_id, value in pairs(env.warehouses.airports) do + if warehouse_id ~= nil then + warehouse_id = tostring(warehouse_id) or "nil" + local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) + DCS_UTIL.__airportsStartingCoalition[warehouse_id] = coalitionNumber + end + end + end + + if env.warehouses.warehouses then + DCS_UTIL.__warehouseStartingCoalition[-1] = "placeholder" + for warehouse_id, value in pairs(env.warehouses.warehouses) do + if warehouse_id ~= nil then + warehouse_id = tostring(warehouse_id) or "nil" + local coalitionNumber = DCS_UTIL.stringToCoalition(value.coalition) + DCS_UTIL.__warehouseStartingCoalition[warehouse_id] = coalitionNumber + end + end + end + end + + do -- fill airbaseNames + local airbases = world.getAirbases() + if airbases then + for _, airbase in pairs(airbases) do + local name = airbase:getName() + local id = tostring(airbase:getID()) + + if name and id then + DCS_UTIL.__airbaseNamesById[id] = name + end + end + end + end + end + end + + ---maps the coalition name to the DCS coalition integer + ---@param input string the name + ---@return integer + function DCS_UTIL.stringToCoalition(input) + --[[ + coalition.side = { + NEUTRAL = 0 + RED = 1 + BLUE = 2 + } + ]] -- + local input = string.lower(input) + if input == 'neutrals' or input == "neutral" or input == "0" then + return DCS_UTIL.Coalition.NEUTRAL + end + + if input == 'red' or input == "1" then + return DCS_UTIL.Coalition.RED + end + + if input == 'blue' or input == "2" then + return DCS_UTIL.Coalition.BLUE + end + + return -1 + end + + ---checks if the groupname is a static group + ---@param groupName any + function DCS_UTIL.IsGroupStatic(groupName) + return DCS_UTIL.__miz_groups[groupName].category == 5; + end + + ---comment + ---@param groupName string destroy the given group + function DCS_UTIL.DestroyGroup(groupName) + if DCS_UTIL.IsGroupStatic(groupName) then + local object = StaticObject.getByName(groupName) + if object ~= nil then + object:destroy() + end + else + local group = Group.getByName(groupName) + if group and group:isExist() then + group:destroy() + end + end + end + + ---comment + ---@param polygon table of pairs { x, z } + ---@param x number X location + ---@param z number Y location + ---@return boolean + function DCS_UTIL.IsPointInPolygon(polygon, x, z) + local function isInComplexPolygon(polygon, x, z) + local function getEdges(poly) + local moved = {} + moved[#poly] = poly[1] + for i = 2, #poly do + moved[i - 1] = poly[i] + end + + local result = {} + for i = 1, #poly do + local point1 = moved[i] + local point2 = poly[i] + local edge = { x1 = point1.x, z1 = point1.z, x2 = point2.x, z2 = point2.z } + table.insert(result, edge) + end + return result + end + + local edges = getEdges(polygon) + local count = 0; + for _, edge in pairs(edges) do + if (x < edge.x1) ~= (x < edge.x2) and z < edge.z1 + ((x - edge.x1) / (edge.x2 - edge.x1)) * (edge.z2 - edge.z1) then + count = count + 1 + -- if (yp < y1) != (yp < y2) and xp < x1 + ((yp-y1)/(y2-y1))*(x2-x1) then + -- count = count + 1 + end + end + return count % 2 == 1 + end + return isInComplexPolygon(polygon, x, z) + end + + --- takes a list of units and returns all the units that are in any of the zones + ---@param unit_names table unit names + ---@param zone_names table zone names + ---@return table unit list of objects { unit = UNIT, zone_name = zoneName} + function DCS_UTIL.getUnitsInZones(unit_names, zone_names) + local units = {} + local zones = {} + + for k = 1, #unit_names do + local unit = Unit.getByName(unit_names[k]) or StaticObject.getByName(unit_names[k]) + if unit and unit:isExist() == true then + units[#units + 1] = unit + end + end + + for index, zone_name in pairs(zone_names) do + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone then + zones[#zones + 1] = zone + end + end + + local in_zone_units = {} + for units_ind = 1, #units do + local lUnit = units[units_ind] + local unit_pos = lUnit:getPosition().p + local lCat = Object.getCategory(lUnit) + for zone_name, zone in pairs(zones) do + if unit_pos and ((lCat == 1 and lUnit:isActive() == true) or lCat ~= 1) then -- it is a unit and is active or it is not a unit + if zone.zone_type == DCS_UTIL.ZoneType.Polygon and zone.verts then + if DCS_UTIL.IsPointInPolygon(zone.verts, unit_pos.x, unit_pos.z) == true then + in_zone_units[#in_zone_units + 1] = { unit = lUnit, zone_name = zone.name } + end + else + if (((unit_pos.x - zone.x) ^ 2 + (unit_pos.z - zone.z) ^ 2) ^ 0.5 <= zone.radius) then + in_zone_units[#in_zone_units + 1] = { unit = lUnit, zone_name = zone.name } + end + end + end + end + end + return in_zone_units + end + + --- takes a list of groups and returns all the group leaders that are in any of the zones + ---@param group_names table unit names + ---@param zone_name string zone names + ---@return table groupnames list of group names + function DCS_UTIL.getGroupsInZone(group_names, zone_name) + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone == nil then + return {} + end + + -- MAP Just for mapping sake + local custom_zone = { + x = zone.x, + z = zone.z, + zone_type = zone.zone_type, + radius = zone.radius, + verts = zone.verts + } + + return DCS_UTIL.areGroupsInCustomZone(group_names, custom_zone) + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param group_names table North South position + ---@param zone table { x, z, zonetype, radius, verts } + ---@return table groupnames list of groups that are in the zone + function DCS_UTIL.areGroupsInCustomZone(group_names, zone) + local units = {} + if Spearhead.Util.tableLength(group_names) < 1 then return {} end + + for k = 1, #group_names do + local entry = nil + local group = Group.getByName(group_names[k]) + if group ~= nil then + entry = { unit = group:getUnit(1), groupname = group_names[k] } + else + entry = { unit = StaticObject.getByName(group_names[k]), groupname = group_names[k] } + end + + if entry and entry.unit and entry.unit:isExist() == true then + units[#units + 1] = entry + end + end + + local result_groups = {} + for _, entry in pairs(units) do + local pos = entry.unit:getPoint() + if zone.zone_type == DCS_UTIL.ZoneType.Polygon and zone.verts then + if DCS_UTIL.IsPointInPolygon(zone.verts, pos.x, pos.z) == true then + table.insert(result_groups, entry.groupname) + end + else + if (((pos.x - zone.x) ^ 2 + (pos.z - zone.z) ^ 2) ^ 0.5 <= zone.radius) then + table.insert(result_groups, entry.groupname) + end + end + end + return result_groups + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param x number North South position + ---@param z number West East position + ---@param zone_names table zone names + ---@return table zones list of objects { zone_name = zoneName} + function DCS_UTIL.isPositionInZones(x, z, zone_names) + local zones = {} + for index, zone_name in pairs(zone_names) do + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone then + zones[#zones + 1] = zone + end + end + + local result_zones = {} + for zone_name, zone in pairs(zones) do + if zone.zone_type == DCS_UTIL.ZoneType.Polygon and zone.verts then + if DCS_UTIL.IsPointInPolygon(zone.verts, x, z) == true then + result_zones[#result_zones + 1] = zone.name + end + else + if (((x - zone.x) ^ 2 + (z - zone.z) ^ 2) ^ 0.5 <= zone.radius) then + result_zones[#result_zones + 1] = zone.name + end + end + end + return result_zones + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param x number North South position + ---@param z number West East position + ---@param zone_name table zone names + ---@return boolean result + function DCS_UTIL.isPositionInZone(x, z, zone_name) + local zone = DCS_UTIL.__trigger_zones[zone_name] + if zone.zone_type == DCS_UTIL.ZoneType.Polygon and zone.verts then + if DCS_UTIL.IsPointInPolygon(zone.verts, x, z) == true then + return true + end + else + if (((x - zone.x) ^ 2 + (z - zone.z) ^ 2) ^ 0.5 <= zone.radius) then + return true + end + end + return false + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param zone_name string + ---@param parent_zone_name string + ---@return boolean result + function DCS_UTIL.isZoneInZone(zone_name, parent_zone_name) + local zoneA = DCS_UTIL.__trigger_zones[zone_name] + local zoneB = DCS_UTIL.__trigger_zones[parent_zone_name] + + if zoneB.zone_type == DCS_UTIL.ZoneType.Polygon and zoneB.verts then + if DCS_UTIL.IsPointInPolygon(zoneB.verts, zoneA.x, zoneA.z) == true then + return true + end + else + if (((zoneA.x - zoneB.x) ^ 2 + (zoneA.z - zoneB.z) ^ 2) ^ 0.5 <= zoneB.radius) then + return true + end + end + return false + end + + --- takes a x, y poistion and checks if it is inside any of the zones + ---@param x number North South position + ---@param z number West East position + ---@param zone table { x, z, zonetype, radius } + ---@return boolean result + function DCS_UTIL.isPositionInCustomZone(x, z, zone) + if zone.zone_type == DCS_UTIL.ZoneType.Polygon and zone.verts then + if DCS_UTIL.IsPointInPolygon(zone.verts, x, z) == true then + return true + end + else + if (((x - zone.x) ^ 2 + (z - zone.z) ^ 2) ^ 0.5 <= zone.radius) then + return true + end + end + return false + end + + ---comment + ---@param zone_name any + ---@return table? zone { name,b zone_type, x, z, radius, verts } + function DCS_UTIL.getZoneByName(zone_name) + if zone_name == nil then return nil end + return DCS_UTIL.__trigger_zones[zone_name] + end + + ---maps the category name to the DCS group category + ---@param input string the name + ---@return integer? + function DCS_UTIL.stringToGroupCategory(input) + input = string.lower(input) + if input == 'airplane' or input == 'plane' then + return DCS_UTIL.GroupCategory.AIRPLANE + end + if input == 'helicopter' then + return DCS_UTIL.GroupCategory.HELICOPTER + end + if input == 'ground' or input == 'vehicle' then + return DCS_UTIL.GroupCategory.GROUND + end + if input == 'ship' then + return DCS_UTIL.GroupCategory.SHIP + end + if input == 'train' then + return DCS_UTIL.GroupCategory.TRAIN + end + if input == "static" then + return DCS_UTIL.GroupCategory.STATIC + end + return nil; + end + + --- get the group config as per start of the mission + --- group = { + --- category, + --- country_id, + --- group_template + --- } + ---@param groupname string groupName you're looking for + function DCS_UTIL.GetMizGroupOrDefault(groupname, default) + local group = DCS_UTIL.__miz_groups[groupname] + if group == nil then + return default + end + return group + end + + ---comment Get all group names. Can be a LOT + ---Includes statics + ---@return table groups + function DCS_UTIL.getAllGroupNames() + return DCS_UTIL.__groupNames + end + + ---comment Get all BLUE group names. Can be a LOT + ---Includes statics + ---@return table groups + function DCS_UTIL.getAllBlueGroupNames() + return DCS_UTIL.__blueGroupNames + end + + ---comment Get all RED group names. Can be a LOT + ---Includes statics + ---@return table groups + function DCS_UTIL.getAllRedGroupNames() + return DCS_UTIL.__redGroupNames + end + + ---comment Get all units that are players + ---@return table units + function DCS_UTIL.getAllPlayerUnits() + local units = {} + for key, value in pairs({ 1, 2, 3 }) do + local players = coalition.getPlayers(value) + for key, unit in pairs(players) do + units[#units + 1] = unit + end + end + return units + end + + ---get base name from ID + ---@param baseId number + ---@return string? name + function DCS_UTIL.getAirbaseName(baseId) + local stringified = tostring(baseId) + return DCS_UTIL.__airbaseNamesById[stringified] + end + + ---get base from id + ---@param baseId number + ---@return table? table + function DCS_UTIL.getAirbaseById(baseId) + local name = DCS_UTIL.getAirbaseName(baseId) + if name == nil then return nil end + return Airbase.getByName(name) + end + + ---Get the starting coalition of a farp or airbase + ---@return number? coalition + function DCS_UTIL.getStartingCoalition(baseId) + if baseId == nil then + return nil + end + + --STRING based dictionary otherwise it'll be a string/collapsed array + baseId = tostring(baseId) or "nil" + + local result = DCS_UTIL.__airportsStartingCoalition[baseId] + if result == nil then + result = DCS_UTIL.__warehouseStartingCoalition[baseId] + end + return result + end + + ---Gets all groups that have players + ---@return table groups + function DCS_UTIL.getAllPlayerGroups() + local groupNames = {} + local result = {} + for key, value in pairs({ 1, 2, 3 }) do + local players = coalition.getPlayers(value) + for key, unit in pairs(players) do + local group = unit:getGroup() + if group ~= nil then + local name = group:getName() + if name ~= nil then + if groupNames[name] ~= nil then + groupNames[name] = 1 + table.insert(result, group) + end + end + end + end + end + return result + end + + --- spawns the units as specified in the mission file itself + --- location and route can be nil and will then use default route + ---@param groupName string + ---@param location table? vector 3 data. { x , z, alt } + ---@param route table? route of the group. If nil wil be the default route. + ---@param uncontrolled boolean? Sets the group to be uncontrolled on spawn + ---@return table? new_group the Group class that was spawned + function DCS_UTIL.SpawnGroupTemplate(groupName, location, route, uncontrolled) + if groupName == nil then + return + end + + local template = DCS_UTIL.GetMizGroupOrDefault(groupName, nil) + if template == nil then + return nil + end + if template.category == DCS_UTIL.GroupCategory.STATIC then + --TODO: Implement location and route stuff + local spawn_template = template.group_template + coalition.addStaticObject(template.country_id, spawn_template) + else + local spawn_template = template.group_template + if location ~= nil then + local x_offset + if location.x ~= nil then x_offset = spawn_template.x - location.x end + + local y_offset + if location.z ~= nil then y_offset = spawn_template.y - location.z end + + spawn_template.x = location.x + spawn_template.y = location.z + + for i, unit in pairs(spawn_template.units) do + unit.x = unit.x - x_offset + unit.y = unit.y - y_offset + unit.alt = location.alt + end + end + + if route ~= nil then + spawn_template.route = route + end + + if uncontrolled ~= nil then + spawn_template.uncontrolled = uncontrolled + end + local new_group = coalition.addGroup(template.country_id, template.category, spawn_template) + return new_group + end + end + + function DCS_UTIL.IsBingoFuel(groupName, offset) + if offset == nil then offset = 0 end + local bingoSetting = 0.20 + if Spearhead.config and Spearhead.config.CAP then + bingoSetting = Spearhead.config.CAP.bingoRatio or 0.20 + end + + bingoSetting = bingoSetting + offset + + local group = Group.getByName(groupName) + for _, unit in pairs(group:getUnits()) do + if unit and unit:isExist() == true and unit:inAir() == true and unit:getFuel() < bingoSetting then + if Spearhead.config.logLevel == Spearhead.LoggerTemplate.LogLevelOptions.DEBUG then + env.info("[Spearhead][DcsUtil][DEBUG] " .. groupName .. " is bingo fuel: " .. unit:getFuel()) + end + return true + end + end + return false + end + + DCS_UTIL.__INIT(); +end +Spearhead.DcsUtil = DCS_UTIL + +local LOGGER = {} +do + LOGGER.LogLevelOptions = { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 + } + + local PreFix = "Spearhead" + + function LOGGER:new(logger_name, logLevel, liveLoggingLevel) + local o = {} + setmetatable(o, { __index = self }) + o.LoggerName = logger_name or "(loggername not set)" + o.LogLevel = logLevel or LOGGER.LogLevelOptions.INFO + o.LiveLoggingLevel = liveLoggingLevel or LOGGER.LogLevelOptions.NONE + + ---comment + ---@param self table self logger + ---@param message any the message + o.info = function(self, message) + if message == nil then + return + end + message = UTIL.toString(message) + + if self.LogLevel <= LOGGER.LogLevelOptions.INFO then + env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + + if self.LiveLoggingLevel <= LOGGER.LogLevelOptions.INFO then + trigger.action.outText(message, 20) + end + end + + ---comment + ---@param message string + o.warn = function(self, message) + if message == nil then + return + end + message = UTIL.toString(message) + + if self.LogLevel <= LOGGER.LogLevelOptions.WARN then + env.warning("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + + if self.LiveLoggingLevel <= LOGGER.LogLevelOptions.WARN then + trigger.action.outText(message, 20) + end + end + + ---comment + ---@param self table -- logger + ---@param message any -- the message + o.error = function(self, message) + if message == nil then + return + end + + message = UTIL.toString(message) + + if self.LogLevel <= LOGGER.LogLevelOptions.ERROR then + env.error("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "] " .. message) + end + + if self.LiveLoggingLevel <= LOGGER.LogLevelOptions.ERROR then + trigger.action.outText(message, 20) + end + end + + ---write debug + ---@param self table + ---@param message any the message + o.debug = function(self, message) + if message == nil then + return + end + + message = UTIL.toString(message) + if self.LogLevel <= LOGGER.LogLevelOptions.DEBUG then + env.info("[" .. PreFix .. "]" .. "[" .. self.LoggerName .. "][DEBUG] " .. message) + end + + if self.LiveLoggingLevel <= LOGGER.LogLevelOptions.DEBUG then + trigger.action.outText(message, 20) + end + end + + + return o + end +end +Spearhead.LoggerTemplate = LOGGER + +local ROUTE_UTIL = {} +do --setup route util + ---comment + ---@param attackHelos boolean + ---@return table + local function GetCAPTargetTypes(attackHelos) + local targetTypes = { + [1] = "Fighters", + [2] = "Multirole fighters", + [3] = "Bombers", + } + + if attackHelos then + targetTypes[4] = "Helicopters" + end + + return targetTypes + end + + ---comment + ---@param airdromeId number + ---@param basePoint table { x, z, y } (y == alt) + ---@param speed number the speed + ---@return table task + local RtbTask = function(airdromeId, basePoint, speed) + if basePoint == nil then + basePoint = Spearhead.Util.getAirbaseById(airdromeId):getPoint() + end + + return { + alt = basePoint.y, + action = "Landing", + alt_type = "BARO", + speed = speed, + ETA = 0, + ETA_locked = false, + x = basePoint.x, + y = basePoint.z, + speed_locked = true, + formation_template = "", + airdromeId = airdromeId, + type = "Land", + task = { + id = "ComboTask", + params = { + tasks = {} + } + } + } + end + + ---comment + ---@param groupName string + ---@param position table { x, y} + ---@param altitude number + ---@param speed number + ---@param duration number + ---@param engageHelos boolean + ---@param pattern string ["Race-Track"|"Circle"] + ---@return table + local CapTask = function(groupName, position, altitude, speed, duration, engageHelos, deviationdistance, pattern) + local durationBefore10 = duration - 600 + if durationBefore10 < 0 then durationBefore10 = 0 end + local durationAfter10 = 600 + if duration < 600 then + durationAfter10 = duration + end + + return { + alt = altitude, + action = "Turning Point", + alt_type = "BARO", + speed = speed, + ETA = 0, + ETA_locked = false, + x = position.x, + y = position.z, + speed_locked = true, + formation_template = "", + task = { + id = "ComboTask", + params = { + tasks = { + [1] = { + number = 1, + auto = false, + id = "WrappedAction", + enabled = "true", + params = { + action = { + id = "Script", + params = { + command = "pcall(Spearhead.Events.PublishOnStation, \"" .. groupName .. "\")" + } + } + } + }, + [2] = { + id = 'EngageTargets', + params = { + maxDist = deviationdistance, + maxDistEnabled = deviationdistance >= 0, -- required to check maxDist + targetTypes = GetCAPTargetTypes(engageHelos), + priority = 0 + } + }, + [3] = { + number = 3, + auto = false, + id = "ControlledTask", + enabled = true, + params = { + task = { + id = "Orbit", + params = { + altitude = altitude, + pattern = pattern, + speed = speed, + } + }, + stopCondition = { + duration = durationBefore10, + condition = "return Spearhead.DcsUtil.IsBingoFuel(\"" .. groupName .. "\", 0.10)", + } + } + }, + [4] = { + number = 4, + auto = false, + id = "WrappedAction", + enabled = "true", + params = { + action = { + id = "Script", + params = { + command = "pcall(Spearhead.Events.PublishRTBInTen, \"" .. groupName .. "\")" + } + } + } + }, + [5] = { + number = 5, + auto = false, + id = "ControlledTask", + enabled = true, + params = { + task = { + id = "Orbit", + params = { + altitude = altitude, + pattern = pattern, + speed = speed, + } + }, + stopCondition = { + duration = durationAfter10, + condition = "return Spearhead.DcsUtil.IsBingoFuel(\"" .. groupName .. "\")", + } + } + }, + [6] = { + number = 6, + auto = false, + id = "WrappedAction", + enabled = "true", + params = { + action = { + id = "Script", + params = { + command = "pcall(Spearhead.Events.PublishRTB, \"" .. groupName .. "\")" + } + } + } + } + } + } + } + } + end + + ---comment + ---@param position table { x, y} + ---@param altitude number + ---@param speed number + ---@param childTasks table + ---@return table + local FlyToPointTask = function(position, altitude, speed, childTasks) + return { + alt = altitude, + action = "Turning Point", + alt_type = "BARO", + speed = speed, + ETA = 0, + ETA_locked = false, + x = position.x, + y = position.z, + speed_locked = true, + formation_template = "", + task = { + id = "ComboTask", + params = { + tasks = childTasks or {} + } + } + } + end + + ---comment + ---@param groupName string groupName you're creating this route for + ---@param airdromeId number airdromeId + ---@param capPoint table { x, z } + ---@param altitude number + ---@param speed number + ---@param durationOnStation number + ---@param attackHelos boolean + ---@param deviationDistance number + ---@return table route + ROUTE_UTIL.createCapMission = function(groupName, airdromeId, capPoint, racetrackSecondPoint, altitude, speed, + durationOnStation, attackHelos, deviationDistance) + local baseName = Spearhead.DcsUtil.getAirbaseName(airdromeId) + if baseName == nil then + return {} + end + + durationOnStation = durationOnStation or 1800 + altitude = altitude or 3000 + speed = speed or 130 + attackHelos = attackHelos or false + deviationDistance = deviationDistance or 32186 + + local base = Airbase.getByName(baseName) + if base == nil then + return {} + end + + local additionalFlyOverTasks = { + { + enabled = true, + auto = false, + id = "WrappedAction", + number = 1, + params = { + action = { + id = "Option", + params = { + variantIndex = 2, + name = AI.Option.Air.id.FORMATION, + formationIndex = 2, + value = 131074 + } + } + } + } + } + + local orbitType = "Circle" + if racetrackSecondPoint then orbitType = "Race-Track" end + + local basePoint = base:getPoint() + local points = {} + if racetrackSecondPoint == nil then + points = { + [1] = FlyToPointTask(capPoint, altitude, speed, additionalFlyOverTasks), + [2] = CapTask(groupName, capPoint, altitude, speed, durationOnStation, attackHelos, deviationDistance, + orbitType), + [3] = RtbTask(airdromeId, basePoint, speed) + } + else + points = { + [1] = FlyToPointTask(capPoint, altitude, speed, additionalFlyOverTasks), + [2] = CapTask(groupName, capPoint, altitude, speed, durationOnStation, attackHelos, deviationDistance, + orbitType), + [3] = FlyToPointTask(racetrackSecondPoint, altitude, speed, {}), + [4] = RtbTask(airdromeId, basePoint, speed) + } + end + + return { + id = 'Mission', + params = { + airborne = true, + route = { + points = points + } + } + } + end + + ---Creates an RTB task. The first point is to trigger the TDCS OnRTB Event, the second task will be the actual RTB point + ---If any of the values are not met it will return nil + ---@param groupName string + ---@param airdromeId number + ---@param speed number + ---@return table?, string ComboTask + ROUTE_UTIL.CreateRTBMission = function(groupName, airdromeId, speed) + --[[ + TODO: Test the creation and pubishing of event and the timing of said event + ]] -- + + local base = Spearhead.DcsUtil.getAirbaseById(airdromeId) + if base == nil then + return nil, "No airbase found for ID " .. tostring(airdromeId) + end + + local group = Group.getByName(groupName) + local pos; + local i = 1 + local units = group:getUnits() + while pos == nil and i <= Spearhead.Util.tableLength(units) do + local unit = units[i] + if unit and unit:isExist() == true and unit:inAir() == true then + pos = unit:getPoint() + end + i = i + 1 + end + + speed = speed or 130 + if pos == nil then + return nil, "Could not find any unit in the air to set the RTB task" + end + + local additionalFlyOverTasks = { + { + enabled = true, + auto = false, + id = "WrappedAction", + number = 1, + params = { + action = { + id = "Option", + params = { + variantIndex = 2, + name = AI.Option.Air.id.FORMATION, + formationIndex = 2, + value = 131074 + } + } + } + } + } + + return { + id = "Mission", + params = { + airborne = true, -- RTB mission generally are given to airborne units + route = { + points = { + + [1] = { + alt = pos.y, + action = "Turning Point", + alt_type = "BARO", + speed = speed, + ETA = 0, + ETA_locked = false, + x = pos.x, + y = pos.z, + speed_locked = true, + formation_template = "", + task = { + id = "ComboTask", + params = { + tasks = { + [1] = { + number = 1, + auto = false, + id = "WrappedAction", + enabled = "true", + params = { + action = { + id = "Script", + params = { + command = "pcall(Spearhead.Events.PublishRTB, \"" .. + groupName .. "\")" + } + } + } + } + } + } + } + }, + [2] = FlyToPointTask(base:getPoint(), 600, speed, additionalFlyOverTasks), + [3] = RtbTask(airdromeId, base:getPoint(), speed) + } + } + } + }, "" + end +end + +Spearhead.RouteUtil = ROUTE_UTIL + +local SpearheadEvents = {} +do + local SpearheadLogger = Spearhead.LoggerTemplate:new("Spearhead Events", Spearhead.config.logLevel) + + do -- STAGE NUMBER CHANGED + local OnStageNumberChangedListeners = {} + local OnStageNumberChangedHandlers = {} + + ---Add a stage zone number changed listener + ---@param listener table object with function OnStageNumberChanged(self, number) + SpearheadEvents.AddStageNumberChangedListener = function(listener) + if type(listener) ~= "table" then + SpearheadLogger:warn("Event listener not of type table, did you mean to use handler?") + return + end + table.insert(OnStageNumberChangedListeners, listener) + end + + ---Add a stage zone number changed listener + ---@param handler function function(number) + SpearheadEvents.AddStageNumberChangedHandler = function(handler) + if type(handler) ~= "function" then + SpearheadLogger:warn("Event handler not of type function, did you mean to use listener?") + return + end + table.insert(OnStageNumberChangedHandlers, handler) + end + + ---@param newStageNumber number + SpearheadEvents.PublishStageNumberChanged = function(newStageNumber) + for _, callable in pairs(OnStageNumberChangedListeners) do + local succ, err = pcall(function() + callable:OnStageNumberChanged(newStageNumber) + end) + if err then + SpearheadLogger:error(err) + end + end + + for _, callable in pairs(OnStageNumberChangedHandlers) do + local succ, err = pcall(callable, newStageNumber) + if err then + SpearheadLogger:error(err) + end + end + end + end + + local onLandEventListeners = {} + ---Add an event listener to a specific unit + ---@param unitName string to call when the unit lands + ---@param landListener table table with function OnUnitLanded(self, initiatorUnit, airbase) + SpearheadEvents.addOnUnitLandEventListener = function(unitName, landListener) + if type(landListener) ~= "table" then + SpearheadLogger:warn("Event handler not of type table/object") + return + end + + SpearheadLogger:debug("Added Land event handler for unit: " .. unitName) + + if onLandEventListeners[unitName] == nil then + onLandEventListeners[unitName] = {} + end + table.insert(onLandEventListeners[unitName], landListener) + end + + local OnUnitLostListeners = {} + ---This listener gets fired for any event that can indicate a loss of a unit. + ---Such as: Eject, Crash, Dead, Unit_Lost, + ---@param unitName any + ---@param unitLostListener table Object with function: OnUnitLost(initiatorUnit) + SpearheadEvents.addOnUnitLostEventListener = function(unitName, unitLostListener) + if type(unitLostListener) ~= "table" then + SpearheadLogger:warn("Unit lost Event listener not of type table/object") + return + end + + if OnUnitLostListeners[unitName] == nil then + OnUnitLostListeners[unitName] = {} + end + + table.insert(OnUnitLostListeners[unitName], unitLostListener) + end + + do -- ON RTB + local OnGroupRTBListeners = {} + ---Adds a function to the events listener that triggers when a group publishes themselves RTB. + ---This is only available when a ROUTE is created via the Spearhead.RouteUtil + ---@param groupName string the groupname to expect + ---@param handlingObject table object with OnGroupRTB(self, groupName) + SpearheadEvents.addOnGroupRTBListener = function(groupName, handlingObject) + if type(handlingObject) ~= "table" then + SpearheadLogger:warn("Event handler not of type table/object") + return + end + + if OnGroupRTBListeners[groupName] == nil then + OnGroupRTBListeners[groupName] = {} + end + + table.insert(OnGroupRTBListeners[groupName], handlingObject) + end + + ---Publish the Group to RTB + ---@param groupName string + SpearheadEvents.PublishRTB = function(groupName) + SpearheadLogger:debug("Publishing RTB event for group " .. groupName) + if groupName ~= nil then + if OnGroupRTBListeners[groupName] then + for _, callable in pairs(OnGroupRTBListeners[groupName]) do + local succ, err = pcall(function() + callable:OnGroupRTB(groupName) + end) + if err then + SpearheadLogger:error(err) + end + end + end + end + end + + local OnGroupRTBInTenListeners = {} + ---Adds a function to the events listener that triggers when a group publishes themselves RTB. + ---This is only available when a ROUTE is created via the Spearhead.RouteUtil + ---@param groupName string the groupname to expect + ---@param handlingObject table object with OnGroupRTBInTen(self, groupName) + SpearheadEvents.addOnGroupRTBInTenListener = function(groupName, handlingObject) + if type(handlingObject) ~= "table" then + SpearheadLogger:warn("Event handler not of type table/object") + return + end + + if OnGroupRTBInTenListeners[groupName] == nil then + OnGroupRTBInTenListeners[groupName] = {} + end + + table.insert(OnGroupRTBInTenListeners[groupName], handlingObject) + end + + ---Publish the Group is RTB + ---@param groupName string + SpearheadEvents.PublishRTBInTen = function(groupName) + SpearheadLogger:debug("Publishing RTB in TEN event for group " .. groupName) + if groupName ~= nil then + if OnGroupRTBInTenListeners[groupName] then + for _, callable in pairs(OnGroupRTBInTenListeners[groupName]) do + local succ, err = pcall(function() + callable:OnGroupRTBInTen(groupName) + end) + if err then + SpearheadLogger:error(err) + end + end + end + end + end + end + + do -- ON Station + local OnGroupOnStationListeners = {} + ---Adds a function to the events listener that triggers when a group publishes themselves RTB. + ---This is only available when a ROUTE is created via the Spearhead.RouteUtil + ---@param groupName string the groupname to expect + SpearheadEvents.addOnGroupOnStationListener = function(groupName, handlingObject) + if type(handlingObject) ~= "table" then + SpearheadLogger:warn("Event handler not of type table/object") + return + end + + if OnGroupOnStationListeners[groupName] == nil then + OnGroupOnStationListeners[groupName] = {} + end + + table.insert(OnGroupOnStationListeners[groupName], handlingObject) + end + + ---Publish the Group to RTB + ---@param groupName string + SpearheadEvents.PublishOnStation = function(groupName) + SpearheadLogger:debug("Publishing onStation event for group " .. groupName) + if groupName ~= nil then + if OnGroupOnStationListeners[groupName] then + for _, callable in pairs(OnGroupOnStationListeners[groupName]) do + local succ, err = pcall(function() + callable:OnGroupOnStation(groupName) + end) + if err then + SpearheadLogger:error(err) + end + end + end + end + end + end + + + local e = {} + function e:onEvent(event) + if event.id == world.event.S_EVENT_LAND or event.id == world.event.S_EVENT_RUNWAY_TOUCH then + local unit = event.initiator + local airbase = event.place + if unit ~= nil then + local name = unit:getName() + if onLandEventListeners[name] then + for _, callable in pairs(onLandEventListeners[name]) do + local succ, err = pcall(function() + callable:OnUnitLanded(unit, airbase) + end) + if err then + SpearheadLogger:error(err) + end + end + end + end + end + + if event.id == world.event.S_EVENT_DEAD or + event.id == world.event.S_EVENT_CRASH or + event.id == world.event.S_EVENT_EJECTION or + event.id == world.event.S_EVENT_UNIT_LOST then + local object = event.initiator + if object and OnUnitLostListeners[object:getName()] then + for _, callable in pairs(OnUnitLostListeners[object:getName()]) do + local succ, err = pcall(function() + callable:OnUnitLost(object) + end) + + if err then + SpearheadLogger:error(err) + end + end + end + end + end + + world.addEventHandler(e) +end +Spearhead.Events = SpearheadEvents + +Spearhead.MissionEditingWarnings = {} +function Spearhead.AddMissionEditorWarning(warningMessage) + table.insert(Spearhead.MissionEditingWarnings, warningMessage or "skip") +end + +local loadDone = false +Spearhead.LoadingDone = function() + if loadDone == true then + return + end + + local warningLogger = Spearhead.LoggerTemplate:new("MISSIONPARSER", Spearhead.LoggerTemplate.LogLevelOptions.INFO, 4) + if Spearhead.Util.tableLength(Spearhead.MissionEditingWarnings) > 0 then + for key, message in pairs(Spearhead.MissionEditingWarnings) do + warningLogger:warn(message) + end + else + warningLogger:info("No issues detected") + end + + loadDone = true +end diff --git a/spearhead_cap.lua b/spearhead_cap.lua new file mode 100644 index 0000000..eac0cf9 --- /dev/null +++ b/spearhead_cap.lua @@ -0,0 +1,31 @@ +--[[ + + CAP Standby Timing + This works with 3 timing + + + + MONITORING AND PREDICTIONS + TODO: Make sure that before the mission is started the mission maker has a way to see how many groups(units) are scheduled for each zone. + + + CAP Naming + CAP_1_1_ means the required cap state is 1 and it's the first unit. This means these units will be the first units to be dispatched. + + CAP_1-2_1_ means the unit cap state is 1 AND 2 and it will spawn a unique + + +]]-- + +--[[ + TODO: Spawn groups that are in state 1 or 2 at position and then give them waypoints when tasked. +]]-- + +local StageCapManager = {} +do + + + +end + +Spearhead.StageCapManager = StageCapManager \ No newline at end of file diff --git a/spearhead_db.lua b/spearhead_db.lua new file mode 100644 index 0000000..91923d3 --- /dev/null +++ b/spearhead_db.lua @@ -0,0 +1,589 @@ +-- 3 + +local SpearheadDB = {} +do -- DB + + local singleton = nil + + ---comment + ---@param Logger table + ---@return table + function SpearheadDB:new(Logger) + if singleton ~= nil then + Logger:info("Returning an already initiated instance of SpearheadDB") + return singleton + end + + local o = {} + setmetatable(o, { __index = self }) + + o.Logger = Logger + o.tables = {} + do --INIT ALL TABLES + Logger:debug("Initiating tables") + + o.tables.all_zones = {} + o.tables.stage_zones = {} + o.tables.mission_zones = {} + o.tables.random_mission_zones = {} + o.tables.farp_zones = {} + o.tables.cap_route_zones = {} + + o.tables.stage_zonesByNumer = {} + + do -- INIT ZONE TABLES + if env.mission.triggers and env.mission.triggers.zones then + for zone_ind, zone_data in pairs(env.mission.triggers.zones) do + local zone_name = zone_data.name + local split_string = Spearhead.Util.split_string(zone_name, "_") + table.insert(o.tables.all_zones, zone_name) + + if string.lower(split_string[1]) == "missionstage" then + table.insert(o.tables.stage_zones, zone_name) + if split_string[2] then + local stringified = tostring(split_string[2]) or "unknown" + if o.tables.stage_zonesByNumer[stringified] == nil then + o.tables.stage_zonesByNumer[stringified] = zone_name + else + table.insert(Spearhead.MissionEditingWarnings, "Duplicate Stage Order number found. This zone will work, but will not be part of the CAP script") + end + + end + end + + if string.lower(split_string[1]) == "mission" then + table.insert(o.tables.mission_zones, zone_name) + end + + if string.lower(split_string[1]) == "randommission" then + table.insert(o.tables.random_mission_zones, zone_name) + end + + if string.lower(split_string[1]) == "farp" then + table.insert(o.tables.farp_zones, zone_name) + end + + if string.lower(split_string[1]) == "caproute" then + table.insert(o.tables.cap_route_zones, zone_name) + end + end + end + end + + Logger:debug("initiated zone tables, continuing with descriptions") + o.tables.descriptions = {} + do --load markers + if env.mission.drawings and env.mission.drawings.layers then + for i, layer in pairs(env.mission.drawings.layers) do + if string.lower(layer.name) == "author" then + for key, layer_object in pairs(layer.objects) do + local inZone = Spearhead.DcsUtil.isPositionInZones(layer_object.mapX, layer_object.mapY, o.tables.mission_zones) + if Spearhead.Util.tableLength(inZone) >= 1 then + local name = inZone[1] + if name ~= nil then + o.tables.descriptions[name] = layer_object.text + end + end + end + end + end + end + end + + o.tables.missionZonesPerStage = {} + for key, missionZone in pairs(o.tables.mission_zones) do + local found = false + local i = 1 + while found == false and i <= Spearhead.Util.tableLength(o.tables.stage_zones) do + local stageZone = o.tables.stage_zones[i] + if Spearhead.DcsUtil.isZoneInZone(missionZone, stageZone) == true then + if o.tables.missionZonesPerStage[stageZone] == nil then + o.tables.missionZonesPerStage[stageZone] = {} + end + table.insert(o.tables.missionZonesPerStage[stageZone], missionZone) + end + i = i + 1 + end + end + + o.tables.randomMissionZonesPerStage = {} + for key, missionZone in pairs(o.tables.random_mission_zones) do + local found = false + local i = 1 + while found == false and i <= Spearhead.Util.tableLength(o.tables.stage_zones) do + local stageZone = o.tables.stage_zones[i] + if Spearhead.DcsUtil.isZoneInZone(missionZone, stageZone) == true then + if o.tables.randomMissionZonesPerStage[stageZone] == nil then + o.tables.randomMissionZonesPerStage[stageZone] = {} + end + table.insert(o.tables.randomMissionZonesPerStage[stageZone], missionZone) + end + i = i + 1 + end + end + + local isAirbaseInZone = {} + o.tables.airbasesPerStage = {} + o.tables.farpIdsInFarpZones = {} + local airbases = world.getAirbases() + for _, airbase in pairs(airbases) do + local baseId = airbase:getID() + local point = airbase:getPoint() + local found = false + for _, zoneName in pairs(o.tables.stage_zones) do + if found == false then + if Spearhead.DcsUtil.isPositionInZone(point.x, point.z, zoneName) == true then + found = true + local baseIdString = tostring(baseId) or "nil" + isAirbaseInZone[baseIdString] = true + + if airbase:getDesc().category == 0 then + --Airbase + if Spearhead.DcsUtil.getStartingCoalition(baseId) == 2 then + airbase:setCoalition(1) + airbase:autoCapture(false) + end + + if o.tables.airbasesPerStage[zoneName] == nil then + o.tables.airbasesPerStage[zoneName] = {} + end + + table.insert(o.tables.airbasesPerStage[zoneName], baseId) + else + -- farp + local i = 1 + local farpFound = false + while farpFound == false and i <= Spearhead.Util.tableLength(o.tables.farp_zones) do + local farpZoneName = o.tables.farp_zones[i] + if Spearhead.DcsUtil.isPositionInZone(point.x, point.z, farpZoneName) == true then + farpFound = true + + if o.tables.farpIdsInFarpZones[farpZoneName] == nil then + o.tables.farpIdsInFarpZones[farpZoneName] = {} + end + + airbase:setCoalition(1) + airbase:autoCapture(false) + table.insert(o.tables.farpIdsInFarpZones[farpZoneName], baseIdString) + end + i = i +1 + end + end + end + end + end + end + + + o.tables.farpZonesPerStage = {} + for _, farpZoneName in pairs(o.tables.farp_zones) do + local findFirst = function (farpZoneName) + for _, stage_zone in pairs(o.tables.stage_zones) do + if Spearhead.DcsUtil.isZoneInZone(farpZoneName, stage_zone) then + return stage_zone + end + end + return nil + end + + local found = findFirst(farpZoneName) + if found then + + if o.tables.farpZonesPerStage[found] == nil then + o.tables.farpZonesPerStage[found] = {} + end + + table.insert(o.tables.farpZonesPerStage[found], farpZoneName) + end + end + + + local is_group_taken = {} + do + local all_groups = Spearhead.DcsUtil.getAllGroupNames() + for _, value in pairs(all_groups) do + is_group_taken[value] = false + end + end + + local getAvailableGroups = function() + local result = {} + for name, value in pairs(is_group_taken) do + if value == false then + table.insert(result, name) + end + end + return result + end + + local getAvailableCAPGroups = function() + local result = {} + for name, value in pairs(is_group_taken) do + if value == false and Spearhead.Util.startswith(name, "CAP") then + table.insert(result, name) + end + end + return result + end + + --- airbaseId <> groupname[] + o.tables.capGroupsOnAirbase = {} + local loadCapUnits = function() + local all_groups = getAvailableCAPGroups() + Logger:debug(all_groups) + local airbases = world.getAirbases() + for _, airbase in pairs(airbases) do + local baseId = airbase:getID() + local point = airbase:getPoint() + + o.tables.capGroupsOnAirbase[baseId] = {} + local groups = Spearhead.DcsUtil.areGroupsInCustomZone(all_groups, + { x = point.x, z = point.z, radius = 6600 }) + for _, groupName in pairs(groups) do + is_group_taken[groupName] = true + table.insert(o.tables.capGroupsOnAirbase[baseId], groupName) + end + end + end + + --- missionZoneName <> groupname[] + o.tables.groupsInMissionZone = {} + local loadMissionzoneUnits = function() + local all_groups = getAvailableGroups() + for _, missionZoneName in pairs(o.tables.mission_zones) do + o.tables.groupsInMissionZone[missionZoneName] = {} + local groups = Spearhead.DcsUtil.getGroupsInZone(all_groups, missionZoneName) + for _, groupName in pairs(groups) do + is_group_taken[groupName] = true + table.insert(o.tables.groupsInMissionZone[missionZoneName], groupName) + end + end + end + + --- missionZoneName <> groupname[] + o.tables.groupsInRandomMissions = {} + local loadRandomMissionzoneUnits = function() + local all_groups = getAvailableGroups() + for _, missionZoneName in pairs(o.tables.random_mission_zones) do + o.tables.groupsInRandomMissions[missionZoneName] = {} + local groups = Spearhead.DcsUtil.getGroupsInZone(all_groups, missionZoneName) + for _, groupName in pairs(groups) do + is_group_taken[groupName] = true + table.insert(o.tables.groupsInRandomMissions[missionZoneName], groupName) + end + end + end + + --- farpZoneName <> groupname[] + o.tables.groupsInFarpZone = {} + local loadFarpGroups = function() + local all_groups = getAvailableGroups() + for _, farpZone in pairs(o.tables.farp_zones) do + o.tables.groupsInFarpZone[farpZone] = {} + local groups = Spearhead.DcsUtil.getGroupsInZone(all_groups, farpZone) + for _, groupName in pairs(groups) do + is_group_taken[groupName] = true + table.insert(o.tables.groupsInFarpZone[farpZone], groupName) + end + end + end + + --- farpZoneName <> groupname[] + o.tables.redAirbaseGroupsPerAirbase = {} + o.tables.blueAirbaseGroupsPerAirbase = {} + local loadAirbaseGroups = function() + local all_groups = getAvailableGroups() + local airbases = world.getAirbases() + for _, airbase in pairs(airbases) do + local baseId = tostring(airbase:getID()) + local point = airbase:getPoint() + + if isAirbaseInZone[tostring(baseId) or "something" ] == true then + o.tables.redAirbaseGroupsPerAirbase[baseId] = {} + o.tables.blueAirbaseGroupsPerAirbase[baseId] = {} + local groups = Spearhead.DcsUtil.areGroupsInCustomZone(all_groups, { x = point.x, z = point.z, radius = 6600 }) + for _, groupName in pairs(groups) do + + if Spearhead.DcsUtil.IsGroupStatic(groupName) == true then + local object = StaticObject.getByName(groupName) + if object then + if object:getCoalition() == coalition.side.RED then + table.insert(o.tables.redAirbaseGroupsPerAirbase[baseId], groupName) + elseif object:getCoalition() == coalition.side.BLUE then + table.insert(o.tables.blueAirbaseGroupsPerAirbase[baseId], groupName) + end + end + else + local group = Group.getByName(groupName) + if group then + if group:getCoalition() == coalition.side.RED then + table.insert(o.tables.redAirbaseGroupsPerAirbase[baseId], groupName) + is_group_taken[groupName] = true + elseif group:getCoalition() == coalition.side.BLUE then + table.insert(o.tables.blueAirbaseGroupsPerAirbase[baseId], groupName) + is_group_taken[groupName] = true + end + end + end + end + end + end + end + + o.tables.miscGroupsInStage = {} + local loadMiscGroupsInStage = function () + local all_groups = getAvailableGroups() + for _, stage_zone in pairs(o.tables.stage_zones) do + o.tables.miscGroupsInStage[stage_zone] = {} + local groups = Spearhead.DcsUtil.getGroupsInZone(all_groups, stage_zone) + for _, groupName in pairs(groups) do + + if Spearhead.DcsUtil.IsGroupStatic(groupName) == true then + local object = StaticObject.getByName(groupName) + if object and object:getCoalition() ~= coalition.side.NEUTRAL then + is_group_taken[groupName] = true + table.insert(o.tables.miscGroupsInStage[stage_zone], groupName) + end + else + local group = Group.getByName(groupName) + if group and group:getCoalition() ~= coalition.side.NEUTRAL then + is_group_taken[groupName] = true + table.insert(o.tables.miscGroupsInStage[stage_zone], groupName) + end + end + end + end + end + + loadCapUnits() + loadMissionzoneUnits() + loadRandomMissionzoneUnits() + loadFarpGroups() + loadAirbaseGroups() + loadMiscGroupsInStage() + + local cleanup = function () --CLean up all groups that are now managed inside zones by spearhead + + local count = 0 + for name, taken in pairs(is_group_taken) do + if taken == true then + Spearhead.DcsUtil.DestroyGroup(name) + count = count + 1 + end + end + Logger:info("Destroyed " .. count .. " units that are now managed in zones by Spearhead") + end + cleanup() + + --- key: zoneName value: { current, routes = [ { point1, point2 } ] } + o.tables.capRoutes = {} + for _, zoneName in pairs(o.tables.stage_zones) do + + local configValue = { + current = 0, + routes = {} + } + for _, cap_route_zone in pairs(o.tables.cap_route_zones) do + if Spearhead.DcsUtil.isZoneInZone(cap_route_zone, zoneName) == true then + local zone = Spearhead.DcsUtil.getZoneByName(cap_route_zone) + if zone then + if zone.zone_type == Spearhead.DcsUtil.ZoneType.Cilinder then + table.insert(configValue.routes, { point1 = { x = zone.x , z = zone.z }, point2 = nil } ) + else + local function getDist(a, b) + return math.sqrt((b.x - a.x) ^ 2 + (b.z - a.z) ^ 2) + end + + local biggest = nil + local biggestA = nil + local biggestB = nil + + for i=1, 3 do + for ii = i + 1, 4 do + + local a = zone.verts[i] + local b = zone.verts[ii] + local dist = getDist(a,b) + + if biggest == nil or dist > biggest then + biggestA = a + biggestB = b + biggest = dist + end + end + end + + if biggestA and biggestB then + table.insert(configValue.routes, + { + point1 = { x = biggestA.x , z = biggestA.z }, + point2 = { x = biggestB.x , z = biggestB.z } + }) + end + end + end + end + end + o.tables.capRoutes[zoneName] = configValue + end + + o.tables.missionCodes = {} + end + + o.GetDescriptionForMission = function(self, missionZoneName) + return self.tables.descriptions[missionZoneName] + end + + o.getCapRouteInZone = function(self, targetZone, baseId) + + local routeData = self.tables.capRoutes[targetZone] + if routeData then + local count = Spearhead.Util.tableLength(routeData.routes) + if count > 0 then + routeData.current = routeData.current + 1 + if count < routeData.current then + routeData.current = 1 + end + + return routeData.routes[routeData.current] + end + end + do + local function GetClosestPointOnCircle(pC, radius, p) + local vX = p.x - pC.x; + local vY = p.z - pC.z; + local magV = math.sqrt(vX*vX + vY*vY); + local aX = pC.x + vX / magV * radius; + local aY = pC.z + vY / magV * radius; + return { x = aX, z = aY } + end + local stagezone = Spearhead.DcsUtil.getZoneByName(targetZone) + if stagezone then + local base = Spearhead.DcsUtil.getAirbaseById(baseId) + if base then + local closest = nil + if stagezone.zone_type == Spearhead.DcsUtil.ZoneType.Cilinder then + closest = GetClosestPointOnCircle({x = stagezone.x, z = stagezone.z}, stagezone.radius, base:getPoint()) + else + local function getDist(a, b) + return math.sqrt((b.x - a.x) ^ 2 + (b.z - a.z) ^ 2) + end + + local closestDistance = -1 + for _, vert in pairs(stagezone.verts) do + local distance = getDist(vert, base:getPoint()) + if closestDistance == -1 or distance < closestDistance then + closestDistance = distance + closest = vert + end + end + end + + if math.random(1,2)%2 == 0 then + return { point1 = closest, point2 = {x = stagezone.x, z = stagezone.z} } + else + return { point1 = {x = stagezone.x, z = stagezone.z}, point2 = closest } + end + end + end + end + + end + ---comment + ---@param self table + ---@param number number + ---@return string zoneName + o.getStageZoneByStageNumber = function (self, number) + local numberString = tostring(number) + return self.tables.stage_zonesByNumer[numberString] + end + + ---comment + ---@param self table + ---@return table result a list of stage zone names + o.getStagezoneNames = function (self) + return self.tables.stage_zones + end + + o.getMissionsForStage = function (self, stagename) + return self.tables.missionZonesPerStage[stagename] or {} + end + + o.getRandomMissionsForStage = function(self, stagename) + return self.tables.randomMissionZonesPerStage[stagename] or {} + end + + o.getGroupsForMissionZone = function (self, missionZoneName) + if Spearhead.Util.startswith(missionZoneName, "RANDOMMISSION") == true then + return self.tables.groupsInRandomMissions[missionZoneName] or {} + end + return self.tables.groupsInMissionZone[missionZoneName] or {} + end + + o.getMissionBriefingForMissionZone = function (self, missionZoneName) + return self.tables.descriptions[missionZoneName] or "" + end + + ---comment + ---@param self table + ---@param stageName string + ---@return table result airbase IDs. Use Spearhead.DcsUtil.getAirbaseById + o.getAirbaseIdsInStage = function (self, stageName) + return self.tables.airbasesPerStage[stageName] or {} + end + + o.getFarpZonesInStage = function (self, stageName) + return self.tables.farpZonesPerStage[stageName] + end + + ---comment + ---@param self table + ---@param airbaseId number + ---@return table + o.getCapGroupsAtAirbase = function(self, airbaseId) + return self.tables.capGroupsOnAirbase[airbaseId] or {} + end + + o.getRedGroupsAtAirbase = function (self, airbaseId) + local baseId = tostring(airbaseId) + return self.tables.redAirbaseGroupsPerAirbase[baseId] + end + + o.getBlueGroupsAtAirbase = function (self, airbaseId) + local baseId = tostring(airbaseId) + return self.tables.blueAirbaseGroupsPerAirbase[baseId] + end + + o.GetNewMissionCode = function (self) + local code = nil + local tries = 0 + while code == nil and tries < 10 do + local random = math.random(1000, 9999) + if self.tables.missionCodes[random] == nil then + code = random + end + tries = tries + 1 + end + return code + + --[[ + TODO: What to do when there's no random possible + ]] + end + + do -- LOG STATE + Logger:info("initiated the database with amount of zones: ") + Logger:info("Stages: " .. Spearhead.Util.tableLength(o.tables.stage_zones)) + Logger:info("Missions: " .. Spearhead.Util.tableLength(o.tables.mission_zones)) + Logger:info("Random Missions: " .. Spearhead.Util.tableLength(o.tables.random_mission_zones)) + Logger:info("Farps: " .. Spearhead.Util.tableLength(o.tables.farp_zones)) + Logger:info("Airbases: " .. Spearhead.Util.tableLength(o.tables.airbasesPerStage)) + Logger:info("RedAirbase Groups: " .. Spearhead.Util.tableLength(o.tables.redAirbaseGroupsPerAirbase["21"])) + Spearhead.Util.tableLength(o.tables.airbasesPerStage) + end + singleton = o + return o + end +end + +Spearhead.DB = SpearheadDB diff --git a/stageClasses/GlobalStageManager.lua b/stageClasses/GlobalStageManager.lua new file mode 100644 index 0000000..c513da2 --- /dev/null +++ b/stageClasses/GlobalStageManager.lua @@ -0,0 +1,22 @@ + + +local StagesByName = {} + + +GlobalStageManager = {} +GlobalStageManager.start = function (database) + + for _, stageName in pairs(database:getStagezoneNames()) do + + local logger = Spearhead.LoggerTemplate:new(stageName, Spearhead.config.logLevel) + local stage = Spearhead.internal.Stage:new(stageName, database, logger) + + StagesByName[stageName] = stage + + local logger = Spearhead.LoggerTemplate:new("StageManager", Spearhead.config.logLevel) + logger:info("Initiated " .. Spearhead.Util.tableLength(StagesByName) .. " airbases for cap") + + end +end + +Spearhead.internal.GlobalStageManager = GlobalStageManager diff --git a/stageClasses/Mission.lua b/stageClasses/Mission.lua new file mode 100644 index 0000000..db0217d --- /dev/null +++ b/stageClasses/Mission.lua @@ -0,0 +1,396 @@ +--[[ +# 3. Missions + +A mission is a completable objective with a state and a continuous check to see if itself is completed.
+The delay between checks is quite big, but it also is checked on unit deaths and other events. + +### Placement +The placement of MISSION trigger zones can be anywhere.
+The order of unit detection is `CAP` > `MISSION` > `AIRBASE` > `STAGE`
+This means that if a unit has a name that starts with `"CAP_"` it will not be included in a mission.
+But all other units in a `MISSION` trigger zone will be managed as part of that mission. + +Units inside a `MISSION` do not have to stay within the triggerzone.
+They just need to be inside the zone at the start of the mission.
+You can for example let a `BAI` mission drive back and forth between airbases. The `MISSIONZONE` only needs to be around the units that are part of the objective, not the waypoints. + +### Naming +`MISSION__`
+`RANDOMMISSION___` (Read RANDOMISATION below) + +With:
+`name` = A name that is easy to remember and type. Like a codename. Exmaples: BYRON, PLUKE, etc.
+`type` = any of the below described types. Special types are marked with an * + +TIP: You can click on the type to get more details + +
+SAM* +  SAM Sites are managed a little different. SAM Sites can be used to guide players and to protect airfields.
+  In the future when deepstrike missions might come into scope these SAM sites will also be more important.
+  SAM sites will be activated when a zone is "Pre-Active".
+  A stage is "Pre-Active" when there is a CAP base active, or there is other things to do that would need the SAM site to be live (OCA, DEEPSTRIKE, EXTRACTION \<= all feature development)
+  If you want a SAM site to become active ONLY when the stage is fully active, then `DEAD` is the type for you! + +  Completion logic
+  TODO: documentation: Completion logic + +
+ +
+DEAD + +  DEAD missions will be spawned on activation of the stage.
+  ALL DEAD missions will be activated right at the start of a stage.
+  This might be against the "randomisation" feel, but it is to make sure mission don't get activated randomly and players get ambushed by a random spawn.
+ +  Completion logic
+  TODO: documentation: Completion logic + + +
+ +
+BAI +
+ +
+STRIKE +  STRIKE missions will be activated randomly until all of them are completed.
+  A strike mission can be placed anywhere, even on airbases + +  Completion logic
+  TODO: documentation: Completion logic + +
+ +### Randomisation + +You can randomise missions.
+Spearhead will pick up all mission zones that start with `"RANDOMMISSION_"`
+Then it will combine each `RANDOMMISSION` in a zone with the same `` and pick a random one.
+It will always pick 1 and only 1. + +This means you have some options for randomisation.
+For example, if you have 1 missionzone with name `RANDOMMISSION_SAM_PLUKE_1` that is filled with an SA-2 and another zone with `RANDOMMISSION_SAM_PLUKE_2` filled with an SA-3 then some runs of the mission it will spawn an SA-3 and sometimes spawn an SA-3. (works for any mission type) + +What you can also do is add empty `RANDOMMISSION_` zones next to the filled `RANDOMMISSION_` zone. +For example. You have a `RANDOMMISSION_DEAD_BYRON_1` filled with an SA-19 driving around and 2 more `RANDOMMISSION_DEAD_BYRON_<2 & 3>` zones then it will have a 33% chance of being spawned. +If a zone is empty it will not be briefed, activated or count towards completion of the `STAGE` + +]] + +--- A mission Object. +local Mission = {} +do -- INIT Mission Class + + local MINIMAL_UNITS_ALIVE_RATIO = 0.20 + + local Defaults = {} + Defaults.MainMenu = "Missions" + Defaults.SelectMenuSubMenus = { Defaults.MainMenu, "Select Mission" } + Defaults.ShowMissionSubs = { Defaults.MainMenu } + + local PlayersInMission = {} + local MissionType = { + UNKNOWN = 0, + STRIKE = 1, + BAI = 2, + DEAD = 3, + SAM = 4, + } + + do --INIT MISSION TYPE FUNCTIONS + ---Parse string to mission type + ---@param input string + MissionType.Parse = function(input) + if input == nil then + return Mission.MissionType.UNKNOWN + end + + input = string.lower(input) + if input == "dead" then return MissionType.DEAD end + if input == "strike" then return MissionType.STRIKE end + if input == "bai" then return MissionType.BAI end + if input == "sam" then return MissionType.SAM end + return Mission.MissionType.UNKNOWN + end + + ---comment + ---@param input number missionType + ---@return string text + MissionType.toString = function(input) + if input == MissionType.DEAD then return "DEAD" end + if input == MissionType.STRIKE then return "STRIKE" end + if input == MissionType.BAI then return "BAI" end + if input == MissionType.SAM then return "SAM" end + return "?" + end + end + Mission.MissionType = MissionType + + Mission.MissionState = { + NEW = 0, + ACTIVE = 1, + COMPLETED = 2, + } + + ---comment + ---@param missionZoneName string missionZoneName + ---@param database table db dependency injection + ---@return table? + function Mission:new(missionZoneName, database, logger) + local o = {} + setmetatable(o, { __index = self }) + + local function ParseGroupName(input) + local split_name = Spearhead.Util.split_string(input, "_") + local split_length = Spearhead.Util.tableLength(split_name) + if Spearhead.Util.startswith(input, "RANDOMMISSION") == true and split_length < 4 then + Spearhead.AddMissionEditorWarning("Random Mission with zonename " .. input .. " not in right format") + return nil + elseif split_length < 3 then + Spearhead.AddMissionEditorWarning("Mission with zonename" .. input .. " not in right format") + return nil + end + local type = split_name[2] + local parsedType = Mission.MissionType.Parse(type) + + if parsedType == nil then + Spearhead.AddMissionEditorWarning("Mission with zonename '" .. input .. "' has an unsupported type '" .. (type or "nil" )) + return nil + end + local name = split_name[3] + return { + missionName = name, + type = parsedType + } + end + + local parsed = ParseGroupName(missionZoneName) + if parsed == nil then return nil end + + o.missionZoneName = missionZoneName + o.database = database + o.groupNames = database:getGroupsForMissionZone(missionZoneName) + o.name = parsed.missionName + o.missionType = parsed.type + o.startingGroups = Spearhead.Util.tableLength(o.groupNames) + o.missionState = Mission.MissionState.NEW + o.missionbriefing = database:GetDescriptionForMission(missionZoneName) + o.startingUnits = 0 + o.logger = logger + o.code = database:GetNewMissionCode() + + o.groupUnitAliveDict = {} + o.targetAliveStates = {} + o.hasSpecificTargets = false + + local CheckStateAsync = function (self, time) + self:CheckAndUpdateSelf() + return nil + end + + o.OnUnitLost = function(self, object) + --[[ + OnUnit lost event + ]]-- + local category = object:getCategory() + if category == Object.Category.UNIT then + local unitName = object:getName() + local groupName = object:getGroup():getName() + self.groupUnitAliveDict[groupName][unitName] = false + + if self.targetAliveStates[groupName][unitName] then + self.targetAliveStates[groupName][unitName] = false + end + elseif category == Object.Category.STATIC then + local name = object:getName() + self.groupUnitAliveDict[name][name] = false + + if self.targetAliveStates[name][name] then + self.targetAliveStates[name][name] = false + end + end + CheckStateAsync(false) + end + + o.MissionCompleteListeners = {} + ---comment + ---@param self table + ---@param listener table Object that implements "OnMissionComplete(self, mission)" + o.AddMissionCompleteListener = function(self, listener) + if type(listener) ~= "table" then + return + end + + table.insert(self.MissionCompleteListeners, listener) + end + + local TriggerMissionComplete = function(self) + for _, callable in pairs(self.MissionCompleteListeners) do + local succ, err = pcall( function() + callable:OnMissionComplete(self) + end) + if err then + self.logger:warn("Error in misstion complete listener:" .. err) + end + end + end + + + local StartCheckingAndUpdateSelfContinuous = function (self) + local CheckAndUpdate = function(self, time) + self:CheckAndUpdateSelf(true) + if self.missionState == Mission.MissionState.COMPLETED or self.missionState == Mission.MissionState.NEW then + return nil + else + return time + 60 + end + end + + timer.scheduleFunction(CheckAndUpdate, self, timer.getTime() + 300) + end + + local CleanupDelayedAsync = function (self, time) + self:Cleanup() + return nil + end + + ---comment + ---@param self table + ---@param checkUnitHealth boolean? + o.CheckAndUpdateSelf = function(self, checkUnitHealth) + if not checkUnitHealth then checkUnitHealth = false end + + if self.missionState == Mission.MissionState.COMPLETED then + return + end + --[[ + TODO: Check own state based on mission type + ]]-- + + local specificTargetsAlive = false + if self.hasSpecificTargets == true then + for groupName, unitNameDict in pairs(self.targetAliveStates) do + for unitName, isAlive in pairs(unitNameDict) do + if isAlive == true then + specificTargetsAlive = true + end + end + end + else + + local function CountAliveGroups() + local aliveGroups = 0 + + for _, group in pairs(self.groupUnitAliveDict) do + local groupTotal = 0 + local groupDeath = 0 + for _, isAlive in pairs(group) do + if isAlive ~= true then + groupDeath = groupDeath + 1 + end + groupTotal = groupTotal + 1 + end + + local aliveRatio = (groupTotal - groupDeath) / groupTotal + if aliveRatio >= MINIMAL_UNITS_ALIVE_RATIO then + aliveGroups = 1 + end + end + end + + if self.missionType == Mission.MissionType.STRIKE then --strike targets should normally have TGT targets + if CountAliveGroups() == 0 then + self.missionState = Mission.MissionState.COMPLETED + end + elseif self.missionType == Mission.MissionType.BAI then + if CountAliveGroups() == 0 then + self.missionState = Mission.MissionState.COMPLETED + end + end + --[[ + TODO: Other checks for mission complete + ]] + end + + + if self.missionState == Mission.MissionState.COMPLETED then + TriggerMissionComplete(self) + --Schedule cleanup after 5 minutes of mission complete + timer.scheduleFunction(CleanupDelayedAsync, self, timer.getTime() + 300) + end + end + + ---Activates groups for this mission + ---@param self table + o.Activate = function(self) + if self.missionState == Mission.MissionState.ACTIVE then + return + end + + self.missionState = Mission.MissionState.ACTIVE + do --spawn groups + for key, groupname in pairs(self.groupNames) do + Spearhead.DcsUtil.SpawnGroupTemplate(groupname) + end + end + + StartCheckingAndUpdateSelfContinuous(self) + end + + o.ShowBriefing = function(self, unitId) + local text = "Mission #" .. self.code .. "\n" .. self.missionbriefing .. " \n \nState TODO" + trigger.action.outTextForUnit(unitId, text, 30); + end + + o.Cleanup = function(self) + for key, groupName in pairs(self.groupNames) do + Spearhead.DcsUtil.DestroyGroup(groupName) + end + end + + local Init = function(self) + for key, group_name in pairs(self.groupNames) do + + self.groupUnitAliveDict[group_name] = {} + self.targetAliveStates[group_name] = {} + + if Spearhead.DcsUtil.IsGroupStatic(group_name) then + self.startingUnits = self.startingUnits + 1 + Spearhead.Events.addOnUnitLostEventListener(group_name, self) + + self.groupUnitAliveDict[group_name][group_name] = true + if Spearhead.Util.startswith(group_name, "TGT_") == true then + self.targetAliveStates[group_name][group_name] = true + end + + else + local group = Group.getByName(group_name) + local isGroupTarget = Spearhead.Util.startswith(group_name, "TGT_") + + self.startingUnits = self.startingUnits + group:getInitialSize() + for _, unit in pairs(group:getUnits()) do + local unitName = unit:getName() + Spearhead.Events.addOnUnitLostEventListener(unitName, self) + self.groupUnitAliveDict[group_name][unitName] = true + + if isGroupTarget == true or Spearhead.Util.startswith(unitName, "TGT_") == true then + self.targetAliveStates[group_name][unitName] = true + end + end + end + end + + if Spearhead.Util.tableLength(self.targetAliveStates) > 0 then + self.hasSpecificTargets = true + end + end + + Init(o) + return o; + end +end + +Spearhead.internal.Mission = Mission \ No newline at end of file diff --git a/stageClasses/Stage.lua b/stageClasses/Stage.lua new file mode 100644 index 0000000..2806503 --- /dev/null +++ b/stageClasses/Stage.lua @@ -0,0 +1,249 @@ +--[[ + +# 2. Stages + +]] + + +local Stage = {} +do --init STAGE DIRECTOR + + ---comment + ---@param stagezone_name string + ---@param database table + ---@param logger table + ---@return table? + function Stage:new(stagezone_name, database, logger) + local o = {} + setmetatable(o, { __index = self }) + + o.zoneName = stagezone_name + + local split = Spearhead.Util.split_string(stagezone_name, "_") + if Spearhead.Util.tableLength(split) < 2 then + Spearhead.AddMissionEditorWarning("Stage zone with name " .. stagezone_name .. " does not have a order number or valid format") + return nil + end + + local orderNumber = tonumber(split[2]) + if orderNumber == nil then + Spearhead.AddMissionEditorWarning("Stage zone with name " .. stagezone_name .. " does not have a valid order number : " .. split[2]) + return nil + end + + o.stageNumber = orderNumber + o.database = database + + o.db = {} + o.db.missions = {} + o.db.sams = {} + o.db.redAirbasegroups = {} + o.db.blueAirbasegroups = {} + o.db.airbaseIds = {} + o.db.farps = {} + o.preActivated = false + + do --Init Stage + logger:info("Initiating new Stage with name: " .. stagezone_name) + + local missionZones = database:getMissionsForStage(stagezone_name) + for _, missionZone in pairs(missionZones) do + local mission = Spearhead.internal.Mission:new(missionZone, database) + if mission then + if mission.missionType == Spearhead.internal.Mission.MissionType.SAM then + table.insert(o.db.sams, mission) + else + table.insert(o.db.missions, mission) + end + end + end + + local randomMissionNames = database:getRandomMissionsForStage(stagezone_name) + + local randomMissionByName = {} + for _, missionZoneName in pairs(randomMissionNames) do + local mission = Spearhead.internal.Mission:new(missionZoneName, database) + if mission then + if randomMissionByName[mission.name] == nil then + randomMissionByName[mission.name] = {} + end + table.insert(randomMissionByName[mission.name], mission) + end + end + + for _, missions in pairs(randomMissionByName) do + local mission = Spearhead.Util.randomFromList(missions) + if mission then + if mission.missionType == Spearhead.internal.Mission.MissionType.SAM then + table.insert(o.db.sams, mission) + else + table.insert(o.db.missions, mission) + end + end + end + + local airbaseIds = database:getAirbaseIdsInStage(o.zoneName) + if airbaseIds ~= nil and type(airbaseIds) == "table" then + o.db.airbaseIds = airbaseIds + for _, airbaseId in pairs(airbaseIds) do + + for _, groupName in pairs(database:getRedGroupsAtAirbase(airbaseId)) do + logger:debug("blaat)") + table.insert(o.db.redAirbasegroups, groupName) + end + + for _, groupName in pairs(database:getBlueGroupsAtAirbase(airbaseId)) do + table.insert(o.db.blueAirbasegroups, groupName) + end + end + end + + local farps = database:getFarpZonesInStage(o.zoneName) + if farps ~= nil and type(farps) == "table" then o.db.farps = farps end + end + + o.IsComplete = function(self) + for i, mission in pairs(self.db.missions) do + local state = mission:GetState() + if state == Spearhead.Mission.MissionState.ACTIVE or state == Spearhead.Mission.MissionState.NEW then + return false + end + end + return true + end + + ---Activates all SAMS, Airbase units etc all at once. + ---@param self table + o.PreActivate = function(self) + if self.preActivated == true then return end + self.preActivated = true + for key, mission in pairs(self.db.sams) do + if mission and mission.Activate then + mission:Activate() + end + end + + logger:debug("blaat Pre-activating stage with airbase groups amount: " .. Spearhead.Util.tableLength(self.db.redAirbasegroups)) + + for _ , groupName in pairs(self.db.redAirbasegroups) do + Spearhead.DcsUtil.SpawnGroupTemplate(groupName) + end + end + + o.ActiveStage = function(self) + if self.preActivated == false then + self:PreActivate() + end + + --[[ + TODO: Activate Stage + ]]-- + + end + + o.ActivateAllMissions = function(self) + for _, mission in pairs(self.db.missions) do + mission:Activate() + end + end + + ---Cleans up all missions + ---@param self table + o.Clean = function(self) + for key, mission in pairs(self.db.missions) do + mission:Cleanup() + end + + for key, samMission in pairs(self.db.sams) do + samMission:Cleanup() + end + + for _, airbase in pairs(self.db.airbases) do + for _, redGroupName in pairs(airbase.redAirbaseGroupNames) do + Spearhead.DcsUtil.DcsUtil.DestroyGroup(redGroupName) + end + end + + logger:debug("'" .. Spearhead.Util.toString(self.zoneName) .. "' cleaned") + end + + local ActivateBlueAsync = function(self) + for key, airbaseId in pairs(self.db.airbaseIds) do + local airbase = Spearhead.DcsUtil.getAirbaseById(airbaseId) + + if airbase then + local startingCoalition = Spearhead.DcsUtil.getStartingCoalition(airbaseId) + if startingCoalition == coalition.side.BLUE then + airbase:setCoalition(2) + for _, blueGroupName in pairs(self.db.blueAirbasegroups) do + Spearhead.DcsUtil.SpawnGroupTemplate(blueGroupName) + end + else + airbase:setCoalition(0) + end + end + end + end + + ---Sets airfields to blue and spawns friendly farps + o.ActivateBlueStage = function(self) + logger:debug("Setting stage '" .. Spearhead.Util.toString(self.zoneName) .. "' to blue") + + for _, groupName in pairs(self.db.redAirbasegroups) do + Spearhead.DcsUtil.DestroyGroup(groupName) + end + + for _, mission in pairs(self.db.missions) do + mission:Cleanup() + end + + for _, mission in pairs(self.db.sams) do + mission:Cleanup() + end + + timer.scheduleFunction(ActivateBlueAsync, self, timer.getTime() + 3) + + -- for key, farp in pairs(self.db.farps) do + -- if farp.helipadnames then + -- for _, helipadName in pairs(farp.helipadnames) do + -- local helipad = Airbase.getByName(helipadName) + -- if helipad then + -- logger:debug("Enabling: '" .. helipad:getName() .. "'") + -- helipad:setCoalition(2) + -- else + -- logger:warn(helipadName .. " not found when spawning farps") + -- end + -- end + -- end + + -- if farp.group_names then + -- for _, groupName in pairs(farp.group_names) do + -- Spearhead.DcsUtil.SpawnGroupTemplate(groupName) + -- end + -- end + -- end + end + + o.ManageStage = function(self) + end + + o.OnStageNumberChanged = function (self, number) + if Spearhead.capInfo.IsCapActiveWhenZoneIsActive(self.zoneName, number) == true then + self:PreActivate() + end + + if number == self.stageNumber then + self:ActiveStage() + end + + if number > self.stageNumber then + self:ActivateBlueStage() + end + end + + Spearhead.Events.AddStageNumberChangedListener(o) + return o + end +end + +Spearhead.internal.Stage = Stage \ No newline at end of file