RotorOps = {}
RotorOps.version = "1.4.5"
local debug = false



---[[ROTOROPS OPTIONS]]---
--- Protip: change these options from the mission editor rather than changing the script file itself.  See documentation on github for details.


--RotorOps settings that can be changed dynamically (ideally from the mission editor in DO SCRIPT for portability). You can change these while the script is running, at any time. Be sure of your syntax and test...errors may crash the script.
RotorOps.voice_overs = true
RotorOps.ground_speed = 60 --max speed for ground vehicles moving between zones. Doesn't have much effect since always limited by slowest vehicle in group
RotorOps.zone_status_display = true --constantly show units remaining and zone status on screen
RotorOps.max_units_left = 0 --allow clearing the zone when a few units are left to prevent frustration with units getting stuck in buildings etc
RotorOps.force_offroad = false  --affects "move_to_zone" tasks only
RotorOps.apcs_spawn_infantry = false  --apcs will unload troops when arriving to a new zone
RotorOps.auto_push = true --should attacking ground units move to the next zone after clearing?
RotorOps.defending_vehicles_disperse = true

RotorOps.inf_spawns_avail = 0 --this is the number of infantry group spawn events remaining in the active zone
RotorOps.inf_spawn_chance = 25 -- 0-100 the chance of spawning infantry in an active zone spawn zone, per 'assessUnitsInZone' loop (10 seconds)
RotorOps.inf_spawn_trigger_percent = 70 --infantry has a chance of spawning if the percentage of defenders remaining in zone is less than this value
--RotorOps.inf_spawns_per_zone = 3 --number of infantry groups to spawn per zone
RotorOps.inf_spawn_messages = true --voiceovers and messages for infantry spawns
RotorOps.inf_spawn_blue = {mg=1,at=0,aa=0,inf=4,mortar=0} --can be an integer quantity, or a ctld defined group table
RotorOps.inf_spawn_red = {mg=1,at=0,aa=0,inf=4,mortar=0} --can be an integer quantity, or a ctld defined group table
RotorOps.inf_apc_group = {mg=1,at=0,aa=0,inf=3,mortar=0} --can be an integer quantity, or a ctld defined group table
RotorOps.inf_spawns_total = 0 --number of infantry groups to spawn per game

RotorOps.farp_smoke_color = 2  -- Green=0 Red=1 White=2 Orange=3 Blue=4 NONE= -1


--RotorOps settings that are safe to change only before calling setupConflict()
RotorOps.transports = {'UH-1H', 'Mi-8MT', 'Mi-24P', 'SA342M', 'SA342L', 'SA342Mistral', 'UH-60L', 'CH-47Fbl1'} --players flying these will have ctld transport access
RotorOps.CTLD_crates = false
RotorOps.CTLD_sound_effects = true --sound effects for troop pickup/dropoffs
RotorOps.exclude_ai_group_name = "Static"  --include this somewhere in a group name to exclude the group from being tasked in the active zone
RotorOps.pickup_zone_smoke = "blue"
RotorOps.ai_task_by_name = true  --allow tasking all groups that include key strings in their group names eg 'Patrol'
RotorOps.ai_task_by_name_scheduler = true --continually search active groups for key strings and ai tasking
RotorOps.patrol_task_string = 'patrol' --default string to search group names for the patrol task. requires ai_task_by_name
RotorOps.aggressive_task_string = 'aggressive' --default string to search group names for the aggressive task. requires ai_task_by_name
RotorOps.move_to_active_task_string = "activezone" --default string to search group names for the move to active zone task. requires ai_task_by_name
RotorOps.shift_task_string = "shift"
RotorOps.guard_task_string = "guard"
--RotorOps.patrol_task_radius = 100 --patrol search radius
--RotorOps.aggressive_task_radius = 1000 --aggressive search radius  --not implementing for now until more time for testing
RotorOps.defending_vehicles_behavior = "shift"  --available options: 'none', 'patrol', 'shift'
RotorOps.farp_pickups = true --allow ctld troop pickup at FARPs
RotorOps.enable_staging_pickzones = true
RotorOps.persistent_tasking = false --prevent the script from restasking in a loop --might help with odd movement patterns between zones
RotorOps.halt_convoy_without_airsupport = true --if true, attacking convoys not proceed in zone without player helicopters nearby. Does not affect defensive missions

--RotorOps settings that are safe to change only in the script config option in the scenario config file
RotorOps.draw_conflict_zones = true

---[[END OF OPTIONS]]---




--RotorOps variables that are safe to read only
RotorOps.game_states = {not_started = 0, alpha_active = 1, bravo_active = 2, charlie_active = 3, delta_active = 4, lost = 98, won = 99} --game level user flag will use these values
RotorOps.game_state = 0
RotorOps.zones = {}
RotorOps.active_zone = "" --name of the active zone
RotorOps.active_zone_index = 0
RotorOps.game_state_flag = 100  --user flag to store the game state
RotorOps.staging_zones = {}
RotorOps.ai_defending_infantry_groups = {}
RotorOps.ai_attacking_infantry_groups = {}
RotorOps.ai_defending_vehicle_groups = {}
RotorOps.ai_attacking_vehicle_groups = {}
RotorOps.ai_tasks = {}
RotorOps.defending = false
RotorOps.staged_units_flag = 111  -- shows a percentage of the units found in the staging zone when the game starts. you can also use 'ROPS_ATTACKERS' for readability

--fighter variables
local fighters_by_detected_unitname = {}
RotorOps.fighter_radar_unit_string = 'FIGHTER_DEPLOYMENT'  --any unit capable of detecting aircraft by radar can be used as a detection source to spawn intercept fighters, if this string is in the unit name
RotorOps.fighter_min_detection_alt = 609 --aircraft below this agl altitude (meters) will not be 'detected' by radar units.
RotorOps.fighter_max_detection_dist = 7000 --default max range from radar to target in order for intercept fighters to spawn (you can also set range for individual radar sources via unit name)
RotorOps.fighter_max_active = 2 --total maximum active deployed fighters, shared between red/blue

trigger.action.outText("ROTOR OPS STARTED: "..RotorOps.version, 5)
env.info("ROTOR OPS STARTED: "..RotorOps.version)

RotorOps.staged_units = {} --table of ground units that started in the staging zone
RotorOps.staged_units_by_zone = {}
RotorOps.eventHandler = {}
local commandDB = {}
local game_message_buffer = {}
local active_zone_initial_defenders
local initial_stage_units
local apcs = {} --table to keep track of infantry vehicles
local low_units_message_fired = false
local inf_spawn_zones = {}
local cooldown = {
  ["attack_helo_msg"] = 0,
  ["attack_plane_msg"] = 0,
  ["trans_helo_msg"] = 0,
  ["e_fighters_inbound_msg"] = 0,
}
local zone_defenders_flags = {
  'ROPS_A_DEFENDERS',
  'ROPS_B_DEFENDERS',
  'ROPS_C_DEFENDERS',
  'ROPS_D_DEFENDERS',
  'ROPS_E_DEFENDERS',
  'ROPS_F_DEFENDERS',
  'ROPS_G_DEFENDERS',
  'ROPS_H_DEFENDERS',
}
local clear_text_index = 0
RotorOps.farp_names = {}
RotorOps.convoy_suppressor = nil
RotorOps.convoy_assaulter = nil
RotorOps.convoy_status = "none"


RotorOps.gameMsgs = {
  push = {
    {'ALL GROUND UNITS, PUSH TO THE ACTIVE ZONE!', 'push_next_zone.ogg'},
    {'ALL GROUND UNITS, PUSH TO ALPHA!', 'push_alpha.ogg'},
    {'ALL GROUND UNITS, PUSH TO BRAVO!', 'push_bravo.ogg'},
    {'ALL GROUND UNITS, PUSH TO CHARLIE!', 'push_charlie.ogg'},
    {'ALL GROUND UNITS, PUSH TO DELTA!', 'push_delta.ogg'},
    {'ALL GROUND UNITS, PUSH TO ECHO!', 'push_next_zone.ogg'},
    {'ALL GROUND UNITS, PUSH TO FOXTROT!', 'push_next_zone.ogg'},
    {'ALL GROUND UNITS, PUSH TO GOLF!', 'push_next_zone.ogg'},
    {'ALL GROUND UNITS, PUSH TO HOTEL!', 'push_next_zone.ogg'},
  },
  cleared = {
    {'ZONE CLEARED!', 'cleared_active.ogg'},
    {'ALPHA CLEARED!', 'cleared_alpha.ogg'},
    {'BRAVO CLEARED!', 'cleared_bravo.ogg'},
    {'CHARLIE CLEARED!', 'cleared_charlie.ogg'},
    {'DELTA CLEARED!', 'cleared_delta.ogg'},
    {'ECHO CLEARED!', 'cleared_active.ogg'},
    {'FOXTROT CLEARED!', 'cleared_active.ogg'},
    {'GOLF CLEARED!', 'cleared_active.ogg'},
    {'HOTEL CLEARED!', 'cleared_active.ogg'},
  },
  success = {
    {'GROUND MISSION SUCCESS!', 'mission_success.ogg'},
  },
  start = {
    {'SUPPORT THE WAR ON THE GROUND!', 'support_troops.ogg'},
  },
  troops_dropped = {
    {'TROOPS DROPPED INTO ZONE!', 'troops_dropped_active.ogg'},
    {'TROOPS DROPPED INTO ALPHA!', 'troops_dropped_alpha.ogg'},
    {'TROOPS DROPPED INTO BRAVO!', 'troops_dropped_bravo.ogg'},
    {'TROOPS DROPPED INTO CHARLIE!', 'troops_dropped_charlie.ogg'},
    {'TROOPS DROPPED INTO DELTA!', 'troops_dropped_delta.ogg'},
    {'TROOPS DROPPED INTO ECHO!', 'troops_dropped_active.ogg'},
    {'TROOPS DROPPED INTO FOXTROT!', 'troops_dropped_active.ogg'},
    {'TROOPS DROPPED INTO GOLF!', 'troops_dropped_active.ogg'},
    {'TROOPS DROPPED INTO HOTEL!', 'troops_dropped_active.ogg'},
  },
  get_troops_to_zone = {
    {'GET OUR TROOPS TO THE NEXT ZONE!', 'get_troops_next_zone.ogg'},
    {'GET OUR TROOPS TO ALPHA!', 'get_troops_alpha.ogg'},
    {'GET OUR TROOPS TO BRAVO!', 'get_troops_bravo.ogg'},
    {'GET OUR TROOPS TO CHARLIE!', 'get_troops_charlie.ogg'},
    {'GET OUR TROOPS TO DELTA!', 'get_troops_delta.ogg'},
    {'GET OUR TROOPS TO ECHO!', 'get_troops_next_zone.ogg'},
    {'GET OUR TROOPS TO FOXTROT!', 'get_troops_next_zone.ogg'},
    {'GET OUR TROOPS TO GOLF!', 'get_troops_next_zone.ogg'},
    {'GET OUR TROOPS TO HOTEL!', 'get_troops_next_zone.ogg'},
  },
  jtac = {
    {'JTAC DROPPED!', 'jtac_dropped.ogg'},
  },
  enemy_almost_cleared = {
    {'ENEMY HAS NEARLY CAPTURED THE ZONE!', 'enemy_almost_cleared.ogg'},
    {'ENEMY HAS NEARLY CAPTURED THE ZONE!', 'enemy_decimating_forces.ogg'},
    {'ENEMY HAS NEARLY CAPTURED THE ZONE!', 'enemy_destroying_ground.ogg'},
  },
  almost_cleared = {
    {'WE HAVE NEARLY CLEARED THE ZONE!', 'almost_cleared.ogg'},
    {'WE HAVE NEARLY CLEARED THE ZONE!', 'theyre_weak.ogg'},
    {'WE HAVE NEARLY CLEARED THE ZONE!', 'tearing_them_up.ogg'},
  },
  enemy_pushing = {
    {'ENEMY PUSHING TO THE NEXT ZONE!', 'enemy_pushing_zone.ogg'},
    {'ENEMY PUSHING TO ALPHA!', 'enemy_pushing_alpha.ogg'},
    {'ENEMY PUSHING TO BRAVO!', 'enemy_pushing_bravo.ogg'},
    {'ENEMY PUSHING TO CHARLIE!', 'enemy_pushing_charlie.ogg'},
    {'ENEMY PUSHING TO DELTA!', 'enemy_pushing_delta.ogg'},
    {'ENEMY PUSHING TO ECHO!', 'enemy_pushing_zone.ogg'},
    {'ENEMY PUSHING TO FOXTROT!', 'enemy_pushing_zone.ogg'},
    {'ENEMY PUSHING TO GOLF!', 'enemy_pushing_zone.ogg'},
    {'ENEMY PUSHING TO HOTEL!', 'enemy_pushing_zone.ogg'},
  },
  start_defense = {
    {'SUPPORT THE WAR ON THE GROUND!  PUSH BACK AGAINST THE ENEMY!', 'push_back.ogg'},
  },
  failure = {
    {'GROUND MISSION FAILED!', 'mission_failure.ogg'},
  },
  friendly_troops_dropped = {
    {'FRIENDLY TROOPS DROPPED INTO ZONE!', 'friendly_troops_dropped_active.ogg'},
  },
  hold_ground = {
    {'HOLD GROUND TO WIN!', 'hold_our_ground.ogg'},
  },
  enemy_cleared_zone = {
    {'ENEMY TOOK THE ACTIVE ZONE!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK ALPHA!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK BRAVO!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK CHARLIE!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK DELTA!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK ECHO!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK FOXTROT!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK GOLF!', 'enemy_destroying_us.ogg'},
    {'ENEMY TOOK HOTEL!', 'enemy_destroying_us.ogg'},
  },
  attack_helos = {
    {'ENEMY ATTACK HELICOPTERS INBOUND!', 'enemy_attack_choppers.ogg'},
  },
  attack_planes = {
    {'ENEMY ATTACK PLANES INBOUND!', 'enemy_attack_planes.ogg'},
  },
  attack_helos_prep = {
    {'ENEMY ATTACK HELICOPTERS PREPARING FOR TAKEOFF!', 'e_attack_helicopters_preparing.ogg'},
  },
  attack_planes_prep = {
    {'ENEMY ATTACK PLANES PREPARING FOR TAKEOFF!', 'e_attack_planes_preparing.ogg'},
  },
  infantry_spawned = {
    {'ENEMY CONTACTS IN THE OPEN!', 'e_infantry_spawn1.ogg'},
    {'ENEMY TROOPS LEAVING COVER!', 'e_infantry_spawn2.ogg'},
    {'VISUAL ON ENEMY INFANTRY!', 'e_infantry_spawn3.ogg'},
    {'ENEMY CONTACTS IN THE ACTIVE!', 'e_infantry_spawn4.ogg'},
    {'ENEMY TROOPS IN THE ACTIVE!', 'e_infantry_spawn5.ogg'},
    {'VISUAL ON ENEMY TROOPS!', 'e_infantry_spawn6.ogg'},
  },
  farp_established = {
    {'NEW FARP AVAILABLE!', 'forward_base_established.ogg'},
    {'NEW FARP AT ALPHA!', 'forward_base_established.ogg'},
    {'NEW FARP AT BRAVO!', 'forward_base_established.ogg'},
    {'NEW FARP AT CHARLIE!', 'forward_base_established.ogg'},
    {'NEW FARP AT DELTA!', 'forward_base_established.ogg'},
    {'NEW FARP AT ECHO!', 'forward_base_established.ogg'},
    {'NEW FARP AT FOXTROT!', 'forward_base_established.ogg'},
    {'NEW FARP AT GOLF!', 'forward_base_established.ogg'},
    {'NEW FARP AT HOTEL!', 'forward_base_established.ogg'},
  },
  transp_helos_toff = {
    {'ENEMY TRANSPORT HELICOPTERS INBOUND!', 'enemy_chopper_inbound.ogg'},
  },
  enemy_fighters_inbound = {
    {'ENEMY FIGHTERS INBOUND!', 'enemy_fighters_inbound.ogg'},
  },


}


local sound_effects = {
    ["troop_pickup"] = {'troops_load_ao.ogg', 'troops_load_ready.ogg', 'troops_load_to_action.ogg',},
    ["troop_dropoff"] = {'troops_unload_thanks.ogg', 'troops_unload_everybody_off.ogg', 'troops_unload_get_off.ogg', 'troops_unload_here_we_go.ogg', 'troops_unload_moving_out.ogg',},
}


function RotorOps.getTime()
  return timer.getAbsTime() - timer.getTime0() --time since mission started
end



function RotorOps.eventHandler:onEvent(event)
   ---ENGINE STARTUP EVENTS
   if (world.event.S_EVENT_ENGINE_STARTUP  == event.id) then  --play some sound files when a player starts engines
     local initiator = event.initiator:getGroup():getID()

     if #event.initiator:getGroup():getUnits() == 1 and RotorOps.voice_overs then --if there are no other units in the player flight group (preventing duplicated messages for ai wingman flights)
       if RotorOps.defending then
         trigger.action.outSoundForGroup(initiator , RotorOps.gameMsgs.enemy_pushing[RotorOps.active_zone_index + 1][2])
       else
         trigger.action.outSoundForGroup(initiator , RotorOps.gameMsgs.push[RotorOps.active_zone_index + 1][2])
       end
     end

   end

   ---TAKEOFF EVENTS
   if (world.event.S_EVENT_TAKEOFF  == event.id) then
     local initiator_name = event.initiator:getGroup():getName()

     if (initiator_name == "Enemy Attack Helicopters") then
       --we use flights of two aircraft which triggers two events, but we only want to use one event so we use a cooldown timer
       if ((RotorOps.getTime() - cooldown["attack_helo_msg"]) > 90) then
         RotorOps.gameMsg(RotorOps.gameMsgs.attack_helos)
         cooldown["attack_helo_msg"] = RotorOps.getTime()
       else
         env.warning("RotorOps attack helo message skipped")
       end
     end

     if initiator_name == "Enemy Attack Planes" then
       if ((RotorOps.getTime() - cooldown["attack_plane_msg"]) > 90) then
         RotorOps.gameMsg(RotorOps.gameMsgs.attack_planes)
         cooldown["attack_plane_msg"] = RotorOps.getTime()
       else
         env.warning("RotorOps attack plane message skipped")
       end
     end

     if initiator_name == "Enemy Transport Helicopters" then  --we're using mist clone now so group name will not match
       env.info("Transport helicopter took off")

       if ((RotorOps.getTime() - cooldown["trans_helo_msg"]) > 90) then
         timer.scheduleFunction(function()RotorOps.gameMsg(RotorOps.gameMsgs.transp_helos_toff) end, {}, timer.getTime() + 1)
         cooldown["trans_helo_msg"] = RotorOps.getTime()
       else
         env.warning("RotorOps transport helo message skipped")
       end
     end

   end

   ---BASE CAPTURE EVENTS  --doesn't work with FARPs..
   if (world.event.S_EVENT_BASE_CAPTURED == event.id) then
     env.info("Base captured")
     if (event.place:getCoalition() == 2) then
       env.info("Blue forces captured a base via place attribute")
     end
   end

   ---UNIT DESTROYED EVENTS
    if (world.event.S_EVENT_KILL == event.id) then
      if event.initiator and event.target then
        if not event.initiator.getCoalition or not event.target.getCoalition then
          return
        end
        if event.initiator:getCoalition() and event.target:getCoalition() and event.initiator:getCoalition() ~= event.target:getCoalition() then
          local unit = mist.DBs.unitsByName[event.target:getName()]  --unit is already dead, so we'll need to use the database to get other attributes
          if unit then
            for index, group in pairs(RotorOps.ai_attacking_vehicle_groups) do
              if group == unit.groupName then
                if event.initiator:hasAttribute("Tanks") then
                  env.info("ROTOROPS: convoy unit killed by armor. Should suppress convoy")
                  RotorOps.convoy_suppressor = event.initiator
                else
                  env.info("ROTOROPS: convoy unit killed by non-armor.")
                  RotorOps.convoy_assaulter = event.initiator
                  --set a timer to nullify the convoy assaulter; in case it was a passing attack
                  timer.scheduleFunction(function() RotorOps.convoy_assaulter = nil end, nil, timer.getTime() + 300)
                end
              end
            end
          end
        end
      end
    end


end



function RotorOps.registerCtldCallbacks(var)
ctld.addCallback(function(_args)
    local action = _args.action
    local unit = _args.unit
    local picked_troops = _args.onboard
    local dropped_troops = _args.unloaded
    --env.info("ctld callback: ".. mist.utils.tableShow(_args))


    if action == "load_troops" or action == "extract_troops" then
      trigger.action.outSoundForGroup(unit:getGroup():getID() , sound_effects.troop_pickup[math.random(1, #sound_effects.troop_pickup)])
    elseif action == "unload_troops_zone" or action == "dropped_troops" then
      trigger.action.outSoundForGroup(unit:getGroup():getID() , sound_effects.troop_dropoff[math.random(1, #sound_effects.troop_dropoff)])
      if RotorOps.isUnitInZone(unit, RotorOps.active_zone) then
        local id = timer.scheduleFunction(RotorOps.gameMsgHandler, RotorOps.gameMsgs.friendly_troops_dropped, timer.getTime() + 6)  --allow some extra time so we don't step on the player's troop/unload sound effects

      end
      if dropped_troops.jtac == true then
        local id = timer.scheduleFunction(RotorOps.gameMsgHandler, RotorOps.gameMsgs.jtac, timer.getTime() + 12) --allow some extra time so we don't step on the player's troop/unload sound effects
      end
    end

end)
end

---UTILITY FUNCTIONS---

local function debugMsg(text)
  if(debug) then
    --trigger.action.outText(text, 5)
    env.info("ROTOROPS_DEBUG: "..text)
  end
end


local function debugTable(table)
  --trigger.action.outText("dbg: ".. mist.utils.tableShow(table), 5)
  env.info("ROTOROPS_DEBUG: ".. mist.utils.tableShow(table))
end


local function dispMsg(text)
  trigger.action.outText(text, 5)
  return text
end


local function tableHasKey(table,key)
  if table then
    return table[key] ~= nil
  else
    env.warning("table parameter not provided")
    return nil
  end
end


local function hasValue (tab, val)
    for index, value in ipairs(tab) do
        if value == val then
            return true
        end
    end
    return false
end

local function getObjectVolume(obj)
  local length = (obj:getDesc().box.max.x + math.abs(obj:getDesc().box.min.x))
  local height = (obj:getDesc().box.max.y + math.abs(obj:getDesc().box.min.y))
  local depth = (obj:getDesc().box.max.z + math.abs(obj:getDesc().box.min.z))
  return length * height * depth
end

local function getDistance(point1, point2)
  local x1 = point1.x
  local y1 = point1.y
  local z1 = point1.z
  local x2 = point2.x
  local y2 = point2.y
  local z2 = point2.z
  local dX = math.abs(x1-x2)
  local dZ = math.abs(z1-z2)
  local distance = math.sqrt(dX*dX + dZ*dZ)
  return distance
end

function RotorOps.isUnitInZone(unit, zone_name)
  local zone = trigger.misc.getZone(zone_name)
  local distance = getDistance(unit:getPoint(), zone.point)
  --local distance = mist.utils.get2DDist(unit:getPoint(), zone.point)
  if distance <= zone.radius then
    return true
  else
    return false
  end
end


function RotorOps.groupsFromUnits(units, table)
  local groups = {}
  for i = 1, #units do
   if units[i]:isExist() then
     if hasValue(groups, units[i]:getGroup():getName()) == false then
         groups[#groups + 1] = units[i]:getGroup():getName()
     else
     end
   end
  end
  return groups
end


function RotorOps.gameMsg(event, _index)
  if not event then
    env.warning("event parameter is nil")
    return
  end
  local index = 1
  if _index ~= nil then
    index = _index + 1
  end
  if tableHasKey(event, index) then
    game_message_buffer[#game_message_buffer + 1] = {event[index][1], event[index][2]}
  else env.warning("ROTOR OPS could not find sound file entry")
  end
end

function RotorOps.gameMsgHandler(event)  --for use with scheduled functions
  RotorOps.gameMsg(event)
end



local function processMsgBuffer(vars)
  if #game_message_buffer > 0 then
    local message = table.remove(game_message_buffer, 1)
    trigger.action.outText(message[1], 10, true)
    env.info("RotorOps: "..message[1])
    if RotorOps.voice_overs then
      trigger.action.outSound(message[2])
    end
  end
  local id = timer.scheduleFunction(processMsgBuffer, 1, timer.getTime() + 5)
end


function RotorOps.sortOutInfantry(mixed_units)
  local _infantry = {}
  local _not_infantry = {}
  for index, unit in pairs(mixed_units)
  do
    if unit:hasAttribute("Infantry") then
      _infantry[#_infantry + 1] = unit
    else _not_infantry[#_not_infantry + 1] = unit
    end
  end
  return {infantry = _infantry, not_infantry = _not_infantry}
end


function RotorOps.getValidUnitFromGroup(grp)
 if grp == nil then return end
 local group_obj
 if type(grp) == 'string' then
   group_obj = Group.getByName(grp)
 else
  group_obj = grp
 end
 if group_obj == nil then
  return
 end
 if group_obj.isExist == nil or group_obj:isExist() ~= true then
  return
 end
 local first_valid_unit
 for index, unit in pairs(group_obj:getUnits())
 do
   if unit:isExist() == true then
     first_valid_unit = unit
     break
   else --trigger.action.outText("a unit no longer exists", 15)
   end
 end
 return first_valid_unit
end

--"static" in this case, is our groups/units that we don't want controlled by conflict zone tasks
local function isStaticUnit(unit)
  local unit_obj
  if type(unit) == 'string' then
    unit_obj = Unit.getByName(unit)
  else
    unit_obj = unit
  end
  if string.find(unit_obj:getGroup():getName():lower(), RotorOps.exclude_ai_group_name:lower()) then
    return true
  else
    return false
  end
end

--"static" in this case, is our groups/units that we don't want controlled by conflict zone tasks
local function isStaticGroup(group)
  local group_obj
  if type(group) == 'string' then
    group_obj = Group.getByName(group)
  else
    group_obj = group
  end
  if string.find(group_obj:getName():lower(), RotorOps.exclude_ai_group_name:lower()) then
    return true
  else
    return false
  end
end

----USEFUL PUBLIC FUNCTIONS FOR THE MISSION EDITOR---

--Spawn/clone a group onto the location of one unit in the group. This is similar to deployTroops, but it does not use CTLD. You must provide a source group to copy.
function RotorOps.spawnGroupOnGroup(grp, src_grp_name, ai_task) --allow to spawn on other group units
  local valid_unit = RotorOps.getValidUnitFromGroup(grp)
  if not valid_unit then return end
  local vars = {}
  vars.gpName = src_grp_name
  vars.action = 'clone'
  vars.point = valid_unit:getPoint()
  vars.radius = 5
  vars.disperse = 'disp'
  vars.maxDisp = 5
  local new_grp_table = mist.teleportToPoint(vars)

  if new_grp_table then
      RotorOps.aiTask(new_grp_table, ai_task)
  else debugMsg("Infantry failed to spawn. ")
  end
end



--Easy way to deploy troops from a vehicle with waypoint action.  Spawns from the first valid unit found in a group
function RotorOps.deployTroops(quantity, target_group, announce)
  local target_group_obj
  if type(target_group) == 'string' then
    target_group_obj = Group.getByName(target_group)
  else
    target_group_obj = target_group
  end
  debugMsg("DeployTroops on group: "..target_group_obj:getName())
  local valid_unit = RotorOps.getValidUnitFromGroup(target_group_obj)
  if not valid_unit then return end
  local coal = valid_unit:getCoalition()
  local side = "red"
  if coal == 2 then side = "blue" end
  local point = valid_unit:getPoint()
  ctld.spawnGroupAtPoint(side, quantity, point, 1000)

  -- voiceover trigger stuff
  for index, zone in pairs(RotorOps.zones)
  do
    if RotorOps.isUnitInZone(valid_unit, zone.name) and announce == true then
      if side == "red" then
        RotorOps.gameMsg(RotorOps.gameMsgs.troops_dropped, index)
      else
        RotorOps.gameMsg(RotorOps.gameMsgs.friendly_troops_dropped)
      end
    end
  end
end



--see list of tasks in aiExecute. Zone/point is optional for many tasks. Works with group name or object/table
function RotorOps.aiTask(grp, task, zone, point)
   local group_name
   if type(grp) == 'string' then
    group_name = grp
   else
    group_name = Group.getName(grp)
   end

   if tableHasKey(RotorOps.ai_tasks, group_name) == true then  --if we already have this group in our list to manage
     --debugMsg("timer already exists, updating task for "..group_name.." : ".. RotorOps.ai_tasks[group_name].ai_task.." to "..task)
     RotorOps.ai_tasks[group_name].ai_task = task
     RotorOps.ai_tasks[group_name].zone = zone
	 RotorOps.ai_tasks[group_name].point = point
   else
     local vars = {}
     vars.group_name = group_name
     --vars.last_task = task
     if zone then
       vars.zone = zone
     end
	 if point then
	   vars.point = point
	 end
     local timer_id = timer.scheduleFunction(RotorOps.aiExecute, vars, timer.getTime() + 5)
     RotorOps.ai_tasks[group_name] = {['timer_id'] = timer_id, ['ai_task'] = task, ['zone'] = zone, ['point'] = point}
   end
end


--add units to the staged_units table for ai tasking as attackers
function RotorOps.tallyZone(zone_name)
  local new_units
  if RotorOps.defending then
    new_units = mist.getUnitsInZones(mist.makeUnitTable({'[red][vehicle]'}), {zone_name})
  else
    new_units = mist.getUnitsInZones(mist.makeUnitTable({'[blue][vehicle]'}), {zone_name})
  end

  if new_units and #new_units > 0 then

    for index, unit in pairs(new_units) do
      if not hasValue(RotorOps.staged_units, unit) then
	    if not isStaticUnit(unit) then
          env.info("RotorOps adding new units to staged_units: "..#new_units)
          table.insert(RotorOps.staged_units, unit)
          RotorOps.aiTask(unit:getGroup(),"move_to_active_zone", RotorOps.zones[RotorOps.active_zone_index].name)
		end
      else
        --env.info("unit already in table")
      end
    end

  end

end

--display a text message to all players with a radio sound effect
function RotorOps.radioText(message)
  RotorOps.gameMsg({message, 'radio_effect.ogg'})
end


---AI CORE BEHAVIOR--
--


function RotorOps.chargeEnemy(vars)
 --trigger.action.outText("charge enemies: "..mist.utils.tableShow(vars), 5)
 local grp = vars.grp
 local search_radius = vars.radius or 5000
  ----
 local first_valid_unit = RotorOps.getValidUnitFromGroup(grp)
  if first_valid_unit == nil then return end

 if first_valid_unit == nil then return end
 local start_point = first_valid_unit:getPoint()
 if not vars.spawn_point then vars.spawn_point = start_point end

 local enemy_coal
 if grp:getCoalition() == 1 then enemy_coal = 2 end
 if grp:getCoalition() == 2 then enemy_coal = 1 end

  local ifFound = function(foundItem, val)  ---dcs world.searchObjects method
    local enemy_unit
    local path = {}
    --trigger.action.outText("found item: "..foundItem:getTypeName(), 5)
   -- if foundItem:hasAttribute("Infantry") == true and foundItem:getCoalition() == enemy_coal then
    if foundItem:getCoalition() == enemy_coal and foundItem:isActive() then
      enemy_unit = foundItem
      --debugMsg("found enemy! "..foundItem:getTypeName())

      path[1] = mist.ground.buildWP(start_point, '', 5)
      path[2] = mist.ground.buildWP(enemy_unit:getPoint(), '', 5)
      mist.goRoute(grp, path)
    else

      --trigger.action.outText("object found is not enemy inf in "..search_radius, 5)
    end

    return true
   end



   if vars.zone then     ---mist getUnitsInZones method
     local units_in_zone
     if enemy_coal == 1 then
       units_in_zone = mist.getUnitsInZones(mist.makeUnitTable({'[red][vehicle]'}), {vars.zone}, "spherical")
     elseif enemy_coal == 2 then
       units_in_zone = mist.getUnitsInZones(mist.makeUnitTable({'[blue][vehicle]'}), {vars.zone}, "spherical")
     end
     local closest_dist = 10000
     local closest_unit
     for index, unit in pairs(units_in_zone) do
       if unit:getCoalition() == enemy_coal then
         local dist = mist.utils.get2DDist(start_point, unit:getPoint())
         if dist < closest_dist then
           closest_unit = unit
           closest_dist = dist
         end
       end
     end

     if closest_unit ~= nil then
       local path = {}
       path[1] = mist.ground.buildWP(start_point, '', 5)
       path[2] = mist.ground.buildWP(closest_unit:getPoint(), '', 5)
       mist.goRoute(grp, path)
     end

   else    ---dcs world.searchObjects method
     --debugMsg("CHARGE ENEMY in radius: "..search_radius)
     local volS = {
       id = world.VolumeType.SPHERE,
       params = {
         point = first_valid_unit:getPoint(),
         radius = search_radius
       }
     }
     world.searchObjects(Object.Category.UNIT, volS, ifFound)
   end

end


function RotorOps.patrolRadius(vars)
 --debugMsg("patrol radius: "..mist.utils.tableShow(vars.grp))
 local grp = vars.grp
 local search_radius = vars.radius or 100
 local first_valid_unit = RotorOps.getValidUnitFromGroup(grp)
 if first_valid_unit == nil then return end
 for index, unit in pairs(grp:getUnits())
 do
   if unit:isExist() == true then
     first_valid_unit = unit
     break
   else --trigger.action.outText("a unit no longer exists", 15)
   end
 end
 if first_valid_unit == nil then return end
 local start_point = first_valid_unit:getPoint()
 local object_vol_thresh = 0
 local max_waypoints = 5
 local foundUnits = {}
 --local sphere = trigger.misc.getZone('town')
 local volS = {
   id = world.VolumeType.SPHERE,
   params = {
     point = grp:getUnit(1):getPoint(),  --check if exists, maybe itterate through grp
     radius = search_radius
   }
 }

 local ifFound = function(foundItem, val)
  --trigger.action.outText("found item: "..foundItem:getTypeName(), 5)
  if foundItem:hasAttribute("Infantry") ~= true then  --disregard infantry...we only want objects that might provide cover
    if getObjectVolume(foundItem) > object_vol_thresh then
      foundUnits[#foundUnits + 1] = foundItem
      --trigger.action.outText("valid cover item: "..foundItem:getTypeName(), 5)
    else --debugMsg("object not large enough: "..foundItem:getTypeName())
    end
  else --trigger.action.outText("object not the right type", 5)
  end
 return true
 end

 world.searchObjects(1, volS, ifFound)
 world.searchObjects(3, volS, ifFound)
 world.searchObjects(5, volS, ifFound)
 --world.searchObjects(Object.Category.BASE, volS, ifFound)
 local path = {}
 path[1] = mist.ground.buildWP(start_point, '', 5)
 local m = math.min(#foundUnits, max_waypoints)
 for i = 1, m, 1
   do
     local rand_index = math.random(1,#foundUnits)
     path[i + 1] = mist.ground.buildWP(foundUnits[rand_index]:getPoint(), '', 5)
     --trigger.action.outText("waypoint to: "..foundUnits[rand_index]:getTypeName(), 5)
   end
 if #path <= 3 then
   for i = #path, max_waypoints, 1
   do
     path[#path + 1] = mist.ground.buildWP(mist.getRandPointInCircle(start_point, search_radius), '', 5)
   end
 end
 --trigger.action.outText("new waypoints created: "..(#path - 1), 5)
 mist.goRoute(grp, path)
end


function RotorOps.shiftPosition(vars)
 --debugMsg("patrol radius: "..mist.utils.tableShow(vars.grp))
 local grp = vars.grp
 local search_radius = vars.radius or 100
 local inner_radius = 50 --minimum distance to move for randpointincircle
 local first_valid_unit = RotorOps.getValidUnitFromGroup(grp)
 if first_valid_unit == nil then return end
 local start_point = vars.point

 if not start_point then
     --env.info("RotorOps: No point provided, getting current position.")
	 for index, unit in pairs(grp:getUnits()) do
	   if unit:isExist() == true then
		 first_valid_unit = unit
		 break
	   else --trigger.action.outText("a unit no longer exists", 15)
	   end
	 end
	 if first_valid_unit == nil then return end
	 start_point = first_valid_unit:getPoint()
 end


 local max_waypoints = 2

 local urban = RotorOps.pointIsUrban(start_point, 100)
 formation = 'Cone'
 if urban then
   formation = 'On Road'
 end

 local path = {}
 path[1] = mist.ground.buildWP(start_point, '', 5)

 for i = #path, max_waypoints, 1 do
   for i = 1, 4, 1 do
    local rand_point = mist.getRandPointInCircle(start_point, search_radius, inner_radius)

    if mist.isTerrainValid(rand_point, {'LAND', 'ROAD'}) == true then
        path[#path + 1] = mist.ground.buildWP(rand_point, formation, 5)
    	--env.info("point is valid, adding as waypoint with formation: " .. formation)
    	break
    end

   end
 end

 mist.goRoute(grp, path)
end


function RotorOps.guardPosition(vars)
 --debugMsg("patrol radius: "..mist.utils.tableShow(vars.grp))
 local grp = vars.grp
 local search_radius = vars.radius or 100
 local first_valid_unit = RotorOps.getValidUnitFromGroup(grp)
 if first_valid_unit == nil then return end
 local start_point = vars.point

 if not start_point then
     --env.info("RotorOps: No point provided, getting current position.")
	 for index, unit in pairs(grp:getUnits()) do
	   if unit:isExist() == true then
		 first_valid_unit = unit
		 break
	   else --trigger.action.outText("a unit no longer exists", 15)
	   end
	 end
	 if first_valid_unit == nil then return end
	 start_point = first_valid_unit:getPoint()
 end
 local object_vol_thresh = 0
 local foundUnits = {}

 local volS = {
   id = world.VolumeType.SPHERE,
   params = {
     point = start_point,
     radius = search_radius
   }
 }

 local ifFound = function(foundItem, val)
  --trigger.action.outText("found item: "..foundItem:getTypeName(), 5)
  if foundItem:hasAttribute("Infantry") ~= true then  --disregard infantry...we only want objects that might provide cover
    if getObjectVolume(foundItem) > object_vol_thresh then
      foundUnits[#foundUnits + 1] = foundItem
      --trigger.action.outText("valid cover item: "..foundItem:getTypeName(), 5)
    else --debugMsg("object not large enough: "..foundItem:getTypeName())
    end
  else --trigger.action.outText("object not the right type", 5)
  end
 return true
 end

 world.searchObjects(1, volS, ifFound)
 world.searchObjects(3, volS, ifFound)
 world.searchObjects(5, volS, ifFound)
 --world.searchObjects(Object.Category.BASE, volS, ifFound)
 if #foundUnits > 0 then
   local path = {}
   --path[1] = mist.ground.buildWP(RotorOps.getValidUnitFromGroup(grp):getPoint(), '', 2)
   path[1] = mist.ground.buildWP(RotorOps.getValidUnitFromGroup(grp):getPoint(), '', 2)
   local rand_index = math.random(1,#foundUnits)
   path[#path + 1] = mist.ground.buildWP(foundUnits[rand_index]:getPoint(), '', 2)
   mist.goRoute(grp, path)
 end
end

--helper function to try to determine a point is near many scenery objects
function RotorOps.pointIsUrban(_point, _radius)
   local volS = {
   id = world.VolumeType.SPHERE,
   params = {
     point = _point,
     radius = _radius
   }
 }
 local foundUnits = {}
 local ifFound = function(foundItem, val)
      foundUnits[#foundUnits + 1] = foundItem
 end

 world.searchObjects(5, volS, ifFound)
 --env.info("Found scenery objects: " .. #foundUnits)
 if #foundUnits > 10 then
   return true
 end
   return false
end



function RotorOps.aiExecute(vars)
  local update_interval = 60
  local last_task = vars.last_task
  local last_zone = vars.last_zone
  local group_name = vars.group_name
  if not vars.group_name or not tableHasKey(RotorOps.ai_tasks, group_name) then
    return
  end
  local task = RotorOps.ai_tasks[group_name].ai_task
  local zone = RotorOps.ai_tasks[group_name].zone
  local point = RotorOps.ai_tasks[group_name].point

--  if vars.zone then zone = vars.zone end


--error after Apache update
--  if Group.isExist(Group.getByName(group_name)) ~= true or #Group.getByName(group_name):getUnits() < 1 then
--    debugMsg(group_name.." no longer exists")
--    RotorOps.ai_tasks[group_name] = nil
--    return
--  end

  if Group.getByName(group_name) then
    if Group.isExist(Group.getByName(group_name)) ~= true or #Group.getByName(group_name):getUnits() < 1 then
      debugMsg(group_name.." no longer exists")
      RotorOps.ai_tasks[group_name] = nil
      return
    end
  else
    debugMsg(group_name.." no longer exists")
    RotorOps.ai_tasks[group_name] = nil
  end



  local same_zone = false
  if zone ~= nil then
    if zone ~= last_zone then
      same_zone = true
    end
  end

  local should_update = true

  if RotorOps.persistent_tasking and task == last_task then
   if task == "move_to_active_zone" or task == "move_to_zone" then
     if same_zone then
	   should_update = false
	 end
   end
  end


 if should_update then  --check to make sure we don't have the same task

  debugMsg("tasking: "..group_name.." : "..task)

  if task == "patrol" then
    local vars = {}
    vars.grp = Group.getByName(group_name)
    vars.radius = 300
    RotorOps.patrolRadius(vars) --takes a group object, not name
    update_interval = math.random(150,200)
  elseif task == "aggressive" then
    local vars = {}
    vars.grp = Group.getByName(group_name)
    vars.radius = 5000
    update_interval = math.random(60,90)
    RotorOps.chargeEnemy(vars) --takes a group object, not name
  elseif task == "clear_zone" then
    local vars = {}
    vars.grp = Group.getByName(group_name)
    vars.zone = zone
    update_interval = math.random(50,70)
    RotorOps.chargeEnemy(vars) --takes a group object, not name
  elseif task == "move_to_zone" then
    update_interval = math.random(90,120)
    local formation = 'cone'
    local final_heading = nil
    local speed = RotorOps.ground_speed
    local force_offroad = RotorOps.force_offroad
    mist.groupToPoint(group_name, zone, formation, final_heading, speed, force_offroad)
  elseif task == "move_to_active_zone" then
    update_interval = math.random(90,120)
    local formation = 'cone'
    local final_heading = nil
    local speed = RotorOps.ground_speed
    local force_offroad = RotorOps.force_offroad
    mist.groupToPoint(group_name, RotorOps.active_zone, formation, final_heading, speed, force_offroad)
  elseif task == "shift" then
    local vars = {}
    vars.grp = Group.getByName(group_name)
    vars.radius = 250
	  vars.point = point
    RotorOps.shiftPosition(vars) --takes a group object, not name
    update_interval = math.random(60,360)
  elseif task == "guard" then
    local vars = {}
    vars.grp = Group.getByName(group_name)
    vars.radius = 100
	  vars.point = point
    RotorOps.guardPosition(vars) --takes a group object, not name
    update_interval = math.random(60,120)

  end

 end

  vars.last_task = task
  vars.last_zone = zone

  local timer_id = timer.scheduleFunction(RotorOps.aiExecute, vars, timer.getTime() + update_interval)
end





---CONFLICT ZONES GAME FUNCTIONS---

--take stock of the blue/red forces in zone and apply some logic to determine game/zone states
function RotorOps.assessUnitsInZone(var)
   if RotorOps.game_state == RotorOps.game_states.not_started then return end


   local defending_ground_units
   local defending_infantry
   local defending_vehicles
   local attacking_ground_units
   local attacking_infantry
   local attacking_vehicles


    --find and sort units found in the active zone
   if RotorOps.defending then
     defending_ground_units = mist.getUnitsInZones(mist.makeUnitTable({'[blue][vehicle]'}), {RotorOps.active_zone})
     defending_infantry = RotorOps.sortOutInfantry(defending_ground_units).infantry
     defending_vehicles = RotorOps.sortOutInfantry(defending_ground_units).not_infantry
     attacking_ground_units = mist.getUnitsInZones(mist.makeUnitTable({'[red][vehicle]'}), {RotorOps.active_zone})
     attacking_infantry = RotorOps.sortOutInfantry(attacking_ground_units).infantry
     attacking_vehicles = RotorOps.sortOutInfantry(attacking_ground_units).not_infantry
   else  --attacking
     defending_ground_units = mist.getUnitsInZones(mist.makeUnitTable({'[red][vehicle]'}), {RotorOps.active_zone})
     defending_infantry = RotorOps.sortOutInfantry(defending_ground_units).infantry
     defending_vehicles = RotorOps.sortOutInfantry(defending_ground_units).not_infantry
     attacking_ground_units = mist.getUnitsInZones(mist.makeUnitTable({'[blue][vehicle]'}), {RotorOps.active_zone})
     attacking_infantry = RotorOps.sortOutInfantry(attacking_ground_units).infantry
     attacking_vehicles = RotorOps.sortOutInfantry(attacking_ground_units).not_infantry
   end


   --ground unit ai stuff
     RotorOps.ai_defending_infantry_groups = RotorOps.groupsFromUnits(defending_infantry)
     RotorOps.ai_defending_vehicle_groups = RotorOps.groupsFromUnits(defending_vehicles)
     RotorOps.ai_attacking_infantry_groups = RotorOps.groupsFromUnits(attacking_infantry)
     RotorOps.ai_attacking_vehicle_groups = RotorOps.groupsFromUnits(attacking_vehicles)

     local halt_convoy = false
     local convoy_status = "enroute"

     if #attacking_ground_units > 0 and not RotorOps.defending and RotorOps.halt_convoy_without_airsupport then
      if RotorOps.convoy_suppressor then
        if RotorOps.convoy_suppressor:isExist() then
          halt_convoy = true
          convoy_status = "suppressed"
        else
          RotorOps.convoy_suppressor = nil
        end

      elseif not RotorOps.predAirSupportNearActive() then
        convoy_status = "waiting_for_escort"
        halt_convoy = true
      end
    end

    if RotorOps.convoy_assaulter then
      if RotorOps.convoy_assaulter:isExist() then
        convoy_status = "under_attack"
      else
        RotorOps.convoy_assaulter = nil
      end
    end

  for index, group in pairs(RotorOps.ai_defending_infantry_groups) do
    if group and not isStaticGroup(group) then
      RotorOps.aiTask(group, "patrol")
    end
  end

  for index, group in pairs(RotorOps.ai_attacking_infantry_groups) do
    if group and not isStaticGroup(group) then
      if halt_convoy then
        RotorOps.aiTask(group, "guard")
      else
        RotorOps.aiTask(group, "clear_zone", RotorOps.active_zone)
        convoy_status = "clearing_zone"
      end
    end
  end

  for index, group in pairs(RotorOps.ai_attacking_vehicle_groups) do
    if group and not isStaticGroup(group) then
      if halt_convoy then
        RotorOps.aiTask(group, "guard")
      else
        RotorOps.aiTask(group, "clear_zone", RotorOps.active_zone)
        convoy_status = "clearing_zone"
      end
    end
  end

  for index, group in pairs(RotorOps.ai_defending_vehicle_groups) do
    if group and not isStaticGroup(group) then
      Group.getByName(group):getController():setOption(AI.Option.Ground.id.DISPERSE_ON_ATTACK , RotorOps.defending_vehicles_disperse)
	  if RotorOps.defending_vehicles_behavior == "patrol" then
	    RotorOps.aiTask(group, "patrol")
	  elseif RotorOps.defending_vehicles_behavior == "shift" then
	    local unit = RotorOps.getValidUnitFromGroup(group)
		if unit then
	      RotorOps.aiTask(group, "shift", nil, unit:getPoint())
		end
	  end
    end
  end




   --FIRES ONCE PER ZONE ACTIVATION
   --let's compare the defending units in zone vs their initial numbers and set a game flag
   if not active_zone_initial_defenders then
     --debugMsg("taking stock of the active zone")
     active_zone_initial_defenders = defending_ground_units
     low_units_message_fired = false

     --sort infantry spawn zones and spawn quantity
     inf_spawn_zones = {}
	 local total_spawn_zones = 0
     for zone, zoneobj in pairs(mist.DBs.zonesByName) do
       if string.find(zone, RotorOps.active_zone) and string.find(zone:lower(), "spawn") then --if we find a zone that has the active zone name and the word spawn
         inf_spawn_zones[#inf_spawn_zones + 1] = zone
         env.info("ROTOR OPS: active spawn zone found:"..zone)
       end

     end

    for index, rops_zone in pairs(RotorOps.zones) do
      for zone, zoneobj in pairs(mist.DBs.zonesByName) do
        if string.find(zone:lower(), rops_zone.name:lower()) and string.find(zone:lower(), "spawn") then
          total_spawn_zones = total_spawn_zones + 1
          env.info("ROTOR OPS: spawn zone found:"..zone..", total spawn zones:"..total_spawn_zones)
        end
      end
    end
     --RotorOps.inf_spawns_avail = RotorOps.inf_spawns_per_zone * RotorOps.inf_spawn_multiplier[RotorOps.active_zone_index]
	 if total_spawn_zones > 0 then
	   RotorOps.inf_spawns_avail = (RotorOps.inf_spawns_total / total_spawn_zones) * #inf_spawn_zones
     RotorOps.inf_spawns_avail = math.ceil(RotorOps.inf_spawns_avail)
	 end

     env.info("ROTOR OPS: zone activated: "..RotorOps.active_zone..", inf spawns avail:"..RotorOps.inf_spawns_avail..", spawn zones:"..#inf_spawn_zones)
   end


   local defenders_status_flag = RotorOps.zones[RotorOps.active_zone_index].defenders_status_flag
   --if #active_zone_initial_defenders == 0 then active_zone_initial_defenders = 1 end --prevent divide by zero
   local defenders_remaining_percent = math.floor((#defending_ground_units / #active_zone_initial_defenders) * 100)

   if #defending_ground_units <= RotorOps.max_units_left then  --if we should declare the zone cleared
     active_zone_initial_defenders = nil
     defenders_remaining_percent = 0
     trigger.action.setUserFlag(defenders_status_flag, 0)  --set the zone's flag to cleared
	 trigger.action.setUserFlag(zone_defenders_flags[RotorOps.active_zone_index], 0)  --set the zone's flag to cleared
     if RotorOps.defending == true then
       RotorOps.gameMsg(RotorOps.gameMsgs.enemy_cleared_zone, RotorOps.active_zone_index)
     else
       RotorOps.gameMsg(RotorOps.gameMsgs.cleared, RotorOps.active_zone_index)
     end
     if RotorOps.auto_push then                                 --push units to the next zone
       RotorOps.setActiveZone(RotorOps.active_zone_index + 1)
     end

   else
     trigger.action.setUserFlag(defenders_status_flag, defenders_remaining_percent)  --set the zones flag to indicate the status of remaining defenders
	   trigger.action.setUserFlag(zone_defenders_flags[RotorOps.active_zone_index], defenders_remaining_percent)
   end

   --are all zones clear?
   local all_zones_clear = true
   for key, value in pairs(RotorOps.zones) do
      local defenders_remaining = trigger.misc.getUserFlag(RotorOps.zones[key].defenders_status_flag)
      if defenders_remaining ~= 0 then
        all_zones_clear = false
      end
   end

   --update staged units remaining flag
   local staged_units_remaining = {}
   for index, unit in pairs(RotorOps.staged_units) do
     if unit:isExist() and unit:getLife() > 0 then
       staged_units_remaining[#staged_units_remaining + 1] = unit
     end
   end
   local percent_staged_remain = 0
   percent_staged_remain = math.floor((#staged_units_remaining / #RotorOps.staged_units) * 100)
   trigger.action.setUserFlag(RotorOps.staged_units_flag, percent_staged_remain)
   trigger.action.setUserFlag('ROPS_ATTACKERS', percent_staged_remain)

   if #staged_units_remaining == 0 then
    convoy_status = "destroyed"
   end


   --is the game finished?
   if all_zones_clear then
    if RotorOps.defending == true then
      RotorOps.game_state = RotorOps.game_states.lost
      trigger.action.setUserFlag(RotorOps.game_state_flag, RotorOps.game_states.lost)
	  trigger.action.setUserFlag('ROPS_GAMESTATE', RotorOps.game_states.lost)
    else
      RotorOps.game_state = RotorOps.game_states.won
      trigger.action.setUserFlag(RotorOps.game_state_flag, RotorOps.game_states.won)
	  trigger.action.setUserFlag('ROPS_GAMESTATE', RotorOps.game_states.won)
    end
    return --we won't reset our timer to fire this function again
   end

   --is the defending game finished?
   local defending_game_won = true
   for key, staged_unit in pairs(RotorOps.staged_units) do
     if staged_unit:isExist() then --check if the enemy has staged units left
       defending_game_won = false
     end
   end
   if RotorOps.defending and defending_game_won then
     RotorOps.game_state = RotorOps.game_states.won
     trigger.action.setUserFlag(RotorOps.game_state_flag, RotorOps.game_states.won)
	 trigger.action.setUserFlag('ROPS_GAMESTATE', RotorOps.game_states.won)
     return  --we won't reset our timer to fire this function again
   end

  --APCs unload
  local function unloadAPCs()
    local units_table = attacking_vehicles

    for index, vehicle in pairs(units_table) do
      local should_deploy = false
      if vehicle:hasAttribute("Infantry carriers") or vehicle:hasAttribute("Trucks") then --if a vehicle is an APC
        if RotorOps.isUnitInZone(vehicle, RotorOps.active_zone) then --if a vehicle is an APC and in zone
          local apc_name = vehicle:getName()

          if tableHasKey(apcs, apc_name) == true then  --if we have this apc in our table already

            for key, apc_details in pairs(apcs[apc_name]) do
              if hasValue(apc_details, RotorOps.active_zone) then     --if our apc table has the current zone

              else                                       --our apc table does not have the current zone
                apcs[apc_name].deployed_zones = {RotorOps.active_zone,}
                should_deploy = true
              end
            end

          else                                          --we don't have the apc in our table
            should_deploy = true
            apcs[apc_name] = {['deployed_zones'] = {RotorOps.active_zone,}}
          end
        end

      end

      if should_deploy then
       local function timedDeploy()
         if vehicle:isExist() then
           env.info(vehicle:getName().." is deploying troops.")
           RotorOps.deployTroops(RotorOps.inf_apc_group, vehicle:getGroup(), false)
         end
       end

       local id = timer.scheduleFunction(timedDeploy, nil, timer.getTime() + math.random(90, 300))
      end

    end

  end

  if RotorOps.apcs_spawn_infantry then
   unloadAPCs()  --this should really be an aitask
  end

  --spawn infantry in infantry spawn zones
  local function spawnInfantry()
   if math.random(0, 100) <= RotorOps.inf_spawn_chance then
      local rand_index = math.random(1, #inf_spawn_zones)
      local zone = inf_spawn_zones[rand_index]

      ctld.spawnGroupAtTrigger("red", RotorOps.inf_spawn_red, zone, 1000)
      if RotorOps.inf_spawn_messages then
        RotorOps.gameMsg(RotorOps.gameMsgs.infantry_spawned, math.random(1, #RotorOps.gameMsgs.infantry_spawned))
      end

      RotorOps.inf_spawns_avail = RotorOps.inf_spawns_avail - 1
      env.info("ROTOR OPS: Attempting to spawn infantry. "..RotorOps.inf_spawns_avail.." spawns remaining in "..zone)
    end
  end

  if RotorOps.inf_spawns_avail > 0 and defenders_remaining_percent <= RotorOps.inf_spawn_trigger_percent then
    spawnInfantry()
  end

  --voiceovers based on remaining defenders
  if not low_units_message_fired then
    if defenders_remaining_percent <= 40 then
      low_units_message_fired = true
      env.info("ROTOR OPS: low units remaining in zone")
      if RotorOps.defending then
        RotorOps.gameMsg(RotorOps.gameMsgs.enemy_almost_cleared, math.random(1, #RotorOps.gameMsgs.enemy_almost_cleared))
      else
        RotorOps.gameMsg(RotorOps.gameMsgs.almost_cleared, math.random(1, #RotorOps.gameMsgs.almost_cleared))
      end
    end
  end

  --Update the convoy status
  RotorOps.convoy_status = convoy_status


   --zone status display
   local message = ""
   local header = ""
   local body = ""
   if RotorOps.defending == true then
     header = "[DEFEND "..RotorOps.active_zone .. "]   "
     body = "BLUE: "..#defending_infantry.. " infantry, " .. #defending_vehicles.." vehicles.  RED CONVOY: " .. #staged_units_remaining .." vehicles. ["..percent_staged_remain.."%]"
   else
     header = "[ATTACK "..RotorOps.active_zone .. "]   "
     body = "RED: " ..#defending_infantry.. " infantry, " .. #defending_vehicles .. " vehicles.  BLUE CONVOY: " .. #staged_units_remaining .." vehicles. ["..percent_staged_remain.."%]"
   end

    if clear_text_index == 1 and not RotorOps.defending then
      if RotorOps.convoy_status == "waiting_for_escort" then
        body = "CONVOY HALTED: Awaiting air support near "..RotorOps.active_zone
      elseif RotorOps.convoy_status == "clearing_zone" then
        body = "Convoy is clearing " .. RotorOps.active_zone
      elseif RotorOps.convoy_status == "enroute" then
        body = "Convoy enroute to " .. RotorOps.active_zone
      elseif RotorOps.convoy_status == "suppressed" then
        local supressor_type = RotorOps.convoy_suppressor:getTypeName() or "enemy units"
        local grid = mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(RotorOps.convoy_suppressor:getPosition().p)), 4) or "unknown location"
        body = "Convoy pinned down by "..supressor_type.." at "..grid
      elseif RotorOps.convoy_status == "under_attack" then
        body = "Convoy under attack!"
      end
      if RotorOps.convoy_status == "destroyed" then
        body = "Convoy has been destroyed."
      end
    end

   message = header .. body
   if RotorOps.zone_status_display then
     game_message_buffer[#game_message_buffer + 1] = {message, ""} --don't load the buffer faster than it's cleared.
   end
   clear_text_index = clear_text_index + 1
    if clear_text_index > 1 then
      clear_text_index = 0
    end
   local id = timer.scheduleFunction(RotorOps.assessUnitsInZone, 1, timer.getTime() + 10)
end




function RotorOps.drawZones()  --this could use a lot of work, we should use trigger.action.removeMark and some way of managing ids created
  local zones = RotorOps.zones
  local previous_point


  for index, zone in pairs(zones)
  do
    local point = trigger.misc.getZone(zone.name).point
    local radius = trigger.misc.getZone(zone.name).radius
    local coal = -1
    local id = index  --this must be UNIQUE!
    local color = {1, 1, 1, 0.5}
    local fill_color = {1, 1, 1, 0.1}
    local text_fill_color = {0, 0, 0, 0}
    local line_type = 5 --1 Solid  2 Dashed  3 Dotted  4 Dot Dash  5 Long Dash  6 Two Dash
    local font_size = 20
    local read_only = false
    local text = index..". "..zone.name
    if zone.name == RotorOps.active_zone then
      id = id + 300
      color = {1, 1, 1, 0.2}
      fill_color = {1, 0, 0, 0.05}
    end
    if previous_point ~= nill then
      --trigger.action.lineToAll(coal, id + 200, point, previous_point, color, line_type)
    end
    previous_point = point

    if RotorOps.draw_conflict_zones == true then
      trigger.action.circleToAll(coal, id, point, radius, color, fill_color, line_type)
      trigger.action.textToAll(coal, id + 100, point, color, text_fill_color, font_size, read_only, text)
    end
  end

  for index, cpz in pairs(ctld.pickupZones) do
    env.info("CTLD pickzone name: " .. cpz[1])
    pickup_zone = trigger.misc.getZone(cpz[1])
    if pickup_zone then
     env.info("found a ctld pickup zone")
     local ctld_zone_status = cpz[4]
     local point = pickup_zone.point
     local radius = pickup_zone.radius
     local coal = -1
     local id = index + 150  --this must be UNIQUE!
     local color = {1, 1, 1, 0.5}
     local fill_color = {0, 0.8, 0, 0.1}
     local line_type = 5 --1 Solid  2 Dashed  3 Dotted  4 Dot Dash  5 Long Dash  6 Two Dash
     if ctld_zone_status == 'yes' or ctld_zone_status == 1 then
	  env.info("pickup zone is active, drawing it to the map")
	  trigger.action.circleToAll(coal, id, point, radius, color, fill_color, line_type)
     end
    end
  end


  -- for index, pickup_zone in pairs(RotorOps.ctld_pickup_zones)
  -- do
    -- for c_index, c_zone in pairs(ctld.pickupZones)
    -- do
      -- if pickup_zone == c_zone[1] then
       -- --debugMsg("found our zone in ctld zones, status: "..c_zone[4])
       -- local ctld_zone_status = c_zone[4]
       -- local point = trigger.misc.getZone(pickup_zone).point
       -- local radius = trigger.misc.getZone(pickup_zone).radius
       -- local coal = -1
       -- local id = index + 150  --this must be UNIQUE!
       -- local color = {1, 1, 1, 0.5}
       -- local fill_color = {0, 0.8, 0, 0.1}
       -- local line_type = 5 --1 Solid  2 Dashed  3 Dotted  4 Dot Dash  5 Long Dash  6 Two Dash
       -- if ctld_zone_status == 'yes' or ctld_zone_status == 1 then
        -- --debugMsg("draw the pickup zone")
        -- trigger.action.circleToAll(coal, id, point, radius, color, fill_color, line_type)
       -- end
      -- end
    -- end
  -- end


end



function RotorOps.setActiveZone(new_index)
  local old_index = RotorOps.active_zone_index
  if new_index > #RotorOps.zones then
    new_index = #RotorOps.zones
  end
  if new_index < 1 then
    new_index = 1
  end

  RotorOps.active_zone_index = new_index
  RotorOps.active_zone = RotorOps.zones[new_index].name

  if new_index ~= old_index then  --the active zone is changing

    -- if not RotorOps.defending then

      -- if old_index > 0 and RotorOps.apcs_spawn_infantry == false then
        -- ctld.activatePickupZone(RotorOps.farp_names[old_index])  --make the captured zone a pickup zone
      -- end
      -- ctld.deactivatePickupZone(RotorOps.farp_names[new_index])
    -- end

    RotorOps.game_state = new_index
    trigger.action.setUserFlag(RotorOps.game_state_flag, new_index)
	trigger.action.setUserFlag('ROPS_GAMESTATE', new_index)
    if new_index > old_index then
      if RotorOps.defending == true then
        RotorOps.gameMsg(RotorOps.gameMsgs.enemy_pushing, new_index)
      else
        RotorOps.gameMsg(RotorOps.gameMsgs.push, new_index)
      end
    end

    local staged_groups = RotorOps.groupsFromUnits(RotorOps.staged_units)
    for index, group in pairs(staged_groups) do
      timer.scheduleFunction(function()RotorOps.aiTask(group,"move_to_active_zone", RotorOps.zones[RotorOps.active_zone_index].name) end, {}, timer.getTime() + index) --add a second between calling aitask
      --RotorOps.aiTask(group,"move_to_active_zone", RotorOps.zones[RotorOps.active_zone_index].name) --send vehicles to next zone; use move_to_active_zone so units don't get stuck if the active zone moves before they arrive
    end


  end


  --debugMsg("active zone: "..RotorOps.active_zone.."  old zone: "..RotorOps.zones[old_index].name)

  RotorOps.drawZones()
end


--make some changes to the CTLD script/settings
function RotorOps.setupCTLD()
  if not ctld then
    trigger.action.outText("ERROR: CTLD Not Loaded!!", 90)
    return
  end
  if type(ctld.pickupZones[1][2]) == "number" then --ctld converts its string table to integer on load, so we'll see if that's happened already
    trigger.action.outText("ERROR: CTLD Loaded Too Soon!!", 90)
    return
  end

  --ctld.Debug = false
  ctld.enableCrates = RotorOps.CTLD_crates
  ctld.enabledFOBBuilding = false
  ctld.JTAC_lock = "vehicle"
  ctld.location_DMS = true
  ctld.numberOfTroops = 24 --max loading size
  ctld.maximumSearchDistance = 4000 -- max distance for troops to search for enemy
  ctld.maximumMoveDistance = 0 -- max distance for troops to move from drop point if no enemy is nearby
  ctld.maximumDistanceLogistic = 300
  ctld.minimumHoverHeight = 5.0 -- Lowest allowable height for crate hover
  ctld.maximumHoverHeight = 15.0 -- Highest allowable height for crate hover
  ctld.maxDistanceFromCrate = 7 -- Maximum distance from from crate for hover
  ctld.hoverTime = 5 -- Time to hold hover above a crate for loading in seconds

  ctld.unitLoadLimits = {
    -- Remove the -- below to turn on options
     ["SA342Mistral"] = 4,
     ["SA342L"] = 4,
     ["SA342M"] = 4,
     ["UH-1H"] = 10,
     ["Mi-8MT"] = 18,
     ["Mi-24P"] = 8,
     ["UH-60L"] = 11,
     ["CH-47Fbl1"] = 33
   }

   ctld.loadableGroups = {
    {name = "Small Standard Group (4)", inf = 2, mg = 1, at = 1 },
    {name = "Standard Group (8)", inf = 4, mg = 2, at = 2 }, -- will make a loadable group with 6 infantry, 2 MGs and 2 anti-tank for both coalitions
    {name = "Anti Air (5)", inf = 2, aa = 3  },
    {name = "Anti Tank (8)", inf = 2, at = 6  },
    {name = "Mortar Squad (6)", mortar = 6 },
    {name = "JTAC Group (4)", inf = 3, jtac = 1 },
    {name = "Small Platoon (16)", inf = 9, mg = 3, at = 3, aa = 1 },
    {name = "Platoon (24)", inf = 10, mg = 5, at = 6, aa = 3 },
   }


   --add to CTLD default pickzone names.  This could be done in a loop but this should be more readable
   --pickupZones = { "Zone name or Ship Unit Name", "smoke color", "limit (-1 unlimited)", "ACTIVE (yes/no)", "side (0 = Both sides / 1 = Red / 2 = Blue )", flag number (optional) }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "STAGING", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "STAGING_BASE", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "ALPHA_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "BRAVO_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "CHARLIE_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "DELTA_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "ECHO_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "FOXTROT_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "GOLF_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "HOTEL_FARP", RotorOps.pickup_zone_smoke, -1, "no", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "HELO_CARRIER", "none", -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "HELO_CARRIER_1", "none", -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops1", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops2", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops3", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops4", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops5", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops6", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops7", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops8", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops9", RotorOps.pickup_zone_smoke, -1, "yes", 0 }
   ctld.pickupZones[#ctld.pickupZones + 1] = { "troops10", RotorOps.pickup_zone_smoke, -1, "yes", 0 }

   ctld.logisticUnits = {}  --eg 'logistic1', 'logistic2', 'logistic3'  --plus -1 -2 -3 suffix for each

  for i = 1, 10 do
    ctld.logisticUnits[#ctld.logisticUnits + 1] = "logistic"..i
    ctld.logisticUnits[#ctld.logisticUnits + 1] = "logistic"..i.."-1"
    ctld.logisticUnits[#ctld.logisticUnits + 1] = "logistic"..i.."-2"
    ctld.logisticUnits[#ctld.logisticUnits + 1] = "logistic"..i.."-3"
  end

end


function RotorOps.setupRadioMenu()
  commandDB['conflict_zones_menu'] = missionCommands.addSubMenu( "ROTOR OPS")
  local conflict_zones_menu = commandDB['conflict_zones_menu']
  commandDB['start_conflict'] = missionCommands.addCommand( "Start conflict"  , conflict_zones_menu , RotorOps.startConflict)

end



function RotorOps.addZone(_name, _zone_defenders_flag)
  if trigger.misc.getZone(_name) == nil then
    trigger.action.outText(_name.." trigger zone missing!  Check RotorOps setup!", 60)
    env.warning(_name.." trigger zone missing!  Check RotorOps setup!")
  end
  table.insert(RotorOps.zones, {name = _name, defenders_status_flag = _zone_defenders_flag})
  trigger.action.setUserFlag(_zone_defenders_flag, 101)
  trigger.action.setUserFlag(zone_defenders_flags[1], 101)
  trigger.action.setUserFlag(zone_defenders_flags[2], 101)
  trigger.action.setUserFlag(zone_defenders_flags[3], 101)
  trigger.action.setUserFlag(zone_defenders_flags[4], 101)
  trigger.action.setUserFlag(zone_defenders_flags[5], 101)
  trigger.action.setUserFlag(zone_defenders_flags[6], 101)
  trigger.action.setUserFlag(zone_defenders_flags[7], 101)
  trigger.action.setUserFlag(zone_defenders_flags[8], 101)

  RotorOps.drawZones()
  local farp_name = _name .. "_FARP"
  RotorOps.farp_names[#RotorOps.farp_names + 1] = farp_name
end


function RotorOps.addStagingZone(_name)
  if trigger.misc.getZone(_name) == nil then
    trigger.action.outText(_name.." trigger zone missing!  Check RotorOps setup!", 60)
    env.warning(_name.." trigger zone missing!  Check RotorOps setup!")
  end

  RotorOps.staging_zones[#RotorOps.staging_zones + 1] = _name
end



--function to automatically add transport craft to ctld, rather than having to define each in the mission editor
function RotorOps.addPilots(var)
   for uName, uData in pairs(mist.DBs.humansByName) do
     if hasValue(RotorOps.transports, uData.type) then
       if hasValue(ctld.transportPilotNames, uData.unitName) ~= true then
         ctld.transportPilotNames [#ctld.transportPilotNames + 1] = uData.unitName
       --else trigger.action.outText("player already in pilot table", 5)
       end
     end
   end
 local id = timer.scheduleFunction(RotorOps.addPilots, 1, timer.getTime() + 15)
end


function RotorOps.setupConflict(_game_state_flag)
  RotorOps.addPilots(1)
  RotorOps.setupCTLD()
  --RotorOps.setupRadioMenu()
  RotorOps.game_state_flag = _game_state_flag
  RotorOps.game_state = RotorOps.game_states.not_started
  processMsgBuffer()
  trigger.action.setUserFlag(RotorOps.game_state_flag, RotorOps.game_states.not_started)
  trigger.action.setUserFlag('ROPS_GAMESTATE', RotorOps.game_states.not_started)
  trigger.action.outText("ALL TROOPS GET TO TRANSPORT AND PREPARE FOR DEPLOYMENT!" , 10, false)
  if RotorOps.CTLD_sound_effects == true then
    local timer_id = timer.scheduleFunction(RotorOps.registerCtldCallbacks, 1, timer.getTime() + 5)
  end
end


function RotorOps.addPickupZone(zone_name, smoke, limit, active, side)  --depreciated, don't use
  ctld.pickupZones[#ctld.pickupZones + 1] = {zone_name, smoke, limit, active, side}
end



function RotorOps.startConflict()
  --if RotorOps.game_state ~= RotorOps.game_states.not_started then return end

  --make some changes to the radio menu
  --local conflict_zones_menu = commandDB['conflict_zones_menu']
  --missionCommands.removeItem(commandDB['start_conflict'])
  --commandDB['clear_zone'] = missionCommands.addCommand( "[CHEAT] Force Clear Zone"  , conflict_zones_menu , RotorOps.clearActiveZone)

  local units_found = mist.getUnitsInZones(mist.makeUnitTable({'[all][vehicle]'}), RotorOps.staging_zones)

  --filter out 'static' units
  for index, unit in pairs(units_found) do
    if not isStaticUnit(unit) then
      RotorOps.staged_units[#RotorOps.staged_units + 1] = unit
    end
  end


  if RotorOps.staged_units[1] == nil then
    trigger.action.outText("RotorOps failed: You must place ground units in the staging and conflict zones!" , 60, false)
    env.warning("No units in staging zone!  Check RotorOps setup!")
    return
  end

  if RotorOps.staged_units[1]:getCoalition() == 1 then  --check the coalition in the staging zone to see if we're defending
    --DEFENSE
    trigger.action.setUserFlag('ROPS_DEFENDING', 1)
    RotorOps.defending = true
    RotorOps.gameMsg(RotorOps.gameMsgs.start_defense)
	ctld.activatePickupZone(RotorOps.farp_names[#RotorOps.farp_names])  --make the last zone a pickup zone for defenders

  else
    --OFFENSE
    RotorOps.gameMsg(RotorOps.gameMsgs.start)
	if RotorOps.enable_staging_pickzones then
    if trigger.misc.getZone("STAGING_BASE") then
	  ctld.activatePickupZone("STAGING_BASE")
    else
	  ctld.activatePickupZone("STAGING")
	end
  end

  end





  RotorOps.setActiveZone(1)

  if RotorOps.ai_task_by_name then
	RotorOps.taskByName()
  end

  local id = timer.scheduleFunction(RotorOps.assessUnitsInZone, 1, timer.getTime() + 5)
  world.addEventHandler(RotorOps.eventHandler)
end


function RotorOps.triggerSpawn(groupName, msg, resume_task)
  local group = Group.getByName(groupName)
  if not group then
    env.warning("RotorOps tried to spawn "..groupName.." but it doesn't exist.")
    return nil
  end
  if group and group:isExist() == true and #group:getUnits() > 0 and group:getUnits()[1]:getLife() > 1 and group:getUnits()[1]:isActive() then
    env.info("RotorOps tried to respawn "..groupName.." but it's already active.")
    return nil
  else
    local new_group = mist.respawnGroup(groupName, resume_task)
    if new_group then
      if msg ~= nil then
        RotorOps.gameMsg(msg)
      end
      env.info("RotorOps spawned "..groupName)
      return new_group
    end
  end

end

---Search for group names containing key strings to assign AI tasks
function RotorOps.taskByName()
  env.info("RotorOps searching for groups to taskByName")
  for group_name, data in pairs(mist.DBs.groupsByName) do
	if string.find(group_name:lower(), RotorOps.patrol_task_string:lower()) then
	  RotorOps.aiTask(group_name, "patrol")
	  env.info("Tasking " .. group_name .. " as patrol.")
	elseif string.find(group_name:lower(), RotorOps.aggressive_task_string:lower()) then
	  RotorOps.aiTask(group_name, "aggressive")
	  env.info("Tasking " .. group_name .. " as aggressive.")
	elseif string.find(group_name:lower(), RotorOps.move_to_active_task_string:lower()) then
	  RotorOps.aiTask(group_name, "move_to_active_zone")
	  env.info("Tasking " .. group_name .. " to move to active zone.")
	elseif string.find(group_name:lower(), RotorOps.shift_task_string:lower()) then
	  RotorOps.aiTask(group_name, "shift")
	  env.info("Tasking " .. group_name .. " to shift positions.")
	elseif string.find(group_name:lower(), RotorOps.guard_task_string:lower()) then
	  RotorOps.aiTask(group_name, "guard")
	  env.info("Tasking " .. group_name .. " to guard positions.")
	end
  end
  if RotorOps.ai_task_by_name_scheduler then
    local timer_id = timer.scheduleFunction(RotorOps.taskByName, nil, timer.getTime() + 500)
  end
end


function RotorOps.spawnAttackHelos()
  RotorOps.triggerSpawn("Enemy Attack Helicopters", RotorOps.gameMsgs.attack_helos_prep, true)
end


function RotorOps.spawnAttackPlanes()
  RotorOps.triggerSpawn("Enemy Attack Planes", RotorOps.gameMsgs.attack_planes_prep, true)
end



function RotorOps.farpEstablished(index, trigger_zone)
  env.info("RotorOps FARP established at "..RotorOps.zones[index].name)
  if trigger_zone then
	if RotorOps.farp_pickups then
      ctld.activatePickupZone(trigger_zone)
	end
    if RotorOps.farp_smoke_color >= 0 and RotorOps.pickup_zone_smoke == 'none' then
	  trigger.action.smoke(trigger.misc.getZone(trigger_zone).point , RotorOps.farp_smoke_color)
	end
  end
  timer.scheduleFunction(function()RotorOps.gameMsg(RotorOps.gameMsgs.farp_established, index) end, {}, timer.getTime() + 15)
end


function RotorOps.getEnemyZones()
  local enemy_zones = {}

  if RotorOps.defending then

    for index, zone in pairs(RotorOps.zones) do
      if index <= RotorOps.active_zone_index then
        enemy_zones[#enemy_zones + 1] = zone.name
      end
    end

  else --not defending

    for index, zone in pairs(RotorOps.zones) do
      if index >= RotorOps.active_zone_index then
        enemy_zones[#enemy_zones + 1] = zone.name
      end
    end

  end
  debugMsg("Got enemy zones:")
  debugTable(enemy_zones)
  return enemy_zones
end


function RotorOps.spawnTranspHelos(troops, max_drops)
  local script_string = [[local this_grp = ...
    this_grp:getController():setOption(AI.Option.Air.id.REACTION_ON_THREAT , AI.Option.Air.val.REACTION_ON_THREAT.EVADE_FIRE)
    this_grp:getController():setOption(AI.Option.Air.id.FLARE_USING , AI.Option.Air.val.FLARE_USING.WHEN_FLYING_NEAR_ENEMIES)]]

  local setOptions = {
    id = 'WrappedAction',
    params = {
      action = {
        id = 'Script',
        params = {
          command = script_string,

        },
      },
    },
  }

  local dropTroops = {
    id = 'WrappedAction',
    params = {
      action = {
        id = 'Script',
        params = {
          command = 'RotorOps.deployTroops('..troops..', ...)',

        },
      },
    },
  }

  local group = Group.getByName("Enemy Transport Helicopters")
  local initial_point = group:getUnits()[1]:getPoint()
  local gp = mist.getGroupData("Enemy Transport Helicopters")
  --debugTable(gp)

  local drop_zones = RotorOps.getEnemyZones()
  if RotorOps.defending then
    drop_zones = {RotorOps.active_zone}
  end
  gp.route = {points = {}}
  gp.route.points[1] = mist.heli.buildWP(initial_point, initial, 'flyover', 0, 0, 'agl')
  gp.route.points[2] = mist.heli.buildWP(initial_point, initial, 'flyover', 100, 100, 'agl')
  gp.route.points[2].task = setOptions


  local failsafe = 100
  local drop_qty = 0
  while drop_qty < max_drops do

    for i = 1, 10 do  --pick some random points to evaluate
      local zone_name = drop_zones[math.random(#drop_zones)]
      local zone_point = trigger.misc.getZone(zone_name).point
      local drop_point = mist.getRandomPointInZone(zone_name, 300)

      if mist.isTerrainValid(drop_point, {'LAND', 'ROAD'}) == true then  --if the point looks like a good drop point
        gp.route.points[#gp.route.points + 1] = mist.heli.buildWP(zone_point, 'flyover', 100, 400, 'agl')
        gp.route.points[#gp.route.points + 1] = mist.heli.buildWP(zone_point, 'flyover', 20, 200, 'agl')
        gp.route.points[#gp.route.points + 1] = mist.heli.buildWP(drop_point, 'turning point', 10, 70, 'agl')
        gp.route.points[#gp.route.points].task = dropTroops
        drop_qty = drop_qty + 1
        break
      end

    end

    failsafe = failsafe - 1
    if failsafe < 1 then
      env.error("ROTOROPS: FINDING DROP POINTS TOOK TOO LONG")
      break
    end

  end
  gp.route.points[#gp.route.points + 1] = mist.heli.buildWP(initial_point, 'flyover', 100, 400, 'agl')
  gp.clone = true
  local new_group_data = mist.dynAdd(gp) --returns a mist group data table
  --debugTable(new_group_data)
--  local new_group = Group.getByName(new_group_data.groupName)
--  local grp_controller = new_group:getController() --controller for aircraft can be group or unit level
--  grp_controller:setOption(AI.Option.Air.id.REACTION_ON_THREAT , AI.Option.Air.val.REACTION_ON_THREAT.EVADE_FIRE)
--  grp_controller:setOption(AI.Option.Air.id.FLARE_USING , AI.Option.Air.val.FLARE_USING.WHEN_FLYING_NEAR_ENEMIES)

  env.info("ROTOROPS: TRANSPORT HELICOPTER DEPARTING WITH "..drop_qty.." PLANNED TROOP DROPS.")


end

function RotorOps.spawnCapToZone(_target_zone, _spawn_zone, coal)
  local target_zone = _target_zone
  if not target_zone then
    target_zone = RotorOps.getEnemyZones()[math.random(1, #RotorOps.getEnemyZones())]
  end
  local zone_point = trigger.misc.getZone(target_zone).point
  RotorOps.spawnCap(zone_point, _spawn_zone, coal)
end

RotorOps.fighter_red_source_string = "RED CAP"
RotorOps.fighter_blue_source_string = "BLUE CAP"
RotorOps.fighter_engagement_dist = 20

function RotorOps.spawnCap(destination_point, _spawn_zone, coal)
  local red_zone_string = "RED_CAP_SPAWN"
  local blue_zone_string = "BLUE_CAP_SPAWN"

  local coal_zone_string = nil
  if not coal or coal == 0 then
    return
  end
  if coal == 1 then
    coal_zone_string = red_zone_string
	source_group_string = RotorOps.fighter_red_source_string
  end
  if coal == 2 then
    coal_zone_string = blue_zone_string
	source_group_string = RotorOps.fighter_blue_source_string
  end

  local spawn_zone = _spawn_zone
  if not _spawn_zone then
	  local spawn_zones = {}
	  for zone, zoneobj in pairs(mist.DBs.zonesByName) do
		if string.find(zone, coal_zone_string) then
		  spawn_zones[#spawn_zones + 1] = zone
		  --env.info("found cap spawn zone: " .. zone)
		end
	  end
	  if #spawn_zones < 1 then
	    return
	  end
	  spawn_zone = spawn_zones[math.random(1, #spawn_zones)]
  end

  local spawn_point = mist.getRandomPointInZone(spawn_zone)


  local altitude = math.random(2000,6000)
  local speed = 300


  --pick a template group at random for the source
  fighter_groups = {} --stores group names of template groups
  for uName, uData in pairs(mist.DBs.groupsByName) do
    if string.find(uName, source_group_string) then
	  fighter_groups[#fighter_groups + 1] = uName
	end
  end

  if #fighter_groups < 1 then
	return
  end

  fighter_group_name = fighter_groups[math.random(1, #fighter_groups)]
  local group = Group.getByName(fighter_group_name)

  if not group then
    return
  end

  local gp = mist.getGroupData(fighter_group_name)
  --debugTable(gp)

  gp.units[1].alt = altitude
  gp.units[1].speed = speed
  gp.units[1].x = spawn_point.x
  gp.units[1].y = spawn_point.y
  gp.units[1].heading = mist.utils.getHeadingPoints(spawn_point, destination_point)



  local engage = {
      id = 'EngageTargets',
      params = {
        maxDist = RotorOps.fighter_engagement_dist,
        maxDistEnabled = true,
        targetTypes = { [1] = "Air" },
      }
    }

  local orbit = {
	  id = 'Orbit',
	  params = {
		pattern = 'Race-Track',
	  }
  }


  gp.route = {points = {}}
  -- gp.route[1] = mist.fixedWing.buildWP(random_airbase:getPoint())
  -- gp.route[1].type = "TakeOffParking"
  -- gp.route[1].action = "From Parking Area"
  -- gp.route[1].airdromeId = airbase_id

  gp.route.points[1] = mist.fixedWing.buildWP(spawn_point, 'turning point', speed, altitude, 'baro')

  gp.route.points[1].task = {}
  gp.route.points[1].task.id = 'ComboTask'
  gp.route.points[1].task.params = {}
  gp.route.points[1].task.params.tasks = {}
  gp.route.points[1].task.params.tasks[1] = {number = 1, id = 'ControlledTask', enabled = true, params = {task = engage}}
  gp.route.points[1].task.params.tasks[2] = {number = 2, id = 'ControlledTask', enabled = true, params = {task = orbit}}

  gp.route.points[2] = mist.fixedWing.buildWP(destination_point, 'turning point', speed, altitude, 'baro')

  gp.clone = true
  local new_group_data = mist.dynAdd(gp) --returns a mist group data table
  --debugTable(new_group_data)
  local new_group = Group.getByName(new_group_data.name)
  if new_group then
    env.info("RotorOps spawned CAP: "..new_group_data.name)
  else
    env.error("RotorOps tried to spawn CAP but something went wrong.")
	return
  end

  local grp_controller = new_group:getController() --controller for aircraft can be group or unit level
  grp_controller:setOption(AI.Option.Air.id.REACTION_ON_THREAT , AI.Option.Air.val.REACTION_ON_THREAT.EVADE_FIRE)
  grp_controller:setOption(AI.Option.Air.id.FLARE_USING , AI.Option.Air.val.FLARE_USING.WHEN_FLYING_NEAR_ENEMIES)
  grp_controller:setOption(AI.Option.Air.id.ROE , AI.Option.Air.val.ROE.OPEN_FIRE_WEAPON_FREE)
  grp_controller:setOption(AI.Option.Air.id.RADAR_USING, AI.Option.Air.val.RADAR_USING.FOR_SEARCH_IF_REQUIRED)

  return new_group_data.name

end



function RotorOps.deployFighters()
  local function spawn(dest_point, target_unit, coal)
    fighter = RotorOps.spawnCap(dest_point, nil, coal)

	if fighter and #fighters_by_detected_unitname < RotorOps.fighter_max_active then
	  _spawn_time = RotorOps.getTime()
	  fighters_by_detected_unitname[target_unit] = {
	    name = fighter,
		spawn_time = _spawn_time,
		rtb_time = math.random(_spawn_time + (15 * 60), _spawn_time + (25 * 60)),
		respawn_time = math.random(_spawn_time + (5 * 60), _spawn_time + (15 * 60)),
	  }
	  if ((RotorOps.getTime() - cooldown["e_fighters_inbound_msg"]) > 90) then
	    RotorOps.gameMsg(RotorOps.gameMsgs.enemy_fighters_inbound)
		cooldown["e_fighters_inbound_msg"] = RotorOps.getTime()
	  end
	  --debugTable(fighters_by_detected_unitname)
	  env.info(target_unit .. " was detected and we spawned a new fighter group: " .. fighter)
	end
  end

  local function rtb(group_name)

    local grp = Group.getByName(group_name)
	if grp then
	  local coal_airbases = coalition.getAirbases(grp:getCoalition())
	  --debugTable(coal_airbases)
	  random_airbase = coal_airbases[math.random(1, #coal_airbases)]

	  local airbase_pos = mist.utils.makeVec2(random_airbase:getPoint())
      local airbase_id = random_airbase:getID()
      local rtb = {
        id = 'Mission',
        params = {
          route = {
            points = {
              [1] = {
                alt = 2000,
                alt_type = "RADIO",
                speed = 300,
                x = airbase_pos.x,
                y = airbase_pos.y,
                aerodromeId = airbase_id,
                type = "Land",
                action = "Landing",
              }
            }
          }
        }
      }

	  grp:getController():setTask(rtb)
	  env.info(group_name .. " is RTB to ".. random_airbase:getName())
	end
  end


  --fighter respawning and rtb
  for target_name, fighter_group_data in pairs(fighters_by_detected_unitname) do
	local group = Group.getByName(fighter_group_data.name)
	if group then --if group alive
	  if fighter_group_data.rtb_time < RotorOps.getTime() then
	    env.info(fighter_group_data.name .. " is RTB. Removing from table.")
		rtb(fighter_group_data.name)
		fighters_by_detected_unitname[target_name] = nil
	  end
	else --if group dead
	  if fighter_group_data.respawn_time < RotorOps.getTime() then
	    env.info(fighter_group_data.name .. " has hit respawn_time limit. Removing from table to allow another group to spawn.")
	    fighters_by_detected_unitname[target_name] = nil
	  end
	end
  end




  for uName, uData in pairs(mist.DBs.unitsByName) do
    local str_index = string.find(uName, RotorOps.fighter_radar_unit_string)
    if str_index then
	  --trigger.action.outText("Found radar unit: " .. uData.unitName, 2)
      local radar_unit = Unit.getByName(uData.unitName)
	  local max_distance = RotorOps.fighter_max_detection_dist
	  local dist_str = string.sub(uName, str_index + #RotorOps.fighter_radar_unit_string + 1)
	  if #dist_str > 3 then
	    --env.info("RotorOps: Radar unit name has the max detection distance property:".. dist_str)
		local dist = tonumber(dist_str)
		if dist and dist > 0 then
		  max_distance = dist
		end
	  end

	  if radar_unit and radar_unit:getLife() > 0 then
	    --trigger.action.outText(uData.unitName .. " is searching for targets. life=" .. radar_unit:getLife(), 2)

		raw_detected_units = radar_unit:getController():getDetectedTargets(Controller.Detection.RADAR)
		if raw_detected_units then
			for i, target in pairs(raw_detected_units) do
			  --debugTable(target)
			  if target.object and target.object:getCategory() == Object.Category.UNIT then
				  local detected_unitname = target.object:getName()
				  local target_pos = target.object:getPosition().p
				  local target_distance = mist.utils.get2DDist(radar_unit:getPosition().p, target_pos)
				  local terrain_height = land.getHeight({x = target_pos.x, y = target_pos.z})
				  local target_agl = target_pos.y - terrain_height

				  --env.info(uData.unitName .. "detected " .. detected_unitname .. " at " .. target_distance .. " agl:" .. target_agl)

				  if target_distance <= max_distance and target_agl >= RotorOps.fighter_min_detection_alt then
					env.info('RotorOps: ' .. uData.unitName .. " has detected "..detected_unitname .. "at agl=" .. target_agl .. " distance=" .. target_distance)

					if tableHasKey(fighters_by_detected_unitname, detected_unitname) then
					  --trigger.action.outText(detected_unitname .. " already in table with " .. fighters_by_detected_unitname[detected_unitname], 2)

					else
					  spawn(target_pos, detected_unitname, radar_unit:getCoalition())
					end

				  end
			  end --end if target.object
			end --end of raw_detected targets loop
		end

	  end --end of radar_unit
	end
  end --end of all units by name loop

end

--- USEFUL PUBLIC 'LUA PREDICATE' FUNCTIONS FOR MISSION EDITOR TRIGGERS (don't forget that DCS lua predicate functions should 'return' these function calls)

--determine if any human players are above a defined ceiling above ground level. If 'above' parameter is false, function will return true if no players above ceiling
function RotorOps.predPlayerMaxAGL(max_agl, above)
  local players_above_ceiling = 0

  for uName, uData in pairs(mist.DBs.humansByName) do
    local player_unit = Unit.getByName(uData.unitName)
    if player_unit then
      local player_pos = player_unit:getPosition().p
      local terrain_height = land.getHeight({x = player_pos.x, y = player_pos.z})
      local player_agl = player_pos.y - terrain_height
      if player_agl > max_agl then
        players_above_ceiling = players_above_ceiling + 1
      end
    end
  end

  if players_above_ceiling > 0 then
    return above
  else
    return not above
  end

end

--determine if any human players are in a zone
function RotorOps.predPlayerInZone(zone_name)
  local players_in_zone = 0
  for uName, uData in pairs(mist.DBs.humansByName) do
    local player_unit = Unit.getByName(uData.unitName)
    if player_unit and RotorOps.isUnitInZone(player_unit, zone_name) then
      players_in_zone = players_in_zone + 1
    end
  end
  if players_in_zone > 0 then
    return true
  else
    return false
  end
end

--determine if enemy CAP is needed
function RotorOps.predSpawnRedCap()
  return true
end

--determine if attacking side aircraft are present near active zone
function RotorOps.predAirSupportNearActive()
  --loop through player aircraft and determine if any are within zone radius plus 5km
  local players_near_active = 0
  for uName, uData in pairs(mist.DBs.humansByName) do
    local player_unit = Unit.getByName(uData.unitName)
    if player_unit then
      local player_pos = player_unit:getPosition().p
      local active_zone = trigger.misc.getZone(RotorOps.active_zone)
      local zone_pos = active_zone.point
      local player_distance = mist.utils.get2DDist(player_pos, zone_pos)
      if player_distance < (active_zone.radius + 8000) then
        players_near_active = players_near_active + 1
      end
    end
  end
  if players_near_active > 0 then
    return true
  else
    return false
  end
end