diff --git a/CTLD.lua b/CTLD.lua new file mode 100644 index 0000000..ffd3de0 --- /dev/null +++ b/CTLD.lua @@ -0,0 +1,8226 @@ +env.info("ctldstart") +--[[ + Combat Troop and Logistics Drop + + Allows Huey, Mi-8 and C130 to transport troops internally and Helicopters to transport Logistic / Vehicle units to the field via sling-loads + without requiring external mods. + + Supports all of the original CTTS functionality such as AI auto troop load and unload as well as group spawning and preloading of troops into units. + + Supports deployment of Auto Lasing JTAC to the field + + See https://github.com/ciribob/DCS-CTLD for a user manual and the latest version + + Contributors: + - Steggles - https://github.com/Bob7heBuilder + - mvee - https://github.com/mvee + - jmontleon - https://github.com/jmontleon + - emilianomolina - https://github.com/emilianomolina + - davidp57 - https://github.com/veaf + - Queton1-1 - https://github.com/Queton1-1 + - Proxy404 - https://github.com/Proxy404 + - atcz - https://github.com/atcz + - marcos2221- https://github.com/marcos2221 + - FullGas1 - https://github.com/FullGas1 (i18n concept, FR and SP translations) + + Add [issues](https://github.com/ciribob/DCS-CTLD/issues) to the GitHub repository if you want to report a bug or suggest a new feature. + + Contact Zip [on Discord](https://discordapp.com/users/421317390807203850) or [on Github](https://github.com/davidp57) if you need help or want to have a friendly chat. + + Send beers (or kind messages) to Ciribob [on Discord](https://discordapp.com/users/204712384747536384), he's the reason we have CTLD ^^ + ]] + +if not ctld then -- should be defined first by CTLD-i18n.lua, but just in case it's an old mission, let's keep it here + --trigger.action.outText("\n\n** HEY MISSION-DESIGNER! **\n\nCTLD-i18n has not been loaded!\n\nMake sure CTLD-i18n is loaded\n*before* running this script!\n\nIt contains all the translations!\n", 10) + ctld = {} -- DONT REMOVE! +end + +--- Identifier. All output in DCS.log will start with this. +ctld.Id = "CTLD - " + +--- Version. +ctld.Version = "1.4.0" + +-- To add debugging messages to dcs.log, change the following log levels to `true`; `Debug` is less detailed than `Trace` +ctld.Debug = false +ctld.Trace = false + +ctld.dontInitialize = false -- if true, ctld.initialize() will not run; instead, you'll have to run it from your own code - it's useful when you want to override some functions/parameters before the initialization takes place + +-- *************************************************************** +-- *************** Internationalization (I18N) ******************* +-- *************************************************************** + +-- If you want to change the language replace "en" with the language you want to use + +--======== ENGLISH - the reference =========================================================================== +ctld.i18n_lang = "en" +--======== FRENCH - FRANCAIS ================================================================================= +--ctld.i18n_lang = "fr" +--====== SPANISH : ESPAÑOL ==================================================================================== +--ctld.i18n_lang = "es" +--====== Korean : 한국어 ==================================================================================== +--ctld.i18n_lang = "ko" + +if not ctld.i18n then -- should be defined first by CTLD-i18n.lua, but just in case it's an old mission, let's keep it here + ctld.i18n = {} -- DONT REMOVE! +end + +-- This is the default language +-- If a string is not found in the current language then it will default to this language +-- Note that no translation is provided for this language (obviously) but that we'll maintain this table to help the translators. +ctld.i18n["en"] = {} +ctld.i18n["en"].translation_version = "1.4" -- make sure that all the translations are compatible with this version of the english language texts +local lang="en";env.info(string.format("I - CTLD.i18n_translate: Loading %s language version %s", lang, tostring(ctld.i18n[lang].translation_version))) + +--- groups names +ctld.i18n["en"]["Standard Group"] = "" +ctld.i18n["en"]["Anti Air"] = "" +ctld.i18n["en"]["Anti Tank"] = "" +ctld.i18n["en"]["Mortar Squad"] = "" +ctld.i18n["en"]["JTAC Group"] = "" +ctld.i18n["en"]["Single JTAC"] = "" +ctld.i18n["en"]["2x - Standard Groups"] = "" +ctld.i18n["en"]["2x - Anti Air"] = "" +ctld.i18n["en"]["2x - Anti Tank"] = "" +ctld.i18n["en"]["2x - Standard Groups + 2x Mortar"] = "" +ctld.i18n["en"]["3x - Standard Groups"] = "" +ctld.i18n["en"]["3x - Anti Air"] = "" +ctld.i18n["en"]["3x - Anti Tank"] = "" +ctld.i18n["en"]["3x - Mortar Squad"] = "" +ctld.i18n["en"]["5x - Mortar Squad"] = "" +ctld.i18n["en"]["Mortar Squad Red"] = "" + +--- crates names +ctld.i18n["en"]["Humvee - MG"] = "" +ctld.i18n["en"]["Humvee - TOW"] = "" +ctld.i18n["en"]["Light Tank - MRAP"] = "" +ctld.i18n["en"]["Med Tank - LAV-25"] = "" +ctld.i18n["en"]["Heavy Tank - Abrams"] = "" +ctld.i18n["en"]["BTR-D"] = "" +ctld.i18n["en"]["BRDM-2"] = "" +ctld.i18n["en"]["Hummer - JTAC"] = "" +ctld.i18n["en"]["M-818 Ammo Truck"] = "" +ctld.i18n["en"]["M-978 Tanker"] = "" +ctld.i18n["en"]["SKP-11 - JTAC"] = "" +ctld.i18n["en"]["Ural-375 Ammo Truck"] = "" +ctld.i18n["en"]["KAMAZ Ammo Truck"] = "" +ctld.i18n["en"]["EWR Radar"] = "" +ctld.i18n["en"]["FOB Crate - Small"] = "" +ctld.i18n["en"]["MQ-9 Repear - JTAC"] = "" +ctld.i18n["en"]["RQ-1A Predator - JTAC"] = "" +ctld.i18n["en"]["MLRS"] = "" +ctld.i18n["en"]["SpGH DANA"] = "" +ctld.i18n["en"]["T155 Firtina"] = "" +ctld.i18n["en"]["Howitzer"] = "" +ctld.i18n["en"]["SPH 2S19 Msta"] = "" +ctld.i18n["en"]["M1097 Avenger"] = "" +ctld.i18n["en"]["M48 Chaparral"] = "" +ctld.i18n["en"]["Roland ADS"] = "" +ctld.i18n["en"]["Gepard AAA"] = "" +ctld.i18n["en"]["LPWS C-RAM"] = "" +ctld.i18n["en"]["9K33 Osa"] = "" +ctld.i18n["en"]["9P31 Strela-1"] = "" +ctld.i18n["en"]["9K35M Strela-10"] = "" +ctld.i18n["en"]["9K331 Tor"] = "" +ctld.i18n["en"]["2K22 Tunguska"] = "" +ctld.i18n["en"]["HAWK Launcher"] = "" +ctld.i18n["en"]["HAWK Search Radar"] = "" +ctld.i18n["en"]["HAWK Track Radar"] = "" +ctld.i18n["en"]["HAWK PCP"] = "" +ctld.i18n["en"]["HAWK CWAR"] = "" +ctld.i18n["en"]["HAWK Repair"] = "" +ctld.i18n["en"]["NASAMS Launcher 120C"] = "" +ctld.i18n["en"]["NASAMS Search/Track Radar"] = "" +ctld.i18n["en"]["NASAMS Command Post"] = "" +ctld.i18n["en"]["NASAMS Repair"] = "" +ctld.i18n["en"]["KUB Launcher"] = "" +ctld.i18n["en"]["KUB Radar"] = "" +ctld.i18n["en"]["KUB Repair"] = "" +ctld.i18n["en"]["BUK Launcher"] = "" +ctld.i18n["en"]["BUK Search Radar"] = "" +ctld.i18n["en"]["BUK CC Radar"] = "" +ctld.i18n["en"]["BUK Repair"] = "" +ctld.i18n["en"]["Patriot Launcher"] = "" +ctld.i18n["en"]["Patriot Radar"] = "" +ctld.i18n["en"]["Patriot ECS"] = "" +ctld.i18n["en"]["Patriot ICC"] = "" +ctld.i18n["en"]["Patriot EPP"] = "" +ctld.i18n["en"]["Patriot AMG (optional)"] = "" +ctld.i18n["en"]["Patriot Repair"] = "" +ctld.i18n["en"]["S-300 Grumble TEL C"] = "" +ctld.i18n["en"]["S-300 Grumble Flap Lid-A TR"] = "" +ctld.i18n["en"]["S-300 Grumble Clam Shell SR"] = "" +ctld.i18n["en"]["S-300 Grumble Big Bird SR"] = "" +ctld.i18n["en"]["S-300 Grumble C2"] = "" +ctld.i18n["en"]["S-300 Repair"] = "" +ctld.i18n["en"]["Humvee - TOW - All crates"] = "" +ctld.i18n["en"]["Light Tank - MRAP - All crates"] = "" +ctld.i18n["en"]["Med Tank - LAV-25 - All crates"] = "" +ctld.i18n["en"]["Heavy Tank - Abrams - All crates"] = "" +ctld.i18n["en"]["Hummer - JTAC - All crates"] = "" +ctld.i18n["en"]["M-818 Ammo Truck - All crates"] = "" +ctld.i18n["en"]["M-978 Tanker - All crates"] = "" +ctld.i18n["en"]["Ural-375 Ammo Truck - All crates"] = "" +ctld.i18n["en"]["EWR Radar - All crates"] = "" +ctld.i18n["en"]["MLRS - All crates"] = "" +ctld.i18n["en"]["SpGH DANA - All crates"] = "" +ctld.i18n["en"]["T155 Firtina - All crates"] = "" +ctld.i18n["en"]["Howitzer - All crates"] = "" +ctld.i18n["en"]["SPH 2S19 Msta - All crates"] = "" +ctld.i18n["en"]["M1097 Avenger - All crates"] = "" +ctld.i18n["en"]["M48 Chaparral - All crates"] = "" +ctld.i18n["en"]["Roland ADS - All crates"] = "" +ctld.i18n["en"]["Gepard AAA - All crates"] = "" +ctld.i18n["en"]["LPWS C-RAM - All crates"] = "" +ctld.i18n["en"]["9K33 Osa - All crates"] = "" +ctld.i18n["en"]["9P31 Strela-1 - All crates"] = "" +ctld.i18n["en"]["9K35M Strela-10 - All crates"] = "" +ctld.i18n["en"]["9K331 Tor - All crates"] = "" +ctld.i18n["en"]["2K22 Tunguska - All crates"] = "" +ctld.i18n["en"]["HAWK - All crates"] = "" +ctld.i18n["en"]["NASAMS - All crates"] = "" +ctld.i18n["en"]["KUB - All crates"] = "" +ctld.i18n["en"]["BUK - All crates"] = "" +ctld.i18n["en"]["Patriot - All crates"] = "" +ctld.i18n["en"]["Patriot - All crates"] = "" + +--- mission design error messages +ctld.i18n["en"]["CTLD.lua ERROR: Can't find trigger called %1"] = "" +ctld.i18n["en"]["CTLD.lua ERROR: Can't find zone called %1"] = "" +ctld.i18n["en"]["CTLD.lua ERROR: Can't find zone or ship called %1"] = "" +ctld.i18n["en"]["CTLD.lua ERROR: Can't find crate with weight %1"] = "" + +--- runtime messages +ctld.i18n["en"]["You are not close enough to friendly logistics to get a crate!"] = "" +ctld.i18n["en"]["No more JTAC Crates Left!"] = "" +ctld.i18n["en"]["Sorry you must wait %1 seconds before you can get another crate"] = "" +ctld.i18n["en"]["A %1 crate weighing %2 kg has been brought out and is at your %3 o'clock "] = "" +ctld.i18n["en"]["%1 fast-ropped troops from %2 into combat"] = "" +ctld.i18n["en"]["%1 dropped troops from %2 into combat"] = "" +ctld.i18n["en"]["%1 fast-ropped troops from %2 into %3"] = "" +ctld.i18n["en"]["%1 dropped troops from %2 into %3"] = "" +ctld.i18n["en"]["Too high or too fast to drop troops into combat! Hover below %1 feet or land."] = "" +ctld.i18n["en"]["%1 dropped vehicles from %2 into combat"] = "" +ctld.i18n["en"]["%1 loaded troops into %2"] = "" +ctld.i18n["en"]["%1 loaded %2 vehicles into %3"] = "" +ctld.i18n["en"]["%1 delivered a FOB Crate"] = "" +ctld.i18n["en"]["Delivered FOB Crate 60m at 6'oclock to you"] = "" +ctld.i18n["en"]["FOB Crate dropped back to base"] = "" +ctld.i18n["en"]["FOB Crate Loaded"] = "" +ctld.i18n["en"]["%1 loaded a FOB Crate ready for delivery!"] = "" +ctld.i18n["en"]["There are no friendly logistic units nearby to load a FOB crate from!"] = "" +ctld.i18n["en"]["You already have troops onboard."] = "" +ctld.i18n["en"]["You already have vehicles onboard."] = "" +ctld.i18n["en"]["This area has no more reinforcements available!"] = "" +ctld.i18n["en"]["You are not in a pickup zone and no one is nearby to extract"] = "" +ctld.i18n["en"]["You are not in a pickup zone"] = "" +ctld.i18n["en"]["No one to unload"] = "" +ctld.i18n["en"]["Dropped troops back to base"] = "" +ctld.i18n["en"]["Dropped vehicles back to base"] = "" +ctld.i18n["en"]["You already have troops onboard."] = "" +ctld.i18n["en"]["You already have vehicles onboard."] = "" +ctld.i18n["en"]["Sorry - The group of %1 is too large to fit. \n\nLimit is %2 for %3"] = "" +ctld.i18n["en"]["%1 extracted troops in %2 from combat"] = "" +ctld.i18n["en"]["No extractable troops nearby!"] = "" +ctld.i18n["en"]["%1 extracted vehicles in %2 from combat"] = "" +ctld.i18n["en"]["No extractable vehicles nearby!"] = "" +ctld.i18n["en"]["%1 troops onboard (%2 kg)\n"] = "" +ctld.i18n["en"]["%1 vehicles onboard (%2)\n"] = "" +ctld.i18n["en"]["1 FOB Crate oboard (%1 kg)\n"] = "" +ctld.i18n["en"]["%1 crate onboard (%2 kg)\n"] = "" +ctld.i18n["en"]["Total weight of cargo : %1 kg\n"] = "" +ctld.i18n["en"]["No cargo."] = "" +ctld.i18n["en"]["Hovering above %1 crate. \n\nHold hover for %2 seconds! \n\nIf the countdown stops you're too far away!"] = "" +ctld.i18n["en"]["Loaded %1 crate!"] = "" +ctld.i18n["en"]["Too low to hook %1 crate.\n\nHold hover for %2 seconds"] = "" +ctld.i18n["en"]["Too high to hook %1 crate.\n\nHold hover for %2 seconds"] = "" +ctld.i18n["en"]["You must land before you can load a crate!"] = "" +ctld.i18n["en"]["No Crates within 50m to load!"] = "" +ctld.i18n["en"]["Maximum number of crates are on board!"] = "" +ctld.i18n["en"]["%1\n%2 crate - kg %3 - %4 m - %5 o'clock"] = "" +ctld.i18n["en"]["FOB Crate - %1 m - %2 o'clock\n"] = "" +ctld.i18n["en"]["No Nearby Crates"] = "" +ctld.i18n["en"]["Nearby Crates:\n%1"] = "" +ctld.i18n["en"]["Nearby FOB Crates (Not Slingloadable):\n%1"] = "" +ctld.i18n["en"]["FOB Positions:"] = "" +ctld.i18n["en"]["%1\nFOB @ %2"] = "" +ctld.i18n["en"]["Sorry, there are no active FOBs!"] = "" +ctld.i18n["en"]["You can't unpack that here! Take it to where it's needed!"] = "" +ctld.i18n["en"]["Sorry you must move this crate before you unpack it!"] = "" +ctld.i18n["en"]["%1 successfully deployed %2 to the field"] = "" +ctld.i18n["en"]["No friendly crates close enough to unpack, or crate too close to aircraft."] = "" +ctld.i18n["en"]["Finished building FOB! Crates and Troops can now be picked up."] = "" +ctld.i18n["en"]["Finished building FOB! Crates can now be picked up."] = "" +ctld.i18n["en"]["%1 started building FOB using %2 FOB crates, it will be finished in %3 seconds.\nPosition marked with smoke."] = "" +ctld.i18n["en"]["Cannot build FOB!\n\nIt requires %1 Large FOB crates ( 3 small FOB crates equal 1 large FOB Crate) and there are the equivalent of %2 large FOB crates nearby\n\nOr the crates are not within 750m of each other"] = "" +ctld.i18n["en"]["You are not currently transporting any crates. \n\nTo Pickup a crate, hover for %1 seconds above the crate or land and use F10 Crate Commands."] = "" +ctld.i18n["en"]["You are not currently transporting any crates. \n\nTo Pickup a crate, hover for %1 seconds above the crate."] = "" +ctld.i18n["en"]["You are not currently transporting any crates. \n\nTo Pickup a crate, land and use F10 Crate Commands to load one."] = "" +ctld.i18n["en"]["%1 crate has been safely unhooked and is at your %2 o'clock"] = "" +ctld.i18n["en"]["%1 crate has been safely dropped below you"] = "" +ctld.i18n["en"]["You were too high! The crate has been destroyed"] = "" +ctld.i18n["en"]["Radio Beacons:\n%1"] = "" +ctld.i18n["en"]["No Active Radio Beacons"] = "" +ctld.i18n["en"]["%1 deployed a Radio Beacon.\n\n%2"] = "" +ctld.i18n["en"]["You need to land before you can deploy a Radio Beacon!"] = "" +ctld.i18n["en"]["%1 removed a Radio Beacon.\n\n%2"] = "" +ctld.i18n["en"]["No Radio Beacons within 500m."] = "" +ctld.i18n["en"]["You need to land before remove a Radio Beacon"] = "" +ctld.i18n["en"]["%1 successfully rearmed a full %2 in the field"] = "" +ctld.i18n["en"]["Missing %1\n"] = "" +ctld.i18n["en"]["Out of parts for AA Systems. Current limit is %1\n"] = "" +ctld.i18n["en"]["Cannot build %1\n%2\n\nOr the crates are not close enough together"] = "" +ctld.i18n["en"]["%1 successfully deployed a full %2 in the field. \n\nAA Active System limit is: %3\nActive: %4"] = "" +ctld.i18n["en"]["%1 successfully repaired a full %2 in the field."] = "" +ctld.i18n["en"]["Cannot repair %1. No damaged %2 within 300m"] = "" +ctld.i18n["en"]["%1 successfully deployed %2 to the field using %3 crates."] = "" +ctld.i18n["en"]["Cannot build %1!\n\nIt requires %2 crates and there are %3 \n\nOr the crates are not within 300m of each other"] = "" +ctld.i18n["en"]["%1 dropped %2 smoke."] = "" + +--- JTAC messages +ctld.i18n["en"]["JTAC Group %1 KIA!"] = "" +ctld.i18n["en"]["%1, selected target reacquired, %2"] = "" +ctld.i18n["en"][". CODE: %1. POSITION: %2"] = "" +ctld.i18n["en"]["new target, "] = "" +ctld.i18n["en"]["standing by on %1"] = "" +ctld.i18n["en"]["lasing %1"] = "" +ctld.i18n["en"][", temporarily %1"] = "" +ctld.i18n["en"]["target lost"] = "" +ctld.i18n["en"]["target destroyed"] = "" +ctld.i18n["en"][", selected %1"] = "" +ctld.i18n["en"]["%1 %2 target lost."] = "" +ctld.i18n["en"]["%1 %2 target destroyed."] = "" +ctld.i18n["en"]["JTAC STATUS: \n\n"] = "" +ctld.i18n["en"][", available on %1 %2,"] = "" +ctld.i18n["en"]["UNKNOWN"] = "" +ctld.i18n["en"][" targeting "] = "" +ctld.i18n["en"][" targeting selected unit "] = "" +ctld.i18n["en"][" attempting to find selected unit, temporarily targeting "] = "" +ctld.i18n["en"]["(Laser OFF) "] = "" +ctld.i18n["en"]["Visual On: "] = "" +ctld.i18n["en"][" searching for targets %1\n"] = "" +ctld.i18n["en"]["No Active JTACs"] = "" +ctld.i18n["en"][", targeting selected unit, %1"] = "" +ctld.i18n["en"][". CODE: %1. POSITION: %2"] = "" +ctld.i18n["en"][", target selection reset."] = "" +ctld.i18n["en"]["%1, laser and smokes enabled"] = "" +ctld.i18n["en"]["%1, laser and smokes disabled"] = "" +ctld.i18n["en"]["%1, wind and target speed laser spot compensations enabled"] = "" +ctld.i18n["en"]["%1, wind and target speed laser spot compensations disabled"] = "" +ctld.i18n["en"]["%1, WHITE smoke deployed near target"] = "" + +--- F10 menu messages +ctld.i18n["en"]["Actions"] = "" +ctld.i18n["en"]["Troop Transport"] = "" +ctld.i18n["en"]["Unload / Extract Troops"] = "" +ctld.i18n["en"]["Next page"] = "" +ctld.i18n["en"]["Load "] = "" +ctld.i18n["en"]["Vehicle / FOB Transport"] = "" +ctld.i18n["en"]["Vehicle / FOB Crates / Drone"] = "" +ctld.i18n["en"]["Unload Vehicles"] = "" +ctld.i18n["en"]["Load / Extract Vehicles"] = "" +ctld.i18n["en"]["Load / Unload FOB Crate"] = "" +ctld.i18n["en"]["CTLD Commands"] = "" +ctld.i18n["en"]["CTLD"] = "" +ctld.i18n["en"]["Check Cargo"] = "" +ctld.i18n["en"]["Load Nearby Crate(s)"] = "" +ctld.i18n["en"]["Unpack Any Crate"] = "" +ctld.i18n["en"]["Drop Crate(s)"] = "" +ctld.i18n["en"]["List Nearby Crates"] = "" +ctld.i18n["en"]["List FOBs"] = "" +ctld.i18n["en"]["List Beacons"] = "" +ctld.i18n["en"]["List Radio Beacons"] = "" +ctld.i18n["en"]["Smoke Markers"] = "" +ctld.i18n["en"]["Drop Red Smoke"] = "" +ctld.i18n["en"]["Drop Blue Smoke"] = "" +ctld.i18n["en"]["Drop Orange Smoke"] = "" +ctld.i18n["en"]["Drop Green Smoke"] = "" +ctld.i18n["en"]["Drop Beacon"] = "" +ctld.i18n["en"]["Radio Beacons"] = "" +ctld.i18n["en"]["Remove Closest Beacon"] = "" +ctld.i18n["en"]["JTAC Status"] = "" +ctld.i18n["en"]["DISABLE "] = "" +ctld.i18n["en"]["ENABLE "] = "" +ctld.i18n["en"]["REQUEST "] = "" +ctld.i18n["en"]["Reset TGT Selection"] = "" +-- F10 RECON menus +ctld.i18n["en"]["RECON"] = "" +ctld.i18n["en"]["Show targets in LOS (refresh)"] = "" +ctld.i18n["en"]["Hide targets in LOS"] = "" +ctld.i18n["en"]["START autoRefresh targets in LOS"] = "" +ctld.i18n["en"]["STOP autoRefresh targets in LOS"] = "" + +--- Translates a string (text) with parameters (parameters) to the language defined in ctld.i18n_lang +---@param text string The text to translate, with the parameters as %1, %2, etc. (all strings!!!!) +---@param ... any (list) The parameters to replace in the text, in order (all paremeters will be converted to string) +---@return string the translated and formatted text +function ctld.i18n_translate(text, ...) + local _text + + if not ctld.i18n[ctld.i18n_lang] then + env.info(string.format(" E - CTLD.i18n_translate: Language %s not found, defaulting to 'en'", tostring(ctld.i18n_lang))) + _text = ctld.i18n["en"][text] + else + _text = ctld.i18n[ctld.i18n_lang][text] + end + + -- default to english + if _text == nil then + _text = ctld.i18n["en"][text] + end + + -- default to the provided text + if _text == nil or _text == "" then + _text = text + end + + if arg and arg.n and arg.n > 0 then + local _args = {} + for i=1,arg.n do + _args[i] = tostring(arg[i]) or "" + end + for i = 1, #_args do + _text = string.gsub(_text, "%%" .. i, _args[i]) + end + end + + return _text +end + +-- ************************************************************************ +-- ********************* USER CONFIGURATION ****************************** +-- ************************************************************************ +ctld.staticBugWorkaround = false -- DCS had a bug where destroying statics would cause a crash. If this happens again, set this to TRUE + +ctld.disableAllSmoke = false -- if true, all smoke is diabled at pickup and drop off zones regardless of settings below. Leave false to respect settings below + +-- Allow units to CTLD by aircraft type and not by pilot name - this is done everytime a player enters a new unit +ctld.addPlayerAircraftByType = true + +ctld.hoverPickup = true -- if set to false you can load crates with the F10 menu instead of hovering... Only if not using real crates! +ctld.loadCrateFromMenu = true -- if set to true, you can load crates with the F10 menu OR hovering, in case of using choppers and planes for example. + +ctld.enableCrates = true -- if false, Helis will not be able to spawn or unpack crates so will be normal CTTS +ctld.enableAllCrates = true -- if false, the "all crates" menu items will not be displayed +ctld.slingLoad = false -- if false, crates can be used WITHOUT slingloading, by hovering above the crate, simulating slingloading but not the weight... +-- There are some bug with Sling-loading that can cause crashes, if these occur set slingLoad to false +-- to use the other method. +-- Set staticBugFix to FALSE if use set ctld.slingLoad to TRUE +ctld.enableSmokeDrop = true -- if false, helis and c-130 will not be able to drop smoke +ctld.maxExtractDistance = 125 -- max distance from vehicle to troops to allow a group extraction +ctld.maximumDistanceLogistic = 250 -- max distance from vehicle to logistics to allow a loading or spawning operation +ctld.maximumSearchDistance = 0 -- 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.minimumDeployDistance = 500 -- minimum distance from a friendly pickup zone where you can deploy a crate +ctld.numberOfTroops = 10 -- default number of troops to load on a transport heli or C-130 + -- also works as maximum size of group that'll fit into a helicopter unless overridden +ctld.enableFastRopeInsertion = true -- allows you to drop troops by fast rope +ctld.fastRopeMaximumHeight = 20.00 -- in meters which is 60 ft max fast rope (not rappell) safe height +ctld.vehiclesForTransportRED = { "BRDM-2", "BTR_D" } -- vehicles to load onto Il-76 - Alternatives {"Strela-1 9P31","BMP-1"} +ctld.vehiclesForTransportBLUE = { "M1045 HMMWV TOW", "M1043 HMMWV Armament" } -- vehicles to load onto c130 - Alternatives {"M1128 Stryker MGS","M1097 Avenger"} +ctld.vehiclesWeight = { + ["BRDM-2"] = 7000, + ["BTR_D"] = 8000, + ["M1045 HMMWV TOW"] = 3220, + ["M1043 HMMWV Armament"] = 2500 +} + +ctld.spawnRPGWithCoalition = false --spawns a friendly RPG unit with Coalition forces +ctld.spawnStinger = false -- spawns a stinger / igla soldier with a group of 6 or more soldiers! +ctld.enabledFOBBuilding = true -- if true, you can load a crate INTO a C-130 than when unpacked creates a Forward Operating Base (FOB) which is a new place to spawn (crates) and carry crates from + -- In future i'd like it to be a FARP but so far that seems impossible... + -- You can also enable troop Pickup at FOBS +ctld.cratesRequiredForFOB = 1 -- The amount of crates required to build a FOB. Once built, helis can spawn crates at this outpost to be carried and deployed in another area. +-- The large crates can only be loaded and dropped by large aircraft, like the C-130 and listed in ctld.vehicleTransportEnabled +-- Small FOB crates can be moved by helicopter. The FOB will require ctld.cratesRequiredForFOB larges crates and small crates are 1/3 of a large fob crate +-- To build the FOB entirely out of small crates you will need ctld.cratesRequiredForFOB * 3 + +ctld.troopPickupAtFOB = true -- if true, troops can also be picked up at a created FOB +ctld.buildTimeFOB = 90 --time in seconds for the FOB to be built +ctld.crateWaitTime = 0 -- time in seconds to wait before you can spawn another crate +ctld.forceCrateToBeMoved = false -- a crate must be picked up at least once and moved before it can be unpacked. Helps to reduce crate spam +ctld.radioSound = "beacon.ogg" -- the name of the sound file to use for the FOB radio beacons. If this isnt added to the mission BEACONS WONT WORK! +ctld.radioSoundFC3 = "beaconsilent.ogg" -- name of the second silent radio file, used so FC3 aircraft dont hear ALL the beacon noises... :) +ctld.deployedBeaconBattery = 60 -- the battery on deployed beacons will last for this number minutes before needing to be re-deployed +ctld.enabledRadioBeaconDrop = true -- if its set to false then beacons cannot be dropped by units +ctld.allowRandomAiTeamPickups = false -- Allows the AI to randomize the loading of infantry teams (specified below) at pickup zones + +-- Simulated Sling load configuration +ctld.minimumHoverHeight = 7.5 -- Lowest allowable height for crate hover +ctld.maximumHoverHeight = 15.0 -- Highest allowable height for crate hover +ctld.maxDistanceFromCrate = 5.5 -- Maximum distance from from crate for hover +ctld.hoverTime = 3 -- Time to hold hover above a crate for loading in seconds + +-- end of Simulated Sling load configuration + +-- ***************** AA SYSTEM CONFIG ***************** +ctld.aaLaunchers = 3 -- controls how many launchers to add to the AA systems when its spawned if no amount is specified in the template. +-- Sets a limit on the number of active AA systems that can be built for RED. +-- A system is counted as Active if its fully functional and has all parts +-- If a system is partially destroyed, it no longer counts towards the total +-- When this limit is hit, a player will still be able to get crates for an AA system, just unable +-- to unpack them + +ctld.AASystemLimitRED = 5 -- Red side limit +ctld.AASystemLimitBLUE = 5 -- Blue side limit + +-- Allows players to create systems using as many crates as they like +-- Example : an amount X of patriot launcher crates allows for Y launchers to be deployed, if a player brings 2*X+Z crates (Z being lower then X), then deploys the patriot site, 2*Y launchers will be in the group and Z launcher crate will be left over + +ctld.AASystemCrateStacking = false +--END AA SYSTEM CONFIG ------------------------------------ + +-- ***************** JTAC CONFIGURATION ***************** +ctld.JTAC_LIMIT_RED = 10 -- max number of JTAC Crates for the RED Side +ctld.JTAC_LIMIT_BLUE = 10 -- max number of JTAC Crates for the BLUE Side +ctld.JTAC_dropEnabled = true -- allow JTAC Crate spawn from F10 menu +ctld.JTAC_maxDistance = 20000 -- How far a JTAC can "see" in meters (with Line of Sight) +ctld.JTAC_smokeOn_RED = true -- enables marking of target with smoke for RED forces +ctld.JTAC_smokeOn_BLUE = true -- enables marking of target with smoke for BLUE forces +ctld.JTAC_smokeColour_RED = 4 -- RED side smoke colour -- Green = 0 , Red = 1, White = 2, Orange = 3, Blue = 4 +ctld.JTAC_smokeColour_BLUE = 1 -- BLUE side smoke colour -- Green = 0 , Red = 1, White = 2, Orange = 3, Blue = 4 +ctld.JTAC_smokeMarginOfError = 0 -- error that the JTAC is allowed to make when popping a smoke (in meters) +ctld.JTAC_smokeOffset_x = 0.0 -- distance in the X direction from target to smoke (meters) +ctld.JTAC_smokeOffset_y = 10.0 -- distance in the Y direction from target to smoke (meters) +ctld.JTAC_smokeOffset_z = 0.0 -- distance in the z direction from target to smoke (meters) +ctld.JTAC_jtacStatusF10 = true -- enables F10 JTAC Status menu +ctld.JTAC_location = true -- shows location of target in JTAC message +ctld.location_DMS = false -- shows coordinates as Degrees Minutes Seconds instead of Degrees Decimal minutes +ctld.JTAC_lock = "all" -- "vehicle" OR "troop" OR "all" forces JTAC to only lock vehicles or troops or all ground units +ctld.JTAC_allowStandbyMode = true -- if true, allow players to toggle lasing on/off +ctld.JTAC_laseSpotCorrections = true -- if true, each JTAC will have a special option (toggle on/off) available in it's menu to attempt to lead the target, taking into account current wind conditions and the speed of the target (particularily useful against moving heavy armor) +ctld.JTAC_allowSmokeRequest = true -- if true, allow players to request a smoke on target (temporary) +ctld.JTAC_allow9Line = true -- if true, allow players to ask for a 9Line (individual) for a specific JTAC's target + +-- ***************** Pickup, dropoff and waypoint zones ***************** + +-- Available colors (anything else like "none" disables smoke): "green", "red", "white", "orange", "blue", "none", +-- Use any of the predefined names or set your own ones +-- You can add number as a third option to limit the number of soldier or vehicle groups that can be loaded from a zone. +-- Dropping back a group at a limited zone will add one more to the limit +-- If a zone isn't ACTIVE then you can't pickup from that zone until the zone is activated by ctld.activatePickupZone +-- using the Mission editor +-- You can pickup from a SHIP by adding the SHIP UNIT NAME instead of a zone name +-- Side - Controls which side can load/unload troops at the zone +-- Flag Number - Optional last field. If set the current number of groups remaining can be obtained from the flag value +--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 = { + { "Luostari Supply", "blue", -1, "yes", 0 }, + { "Ivalo Supply", "blue", -1, "yes", 0 }, + { "London FARP Supply", "blue", -1, "yes", 0 }, + { "Dallas FARP Supply", "blue", -1, "yes", 0 }, + { "Paris FARP Supply", "blue", -1, "yes", 0 }, + { "Kilpyavr Supply", "none", -1, "yes", 0 }, + { "Severomorsk-1 Supply", "none", -1, "yes", 0 }, + { "Severomorsk-3 Supply", "none", -1, "yes", 0 }, + { "Murmansk Supply", "none", -1, "yes", 0 }, + { "Olenya Supply", "none", -1, "yes", 0 }, + { "Monchegorsk Supply", "none", -1, "yes", 0 }, + { "Afrikanda Supply", "none", -1, "yes", 0 }, + { "Koshka Supply", "none", -1, "yes", 0 }, + { "Alakurtti Supply", "blue", -1, "yes", 0}, + + + { "CVN-72 Abraham Lincoln", "none", -1, "yes", 0, 1001 }, -- instead of a Zone Name you can also use the UNIT NAME of a ship +} + +-- dropOffZones = {"name","smoke colour",0,side 1 = Red or 2 = Blue or 0 = Both sides} +ctld.dropOffZones = { + { "dropzone1", "none", 2 }, + { "dropzone2", "blue", 2 }, + { "dropzone3", "orange", 2 }, + { "dropzone4", "none", 2 }, + { "dropzone5", "none", 1 }, + { "dropzone6", "none", 1 }, + { "dropzone7", "none", 1 }, + { "dropzone8", "none", 1 }, + { "dropzone9", "none", 1 }, + { "dropzone10", "none", 1 }, +} + +--wpZones = { "Zone name", "smoke color", "ACTIVE (yes/no)", "side (0 = Both sides / 1 = Red / 2 = Blue )", } +ctld.wpZones = { + { "wpzone1", "green","yes", 2 }, + { "wpzone2", "blue","yes", 2 }, + { "wpzone3", "orange","yes", 2 }, + { "wpzone4", "none","yes", 2 }, + { "wpzone5", "none","yes", 2 }, + { "wpzone6", "none","yes", 1 }, + { "wpzone7", "none","yes", 1 }, + { "wpzone8", "none","yes", 1 }, + { "wpzone9", "none","yes", 1 }, + { "wpzone10", "none","no", 0 }, -- Both sides as its set to 0 +} + +-- ******************** Transports names ********************** +-- If ctld.addPlayerAircraftByType = True, comment or uncomment lines to allow aircraft's type carry CTLD +ctld.aircraftTypeTable = { + --%%%%% MODS %%%%% + --"Bronco-OV-10A", + --"Hercules", + --"SK-60", + "UH-60L", + --"T-45", + + --%%%%% CHOPPERS %%%%% + --"Ka-50", + --"Ka-50_3", + "Mi-8MT", + "Mi-24P", + --"SA342L", + --"SA342M", + --"SA342Mistral", + --"SA342Minigun", + "UH-1H", + "CH-47Fbl1", + + --%%%%% AIRCRAFTS %%%%% + --"C-101EB", + --"C-101CC", + --"Christen Eagle II", + --"L-39C", + --"L-39ZA", + --"MB-339A", + --"MB-339APAN", + --"Mirage-F1B", + --"Mirage-F1BD", + --"Mirage-F1BE", + --"Mirage-F1BQ", + --"Mirage-F1DDA", + --"Su-25T", + --"Yak-52", + + --%%%%% WARBIRDS %%%%% + --"Bf-109K-4", + --"Fw 190A8", + --"FW-190D9", + --"I-16", + --"MosquitoFBMkVI", + --"P-47D-30", + --"P-47D-40", + --"P-51D", + --"P-51D-30-NA", + --"SpitfireLFMkIX", + --"SpitfireLFMkIXCW", + --"TF-51D", +} + +-- Use any of the predefined names or set your own ones +ctld.transportPilotNames = { + -- BLUE + "Mi-8 NELLIS-1", + "Mi-8 NELLIS-2", + "Mi-8 NELLIS-3 AFAC", + "Mi-24P NELLIS-1", + "Mi-24P NELLIS-2", + "Mi-24P NELLIS-3 AFAC", + "UH-1H NELLIS-1", + "UH-1H NELLIS-2", + "UH-1H NELLIS-3 AFAC", + "CH-47F NELLIS-1", + "CH-47F NELLIS-2", + "CH-47F NELLIS-3", + "CH-47F NELLIS-4 AFAC", +} + +-- *************** Optional Extractable GROUPS ***************** + +-- Use any of the predefined names or set your own ones +ctld.extractableGroups = { + "extract1", + "extract2", + "extract3", + "extract4", + "extract5", + "extract6", + "extract7", + "extract8", + "extract9", + "extract10", +} + +-- ************** Logistics UNITS FOR CRATE SPAWNING ****************** + +-- Use any of the predefined names or set your own ones +-- When a logistic unit is destroyed, you will no longer be able to spawn crates +ctld.logisticUnits = { + "Luostari Supply", + "Ivalo Supply", + "London FARP Supply", + "Paris FARP Supply", + "Dallas FARP Supply", + "Kilpyavr Supply", + "Severomorsk-1 Supply", + "Severomorsk-3 Supply", + "Koshka Supply", + "Murmansk Supply", + "Olenya Supply", + "Monchegorsk Supply", + "Afrikanda Supply", + "Alakurtti Supply" + +} + +-- ************** UNITS ABLE TO TRANSPORT VEHICLES ****************** +-- Add the model name of the unit that you want to be able to transport and deploy vehicles +-- units db has all the names or you can extract a mission.miz file by making it a zip and looking +-- in the contained mission file +ctld.vehicleTransportEnabled = { + "76MD", -- the il-76 mod doesnt use a normal - sign so il-76md wont match... !!!! GRR + "Hercules", +} + +-- ************** Units able to use DCS dynamic cargo system ****************** +-- DCS (version) added the ability to load and unload cargo from aircraft. +-- Units listed here will spawn a cargo static that can be loaded with the standard DCS cargo system +-- We will also use this to make modifications to the menu and other checks and messages +ctld.dynamicCargoUnits = { + --"CH-47Fbl1", +} + +-- ************** Maximum Units SETUP for UNITS ****************** +-- Put the name of the Unit you want to limit group sizes too +-- i.e +--["UH-1H"] = 16, +-- +-- Will limit UH1 to only transport groups with a size 10 or less +-- Make sure the unit name is exactly right or it wont work + +ctld.unitLoadLimits = { + -- Remove the -- below to turn on options + -- ["SA342Mistral"] = 4, + -- ["SA342L"] = 4, + -- ["SA342M"] = 4, + + --%%%%% MODS %%%%% + --["Bronco-OV-10A"] = 4, + ["Hercules"] = 30, + --["SK-60"] = 1, + ["UH-60L"] = 16, + --["T-45"] = 1, + + --%%%%% CHOPPERS %%%%% + ["Mi-8MT"] = 16, + ["Mi-24P"] = 16, + --["SA342L"] = 4, + --["SA342M"] = 4, + --["SA342Mistral"] = 4, + --["SA342Minigun"] = 3, + ["UH-1H"] = 16, + ["CH-47Fbl1"] = 33, + + --%%%%% AIRCRAFTS %%%%% + --["C-101EB"] = 1, + --["C-101CC"] = 1, + --["Christen Eagle II"] = 1, + --["L-39C"] = 1, + --["L-39ZA"] = 1, + --["MB-339A"] = 1, + --["MB-339APAN"] = 1, + --["Mirage-F1B"] = 1, + --["Mirage-F1BD"] = 1, + --["Mirage-F1BE"] = 1, + --["Mirage-F1BQ"] = 1, + --["Mirage-F1DDA"] = 1, + --["Su-25T"] = 1, + --["Yak-52"] = 1, + + --%%%%% WARBIRDS %%%%% + --["Bf-109K-4"] = 1, + --["Fw 190A8"] = 1, + --["FW-190D9"] = 1, + --["I-16"] = 1, + --["MosquitoFBMkVI"] = 1, + --["P-47D-30"] = 1, + --["P-47D-40"] = 1, + --["P-51D"] = 1, + --["P-51D-30-NA"] = 1, + --["SpitfireLFMkIX"] = 1, + --["SpitfireLFMkIXCW"] = 1, + --["TF-51D"] = 1, +} + +-- Put the name of the Unit you want to enable loading multiple crates +ctld.internalCargoLimits = { + + -- Remove the -- below to turn on options + --["Mi-8MT"] = 1, + --["CH-47Fbl1"] = 1, +} + + +-- ************** Allowable actions for UNIT TYPES ****************** +-- Put the name of the Unit you want to limit actions for +-- NOTE - the unit must've been listed in the transportPilotNames list above +-- This can be used in conjunction with the options above for group sizes +-- By default you can load both crates and troops unless overriden below +-- i.e +-- ["UH-1H"] = {crates=true, troops=false}, +-- +-- Will limit UH1 to only transport CRATES but NOT TROOPS +-- +-- ["SA342Mistral"] = {crates=fales, troops=true}, +-- Will allow Mistral Gazelle to only transport crates, not troops + +ctld.unitActions = { + + -- Remove the -- below to turn on options + -- ["SA342Mistral"] = {crates=true, troops=true}, + -- ["SA342L"] = {crates=false, troops=true}, + -- ["SA342M"] = {crates=false, troops=true}, + + --%%%%% MODS %%%%% + --["Bronco-OV-10A"] = {crates=true, troops=true}, + ["Hercules"] = {crates=true, troops=true}, + ["SK-60"] = {crates=true, troops=true}, + ["UH-60L"] = {crates=true, troops=true}, + --["T-45"] = {crates=true, troops=true}, + + --%%%%% CHOPPERS %%%%% + --["Ka-50"] = {crates=true, troops=false}, + --["Ka-50_3"] = {crates=true, troops=false}, + ["Mi-8MT"] = {crates=true, troops=true}, + ["Mi-24P"] = {crates=true, troops=true}, + --["SA342L"] = {crates=false, troops=true}, + --["SA342M"] = {crates=false, troops=true}, + --["SA342Mistral"] = {crates=false, troops=true}, + --["SA342Minigun"] = {crates=false, troops=true}, + ["UH-1H"] = {crates=true, troops=true}, + ["CH-47Fbl1"] = {crates=true, troops=true}, + + --%%%%% AIRCRAFTS %%%%% + --["C-101EB"] = {crates=true, troops=true}, + --["C-101CC"] = {crates=true, troops=true}, + --["Christen Eagle II"] = {crates=true, troops=true}, + --["L-39C"] = {crates=true, troops=true}, + --["L-39ZA"] = {crates=true, troops=true}, + --["MB-339A"] = {crates=true, troops=true}, + --["MB-339APAN"] = {crates=true, troops=true}, + --["Mirage-F1B"] = {crates=true, troops=true}, + --["Mirage-F1BD"] = {crates=true, troops=true}, + --["Mirage-F1BE"] = {crates=true, troops=true}, + --["Mirage-F1BQ"] = {crates=true, troops=true}, + --["Mirage-F1DDA"] = {crates=true, troops=true}, + --["Su-25T"]= {crates=true, troops=false}, + --["Yak-52"] = {crates=true, troops=true}, + + --%%%%% WARBIRDS %%%%% + --["Bf-109K-4"] = {crates=true, troops=false}, + --["Fw 190A8"] = {crates=true, troops=false}, + --["FW-190D9"] = {crates=true, troops=false}, + --["I-16"] = {crates=true, troops=false}, + --["MosquitoFBMkVI"] = {crates=true, troops=true}, + --["P-47D-30"] = {crates=true, troops=false}, + --["P-47D-40"] = {crates=true, troops=false}, + --["P-51D"] = {crates=true, troops=false}, + --["P-51D-30-NA"] = {crates=true, troops=false}, + --["SpitfireLFMkIX"] = {crates=true, troops=false}, + --["SpitfireLFMkIXCW"] = {crates=true, troops=false}, + --["TF-51D"] = {crates=true, troops=true}, +} + +-- ************** WEIGHT CALCULATIONS FOR INFANTRY GROUPS ****************** + +-- Infantry groups weight is calculated based on the soldiers' roles, and the weight of their kit +-- Every soldier weights between 90% and 120% of ctld.SOLDIER_WEIGHT, and they all carry a backpack and their helmet (ctld.KIT_WEIGHT) +-- Standard grunts have a rifle and ammo (ctld.RIFLE_WEIGHT) +-- AA soldiers have a MANPAD tube (ctld.MANPAD_WEIGHT) +-- Anti-tank soldiers have a RPG and a rocket (ctld.RPG_WEIGHT) +-- Machine gunners have the squad MG and 200 bullets (ctld.MG_WEIGHT) +-- JTAC have the laser sight, radio and binoculars (ctld.JTAC_WEIGHT) +-- Mortar servants carry their tube and a few rounds (ctld.MORTAR_WEIGHT) + +ctld.SOLDIER_WEIGHT = 80 -- kg, will be randomized between 90% and 120% +ctld.KIT_WEIGHT = 20 -- kg +ctld.RIFLE_WEIGHT = 5 -- kg +ctld.MANPAD_WEIGHT = 18 -- kg +ctld.RPG_WEIGHT = 7.6 -- kg +ctld.MG_WEIGHT = 10 -- kg +ctld.MORTAR_WEIGHT = 26 -- kg +ctld.JTAC_WEIGHT = 15 -- kg + +-- ************** INFANTRY GROUPS FOR PICKUP ****************** +-- Unit Types +-- inf is normal infantry +-- mg is M249 +-- at is RPG-16 +-- aa is Stinger or Igla +-- mortar is a 2B11 mortar unit +-- jtac is a JTAC soldier, which will use JTACAutoLase +-- You must add a name to the group for it to work +-- You can also add an optional coalition side to limit the group to one side +-- for the side - 2 is BLUE and 1 is RED +ctld.loadableGroups = { + {name = ctld.i18n_translate("Standard Group"), inf = 6, mg = 2, at = 2 }, -- will make a loadable group with 6 infantry, 2 MGs and 2 anti-tank for both coalitions + {name = ctld.i18n_translate("Anti Air"), inf = 2, aa = 2 }, + {name = ctld.i18n_translate("Anti Tank"), inf = 2, at = 6 }, + {name = ctld.i18n_translate("Mortar Squad"), mortar = 5 }, + --{name = ctld.i18n_translate("JTAC Group"), inf = 4, jtac = 1 }, -- will make a loadable group with 4 infantry and a JTAC soldier for both coalitions + {name = ctld.i18n_translate("Single JTAC"), jtac = 1 }, -- will make a loadable group witha single JTAC soldier for both coalitions + --{name = ctld.i18n_translate("2x - Standard Groups"), inf = 12, mg = 4, at = 4 }, + --{name = ctld.i18n_translate("2x - Anti Air"), inf = 4, aa = 6 }, + --{name = ctld.i18n_translate("2x - Anti Tank"), inf = 4, at = 12 }, + --{name = ctld.i18n_translate("2x - Standard Groups + 2x Mortar"), inf = 12, mg = 4, at = 4, mortar = 12 }, + --{name = ctld.i18n_translate("3x - Standard Groups"), inf = 18, mg = 6, at = 6 }, + --{name = ctld.i18n_translate("3x - Anti Air"), inf = 6, aa = 9 }, + --{name = ctld.i18n_translate("3x - Anti Tank"), inf = 6, at = 18 }, + --{name = ctld.i18n_translate("3x - Mortar Squad"), mortar = 18}, + --{name = ctld.i18n_translate("5x - Mortar Squad"), mortar = 30}, + --{name = ctld.i18n_translate("Mortar Squad Red"), inf = 2, mortar = 5, side =1 }, --would make a group loadable by RED only +} + +-- ************** SPAWNABLE CRATES ****************** +-- Weights must be unique as we use the weight to change the cargo to the correct unit +-- when we unpack +-- + +ctld.spawnableCrates = { + -- name of the sub menu on F10 for spawning crates + ["Combat Vehicles"] = { + --crates you can spawn + -- weight in KG + -- Desc is the description on the F10 MENU + -- unit is the model name of the unit to spawn + -- cratesRequired - if set requires that many crates of the same type within 100m of each other in order build the unit + -- side is optional but 2 is BLUE and 1 is RED + + -- Some descriptions are filtered to determine if JTAC or not! + + --- BLUE + { weight = 500.10, desc = ctld.i18n_translate("M1128 Stryker MGS"), unit = "M1128 Stryker MGS", side = 2 }, + { weight = 500.01, desc = ctld.i18n_translate("M-60A3 Patton"), unit = "M-60", side = 2 }, --careful with the names as the script matches the desc to JTAC types + { weight = 500.02, desc = ctld.i18n_translate("Humvee - TOW"), unit = "M1045 HMMWV TOW", side = 2 }, + { weight = 500.07, desc = ctld.i18n_translate("M1134 Stryker ATGM"), unit = "M1134 Stryker ATGM", side = 2 }, + { weight = 500.06, desc = ctld.i18n_translate("LAV-25"), unit = "LAV-25", side = 2 }, + { weight = 500.04, desc = ctld.i18n_translate("M2A2 Bradley"), unit="M-2 Bradley", side = 2 }, + { weight = 500.09, desc = ctld.i18n_translate("ATGM VAB Mephisto"), unit="VAB_Mephisto", side = 2 }, + { weight = 500.05, desc = ctld.i18n_translate("M1A2C Abrams"), unit="M1A2C_SEP_V3", side = 2, }, + + --- RED + { weight = 511.01, desc = ctld.i18n_translate("BTR-82A"), unit = "BTR-82A", side = 1 }, + { weight = 511.02, desc = ctld.i18n_translate("BRDM-2"), unit = "BRDM-2", side = 1 }, + { weight = 511.03, desc = ctld.i18n_translate("BMP-3"), unit = "BMP-3", side = 1 }, + { weight = 511.04, desc = ctld.i18n_translate("T-55"), unit = "T-55", side = 1 }, + { weight = 511.05, desc = ctld.i18n_translate("T-72B3"), unit = "T-72B3", side = 1 }, + -- need more redfor! + }, + ["Support"] = { + --- BLUE + { weight = 501.01, desc = ctld.i18n_translate("MRAP - JTAC"), unit = "MaxxPro_MRAP", side = 2 }, -- used as jtac and unarmed, not on the crate list if JTAC is disabled + { weight = 501.02, desc = ctld.i18n_translate("M-818 Ammo Truck"), unit = "M 818", side = 2 }, + { weight = 501.03, desc = ctld.i18n_translate("M-978 Tanker"), unit = "M978 HEMTT Tanker", side = 2 }, + { weight = 501.21, desc = ctld.i18n_translate("EWR Radar FPS-117"), unit="FPS-117", side = 2 }, + + --- RED + { weight = 501.11, desc = ctld.i18n_translate("Tigr - JTAC"), unit = "Tigr_233036", side = 1 }, -- used as jtac and unarmed, not on the crate list if JTAC is disabled + { weight = 501.12, desc = ctld.i18n_translate("Ural-4320-31 Ammo Truck"), unit = "Ural-4320-31", side = 1 }, + { weight = 501.13, desc = ctld.i18n_translate("ATZ-10 Refueler"), unit = "ATZ-10", side = 1 }, + { weight = 501.23, desc = ctld.i18n_translate("EWR Radar 1L13"), unit="1L13 EWR", side = 1 }, + + --- Both + { multiple = {501.22, 501.22, 501.22}, desc = ctld.i18n_translate("FOB Crates - All") }, + { weight = 501.22, desc = ctld.i18n_translate("FOB Crate - Small"), unit = "FOB-SMALL" }, -- Builds a FOB! - requires 3 * ctld.cratesRequiredForFOB + + }, + ["Artillery"] = { + --- BLUE + { weight = 502.01, desc = ctld.i18n_translate("MLRS"), unit = "MLRS", side = 2, cratesRequired = 2 }, + { weight = 502.07, desc = ctld.i18n_translate("Smerch_CM"), unit = "Smerch", side = 2, cratesRequired = 2 }, + { weight = 502.03, desc = ctld.i18n_translate("L118 Light Artillery 105mm"), unit = "L118_Unit", side = 2 }, + { weight = 502.06, desc = ctld.i18n_translate("Smerch_HE"), unit = "Smerch_HE", side = 2, cratesRequired = 2 }, + { weight = 502.04, desc = ctld.i18n_translate("M-109"), unit = "M-109", side = 2, cratesRequired = 2 }, + + + --- RED + { weight = 502.12, desc = ctld.i18n_translate("SAU Gvozdika"), unit = "SAU Gvozdika", side = 1, cratesRequired = 2 }, + { weight = 502.11, desc = ctld.i18n_translate("SPH 2S19 Msta"), unit = "SAU Msta", side = 1, cratesRequired = 2 }, + { weight = 502.14, desc = ctld.i18n_translate("Uragan_BM-27"), unit = "Uragan_BM-27", side = 1, cratesRequired = 2 }, + { weight = 502.13, desc = ctld.i18n_translate("BM-21 Grad Ural"), unit = "Grad-URAL", side = 1, cratesRequired = 2 }, + { weight = 502.15, desc = ctld.i18n_translate("PLZ-05 Mobile Artillery"), unit = "PLZ05", side = 1, cratesRequired = 2 }, + }, + + ["AAA"] = { + --- BLUE + { weight = 508.04, desc = ctld.i18n_translate("Gepard AAA"), unit = "Gepard", side = 2 }, + { weight = 508.05, desc = ctld.i18n_translate("LPWS C-RAM"), unit = "HEMTT_C-RAM_Phalanx", side = 2 }, + { weight = 508.06, desc = ctld.i18n_translate("SPAAA Vulcan M163"), unit = "Vulcan", side = 2 }, + { weight = 508.09, desc = ctld.i18n_translate("Bofors 40mm"), unit = "bofors40", side = 2 }, + + --- RED + { weight = 508.11, desc = ctld.i18n_translate("Ural-375 ZU-23"), unit = "Ural-375 ZU-23", side = 1, cratesRequired = 1 }, + { weight = 508.12, desc = ctld.i18n_translate("ZSU-23-4 Shilka"), unit = "ZSU-23-4 Shilka", side = 1, cratesRequired = 1 }, + { weight = 508.13, desc = ctld.i18n_translate("ZSU_57_2"), unit = "ZSU_57_2", side = 1, cratesRequired = 1 }, + + }, + + ["SAM short range"] = { + --- BLUE + { weight = 503.01, desc = ctld.i18n_translate("M1097 Avenger"), unit = "M1097 Avenger", side = 2, cratesRequired = 2 }, + { weight = 503.02, desc = ctld.i18n_translate("M48 Chaparral"), unit = "M48 Chaparral", side = 2, cratesRequired = 2 }, + { weight = 503.03, desc = ctld.i18n_translate("Roland ADS"), unit = "Roland ADS", side = 2, cratesRequired = 2 }, + { weight = 503.05, desc = ctld.i18n_translate("M6 Linebacker"), unit = "M6 Linebacker", side = 2 }, + -- Rapier System + { multiple = {507.01, 507.02, 507.03}, desc = ctld.i18n_translate("Rapier - All crates"), side = 2 }, + { weight = 507.01, desc = ctld.i18n_translate("Rapier Launcher"), unit = "rapier_fsa_launcher", side = 2 }, + { weight = 507.02, desc = ctld.i18n_translate("Rapier SR"), unit = "rapier_fsa_blindfire_radar", side = 2 }, + { weight = 507.03, desc = ctld.i18n_translate("Rapier Tracker"), unit = "rapier_fsa_optical_tracker_unit", side = 2 }, + { weight = 507.04, desc = ctld.i18n_translate("Rapier Repair"), unit = "Rapier Repair" , side = 2 }, + -- End of Rapier + + --- RED + { weight = 503.11, desc = ctld.i18n_translate("9K33 Osa"), unit = "Osa 9A33 ln", side = 1, cratesRequired = 2 }, + { weight = 503.12, desc = ctld.i18n_translate("9P31 Strela-1"), unit = "Strela-1 9P31", side = 1, cratesRequired = 2 }, + { weight = 503.15, desc = ctld.i18n_translate("2K22 Tunguska"), unit = "2S6 Tunguska", side = 1, cratesRequired = 2 }, + { weight = 503.16, desc = ctld.i18n_translate("SA-13 Strela-10M3"), unit = "Strela-10M3", side = 1, cratesRequired = 2 }, + + -- HQ-7 + { multiple = {503.20, 503.20, 503.21}, desc = ("HQ-7 - All crates"), side = 1 }, + { weight = 503.20, desc = ctld.i18n_translate("HQ-7_Launcher"), unit = "HQ-7_LN_SP", side = 1, cratesRequired = 2 }, + { weight = 503.21, desc = ctld.i18n_translate("HQ-7_STR_SP"), unit = "HQ-7_STR_SP", side = 1 }, + { weight = 503.23, desc = ctld.i18n_translate("HQ-7 Repair"), unit = "HQ-7 Repair", side = 1 }, + -- End of HQ-7 + + }, + ["SAM mid range"] = { + --- BLUE + -- HAWK System + { multiple = {504.01, 504.02, 504.03, 504.04, 504.05}, desc = ctld.i18n_translate("HAWK - All crates"), side = 2 }, + { multiple = {504.11, 504.12, 504.13}, desc = ctld.i18n_translate("NASAMS - All crates"), side = 2 }, + { weight = 504.01, desc = ctld.i18n_translate("HAWK Launcher"), unit = "Hawk ln", side = 2}, + { weight = 504.02, desc = ctld.i18n_translate("HAWK Search Radar"), unit = "Hawk sr", side = 2 }, + { weight = 504.03, desc = ctld.i18n_translate("HAWK Track Radar"), unit = "Hawk tr", side = 2 }, + { weight = 504.04, desc = ctld.i18n_translate("HAWK PCP"), unit = "Hawk pcp" , side = 2 }, + { weight = 504.05, desc = ctld.i18n_translate("HAWK CWAR"), unit = "Hawk cwar" , side = 2 }, + { weight = 504.06, desc = ctld.i18n_translate("HAWK Repair"), unit = "HAWK Repair" , side = 2 }, + -- End of HAWK + + -- NASAMS System + { weight = 504.11, desc = ctld.i18n_translate("NASAMS Launcher 120C"), unit = "NASAMS_LN_C", side = 2}, + { weight = 504.12, desc = ctld.i18n_translate("NASAMS Search/Track Radar"), unit = "NASAMS_Radar_MPQ64F1", side = 2 }, + { weight = 504.13, desc = ctld.i18n_translate("NASAMS Command Post"), unit = "NASAMS_Command_Post", side = 2 }, + { weight = 504.14, desc = ctld.i18n_translate("NASAMS Repair"), unit = "NASAMS Repair", side = 2 }, + -- End of NASAMS + + --- RED + -- KUB SYSTEM + { multiple = {504.21, 504.22}, desc = ctld.i18n_translate("KUB - All crates"), side = 1 }, + { weight = 504.21, desc = ctld.i18n_translate("KUB Launcher"), unit = "Kub 2P25 ln", side = 1}, + { weight = 504.22, desc = ctld.i18n_translate("KUB Radar"), unit = "Kub 1S91 str", side = 1 }, + { weight = 504.23, desc = ctld.i18n_translate("KUB Repair"), unit = "KUB Repair", side = 1}, + -- End of KUB + + }, + ["SAM long range"] = { + --- BLUE + -- Patriot System + { multiple = {505.01, 505.02, 505.03}, desc = ctld.i18n_translate("Patriot - All crates"), side = 2 }, + { weight = 505.01, desc = ctld.i18n_translate("Patriot Launcher"), unit = "Patriot ln", side = 2 }, + { weight = 505.02, desc = ctld.i18n_translate("Patriot Radar"), unit = "Patriot str" , side = 2 }, + { weight = 505.03, desc = ctld.i18n_translate("Patriot ECS"), unit = "Patriot ECS", side = 2 }, + --{ weight = 505.04, desc = ctld.i18n_translate("Patriot ICC"), unit = "Patriot cp", side = 2 }, + --{ weight = 505.05, desc = ctld.i18n_translate("Patriot EPP"), unit = "Patriot EPP", side = 2 }, + --{ weight = 505.06, desc = ctld.i18n_translate("Patriot AMG"), unit = "Patriot AMG" , side = 2 }, + { weight = 505.07, desc = ctld.i18n_translate("Patriot Repair"), unit = "Patriot Repair" , side = 2 }, + -- End of Patriot + + --BUK System + { weight = 504.31, desc = ctld.i18n_translate("BUK Launcher"), unit = "SA-11 Buk LN 9A310M1", side = 1 }, + { weight = 504.32, desc = ctld.i18n_translate("BUK Search Radar"), unit = "SA-11 Buk SR 9S18M1", side = 1 }, + { weight = 504.33, desc = ctld.i18n_translate("BUK CC Radar"), unit = "SA-11 Buk CC 9S470M1", side = 1 }, + { weight = 504.34, desc = ctld.i18n_translate("BUK Repair"), unit = "BUK Repair", side = 1}, + { multiple = {504.31, 504.32, 504.33}, desc = ctld.i18n_translate("BUK - All crates"), side = 1 }, + -- END of BUK + + + -- SA-2 SYSTEM + --{ weight = 505.20, desc = ctld.i18n_translate("SA-2 Guidline Launcher"), unit = "S_75M_Volhov", side = 1 }, + --{ weight = 505.21, desc = ctld.i18n_translate("SA-2 Flat Face SR"), unit = "p-19 s-125 sr", side = 1 }, + --{ weight = 505.22, desc = ctld.i18n_translate("SA-2 Fan Song TR"), unit = "SNR_75V", side = 1 }, + --{ weight = 505.23, desc = ctld.i18n_translate("SA-2 CP"), unit = "RD_75", side = 1 }, + --{ weight = 505.16, desc = ctld.i18n_translate("SA-2 Repair"), unit = "SA-2 Repair", side = 1 }, + --{ multiple = {505.20, 505.21, 505.22, 505.23}, desc = ctld.i18n_translate("SA-2 - All crates"), side = 1 }, + -- End of SA-2 + + + -- S-300 SYSTEM + --{ weight = 505.11, desc = ctld.i18n_translate("S-300 Grumble TEL C"), unit = "S-300PS 5P85C ln", side = 1 }, + --{ weight = 505.12, desc = ctld.i18n_translate("S-300 Grumble Flap Lid-A TR"), unit = "S-300PS 40B6M tr", side = 1 }, + --{ weight = 505.13, desc = ctld.i18n_translate("S-300 Grumble Clam Shell SR"), unit = "S-300PS 40B6MD sr", side = 1 }, + --{ weight = 505.14, desc = ctld.i18n_translate("S-300 Grumble Big Bird SR"), unit = "S-300PS 64H6E sr", side = 1 }, + --{ weight = 505.15, desc = ctld.i18n_translate("S-300 Grumble C2"), unit = "S-300PS 54K6 cp", side = 1 }, + --{ weight = 505.16, desc = ctld.i18n_translate("S-300 Repair"), unit = "S-300 Repair", side = 1 }, + --{ multiple = {505.11, 505.12, 505.13, 505.14, 505.15}, desc = ctld.i18n_translate("SA-10 - All crates"), side = 1 }, + -- End of S-300 + }, + ["Drone"] = { + --- BLUE MQ-9 Repear + { weight = 506.01, desc = ctld.i18n_translate("MQ-9 Repear - JTAC"), unit = "MQ-9 Reaper", side = 2 }, + -- End of BLUE MQ-9 Repear + + --- RED WingLoong-I + { weight = 506.11, desc = ctld.i18n_translate("WingLoong-I - JTAC"), unit = "WingLoong-I", side = 1 }, + -- End of WingLoong-I + }, +} + +ctld.spawnableCratesModels = { + ["load"] = { + ["category"] = "Unarmed", + ["type"] = "Hummer", + ["shape_name"] = "Hummer", + ["canCargo"] = true + }, + ["sling"] = { + ["category"] = "Cargos", + ["shape_name"] = "bw_container_cargo", + ["type"] = "container_cargo", + ["canCargo"] = true + }, + ["dynamic"] = { + ["category"] = "Unarmed", + ["type"] = "Hummer", + ["shape_name"] = "Hummer", + ["canCargo"] = true + } +} + + +--[[ Placeholder for different type of cargo containers. Let's say pipes and trunks, fuel for FOB building + ["shape_name"] = "ab-212_cargo", + ["type"] = "uh1h_cargo" --new type for the container previously used + + ["shape_name"] = "ammo_box_cargo", + ["type"] = "ammo_cargo", + + ["shape_name"] = "barrels_cargo", + ["type"] = "barrels_cargo", + + ["shape_name"] = "bw_container_cargo", + ["type"] = "container_cargo", + + ["shape_name"] = "f_bar_cargo", + ["type"] = "f_bar_cargo", + + ["shape_name"] = "fueltank_cargo", + ["type"] = "fueltank_cargo", + + ["shape_name"] = "iso_container_cargo", + ["type"] = "iso_container", + + ["shape_name"] = "iso_container_small_cargo", + ["type"] = "iso_container_small", + + ["shape_name"] = "oiltank_cargo", + ["type"] = "oiltank_cargo", + + ["shape_name"] = "pipes_big_cargo", + ["type"] = "pipes_big_cargo", + + ["shape_name"] = "pipes_small_cargo", + ["type"] = "pipes_small_cargo", + + ["shape_name"] = "tetrapod_cargo", + ["type"] = "tetrapod_cargo", + + ["shape_name"] = "trunks_long_cargo", + ["type"] = "trunks_long_cargo", + + ["shape_name"] = "trunks_small_cargo", + ["type"] = "trunks_small_cargo", +]]-- + +-- if the unit is on this list, it will be made into a JTAC when deployed +ctld.jtacUnitTypes = { + "Tigr_233036", "MaxxPro_MRAP", -- there are some wierd encoding issues so if you write SKP-11 it wont match as the - sign is encoded differently... + "MQ", "RQ", "WingLoong" --"MQ-9 Repear", "RQ-1A Predator", "WingLoong-I"} +} +ctld.jtacDroneRadius = 1000 -- JTAC offset radius in meters for orbiting drones +ctld.jtacDroneAltitude = 7000 -- JTAC altitude in meters for orbiting drones +-- *************************************************************** +-- **************** Mission Editor Functions ********************* +-- *************************************************************** + +----------------------------------------------------------------- +-- Spawn group at a trigger and set them as extractable. Usage: +-- ctld.spawnGroupAtTrigger("groupside", number, "triggerName", radius) +-- Variables: +-- "groupSide" = "red" for Russia "blue" for USA +-- _number = number of groups to spawn OR Group description +-- "triggerName" = trigger name in mission editor between commas +-- _searchRadius = random distance for units to move from spawn zone (0 will leave troops at the spawn position - no search for enemy) +-- +-- Example: ctld.spawnGroupAtTrigger("red", 2, "spawn1", 1000) +-- +-- This example will spawn 2 groups of russians at the specified point +-- and they will search for enemy or move randomly withing 1000m +-- OR +-- +-- ctld.spawnGroupAtTrigger("blue", {mg=1,at=2,aa=3,inf=4,mortar=5},"spawn2", 2000) +-- Spawns 1 machine gun, 2 anti tank, 3 anti air, 4 standard soldiers and 5 mortars +-- +function ctld.spawnGroupAtTrigger(_groupSide, _number, _triggerName, _searchRadius) + local _spawnTrigger = trigger.misc.getZone(_triggerName) -- trigger to use as reference position + + if _spawnTrigger == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find trigger called %1",_triggerName), 10) + return + end + + local _country + if _groupSide == "red" then + _groupSide = 1 + _country = 0 + else + _groupSide = 2 + _country = 2 + end + + if _searchRadius < 0 then + _searchRadius = 0 + end + + local _pos2 = { x = _spawnTrigger.point.x, y = _spawnTrigger.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + local _groupDetails = ctld.generateTroopTypes(_groupSide, _number, _country) + + local _droppedTroops = ctld.spawnDroppedGroup(_pos3, _groupDetails, false, _searchRadius); + + if _groupSide == 1 then + table.insert(ctld.droppedTroopsRED, _droppedTroops:getName()) + else + table.insert(ctld.droppedTroopsBLUE, _droppedTroops:getName()) + end +end + + +----------------------------------------------------------------- +-- Spawn group at a Vec3 Point and set them as extractable. Usage: +-- ctld.spawnGroupAtPoint("groupside", number,Vec3 Point, radius) +-- Variables: +-- "groupSide" = "red" for Russia "blue" for USA +-- _number = number of groups to spawn OR Group Description +-- Vec3 Point = A vec3 point like {x=1,y=2,z=3}. Can be obtained from a unit like so: Unit.getName("Unit1"):getPoint() +-- _searchRadius = random distance for units to move from spawn zone (0 will leave troops at the spawn position - no search for enemy) +-- +-- Example: ctld.spawnGroupAtPoint("red", 2, {x=1,y=2,z=3}, 1000) +-- +-- This example will spawn 2 groups of russians at the specified point +-- and they will search for enemy or move randomly withing 1000m +-- OR +-- +-- ctld.spawnGroupAtPoint("blue", {mg=1,at=2,aa=3,inf=4,mortar=5}, {x=1,y=2,z=3}, 2000) +-- Spawns 1 machine gun, 2 anti tank, 3 anti air, 4 standard soldiers and 5 mortars +function ctld.spawnGroupAtPoint(_groupSide, _number, _point, _searchRadius) + + local _country + if _groupSide == "red" then + _groupSide = 1 + _country = 0 + else + _groupSide = 2 + _country = 2 + end + + if _searchRadius < 0 then + _searchRadius = 0 + end + + local _groupDetails = ctld.generateTroopTypes(_groupSide, _number, _country) + + local _droppedTroops = ctld.spawnDroppedGroup(_point, _groupDetails, false, _searchRadius); + + if _groupSide == 1 then + table.insert(ctld.droppedTroopsRED, _droppedTroops:getName()) + else + table.insert(ctld.droppedTroopsBLUE, _droppedTroops:getName()) + end +end + + +-- Preloads a transport with troops or vehicles +-- replaces any troops currently on board +function ctld.preLoadTransport(_unitName, _number, _troops) + + local _unit = ctld.getTransportUnit(_unitName) + + if _unit ~= nil then + + -- will replace any units currently on board + -- if not ctld.troopsOnboard(_unit,_troops) then + ctld.loadTroops(_unit, _troops, _number) + -- end + end +end + + +-- Continuously counts the number of crates in a zone and sets the value of the passed in flag +-- to the count amount +-- This means you can trigger actions based on the count and also trigger messages before the count is reached +-- Just pass in the zone name and flag number like so as a single (NOT Continuous) Trigger +-- This will now work for Mission Editor and Spawned Crates +-- e.g. ctld.cratesInZone("DropZone1", 5) +function ctld.cratesInZone(_zone, _flagNumber) + local _triggerZone = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _zonePos = mist.utils.zoneToVec3(_zone) + + --ignore side, if crate has been used its discounted from the count + local _crateTables = { ctld.spawnedCratesRED, ctld.spawnedCratesBLUE, ctld.missionEditorCargoCrates } + + local _crateCount = 0 + + for _, _crates in pairs(_crateTables) do + + for _crateName, _dontUse in pairs(_crates) do + + --get crate + local _crate = ctld.getCrateObject(_crateName) + + --in air seems buggy with crates so if in air is true, get the height above ground and the speed magnitude + if _crate ~= nil and _crate:getLife() > 0 + and (ctld.inAir(_crate) == false) then + + local _dist = ctld.getDistance(_crate:getPoint(), _zonePos) + + if _dist <= _triggerZone.radius then + _crateCount = _crateCount + 1 + end + end + end + end + + --set flag stuff + trigger.action.setUserFlag(_flagNumber, _crateCount) + + -- env.info("FLAG ".._flagNumber.." crates ".._crateCount) + + --retrigger in 5 seconds + timer.scheduleFunction(function(_args) + + ctld.cratesInZone(_args[1], _args[2]) + end, { _zone, _flagNumber }, timer.getTime() + 5) +end + +-- Creates an extraction zone +-- any Soldiers (not vehicles) dropped at this zone by a helicopter will disappear +-- and be added to a running total of soldiers for a set flag number +-- The idea is you can then drop say 20 troops in a zone and trigger an action using the mission editor triggers +-- and the flag value +-- +-- The ctld.createExtractZone function needs to be called once in a trigger action do script. +-- if you dont want smoke, pass -1 to the function. +--Green = 0 , Red = 1, White = 2, Orange = 3, Blue = 4, NO SMOKE = -1 +-- +-- e.g. ctld.createExtractZone("extractzone1", 2, -1) will create an extraction zone at trigger zone "extractzone1", store the number of troops dropped at +-- the zone in flag 2 and not have smoke +-- +-- +-- +function ctld.createExtractZone(_zone, _flagNumber, _smoke) + local _triggerZone = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _pos2 = { x = _triggerZone.point.x, y = _triggerZone.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + trigger.action.setUserFlag(_flagNumber, 0) --start at 0 + + local _details = { point = _pos3, name = _zone, smoke = _smoke, flag = _flagNumber, radius = _triggerZone.radius} + + ctld.extractZones[_zone.."-".._flagNumber] = _details + + if _smoke ~= nil and _smoke > -1 then + + local _smokeFunction + + _smokeFunction = function(_args) + + local _extractDetails = ctld.extractZones[_zone.."-".._flagNumber] + -- check zone is still active + if _extractDetails == nil then + -- stop refreshing smoke, zone is done + return + end + + + trigger.action.smoke(_args.point, _args.smoke) + --refresh in 5 minutes + timer.scheduleFunction(_smokeFunction, _args, timer.getTime() + 300) + end + + --run local function + _smokeFunction(_details) + end +end + + +-- Removes an extraction zone +-- +-- The smoke will take up to 5 minutes to disappear depending on the last time the smoke was activated +-- +-- The ctld.removeExtractZone function needs to be called once in a trigger action do script. +-- +-- e.g. ctld.removeExtractZone("extractzone1", 2) will remove an extraction zone at trigger zone "extractzone1" +-- that was setup with flag 2 +-- +-- +-- +function ctld.removeExtractZone(_zone,_flagNumber) + + local _extractDetails = ctld.extractZones[_zone.."-".._flagNumber] + + if _extractDetails ~= nil then + --remove zone + ctld.extractZones[_zone.."-".._flagNumber] = nil + + end +end + +-- CONTINUOUS TRIGGER FUNCTION +-- This function will count the current number of extractable RED and BLUE +-- GROUPS in a zone and store the values in two flags +-- A group is only counted as being in a zone when the leader of that group +-- is in the zone +-- Use: ctld.countDroppedGroupsInZone("Zone Name", flagBlue, flagRed) +function ctld.countDroppedGroupsInZone(_zone, _blueFlag, _redFlag) + + local _triggerZone = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _zonePos = mist.utils.zoneToVec3(_zone) + + local _redCount = 0; + local _blueCount = 0; + + local _allGroups = {ctld.droppedTroopsRED,ctld.droppedTroopsBLUE,ctld.droppedVehiclesRED,ctld.droppedVehiclesBLUE} + for _, _extractGroups in pairs(_allGroups) do + for _,_groupName in pairs(_extractGroups) do + local _groupUnits = ctld.getGroup(_groupName) + + if #_groupUnits > 0 then + local _zonePos = mist.utils.zoneToVec3(_zone) + local _dist = ctld.getDistance(_groupUnits[1]:getPoint(), _zonePos) + + if _dist <= _triggerZone.radius then + + if (_groupUnits[1]:getCoalition() == 1) then + _redCount = _redCount + 1; + else + _blueCount = _blueCount + 1; + end + end + end + end + end + --set flag stuff + trigger.action.setUserFlag(_blueFlag, _blueCount) + trigger.action.setUserFlag(_redFlag, _redCount) + + -- env.info("Groups in zone ".._blueCount.." ".._redCount) + +end + +-- CONTINUOUS TRIGGER FUNCTION +-- This function will count the current number of extractable RED and BLUE +-- UNITS in a zone and store the values in two flags + +-- Use: ctld.countDroppedUnitsInZone("Zone Name", flagBlue, flagRed) +function ctld.countDroppedUnitsInZone(_zone, _blueFlag, _redFlag) + + local _triggerZone = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _zonePos = mist.utils.zoneToVec3(_zone) + + local _redCount = 0; + local _blueCount = 0; + + local _allGroups = {ctld.droppedTroopsRED,ctld.droppedTroopsBLUE,ctld.droppedVehiclesRED,ctld.droppedVehiclesBLUE} + + for _, _extractGroups in pairs(_allGroups) do + for _,_groupName in pairs(_extractGroups) do + local _groupUnits = ctld.getGroup(_groupName) + + if #_groupUnits > 0 then + + local _zonePos = mist.utils.zoneToVec3(_zone) + for _,_unit in pairs(_groupUnits) do + local _dist = ctld.getDistance(_unit:getPoint(), _zonePos) + + if _dist <= _triggerZone.radius then + + if (_unit:getCoalition() == 1) then + _redCount = _redCount + 1; + else + _blueCount = _blueCount + 1; + end + end + end + end + end + end + + + --set flag stuff + trigger.action.setUserFlag(_blueFlag, _blueCount) + trigger.action.setUserFlag(_redFlag, _redCount) + + -- env.info("Units in zone ".._blueCount.." ".._redCount) +end + + +-- Creates a radio beacon on a random UHF - VHF and HF/FM frequency for homing +-- This WILL NOT WORK if you dont add beacon.ogg and beaconsilent.ogg to the mission!!! +-- e.g. ctld.createRadioBeaconAtZone("beaconZone","red", 1440,"Waypoint 1") will create a beacon at trigger zone "beaconZone" for the Red side +-- that will last 1440 minutes (24 hours ) and named "Waypoint 1" in the list of radio beacons +-- +-- e.g. ctld.createRadioBeaconAtZone("beaconZoneBlue","blue", 20) will create a beacon at trigger zone "beaconZoneBlue" for the Blue side +-- that will last 20 minutes +function ctld.createRadioBeaconAtZone(_zone, _coalition, _batteryLife, _name) + local _triggerZone = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _zonePos = mist.utils.zoneToVec3(_zone) + + ctld.beaconCount = ctld.beaconCount + 1 + + if _name == nil or _name == "" then + _name = "Beacon #" .. ctld.beaconCount + end + + if _coalition == "red" then + ctld.createRadioBeacon(_zonePos, 1, 0, _name, _batteryLife) --1440 + else + ctld.createRadioBeacon(_zonePos, 2, 2, _name, _batteryLife) --1440 + end +end + + +-- Activates a pickup zone +-- Activates a pickup zone when called from a trigger +-- EG: ctld.activatePickupZone("pickzone3") +-- This is enable pickzone3 to be used as a pickup zone for the team set +function ctld.activatePickupZone(_zoneName) + + local _triggerZone = trigger.misc.getZone(_zoneName) -- trigger to use as reference position + + if _triggerZone == nil then + local _ship = ctld.getTransportUnit(_triggerZone) + + if _ship then + local _point = _ship:getPoint() + _triggerZone = {} + _triggerZone.point = _point + end + + end + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone or ship called %1", _zoneName), 10) + end + + for _, _zoneDetails in pairs(ctld.pickupZones) do + + if _zoneName == _zoneDetails[1] then + + --smoke could get messy if designer keeps calling this on an active zone, check its not active first + if _zoneDetails[4] == 1 then + -- they might have a continuous trigger so i've hidden the warning + return + end + + _zoneDetails[4] = 1 --activate zone + + if ctld.disableAllSmoke == true then --smoke disabled + return + end + + if _zoneDetails[2] >= 0 then + + -- Trigger smoke marker + -- This will cause an overlapping smoke marker on next refreshsmoke call + -- but will only happen once + local _pos2 = { x = _triggerZone.point.x, y = _triggerZone.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + trigger.action.smoke(_pos3, _zoneDetails[2]) + end + end + end +end + + +-- Deactivates a pickup zone +-- Deactivates a pickup zone when called from a trigger +-- EG: ctld.deactivatePickupZone("pickzone3") +-- This is disables pickzone3 and can no longer be used to as a pickup zone +-- These functions can be called by triggers, like if a set of buildings is used, you can trigger the zone to be 'not operational' +-- once they are destroyed +function ctld.deactivatePickupZone(_zoneName) + + local _triggerZone = trigger.misc.getZone(_zoneName) -- trigger to use as reference position + + if _triggerZone == nil then + local _ship = ctld.getTransportUnit(_triggerZone) + + if _ship then + local _point = _ship:getPoint() + _triggerZone = {} + _triggerZone.point = _point + end + + end + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zoneName), 10) + return + end + + for _, _zoneDetails in pairs(ctld.pickupZones) do + + if _zoneName == _zoneDetails[1] then + -- i'd just ignore it if its already been deactivated + _zoneDetails[4] = 0 --deactivate zone + end + end +end + +-- Change the remaining groups currently available for pickup at a zone +-- e.g. ctld.changeRemainingGroupsForPickupZone("pickup1", 5) -- adds 5 groups +-- ctld.changeRemainingGroupsForPickupZone("pickup1", -3) -- remove 3 groups +function ctld.changeRemainingGroupsForPickupZone(_zoneName, _amount) + local _triggerZone = trigger.misc.getZone(_zoneName) -- trigger to use as reference position + + if _triggerZone == nil then + local _ship = ctld.getTransportUnit(_triggerZone) + + if _ship then + local _point = _ship:getPoint() + _triggerZone = {} + _triggerZone.point = _point + end + + end + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zoneName), 10) + return + end + + for _, _zoneDetails in pairs(ctld.pickupZones) do + + if _zoneName == _zoneDetails[1] then + ctld.updateZoneCounter(_zoneName, _amount) + end + end + + +end + +-- Activates a Waypoint zone +-- Activates a Waypoint zone when called from a trigger +-- EG: ctld.activateWaypointZone("pickzone3") +-- This means that troops dropped within the radius of the zone will head to the center +-- of the zone instead of searching for troops +function ctld.activateWaypointZone(_zoneName) + local _triggerZone = trigger.misc.getZone(_zoneName) -- trigger to use as reference position + + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zoneName), 10) + + return + end + + for _, _zoneDetails in pairs(ctld.wpZones) do + + if _zoneName == _zoneDetails[1] then + + --smoke could get messy if designer keeps calling this on an active zone, check its not active first + if _zoneDetails[3] == 1 then + -- they might have a continuous trigger so i've hidden the warning + return + end + + _zoneDetails[3] = 1 --activate zone + + if ctld.disableAllSmoke == true then --smoke disabled + return + end + + if _zoneDetails[2] >= 0 then + + -- Trigger smoke marker + -- This will cause an overlapping smoke marker on next refreshsmoke call + -- but will only happen once + local _pos2 = { x = _triggerZone.point.x, y = _triggerZone.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + trigger.action.smoke(_pos3, _zoneDetails[2]) + end + end + end +end + + +-- Deactivates a Waypoint zone +-- Deactivates a Waypoint zone when called from a trigger +-- EG: ctld.deactivateWaypointZone("wpzone3") +-- This disables wpzone3 so that troops dropped in this zone will search for troops as normal +-- These functions can be called by triggers +function ctld.deactivateWaypointZone(_zoneName) + + local _triggerZone = trigger.misc.getZone(_zoneName) + + if _triggerZone == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zoneName), 10) + return + end + + for _, _zoneDetails in pairs(ctld.pickupZones) do + + if _zoneName == _zoneDetails[1] then + + _zoneDetails[3] = 0 --deactivate zone + end + end +end + +-- Continuous Trigger Function +-- Causes an AI unit with the specified name to unload troops / vehicles when +-- an enemy is detected within a specified distance +-- The enemy must have Line or Sight to the unit to be detected +function ctld.unloadInProximityToEnemy(_unitName,_distance) + + local _unit = ctld.getTransportUnit(_unitName) + + if _unit ~= nil and _unit:getPlayerName() == nil then + + -- no player name means AI! + -- the findNearest visible enemy you'd want to modify as it'll find enemies quite far away + -- limited by ctld.JTAC_maxDistance + local _nearestEnemy = ctld.findNearestVisibleEnemy(_unit,"all",_distance) + + if _nearestEnemy ~= nil then + + if ctld.troopsOnboard(_unit, true) then + ctld.deployTroops(_unit, true) + return true + end + + if ctld.unitCanCarryVehicles(_unit) and ctld.troopsOnboard(_unit, false) then + ctld.deployTroops(_unit, false) + return true + end + end + end + + return false + +end + + + +-- Unit will unload any units onboard if the unit is on the ground +-- when this function is called +function ctld.unloadTransport(_unitName) + + local _unit = ctld.getTransportUnit(_unitName) + + if _unit ~= nil then + + if ctld.troopsOnboard(_unit, true) then + ctld.unloadTroops({_unitName,true}) + end + + if ctld.unitCanCarryVehicles(_unit) and ctld.troopsOnboard(_unit, false) then + ctld.unloadTroops({_unitName,false}) + end + end + +end + +-- Loads Troops and Vehicles from a zone or picks up nearby troops or vehicles +function ctld.loadTransport(_unitName) + + local _unit = ctld.getTransportUnit(_unitName) + + if _unit ~= nil then + + ctld.loadTroopsFromZone({ _unitName, true,"",true }) + + if ctld.unitCanCarryVehicles(_unit) then + ctld.loadTroopsFromZone({ _unitName, false,"",true }) + end + + end + +end + +-- adds a callback that will be called for many actions ingame +function ctld.addCallback(_callback) + + table.insert(ctld.callbacks,_callback) + +end + +-- Spawns a sling loadable crate at a Trigger Zone +-- +-- Weights can be found in the ctld.spawnableCrates list +-- e.g. ctld.spawnCrateAtZone("red", 500,"triggerzone1") -- spawn a humvee at triggerzone 1 for red side +-- e.g. ctld.spawnCrateAtZone("blue", 505,"triggerzone1") -- spawn a tow humvee at triggerzone1 for blue side +-- +function ctld.spawnCrateAtZone(_side, _weight,_zone) + local _spawnTrigger = trigger.misc.getZone(_zone) -- trigger to use as reference position + + if _spawnTrigger == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find zone called %1", _zone), 10) + return + end + + local _crateType = ctld.crateLookupTable[tostring(_weight)] + + if _crateType == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find crate with weight %1", _weight), 10) + return + end + + local _country + if _side == "red" then + _side = 1 + _country = 0 + else + _side = 2 + _country = 2 + end + + local _pos2 = { x = _spawnTrigger.point.x, y = _spawnTrigger.point.z } + local _alt = land.getHeight(_pos2) + local _point = { x = _pos2.x, y = _alt, z = _pos2.y } + + local _unitId = ctld.getNextUnitId() + + local _name = string.format("%s #%i", _crateType.desc, _unitId) + + ctld.spawnCrateStatic(_country, _unitId, _point, _name, _crateType.weight, _side) + +end + +-- Spawns a sling loadable crate at a Point +-- +-- Weights can be found in the ctld.spawnableCrates list +-- Points can be made by hand or obtained from a Unit position by Unit.getByName("PilotName"):getPoint() +-- e.g. ctld.spawnCrateAtZone("red", 500,{x=1,y=2,z=3}) -- spawn a humvee at triggerzone 1 for red side at a specified point +-- e.g. ctld.spawnCrateAtZone("blue", 505,{x=1,y=2,z=3}) -- spawn a tow humvee at triggerzone1 for blue side at a specified point +-- +-- +function ctld.spawnCrateAtPoint(_side, _weight, _point,_hdg) + + + local _crateType = ctld.crateLookupTable[tostring(_weight)] + + if _crateType == nil then + trigger.action.outText(ctld.i18n_translate("CTLD.lua ERROR: Can't find crate with weight %1", _weight), 10) + return + end + + local _country + if _side == "red" then + _side = 1 + _country = 0 + else + _side = 2 + _country = 2 + end + + local _unitId = ctld.getNextUnitId() + + local _name = string.format("%s #%i", _crateType.desc, _unitId) + + ctld.spawnCrateStatic(_country, _unitId, _point, _name, _crateType.weight, _side, _hdg) + +end + +-- *************************************************************** +-- **************** BE CAREFUL BELOW HERE ************************ +-- *************************************************************** + +--- Tells CTLD What multipart AA Systems there are and what parts they need +-- A New system added here also needs the launcher added +-- The number of times that each part is spawned for each system is specified by the entry "amount", NOTE : they will be spawned in a circle with the corresponding headings, NOTE 2 : launchers will use the default ctld.aaLauncher amount if nothing is specified +-- If a component does not require a crate, it can be specified via the entry "NoCrate" set to true +ctld.AASystemTemplate = { + + { + name = "HAWK AA System", + count = 5, + parts = { + {name = "Hawk ln", desc = "HAWK Launcher", launcher = true, amount = 5 }, + {name = "Hawk tr", desc = "HAWK Track Radar"}, + {name = "Hawk sr", desc = "HAWK Search Radar"}, + {name = "Hawk pcp", desc = "HAWK PCP"}, + {name = "Hawk cwar", desc = "HAWK CWAR"}, + }, + repair = "HAWK Repair", + }, + { + name = "Patriot AA System", + count = 3, + parts = { + {name = "Patriot ln", desc = "Patriot Launcher", launcher = true, amount = 4}, + {name = "Patriot ECS", desc = "Patriot Control Unit"}, + {name = "Patriot str", desc = "Patriot Search and Track Radar"}, + --{name = "Patriot cp", desc = "Patriot ICC"}, + --{name = "Patriot EPP", desc = "Patriot EPP"}, + --{name = "Patriot AMG", desc = "Patriot AMG DL relay"}, + }, + repair = "Patriot Repair", + }, + { + name = "NASAMS AA System", + count = 3, + parts = { + {name = "NASAMS_LN_C", desc = "NASAMS Launcher 120C", launcher = true, amount = 4}, + {name = "NASAMS_Radar_MPQ64F1", desc = "NASAMS Search/Track Radar"}, + {name = "NASAMS_Command_Post", desc = "NASAMS Command Post"}, + }, + repair = "NASAMS Repair", + }, + { + name = "BUK AA System", + count = 3, + parts = { + {name = "SA-11 Buk LN 9A310M1", desc = "BUK Launcher" , launcher = true, amount = 3}, + {name = "SA-11 Buk CC 9S470M1", desc = "BUK CC Radar"}, + {name = "SA-11 Buk SR 9S18M1", desc = "BUK Search Radar"}, + }, + repair = "BUK Repair", + }, + { + name = "KUB AA System", + count = 2, + parts = { + {name = "Kub 2P25 ln", desc = "KUB Launcher", launcher = true, amount = 3}, + {name = "Kub 1S91 str", desc = "KUB Radar"}, + }, + repair = "KUB Repair", + }, + --{ + -- name = "S-300 AA System", + -- count = 6, + -- parts = { + -- { desc = "S-300 Grumble TEL C", name = "S-300PS 5P85C ln", launcher = true, amount = 1 }, + -- { desc = "S-300 Grumble TEL D", name = "S-300PS 5P85D ln", NoCrate = true, amount = 2 }, + -- { desc = "S-300 Grumble Flap Lid-A TR", name = "S-300PS 40B6M tr"}, + -- { desc = "S-300 Grumble Clam Shell SR", name = "S-300PS 40B6MD sr"}, + -- { desc = "S-300 Grumble Big Bird SR", name = "S-300PS 64H6E sr"}, + -- { desc = "S-300 Grumble C2", name = "S-300PS 54K6 cp"}, + -- }, + -- repair = "S-300 Repair", + --}, + { + name = "SA-2 AA System", + count = 4, + parts = { + { desc = "S_75M_Volhov", name = "SA-2 Guidline Launcher", launcher = true, amount = 4 }, + { desc = "p-19 s-125 sr", name = "SA-2 Flat Face SR"}, + { desc = "SNR_75V", name = "SA-2 Fan Song TR"}, + { desc = "RD_75", name = "SA-2 CP"}, + }, + repair = "SA-2 Repair", + }, + { + name = "HQ-7 System", + count = 2, + parts = { + { desc = "HQ-7_Launcher", name = "HQ-7_LN_SP", launcher = true, amount = 2 }, + { desc = "HQ-7_STR_SP", name = "HQ-7_STR_SP"}, + }, + repair = "HQ-7 Repair", + }, + { + name = "Rapier AA System", + count = 3, + parts = { + {name = "rapier_fsa_launcher", desc = "Rapier Launcher", launcher = true}, + {name = "rapier_fsa_optical_tracker_unit", desc = "Rapier Tracker"}, + {name = "rapier_fsa_blindfire_radar", desc = "Rapier SR"}, + }, + repair = "Rapier Repair", + }, +} + +ctld.crateWait = {} +ctld.crateMove = {} + +---------------- INTERNAL FUNCTIONS ---------------- +--- +--- +------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- Utility methods +------------------------------------------------------------------------------------------------------------------------------------------------------------- + +--- print an object for a debugging log +function ctld.p(o, level) + local MAX_LEVEL = 20 + if level == nil then level = 0 end + if level > MAX_LEVEL then + ctld.logError("max depth reached in ctld.p : "..tostring(MAX_LEVEL)) + return "" + end + local text = "" + if (type(o) == "table") then + text = "\n" + for key,value in pairs(o) do + for i=0, level do + text = text .. " " + end + text = text .. ".".. key.."="..ctld.p(value, level+1) .. "\n" + end + elseif (type(o) == "function") then + text = "[function]" + elseif (type(o) == "boolean") then + if o == true then + text = "[true]" + else + text = "[false]" + end + else + if o == nil then + text = "[nil]" + else + text = tostring(o) + end + end + return text +end + +function ctld.formatText(text, ...) + if not text then + return "" + end + if type(text) ~= 'string' then + text = ctld.p(text) + else + local args = ... + if args and args.n and args.n > 0 then + local pArgs = {} + for i=1,args.n do + pArgs[i] = ctld.p(args[i]) + end + text = text:format(unpack(pArgs)) + end + end + local fName = nil + local cLine = nil + if debug and debug.getinfo then + local dInfo = debug.getinfo(3) + fName = dInfo.name + cLine = dInfo.currentline + end + if fName and cLine then + return fName .. '|' .. cLine .. ': ' .. text + elseif cLine then + return cLine .. ': ' .. text + else + return ' ' .. text + end +end + +function ctld.logError(message, ...) + message = ctld.formatText(message, arg) + env.info(" E - " .. ctld.Id .. message) +end + + +function ctld.logWarning(message, ...) + message = ctld.formatText(message, arg) + env.info(" W - " .. ctld.Id .. message) +end + +function ctld.logInfo(message, ...) + message = ctld.formatText(message, arg) + env.info(" I - " .. ctld.Id .. message) +end + +function ctld.logDebug(message, ...) + if message and ctld.Debug then + message = ctld.formatText(message, arg) + env.info(" D - " .. ctld.Id .. message) + end +end + +function ctld.logTrace(message, ...) + if message and ctld.Trace then + message = ctld.formatText(message, arg) + env.info(" T - " .. ctld.Id .. message) + end +end + +ctld.nextUnitId = 1; +ctld.getNextUnitId = function() + ctld.nextUnitId = ctld.nextUnitId + 1 + + return ctld.nextUnitId +end + +ctld.nextGroupId = 1; + +ctld.getNextGroupId = function() + ctld.nextGroupId = ctld.nextGroupId + 1 + + return ctld.nextGroupId +end + +function ctld.getTransportUnit(_unitName) + + if _unitName == nil then + return nil + end + + local _heli = Unit.getByName(_unitName) + + if _heli ~= nil and _heli:isActive() and _heli:getLife() > 0 then + + return _heli + end + + return nil +end + +function ctld.spawnCrateStatic(_country, _unitId, _point, _name, _weight, _side, _hdg, _model_type) + + local _crate + local _spawnedCrate + + local hdg = _hdg or 0 + + if ctld.staticBugWorkaround and ctld.slingLoad == false then + local _groupId = ctld.getNextGroupId() + local _groupName = "Crate Group #".._groupId + + local _group = { + ["visible"] = false, + -- ["groupId"] = _groupId, + ["hidden"] = false, + ["units"] = {}, + -- ["y"] = _positions[1].z, + -- ["x"] = _positions[1].x, + ["name"] = _groupName, + ["task"] = {}, + } + + _group.units[1] = ctld.createUnit(_point.x , _point.z , hdg, {type="UAZ-469",name=_name,unitId=_unitId}) + + --switch to MIST + _group.category = Group.Category.GROUND; + _group.country = _country; + + local _spawnedGroup = Group.getByName(mist.dynAdd(_group).name) + + -- Turn off AI + trigger.action.setGroupAIOff(_spawnedGroup) + + _spawnedCrate = Unit.getByName(_name) + else + + if _model_type ~= nil then + _crate = mist.utils.deepCopy(ctld.spawnableCratesModels[_model_type]) + elseif ctld.slingLoad then + _crate = mist.utils.deepCopy(ctld.spawnableCratesModels["sling"]) + else + _crate = mist.utils.deepCopy(ctld.spawnableCratesModels["load"]) + end + + _crate["y"] = _point.z + _crate["x"] = _point.x + _crate["mass"] = _weight + _crate["name"] = _name + _crate["heading"] = hdg + _crate["country"] = _country + + mist.dynAddStatic(_crate) + + _spawnedCrate = StaticObject.getByName(_crate["name"]) + end + + + local _crateType = ctld.crateLookupTable[tostring(_weight)] + + if _side == 1 then + ctld.spawnedCratesRED[_name] =_crateType + else + ctld.spawnedCratesBLUE[_name] = _crateType + end + + return _spawnedCrate +end + +function ctld.spawnFOBCrateStatic(_country, _unitId, _point, _name) + + local _crate = { + ["category"] = "Fortifications", + ["shape_name"] = "konteiner_red1", + ["type"] = "Container red 1", + -- ["unitId"] = _unitId, + ["y"] = _point.z, + ["x"] = _point.x, + ["name"] = _name, + ["canCargo"] = false, + ["heading"] = 0, + } + + _crate["country"] = _country + + mist.dynAddStatic(_crate) + + local _spawnedCrate = StaticObject.getByName(_crate["name"]) + --local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + return _spawnedCrate +end + + +function ctld.spawnFOB(_country, _unitId, _point, _name) + + local _id = ctld.getNextUnitId() + local _crate = { + ["category"] = "Heliports", + ["shape_name"] = "FARP", + ["type"] = "FARP", + -- ["unitId"] = _unitId, + ["rate"] = 100, + ["y"] = _point.z, + ["x"] = _point.x + 30.00, + ["name"] = _name, + ["canCargo"] = true, + ["heading"] = 0, + } + + _crate["country"] = _country + mist.dynAddStatic(_crate) + --local _spawnedCrate = StaticObject.getByName(_crate["name"]) + local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + local _id = ctld.getNextUnitId() + local _crate = { + ["type"] = "FARP Tent", + ["shape_name"] = "PalatkaB", + -- ["unitId"] = _id, + ["rate"] = 150, + ["y"] = _point.z, + ["x"] = _point.x + -10.00, + ["name"] = "FARP Tent #" .. _id, + ["category"] = "Structures", + ["canCargo"] = false, + ["heading"] = 0, + } + + _crate["country"] = _country + mist.dynAddStatic(_crate) + local _spawnedCrate = StaticObject.getByName(_crate["name"]) + --local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + local _id = ctld.getNextUnitId() + local _crate = { + ["type"] = "M 818", + ["shape_name"] = "M 818", + -- ["unitId"] = _id, + ["rate"] = 200, + ["y"] = _point.z + 16.00, + ["x"] = _point.x + -5.00, + ["name"] = "M 818" .. _id, + ["category"] = "Unarmed", + ["canCargo"] = false, + ["heading"] = 0, + } + + _crate["country"] = _country + mist.dynAddStatic(_crate) + local _spawnedCrate = StaticObject.getByName(_crate["name"]) + --local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + local _id = ctld.getNextUnitId() + local _crate = { + ["type"] = "FARP Fuel Depot", + ["shape_name"] = "GSM Rus", + -- ["unitId"] = _id, + ["rate"] = 250, + ["y"] = _point.z + 10.00, + ["x"] = _point.x + -2.00, + ["name"] = "FARP Fuel Depot #" .. _id, + ["category"] = "Structures", + ["canCargo"] = false, + ["heading"] = 0, + } + + _crate["country"] = _country + mist.dynAddStatic(_crate) + local _spawnedCrate = StaticObject.getByName(_crate["name"]) + --local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + local _id = ctld.getNextUnitId() + local _crate = { + ["type"] = "FARP Ammo Dump Coating", + ["shape_name"] = "SetkaKP", + -- ["unitId"] = _id, + ["rate"] = 300, + ["y"] = _point.z + -10.00, + ["x"] = _point.x + -5.00, + ["name"] = "FARP Ammo Dump Coating #" .. _id, + ["category"] = "Structures", + ["canCargo"] = true, + ["heading"] = 0, + } + _crate["country"] = _country + mist.dynAddStatic(_crate) + local _spawnedCrate = StaticObject.getByName(_crate["name"]) + --local _spawnedCrate = coalition.addStaticObject(_country, _crate) + + local _id = ctld.getNextUnitId() + local _tower = { + ["type"] = "Container_watchtower_lights", + ["shape_name"] = "Container_watchtower_lights", + -- ["unitId"] = _id, + ["rate"] = 350, + ["y"] = _point.z + 20.00, + ["x"] = _point.x + -5.00, + ["name"] = "Container_watchtower_lights #" .. _id, + ["category"] = "Structures", + ["canCargo"] = false, + ["heading"] = 0, + } + + --coalition.addStaticObject(_country, _tower) + _tower["country"] = _country + + mist.dynAddStatic(_tower) + + + local _id = ctld.getNextUnitId() + local _tower = { + ["type"] = "Container_watchtower_lights", + ["shape_name"] = "Container_watchtower_lights", + -- ["unitId"] = _id, + ["rate"] = 400, + ["y"] = _point.z + -20.00, + ["x"] = _point.x + -5.00, + ["name"] = "Container_watchtower_lights #" .. _id, + ["category"] = "Structures", + ["canCargo"] = false, + ["heading"] = 0, + } + + --coalition.addStaticObject(_country, _tower) + _tower["country"] = _country + + mist.dynAddStatic(_tower) + + return _spawnedCrate +end + + +function ctld.spawnCrate(_arguments, bypassCrateWaitTime) + + local _status, _err = pcall(function(_args) + + -- use the cargo weight to guess the type of unit as no way to add description :( + local _crateType = ctld.crateLookupTable[tostring(_args[2])] + + local _heli = ctld.getTransportUnit(_args[1]) + if not _heli then + return + end + + -- check crate spam + if not(bypassCrateWaitTime) and _heli:getPlayerName() ~= nil and ctld.crateWait[_heli:getPlayerName()] and ctld.crateWait[_heli:getPlayerName()] > timer.getTime() then + ctld.displayMessageToGroup(_heli,ctld.i18n_translate("Sorry you must wait %1 seconds before you can get another crate", (ctld.crateWait[_heli:getPlayerName()] - timer.getTime())), 20) + return + end + + if _crateType and _crateType.multiple then + for _, weight in pairs(_crateType.multiple) do + local _aCrateType = ctld.crateLookupTable[tostring(weight)] + if _aCrateType then + ctld.spawnCrate({_args[1], _aCrateType.weight}, true) + end + end + return + end + + if _crateType ~= nil and _heli ~= nil and ctld.inAir(_heli) == false then + + if ctld.inLogisticsZone(_heli) == false then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not close enough to friendly logistics to get a crate!"), 10) + + return + end + + if ctld.isJTACUnitType(_crateType.unit) then + + local _limitHit = false + + if _heli:getCoalition() == 1 then + + if ctld.JTAC_LIMIT_RED == 0 then + _limitHit = true + else + ctld.JTAC_LIMIT_RED = ctld.JTAC_LIMIT_RED - 1 + end + else + if ctld.JTAC_LIMIT_BLUE == 0 then + _limitHit = true + else + ctld.JTAC_LIMIT_BLUE = ctld.JTAC_LIMIT_BLUE - 1 + end + end + + if _limitHit then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No more JTAC Crates Left!"), 10) + return + end + end + + if _heli:getPlayerName() ~= nil then + ctld.crateWait[_heli:getPlayerName()] = timer.getTime() + ctld.crateWaitTime + end + + local _heli = ctld.getTransportUnit(_args[1]) + + local _model_type = nil + + local _point = ctld.getPointAt12Oclock(_heli, 30) + local _position = "12" + + if ctld.unitDynamicCargoCapable(_heli) then + _model_type = "dynamic" + _point = ctld.getPointAt12Oclock(_heli, 30) + _position = "12" + end + + local _unitId = ctld.getNextUnitId() + + local _side = _heli:getCoalition() + + local _name = string.format("%s #%i", _crateType.desc, _unitId) + + ctld.spawnCrateStatic(_heli:getCountry(), _unitId, _point, _name, _crateType.weight, _side, 0, _model_type) + + -- add to move table + ctld.crateMove[_name] = _name + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("A %1 crate weighing %2 kg has been brought out and is at your %3 o'clock ", _crateType.desc, _crateType.weight, _position), 20) + else + env.info("Couldn't find crate item to spawn") + end + end, _arguments) + + if (not _status) then + env.error(string.format("CTLD ERROR: %s", _err)) + end +end + +ctld.randomCrateSpacing = 0 -- meters +function ctld.getPointAt12Oclock(_unit, _offset) + return ctld.getPointAtDirection(_unit, _offset, 0) +end + +function ctld.getPointAt6Oclock(_unit, _offset) + return ctld.getPointAtDirection(_unit, _offset, math.pi) +end + +function ctld.getPointAtDirection(_unit, _offset, _directionInRadian) + + ctld.logTrace("_offset = %s", ctld.p(_offset)) + local _randomOffsetX = math.random(ctld.randomCrateSpacing * 0 ) - ctld.randomCrateSpacing + local _randomOffsetZ = math.random(0, ctld.randomCrateSpacing) + ctld.logTrace("_randomOffsetX = %s", ctld.p(_randomOffsetX)) + ctld.logTrace("_randomOffsetZ = %s", ctld.p(_randomOffsetZ)) + local _position = _unit:getPosition() + local _angle = math.atan2(_position.x.z, _position.x.x) + _directionInRadian + local _xOffset = math.cos(_angle) * _offset + _randomOffsetX + local _zOffset = math.sin(_angle) * _offset + _randomOffsetZ + + local _point = _unit:getPoint() + return { x = _point.x + _xOffset, z = _point.z + _zOffset, y = _point.y } +end + +function ctld.troopsOnboard(_heli, _troops) + + if ctld.inTransitTroops[_heli:getName()] ~= nil then + + local _onboard = ctld.inTransitTroops[_heli:getName()] + + if _troops then + + if _onboard.troops ~= nil and _onboard.troops.units ~= nil and #_onboard.troops.units > 0 then + return true + else + return false + end + else + + if _onboard.vehicles ~= nil and _onboard.vehicles.units ~= nil and #_onboard.vehicles.units > 0 then + return true + else + return false + end + end + + else + return false + end +end + +-- if its dropped by AI then there is no player name so return the type of unit +function ctld.getPlayerNameOrType(_heli) + + if _heli:getPlayerName() == nil then + + return _heli:getTypeName() + else + return _heli:getPlayerName() + end +end + +function ctld.inExtractZone(_heli) + + local _heliPoint = _heli:getPoint() + + for _, _zoneDetails in pairs(ctld.extractZones) do + + --get distance to center + local _dist = ctld.getDistance(_heliPoint, _zoneDetails.point) + + if _dist <= _zoneDetails.radius then + return _zoneDetails + end + end + + return false +end + +-- safe to fast rope if speed is less than 0.5 Meters per second +function ctld.safeToFastRope(_heli) + + if ctld.enableFastRopeInsertion == false then + return false + end + + --landed or speed is less than 8 km/h and height is less than fast rope height + if (ctld.inAir(_heli) == false or (ctld.heightDiff(_heli) <= ctld.fastRopeMaximumHeight + 3.0 and mist.vec.mag(_heli:getVelocity()) < 2.2)) then + return true + end +end + +function ctld.metersToFeet(_meters) + + local _feet = _meters * 3.2808399 + + return mist.utils.round(_feet) +end + +function ctld.inAir(_heli) + + if _heli:inAir() == false then + return false + end + + -- less than 5 cm/s a second so landed + -- BUT AI can hold a perfect hover so ignore AI + if mist.vec.mag(_heli:getVelocity()) < 0.05 and _heli:getPlayerName() ~= nil then + return false + end + return true +end + +function ctld.deployTroops(_heli, _troops) + + local _onboard = ctld.inTransitTroops[_heli:getName()] + + -- deloy troops + if _troops then + if _onboard.troops ~= nil and #_onboard.troops.units > 0 then + if ctld.inAir(_heli) == false or ctld.safeToFastRope(_heli) then + + -- check we're not in extract zone + local _extractZone = ctld.inExtractZone(_heli) + + if _extractZone == false then + + local _droppedTroops = ctld.spawnDroppedGroup(_heli:getPoint(), _onboard.troops, false) + if _onboard.troops.jtac or _droppedTroops:getName():lower():find("jtac") then + local _code = table.remove(ctld.jtacGeneratedLaserCodes, 1) + table.insert(ctld.jtacGeneratedLaserCodes, _code) + ctld.JTACStart(_droppedTroops:getName(), _code) + end + + if _heli:getCoalition() == 1 then + + table.insert(ctld.droppedTroopsRED, _droppedTroops:getName()) + else + + table.insert(ctld.droppedTroopsBLUE, _droppedTroops:getName()) + end + + ctld.inTransitTroops[_heli:getName()].troops = nil + ctld.adaptWeightToCargo(_heli:getName()) + + if ctld.inAir(_heli) then + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 fast-ropped troops from %2 into combat", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + else + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 dropped troops from %2 into combat", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + end + + ctld.processCallback({unit = _heli, unloaded = _droppedTroops, action = "dropped_troops"}) + + + else + --extract zone! + local _droppedCount = trigger.misc.getUserFlag(_extractZone.flag) + + _droppedCount = (#_onboard.troops.units) + _droppedCount + + trigger.action.setUserFlag(_extractZone.flag, _droppedCount) + + ctld.inTransitTroops[_heli:getName()].troops = nil + ctld.adaptWeightToCargo(_heli:getName()) + + if ctld.inAir(_heli) then + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 fast-ropped troops from %2 into %3", ctld.getPlayerNameOrType(_heli), _heli:getTypeName(), _extractZone.name), 10) + else + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 dropped troops from %2 into %3", ctld.getPlayerNameOrType(_heli), _heli:getTypeName(), _extractZone.name), 10) + end + end + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Too high or too fast to drop troops into combat! Hover below %1 feet or land.", ctld.metersToFeet(ctld.fastRopeMaximumHeight)), 10) + end + end + + else + if ctld.inAir(_heli) == false then + if _onboard.vehicles ~= nil and #_onboard.vehicles.units > 0 then + + local _droppedVehicles = ctld.spawnDroppedGroup(_heli:getPoint(), _onboard.vehicles, true) + + if _heli:getCoalition() == 1 then + + table.insert(ctld.droppedVehiclesRED, _droppedVehicles:getName()) + else + + table.insert(ctld.droppedVehiclesBLUE, _droppedVehicles:getName()) + end + + ctld.inTransitTroops[_heli:getName()].vehicles = nil + ctld.adaptWeightToCargo(_heli:getName()) + + ctld.processCallback({unit = _heli, unloaded = _droppedVehicles, action = "dropped_vehicles"}) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 dropped vehicles from %2 into combat", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + end + end + end +end + +function ctld.insertIntoTroopsArray(_troopType,_count,_troopArray,_troopName) + + for _i = 1, _count do + local _unitId = ctld.getNextUnitId() + table.insert(_troopArray, { type = _troopType, unitId = _unitId, name = string.format("Dropped %s #%i", _troopName or _troopType, _unitId) }) + end + + return _troopArray + +end + + +function ctld.generateTroopTypes(_side, _countOrTemplate, _country) + local _troops = {} + local _weight = 0 + local _hasJTAC = false + + local function getSoldiersWeight(count, additionalWeight) + local _weight = 0 + for i = 1, count do + local _soldierWeight = math.random(90, 120) * ctld.SOLDIER_WEIGHT / 100 + _weight = _weight + _soldierWeight + ctld.KIT_WEIGHT + additionalWeight + end + return _weight + end + + if type(_countOrTemplate) == "table" then + + if _countOrTemplate.aa then + if _side == 2 then + _troops = ctld.insertIntoTroopsArray("Soldier stinger",_countOrTemplate.aa,_troops) + else + _troops = ctld.insertIntoTroopsArray("SA-18 Igla manpad",_countOrTemplate.aa,_troops) + end + _weight = _weight + getSoldiersWeight(_countOrTemplate.aa, ctld.MANPAD_WEIGHT) + end + + if _countOrTemplate.inf then + if _side == 2 then + _troops = ctld.insertIntoTroopsArray("Soldier M4 GRG",_countOrTemplate.inf,_troops) + else + _troops = ctld.insertIntoTroopsArray("Infantry AK",_countOrTemplate.inf,_troops) + end + _weight = _weight + getSoldiersWeight(_countOrTemplate.inf, ctld.RIFLE_WEIGHT) + end + + if _countOrTemplate.mg then + if _side == 2 then + _troops = ctld.insertIntoTroopsArray("Soldier M249",_countOrTemplate.mg,_troops) + else + _troops = ctld.insertIntoTroopsArray("Paratrooper AKS-74",_countOrTemplate.mg,_troops) + end + _weight = _weight + getSoldiersWeight(_countOrTemplate.mg, ctld.MG_WEIGHT) + end + + if _countOrTemplate.at then + _troops = ctld.insertIntoTroopsArray("Paratrooper RPG-16",_countOrTemplate.at,_troops) + _weight = _weight + getSoldiersWeight(_countOrTemplate.at, ctld.RPG_WEIGHT) + end + + if _countOrTemplate.mortar then + _troops = ctld.insertIntoTroopsArray("2B11 mortar",_countOrTemplate.mortar,_troops) + _weight = _weight + getSoldiersWeight(_countOrTemplate.mortar, ctld.MORTAR_WEIGHT) + end + + if _countOrTemplate.jtac then + if _side == 2 then + _troops = ctld.insertIntoTroopsArray("Soldier M4 GRG",_countOrTemplate.jtac,_troops, "JTAC") + else + _troops = ctld.insertIntoTroopsArray("Infantry AK",_countOrTemplate.jtac,_troops, "JTAC") + end + _hasJTAC = true + _weight = _weight + getSoldiersWeight(_countOrTemplate.jtac, ctld.JTAC_WEIGHT + ctld.RIFLE_WEIGHT) + end + + else + for _i = 1, _countOrTemplate do + + local _unitType = "Infantry AK" + + if _side == 2 then + if _i <=2 then + _unitType = "Soldier M249" + _weight = _weight + getSoldiersWeight(1, ctld.MG_WEIGHT) + elseif ctld.spawnRPGWithCoalition and _i > 2 and _i <= 4 then + _unitType = "Paratrooper RPG-16" + _weight = _weight + getSoldiersWeight(1, ctld.RPG_WEIGHT) + elseif ctld.spawnStinger and _i > 4 and _i <= 5 then + _unitType = "Soldier stinger" + _weight = _weight + getSoldiersWeight(1, ctld.MANPAD_WEIGHT) + else + _unitType = "Soldier M4 GRG" + _weight = _weight + getSoldiersWeight(1, ctld.RIFLE_WEIGHT) + end + else + if _i <=2 then + _unitType = "Paratrooper AKS-74" + _weight = _weight + getSoldiersWeight(1, ctld.MG_WEIGHT) + elseif ctld.spawnRPGWithCoalition and _i > 2 and _i <= 4 then + _unitType = "Paratrooper RPG-16" + _weight = _weight + getSoldiersWeight(1, ctld.RPG_WEIGHT) + elseif ctld.spawnStinger and _i > 4 and _i <= 5 then + _unitType = "SA-18 Igla manpad" + _weight = _weight + getSoldiersWeight(1, ctld.MANPAD_WEIGHT) + else + _unitType = "Infantry AK" + _weight = _weight + getSoldiersWeight(1, ctld.RIFLE_WEIGHT) + end + end + + local _unitId = ctld.getNextUnitId() + + _troops[_i] = { type = _unitType, unitId = _unitId, name = string.format("Dropped %s #%i", _unitType, _unitId) } + end + end + + local _groupId = ctld.getNextGroupId() + local _groupName = "Dropped Group" + if _hasJTAC then + _groupName = "Dropped JTAC Group" + end + local _details = { units = _troops, groupId = _groupId, groupName = string.format("%s %i", _groupName, _groupId), side = _side, country = _country, weight = _weight, jtac = _hasJTAC } + + return _details +end + +--Special F10 function for players for troops +function ctld.unloadExtractTroops(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + + if _heli == nil then + return false + end + + + local _extract = nil + if not ctld.inAir(_heli) then + if _heli:getCoalition() == 1 then + _extract = ctld.findNearestGroup(_heli, ctld.droppedTroopsRED) + else + _extract = ctld.findNearestGroup(_heli, ctld.droppedTroopsBLUE) + end + + end + + if _extract ~= nil and not ctld.troopsOnboard(_heli, true) then + -- search for nearest troops to pickup + return ctld.extractTroops({_heli:getName(), true}) + else + return ctld.unloadTroops({_heli:getName(),true,true}) + end + + +end + +-- load troops onto vehicle +function ctld.loadTroops(_heli, _troops, _numberOrTemplate) + + -- load troops + vehicles if c130 or herc + -- "M1045 HMMWV TOW" + -- "M1043 HMMWV Armament" + local _onboard = ctld.inTransitTroops[_heli:getName()] + + --number doesnt apply to vehicles + if _numberOrTemplate == nil or (type(_numberOrTemplate) ~= "table" and type(_numberOrTemplate) ~= "number") then + _numberOrTemplate = ctld.getTransportLimit(_heli:getTypeName()) + end + + if _onboard == nil then + _onboard = { troops = {}, vehicles = {} } + end + + local _list + if _heli:getCoalition() == 1 then + _list = ctld.vehiclesForTransportRED + else + _list = ctld.vehiclesForTransportBLUE + end + + if _troops then + _onboard.troops = ctld.generateTroopTypes(_heli:getCoalition(), _numberOrTemplate, _heli:getCountry()) + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 loaded troops into %2", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + + ctld.processCallback({unit = _heli, onboard = _onboard.troops, action = "load_troops"}) + else + + _onboard.vehicles = ctld.generateVehiclesForTransport(_heli:getCoalition(), _heli:getCountry()) + + local _count = #_list + + ctld.processCallback({unit = _heli, onboard = _onboard.vehicles, action = "load_vehicles"}) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 loaded %2 vehicles into %3", ctld.getPlayerNameOrType(_heli), _count, _heli:getTypeName()), 10) + end + + ctld.inTransitTroops[_heli:getName()] = _onboard + ctld.adaptWeightToCargo(_heli:getName()) +end + +function ctld.generateVehiclesForTransport(_side, _country) + + local _vehicles = {} + local _list + if _side == 1 then + _list = ctld.vehiclesForTransportRED + else + _list = ctld.vehiclesForTransportBLUE + end + + + for _i, _type in ipairs(_list) do + + local _unitId = ctld.getNextUnitId() + local _weight = ctld.vehiclesWeight[_type] or 2500 + _vehicles[_i] = { type = _type, unitId = _unitId, name = string.format("Dropped %s #%i", _type, _unitId), weight = _weight } + end + + + local _groupId = ctld.getNextGroupId() + local _details = { units = _vehicles, groupId = _groupId, groupName = string.format("Dropped Group %i", _groupId), side = _side, country = _country } + + return _details +end + +function ctld.loadUnloadFOBCrate(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _troops = _args[2] + + if _heli == nil then + return + end + + if ctld.inAir(_heli) == true then + return + end + + + local _side = _heli:getCoalition() + + local _inZone = ctld.inLogisticsZone(_heli) + local _crateOnboard = ctld.inTransitFOBCrates[_heli:getName()] ~= nil + + if _inZone == false and _crateOnboard == true then + + ctld.inTransitFOBCrates[_heli:getName()] = nil + + local _position = _heli:getPosition() + + --try to spawn at 6 oclock to us + local _angle = math.atan2(_position.x.z, _position.x.x) + local _xOffset = math.cos(_angle) * -60 + local _yOffset = math.sin(_angle) * -60 + + local _point = _heli:getPoint() + + local _side = _heli:getCoalition() + + local _unitId = ctld.getNextUnitId() + + local _name = string.format("FOB Crate #%i", _unitId) + + local _spawnedCrate = ctld.spawnFOBCrateStatic(_heli:getCountry(), ctld.getNextUnitId(), { x = _point.x + _xOffset, z = _point.z + _yOffset }, _name) + + if _side == 1 then + ctld.droppedFOBCratesRED[_name] = _name + else + ctld.droppedFOBCratesBLUE[_name] = _name + end + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 delivered a FOB Crate", ctld.getPlayerNameOrType(_heli)), 10) + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Delivered FOB Crate 60m at 6'oclock to you"), 10) + + elseif _inZone == true and _crateOnboard == true then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("FOB Crate dropped back to base"), 10) + + ctld.inTransitFOBCrates[_heli:getName()] = nil + + elseif _inZone == true and _crateOnboard == false then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("FOB Crate Loaded"), 10) + + ctld.inTransitFOBCrates[_heli:getName()] = true + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 loaded a FOB Crate ready for delivery!", ctld.getPlayerNameOrType(_heli)), 10) + + else + + -- nearest Crate + local _crates = ctld.getCratesAndDistance(_heli) + local _nearestCrate = ctld.getClosestCrate(_heli, _crates, "FOB") + + if _nearestCrate ~= nil and _nearestCrate.dist < 150 then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("FOB Crate Loaded"), 10) + ctld.inTransitFOBCrates[_heli:getName()] = true + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 loaded a FOB Crate ready for delivery!", ctld.getPlayerNameOrType(_heli)), 10) + + if _side == 1 then + ctld.droppedFOBCratesRED[_nearestCrate.crateUnit:getName()] = nil + else + ctld.droppedFOBCratesBLUE[_nearestCrate.crateUnit:getName()] = nil + end + + --remove + _nearestCrate.crateUnit:destroy() + + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("There are no friendly logistic units nearby to load a FOB crate from!"), 10) + end + end +end + +function ctld.loadTroopsFromZone(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _troops = _args[2] + local _groupTemplate = _args[3] or "" + local _allowExtract = _args[4] + + if _heli == nil then + return false + end + + local _zone = ctld.inPickupZone(_heli) + + if ctld.troopsOnboard(_heli, _troops) then + + if _troops then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You already have troops onboard."), 10) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You already have vehicles onboard."), 10) + end + + return false + end + + local _extract + + if _allowExtract then + -- first check for extractable troops regardless of if we're in a zone or not + if _troops then + if _heli:getCoalition() == 1 then + _extract = ctld.findNearestGroup(_heli, ctld.droppedTroopsRED) + else + _extract = ctld.findNearestGroup(_heli, ctld.droppedTroopsBLUE) + end + else + + if _heli:getCoalition() == 1 then + _extract = ctld.findNearestGroup(_heli, ctld.droppedVehiclesRED) + else + _extract = ctld.findNearestGroup(_heli, ctld.droppedVehiclesBLUE) + end + end + end + + if _extract ~= nil then + -- search for nearest troops to pickup + return ctld.extractTroops({_heli:getName(), _troops}) + elseif _zone.inZone == true then + + if _zone.limit - 1 >= 0 then + -- decrease zone counter by 1 + ctld.updateZoneCounter(_zone.index, -1) + + ctld.loadTroops(_heli, _troops,_groupTemplate) + + return true + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("This area has no more reinforcements available!"), 20) + + return false + end + + else + if _allowExtract then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not in a pickup zone and no one is nearby to extract"), 10) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not in a pickup zone"), 10) + end + + return false + end +end + + + +function ctld.unloadTroops(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _troops = _args[2] + + if _heli == nil then + return false + end + + local _zone = ctld.inPickupZone(_heli) + if not ctld.troopsOnboard(_heli, _troops) then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No one to unload"), 10) + + return false + else + + -- troops must be onboard to get here + if _zone.inZone == true then + + if _troops then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Dropped troops back to base"), 20) + + ctld.processCallback({unit = _heli, unloaded = ctld.inTransitTroops[_heli:getName()].troops, action = "unload_troops_zone"}) + + ctld.inTransitTroops[_heli:getName()].troops = nil + + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Dropped vehicles back to base"), 20) + + ctld.processCallback({unit = _heli, unloaded = ctld.inTransitTroops[_heli:getName()].vehicles, action = "unload_vehicles_zone"}) + + ctld.inTransitTroops[_heli:getName()].vehicles = nil + end + + ctld.adaptWeightToCargo(_heli:getName()) + + -- increase zone counter by 1 + ctld.updateZoneCounter(_zone.index, 1) + + return true + + elseif ctld.troopsOnboard(_heli, _troops) then + + return ctld.deployTroops(_heli, _troops) + end + end + +end + +function ctld.extractTroops(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _troops = _args[2] + + if _heli == nil then + return false + end + + if ctld.inAir(_heli) then + return false + end + + if ctld.troopsOnboard(_heli, _troops) then + if _troops then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You already have troops onboard."), 10) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You already have vehicles onboard."), 10) + end + + return false + end + + local _onboard = ctld.inTransitTroops[_heli:getName()] + + if _onboard == nil then + _onboard = { troops = nil, vehicles = nil } + end + + local _extracted = false + + if _troops then + + local _extractTroops + + if _heli:getCoalition() == 1 then + _extractTroops = ctld.findNearestGroup(_heli, ctld.droppedTroopsRED) + else + _extractTroops = ctld.findNearestGroup(_heli, ctld.droppedTroopsBLUE) + end + + + if _extractTroops ~= nil then + + local _limit = ctld.getTransportLimit(_heli:getTypeName()) + + local _size = #_extractTroops.group:getUnits() + + if _limit < #_extractTroops.group:getUnits() then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Sorry - The group of %1 is too large to fit. \n\nLimit is %2 for %3", _size, _limit, _heli:getTypeName()), 20) + + return + end + + _onboard.troops = _extractTroops.details + _onboard.troops.weight = #_extractTroops.group:getUnits() * 130 -- default to 130kg per soldier + + if _extractTroops.group:getName():lower():find("jtac") then + _onboard.troops.jtac = true + end + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 extracted troops in %2 from combat", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + + if _heli:getCoalition() == 1 then + ctld.droppedTroopsRED[_extractTroops.group:getName()] = nil + else + ctld.droppedTroopsBLUE[_extractTroops.group:getName()] = nil + end + + ctld.processCallback({unit = _heli, extracted = _extractTroops, action = "extract_troops"}) + + --remove + _extractTroops.group:destroy() + + _extracted = true + else + _onboard.troops = nil + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No extractable troops nearby!"), 20) + end + + else + + local _extractVehicles + + + if _heli:getCoalition() == 1 then + + _extractVehicles = ctld.findNearestGroup(_heli, ctld.droppedVehiclesRED) + else + + _extractVehicles = ctld.findNearestGroup(_heli, ctld.droppedVehiclesBLUE) + end + + if _extractVehicles ~= nil then + _onboard.vehicles = _extractVehicles.details + + if _heli:getCoalition() == 1 then + + ctld.droppedVehiclesRED[_extractVehicles.group:getName()] = nil + else + + ctld.droppedVehiclesBLUE[_extractVehicles.group:getName()] = nil + end + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 extracted vehicles in %2 from combat", ctld.getPlayerNameOrType(_heli), _heli:getTypeName()), 10) + + ctld.processCallback({unit = _heli, extracted = _extractVehicles, action = "extract_vehicles"}) + --remove + _extractVehicles.group:destroy() + _extracted = true + + else + _onboard.vehicles = nil + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No extractable vehicles nearby!"), 20) + end + end + + ctld.inTransitTroops[_heli:getName()] = _onboard + ctld.adaptWeightToCargo(_heli:getName()) + + return _extracted +end + + +function ctld.checkTroopStatus(_args) + local _unitName = _args[1] + --list onboard troops, if c130 + local _heli = ctld.getTransportUnit(_unitName) + + if _heli == nil then + return + end + + local _, _message = ctld.getWeightOfCargo(_unitName) + if _message and _message ~= "" then + ctld.displayMessageToGroup(_heli, _message, 10) + end +end + +-- Removes troops from transport when it dies +function ctld.checkTransportStatus() + + timer.scheduleFunction(ctld.checkTransportStatus, nil, timer.getTime() + 3) + + for _, _name in ipairs(ctld.transportPilotNames) do + + local _transUnit = ctld.getTransportUnit(_name) + + if _transUnit == nil then + --env.info("CTLD Transport Unit Dead event") + ctld.inTransitTroops[_name] = nil + ctld.inTransitFOBCrates[_name] = nil + ctld.inTransitSlingLoadCrates[_name] = nil + end + end +end + +function ctld.adaptWeightToCargo(unitName) + local _weight = ctld.getWeightOfCargo(unitName) + trigger.action.setUnitInternalCargo(unitName, _weight) +end + +function ctld.getWeightOfCargo(unitName) + + local FOB_CRATE_WEIGHT = 800 + local _weight = 0 + local _description = "" + + ctld.inTransitSlingLoadCrates[unitName] = ctld.inTransitSlingLoadCrates[unitName] or {} + + -- add troops weight + if ctld.inTransitTroops[unitName] then + local _inTransit = ctld.inTransitTroops[unitName] + if _inTransit then + local _troops = _inTransit.troops + if _troops and _troops.units then + _description = _description .. ctld.i18n_translate("%1 troops onboard (%2 kg)\n", #_troops.units, _troops.weight) + _weight = _weight + _troops.weight + end + local _vehicles = _inTransit.vehicles + if _vehicles and _vehicles.units then + for _, _unit in pairs(_vehicles.units) do + _weight = _weight + _unit.weight + end + _description = _description .. ctld.i18n_translate("%1 vehicles onboard (%2)\n", #_vehicles.units, _weight) + end + end + end + + -- add FOB crates weight + if ctld.inTransitFOBCrates[unitName] then + _weight = _weight + FOB_CRATE_WEIGHT + _description = _description .. ctld.i18n_translate("1 FOB Crate oboard (%1 kg)\n", FOB_CRATE_WEIGHT) + end + + -- add simulated slingload crates weight + for i = 1, #ctld.inTransitSlingLoadCrates[unitName] do + local _crate = ctld.inTransitSlingLoadCrates[unitName][i] + if _crate and _crate.simulatedSlingload then + _weight = _weight + _crate.weight + _description = _description .. ctld.i18n_translate("%1 crate onboard (%2 kg)\n", _crate.desc, _crate.weight) + end + end + if _description ~= "" then + _description = _description .. ctld.i18n_translate("Total weight of cargo : %1 kg\n", _weight) + else + _description = ctld.i18n_translate("No cargo.") + end + + return _weight, _description +end + +function ctld.checkHoverStatus() + timer.scheduleFunction(ctld.checkHoverStatus, nil, timer.getTime() + 1.0) + + local _status, _result = pcall(function() + + for _, _name in ipairs(ctld.transportPilotNames) do + + local _reset = true + local _transUnit = ctld.getTransportUnit(_name) + local _transUnitTypeName = _transUnit and _transUnit:getTypeName() + local _cargoCapacity = ctld.internalCargoLimits[_transUnitTypeName] or 1 + ctld.inTransitSlingLoadCrates[_name] = ctld.inTransitSlingLoadCrates[_name] or {} + + --only check transports that are hovering and not planes + if _transUnit ~= nil and #ctld.inTransitSlingLoadCrates[_name] < _cargoCapacity and ctld.inAir(_transUnit) and ctld.unitCanCarryVehicles(_transUnit) == false and not ctld.unitDynamicCargoCapable(_transUnit) then + + + local _crates = ctld.getCratesAndDistance(_transUnit) + + for _, _crate in pairs(_crates) do + local _crateUnitName = _crate.crateUnit:getName() + if _crate.dist < ctld.maxDistanceFromCrate and _crate.details.unit ~= "FOB" then + + --check height! + local _height = _transUnit:getPoint().y - _crate.crateUnit:getPoint().y + if _height > ctld.minimumHoverHeight and _height <= ctld.maximumHoverHeight then + + local _time = ctld.hoverStatus[_name] + + if _time == nil then + ctld.hoverStatus[_name] = ctld.hoverTime + _time = ctld.hoverTime + else + _time = ctld.hoverStatus[_name] - 1 + ctld.hoverStatus[_name] = _time + end + + if _time > 0 then + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("Hovering above %1 crate. \n\nHold hover for %2 seconds! \n\nIf the countdown stops you're too far away!", _crate.details.desc, _time), 10,true) + else + ctld.hoverStatus[_name] = nil + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("Loaded %1 crate!", _crate.details.desc), 10,true) + + --crates been moved once! + ctld.crateMove[_crateUnitName] = nil + + if _transUnit:getCoalition() == 1 then + ctld.spawnedCratesRED[_crateUnitName] = nil + else + ctld.spawnedCratesBLUE[_crateUnitName] = nil + end + + _crate.crateUnit:destroy() + + local _copiedCrate = mist.utils.deepCopy(_crate.details) + _copiedCrate.simulatedSlingload = true + table.insert(ctld.inTransitSlingLoadCrates[_name], _copiedCrate) + ctld.adaptWeightToCargo(_name) + end + + _reset = false + + break + elseif _height <= ctld.minimumHoverHeight then + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("Too low to hook %1 crate.\n\nHold hover for %2 seconds", _crate.details.desc, ctld.hoverTime), 5,true) + break + else + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("Too high to hook %1 crate.\n\nHold hover for %2 seconds", _crate.details.desc, ctld.hoverTime), 5,true) + break + end + end + end + end + + if _reset then + ctld.hoverStatus[_name] = nil + end + end + end) + + if (not _status) then + env.error(string.format("CTLD ERROR: %s", _result)) + end +end + +function ctld.loadNearbyCrate(_name) + local _transUnit = ctld.getTransportUnit(_name) + + if _transUnit ~= nil then + + local _cargoCapacity = ctld.internalCargoLimits[_transUnit:getTypeName()] or 1 + ctld.inTransitSlingLoadCrates[_name] = ctld.inTransitSlingLoadCrates[_name] or {} + + if ctld.inAir(_transUnit) then + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("You must land before you can load a crate!"), 10,true) + return + end + + local _crates = ctld.getCratesAndDistance(_transUnit) + local loaded = false + for _, _crate in pairs(_crates) do + + if _crate.dist < 50.0 then + if #ctld.inTransitSlingLoadCrates[_name] < _cargoCapacity then + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("Loaded %1 crate!", _crate.details.desc), 10) + + if _transUnit:getCoalition() == 1 then + ctld.spawnedCratesRED[_crate.crateUnit:getName()] = nil + else + ctld.spawnedCratesBLUE[_crate.crateUnit:getName()] = nil + end + + ctld.crateMove[_crate.crateUnit:getName()] = nil + + _crate.crateUnit:destroy() + + local _copiedCrate = mist.utils.deepCopy(_crate.details) + _copiedCrate.simulatedSlingload = true + table.insert(ctld.inTransitSlingLoadCrates[_name], _copiedCrate) + loaded = true + ctld.adaptWeightToCargo(_name) + else + -- Max crates onboard + local outputMsg = ctld.i18n_translate("Crate Loaded!") + for i = 1, _cargoCapacity do + outputMsg = outputMsg .. "\n" .. ctld.inTransitSlingLoadCrates[_name][i].desc + end + ctld.displayMessageToGroup(_transUnit, outputMsg, 10,true) + return + end + end + end + if not loaded then + ctld.displayMessageToGroup(_transUnit, ctld.i18n_translate("No Crates within 50m to load!"), 10,true) + end + end +end + +--check each minute if the beacons' batteries have failed, and stop them accordingly +--there's no more need to actually refresh the beacons, since we set "loop" to true. +function ctld.refreshRadioBeacons() + + timer.scheduleFunction(ctld.refreshRadioBeacons, nil, timer.getTime() + 60) + + + for _index, _beaconDetails in ipairs(ctld.deployedRadioBeacons) do + + if ctld.updateRadioBeacon(_beaconDetails) == false then + + --search used frequencies + remove, add back to unused + + for _i, _freq in ipairs(ctld.usedUHFFrequencies) do + if _freq == _beaconDetails.uhf then + + table.insert(ctld.freeUHFFrequencies, _freq) + table.remove(ctld.usedUHFFrequencies, _i) + end + end + + for _i, _freq in ipairs(ctld.usedVHFFrequencies) do + if _freq == _beaconDetails.vhf then + + table.insert(ctld.freeVHFFrequencies, _freq) + table.remove(ctld.usedVHFFrequencies, _i) + end + end + + for _i, _freq in ipairs(ctld.usedFMFrequencies) do + if _freq == _beaconDetails.fm then + + table.insert(ctld.freeFMFrequencies, _freq) + table.remove(ctld.usedFMFrequencies, _i) + end + end + + --clean up beacon table + table.remove(ctld.deployedRadioBeacons, _index) + end + end +end + +function ctld.getClockDirection(_heli, _crate) + + -- Source: Helicopter Script - Thanks! + + local _position = _crate:getPosition().p -- get position of crate + local _playerPosition = _heli:getPosition().p -- get position of helicopter + local _relativePosition = mist.vec.sub(_position, _playerPosition) + + local _playerHeading = mist.getHeading(_heli) -- the rest of the code determines the 'o'clock' bearing of the missile relative to the helicopter + + local _headingVector = { x = math.cos(_playerHeading), y = 0, z = math.sin(_playerHeading) } + + local _headingVectorPerpendicular = { x = math.cos(_playerHeading + math.pi / 2), y = 0, z = math.sin(_playerHeading + math.pi / 2) } + + local _forwardDistance = mist.vec.dp(_relativePosition, _headingVector) + + local _rightDistance = mist.vec.dp(_relativePosition, _headingVectorPerpendicular) + + local _angle = math.atan2(_rightDistance, _forwardDistance) * 180 / math.pi + + if _angle < 0 then + _angle = 360 + _angle + end + _angle = math.floor(_angle * 12 / 360 + 0.5) + if _angle == 0 then + _angle = 12 + end + + return _angle +end + + +function ctld.getCompassBearing(_ref, _unitPos) + + _ref = mist.utils.makeVec3(_ref, 0) -- turn it into Vec3 if it is not already. + _unitPos = mist.utils.makeVec3(_unitPos, 0) -- turn it into Vec3 if it is not already. + + local _vec = { x = _unitPos.x - _ref.x, y = _unitPos.y - _ref.y, z = _unitPos.z - _ref.z } + + local _dir = mist.utils.getDir(_vec, _ref) + + local _bearing = mist.utils.round(mist.utils.toDegree(_dir), 0) + + return _bearing +end + +function ctld.listNearbyCrates(_args) + + local _message = "" + + local _heli = ctld.getTransportUnit(_args[1]) + + if _heli == nil then + + return -- no heli! + end + + local _crates = ctld.getCratesAndDistance(_heli) + + --sort + local _sort = function( a,b ) return a.dist < b.dist end + table.sort(_crates,_sort) + + for _, _crate in pairs(_crates) do + + if _crate.dist < 1000 and _crate.details.unit ~= "FOB" then + _message = ctld.i18n_translate("%1\n%2 crate - kg %3 - %4 m - %5 o'clock", _message, _crate.details.desc, _crate.details.weight, _crate.dist, ctld.getClockDirection(_heli, _crate.crateUnit)) + end + end + + + local _fobMsg = "" + for _, _fobCrate in pairs(_crates) do + + if _fobCrate.dist < 1000 and _fobCrate.details.unit == "FOB" then + _fobMsg = _fobMsg .. ctld.i18n_translate("FOB Crate - %1 m - %2 o'clock\n", _fobCrate.dist, ctld.getClockDirection(_heli, _fobCrate.crateUnit)) + end + end + + local _txt = ctld.i18n_translate("No Nearby Crates") + if _message ~= "" or _fobMsg ~= "" then + + _txt = "" + + if _message ~= "" then + _txt = ctld.i18n_translate("Nearby Crates:\n%1", _message) + end + + if _fobMsg ~= "" then + + if _txt ~= "" then + _txt = _txt .. "\n\n" + end + + _txt = _txt .. ctld.i18n_translate("Nearby FOB Crates (Not Slingloadable):\n%1", _fobMsg) + end + end + ctld.displayMessageToGroup(_heli, _txt, 20) +end + + +function ctld.listFOBS(_args) + + local _msg = ctld.i18n_translate("FOB Positions:") + + local _heli = ctld.getTransportUnit(_args[1]) + + if _heli == nil then + + return -- no heli! + end + + -- get fob positions + local _fobs = ctld.getSpawnedFobs(_heli) + + if _fobs and #_fobs > 0 then + -- now check spawned fobs + for _, _fob in ipairs(_fobs) do + _msg = ctld.i18n_translate("%1\nFOB @ %2", _msg, ctld.getFOBPositionString(_fob)) + end + else + _msg = ctld.i18n_translate("Sorry, there are no active FOBs!") + end + ctld.displayMessageToGroup(_heli, _msg, 20) +end + +function ctld.getFOBPositionString(_fob) + + local _lat, _lon = coord.LOtoLL(_fob:getPosition().p) + + local _latLngStr = mist.tostringLL(_lat, _lon, 3, ctld.location_DMS) + + -- local _mgrsString = mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(_fob:getPosition().p)), 5) + + local _message = _latLngStr + + local _beaconInfo = ctld.fobBeacons[_fob:getName()] + + if _beaconInfo ~= nil then + _message = string.format("%s - %.2f KHz ", _message, _beaconInfo.vhf / 1000) + _message = string.format("%s - %.2f MHz ", _message, _beaconInfo.uhf / 1000000) + _message = string.format("%s - %.2f MHz ", _message, _beaconInfo.fm / 1000000) + end + + return _message +end + + +function ctld.displayMessageToGroup(_unit, _text, _time,_clear) + + local _groupId = ctld.getGroupId(_unit) + if _groupId then + if _clear == true then + trigger.action.outTextForGroup(_groupId, _text, _time,_clear) + else + trigger.action.outTextForGroup(_groupId, _text, _time) + end + end +end + +function ctld.heightDiff(_unit) + + local _point = _unit:getPoint() + + -- env.info("heightunit " .. _point.y) + --env.info("heightland " .. land.getHeight({ x = _point.x, y = _point.z })) + + return _point.y - land.getHeight({ x = _point.x, y = _point.z }) +end + +--includes fob crates! +function ctld.getCratesAndDistance(_heli) + + local _crates = {} + + local _allCrates + if _heli:getCoalition() == 1 then + _allCrates = ctld.spawnedCratesRED + else + _allCrates = ctld.spawnedCratesBLUE + end + + for _crateName, _details in pairs(_allCrates) do + + --get crate + local _crate = ctld.getCrateObject(_crateName) + + --in air seems buggy with crates so if in air is true, get the height above ground and the speed magnitude + if _crate ~= nil and _crate:getLife() > 0 + and (ctld.inAir(_crate) == false) then + + local _dist = ctld.getDistance(_crate:getPoint(), _heli:getPoint()) + + local _crateDetails = { crateUnit = _crate, dist = _dist, details = _details } + + table.insert(_crates, _crateDetails) + end + end + + local _fobCrates + if _heli:getCoalition() == 1 then + _fobCrates = ctld.droppedFOBCratesRED + else + _fobCrates = ctld.droppedFOBCratesBLUE + end + + for _crateName, _details in pairs(_fobCrates) do + + --get crate + local _crate = ctld.getCrateObject(_crateName) + + if _crate ~= nil and _crate:getLife() > 0 then + + local _dist = ctld.getDistance(_crate:getPoint(), _heli:getPoint()) + + local _crateDetails = { crateUnit = _crate, dist = _dist, details = { unit = "FOB" }, } + + table.insert(_crates, _crateDetails) + end + end + + return _crates +end + + +function ctld.getClosestCrate(_heli, _crates, _type) + + local _closetCrate = nil + local _shortestDistance = -1 + local _distance = 0 + local _minimumDistance = 5 -- prevents dynamic cargo crates from unpacking while in cargo hold + + for _, _crate in pairs(_crates) do + + if (_crate.details.unit == _type or _type == nil) then + _distance = _crate.dist + + if _distance ~= nil and (_shortestDistance == -1 or _distance < _shortestDistance) and _distance > _minimumDistance then + _shortestDistance = _distance + _closetCrate = _crate + end + end + end + + return _closetCrate +end + +function ctld.findNearestAASystem(_heli,_aaSystem) + + local _closestHawkGroup = nil + local _shortestDistance = -1 + local _distance = 0 + + for _groupName, _hawkDetails in pairs(ctld.completeAASystems) do + + local _hawkGroup = Group.getByName(_groupName) + + -- env.info(_groupName..": "..mist.utils.tableShow(_hawkDetails)) + if _hawkGroup ~= nil and _hawkGroup:getCoalition() == _heli:getCoalition() and _hawkDetails[1].system.name == _aaSystem.name then + + local _units = _hawkGroup:getUnits() + + for _, _leader in pairs(_units) do + + if _leader ~= nil and _leader:getLife() > 0 then + + _distance = ctld.getDistance(_leader:getPoint(), _heli:getPoint()) + + if _distance ~= nil and (_shortestDistance == -1 or _distance < _shortestDistance) then + _shortestDistance = _distance + _closestHawkGroup = _hawkGroup + end + + break + end + end + end + end + + if _closestHawkGroup ~= nil then + + return { group = _closestHawkGroup, dist = _shortestDistance } + end + return nil +end + +function ctld.getCrateObject(_name) + local _crate + + if ctld.staticBugWorkaround then + _crate = Unit.getByName(_name) + else + _crate = StaticObject.getByName(_name) + end + return _crate +end + + + +function ctld.unpackCrates(_arguments) + + local _status, _err = pcall(function(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + + if _heli ~= nil and ctld.inAir(_heli) == false then + + local _crates = ctld.getCratesAndDistance(_heli) + local _crate = ctld.getClosestCrate(_heli, _crates) + + + if ctld.inLogisticsZone(_heli) == true or ctld.farEnoughFromLogisticZone(_heli) == false then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You can't unpack that here! Take it to where it's needed!"), 20) + + return + end + + + + if _crate ~= nil and _crate.dist < 750 + and (_crate.details.unit == "FOB" or _crate.details.unit == "FOB-SMALL") then + + ctld.unpackFOBCrates(_crates, _heli) + + return + + elseif _crate ~= nil and _crate.dist < 200 then + + if ctld.forceCrateToBeMoved and ctld.crateMove[_crate.crateUnit:getName()] and not ctld.unitDynamicCargoCapable(_heli) then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Sorry you must move this crate before you unpack it!"), 20) + return + end + + + local _aaTemplate = ctld.getAATemplate(_crate.details.unit) + + if _aaTemplate then + + if _crate.details.unit == _aaTemplate.repair then + ctld.repairAASystem(_heli, _crate,_aaTemplate) + else + ctld.unpackAASystem(_heli, _crate, _crates,_aaTemplate) + end + + return -- stop processing + -- is multi crate? + elseif _crate.details.cratesRequired ~= nil and _crate.details.cratesRequired > 1 then + -- multicrate + + ctld.unpackMultiCrate(_heli, _crate, _crates) + + return + + else + -- single crate + local _cratePoint = _crate.crateUnit:getPoint() + local _crateName = _crate.crateUnit:getName() + local _crateHdg = mist.getHeading(_crate.crateUnit, true) + + --remove crate + -- if ctld.slingLoad == false then + _crate.crateUnit:destroy() + -- end + + local _spawnedGroups = ctld.spawnCrateGroup(_heli, { _cratePoint }, { _crate.details.unit }, { _crateHdg }) + + if _heli:getCoalition() == 1 then + ctld.spawnedCratesRED[_crateName] = nil + else + ctld.spawnedCratesBLUE[_crateName] = nil + end + + ctld.processCallback({unit = _heli, crate = _crate , spawnedGroup = _spawnedGroups, action = "unpack"}) + + if _crate.details.unit == "1L13 EWR" then + ctld.addEWRTask(_spawnedGroups) + + -- env.info("Added EWR") + end + + if _crate.details.unit == "FPS-117" then + ctld.addEWRTask(_spawnedGroups) + + -- env.info("Added EWR") + end + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 successfully deployed %2 to the field", ctld.getPlayerNameOrType(_heli), _crate.details.desc), 10) + + if ctld.isJTACUnitType(_crate.details.unit) and ctld.JTAC_dropEnabled then + + local _code = table.remove(ctld.jtacGeneratedLaserCodes, 1) + --put to the end + table.insert(ctld.jtacGeneratedLaserCodes, _code) + + ctld.JTACStart(_spawnedGroups:getName(), _code) --(_jtacGroupName, _laserCode, _smoke, _lock, _colour) + end + end + + else + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No friendly crates close enough to unpack, or crate too close to aircraft."), 20) + end + end + end, _arguments) + + if (not _status) then + env.error(string.format("CTLD ERROR: %s", _err)) + end +end + + +-- builds a fob! +function ctld.unpackFOBCrates(_crates, _heli) + + if ctld.inLogisticsZone(_heli) == true then + + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You can't unpack that here! Take it to where it's needed!"), 20) + + return + end + + -- unpack multi crate + local _nearbyMultiCrates = {} + + local _bigFobCrates = 0 + local _smallFobCrates = 0 + local _totalCrates = 0 + + for _, _nearbyCrate in pairs(_crates) do + + if _nearbyCrate.dist < 750 then + + if _nearbyCrate.details.unit == "FOB" then + _bigFobCrates = _bigFobCrates + 1 + table.insert(_nearbyMultiCrates, _nearbyCrate) + elseif _nearbyCrate.details.unit == "FOB-SMALL" then + _smallFobCrates = _smallFobCrates + 1 + table.insert(_nearbyMultiCrates, _nearbyCrate) + end + + --catch divide by 0 + if _smallFobCrates > 0 then + _totalCrates = _bigFobCrates + (_smallFobCrates/3.0) + else + _totalCrates = _bigFobCrates + end + + if _totalCrates >= ctld.cratesRequiredForFOB then + break + end + end + end + + --- check crate count + if _totalCrates >= ctld.cratesRequiredForFOB then + + -- destroy crates + + local _points = {} + + for _, _crate in pairs(_nearbyMultiCrates) do + + if _heli:getCoalition() == 1 then + ctld.droppedFOBCratesRED[_crate.crateUnit:getName()] = nil + ctld.spawnedCratesRED[_crate.crateUnit:getName()] = nil + else + ctld.droppedFOBCratesBLUE[_crate.crateUnit:getName()] = nil + ctld.spawnedCratesBLUE[_crate.crateUnit:getName()] = nil + end + + table.insert(_points, _crate.crateUnit:getPoint()) + + --destroy + _crate.crateUnit:destroy() + end + + local _centroid = ctld.getCentroid(_points) + + timer.scheduleFunction(function(_args) + + local _unitId = ctld.getNextUnitId() + local _name = "Deployed FOB #" .. _unitId + + local _fob = ctld.spawnFOB(_args[2], _unitId, _args[1], _name) + + --make it able to deploy crates + table.insert(ctld.logisticUnits, _fob:getName()) + + ctld.beaconCount = ctld.beaconCount + 1 + + local _radioBeaconName = "FOB Beacon #" .. ctld.beaconCount + + local _radioBeaconDetails = ctld.createRadioBeacon(_args[1], _args[3], _args[2], _radioBeaconName, nil, true) + + ctld.fobBeacons[_name] = { vhf = _radioBeaconDetails.vhf, uhf = _radioBeaconDetails.uhf, fm = _radioBeaconDetails.fm } + + if ctld.troopPickupAtFOB == true then + table.insert(ctld.builtFOBS, _fob:getName()) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("Finished building FOB! Crates and Troops can now be picked up."), 10) + else + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("Finished building FOB! Crates can now be picked up."), 10) + end + end, { _centroid, _heli:getCountry(), _heli:getCoalition() }, timer.getTime() + ctld.buildTimeFOB) + + ctld.processCallback({unit = _heli, position = _centroid, action = "fob"}) + + trigger.action.smoke(_centroid, trigger.smokeColor.Green) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 started building FOB using %2 FOB crates, it will be finished in %3 seconds.\nPosition marked with smoke.", ctld.getPlayerNameOrType(_heli), _totalCrates, ctld.buildTimeFOB, 10)) + else + local _txt = ctld.i18n_translate("Cannot build FOB!\n\nIt requires %1 Large FOB crates ( 3 small FOB crates equal 1 large FOB Crate) and there are the equivalent of %2 large FOB crates nearby\n\nOr the crates are not within 750m of each other", ctld.cratesRequiredForFOB, _totalCrates) + ctld.displayMessageToGroup(_heli, _txt, 20) + end +end + +--unloads the sling crate when the helicopter is on the ground or between 4.5 - 10 meters +function ctld.dropSlingCrate(_args) + local _unitName = _args[1] + local _heli = ctld.getTransportUnit(_unitName) + ctld.inTransitSlingLoadCrates[_unitName] = ctld.inTransitSlingLoadCrates[_unitName] or {} + + if _heli == nil then + return -- no heli! + end + + local _currentCrate = ctld.inTransitSlingLoadCrates[_unitName][#ctld.inTransitSlingLoadCrates[_unitName]] + + if _currentCrate == nil then + if ctld.hoverPickup and ctld.loadCrateFromMenu then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not currently transporting any crates. \n\nTo Pickup a crate, hover for %1 seconds above the crate or land and use F10 Crate Commands.", ctld.hoverTime), 10) + elseif ctld.hoverPickup then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not currently transporting any crates. \n\nTo Pickup a crate, hover for %1 seconds above the crate.", ctld.hoverTime), 10) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You are not currently transporting any crates. \n\nTo Pickup a crate, land and use F10 Crate Commands to load one."), 10) + end + else + local _point = _heli:getPoint() + local _side = _heli:getCoalition() + local _hdg = mist.getHeading(_heli, true) + local _heightDiff = ctld.heightDiff(_heli) + + if _heightDiff > 50.0 then + table.remove(ctld.inTransitSlingLoadCrates[_unitName],#ctld.inTransitSlingLoadCrates[_unitName]) + ctld.adaptWeightToCargo(_unitName) + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You were too high! The crate has been destroyed"), 10) + return + end + local _loadedCratesCopy = mist.utils.deepCopy(ctld.inTransitSlingLoadCrates[_unitName]) + ctld.logTrace("_loadedCratesCopy = %s", ctld.p(_loadedCratesCopy)) + for _, _crate in pairs(_loadedCratesCopy) do + ctld.logTrace("_crate = %s", ctld.p(_crate)) + ctld.logTrace("ctld.inAir(_heli) = %s", ctld.p(ctld.inAir(_heli))) + ctld.logTrace("_heightDiff = %s", ctld.p(_heightDiff)) + local _unitId = ctld.getNextUnitId() + local _name = string.format("%s #%i", _crate.desc, _unitId) + local _model_type = nil + if ctld.inAir(_heli) == false or _heightDiff <= 7.5 then + _point = ctld.getPointAt12Oclock(_heli, 30) + local _position = "12" + if ctld.unitDynamicCargoCapable(_heli) then + _model_type = "dynamic" + _point = ctld.getPointAt12Oclock(_heli, 30) + _position = "12" + end + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("%1 crate has been safely unhooked and is at your %2 o'clock", _crate.desc, _position), 10) + elseif _heightDiff > 7.5 and _heightDiff <= 50.0 then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("%1 crate has been safely dropped below you", _crate.desc), 10) + end + --remove crate from cargo + table.remove(ctld.inTransitSlingLoadCrates[_unitName],#ctld.inTransitSlingLoadCrates[_unitName]) + ctld.spawnCrateStatic(_heli:getCountry(), _unitId, _point, _name, _crate.weight, _side, _hdg, _model_type) + end + ctld.adaptWeightToCargo(_unitName) + end +end + +--spawns a radio beacon made up of two units, +-- one for VHF and one for UHF +-- The units are set to to NOT engage +function ctld.createRadioBeacon(_point, _coalition, _country, _name, _batteryTime, _isFOB) + + local _freq = ctld.generateADFFrequencies() + + --create timeout + local _battery + + if _batteryTime == nil then + _battery = timer.getTime() + (ctld.deployedBeaconBattery * 60) + else + _battery = timer.getTime() + (_batteryTime * 60) + end + + local _lat, _lon = coord.LOtoLL(_point) + + local _latLngStr = mist.tostringLL(_lat, _lon, 3, ctld.location_DMS) + + --local _mgrsString = mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(_point)), 5) + + local _freqsText = _name + + if _isFOB then + -- _message = "FOB " .. _message + _battery = -1 --never run out of power! + end + + _freqsText = _freqsText .. " - " .. _latLngStr + + + _freqsText = string.format("%.2f kHz - %.2f / %.2f MHz", _freq.vhf / 1000, _freq.uhf / 1000000, _freq.fm / 1000000) + + local _uhfGroup = ctld.spawnRadioBeaconUnit(_point, _country, _name, _freqsText) + local _vhfGroup = ctld.spawnRadioBeaconUnit(_point, _country, _name, _freqsText) + local _fmGroup = ctld.spawnRadioBeaconUnit(_point, _country, _name, _freqsText) + + local _beaconDetails = { + vhf = _freq.vhf, + vhfGroup = _vhfGroup:getName(), + uhf = _freq.uhf, + uhfGroup = _uhfGroup:getName(), + fm = _freq.fm, + fmGroup = _fmGroup:getName(), + text = _freqsText, + battery = _battery, + coalition = _coalition, + } + + ctld.updateRadioBeacon(_beaconDetails) + + table.insert(ctld.deployedRadioBeacons, _beaconDetails) + + return _beaconDetails +end + +function ctld.generateADFFrequencies() + + if #ctld.freeUHFFrequencies <= 3 then + ctld.freeUHFFrequencies = ctld.usedUHFFrequencies + ctld.usedUHFFrequencies = {} + end + + --remove frequency at RANDOM + local _uhf = table.remove(ctld.freeUHFFrequencies, math.random(#ctld.freeUHFFrequencies)) + table.insert(ctld.usedUHFFrequencies, _uhf) + + + if #ctld.freeVHFFrequencies <= 3 then + ctld.freeVHFFrequencies = ctld.usedVHFFrequencies + ctld.usedVHFFrequencies = {} + end + + local _vhf = table.remove(ctld.freeVHFFrequencies, math.random(#ctld.freeVHFFrequencies)) + table.insert(ctld.usedVHFFrequencies, _vhf) + + if #ctld.freeFMFrequencies <= 3 then + ctld.freeFMFrequencies = ctld.usedFMFrequencies + ctld.usedFMFrequencies = {} + end + + local _fm = table.remove(ctld.freeFMFrequencies, math.random(#ctld.freeFMFrequencies)) + table.insert(ctld.usedFMFrequencies, _fm) + + return { uhf = _uhf, vhf = _vhf, fm = _fm } + --- return {uhf=_uhf,vhf=_vhf} +end + + + +function ctld.spawnRadioBeaconUnit(_point, _country, _name, _freqsText) + + local _groupId = ctld.getNextGroupId() + + local _unitId = ctld.getNextUnitId() + + local _radioGroup = { + ["visible"] = false, + -- ["groupId"] = _groupId, + ["hidden"] = false, + ["units"] = { + [1] = { + ["y"] = _point.z, + ["type"] = "TACAN_beacon", + ["name"] = "Unit #" .. _unitId .. " - " .. _name .. " [" .. _freqsText .. "]", + -- ["unitId"] = _unitId, + ["heading"] = 0, + ["playerCanDrive"] = true, + ["skill"] = "Excellent", + ["x"] = _point.x, + } + }, + -- ["y"] = _positions[1].z, + -- ["x"] = _positions[1].x, + ["name"] = "Group #" .. _groupId .. " - " .. _name, + ["task"] = {}, + --added two fields below for MIST + ["category"] = Group.Category.GROUND, + ["country"] = _country + } + + -- return coalition.addGroup(_country, Group.Category.GROUND, _radioGroup) + return Group.getByName(mist.dynAdd(_radioGroup).name) +end + +function ctld.updateRadioBeacon(_beaconDetails) + + local _vhfGroup = Group.getByName(_beaconDetails.vhfGroup) + + local _uhfGroup = Group.getByName(_beaconDetails.uhfGroup) + + local _fmGroup = Group.getByName(_beaconDetails.fmGroup) + + local _radioLoop = {} + + if _vhfGroup ~= nil and _vhfGroup:getUnits() ~= nil and #_vhfGroup:getUnits() == 1 then + table.insert(_radioLoop, { group = _vhfGroup, freq = _beaconDetails.vhf, silent = false, mode = 0 }) + end + + if _uhfGroup ~= nil and _uhfGroup:getUnits() ~= nil and #_uhfGroup:getUnits() == 1 then + table.insert(_radioLoop, { group = _uhfGroup, freq = _beaconDetails.uhf, silent = true, mode = 0 }) + end + + if _fmGroup ~= nil and _fmGroup:getUnits() ~= nil and #_fmGroup:getUnits() == 1 then + table.insert(_radioLoop, { group = _fmGroup, freq = _beaconDetails.fm, silent = false, mode = 1 }) + end + + local _batLife = _beaconDetails.battery - timer.getTime() + + if (_batLife <= 0 and _beaconDetails.battery ~= -1) or #_radioLoop ~= 3 then + -- ran out of batteries + if _vhfGroup ~= nil then + trigger.action.stopRadioTransmission(_vhfGroup:getName()) + _vhfGroup:destroy() + end + if _uhfGroup ~= nil then + trigger.action.stopRadioTransmission(_uhfGroup:getName()) + _uhfGroup:destroy() + end + if _fmGroup ~= nil then + trigger.action.stopRadioTransmission(_fmGroup:getName()) + _fmGroup:destroy() + end + + return false + end + + --fobs have unlimited battery life + -- if _battery ~= -1 then + -- _text = _text.." "..mist.utils.round(_batLife).." seconds of battery" + -- end + + for _, _radio in pairs(_radioLoop) do + + local _groupController = _radio.group:getController() + + local _sound = ctld.radioSound + if _radio.silent then + _sound = ctld.radioSoundFC3 + end + + _sound = "l10n/DEFAULT/".._sound + + _groupController:setOption(AI.Option.Ground.id.ROE, AI.Option.Ground.val.ROE.WEAPON_HOLD) + + + -- stop the transmission at each call to the ctld.updateRadioBeacon method (default each minute) + trigger.action.stopRadioTransmission(_radio.group:getName()) + + -- restart it as the battery is still up + -- the transmission is set to loop and has the name of the transmitting DCS group (that includes the type - i.e. FM, UHF, VHF) + trigger.action.radioTransmission(_sound, _radio.group:getUnit(1):getPoint(), _radio.mode, true, _radio.freq, 1000, _radio.group:getName()) + end + + return true +end + +function ctld.listRadioBeacons(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _message = "" + + if _heli ~= nil then + + for _x, _details in pairs(ctld.deployedRadioBeacons) do + + if _details.coalition == _heli:getCoalition() then + _message = _message .. _details.text .. "\n" + end + end + + if _message ~= "" then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Radio Beacons:\n%1", _message), 20) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No Active Radio Beacons"), 20) + end + end +end + +function ctld.dropRadioBeacon(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _message = "" + + if _heli ~= nil and ctld.inAir(_heli) == false then + + --deploy 50 m infront + --try to spawn at 12 oclock to us + local _point = ctld.getPointAt12Oclock(_heli, 50) + + ctld.beaconCount = ctld.beaconCount + 1 + local _name = "Beacon #" .. ctld.beaconCount + + local _radioBeaconDetails = ctld.createRadioBeacon(_point, _heli:getCoalition(), _heli:getCountry(), _name, nil, false) + + -- mark with flare? + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 deployed a Radio Beacon.\n\n%2", ctld.getPlayerNameOrType(_heli), _radioBeaconDetails.text, 20)) + + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You need to land before you can deploy a Radio Beacon!"), 20) + end +end + +--remove closet radio beacon +function ctld.removeRadioBeacon(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + local _message = "" + + if _heli ~= nil and ctld.inAir(_heli) == false then + + -- mark with flare? + + local _closestBeacon = nil + local _shortestDistance = -1 + local _distance = 0 + + for _x, _details in pairs(ctld.deployedRadioBeacons) do + + if _details.coalition == _heli:getCoalition() then + + local _group = Group.getByName(_details.vhfGroup) + + if _group ~= nil and #_group:getUnits() == 1 then + + _distance = ctld.getDistance(_heli:getPoint(), _group:getUnit(1):getPoint()) + if _distance ~= nil and (_shortestDistance == -1 or _distance < _shortestDistance) then + _shortestDistance = _distance + _closestBeacon = _details + end + end + end + end + + if _closestBeacon ~= nil and _shortestDistance then + local _vhfGroup = Group.getByName(_closestBeacon.vhfGroup) + + local _uhfGroup = Group.getByName(_closestBeacon.uhfGroup) + + local _fmGroup = Group.getByName(_closestBeacon.fmGroup) + + if _vhfGroup ~= nil then + trigger.action.stopRadioTransmission(_vhfGroup:getName()) + _vhfGroup:destroy() + end + if _uhfGroup ~= nil then + trigger.action.stopRadioTransmission(_uhfGroup:getName()) + _uhfGroup:destroy() + end + if _fmGroup ~= nil then + trigger.action.stopRadioTransmission(_fmGroup:getName()) + _fmGroup:destroy() + end + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 removed a Radio Beacon.\n\n%2", ctld.getPlayerNameOrType(_heli), _closestBeacon.text, 20)) + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("No Radio Beacons within 500m."), 20) + end + + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("You need to land before remove a Radio Beacon"), 20) + end +end + +-- gets the center of a bunch of points! +-- return proper DCS point with height +function ctld.getCentroid(_points) + local _tx, _ty = 0, 0 + for _index, _point in ipairs(_points) do + _tx = _tx + _point.x + _ty = _ty + _point.z + end + + local _npoints = #_points + + local _point = { x = _tx / _npoints, z = _ty / _npoints } + + _point.y = land.getHeight({ _point.x, _point.z }) + + return _point +end + +function ctld.getAATemplate(_unitName) + + for _,_system in pairs(ctld.AASystemTemplate) do + + if _system.repair == _unitName then + return _system + end + + for _,_part in pairs(_system.parts) do + + if _unitName == _part.name then + return _system + end + end + end + + return nil + +end + +function ctld.getLauncherUnitFromAATemplate(_aaTemplate) + for _,_part in pairs(_aaTemplate.parts) do + + if _part.launcher then + return _part.name + end + end + + return nil +end + +function ctld.rearmAASystem(_heli, _nearestCrate, _nearbyCrates, _aaSystemTemplate) + + -- are we adding to existing aa system? + -- check to see if the crate is a launcher + if ctld.getLauncherUnitFromAATemplate(_aaSystemTemplate) == _nearestCrate.details.unit then + + -- find nearest COMPLETE AA system + local _nearestSystem = ctld.findNearestAASystem(_heli, _aaSystemTemplate) + + if _nearestSystem ~= nil and _nearestSystem.dist < 300 then + + local _uniqueTypes = {} -- stores each unique part of system + local _types = {} + local _points = {} + local _hdgs = {} + + local _units = _nearestSystem.group:getUnits() + + if _units ~= nil and #_units > 0 then + + for x = 1, #_units do + if _units[x]:getLife() > 0 then + + --this allows us to count each type once + _uniqueTypes[_units[x]:getTypeName()] = _units[x]:getTypeName() + + table.insert(_points, _units[x]:getPoint()) + table.insert(_types, _units[x]:getTypeName()) + table.insert(_hdgs, mist.getHeading(_units[x], true)) + end + end + end + + -- do we have the correct number of unique pieces and do we have enough points for all the pieces + if ctld.countTableEntries(_uniqueTypes) == _aaSystemTemplate.count and #_points >= _aaSystemTemplate.count then + + -- rearm aa system + -- destroy old group + ctld.completeAASystems[_nearestSystem.group:getName()] = nil + + _nearestSystem.group:destroy() + + local _spawnedGroup = ctld.spawnCrateGroup(_heli, _points, _types, _hdgs) + + ctld.completeAASystems[_spawnedGroup:getName()] = ctld.getAASystemDetails(_spawnedGroup, _aaSystemTemplate) + + ctld.processCallback({unit = _heli, crate = _nearestCrate , spawnedGroup = _spawnedGroup, action = "rearm"}) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 successfully rearmed a full %2 in the field", ctld.getPlayerNameOrType(_heli), _aaSystemTemplate.name, 20)) + + if _heli:getCoalition() == 1 then + ctld.spawnedCratesRED[_nearestCrate.crateUnit:getName()] = nil + else + ctld.spawnedCratesBLUE[_nearestCrate.crateUnit:getName()] = nil + end + + -- remove crate + -- if ctld.slingLoad == false then + _nearestCrate.crateUnit:destroy() + -- end + + return true -- all done so quit + end + end + end + + return false +end + +function ctld.getAASystemDetails(_hawkGroup,_aaSystemTemplate) + + local _units = _hawkGroup:getUnits() + + local _hawkDetails = {} + + for _, _unit in pairs(_units) do + table.insert(_hawkDetails, { point = _unit:getPoint(), unit = _unit:getTypeName(), name = _unit:getName(), system =_aaSystemTemplate, hdg = mist.getHeading(_unit, true) }) + end + + return _hawkDetails +end + +function ctld.countTableEntries(_table) + + if _table == nil then + return 0 + end + + + local _count = 0 + + for _key, _value in pairs(_table) do + + _count = _count + 1 + end + + return _count +end + +function ctld.unpackAASystem(_heli, _nearestCrate, _nearbyCrates,_aaSystemTemplate) + ctld.logTrace("_nearestCrate = %s", ctld.p(_nearestCrate)) + ctld.logTrace("_nearbyCrates = %s", ctld.p(_nearbyCrates)) + ctld.logTrace("_aaSystemTemplate = %s", ctld.p(_aaSystemTemplate)) + + if ctld.rearmAASystem(_heli, _nearestCrate, _nearbyCrates,_aaSystemTemplate) then + -- rearmed system + return + end + + local _systemParts = {} + + --initialise list of parts + for _,_part in pairs(_aaSystemTemplate.parts) do + local _systemPart = {name = _part.name, desc = _part.desc, launcher = _part.launcher, amount = _part.amount, NoCrate = _part.NoCrate, found = 0, required = 1} + -- if the part is a NoCrate required, it's found by default + if _systemPart.NoCrate ~= nil then + _systemPart.found = 1 + end + _systemParts[_part.name] = _systemPart + end + + local _cratePositions = {} + local _crateHdg = {} + + local crateDistance = 500 + + -- find all crates close enough and add them to the list if they're part of the AA System + for _, _nearbyCrate in pairs(_nearbyCrates) do + ctld.logTrace("_nearbyCrate = %s", ctld.p(_nearbyCrate)) + if _nearbyCrate.dist < crateDistance then + + local _name = _nearbyCrate.details.unit + ctld.logTrace("_name = %s", ctld.p(_name)) + + if _systemParts[_name] ~= nil then + + local foundCount = _systemParts[_name].found + ctld.logTrace("foundCount = %s", ctld.p(foundCount)) + + if not _cratePositions[_name] then + _cratePositions[_name] = {} + end + if not _crateHdg[_name] then + _crateHdg[_name] = {} + end + + -- if this is our first time encountering this part of the system + if foundCount == 0 then + local _foundPart = _systemParts[_name] + + _foundPart.found = 1 + + -- store the number of crates required to compute how many crates will have to be removed later and to see if the system can be deployed + local cratesRequired = _nearbyCrate.details.cratesRequired + ctld.logTrace("cratesRequired = %s", ctld.p(cratesRequired)) + if cratesRequired ~= nil then + _foundPart.required = cratesRequired + end + + _systemParts[_name] = _foundPart + else + -- otherwise, we found another crate for the same part + _systemParts[_name].found = foundCount + 1 + end + + -- add the crate to the part info along with it's position and heading + local crateUnit = _nearbyCrate.crateUnit + if not _systemParts[_name].crates then + _systemParts[_name].crates = {} + end + table.insert(_systemParts[_name].crates, _nearbyCrate) + table.insert(_cratePositions[_name], crateUnit:getPoint()) + table.insert(_crateHdg[_name], mist.getHeading(crateUnit, true)) + end + end + end + + -- Compute the centroids for each type of crates and then the centroid of all the system crates which is used to find the spawn location for each part and a position for the NoCrate parts respectively + -- One issue, all crates are considered for the centroid and the headings but not all of them may be used if crate stacking is allowed + local _crateCentroids = {} + local _idxCentroids = {} + for _partName, _partPositions in pairs(_cratePositions) do + _crateCentroids[_partName] = ctld.getCentroid(_partPositions) + table.insert(_idxCentroids, _crateCentroids[_partName]) + end + local _crateCentroid = ctld.getCentroid(_idxCentroids) + + -- Compute the average heading for each type of crates to know the heading to spawn the part + local _aveHdg = {} + -- Headings of each group of crates + for _partName, _crateHeadings in pairs(_crateHdg) do + local crateCount = #_crateHeadings + _aveHdg[_partName] = 0 + -- Heading of each crate within a group + for _index, _crateHeading in pairs(_crateHeadings) do + _aveHdg[_partName] = _crateHeading / crateCount + _aveHdg[_partName] + end + end + + local spawnDistance = 30 -- circle radius to spawn units in a circle and randomize position relative to the crate location + local arcRad = math.pi * 2 + + local _txt = "" + + local _posArray = {} + local _hdgArray = {} + local _typeArray = {} + -- for each part of the system parts + for _name, _systemPart in pairs(_systemParts) do + + -- check if enough crates were found to build the part + if _systemPart.found < _systemPart.required then + _txt = _txt..ctld.i18n_translate("Missing %1\n",_systemPart.desc) + else + -- use the centroid of the crates for this part as a spawn location + local _point = _crateCentroids[_name] + -- in the case this centroid does not exist (NoCrate), use the centroid of all crates found and add some randomness + if _point == nil then + _point = _crateCentroid + _point = { x = _point.x + math.random(0,3)*spawnDistance, y = _point.y, z = _point.z + math.random(0,3)*spawnDistance} + end + + -- use the average heading to spawn the part at + local _hdg = _aveHdg[_name] + -- if non are found (NoCrate), random heading + if _hdg == nil then + _hdg = math.random(0, arcRad) + end + + -- search for the amount of times this part needs to be spawned, by default 1 for any unit and aaLaunchers for launchers + local partAmount = 1 + if _systemPart.amount == nil then + if _systemPart.launcher ~= nil then + partAmount = ctld.aaLaunchers + end + else + -- but the amount may also be specified in the template + partAmount = _systemPart.amount + end + -- if crate stacking is allowed, then find the multiplication factor for the amount depending on how many crates are required and how many were found + if ctld.AASystemCrateStacking then + _systemPart.amountFactor = _systemPart.found - _systemPart.found%_systemPart.required + else + _systemPart.amountFactor = 1 + end + partAmount = partAmount * _systemPart.amountFactor + + --handle multiple units per part by spawning them in a circle around the crate + if partAmount > 1 then + + local angular_step = arcRad / partAmount + + for _i = 1, partAmount do + local _angle = (angular_step * (_i - 1) + _hdg)%arcRad + local _xOffset = math.cos(_angle) * spawnDistance + local _yOffset = math.sin(_angle) * spawnDistance + + table.insert(_posArray, { x = _point.x + _xOffset, y = _point.y, z = _point.z + _yOffset }) + table.insert(_hdgArray, _angle) -- also spawn them perpendicular to that point of the circle + table.insert(_typeArray, _name) + end + else + table.insert(_posArray, _point) + table.insert(_hdgArray, _hdg) + table.insert(_typeArray, _name) + end + end + end + + local _activeLaunchers = ctld.countCompleteAASystems(_heli) + + local _allowed = ctld.getAllowedAASystems(_heli) + + env.info("Active: ".._activeLaunchers.." Allowed: ".._allowed) + + if _activeLaunchers + 1 > _allowed then + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("Out of parts for AA Systems. Current limit is %1\n", _allowed, 10)) + return + end + + if _txt ~= "" then + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Cannot build %1\n%2\n\nOr the crates are not close enough together", _aaSystemTemplate.name, _txt), 20) + return + else + + -- destroy crates + for _name, _systemPart in pairs(_systemParts) do + -- if there is a crate to delete in the first place + if _systemPart.NoCrate ~= true then + -- figure out how many crates to delete since we searched for as many as possible, not all of them might have been used + local amountToDel = _systemPart.amountFactor*_systemPart.required + local DelCounter = 0 + + -- for each crate found for this part + for _index, _crate in pairs(_systemPart.crates) do + -- if we still need to delete some crates + if DelCounter < amountToDel then + if _heli:getCoalition() == 1 then + ctld.spawnedCratesRED[_crate.crateUnit:getName()] = nil + else + ctld.spawnedCratesBLUE[_crate.crateUnit:getName()] = nil + end + + --destroy + -- if ctld.slingLoad == false then + _crate.crateUnit:destroy() + DelCounter = DelCounter + 1 -- count up for one more crate has been deleted + --end + else + break + end + end + end + end + + -- HAWK / BUK READY! + local _spawnedGroup = ctld.spawnCrateGroup(_heli, _posArray, _typeArray, _hdgArray) + + ctld.completeAASystems[_spawnedGroup:getName()] = ctld.getAASystemDetails(_spawnedGroup,_aaSystemTemplate) + + ctld.processCallback({unit = _heli, crate = _nearestCrate , spawnedGroup = _spawnedGroup, action = "unpack"}) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 successfully deployed a full %2 in the field. \n\nAA Active System limit is: %3\nActive: %4", ctld.getPlayerNameOrType(_heli), _aaSystemTemplate.name, _allowed, (_activeLaunchers+1)), 10) + end +end + +--count the number of captured cities, sets the amount of allowed AA Systems +function ctld.getAllowedAASystems(_heli) + + if _heli:getCoalition() == 1 then + return ctld.AASystemLimitBLUE + else + return ctld.AASystemLimitRED + end + + +end + + +function ctld.countCompleteAASystems(_heli) + + local _count = 0 + + for _groupName, _hawkDetails in pairs(ctld.completeAASystems) do + + local _hawkGroup = Group.getByName(_groupName) + + -- env.info(_groupName..": "..mist.utils.tableShow(_hawkDetails)) + if _hawkGroup ~= nil and _hawkGroup:getCoalition() == _heli:getCoalition() then + + local _units = _hawkGroup:getUnits() + + if _units ~=nil and #_units > 0 then + --get the system template + local _aaSystemTemplate = _hawkDetails[1].system + + local _uniqueTypes = {} -- stores each unique part of system + local _types = {} + local _points = {} + + if _units ~= nil and #_units > 0 then + + for x = 1, #_units do + if _units[x]:getLife() > 0 then + + --this allows us to count each type once + _uniqueTypes[_units[x]:getTypeName()] = _units[x]:getTypeName() + + table.insert(_points, _units[x]:getPoint()) + table.insert(_types, _units[x]:getTypeName()) + end + end + end + + -- do we have the correct number of unique pieces and do we have enough points for all the pieces + if ctld.countTableEntries(_uniqueTypes) == _aaSystemTemplate.count and #_points >= _aaSystemTemplate.count then + _count = _count +1 + end + end + end + end + + return _count +end + + +function ctld.repairAASystem(_heli, _nearestCrate,_aaSystem) + + -- find nearest COMPLETE AA system + local _nearestHawk = ctld.findNearestAASystem(_heli,_aaSystem) + + + + if _nearestHawk ~= nil and _nearestHawk.dist < 300 then + + local _oldHawk = ctld.completeAASystems[_nearestHawk.group:getName()] + + --spawn new one + + local _types = {} + local _hdgs = {} + local _points = {} + + for _, _part in pairs(_oldHawk) do + table.insert(_points, _part.point) + table.insert(_hdgs, _part.hdg) + table.insert(_types, _part.unit) + end + + --remove old system + ctld.completeAASystems[_nearestHawk.group:getName()] = nil + _nearestHawk.group:destroy() + + local _spawnedGroup = ctld.spawnCrateGroup(_heli, _points, _types, _hdgs) + + ctld.completeAASystems[_spawnedGroup:getName()] = ctld.getAASystemDetails(_spawnedGroup,_aaSystem) + + ctld.processCallback({unit = _heli, crate = _nearestCrate , spawnedGroup = _spawnedGroup, action = "repair"}) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 successfully repaired a full %2 in the field.", ctld.getPlayerNameOrType(_heli), _aaSystem.name), 10) + + if _heli:getCoalition() == 1 then + ctld.spawnedCratesRED[_nearestCrate.crateUnit:getName()] = nil + else + ctld.spawnedCratesBLUE[_nearestCrate.crateUnit:getName()] = nil + end + + -- remove crate + -- if ctld.slingLoad == false then + _nearestCrate.crateUnit:destroy() + -- end + + else + ctld.displayMessageToGroup(_heli, ctld.i18n_translate("Cannot repair %1. No damaged %2 within 300m", _aaSystem.name, _aaSystem.name), 10) + end +end + +function ctld.unpackMultiCrate(_heli, _nearestCrate, _nearbyCrates) + + -- unpack multi crate + local _nearbyMultiCrates = {} + + for _, _nearbyCrate in pairs(_nearbyCrates) do + + if _nearbyCrate.dist < 300 then + + if _nearbyCrate.details.unit == _nearestCrate.details.unit then + + table.insert(_nearbyMultiCrates, _nearbyCrate) + + if #_nearbyMultiCrates == _nearestCrate.details.cratesRequired then + break + end + end + end + end + + --- check crate count + if #_nearbyMultiCrates == _nearestCrate.details.cratesRequired then + + local _point = _nearestCrate.crateUnit:getPoint() + local _crateHdg = mist.getHeading(_nearestCrate.crateUnit, true) + + -- destroy crates + for _, _crate in pairs(_nearbyMultiCrates) do + + if _point == nil then + _point = _crate.crateUnit:getPoint() + end + + if _heli:getCoalition() == 1 then + ctld.spawnedCratesRED[_crate.crateUnit:getName()] = nil + else + ctld.spawnedCratesBLUE[_crate.crateUnit:getName()] = nil + end + + --destroy + -- if ctld.slingLoad == false then + _crate.crateUnit:destroy() + -- end + end + + + local _spawnedGroup = ctld.spawnCrateGroup(_heli, { _point }, { _nearestCrate.details.unit }, { _crateHdg }) + if _spawnedGroup == nil then + ctld.logError("ctld.unpackMultiCrate group was not spawned - skipping setGrpROE") + else + ctld.setGrpROE(_spawnedGroup) + ctld.processCallback({unit = _heli, crate = _nearestCrate , spawnedGroup = _spawnedGroup, action = "unpack"}) + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 successfully deployed %2 to the field using %3 crates.", ctld.getPlayerNameOrType(_heli), _nearestCrate.details.desc, #_nearbyMultiCrates), 10) + end + else + + local _txt = ctld.i18n_translate("Cannot build %1!\n\nIt requires %2 crates and there are %3 \n\nOr the crates are not within 300m of each other", _nearestCrate.details.desc, _nearestCrate.details.cratesRequired, #_nearbyMultiCrates) + + ctld.displayMessageToGroup(_heli, _txt, 20) + end +end + + +function ctld.spawnCrateGroup(_heli, _positions, _types, _hdgs) + local _id = ctld.getNextGroupId() + local _groupName = _types[1] .. " #" .. _id + local _side = _heli:getCoalition() + local _group = { + ["visible"] = false, + -- ["groupId"] = _id, + ["hidden"] = false, + ["units"] = {}, + -- ["y"] = _positions[1].z, + -- ["x"] = _positions[1].x, + ["name"] = _groupName, + ["tasks"] = {}, + ["radioSet"] = false, + ["task"] = "Reconnaissance", + ["route"] = {}, + } + + local _hdg = 120 * math.pi / 180 -- radians = 120 degrees + if _types[1] ~= "MQ-9 Reaper" and _types[1] ~= "WingLoong-I" then -- non-drones - JTAC + local _spreadMin = 0 + local _spreadMax = 0 + local _spreadMult = 1 + for _i, _pos in ipairs(_positions) do + local _unitId = ctld.getNextUnitId() + local _details = { type = _types[_i], unitId = _unitId, name = string.format("Unpacked %s #%i", _types[_i], _unitId) } + + if _hdgs and _hdgs[_i] then + _hdg = _hdgs[_i] + end + + _group.units[_i] = ctld.createUnit(_pos.x + 0, _pos.z + 0, mist.getHeading(_heli, true), _details) + end + _group.category = Group.Category.GROUND + else -- drones - JTAC + local _unitId = ctld.getNextUnitId() + local _details = { type = _types[1], + unitId = _unitId, + name = string.format("Unpacked %s #%i", _types[1], _unitId), + livery_id = "'camo' scheme", + skill = "High", + speed = 100, + payload = { pylons = {}, fuel = 1300, flare = 0, chaff = 0, gun = 100 } } + + _group.units[1] = ctld.createUnit( _positions[1].x, + _positions[1].z + ctld.jtacDroneRadius, + _hdg, + _details) + + _group.category = Group.Category.AIRPLANE -- for drones + + -- create drone orbiting route + local DroneRoute = { + ["points"] = + { + [1] = + { + ["alt"] = 7000, + ["action"] = "Turning Point", + ["alt_type"] = "BARO", + ["properties"] = + { + ["addopt"] = {}, + }, -- end of ["properties"] + ["speed"] = 100, + ["task"] = + { + ["id"] = "ComboTask", + ["params"] = + { + ["tasks"] = + { + [1] = + { + ["enabled"] = true, + ["auto"] = false, + ["id"] = "WrappedAction", + ["number"] = 1, + ["params"] = + { + ["action"] = + { + ["id"] = "EPLRS", + ["params"] = + { + ["value"] = true, + ["groupId"] = 0, + }, -- end of ["params"] + }, -- end of ["action"] + }, -- end of ["params"] + }, -- end of [1] + [2] = + { + ["number"] = 2, + ["auto"] = false, + ["id"] = "Orbit", + ["enabled"] = true, + ["params"] = + { + ["altitude"] = ctld.jtacDroneAltitude, + ["pattern"] = "Circle", + ["speed"] = 125, + }, -- end of ["params"] + }, -- end of [2] + [3] = + { + ["enabled"] = true, + ["auto"] = false, + ["id"] = "WrappedAction", + ["number"] = 3, + ["params"] = + { + ["action"] = + { + ["id"] = "Option", + ["params"] = + { + ["value"] = true, + ["name"] = 6, + }, -- end of ["params"] + }, -- end of ["action"] + }, -- end of ["params"] + }, -- end of [3] + }, -- end of ["tasks"] + }, -- end of ["params"] + }, -- end of ["task"] + ["type"] = "Turning Point", + ["ETA"] = 0, + ["ETA_locked"] = true, + ["y"] = _positions[1].z, + ["x"] = _positions[1].x, + ["speed_locked"] = true, + ["formation_template"] = "", + }, -- end of [1] + }, -- end of ["points"] + } -- end of ["route"] + --------------------------------------------------------------------------------- + _group.route = DroneRoute + end + + _group.country = _heli:getCountry() + local _spawnedGroup = Group.getByName(mist.dynAdd(_group).name) + return _spawnedGroup +end + + + +-- spawn normal group +function ctld.spawnDroppedGroup(_point, _details, _spawnBehind, _maxSearch) + + local _groupName = _details.groupName + + local _group = { + ["visible"] = false, + -- ["groupId"] = _details.groupId, + ["hidden"] = false, + ["units"] = {}, + -- ["y"] = _positions[1].z, + -- ["x"] = _positions[1].x, + ["name"] = _groupName, + ["task"] = {}, + } + + + if _spawnBehind == false then + + -- spawn in circle around heli + + local _pos = _point + + for _i, _detail in ipairs(_details.units) do + + local _angle = math.pi * 2 * (_i - 1) / #_details.units + local _xOffset = math.cos(_angle) * 30 + local _yOffset = math.sin(_angle) * 30 + + _group.units[_i] = ctld.createUnit(_pos.x + _xOffset, _pos.z + _yOffset, _angle, _detail) + end + + else + + local _pos = _point + + --try to spawn at 6 oclock to us + local _angle = math.atan2(_pos.z, _pos.x) + local _xOffset = math.cos(_angle) * -30 + local _yOffset = math.sin(_angle) * -30 + + + for _i, _detail in ipairs(_details.units) do + _group.units[_i] = ctld.createUnit(_pos.x + (_xOffset + 10 * _i), _pos.z + (_yOffset + 10 * _i), _angle, _detail) + end + end + + --switch to MIST + _group.category = Group.Category.GROUND; + _group.country = _details.country; + + local _spawnedGroup = Group.getByName(mist.dynAdd(_group).name) + + --local _spawnedGroup = coalition.addGroup(_details.country, Group.Category.GROUND, _group) + + + -- find nearest enemy and head there + if _maxSearch == nil then + _maxSearch = ctld.maximumSearchDistance + end + + local _wpZone = ctld.inWaypointZone(_point,_spawnedGroup:getCoalition()) + + if _wpZone.inZone then + ctld.orderGroupToMoveToPoint(_spawnedGroup:getUnit(1), _wpZone.point) + env.info("Heading to waypoint - In Zone ".._wpZone.name) + else + local _enemyPos = ctld.findNearestEnemy(_details.side, _point, _maxSearch) + + ctld.orderGroupToMoveToPoint(_spawnedGroup:getUnit(1), _enemyPos) + end + + return _spawnedGroup +end + +function ctld.findNearestEnemy(_side, _point, _searchDistance) + + local _closestEnemy = nil + + local _groups + + local _closestEnemyDist = _searchDistance + + local _heliPoint = _point + + if _side == 2 then + _groups = coalition.getGroups(1, Group.Category.GROUND) + else + _groups = coalition.getGroups(2, Group.Category.GROUND) + end + + for _, _group in pairs(_groups) do + + if _group ~= nil then + local _units = _group:getUnits() + + if _units ~= nil and #_units > 0 then + + local _leader = nil + + -- find alive leader + for x = 1, #_units do + if _units[x]:getLife() > 0 then + _leader = _units[x] + break + end + end + + if _leader ~= nil then + local _leaderPos = _leader:getPoint() + local _dist = ctld.getDistance(_heliPoint, _leaderPos) + if _dist < _closestEnemyDist then + _closestEnemyDist = _dist + _closestEnemy = _leaderPos + end + end + end + end + end + + + -- no enemy - move to random point + if _closestEnemy ~= nil then + + -- env.info("found enemy") + return _closestEnemy + else + + local _x = _heliPoint.x + math.random(0, ctld.maximumMoveDistance) - math.random(0, ctld.maximumMoveDistance) + local _z = _heliPoint.z + math.random(0, ctld.maximumMoveDistance) - math.random(0, ctld.maximumMoveDistance) + local _y = _heliPoint.y + math.random(0, ctld.maximumMoveDistance) - math.random(0, ctld.maximumMoveDistance) + + return { x = _x, z = _z,y=_y } + end +end + +function ctld.findNearestGroup(_heli, _groups) + + local _closestGroupDetails = {} + local _closestGroup = nil + + local _closestGroupDist = ctld.maxExtractDistance + + local _heliPoint = _heli:getPoint() + + for _, _groupName in pairs(_groups) do + + local _group = Group.getByName(_groupName) + + if _group ~= nil then + local _units = _group:getUnits() + + if _units ~= nil and #_units > 0 then + + local _leader = nil + + local _groupDetails = { groupId = _group:getID(), groupName = _group:getName(), side = _group:getCoalition(), units = {} } + + -- find alive leader + for x = 1, #_units do + if _units[x]:getLife() > 0 then + + if _leader == nil then + _leader = _units[x] + -- set country based on leader + _groupDetails.country = _leader:getCountry() + end + + local _unitDetails = { type = _units[x]:getTypeName(), unitId = _units[x]:getID(), name = _units[x]:getName() } + + table.insert(_groupDetails.units, _unitDetails) + end + end + + if _leader ~= nil then + local _leaderPos = _leader:getPoint() + local _dist = ctld.getDistance(_heliPoint, _leaderPos) + if _dist < _closestGroupDist then + _closestGroupDist = _dist + _closestGroupDetails = _groupDetails + _closestGroup = _group + end + end + end + end + end + + + if _closestGroup ~= nil then + + return { group = _closestGroup, details = _closestGroupDetails } + else + + return nil + end +end + + +function ctld.createUnit(_x, _y, _angle, _details) + + local _newUnit = { + ["y"] = _y, + ["type"] = _details.type, + ["name"] = _details.name, + -- ["unitId"] = _details.unitId, + ["heading"] = _angle, + ["playerCanDrive"] = true, + ["skill"] = "Excellent", + ["x"] = _x, + } + + return _newUnit +end + +function ctld.addEWRTask(_group) + + -- delayed 2 second to work around bug + timer.scheduleFunction(function(_ewrGroup) + local _grp = ctld.getAliveGroup(_ewrGroup) + + if _grp ~= nil then + local _controller = _grp:getController(); + local _EWR = { + id = 'EWR', + auto = true, + params = { + } + } + end + end + , _group:getName(), timer.getTime() + 2) + +end + +function ctld.orderGroupToMoveToPoint(_leader, _destination) + + local _group = _leader:getGroup() + + local _path = {} + table.insert(_path, mist.ground.buildWP(_leader:getPoint(), 'Off Road', 50)) + table.insert(_path, mist.ground.buildWP(_destination, 'Off Road', 50)) + + local _mission = { + id = 'Mission', + params = { + route = { + points =_path + }, + }, + } + + + -- delayed 2 second to work around bug + timer.scheduleFunction(function(_arg) + local _grp = ctld.getAliveGroup(_arg[1]) + + if _grp ~= nil then + local _controller = _grp:getController(); + Controller.setOption(_controller, AI.Option.Ground.id.ALARM_STATE, AI.Option.Ground.val.ALARM_STATE.AUTO) + Controller.setOption(_controller, AI.Option.Ground.id.ROE, AI.Option.Ground.val.ROE.OPEN_FIRE) + _controller:setTask(_arg[2]) + end + end + , {_group:getName(), _mission}, timer.getTime() + 2) + +end + +-- are we in pickup zone +function ctld.inPickupZone(_heli) + + if ctld.inAir(_heli) then + return { inZone = false, limit = -1, index = -1 } + end + + local _heliPoint = _heli:getPoint() + + for _i, _zoneDetails in pairs(ctld.pickupZones) do + + local _triggerZone = trigger.misc.getZone(_zoneDetails[1]) + + if _triggerZone == nil then + local _ship = ctld.getTransportUnit(_zoneDetails[1]) + + if _ship then + local _point = _ship:getPoint() + _triggerZone = {} + _triggerZone.point = _point + _triggerZone.radius = 200 -- should be big enough for ship + end + + end + + if _triggerZone ~= nil then + + --get distance to center + + local _dist = ctld.getDistance(_heliPoint, _triggerZone.point) + if _dist <= _triggerZone.radius then + local _heliCoalition = _heli:getCoalition() + if _zoneDetails[4] == 1 and (_zoneDetails[5] == _heliCoalition or _zoneDetails[5] == 0) then + return { inZone = true, limit = _zoneDetails[3], index = _i } + end + end + end + end + + local _fobs = ctld.getSpawnedFobs(_heli) + + -- now check spawned fobs + for _, _fob in ipairs(_fobs) do + + --get distance to center + + local _dist = ctld.getDistance(_heliPoint, _fob:getPoint()) + + if _dist <= 150 then + return { inZone = true, limit = 10000, index = -1 }; + end + end + + + + return { inZone = false, limit = -1, index = -1 }; +end + +function ctld.getSpawnedFobs(_heli) + + local _fobs = {} + + for _, _fobName in ipairs(ctld.builtFOBS) do + + local _fob = StaticObject.getByName(_fobName) + + if _fob ~= nil and _fob:isExist() and _fob:getCoalition() == _heli:getCoalition() and _fob:getLife() > 0 then + + table.insert(_fobs, _fob) + end + end + + return _fobs +end + +-- are we in a dropoff zone +function ctld.inDropoffZone(_heli) + + if ctld.inAir(_heli) then + return false + end + + local _heliPoint = _heli:getPoint() + + for _, _zoneDetails in pairs(ctld.dropOffZones) do + + local _triggerZone = trigger.misc.getZone(_zoneDetails[1]) + + if _triggerZone ~= nil and (_zoneDetails[3] == _heli:getCoalition() or _zoneDetails[3]== 0) then + + --get distance to center + + local _dist = ctld.getDistance(_heliPoint, _triggerZone.point) + + if _dist <= _triggerZone.radius then + return true + end + end + end + + return false +end + +-- are we in a waypoint zone +function ctld.inWaypointZone(_point,_coalition) + + for _, _zoneDetails in pairs(ctld.wpZones) do + + local _triggerZone = trigger.misc.getZone(_zoneDetails[1]) + + --right coalition and active? + if _triggerZone ~= nil and (_zoneDetails[4] == _coalition or _zoneDetails[4]== 0) and _zoneDetails[3] == 1 then + + --get distance to center + + local _dist = ctld.getDistance(_point, _triggerZone.point) + + if _dist <= _triggerZone.radius then + return {inZone = true, point = _triggerZone.point, name = _zoneDetails[1]} + end + end + end + + return {inZone = false} +end + +-- are we near friendly logistics zone +function ctld.inLogisticsZone(_heli) + ctld.logDebug("ctld.inLogisticsZone(), _heli = %s", ctld.p(_heli)) + + if ctld.inAir(_heli) then + return false + end + local _heliPoint = _heli:getPoint() + ctld.logDebug("_heliPoint = %s", ctld.p(_heliPoint)) + for _, _name in pairs(ctld.logisticUnits) do + ctld.logDebug("_name = %s", ctld.p(_name)) + local _logistic = StaticObject.getByName(_name) + if not _logistic then + _logistic = Unit.getByName(_name) + end + ctld.logDebug("_logistic = %s", ctld.p(_logistic)) + if _logistic ~= nil and _logistic:getCoalition() == _heli:getCoalition() and _logistic:getLife() > 0 then + --get distance + local _dist = ctld.getDistance(_heliPoint, _logistic:getPoint()) + ctld.logDebug("_dist = %s", ctld.p(_dist)) + if _dist <= ctld.maximumDistanceLogistic then + return true + end + end + end + + return false +end + + +-- are far enough from a friendly logistics zone +function ctld.farEnoughFromLogisticZone(_heli) + + if ctld.inAir(_heli) then + return false + end + + local _heliPoint = _heli:getPoint() + + local _farEnough = true + + for _, _name in pairs(ctld.logisticUnits) do + + local _logistic = StaticObject.getByName(_name) + + if _logistic ~= nil and _logistic:getCoalition() == _heli:getCoalition() then + + --get distance + local _dist = ctld.getDistance(_heliPoint, _logistic:getPoint()) + -- env.info("DIST ".._dist) + if _dist <= ctld.minimumDeployDistance then + -- env.info("TOO CLOSE ".._dist) + _farEnough = false + end + end + end + + return _farEnough +end + +function ctld.refreshSmoke() + + if ctld.disableAllSmoke == true then + return + end + + for _, _zoneGroup in pairs({ ctld.pickupZones, ctld.dropOffZones }) do + + for _, _zoneDetails in pairs(_zoneGroup) do + + local _triggerZone = trigger.misc.getZone(_zoneDetails[1]) + + if _triggerZone == nil then + local _ship = ctld.getTransportUnit(_triggerZone) + + if _ship then + local _point = _ship:getPoint() + _triggerZone = {} + _triggerZone.point = _point + end + + end + + + --only trigger if smoke is on AND zone is active + if _triggerZone ~= nil and _zoneDetails[2] >= 0 and _zoneDetails[4] == 1 then + + -- Trigger smoke markers + + local _pos2 = { x = _triggerZone.point.x, y = _triggerZone.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + trigger.action.smoke(_pos3, _zoneDetails[2]) + end + end + end + + --waypoint zones + for _, _zoneDetails in pairs(ctld.wpZones) do + + local _triggerZone = trigger.misc.getZone(_zoneDetails[1]) + + --only trigger if smoke is on AND zone is active + if _triggerZone ~= nil and _zoneDetails[2] >= 0 and _zoneDetails[3] == 1 then + + -- Trigger smoke markers + + local _pos2 = { x = _triggerZone.point.x, y = _triggerZone.point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _pos2.x, y = _alt, z = _pos2.y } + + trigger.action.smoke(_pos3, _zoneDetails[2]) + end + end + + + --refresh in 5 minutes + timer.scheduleFunction(ctld.refreshSmoke, nil, timer.getTime() + 300) +end + +function ctld.dropSmoke(_args) + + local _heli = ctld.getTransportUnit(_args[1]) + + if _heli ~= nil then + + local _colour = "" + + if _args[2] == trigger.smokeColor.Red then + + _colour = "RED" + elseif _args[2] == trigger.smokeColor.Blue then + + _colour = "BLUE" + elseif _args[2] == trigger.smokeColor.Green then + + _colour = "GREEN" + elseif _args[2] == trigger.smokeColor.Orange then + + _colour = "ORANGE" + end + + local _point = _heli:getPoint() + + local _pos2 = { x = _point.x, y = _point.z } + local _alt = land.getHeight(_pos2) + local _pos3 = { x = _point.x, y = _alt, z = _point.z } + + trigger.action.smoke(_pos3, _args[2]) + + trigger.action.outTextForCoalition(_heli:getCoalition(), ctld.i18n_translate("%1 dropped %2 smoke.", ctld.getPlayerNameOrType(_heli), _colour), 10) + end +end + +function ctld.unitCanCarryVehicles(_unit) + + local _type = string.lower(_unit:getTypeName()) + + for _, _name in ipairs(ctld.vehicleTransportEnabled) do + local _nameLower = string.lower(_name) + if string.find(_type, _nameLower, 1, true) then + return true + end + end + + return false +end + +function ctld.unitDynamicCargoCapable(_unit) + local cache = {} + local _type = string.lower(_unit:getTypeName()) + local result = cache[_type] + if result == nil then + result = false + ctld.logDebug("ctld.unitDynamicCargoCapable(_type=[%s])", ctld.p(_type)) + for _, _name in ipairs(ctld.dynamicCargoUnits) do + local _nameLower = string.lower(_name) + if string.find(_type, _nameLower, 1, true) then --string.match does not work with patterns containing '-' as it is a magic character + result = true + break + end + end + cache[_type] = result + ctld.logDebug("result=[%s]", ctld.p(result)) + end + return result +end + +function ctld.isJTACUnitType(_type) + if _type then + _type = string.lower(_type) + for _, _name in ipairs(ctld.jtacUnitTypes) do + local _nameLower = string.lower(_name) + if string.match(_type, _nameLower) then + return true + end + end + end + return false +end + +function ctld.updateZoneCounter(_index, _diff) + + if ctld.pickupZones[_index] ~= nil then + + ctld.pickupZones[_index][3] = ctld.pickupZones[_index][3] + _diff + + if ctld.pickupZones[_index][3] < 0 then + ctld.pickupZones[_index][3] = 0 + end + + if ctld.pickupZones[_index][6] ~= nil then + trigger.action.setUserFlag(ctld.pickupZones[_index][6], ctld.pickupZones[_index][3]) + end + -- env.info(ctld.pickupZones[_index][1].." = " ..ctld.pickupZones[_index][3]) + end +end + +function ctld.processCallback(_callbackArgs) + + for _, _callback in pairs(ctld.callbacks) do + + local _status, _result = pcall(function() + + _callback(_callbackArgs) + + end) + + if (not _status) then + env.error(string.format("CTLD Callback Error: %s", _result)) + end + end +end + + +-- checks the status of all AI troop carriers and auto loads and unloads troops +-- as long as the troops are on the ground +function ctld.checkAIStatus() + + timer.scheduleFunction(ctld.checkAIStatus, nil, timer.getTime() + 2) + + + for _, _unitName in pairs(ctld.transportPilotNames) do + local status, error = pcall(function() + + local _unit = ctld.getTransportUnit(_unitName) + + -- no player name means AI! + if _unit ~= nil and _unit:getPlayerName() == nil then + local _zone = ctld.inPickupZone(_unit) + -- env.error("Checking.. ".._unit:getName()) + if _zone.inZone == true and not ctld.troopsOnboard(_unit, true) then + -- env.error("in zone, loading.. ".._unit:getName()) + + if ctld.allowRandomAiTeamPickups == true then + -- Random troop pickup implementation + local _team = nil + if _unit:getCoalition() == 1 then + _team = math.floor((math.random(#ctld.redTeams * 100) / 100) + 1) + ctld.loadTroopsFromZone({ _unitName, true,ctld.loadableGroups[ctld.redTeams[_team]],true }) + else + _team = math.floor((math.random(#ctld.blueTeams * 100) / 100) + 1) + ctld.loadTroopsFromZone({ _unitName, true,ctld.loadableGroups[ctld.blueTeams[_team]],true }) + end + else + ctld.loadTroopsFromZone({ _unitName, true,"",true }) + end + + elseif ctld.inDropoffZone(_unit) and ctld.troopsOnboard(_unit, true) then + -- env.error("in dropoff zone, unloading.. ".._unit:getName()) + ctld.unloadTroops( { _unitName, true }) + end + + if ctld.unitCanCarryVehicles(_unit) then + + if _zone.inZone == true and not ctld.troopsOnboard(_unit, false) then + + ctld.loadTroopsFromZone({ _unitName, false,"",true }) + + elseif ctld.inDropoffZone(_unit) and ctld.troopsOnboard(_unit, false) then + + ctld.unloadTroops( { _unitName, false }) + end + end + end + end) + + if (not status) then + env.error(string.format("Error with ai status: %s", error), false) + end + end + + +end + +function ctld.getTransportLimit(_unitType) + + if ctld.unitLoadLimits[_unitType] then + + return ctld.unitLoadLimits[_unitType] + end + + return ctld.numberOfTroops + +end + +function ctld.getUnitActions(_unitType) + + if ctld.unitActions[_unitType] then + return ctld.unitActions[_unitType] + end + + return {crates=true,troops=true} + +end + +-- Adds menuitem to a human unit +function ctld.addTransportF10MenuOptions(_unitName) + ctld.logDebug("ctld.addTransportF10MenuOptions(_unitName=[%s])", ctld.p(_unitName)) + + local status, error = pcall(function() + + local _unit = ctld.getTransportUnit(_unitName) + ctld.logTrace("_unit = %s", ctld.p(_unit)) + if _unit then + local _unitTypename = _unit:getTypeName() + local _groupId = ctld.getGroupId(_unit) + + if _groupId then + ctld.logTrace("_groupId = %s", ctld.p(_groupId)) + ctld.logTrace("ctld.addedTo = %s", ctld.p(ctld.addedTo)) + if ctld.addedTo[tostring(_groupId)] == nil then + ctld.logTrace("adding CTLD menu for _groupId = %s", ctld.p(_groupId)) + local _rootPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("CTLD")) + + local _unitActions = ctld.getUnitActions(_unitTypename) + + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Check Cargo"), _rootPath, ctld.checkTroopStatus, { _unitName }) + + if _unitActions.troops then + + local _troopCommandsPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Troop Transport"), _rootPath) + + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Unload / Extract Troops"), _troopCommandsPath, ctld.unloadExtractTroops, { _unitName }) + + + -- local _loadPath = missionCommands.addSubMenuForGroup(_groupId, "Load From Zone", _troopCommandsPath) + local _transportLimit = ctld.getTransportLimit(_unitTypename) + local itemNb = 0 + local menuEntries = {} + local menuPath = _troopCommandsPath + for _,_loadGroup in pairs(ctld.loadableGroups) do + if not _loadGroup.side or _loadGroup.side == _unit:getCoalition() then + -- check size & unit + if _transportLimit >= _loadGroup.total then + table.insert(menuEntries, { text = ctld.i18n_translate("Load ").._loadGroup.name, group = _loadGroup }) + end + end + end + for _i, _menu in ipairs(menuEntries) do + -- add the menu item + itemNb = itemNb + 1 + if itemNb == 9 and _i < #menuEntries then -- page limit reached (first item is "unload") + menuPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Next page"), menuPath) + itemNb = 1 + end + missionCommands.addCommandForGroup(_groupId, _menu.text, menuPath, ctld.loadTroopsFromZone, { _unitName, true,_menu.group,false }) + end + + if ctld.unitCanCarryVehicles(_unit) then + + local _vehicleCommandsPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Vehicle / FOB Transport"), _rootPath) + + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Unload Vehicles"), _vehicleCommandsPath, ctld.unloadTroops, { _unitName, false }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Load / Extract Vehicles"), _vehicleCommandsPath, ctld.loadTroopsFromZone, { _unitName, false,"",true }) + + if ctld.enabledFOBBuilding and ctld.staticBugWorkaround == false then + + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Load / Unload FOB Crate"), _vehicleCommandsPath, ctld.loadUnloadFOBCrate, { _unitName, false }) + end + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Check Cargo"), _vehicleCommandsPath, ctld.checkTroopStatus, { _unitName }) + end + + end + + if ctld.enableCrates and _unitActions.crates then + if ctld.unitCanCarryVehicles(_unit) == false then + -- sort the crate categories alphabetically + local crateCategories = {} + for category, _ in pairs(ctld.spawnableCrates) do + table.insert(crateCategories, category) + end + table.sort(crateCategories) + ctld.logTrace("crateCategories = [%s]", ctld.p(crateCategories)) + + -- add menu for spawning crates + local itemNbMain = 0 + local _cratesMenuPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Vehicle / FOB Crates / Drone"), _rootPath) + for _i, _category in ipairs(crateCategories) do + local _subMenuName = _category + local _crates = ctld.spawnableCrates[_subMenuName] + + -- add the submenu item + itemNbMain = itemNbMain + 1 + if itemNbMain == 10 and _i < #crateCategories then -- page limit reached + _cratesMenuPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Next page"), _cratesMenuPath) + itemNbMain = 1 + end + local itemNbSubmenu = 0 + local menuEntries = {} + local _subMenuPath = missionCommands.addSubMenuForGroup(_groupId, _subMenuName, _cratesMenuPath) + for _, _crate in pairs(_crates) do + ctld.logTrace("_crate = [%s]", ctld.p(_crate)) + if not(_crate.multiple) or ctld.enableAllCrates then + local isJTAC = ctld.isJTACUnitType(_crate.unit) + ctld.logTrace("isJTAC = [%s]", ctld.p(isJTAC)) + if not isJTAC or (isJTAC and ctld.JTAC_dropEnabled) then + if _crate.side == nil or (_crate.side == _unit:getCoalition()) then + local _crateRadioMsg = _crate.desc + --add in the number of crates required to build something + if _crate.cratesRequired ~= nil and _crate.cratesRequired > 1 then + _crateRadioMsg = _crateRadioMsg.." (".._crate.cratesRequired..")" + end + if _crate.multiple then + _crateRadioMsg = "* " .. _crateRadioMsg + end + local _menuEntry = { text = _crateRadioMsg, crate = _crate } + ctld.logTrace("_menuEntry = [%s]", ctld.p(_menuEntry)) + table.insert(menuEntries, _menuEntry) + end + end + end + end + for _i, _menu in ipairs(menuEntries) do + ctld.logTrace("_menu = [%s]", ctld.p(_menu)) + -- add the submenu item + itemNbSubmenu = itemNbSubmenu + 1 + if itemNbSubmenu == 10 and _i < #menuEntries then -- page limit reached + _subMenuPath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Next page"), _subMenuPath) + itemNbSubmenu = 1 + end + missionCommands.addCommandForGroup(_groupId, _menu.text, _subMenuPath, ctld.spawnCrate, { _unitName, _menu.crate.weight }) + end + end + end + end + + if (ctld.enabledFOBBuilding or ctld.enableCrates) and _unitActions.crates then + + local _crateCommands = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("CTLD Commands"), _rootPath) + if ctld.hoverPickup == false or ctld.loadCrateFromMenu == true then + if ctld.loadCrateFromMenu then + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Load Nearby Crate(s)"), _crateCommands, ctld.loadNearbyCrate, _unitName ) + end + end + + if ctld.loadCrateFromMenu or ctld.hoverPickup then + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Crate(s)"), _crateCommands, ctld.dropSlingCrate, { _unitName }) + end + + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Unpack Any Crate"), _crateCommands, ctld.unpackCrates, { _unitName }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("List Nearby Crates"), _crateCommands, ctld.listNearbyCrates, { _unitName }) + + if ctld.enabledFOBBuilding then + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("List FOBs"), _crateCommands, ctld.listFOBS, { _unitName }) + end + end + + if ctld.enableSmokeDrop then + local _smokeMenu = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Smoke Markers"), _rootPath) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Red Smoke"), _smokeMenu, ctld.dropSmoke, { _unitName, trigger.smokeColor.Red }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Blue Smoke"), _smokeMenu, ctld.dropSmoke, { _unitName, trigger.smokeColor.Blue }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Orange Smoke"), _smokeMenu, ctld.dropSmoke, { _unitName, trigger.smokeColor.Orange }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Green Smoke"), _smokeMenu, ctld.dropSmoke, { _unitName, trigger.smokeColor.Green }) + end + + if ctld.enabledRadioBeaconDrop then + local _radioCommands = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Radio Beacons"), _rootPath) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("List Beacons"), _radioCommands, ctld.listRadioBeacons, { _unitName }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Drop Beacon"), _radioCommands, ctld.dropRadioBeacon, { _unitName }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Remove Closest Beacon"), _radioCommands, ctld.removeRadioBeacon, { _unitName }) + elseif ctld.deployedRadioBeacons ~= {} then + local _radioCommands = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Radio Beacons"), _rootPath) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("List Beacons"), _radioCommands, ctld.listRadioBeacons, { _unitName }) + end + + if ctld.enabledRadioBeaconDrop then + local _radioCommands = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("CTLD Pilots Read This"), _rootPath) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("We use the unarmed Hummer as the CTLD crate because they will not stack up on top of each other causing issues for hovering helicopters to pick up crates. Multiple crates will appear as one until moved. To prevent the stacking, the original programming spread out the crates randomly within a 12m circle in front of the chopper. This caused an issue trying to place a single crate where you want it and now it places your dropped crate 30m directly in front of the chopper. When building a large sam site, spread the pieces out, don't place the launchers on top of the radars. Allow space for them. Don't build a launcher crate near a runway or taxiway, it may spread onto them when built. MOVE AWAY FROM THE SITE SO IT DOESN'T BUILD AND DESTROY YOUR CHOPPER"), _radioCommands, ctld.listRadioBeacons,{ _unitName }) + end + + + ctld.addedTo[tostring(_groupId)] = true + ctld.logTrace("ctld.addedTo = %s", ctld.p(ctld.addedTo)) + ctld.logTrace("done adding CTLD menu for _groupId = %s", ctld.p(_groupId)) + end + end + end + end) + if (not status) then + ctld.logError(string.format("Error adding f10 to transport: %s", error)) + end +end + +function ctld.addOtherF10MenuOptions() + ctld.logDebug("ctld.addOtherF10MenuOptions") + -- reschedule every 10 seconds + timer.scheduleFunction(ctld.addOtherF10MenuOptions, nil, timer.getTime() + 10) + local status, error = pcall(function() + + -- now do any player controlled aircraft that ARENT transport units + if ctld.enabledRadioBeaconDrop then + -- get all BLUE players + ctld.addRadioListCommand(2) + + -- get all RED players + ctld.addRadioListCommand(1) + end + + if ctld.JTAC_jtacStatusF10 then + ctld.addJTACRadioCommand(2) -- get all BLUE players + ctld.addJTACRadioCommand(1) -- get all RED players + end + + if ctld.reconF10Menu then + ctld.addReconRadioCommand(2) -- get all BLUE players + ctld.addReconRadioCommand(1) -- get all RED players + end + end) + + if (not status) then + env.error(string.format("Error adding f10 to other players: %s", error), false) + end +end + +--add to all players that arent transport +function ctld.addRadioListCommand(_side) + + local _players = coalition.getPlayers(_side) + + if _players ~= nil then + + for _, _playerUnit in pairs(_players) do + + local _groupId = ctld.getGroupId(_playerUnit) + + if _groupId then + + ctld.logTrace("ctld.addedTo = %s", ctld.p(ctld.addedTo)) + if ctld.addedTo[tostring(_groupId)] == nil then + ctld.logTrace("adding List Radio Beacons for _groupId = %s", ctld.p(_groupId)) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("List Radio Beacons"), nil, ctld.listRadioBeacons, { _playerUnit:getName() }) + ctld.addedTo[tostring(_groupId)] = true + end + end + end + end +end + +function ctld.addJTACRadioCommand(_side) + + local _players = coalition.getPlayers(_side) + + if _players ~= nil then + + for _, _playerUnit in pairs(_players) do + + local _groupId = ctld.getGroupId(_playerUnit) + + if _groupId then + + local newGroup = false + if ctld.jtacRadioAdded[tostring(_groupId)] == nil then + ctld.logDebug("ctld.addJTACRadioCommand - adding JTAC radio menu for unit [%s]", ctld.p(_playerUnit:getName())) + newGroup = true + local JTACpath = missionCommands.addSubMenuForGroup(_groupId, ctld.jtacMenuName) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("JTAC Status"), JTACpath, ctld.getJTACStatus, { _playerUnit:getName() }) + ctld.jtacRadioAdded[tostring(_groupId)] = true + end + + --fetch the time to check for a regular refresh + local time = timer.getTime() + + --depending on the delay, this part of the radio menu will be refreshed less often or as often as the static JTAC status command, this is for better reliability for the user when navigating through the menus. New groups will get the lists regardless and if a new JTAC is added all lists will be refreshed regardless of the delay. + if ctld.jtacLastRadioRefresh + ctld.jtacRadioRefreshDelay <= time or ctld.refreshJTACmenu[_side] or newGroup then + + ctld.jtacLastRadioRefresh = time + + --build the path to the CTLD JTAC menu + local jtacCurrentPagePath = {[1]=ctld.jtacMenuName} + --build the path for the NextPage submenu on the first page of the CTLD JTAC menu + local NextPageText = "Next Page" + local MainNextPagePath = {[1]=ctld.jtacMenuName, [2]=NextPageText} + --remove it along with everything that's in it + missionCommands.removeItemForGroup(_groupId, MainNextPagePath) + + --counter to know when to add the next page submenu to fit all of the JTAC group submenus + local jtacCounter = 0 + + for _jtacGroupName,jtacUnit in pairs(ctld.jtacUnits) do + --ctld.logTrace(string.format("JTAC - MENU - [%s] - processing menu", ctld.p(_jtacGroupName))) + + --if the JTAC is on the same team as the group being considered + local jtacCoalition = ctld.jtacUnits[_jtacGroupName].side + if jtacCoalition and jtacCoalition == _side then + --only bother removing the submenus on the first page of the CTLD JTAC menu as the other pages were deleted entirely above + if ctld.jtacGroupSubMenuPath[_jtacGroupName] and #ctld.jtacGroupSubMenuPath[_jtacGroupName]==2 then + missionCommands.removeItemForGroup(_groupId, ctld.jtacGroupSubMenuPath[_jtacGroupName]) + end + --ctld.logTrace(string.format("JTAC - MENU - [%s] - jtacTargetsList = %s", ctld.p(_jtacGroupName), ctld.p(ctld.jtacTargetsList[_jtacGroupName]))) + --ctld.logTrace(string.format("JTAC - MENU - [%s] - jtacCurrentTargets = %s", ctld.p(_jtacGroupName), ctld.p(ctld.jtacCurrentTargets[_jtacGroupName]))) + + local jtacActionMenu = false + for _,_specialOptionTable in pairs(ctld.jtacSpecialOptions) do + if _specialOptionTable.globalToggle then + jtacActionMenu = true + break + end + end + + --if JTAC has at least one other target in sight or (if special options are available (NOTE : accessed through the JTAC's own menu also) and the JTAC has at least one target) + if (ctld.jtacTargetsList[_jtacGroupName] and #ctld.jtacTargetsList[_jtacGroupName] >= 1) or (ctld.jtacCurrentTargets[_jtacGroupName] and jtacActionMenu) then + + local jtacGroupSubMenuName = string.format(_jtacGroupName .. " Selection") + + jtacCounter = jtacCounter + 1 + --F2 through F10 makes 9 entries possible per page, with one being the NextMenu submenu. F1 is taken by JTAC status entry. + if jtacCounter % 9 == 0 then + --recover the path to the current page with space available for JTAC group submenus + jtacCurrentPagePath = missionCommands.addSubMenuForGroup(_groupId, NextPageText, jtacCurrentPagePath) + end + --add the JTAC group submenu to the current page + ctld.jtacGroupSubMenuPath[_jtacGroupName] = missionCommands.addSubMenuForGroup(_groupId, jtacGroupSubMenuName, jtacCurrentPagePath) + --ctld.logTrace(string.format("JTAC - MENU - [%s] - jtacGroupSubMenuPath = %s", ctld.p(_jtacGroupName), ctld.p(ctld.jtacGroupSubMenuPath[_jtacGroupName]))) + + --make a copy of the JTAC group submenu's path to insert the target's list on as many pages as required. The JTAC's group submenu path only leads to the first page + local jtacTargetPagePath = mist.utils.deepCopy(ctld.jtacGroupSubMenuPath[_jtacGroupName]) + + --counter to know when to add the next page submenu to fit all of the targets in the JTAC's group submenu. SMay not actually start at 0 due to static items being present on the first page + local itemCounter = 0 + local jtacSpecialOptPagePath = nil + + if jtacActionMenu then + --special options + local SpecialOptionsCounter = 0 + + for _,_specialOption in pairs(ctld.jtacSpecialOptions) do + if _specialOption.globalToggle then + + if not jtacSpecialOptPagePath then + itemCounter = itemCounter + 1 --one item is added to the first JTAC target page + jtacSpecialOptPagePath = missionCommands.addSubMenuForGroup(_groupId, ctld.i18n_translate("Actions"), jtacTargetPagePath) + end + + SpecialOptionsCounter = SpecialOptionsCounter+1 + + if SpecialOptionsCounter%10 == 0 then + jtacSpecialOptPagePath = missionCommands.addSubMenuForGroup(_groupId, NextPageText, jtacSpecialOptPagePath) + SpecialOptionsCounter = SpecialOptionsCounter+1 --Added Next Page item + end + + if _specialOption.jtacs then + if _specialOption.jtacs[_jtacGroupName] then + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("DISABLE ") .. _specialOption.message, jtacSpecialOptPagePath, _specialOption.setter, {jtacGroupName = _jtacGroupName, value = false}) + else + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("ENABLE ") .. _specialOption.message, jtacSpecialOptPagePath, _specialOption.setter, {jtacGroupName = _jtacGroupName, value = true}) + end + else + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("REQUEST ") .. _specialOption.message, jtacSpecialOptPagePath, _specialOption.setter, {jtacGroupName = _jtacGroupName, value = false}) --value is not used here + end + end + end + end + + if #ctld.jtacTargetsList[_jtacGroupName] >= 1 then + --ctld.logTrace(string.format("JTAC - MENU - [%s] - adding targets menu", ctld.p(_jtacGroupName))) + + --add a reset targeting option to revert to automatic JTAC unit targeting + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Reset TGT Selection"), jtacTargetPagePath, ctld.setJTACTarget, {jtacGroupName = _jtacGroupName, targetName = nil}) + + itemCounter = itemCounter + 1 --one item is added to the first JTAC target page + + --indicator table to know which unitType was already added to the radio submenu + local typeNameList = {} + for _,target in pairs(ctld.jtacTargetsList[_jtacGroupName]) do + local targetName = target.unit:getName() + --check if the jtac has a current target before filtering it out if possible + if (ctld.jtacCurrentTargets[_jtacGroupName] and targetName ~= ctld.jtacCurrentTargets[_jtacGroupName].name) then + local targetType_name = target.unit:getTypeName() + + if targetType_name then + if typeNameList[targetType_name] then + typeNameList[targetType_name].amount = typeNameList[targetType_name].amount + 1 + else + typeNameList[targetType_name] = {} + typeNameList[targetType_name].targetName = targetName --store the first targetName + typeNameList[targetType_name].amount = 1 + end + end + end + end + + for typeName,info in pairs(typeNameList) do + local amount = info.amount + local targetName = info.targetName + itemCounter = itemCounter + 1 + + --F1 through F10 makes 10 entries possible per page, with one being the NextMenu submenu. + if itemCounter%10 == 0 then + jtacTargetPagePath = missionCommands.addSubMenuForGroup(_groupId, NextPageText, jtacTargetPagePath) + itemCounter = itemCounter + 1 --added the next page item + end + + missionCommands.addCommandForGroup(_groupId, string.format(typeName .. "(" .. amount .. ")"), jtacTargetPagePath, ctld.setJTACTarget, {jtacGroupName = _jtacGroupName, targetName = targetName}) + end + end + end + end + end + end + end + end + + if ctld.refreshJTACmenu[_side] then + ctld.refreshJTACmenu[_side] = false + end + end +end + +function ctld.getGroupId(_unit) + + local _unitDB = mist.DBs.unitsById[tonumber(_unit:getID())] + if _unitDB ~= nil and _unitDB.groupId then + return _unitDB.groupId + end + + return nil +end + +--get distance in meters assuming a Flat world +function ctld.getDistance(_point1, _point2) + + local xUnit = _point1.x + local yUnit = _point1.z + local xZone = _point2.x + local yZone = _point2.z + + local xDiff = xUnit - xZone + local yDiff = yUnit - yZone + + return math.sqrt(xDiff * xDiff + yDiff * yDiff) +end + + +------------ JTAC ----------- + +ctld.jtacMenuName = "JTAC" --name of the CTLD JTAC radio menu +ctld.jtacLaserPoints = {} +ctld.jtacIRPoints = {} +ctld.jtacSmokeMarks = {} +ctld.jtacUnits = {} -- list of JTAC units for f10 command +ctld.jtacStop = {} -- jtacs to tell to stop lasing +ctld.jtacCurrentTargets = {} +ctld.jtacTargetsList = {} --current available targets to each JTAC for lasing (targets from other JTACs are filtered out). Contains DCS unit objects with their methods and the distance to the JTAC {unit, dist} +ctld.jtacSelectedTarget = {} --currently user selected target if it contains a unit's name, otherwise contains 1 or nil (if not initialized) +ctld.jtacSpecialOptions = { --list which contains the status of special options for each jtac, ordered for them to show up in the correct order in the corresponding radio menu + standbyMode = { --#1 + globalToggle = ctld.JTAC_allowStandbyMode; + message = "Standby Mode"; + setter = nil; --ctld.setStdbMode, will be set after declaration of said function + jtacs = { + --enable flag for each JTAC + }; + }; --disable designation by the JTAC + smokeMarker = { --#4 + globalToggle = ctld.JTAC_allowSmokeRequest; + message = "Smoke on TGT"; + setter = nil; --ctld.setSmokeOnTarget + }; --smoke marker on target + laseSpotCorrections = { --#2 + globalToggle = ctld.JTAC_laseSpotCorrections; + message = "Speed Corrections"; + setter = nil; --ctld.setLaseCompensation + jtacs = { + --enable flag for each JTAC + }; + }; --target speed and wind compensation for laser spot + _9Line = { --#3 + globalToggle = ctld.JTAC_allow9Line; + message = "9 Line"; + setter = nil; --ctld.setJTAC9Line + }; --9Line message for JTAC +} +ctld.jtacRadioAdded = {} --keeps track of who's had the radio command added +ctld.jtacGroupSubMenuPath = {} --keeps track of which submenu contains each JTAC's target selection menu +ctld.jtacRadioRefreshDelay = 120 --determines how often in seconds the dynamic parts of the jtac radio menu (target lists) will be refreshed +ctld.jtacLastRadioRefresh = 0 -- time at which the target lists were refreshed for everyone at least +ctld.refreshJTACmenu = {} --indicator to know when a new JTAC is added to a coalition in order to rebuild the corresponding target lists +ctld.jtacGeneratedLaserCodes = {} -- keeps track of generated codes, cycles when they run out +ctld.jtacLaserPointCodes = {} +ctld.jtacRadioData = {} + +--[[ + Called when a new JTAC is spawned, it will wait one second for DCS to have time to fill the group with units, and then call ctld.JTACAutoLase. + + The goal here is to correct a bug: when a group is respawned (i.e. when any group with the name of a previously existing group is spawned), + DCS spawns a group which exists (Group.getByName gets a valid table, and group:isExist returns true), but has no units (i.e. group:getUnits returns an empty table). + This causes JTACAutoLase to call cleanupJTAC because it does not find the JTAC unit, and the JTAC to be put out of the JTACAutoLase loop, and never processed again. + By waiting a bit, the group gets populated before JTACAutoLase is called, hence avoiding a trip to cleanupJTAC. +]] +function ctld.JTACStart(_jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio) + mist.scheduleFunction(ctld.JTACAutoLase, {_jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio}, timer.getTime()+1) +end + +function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio) + ctld.logDebug(string.format("ctld.JTACAutoLase(_jtacGroupName=%s, _laserCode=%s", ctld.p(_jtacGroupName), ctld.p(_laserCode))) + + local _radio = _radio + if not _radio then + _radio = {} + if _laserCode then + local _laserCode = tonumber(_laserCode) + if _laserCode and _laserCode >= 1111 and _laserCode <= 1688 then + local _laserB = math.floor((_laserCode - 1000)/100) + local _laserCD = _laserCode - 1000 - _laserB*100 + local _frequency = tostring(30+_laserB+_laserCD*0.05) + ctld.logTrace(string.format("_laserB=%s", ctld.p(_laserB))) + ctld.logTrace(string.format("_laserCD=%s", ctld.p(_laserCD))) + ctld.logTrace(string.format("_frequency=%s", ctld.p(_frequency))) + _radio.freq = _frequency + _radio.mod = "fm" + end + end + end + + if _radio and not _radio.name then + _radio.name = _jtacGroupName + end + + if ctld.jtacStop[_jtacGroupName] == true then + ctld.jtacStop[_jtacGroupName] = nil -- allow it to be started again + ctld.cleanupJTAC(_jtacGroupName) + return + end + + if _lock == nil then + _lock = ctld.JTAC_lock + end + + ctld.jtacLaserPointCodes[_jtacGroupName] = _laserCode + ctld.jtacRadioData[_jtacGroupName] = _radio + + local _jtacGroup = ctld.getGroup(_jtacGroupName) + local _jtacUnit + + if _jtacGroup == nil or #_jtacGroup == 0 then + + --check not in a heli + if ctld.inTransitTroops then + for _, _onboard in pairs(ctld.inTransitTroops) do + if _onboard ~= nil then + if _onboard.troops ~= nil and _onboard.troops.groupName ~= nil and _onboard.troops.groupName == _jtacGroupName then + + --jtac soldier being transported by heli + ctld.cleanupJTAC(_jtacGroupName) + + ctld.logTrace(string.format("JTAC - LASE - [%s] - in transport, waiting - scheduling JTACAutoLase in %ss at %s", ctld.p(_jtacGroupName), ctld.p(10), ctld.p(timer.getTime() + 10))) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 10) + return + end + + if _onboard.vehicles ~= nil and _onboard.vehicles.groupName ~= nil and _onboard.vehicles.groupName == _jtacGroupName then + --jtac vehicle being transported by heli + ctld.cleanupJTAC(_jtacGroupName) + + ctld.logTrace(string.format("JTAC - LASE - [%s] - in transport, waiting - scheduling JTACAutoLase in %ss at %s", ctld.p(_jtacGroupName), ctld.p(10), ctld.p(timer.getTime() + 10))) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 10) + return + end + end + end + end + + if ctld.jtacUnits[_jtacGroupName] ~= nil then + ctld.notifyCoalition(ctld.i18n_translate("JTAC Group %1 KIA!", _jtacGroupName), 10, ctld.jtacUnits[_jtacGroupName].side, _radio) + end + + --remove from list + ctld.cleanupJTAC(_jtacGroupName) + + return + else + + _jtacUnit = _jtacGroup[1] + local _jtacCoalition = _jtacUnit:getCoalition() + --add to list + ctld.jtacUnits[_jtacGroupName] = { name = _jtacUnit:getName(), side = _jtacCoalition, radio = _radio } + + --Targets list, special options and Selected target initialization + if not ctld.jtacTargetsList[_jtacGroupName] then + --Target list + ctld.jtacTargetsList[_jtacGroupName] = {} + if _jtacCoalition then ctld.refreshJTACmenu[_jtacCoalition] = true end + + --Special Options + for _,_specialOption in pairs(ctld.jtacSpecialOptions) do + if _specialOption.jtacs then + _specialOption.jtacs[_jtacGroupName] = false + end + end + end + + if not ctld.jtacSelectedTarget[_jtacGroupName] then + ctld.jtacSelectedTarget[_jtacGroupName] = 1 + end + + -- work out smoke colour + if _colour == nil then + + if _jtacUnit:getCoalition() == 1 then + _colour = ctld.JTAC_smokeColour_RED + else + _colour = ctld.JTAC_smokeColour_BLUE + end + end + + + if _smoke == nil then + + if _jtacUnit:getCoalition() == 1 then + _smoke = ctld.JTAC_smokeOn_RED + else + _smoke = ctld.JTAC_smokeOn_BLUE + end + end + end + + + -- search for current unit + + if _jtacUnit:isActive() == false then + + ctld.cleanupJTAC(_jtacGroupName) + + ctld.logTrace(string.format("JTAC - LASE - [%s] - not active, scheduling JTACAutoLase in 30s at %s", ctld.p(_jtacGroupName), ctld.p(timer.getTime() + 30))) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 30) + + return + end + + local _enemyUnit = ctld.getCurrentUnit(_jtacUnit, _jtacGroupName) + --update targets list and store the next potential target if the selected one was lost + local _defaultEnemyUnit = ctld.findNearestVisibleEnemy(_jtacUnit, _lock) + + -- if the JTAC sees a unit and a target was selected by users but is not the current unit, check if the selected target is in the targets list, if it is, then it's been reacquired + if _enemyUnit and ctld.jtacSelectedTarget[_jtacGroupName] ~= 1 and ctld.jtacSelectedTarget[_jtacGroupName] ~= _enemyUnit:getName() then + for _,target in pairs(ctld.jtacTargetsList[_jtacGroupName]) do + if target then + local targetUnit = target.unit + local targetName = targetUnit:getName() + + if ctld.jtacSelectedTarget[_jtacGroupName] == targetName then + + ctld.jtacCurrentTargets[_jtacGroupName] = { name = targetName, unitType = targetUnit:getTypeName(), unitId = targetUnit:getID() } + _enemyUnit = targetUnit + + local message = ctld.i18n_translate("%1, selected target reacquired, %2", _jtacGroupName, _enemyUnit:getTypeName()) + local fullMessage = message .. ctld.i18n_translate(". CODE: %1. POSITION: %2", _laserCode, ctld.getPositionString(_enemyUnit)) + ctld.notifyCoalition(fullMessage, 10, _jtacUnit:getCoalition(), _radio, message) + end + end + end + end + + local targetDestroyed = false + local targetLost = false + local wasSelected = false + + if _enemyUnit == nil and ctld.jtacCurrentTargets[_jtacGroupName] ~= nil then + + local _tempUnitInfo = ctld.jtacCurrentTargets[_jtacGroupName] + + -- env.info("TEMP UNIT INFO: " .. tempUnitInfo.name .. " " .. tempUnitInfo.unitType) + + local _tempUnit = Unit.getByName(_tempUnitInfo.name) + + wasSelected = (ctld.jtacCurrentTargets[_jtacGroupName].name == ctld.jtacSelectedTarget[_jtacGroupName]) + + if _tempUnit ~= nil and _tempUnit:getLife() > 0 and _tempUnit:isActive() == true then + targetLost = true + else + targetDestroyed = true + ctld.jtacSelectedTarget[_jtacGroupName] = 1 + end + + --remove from smoke list + ctld.jtacSmokeMarks[_tempUnitInfo.name] = nil + + -- JTAC Unit: resume his route ------------ + trigger.action.groupContinueMoving(Group.getByName(_jtacGroupName)) + + -- remove from target list + ctld.jtacCurrentTargets[_jtacGroupName] = nil + + --stop lasing + ctld.cancelLase(_jtacGroupName) + end + + + if _enemyUnit == nil then + if _defaultEnemyUnit ~= nil then + + -- store current target for easy lookup + ctld.jtacCurrentTargets[_jtacGroupName] = { name = _defaultEnemyUnit:getName(), unitType = _defaultEnemyUnit:getTypeName(), unitId = _defaultEnemyUnit:getID() } + + --add check for lasing or not + local action = ctld.i18n_translate("new target, ") + + if ctld.jtacSpecialOptions.standbyMode.jtacs[_jtacGroupName] then + action = ctld.i18n_translate("standing by on %1", action) + else + action = ctld.i18n_translate("lasing %1", action) + end + + if wasSelected and targetLost then + action = ctld.i18n_translate(", temporarily %1", action) + else + action = ", " .. action + end + + if targetLost then + action = ctld.i18n_translate("target lost") .. action + elseif targetDestroyed then + action = ctld.i18n_translate("target destroyed") .. action + end + + if wasSelected then + action = ctld.i18n_translate(", selected %1", action) + elseif targetLost or targetDestroyed then + action = ", " .. action + end + wasSelected = false + targetDestroyed = false + targetLost = false + + local message = _jtacGroupName .. action .. _defaultEnemyUnit:getTypeName() + local fullMessage = message .. '. CODE: ' .. _laserCode .. ". POSITION: " .. ctld.getPositionString(_defaultEnemyUnit) + ctld.notifyCoalition(fullMessage, 10, _jtacUnit:getCoalition(), _radio, message) + + -- JTAC Unit stop his route ----------------- + trigger.action.groupStopMoving(Group.getByName(_jtacGroupName)) -- stop JTAC + + -- create smoke + if _smoke == true then + + --create first smoke + ctld.createSmokeMarker(_defaultEnemyUnit, _colour) + end + end + end + + if _enemyUnit ~= nil and not ctld.jtacSpecialOptions.standbyMode.jtacs[_jtacGroupName] then + + local refreshDelay = 15 --delay in between JTACAutoLase scheduled calls when a target is tracked + local targetSpeedVec = _enemyUnit:getVelocity() + local targetSpeed = math.sqrt(targetSpeedVec.x^2+targetSpeedVec.y^2+targetSpeedVec.z^2) + local maxUpdateDist = 5 --maximum distance the unit will be allowed to travel before the lase spot is updated again + ctld.logTrace(string.format("targetSpeed=%s", ctld.p(targetSpeed))) + + ctld.laseUnit(_enemyUnit, _jtacUnit, _jtacGroupName, _laserCode) + + --if the target is going sufficiently fast for it to wander off futher than the maxUpdateDist, schedule laseUnit calls to update the lase spot only (we consider that the unit lives and drives on between JTACAutoLase calls) + if targetSpeed >= maxUpdateDist/refreshDelay then + local updateTimeStep = maxUpdateDist/targetSpeed --calculate the time step so that the target is never more than maxUpdateDist from it's last lased position + ctld.logTrace(string.format("JTAC - LASE - [%s] - target is moving at %s m/s, schedulting lasing steps every %ss", ctld.p(_jtacGroupName), ctld.p(targetSpeed), ctld.p(updateTimeStep))) + + local i = 1 + while i*updateTimeStep <= refreshDelay - updateTimeStep do --while the scheduled time for the laseUnit call isn't greater than the time between two JTACAutoLase() calls minus one time step (because at the next time step JTACAutoLase() should have been called and this in term also calls laseUnit()) + timer.scheduleFunction(ctld.timerLaseUnit,{_enemyUnit, _jtacUnit, _jtacGroupName, _laserCode}, timer.getTime()+i*updateTimeStep) + i = i + 1 + end + ctld.logTrace(string.format("JTAC - LASE - [%s] - scheduled %s moving target lasing steps", ctld.p(_jtacGroupName), ctld.p(i))) + end + + ctld.logTrace(string.format("JTAC - LASE - [%s] - scheduling JTACAutoLase in %ss at %s", ctld.p(_jtacGroupName), ctld.p(refreshDelay), ctld.p(timer.getTime() + refreshDelay))) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + refreshDelay) + + if _smoke == true then + local _nextSmokeTime = ctld.jtacSmokeMarks[_enemyUnit:getName()] + + --recreate smoke marker after 5 mins + if _nextSmokeTime ~= nil and _nextSmokeTime < timer.getTime() then + + ctld.createSmokeMarker(_enemyUnit, _colour) + end + end + + else + ctld.logDebug(string.format("JTAC - MODE - [%s] - No Enemies Nearby / Standby mode", ctld.p(_jtacGroupName))) + + -- stop lazing the old spot + ctld.logDebug(string.format("JTAC - LASE - [%s] - canceling lasing of the old spot", ctld.p(_jtacGroupName))) + ctld.cancelLase(_jtacGroupName) + + ctld.logTrace(string.format("JTAC - LASE - [%s] - scheduling JTACAutoLase in %ss at %s", ctld.p(_jtacGroupName), ctld.p(5), ctld.p(timer.getTime() + 5))) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 5) + end + + local action = ", " + if wasSelected then + action = action .. "selected " + end + + if targetLost then + ctld.notifyCoalition(ctld.i18n_translate("%1 %2 target lost.", _jtacGroupName , action), 10, _jtacUnit:getCoalition(), _radio) + elseif targetDestroyed then + ctld.notifyCoalition(ctld.i18n_translate("%1 %2 target destroyed.", _jtacGroupName , action), 10, _jtacUnit:getCoalition(), _radio) + end +end + +function ctld.JTACAutoLaseStop(_jtacGroupName) + ctld.jtacStop[_jtacGroupName] = true +end + +-- used by the timer function +function ctld.timerJTACAutoLase(_args) + + ctld.JTACAutoLase(_args[1], _args[2], _args[3], _args[4], _args[5], _args[6]) +end + +function ctld.cleanupJTAC(_jtacGroupName) + -- clear laser - just in case + ctld.cancelLase(_jtacGroupName) + + -- Cleanup + ctld.jtacCurrentTargets[_jtacGroupName] = nil + + ctld.jtacTargetsList[_jtacGroupName] = nil + + ctld.jtacSelectedTarget[_jtacGroupName] = nil + + for _,_specialOption in pairs(ctld.jtacSpecialOptions) do --delete jtac specific settings for all special options + if _specialOption.jtacs then + _specialOption.jtacs[_jtacGroupName] = nil + end + end + + ctld.jtacRadioData[_jtacGroupName] = nil + + --remove the JTAC's group submenu and all of the target pages it potentially contained if the JTAC has or had a menu + if ctld.jtacUnits[_jtacGroupName] and ctld.jtacUnits[_jtacGroupName].side and ctld.jtacGroupSubMenuPath[_jtacGroupName] then + local _players = coalition.getPlayers(ctld.jtacUnits[_jtacGroupName].side) + + if _players ~= nil then + + for _, _playerUnit in pairs(_players) do + + local _groupId = ctld.getGroupId(_playerUnit) + + if _groupId then + missionCommands.removeItemForGroup(_groupId, ctld.jtacGroupSubMenuPath[_jtacGroupName]) + end + end + end + end + + ctld.jtacUnits[_jtacGroupName] = nil + + ctld.jtacGroupSubMenuPath[_jtacGroupName] = nil +end + + +--- send a message to the coalition +--- if _radio is set, the message will be read out loud via SRS +function ctld.notifyCoalition(_message, _displayFor, _side, _radio, _shortMessage) + + trigger.action.outTextForCoalition(_side, _message, _displayFor) + + local _shortMessage = _shortMessage + if _shortMessage == nil then + _shortMessage = _message + end + + if STTS and STTS.TextToSpeech and _radio and _radio.freq then + local _freq = _radio.freq + local _modulation = _radio.mod or "FM" + local _volume = _radio.volume or "1.0" + local _name = _radio.name or "JTAC" + local _gender = _radio.gender or "male" + local _culture = _radio.culture or "en-US" + local _voice = _radio.voice + local _googleTTS = _radio.googleTTS or false + STTS.TextToSpeech(_shortMessage, _freq, _modulation, _volume, _name, _side, nil, 1, _gender, _culture, _voice, _googleTTS) + else + trigger.action.outSoundForCoalition(_side, "radiobeep.ogg") + end +end + +function ctld.createSmokeMarker(_enemyUnit, _colour) + + --recreate in 5 mins + ctld.jtacSmokeMarks[_enemyUnit:getName()] = timer.getTime() + 300.0 + + local _enemyPoint = _enemyUnit:getPoint() + trigger.action.smoke({ x = _enemyPoint.x + math.random(-ctld.JTAC_smokeMarginOfError, ctld.JTAC_smokeMarginOfError) + ctld.JTAC_smokeOffset_x, y = _enemyPoint.y + ctld.JTAC_smokeOffset_y, z = _enemyPoint.z + math.random(-ctld.JTAC_smokeMarginOfError, ctld.JTAC_smokeMarginOfError) + ctld.JTAC_smokeOffset_z }, _colour) +end + +function ctld.cancelLase(_jtacGroupName) + + --local index = "JTAC_"..jtacUnit:getID() + + local _tempLase = ctld.jtacLaserPoints[_jtacGroupName] + + if _tempLase ~= nil then + Spot.destroy(_tempLase) + ctld.jtacLaserPoints[_jtacGroupName] = nil + + -- env.info('Destroy laze '..index) + + _tempLase = nil + end + + local _tempIR = ctld.jtacIRPoints[_jtacGroupName] + + if _tempIR ~= nil then + Spot.destroy(_tempIR) + ctld.jtacIRPoints[_jtacGroupName] = nil + + -- env.info('Destroy laze '..index) + + _tempIR = nil + end +end + +-- used by the timer function +function ctld.timerLaseUnit(_args) + + ctld.laseUnit(_args[1], _args[2], _args[3], _args[4]) +end + +function ctld.laseUnit(_enemyUnit, _jtacUnit, _jtacGroupName, _laserCode) + + --cancelLase(jtacGroupName) + ctld.logTrace("ctld.laseUnit()") + + local _spots = {} + + if _enemyUnit:isExist() then + local _enemyVector = _enemyUnit:getPoint() + local _enemyVectorUpdated = { x = _enemyVector.x, y = _enemyVector.y + 2.0, z = _enemyVector.z } + + if ctld.jtacSpecialOptions.laseSpotCorrections.jtacs[_jtacGroupName] then + local _enemySpeedVector = _enemyUnit:getVelocity() + ctld.logTrace(string.format("_enemySpeedVector=%s", ctld.p(_enemySpeedVector))) + + local _WindSpeedVector = atmosphere.getWind(_enemyVectorUpdated) + ctld.logTrace(string.format("_WindSpeedVector=%s", ctld.p(_WindSpeedVector))) + + --if target speed is greater than 0, calculated using absolute value norm + if math.abs(_enemySpeedVector.x) + math.abs(_enemySpeedVector.y) + math.abs(_enemySpeedVector.z) > 0 then + local CorrectionFactor = 1 --correction factor in seconds applied to the target speed components to determine the lasing spot for a direct hit on a moving vehicle + + --correct in the direction of the movement + _enemyVectorUpdated.x = _enemyVectorUpdated.x + _enemySpeedVector.x * CorrectionFactor + _enemyVectorUpdated.y = _enemyVectorUpdated.y + _enemySpeedVector.y * CorrectionFactor + _enemyVectorUpdated.z = _enemyVectorUpdated.z + _enemySpeedVector.z * CorrectionFactor + end + + --if wind speed is greater than 0, calculated using absolute value norm + if math.abs(_WindSpeedVector.x) + math.abs(_WindSpeedVector.y) + math.abs(_WindSpeedVector.z) > 0 then + local CorrectionFactor = 1.05 --correction factor in seconds applied to the wind speed components to determine the lasing spot for a direct hit in adverse conditions + + --correct to the opposite of the wind direction + _enemyVectorUpdated.x = _enemyVectorUpdated.x - _WindSpeedVector.x * CorrectionFactor + _enemyVectorUpdated.y = _enemyVectorUpdated.y - _WindSpeedVector.y * CorrectionFactor --not sure about correcting altitude but that component is always 0 in testing + _enemyVectorUpdated.z = _enemyVectorUpdated.z - _WindSpeedVector.z * CorrectionFactor + end + --combination of both should result in near perfect accuracy if the bomb doesn't stall itself following fast vehicles or correcting for heavy winds, correction factors can be adjusted but should work up to 40kn of wind for vehicles moving at 90kph (beware to drop the bomb in a way to not stall it, facing which ever is larger, target speed or wind) + end + + local _oldLase = ctld.jtacLaserPoints[_jtacGroupName] + local _oldIR = ctld.jtacIRPoints[_jtacGroupName] + + if _oldLase == nil or _oldIR == nil then + + -- create lase + + local _status, _result = pcall(function() + _spots['irPoint'] = Spot.createInfraRed(_jtacUnit, { x = 0, y = 2.0, z = 0 }, _enemyVectorUpdated) + _spots['laserPoint'] = Spot.createLaser(_jtacUnit, { x = 0, y = 2.0, z = 0 }, _enemyVectorUpdated, _laserCode) + return _spots + end) + + if not _status then + env.error('ERROR: ' .. _result, false) + else + if _result.irPoint then + + -- env.info(jtacUnit:getName() .. ' placed IR Pointer on '..enemyUnit:getName()) + + ctld.jtacIRPoints[_jtacGroupName] = _result.irPoint --store so we can remove after + end + if _result.laserPoint then + + -- env.info(jtacUnit:getName() .. ' is Lasing '..enemyUnit:getName()..'. CODE:'..laserCode) + + ctld.jtacLaserPoints[_jtacGroupName] = _result.laserPoint + end + end + + else + + -- update lase + + if _oldLase ~= nil then + _oldLase:setPoint(_enemyVectorUpdated) + end + + if _oldIR ~= nil then + _oldIR:setPoint(_enemyVectorUpdated) + end + end + end +end + +-- get currently selected unit and check they're still in range +function ctld.getCurrentUnit(_jtacUnit, _jtacGroupName) + + local _unit = nil + + if ctld.jtacCurrentTargets[_jtacGroupName] ~= nil then + _unit = Unit.getByName(ctld.jtacCurrentTargets[_jtacGroupName].name) + end + + local _tempPoint = nil + local _tempDist = nil + local _tempPosition = nil + + local _jtacPosition = _jtacUnit:getPosition() + local _jtacPoint = _jtacUnit:getPoint() + + if _unit ~= nil and _unit:getLife() > 0 and _unit:isActive() == true then + + -- calc distance + _tempPoint = _unit:getPoint() + -- tempPosition = unit:getPosition() + + _tempDist = ctld.getDistance(_unit:getPoint(), _jtacUnit:getPoint()) + if _tempDist < ctld.JTAC_maxDistance then + -- calc visible + + -- check slightly above the target as rounding errors can cause issues, plus the unit has some height anyways + local _offsetEnemyPos = { x = _tempPoint.x, y = _tempPoint.y + 2.0, z = _tempPoint.z } + local _offsetJTACPos = { x = _jtacPoint.x, y = _jtacPoint.y + 2.0, z = _jtacPoint.z } + + if land.isVisible(_offsetEnemyPos, _offsetJTACPos) then + return _unit + end + end + end + return nil +end + + +-- Find nearest enemy to JTAC that isn't blocked by terrain +function ctld.findNearestVisibleEnemy(_jtacUnit, _targetType,_distance) + + --local startTime = os.clock() + + local _maxDistance = _distance or ctld.JTAC_maxDistance + + local _nearestDistance = _maxDistance + + local _jtacGroupName = _jtacUnit:getGroup():getName() + local _jtacPoint = _jtacUnit:getPoint() + local _coa = _jtacUnit:getCoalition() + + local _offsetJTACPos = { x = _jtacPoint.x, y = _jtacPoint.y + 2.0, z = _jtacPoint.z } + + local _volume = { + id = world.VolumeType.SPHERE, + params = { + point = _offsetJTACPos, + radius = _maxDistance + } + } + + local _unitList = {} + + + local _search = function(_unit, _coa) + pcall(function() + + if _unit ~= nil + and _unit:getLife() > 0 + and _unit:isActive() + and _unit:getCoalition() ~= _coa + and not _unit:inAir() + and not ctld.alreadyTarget(_jtacUnit,_unit) then + + local _tempPoint = _unit:getPoint() + local _offsetEnemyPos = { x = _tempPoint.x, y = _tempPoint.y + 2.0, z = _tempPoint.z } + + if land.isVisible(_offsetJTACPos,_offsetEnemyPos ) then + + local _dist = ctld.getDistance(_offsetJTACPos, _offsetEnemyPos) + + if _dist < _maxDistance then + table.insert(_unitList,{unit=_unit, dist=_dist}) + + end + end + end + end) + + return true + end + + world.searchObjects(Object.Category.UNIT, _volume, _search, _coa) + + --log.info(string.format("JTAC Search elapsed time: %.4f\n", os.clock() - startTime)) + + -- generate list order by distance & visible + + -- first check + -- hpriority + -- priority + -- vehicle + -- unit + + + ctld.jtacTargetsList[_jtacGroupName] = _unitList + --from the units in range, build the targets list, unsorted as to keep consistency between radio menu refreshes + + local _sort = function( a,b ) return a.dist < b.dist end + table.sort(_unitList,_sort) + -- sort list + + -- check for hpriority + for _, _enemyUnit in ipairs(_unitList) do + local _enemyName = _enemyUnit.unit:getName() + + if string.match(_enemyName, "hpriority") then + return _enemyUnit.unit + end + end + + for _, _enemyUnit in ipairs(_unitList) do + local _enemyName = _enemyUnit.unit:getName() + + if string.match(_enemyName, "priority") then + return _enemyUnit.unit + end + end + + local result = nil + for _, _enemyUnit in ipairs(_unitList) do + local _enemyName = _enemyUnit.unit:getName() + --log.info(string.format("CTLD - checking _enemyName=%s", _enemyName)) + + -- check for air defenses + --log.info(string.format("CTLD - _enemyUnit.unit:getDesc()[attributes]=%s", ctld.p(_enemyUnit.unit:getDesc()["attributes"]))) + local airdefense = (_enemyUnit.unit:getDesc()["attributes"]["Air Defence"] ~= nil) + --log.info(string.format("CTLD - airdefense=%s", tostring(airdefense))) + + if (_targetType == "vehicle" and ctld.isVehicle(_enemyUnit.unit)) or _targetType == "all" then + if airdefense then + return _enemyUnit.unit + else + result = _enemyUnit.unit + end + + elseif (_targetType == "troop" and ctld.isInfantry(_enemyUnit.unit)) or _targetType == "all" then + if airdefense then + return _enemyUnit.unit + else + result = _enemyUnit.unit + end + end + end + + return result + +end + + +function ctld.listNearbyEnemies(_jtacUnit) + + local _maxDistance = ctld.JTAC_maxDistance + + local _jtacPoint = _jtacUnit:getPoint() + local _coa = _jtacUnit:getCoalition() + + local _offsetJTACPos = { x = _jtacPoint.x, y = _jtacPoint.y + 2.0, z = _jtacPoint.z } + + local _volume = { + id = world.VolumeType.SPHERE, + params = { + point = _offsetJTACPos, + radius = _maxDistance + } + } + local _enemies = nil + + local _search = function(_unit, _coa) + pcall(function() + + if _unit ~= nil + and _unit:getLife() > 0 + and _unit:isActive() + and _unit:getCoalition() ~= _coa + and not _unit:inAir() then + + local _tempPoint = _unit:getPoint() + local _offsetEnemyPos = { x = _tempPoint.x, y = _tempPoint.y + 2.0, z = _tempPoint.z } + + if land.isVisible(_offsetJTACPos,_offsetEnemyPos ) then + + if not _enemies then + _enemies = {} + end + + _enemies[_unit:getTypeName()] = _unit:getTypeName() + + end + end + end) + + return true + end + + world.searchObjects(Object.Category.UNIT, _volume, _search, _coa) + + return _enemies +end + +-- tests whether the unit is targeted by another JTAC +function ctld.alreadyTarget(_jtacUnit, _enemyUnit) + + for _, _jtacTarget in pairs(ctld.jtacCurrentTargets) do + + if _jtacTarget.unitId == _enemyUnit:getID() then + -- env.info("ALREADY TARGET") + return true + end + end + + return false +end + + +-- Returns only alive units from group but the group / unit may not be active + +function ctld.getGroup(groupName) + + local _group = Group.getByName(groupName) + + local _filteredUnits = {} --contains alive units + local _x = 1 + + if _group ~= nil then + ctld.logTrace(string.format("ctld.getGroup - %s - group ~= nil", ctld.p(groupName))) + if _group:isExist() then + ctld.logTrace(string.format("ctld.getGroup - %s - group:isExist()", ctld.p(groupName))) + local _groupUnits = _group:getUnits() + + if _groupUnits ~= nil and #_groupUnits > 0 then + ctld.logTrace(string.format("ctld.getGroup - %s - group has %s units", ctld.p(groupName), ctld.p(#_groupUnits))) + for _x = 1, #_groupUnits do + if _groupUnits[_x]:getLife() > 0 then -- removed and _groupUnits[_x]:isExist() as isExist doesnt work on single units! + table.insert(_filteredUnits, _groupUnits[_x]) + else + ctld.logTrace(string.format("ctld.getGroup - %s - dead unit %s", ctld.p(groupName), ctld.p(_groupUnits[_x]:getName()))) + end + end + end + end + end + + return _filteredUnits +end + +function ctld.getAliveGroup(_groupName) + + local _group = Group.getByName(_groupName) + + if _group and _group:isExist() == true and #_group:getUnits() > 0 then + return _group + end + + return nil +end + +-- gets the JTAC status and displays to coalition units +function ctld.getJTACStatus(_args) + + --returns the status of all JTAC units unless the status of a single JTAC is asked for (by inserting it's groupName in _args[2]) + + local _playerUnit = ctld.getTransportUnit(_args[1]) + local _singleJtacGroupName = _args[2] + + if _playerUnit == nil and _singleJtacGroupName == nil then + return + end + + local _side = nil + + if _playerUnit == nil then + _side = ctld.jtacUnits[_singleJtacGroupName].side + else + _side = _playerUnit:getCoalition() + end + + local _jtacUnit = nil + local hasJTAC = false + local _message = ctld.i18n_translate("JTAC STATUS: \n\n") + + for _jtacGroupName, _jtacDetails in pairs(ctld.jtacUnits) do + + --look up units + if _singleJtacGroupName == nil or (_singleJtacGroupName and _singleJtacGroupName == _jtacGroupName) then --if the status of a single JTAC or if the status of a single JTAC was asked and this is the correct JTAC we're going over in the loop + _jtacUnit = Unit.getByName(_jtacDetails.name) + + if _jtacUnit ~= nil and _jtacUnit:getLife() > 0 and _jtacUnit:isActive() == true and _jtacUnit:getCoalition() == _side then + + hasJTAC = true + + local _enemyUnit = ctld.getCurrentUnit(_jtacUnit, _jtacGroupName) + + local _laserCode = ctld.jtacLaserPointCodes[_jtacGroupName] + + local _start = "->" .. _jtacGroupName + if (_jtacDetails.radio) then + _start = _start .. ctld.i18n_translate(", available on %1 %2,", _jtacDetails.radio.freq, _jtacDetails.radio.mod) + end + + if _laserCode == nil then + _laserCode = ctld.i18n_translate("UNKNOWN") + end + + if _enemyUnit ~= nil and _enemyUnit:getLife() > 0 and _enemyUnit:isActive() == true then + + local action = ctld.i18n_translate(" targeting ") + + if ctld.jtacSelectedTarget[_jtacGroupName] == _enemyUnit:getName() then + action = ctld.i18n_translate(" targeting selected unit ") + else + if ctld.jtacSelectedTarget[_jtacGroupName] ~= 1 then + action = ctld.i18n_translate(" attempting to find selected unit, temporarily targeting ") + end + end + + if ctld.jtacSpecialOptions.standbyMode.jtacs[_jtacGroupName] then + action = action .. ctld.i18n_translate("(Laser OFF) ") + end + + _message = _message .. "" .. _start .. action .. _enemyUnit:getTypeName() .. " CODE: " .. _laserCode .. ctld.getPositionString(_enemyUnit) .. "\n" + + local _list = ctld.listNearbyEnemies(_jtacUnit) + + if _list then + _message = _message .. ctld.i18n_translate("Visual On: ") + + for _,_type in pairs(_list) do + _message = _message.._type..", " + end + _message = _message.."\n" + end + + else + _message = _message .. "" .. _start .. ctld.i18n_translate(" searching for targets %1\n", ctld.getPositionString(_jtacUnit)) + end + end + end + end + + if not hasJTAC then + ctld.notifyCoalition(ctld.i18n_translate("No Active JTACs"), 10, _side) + else + ctld.notifyCoalition(_message, 10, _side) + end +end + +function ctld.setJTACTarget(_args) + if _args then + local _jtacGroupName = _args.jtacGroupName + local targetName = _args.targetName + + if _jtacGroupName and targetName and ctld.jtacSelectedTarget[_jtacGroupName] and ctld.jtacTargetsList[_jtacGroupName] then + + --look for the unit's (target) name in the Targets List, create the required data structure for jtacCurrentTargets and then assign it to the JTAC called _jtacGroupName + for _, target in pairs(ctld.jtacTargetsList[_jtacGroupName]) do + + if target then + + local listedTargetUnit = target.unit + local ListedTargetName = listedTargetUnit:getName() + + if ListedTargetName == targetName then + + ctld.jtacSelectedTarget[_jtacGroupName] = targetName + ctld.jtacCurrentTargets[_jtacGroupName] = { name = targetName, unitType = listedTargetUnit:getTypeName(), unitId = listedTargetUnit:getID() } + + local message = _jtacGroupName .. ctld.i18n_translate(", targeting selected unit, %1",listedTargetUnit:getTypeName()) + local fullMessage = message .. ctld.i18n_translate(". CODE: %1. POSITION: %2", ctld.jtacLaserPointCodes[_jtacGroupName], ctld.getPositionString(listedTargetUnit)) + ctld.notifyCoalition(fullMessage, 10, ctld.jtacUnits[_jtacGroupName].side, ctld.jtacRadioData[_jtacGroupName], message) + end + end + end + elseif not targetName and ctld.jtacSelectedTarget[_jtacGroupName] ~= 1 then + ctld.jtacSelectedTarget[_jtacGroupName] = 1 + ctld.jtacCurrentTargets[_jtacGroupName] = nil + + local message = _jtacGroupName .. ctld.i18n_translate(", target selection reset.") + ctld.notifyCoalition(message, 10, ctld.jtacUnits[_jtacGroupName].side, ctld.jtacRadioData[_jtacGroupName]) + + if ctld.jtacSpecialOptions.laseSpotCorrections.jtacs[_jtacGroupName] then + ctld.setLaseCompensation({jtacGroupName = _jtacGroupName, value = false}) --disable laser spot corrections + end + + if ctld.jtacSpecialOptions.standbyMode.jtacs[_jtacGroupName] then + ctld.setStdbMode({jtacGroupName = _jtacGroupName, value = false}) --make the JTAC exit standby mode after either target selection or targeting selection reset + end + end + + ctld.refreshJTACmenu[ctld.jtacUnits[_jtacGroupName].side] = true + end +end + +--special option setters (make sure to affect the function pointer to the corresponding .setter in the special options table after declaration of said function) +function ctld.setSpecialOptionArgsCheck(_args) + if _args then + local _jtacGroupName = _args.jtacGroupName + local _value = _args.value --expected boolean + local _notOutput = _args.noOutput --expected boolean + + if _jtacGroupName then + return {jtacGroupName = _jtacGroupName, value = _value, noOutput = _notOutput} + end + end + + return nil +end + +function ctld.setStdbMode(_args) + local parsedArgs = ctld.setSpecialOptionArgsCheck(_args) + if parsedArgs then + + local _jtacGroupName = parsedArgs.jtacGroupName + local _value = parsedArgs.value + local _noOutput = parsedArgs.noOutput + + local message = ctld.i18n_translate("%1, laser and smokes enabled", _jtacGroupName) + if _value then + message = ctld.i18n_translate("%1, laser and smokes disabled", _jtacGroupName) + end + if not _noOutput then + ctld.notifyCoalition(message, 10, ctld.jtacUnits[_jtacGroupName].side, ctld.jtacRadioData[_jtacGroupName]) + end + + ctld.jtacSpecialOptions.standbyMode.jtacs[_jtacGroupName] = _value + ctld.refreshJTACmenu[ctld.jtacUnits[_jtacGroupName].side] = true + end +end +ctld.jtacSpecialOptions.standbyMode.setter = ctld.setStdbMode + +function ctld.setLaseCompensation(_args) + local parsedArgs = ctld.setSpecialOptionArgsCheck(_args) + if parsedArgs then + + local _jtacGroupName = parsedArgs.jtacGroupName + local _value = parsedArgs.value + local _noOutput = parsedArgs.noOutput + + local message = ctld.i18n_translate("%1, wind and target speed laser spot compensations enabled", _jtacGroupName) + if _value then + message = ctld.i18n_translate("%1, wind and target speed laser spot compensations disabled", _jtacGroupName) + end + if not _noOutput then + ctld.notifyCoalition(message, 10, ctld.jtacUnits[_jtacGroupName].side, ctld.jtacRadioData[_jtacGroupName]) + end + + ctld.jtacSpecialOptions.laseSpotCorrections.jtacs[_jtacGroupName] = _value + ctld.refreshJTACmenu[ctld.jtacUnits[_jtacGroupName].side] = true + end +end +ctld.jtacSpecialOptions.laseSpotCorrections.setter = ctld.setLaseCompensation + +function ctld.setSmokeOnTarget(_args) + local parsedArgs = ctld.setSpecialOptionArgsCheck(_args) + if parsedArgs then + + local _jtacGroupName = parsedArgs.jtacGroupName + local _noOutput = parsedArgs.noOutput + local _enemyUnit = Unit.getByName(ctld.jtacCurrentTargets[_jtacGroupName].name) + + if _enemyUnit then + if not _noOutput then + ctld.notifyCoalition(ctld.i18n_translate("%1, WHITE smoke deployed near target", _jtacGroupName), 10, ctld.jtacUnits[_jtacGroupName].side, ctld.jtacRadioData[_jtacGroupName]) + end + + local _enemyPoint = _enemyUnit:getPoint() + local randomCircleDiam = 30; + trigger.action.smoke({ x = _enemyPoint.x + math.random(randomCircleDiam,-randomCircleDiam), y = _enemyPoint.y + 2.0, z = _enemyPoint.z + math.random(randomCircleDiam,-randomCircleDiam)}, 2) + end + end + end +ctld.jtacSpecialOptions.smokeMarker.setter = ctld.setSmokeOnTarget + +function ctld.setJTAC9Line(_args) + local parsedArgs = ctld.setSpecialOptionArgsCheck(_args) + if parsedArgs then + + local _jtacGroupName = parsedArgs.jtacGroupName + + ctld.getJTACStatus({nil, _jtacGroupName}) + end +end +ctld.jtacSpecialOptions._9Line.setter = ctld.setJTAC9Line + +function ctld.setGrpROE(_grp, _ROE) + if _grp == nil then + ctld.logError("ctld.setGrpROE called with a nil group") + return + end + + if _ROE == nil then + _ROE = AI.Option.Ground.val.ROE.OPEN_FIRE + end + + if _grp and _grp:isExist() == true and #_grp:getUnits() > 0 then -- check if the group truly exists + local _controller = _grp:getController(); + Controller.setOption(_controller, AI.Option.Ground.id.ALARM_STATE, AI.Option.Ground.val.ALARM_STATE.AUTO) + Controller.setOption(_controller, AI.Option.Ground.id.ROE, _ROE) + _controller:setTask(_grp) + end +end + +function ctld.isInfantry(_unit) + + local _typeName = _unit:getTypeName() + + --type coerce tostring + _typeName = string.lower(_typeName .. "") + + local _soldierType = { "infantry", "paratrooper", "stinger", "manpad", "mortar" } + + for _key, _value in pairs(_soldierType) do + if string.match(_typeName, _value) then + return true + end + end + + return false +end + +-- assume anything that isnt soldier is vehicle +function ctld.isVehicle(_unit) + + if ctld.isInfantry(_unit) then + return false + end + + return true +end + +-- The entered value can range from 1111 - 1788, +-- -- but the first digit of the series must be a 1 or 2 +-- -- and the last three digits must be between 1 and 8. +-- The range used to be bugged so its not 1 - 8 but 0 - 7. +-- function below will use the range 1-7 just incase +function ctld.generateLaserCode() + + ctld.jtacGeneratedLaserCodes = {} + + -- generate list of laser codes + local _code = 1511 + + local _count = 1 + + while _code < 1777 and _count < 30 do + + while true do + + _code = _code + 1 + + if not ctld.containsDigit(_code, 8) + and not ctld.containsDigit(_code, 9) + and not ctld.containsDigit(_code, 0) then + + table.insert(ctld.jtacGeneratedLaserCodes, _code) + + --env.info(_code.." Code") + break + end + end + _count = _count + 1 + end +end + +function ctld.containsDigit(_number, _numberToFind) + + local _thisNumber = _number + local _thisDigit = 0 + + while _thisNumber ~= 0 do + + _thisDigit = _thisNumber % 10 + _thisNumber = math.floor(_thisNumber / 10) + + if _thisDigit == _numberToFind then + return true + end + end + + return false +end + +-- 200 - 400 in 10KHz +-- 400 - 850 in 10 KHz +-- 850 - 1250 in 50 KHz +function ctld.generateVHFrequencies() + + --ignore list + --list of all frequencies in KHZ that could conflict with + -- 191 - 1290 KHz, beacon range + local _skipFrequencies = { + 745, --Astrahan + 381, + 384, + 300.50, + 312.5, + 1175, + 342, + 735, + 300.50, + 353.00, + 440, + 795, + 525, + 520, + 690, + 625, + 291.5, + 300.50, + 435, + 309.50, + 920, + 1065, + 274, + 312.50, + 580, + 602, + 297.50, + 750, + 485, + 950, + 214, + 1025, 730, 995, 455, 307, 670, 329, 395, 770, + 380, 705, 300.5, 507, 740, 1030, 515, + 330, 309.5, + 348, 462, 905, 352, 1210, 942, 435, + 324, + 320, 420, 311, 389, 396, 862, 680, 297.5, + 920, 662, + 866, 907, 309.5, 822, 515, 470, 342, 1182, 309.5, 720, 528, + 337, 312.5, 830, 740, 309.5, 641, 312, 722, 682, 1050, + 1116, 935, 1000, 430, 577, + 326 -- Nevada + } + + ctld.freeVHFFrequencies = {} + local _start = 200000 + + -- first range + while _start < 400000 do + + -- skip existing NDB frequencies + local _found = false + for _, value in pairs(_skipFrequencies) do + if value * 1000 == _start then + _found = true + break + end + end + + + if _found == false then + table.insert(ctld.freeVHFFrequencies, _start) + end + + _start = _start + 10000 + end + + _start = 400000 + -- second range + while _start < 850000 do + + -- skip existing NDB frequencies + local _found = false + for _, value in pairs(_skipFrequencies) do + if value * 1000 == _start then + _found = true + break + end + end + + if _found == false then + table.insert(ctld.freeVHFFrequencies, _start) + end + + + _start = _start + 10000 + end + + _start = 850000 + -- third range + while _start <= 1250000 do + + -- skip existing NDB frequencies + local _found = false + for _, value in pairs(_skipFrequencies) do + if value * 1000 == _start then + _found = true + break + end + end + + if _found == false then + table.insert(ctld.freeVHFFrequencies, _start) + end + + _start = _start + 50000 + end +end + +-- 220 - 399 MHZ, increments of 0.5MHZ +function ctld.generateUHFrequencies() + + ctld.freeUHFFrequencies = {} + local _start = 220000000 + + while _start < 399000000 do + table.insert(ctld.freeUHFFrequencies, _start) + _start = _start + 500000 + end +end + + +-- 220 - 399 MHZ, increments of 0.5MHZ +-- -- first digit 3-7MHz +-- -- second digit 0-5KHz +-- -- third digit 0-9 +-- -- fourth digit 0 or 5 +-- -- times by 10000 +-- +function ctld.generateFMFrequencies() + + ctld.freeFMFrequencies = {} + local _start = 220000000 + + while _start < 399000000 do + + _start = _start + 500000 + end + + for _first = 3, 7 do + for _second = 0, 5 do + for _third = 0, 9 do + local _frequency = ((100 * _first) + (10 * _second) + _third) * 100000 --extra 0 because we didnt bother with 4th digit + table.insert(ctld.freeFMFrequencies, _frequency) + end + end + end +end + +function ctld.getPositionString(_unit) + if ctld.JTAC_location == false then + return "" + end + + local _lat, _lon = coord.LOtoLL(_unit:getPosition().p) + local _latLngStr = mist.tostringLL(_lat, _lon, 3, false) + local _latLngSecStr = mist.tostringLL(_lat, _lon, 3, true) + local _mgrsString = mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(_unit:getPosition().p)), 5) + local _TargetAlti = land.getHeight(mist.utils.makeVec2(_unit:getPoint())) + return " @\n- DD " .. _latLngStr .." \n- DMS: " .. _latLngSecStr .. " \n- MGRS: " .. _mgrsString .. " - ALTI: " .. mist.utils.round(_TargetAlti, 0) .. " m / " .. mist.utils.round(_TargetAlti/0.3048, 0) .. " ft" +end + +--********************************************************************** +-- RECOGNITION SUPPORT FUNCTIONS +-- Shows/remove/refresh marks in F10 map on targets in LOS of a unit passed in params +--------------------------------------------------------------------- +-- examples --------------------------------------------------------- +--ctld.reconRefreshTargetsInLosOnF10Map(Unit.getByName("uh2-1"), 2000, 200) +--ctld.reconRemoveTargetsInLosOnF10Map(Unit.getByName("uh2-1")) +--ctld.reconShowTargetsInLosOnF10Map(Unit.getByName("uh2-1"), 2000, 200) +---------------------------------------------------------------------- +--if ctld == nil then ctld = {} end +if ctld.lastMarkId == nil then + ctld.lastMarkId = 0 +end + +-- ***************** RECON CONFIGURATION ***************** +ctld.reconF10Menu = true -- enables F10 RECON menu +ctld.reconMenuName = ctld.i18n_translate("RECON") --name of the CTLD JTAC radio menu +ctld.reconRadioAdded = {} --stores the groups that have had the radio menu added +ctld.reconLosSearchRadius = 2000 -- search radius in meters +ctld.reconLosMarkRadius = 100 -- mark radius dimension in meters +ctld.reconAutoRefreshLosTargetMarks = true -- if true recon LOS marks are automaticaly refreshed on F10 map +ctld.reconLastScheduleIdAutoRefresh = 0 + +---- F10 RECON Menus ------------------------------------------------------------------ +function ctld.addReconRadioCommand(_side) -- _side = 1 or 2 (red or blue) + if ctld.reconF10Menu then + if _side == 1 or _side == 2 then + local _players = coalition.getPlayers(_side) + if _players ~= nil then + for _, _playerUnit in pairs(_players) do + local _groupId = ctld.getGroupId(_playerUnit) + if _groupId then + if ctld.reconRadioAdded[tostring(_groupId)] == nil then + ctld.logDebug("ctld.addReconRadioCommand - adding RECON radio menu for unit [%s]", ctld.p(_playerUnit:getName())) + local RECONpath = missionCommands.addSubMenuForGroup(_groupId, ctld.reconMenuName) + missionCommands.addCommandForGroup(_groupId, + ctld.i18n_translate("Show targets in LOS (refresh)"), RECONpath, + ctld.reconRefreshTargetsInLosOnF10Map, { + _groupId = _groupId, + _playerUnit = _playerUnit, + _searchRadius = ctld.reconLosSearchRadius, + _markRadius = ctld.reconLosMarkRadius, + _boolRemove = true + }) + missionCommands.addCommandForGroup(_groupId, ctld.i18n_translate("Hide targets in LOS"), RECONpath, ctld.reconRemoveTargetsInLosOnF10Map, _playerUnit) + if ctld.reconAutoRefreshLosTargetMarks then + missionCommands.addCommandForGroup(_groupId, + ctld.i18n_translate("STOP autoRefresh targets in LOS"), RECONpath, + ctld.reconStopAutorefreshTargetsInLosOnF10Map, + _groupId, + _playerUnit, + ctld.reconLosSearchRadius, + ctld.reconLosMarkRadius, + true) + else + missionCommands.addCommandForGroup(_groupId, + ctld.i18n_translate("START autoRefresh targets in LOS"), RECONpath, + ctld.reconStartAutorefreshTargetsInLosOnF10Map, + _groupId, + _playerUnit, + ctld.reconLosSearchRadius, + ctld.reconLosMarkRadius, + true + ) + end + ctld.reconRadioAdded[tostring(_groupId)] = timer.getTime() --fetch the time to check for a regular refresh + end + end + end + end + end + end +end +-------------------------------------------------------------------- +function ctld.reconStopAutorefreshTargetsInLosOnF10Map(_groupId, _playerUnit, _searchRadius, _markRadius, _boolRemove) + ctld.reconAutoRefreshLosTargetMarks = false + + if ctld.reconLastScheduleIdAutoRefresh ~= 0 then + timer.removeFunction(ctld.reconLastScheduleIdAutoRefresh) -- reset last schedule + end + + ctld.reconRemoveTargetsInLosOnF10Map(_playerUnit) + missionCommands.removeItemForGroup(_groupId, {ctld.reconMenuName, ctld.i18n_translate("STOP autoRefresh targets in LOS")}) + missionCommands.addCommandForGroup( _groupId, ctld.i18n_translate("START autoRefresh targets in LOS"), {ctld.reconMenuName}, + ctld.reconStartAutorefreshTargetsInLosOnF10Map, + _groupId, + _playerUnit, + _searchRadius, + _markRadius, + _boolRemove) +end +-------------------------------------------------------------------- +function ctld.reconStartAutorefreshTargetsInLosOnF10Map(_groupId, _playerUnit, _searchRadius, _markRadius, _boolRemove) + ctld.reconAutoRefreshLosTargetMarks = true + ctld.reconRefreshTargetsInLosOnF10Map({ _groupId = _groupId, + _playerUnit = _playerUnit, + _searchRadius = _searchRadius or ctld.reconLosSearchRadius, + _markRadius = _markRadius or ctld.reconLosMarkRadius, + _boolRemove = _boolRemove or true}, + timer.getTime()) + missionCommands.removeItemForGroup( _groupId, {ctld.reconMenuName, ctld.i18n_translate("START autoRefresh targets in LOS")}) + missionCommands.addCommandForGroup( _groupId, ctld.i18n_translate("STOP autoRefresh targets in LOS"), {ctld.reconMenuName}, + ctld.reconStopAutorefreshTargetsInLosOnF10Map, + _groupId, + _playerUnit, + _searchRadius, + _markRadius, + _boolRemove) +end +-------------------------------------------------------------------- +function ctld.reconShowTargetsInLosOnF10Map(_playerUnit, _searchRadius, _markRadius) -- _groupId targeting + -- _searchRadius and _markRadius in meters + if _playerUnit then + local TargetsInLOS = {} + + local enemyColor = "red" + local color = {1, 0, 0, 0.2} -- red + + if _playerUnit:getCoalition() == 1 then + enemyColor = "blue" + color = {51/255, 51/255, 1, 0.2} -- blue + end + + local t = mist.getUnitsLOS({_playerUnit:getName()}, 180, mist.makeUnitTable({'['..enemyColor..'][vehicle]'}),180, _searchRadius) + + local MarkIds = {} + if t then + for i=1, #t do -- for each unit having los on enemies + for j=1, #t[i].vis do -- for each enemy unit in los + local targetPoint = t[i].vis[j]:getPoint() -- point of each target on LOS + ctld.lastMarkId = ctld.lastMarkId + 1 + trigger.action.circleToAll(_playerUnit:getCoalition(), ctld.lastMarkId, targetPoint, _markRadius , color, color, 1, false, nil) + MarkIds[#MarkIds+1] = ctld.lastMarkId + TargetsInLOS[#TargetsInLOS+1] = { targetObject = t[i].vis[j]:getName(), + targetTypeName = t[i].vis[j]:getTypeName(), + targetPoint = targetPoint} + end + end + end + mist.DBs.humansByName[_playerUnit:getName()].losMarkIds = MarkIds -- store list of marksIds generated and showed on F10 map + return TargetsInLOS + else + return nil + end +end +--------------------------------------------------------- +function ctld.reconRemoveTargetsInLosOnF10Map(_playerUnit) + local unitName = _playerUnit:getName() + if mist.DBs.humansByName[unitName].losMarkIds then + for i=1, #mist.DBs.humansByName[unitName].losMarkIds do -- for each unit having los on enemies + trigger.action.removeMark(mist.DBs.humansByName[unitName].losMarkIds[i]) + end + mist.DBs.humansByName[unitName].losMarkIds = nil + end +end +--------------------------------------------------------- +function ctld.reconRefreshTargetsInLosOnF10Map(_params, _t) -- _params._playerUnit targeting + -- _params._searchRadius and _params._markRadius in meters + -- _params._boolRemove = true to remove previous marksIds + if _t == nil then _t = timer.getTime() end + + if ctld.reconAutoRefreshLosTargetMarks then -- to follow mobile enemy targets + ctld.reconLastScheduleIdAutoRefresh = timer.scheduleFunction(ctld.reconRefreshTargetsInLosOnF10Map, {_groupId = _params._groupId, + _playerUnit = _params._playerUnit, + _searchRadius = _params._searchRadius, + _markRadius = _params._markRadius, + _boolRemove = _params._boolRemove + }, + timer.getTime() + 10) + end + + if _params._boolRemove == true then + ctld.reconRemoveTargetsInLosOnF10Map(_params._playerUnit) + end + + return ctld.reconShowTargetsInLosOnF10Map(_params._playerUnit, _params._searchRadius, _params._markRadius) -- returns TargetsInLOS table +end +--- test ------------------------------------------------------ +--local unitName = "uh2-1" --"uh1-1" --"uh2-1" +--ctld.reconShowTargetsInLosOnF10Map(Unit.getByName(unitName),2000,200) + + +--********************************************************************** + +-- ***************** SETUP SCRIPT **************** +function ctld.initialize() + ctld.logInfo(string.format("Initializing version %s", ctld.Version)) + + assert(mist ~= nil, "\n\n** HEY MISSION-DESIGNER! **\n\nMiST has not been loaded!\n\nMake sure MiST 3.6 or higher is running\n*before* running this script!\n") + + ctld.addedTo = {} + ctld.spawnedCratesRED = {} -- use to store crates that have been spawned + ctld.spawnedCratesBLUE = {} -- use to store crates that have been spawned + + ctld.droppedTroopsRED = {} -- stores dropped troop groups + ctld.droppedTroopsBLUE = {} -- stores dropped troop groups + + ctld.droppedVehiclesRED = {} -- stores vehicle groups for c-130 / hercules + ctld.droppedVehiclesBLUE = {} -- stores vehicle groups for c-130 / hercules + + ctld.inTransitTroops = {} + + ctld.inTransitFOBCrates = {} + + ctld.inTransitSlingLoadCrates = {} -- stores crates that are being transported by helicopters for alternative to real slingload + + ctld.droppedFOBCratesRED = {} + ctld.droppedFOBCratesBLUE = {} + + ctld.builtFOBS = {} -- stores fully built fobs + + ctld.completeAASystems = {} -- stores complete spawned groups from multiple crates + + ctld.fobBeacons = {} -- stores FOB radio beacon details, refreshed every 60 seconds + + ctld.deployedRadioBeacons = {} -- stores details of deployed radio beacons + + ctld.beaconCount = 1 + + ctld.usedUHFFrequencies = {} + ctld.usedVHFFrequencies = {} + ctld.usedFMFrequencies = {} + + ctld.freeUHFFrequencies = {} + ctld.freeVHFFrequencies = {} + ctld.freeFMFrequencies = {} + + --used to lookup what the crate will contain + ctld.crateLookupTable = {} + + ctld.extractZones = {} -- stored extract zones + + ctld.missionEditorCargoCrates = {} --crates added by mission editor for triggering cratesinzone + ctld.hoverStatus = {} -- tracks status of a helis hover above a crate + + ctld.callbacks = {} -- function callback + + + -- Remove intransit troops when heli / cargo plane dies + --ctld.eventHandler = {} + --function ctld.eventHandler:onEvent(_event) + -- + -- if _event == nil or _event.initiator == nil then + -- env.info("CTLD null event") + -- elseif _event.id == 9 then + -- -- Pilot dead + -- ctld.inTransitTroops[_event.initiator:getName()] = nil + -- + -- elseif world.event.S_EVENT_EJECTION == _event.id or _event.id == 8 then + -- -- env.info("Event unit - Pilot Ejected or Unit Dead") + -- ctld.inTransitTroops[_event.initiator:getName()] = nil + -- + -- -- env.info(_event.initiator:getName()) + -- end + -- + --end + + -- create crate lookup table + for _subMenuName, _crates in pairs(ctld.spawnableCrates) do + + for _, _crate in pairs(_crates) do + -- convert number to string otherwise we'll have a pointless giant + -- table. String means 'hashmap' so it will only contain the right number of elements + if _crate.multiple then + local _totalWeight = 0 + for _, _weight in pairs(_crate.multiple) do + _totalWeight = _totalWeight + _weight + end + _crate.weight = _totalWeight + end + ctld.crateLookupTable[tostring(_crate.weight)] = _crate + end + end + + + --sort out pickup zones + for _, _zone in pairs(ctld.pickupZones) do + + local _zoneName = _zone[1] + local _zoneColor = _zone[2] + local _zoneActive = _zone[4] + + if _zoneColor == "green" then + _zone[2] = trigger.smokeColor.Green + elseif _zoneColor == "red" then + _zone[2] = trigger.smokeColor.Red + elseif _zoneColor == "white" then + _zone[2] = trigger.smokeColor.White + elseif _zoneColor == "orange" then + _zone[2] = trigger.smokeColor.Orange + elseif _zoneColor == "blue" then + _zone[2] = trigger.smokeColor.Blue + else + _zone[2] = -1 -- no smoke colour + end + + -- add in counter for troops or units + if _zone[3] == -1 then + _zone[3] = 10000; + end + + -- change active to 1 / 0 + if _zoneActive == "yes" then + _zone[4] = 1 + else + _zone[4] = 0 + end + end + + --sort out dropoff zones + for _, _zone in pairs(ctld.dropOffZones) do + + local _zoneColor = _zone[2] + + if _zoneColor == "green" then + _zone[2] = trigger.smokeColor.Green + elseif _zoneColor == "red" then + _zone[2] = trigger.smokeColor.Red + elseif _zoneColor == "white" then + _zone[2] = trigger.smokeColor.White + elseif _zoneColor == "orange" then + _zone[2] = trigger.smokeColor.Orange + elseif _zoneColor == "blue" then + _zone[2] = trigger.smokeColor.Blue + else + _zone[2] = -1 -- no smoke colour + end + + --mark as active for refresh smoke logic to work + _zone[4] = 1 + end + + --sort out waypoint zones + for _, _zone in pairs(ctld.wpZones) do + + local _zoneColor = _zone[2] + + if _zoneColor == "green" then + _zone[2] = trigger.smokeColor.Green + elseif _zoneColor == "red" then + _zone[2] = trigger.smokeColor.Red + elseif _zoneColor == "white" then + _zone[2] = trigger.smokeColor.White + elseif _zoneColor == "orange" then + _zone[2] = trigger.smokeColor.Orange + elseif _zoneColor == "blue" then + _zone[2] = trigger.smokeColor.Blue + else + _zone[2] = -1 -- no smoke colour + end + + --mark as active for refresh smoke logic to work + -- change active to 1 / 0 + if _zone[3] == "yes" then + _zone[3] = 1 + else + _zone[3] = 0 + end + end + + -- Sort out extractable groups + for _, _groupName in pairs(ctld.extractableGroups) do + + local _group = Group.getByName(_groupName) + + if _group ~= nil then + + if _group:getCoalition() == 1 then + table.insert(ctld.droppedTroopsRED, _group:getName()) + else + table.insert(ctld.droppedTroopsBLUE, _group:getName()) + end + end + end + + + -- Seperate troop teams into red and blue for random AI pickups + if ctld.allowRandomAiTeamPickups == true then + ctld.redTeams = {} + ctld.blueTeams = {} + for _,_loadGroup in pairs(ctld.loadableGroups) do + if not _loadGroup.side then + table.insert(ctld.redTeams, _) + table.insert(ctld.blueTeams, _) + elseif _loadGroup.side == 1 then + table.insert(ctld.redTeams, _) + elseif _loadGroup.side == 2 then + table.insert(ctld.blueTeams, _) + end + end + end + + -- add total count + + for _,_loadGroup in pairs(ctld.loadableGroups) do + + _loadGroup.total = 0 + if _loadGroup.aa then + _loadGroup.total = _loadGroup.aa + _loadGroup.total + end + + if _loadGroup.inf then + _loadGroup.total = _loadGroup.inf + _loadGroup.total + end + + + if _loadGroup.mg then + _loadGroup.total = _loadGroup.mg + _loadGroup.total + end + + if _loadGroup.at then + _loadGroup.total = _loadGroup.at + _loadGroup.total + end + + if _loadGroup.mortar then + _loadGroup.total = _loadGroup.mortar + _loadGroup.total + end + + end + + + -- Scheduled functions (run cyclically) -- but hold execution for a second so we can override parts + + timer.scheduleFunction(ctld.checkAIStatus, nil, timer.getTime() + 1) + timer.scheduleFunction(ctld.checkTransportStatus, nil, timer.getTime() + 5) + + timer.scheduleFunction(function() + + timer.scheduleFunction(ctld.refreshRadioBeacons, nil, timer.getTime() + 5) + timer.scheduleFunction(ctld.refreshSmoke, nil, timer.getTime() + 5) + timer.scheduleFunction(ctld.addOtherF10MenuOptions, nil, timer.getTime() + 5) + + if ctld.enableCrates == true and ctld.hoverPickup == true then + timer.scheduleFunction(ctld.checkHoverStatus, nil, timer.getTime() + 1) + end + + end,nil, timer.getTime()+1 ) + + --event handler for deaths + --world.addEventHandler(ctld.eventHandler) + + --env.info("CTLD event handler added") + + env.info("Generating Laser Codes") + ctld.generateLaserCode() + env.info("Generated Laser Codes") + + + + env.info("Generating UHF Frequencies") + ctld.generateUHFrequencies() + env.info("Generated UHF Frequencies") + + env.info("Generating VHF Frequencies") + ctld.generateVHFrequencies() + env.info("Generated VHF Frequencies") + + + env.info("Generating FM Frequencies") + ctld.generateFMFrequencies() + env.info("Generated FM Frequencies") + + -- Search for crates + -- Crates are NOT returned by coalition.getStaticObjects() for some reason + -- Search for crates in the mission editor instead + env.info("Searching for Crates") + for _coalitionName, _coalitionData in pairs(env.mission.coalition) do + + if (_coalitionName == 'red' or _coalitionName == 'blue') + and type(_coalitionData) == 'table' then + if _coalitionData.country then --there is a country table + for _, _countryData in pairs(_coalitionData.country) do + + if type(_countryData) == 'table' then + for _objectTypeName, _objectTypeData in pairs(_countryData) do + if _objectTypeName == "static" then + + if ((type(_objectTypeData) == 'table') + and _objectTypeData.group + and (type(_objectTypeData.group) == 'table') + and (#_objectTypeData.group > 0)) then + + for _groupId, _group in pairs(_objectTypeData.group) do + if _group and _group.units and type(_group.units) == 'table' then + for _unitNum, _unit in pairs(_group.units) do + if _unit.canCargo == true then + local _cargoName = env.getValueDictByKey(_unit.name) + ctld.missionEditorCargoCrates[_cargoName] = _cargoName + env.info("Crate Found: " .. _unit.name.." - Unit: ".._cargoName) + end + end + end + end + end + end + end + end + end + end + end + end + env.info("END search for crates") + + -- register event handler + ctld.logInfo("registering event handler") + world.addEventHandler(ctld.eventHandler) + + env.info("CTLD READY") +end + +--- Handle world events. +ctld.eventHandler = {} +function ctld.eventHandler:onEvent(event) + ctld.logTrace("ctld.eventHandler:onEvent()") + if event == nil then + ctld.logError("Event handler was called with a nil event!") + return + end + + local eventName = "unknown" + -- check that we know the event + if event.id == world.event.S_EVENT_PLAYER_ENTER_UNIT then + eventName = "S_EVENT_PLAYER_ENTER_UNIT" + elseif event.id == world.event.S_EVENT_BIRTH then + eventName = "S_EVENT_BIRTH" + else + ctld.logTrace("Ignoring event %s", ctld.p(event)) + return + end + ctld.logDebug("caught event %s: %s", ctld.p(eventName), ctld.p(event)) + + -- find the originator unit + local unitName = nil + if event.initiator ~= nil and event.initiator.getName then + unitName = event.initiator:getName() + ctld.logDebug("unitName = [%s]", ctld.p(unitName)) + end + if not unitName then + ctld.logInfo("no unitname found in event %s", ctld.p(event)) + return + end + + local function processHumanPlayer() + ctld.logTrace("in the 'processHumanPlayer' function") + if mist.DBs.humansByName[unitName] then -- it's a human unit + ctld.logDebug("caught event %s for human unit [%s]", ctld.p(eventName), ctld.p(unitName)) + local _unit = Unit.getByName(unitName) + if _unit ~= nil then + -- assign transport pilot + ctld.logTrace("_unit = %s", ctld.p(_unit)) + + local playerTypeName = _unit:getTypeName() + ctld.logTrace("playerTypeName = %s", ctld.p(playerTypeName)) + + -- Allow units to CTLD by aircraft type and not by pilot name + if ctld.addPlayerAircraftByType then + for _,aircraftType in pairs(ctld.aircraftTypeTable) do + if aircraftType == playerTypeName then + ctld.logTrace("adding by aircraft type, unitName = %s", ctld.p(unitName)) + -- add transport unit to the list + table.insert(ctld.transportPilotNames, unitName) + -- add transport radio menu + ctld.addTransportF10MenuOptions(unitName) + break + end + end + else + for _, _unitName in pairs(ctld.transportPilotNames) do + if _unitName == unitName then + ctld.logTrace("adding by transportPilotNames, unitName = %s", ctld.p(unitName)) + -- add transport radio menu + ctld.addTransportF10MenuOptions(unitName) + break + end + end + end + end + end + end + + if not mist.DBs.humansByName[unitName] then + -- give a few milliseconds for MiST to handle the BIRTH event too + ctld.logTrace("give MiST some time to handle the BIRTH event too") + timer.scheduleFunction(function() + ctld.logTrace("calling the 'processHumanPlayer' function in a timer") + processHumanPlayer() + end, nil, timer.getTime()+0.5) + else + ctld.logTrace("calling the 'processHumanPlayer' function immediately") + processHumanPlayer() + end + +end + +function ctld.i18n_check(language, verbose) + local english = ctld.i18n["en"] + local tocheck = ctld.i18n[language] + if not tocheck then + ctld.logError(string.format("CTLD.i18n_check: Language %s not found", language)) + return false + end + local englishVersion = english.translation_version + local tocheckVersion = tocheck.translation_version + if englishVersion ~= tocheckVersion then + ctld.logError(string.format("CTLD.i18n_check: Language version mismatch: EN has version %s, %s has version %s", englishVersion, language, tocheckVersion)) + end + --ctld.logTrace(string.format("english = %s", ctld.p(english))) + for textRef, textEnglish in pairs(english) do + if textRef ~= "translation_version" then + local textTocheck = tocheck[textRef] + if not textTocheck then + ctld.logError(string.format( "CTLD.i18n_check: NOT FOUND: checking %s text [%s]", language, textRef)) + elseif textTocheck == textEnglish then + ctld.logWarning(string.format("CTLD.i18n_check: SAME: checking %s text [%s] as in EN", language, textRef)) + elseif verbose then + ctld.logInfo(string.format( "CTLD.i18n_check: OK: checking %s text [%s]", language, textRef)) + end + end + end +end + +-- example of usage: +--ctld.i18n_check("fr") + + +-- initialize the random number generator to make it almost random +math.random(); math.random(); math.random() + +--- Enable/Disable error boxes displayed on screen. +env.setErrorMessageBoxEnabled(false) + +-- initialize CTLD +-- if you need to have a chance to modify the configuration before initialization in your other scripts, please set ctld.dontInitialize to true and call ctld.initialize() manually +if ctld.dontInitialize then + ctld.logInfo(string.format("Skipping initializion of version %s because ctld.dontInitialize is true", ctld.Version)) +else + ctld.initialize() +end +trigger.action.outText("\n\n** CTLD LOADED\n", 10) + + + +-- === Welcome-on-spawn for CTLD helicopters (no double message) === +local _ctldTypeSet = {} +do + for k, v in pairs(ctld.aircraftTypeTable or {}) do + if type(k) == "string" then _ctldTypeSet[k] = true end + if type(v) == "string" then _ctldTypeSet[v] = true + elseif type(v) == "table" and v.type then _ctldTypeSet[v.type] = true end + end +end + +local function _isCtldChopper(u) + if not u or not Unit.isExist(u) then return false end + local ut = u:getTypeName() + return ut and _ctldTypeSet[ut] == true +end + +local function _welcomeUnit(u) + if not u or not Unit.isExist(u) then return end + timer.scheduleFunction(function() + if not Unit.isExist(u) then return end + if u:getPlayerName() and _isCtldChopper(u) then + trigger.action.outTextForUnit(u:getID(), "When building a SAM site spread the crates out so they don't spawn together. Keep launcher crates away from the other SAM parts, taxiways and runways. Move your chopper away from the launcher crate so the spawning launchers don't damage your chopper.", 40) + end + end, {}, timer.getTime() + 0.1) +end + +local WelcomeHandler = {} +function WelcomeHandler:onEvent(e) + if e.id == world.event.S_EVENT_PLAYER_ENTER_UNIT and e.initiator then + _welcomeUnit(e.initiator) + end +end +world.addEventHandler(WelcomeHandler) +-- === End === diff --git a/Moose_CTLD.lua b/Moose_CTLD.lua index 1067e8a..9c8ec05 100644 --- a/Moose_CTLD.lua +++ b/Moose_CTLD.lua @@ -182,13 +182,13 @@ CTLD.Messages = { -- FARP System messages farp_upgrade_started = "Upgrading FOB to FARP Stage {stage}... Building in progress.", - farp_upgrade_complete = "{player} upgraded FOB to FARP Stage {stage}! Services available: {services}", + farp_upgrade_complete = "{player} upgraded FOB to FARP Stage {stage}!\nFARP Services: {services}\nFOB still active for logistics and troop operations.", farp_upgrade_insufficient_salvage = "Insufficient salvage to upgrade to FARP Stage {stage}. Need {need} points (have {current}). Deliver crews to MASH or sling-load salvage!", - farp_status = "FOB Status: FARP Stage {stage}/{max_stage}\nServices: {services}\nNext upgrade: {next_cost} salvage (Stage {next_stage})", - farp_status_maxed = "FOB Status: FARP Stage {stage}/{max_stage} (FULLY UPGRADED)\nServices: {services}", + farp_status = "FOB + FARP Status: Stage {stage}/{max_stage}\nFARP Services: {services}\nFOB logistics: ACTIVE\nNext upgrade: {next_cost} salvage (Stage {next_stage})", + farp_status_maxed = "FOB + FARP Status: Stage {stage}/{max_stage} (FULLY UPGRADED)\nFARP Services: {services}\nFOB logistics: ACTIVE", farp_not_at_fob = "You must be near a FOB Pickup Zone to upgrade it to a FARP.", farp_already_maxed = "This FOB is already at maximum FARP stage (Stage 3).", - farp_service_available = "FARP services available: Rearm, Refuel, Repair for ground vehicles and helicopters within {radius}m.", + farp_service_available = "FARP services available: Rearm, Refuel, Repair for ground vehicles and helicopters within {radius}m. FOB logistics remain active.", slingload_salvage_warn_5min = "SALVAGE URGENT: Crate {id} at {grid} expires in 5 minutes!", slingload_salvage_hooked_in_zone = "Salvage crate {id} is inside {zone}. Release the sling to complete delivery.", slingload_salvage_wrong_zone = "Salvage crate {id} is sitting in {zone_type} zone {zone}. Take it to an active Salvage zone for credit.", @@ -621,16 +621,16 @@ CTLD.Config = { -- Spawn probability when enemy ground units die SpawnChance = { - [coalition.side.BLUE] = 0.20, -- 20% chance when BLUE unit dies (RED can collect the salvage) - [coalition.side.RED] = 0.20, -- 20% chance when RED unit dies (BLUE can collect the salvage) + [coalition.side.BLUE] = 0.10, -- 20% chance when BLUE unit dies (RED can collect the salvage) + [coalition.side.RED] = 0.10, -- 20% chance when RED unit dies (BLUE can collect the salvage) }, -- Weight classes with spawn probabilities and reward rates WeightClasses = { - { name = 'Light', min = 500, max = 1000, probability = 0.50, rewardPer500kg = 2 }, -- Huey-capable - { name = 'Medium', min = 2501, max = 5000, probability = 0.30, rewardPer500kg = 3 }, -- Hip/Mi-8 - { name = 'Heavy', min = 5001, max = 8000, probability = 0.15, rewardPer500kg = 5 }, -- Large helos - { name = 'SuperHeavy', min = 8001, max = 12000, probability = 0.05, rewardPer500kg = 8 }, -- Chinook only + { name = 'Light', min = 500, max = 1000, probability = 0.50, rewardPer500kg = 0.5 }, -- 1-2 pts (reduced from 2) + { name = 'Medium', min = 2501, max = 5000, probability = 0.30, rewardPer500kg = 1 }, -- 5-10 pts (reduced from 3) + { name = 'Heavy', min = 5001, max = 8000, probability = 0.15, rewardPer500kg = 1.5 }, -- 15-24 pts (reduced from 5) + { name = 'SuperHeavy', min = 8001, max = 12000, probability = 0.05, rewardPer500kg = 2 }, -- 32-48 pts (reduced from 8) }, -- Condition-based reward multipliers (based on crate health when delivered) @@ -713,134 +713,76 @@ CTLD.FARPConfig = { -- Format: { type = "DCS_Static_Name", x = offset_x, z = offset_z, heading = degrees, height = 0 } -- Positions are relative to FOB center point StageLayouts = { - -- Stage 1: Basic FARP Pad (3 salvage) + -- Stage 1: Basic FARP Pad (3 salvage) - Inner ring 30-60m [1] = { - { type = "FARP CP Blindage", x = 0, z = 25, heading = 180 }, - { type = "FARP Tent", x = 17.3, z = 10, heading = 240 }, - { type = "FARP Tent", x = -17.3, z = 10, heading = 120 }, - { type = "container_20ft", x = 15.4, z = -6.2, heading = 90 }, - { type = "container_20ft", x = -15.4, z = -6.2, heading = 90 }, - { type = "Windsock", x = 0, z = 30, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 13, z = -10.6, heading = 30 }, - { type = "FARP Ammo Dump Coating", x = -13, z = -10.6, heading = 330 }, - { type = ".Ammunition depot", x = 17, z = 12, heading = 0 }, - { type = ".Ammunition depot", x = -17, z = 12, heading = 0 }, - { type = "BarrelCargo", x = 8, z = -18, heading = 0 }, - { type = "BarrelCargo", x = -8, z = -18, heading = 0 }, - { type = "BarrelCargo", x = 12, z = 20, heading = 0 }, - { type = "BarrelCargo", x = -12, z = 20, heading = 0 }, - { type = "GeneratorF", x = 22, z = -3, heading = 270 }, - { type = "Sandbox", x = 10, z = -16, heading = 0 }, - { type = "Sandbox", x = -10, z = -16, heading = 0 }, - { type = "Sandbox", x = 10, z = 16, heading = 0 }, - { type = "Sandbox", x = -10, z = 16, heading = 0 }, - { type = "Sandbox", x = 18, z = 0, heading = 0 }, - { type = "Sandbox", x = -18, z = 0, heading = 0 }, + { type = "FARP CP Blindage", x = 0, z = 40, heading = 0 }, + { type = "FARP Tent", x = 45, z = 20, heading = 30 }, + { type = "FARP Tent", x = -45, z = 20, heading = 330 }, + { type = "container_20ft", x = 35, z = -30, heading = 338 }, + { type = "container_20ft", x = -35, z = -30, heading = 202 }, + { type = "Windsock", x = 0, z = 60, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = 30, z = -25, heading = 219 }, + { type = "FARP Ammo Dump Coating", x = -30, z = -25, heading = 141 }, + { type = "FARP Fuel Depot", x = 40, z = 30, heading = 30 }, + { type = "FARP Fuel Depot", x = -40, z = 30, heading = 330 }, + { type = "GeneratorF", x = 50, z = -10, heading = 278 }, + { type = "container_20ft", x = -50, z = -10, heading = 98 }, }, - -- Stage 2: Operational FARP - adds fuel capability (5 more salvage) + -- Stage 2: Operational FARP - adds fuel capability (5 more salvage) - Middle ring 80-130m [2] = { + { type = "M978 HEMTT Tanker", x = 90, z = 0, heading = 270 }, + { type = "M978 HEMTT Tanker", x = -90, z = 0, heading = 90 }, + { type = "FARP Fuel Depot", x = 95, z = -40, heading = 281 }, + { type = "FARP Fuel Depot", x = -95, z = -40, heading = 79 }, + { type = "FARP Tent", x = 80, z = 60, heading = 39 }, + { type = "FARP Tent", x = -80, z = 60, heading = 321 }, + { type = "container_40ft", x = 0, z = -100, heading = 180 }, + { type = "container_40ft", x = 50, z = -100, heading = 180 }, + { type = "FARP Ammo Dump Coating", x = 85, z = 70, heading = 30 }, + { type = "FARP Ammo Dump Coating", x = -85, z = 70, heading = 330 }, + { type = "Ural-375 PBU", x = 105, z = -30, heading = 247 }, + { type = "Electric power box", x = 90, z = 50, heading = 36 }, + { type = "Electric power box", x = -90, z = 50, heading = 324 }, + { type = "GeneratorF", x = 0, z = -85, heading = 180 }, + }, + + -- Stage 3: Full Forward Airbase - adds ammo and comms (8 more salvage) - Outer ring 150-250m + [3] = { + { type = "FARP", x = 0, z = 0, heading = 0 }, + -- Service objects near pad for scripted services { type = "M978 HEMTT Tanker", x = 35, z = 0, heading = 270 }, { type = "M978 HEMTT Tanker", x = -35, z = 0, heading = 90 }, - { type = "FARP Fuel Depot", x = 40, z = -8, heading = 0 }, - { type = "FARP Fuel Depot", x = -40, z = -8, heading = 0 }, - { type = "FARP Tent", x = 26.5, z = 21.7, heading = 210 }, - { type = "FARP Tent", x = -26.5, z = 21.7, heading = 150 }, - { type = "container_40ft", x = 0, z = -35, heading = 0 }, - { type = "container_40ft", x = 8, z = -35, heading = 0 }, - { type = "Hesco_wallperimeter_7", x = 0, z = 42, heading = 0 }, - { type = "Hesco_wallperimeter_7", x = 30, z = 30, heading = 315 }, - { type = "Hesco_wallperimeter_7", x = -30, z = 30, heading = 45 }, - { type = "Red_Flag", x = 0, z = 45, heading = 0 }, - { type = "Red_Flag", x = 45, z = 0, heading = 0 }, - { type = "Red_Flag", x = -45, z = 0, heading = 0 }, - { type = "Red_Flag", x = 0, z = -45, heading = 0 }, - { type = "Ural-375 PBU", x = 32.9, z = -13.1, heading = 225 }, - { type = "Electric power box", x = 28, z = 20, heading = 0 }, - { type = "Electric power box", x = -28, z = 20, heading = 0 }, - { type = "Landmine pot", x = 36, z = 8, heading = 0 }, - { type = "Landmine pot", x = -36, z = 8, heading = 0 }, - { type = "Landmine pot", x = 30, z = -25, heading = 0 }, - { type = "Landmine pot", x = -30, z = -25, heading = 0 }, - { type = "Landmine pot", x = 20, z = 30, heading = 0 }, - { type = "Landmine pot", x = -20, z = 30, heading = 0 }, - { type = "Tetrapod", x = 42, z = 15, heading = 0 }, - { type = "Tetrapod", x = -42, z = 15, heading = 0 }, - { type = "Tetrapod", x = 42, z = -15, heading = 0 }, - { type = "Tetrapod", x = -42, z = -15, heading = 0 }, - { type = "Tetrapod", x = 15, z = 42, heading = 0 }, - { type = "Tetrapod", x = -15, z = 42, heading = 0 }, - { type = "Tetrapod", x = 15, z = -42, heading = 0 }, - { type = "Tetrapod", x = -15, z = -42, heading = 0 }, - { type = "FARP Command Post", x = 0, z = 25, heading = 180 }, - }, - - -- Stage 3: Full Forward Airbase - adds ammo and comms (8 more salvage) - [3] = { - { type = "M939 Heavy", x = 38.9, z = -21.9, heading = 225 }, - { type = "M939 Heavy", x = -38.9, z = -21.9, heading = 135 }, - { type = "Shelter", x = 0, z = -50, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 48, z = -10, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = -48, z = -10, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 45, z = -20, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = -45, z = -20, heading = 0 }, - { type = "SKP-11", x = 0, z = 55, heading = 180 }, - { type = "ZiL-131 APA-80", x = 8, z = 52, heading = 180 }, - { type = "Hesco_wallperimeter_1", x = 52, z = 30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = -52, z = 30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = 52, z = -30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = -52, z = -30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = 30, z = 52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = -30, z = 52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = 30, z = -52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = -30, z = -52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = 45, z = 40, heading = 45 }, - { type = "Hesco_wallperimeter_1", x = -45, z = 40, heading = 315 }, - { type = "Hesco_wallperimeter_1", x = 45, z = -40, heading = 135 }, - { type = "Hesco_wallperimeter_1", x = -45, z = -40, heading = 225 }, - { type = "FARP Tent", x = 52, z = 0, heading = 270 }, - { type = "FARP Tent", x = -52, z = 0, heading = 90 }, - { type = "FARP Tent", x = 36.8, z = 36.8, heading = 225 }, - { type = "container_20ft", x = -30, z = -38, heading = 45 }, - { type = "container_20ft", x = -35, z = -33, heading = 45 }, - { type = "container_20ft", x = -25, z = -43, heading = 45 }, - { type = "container_20ft", x = -33, z = -45, heading = 135 }, - { type = "GeneratorF", x = 43.1, z = -31.4, heading = 225 }, - { type = "UAZ-469", x = 12, z = 48, heading = 200 }, - { type = "UAZ-469", x = 16, z = 50, heading = 170 }, - { type = "Ural-375", x = -25, z = -35, heading = 45 }, - { type = "Landmine pot", x = 50, z = 15, heading = 0 }, - { type = "Landmine pot", x = -50, z = 15, heading = 0 }, - { type = "Landmine pot", x = 50, z = -15, heading = 0 }, - { type = "Landmine pot", x = -50, z = -15, heading = 0 }, - { type = "Landmine pot", x = 15, z = 50, heading = 0 }, - { type = "Landmine pot", x = -15, z = 50, heading = 0 }, - { type = "Landmine pot", x = 40, z = 30, heading = 0 }, - { type = "Landmine pot", x = -40, z = 30, heading = 0 }, - { type = "Landmine pot", x = 30, z = -40, heading = 0 }, - { type = "Landmine pot", x = -30, z = -40, heading = 0 }, - { type = "Landmine pot", x = 45, z = 25, heading = 0 }, - { type = "Landmine pot", x = -45, z = 25, heading = 0 }, - { type = "billboard_motorized rifle troops", x = 0, z = -58, heading = 0 }, - { type = "Sandbox", x = 38, z = -8, heading = 0 }, - { type = "Sandbox", x = -38, z = -8, heading = 0 }, - { type = "Sandbox", x = 38, z = 8, heading = 0 }, - { type = "Sandbox", x = -38, z = 8, heading = 0 }, - { type = "Black_Tyre", x = 20, z = -48, heading = 0 }, - { type = "Black_Tyre", x = -20, z = -48, heading = 0 }, - { type = "Black_Tyre", x = 24, z = -46, heading = 0 }, - { type = "Black_Tyre", x = -24, z = -46, heading = 0 }, - { type = "Black_Tyre", x = 28, z = -44, heading = 0 }, - { type = "Black_Tyre", x = -28, z = -44, heading = 0 }, - { type = "Black_Tyre", x = 48, z = 20, heading = 0 }, - { type = "Black_Tyre", x = -48, z = 20, heading = 0 }, - { type = "WatchTower", x = -38.9, z = 38.9, heading = 225 }, - { type = "warning_board_c", x = 0, z = 48, heading = 180 }, - { type = "warning_board_c", x = 48, z = 0, heading = 270 }, - { type = "warning_board_c", x = -48, z = 0, heading = 90 }, - { type = "warning_board_c", x = 35, z = -35, heading = 45 }, - { type = "warning_board_c", x = -35, z = -35, heading = 315 }, - { type = "warning_board_c", x = 35, z = 35, heading = 225 }, + { type = "FARP Fuel Depot", x = 30, z = 25, heading = 315 }, + { type = "FARP Fuel Depot", x = -30, z = 25, heading = 45 }, + { type = "FARP Ammo Dump Coating", x = 40, z = -20, heading = 282 }, + { type = "FARP Ammo Dump Coating", x = -40, z = -20, heading = 78 }, + -- Extended support structures at outer ring + { type = "FARP CP Blindage", x = 0, z = 150, heading = 0 }, + { type = "Shelter", x = 0, z = -160, heading = 180 }, + { type = "SKP-11", x = 0, z = 180, heading = 0 }, + { type = "ZiL-131 APA-80", x = 50, z = 165, heading = 8 }, + { type = "FARP Tent", x = 170, z = 0, heading = 270 }, + { type = "FARP Tent", x = -170, z = 0, heading = 90 }, + { type = "FARP Tent", x = 120, z = 120, heading = 315 }, + { type = "FARP Tent", x = -120, z = 120, heading = 45 }, + { type = "FARP Ammo Dump Coating", x = 160, z = 80, heading = 30 }, + { type = "FARP Ammo Dump Coating", x = -160, z = 80, heading = 330 }, + { type = "container_20ft", x = -140, z = -130, heading = 128 }, + { type = "container_20ft", x = -150, z = -120, heading = 133 }, + { type = "container_20ft", x = 140, z = -130, heading = 52 }, + { type = "container_20ft", x = 150, z = -120, heading = 47 }, + { type = "GeneratorF", x = 155, z = -140, heading = 234 }, + { type = "GeneratorF", x = -155, z = -140, heading = 126 }, + { type = "UAZ-469", x = 70, z = 160, heading = 14 }, + { type = "UAZ-469", x = -70, z = 160, heading = 346 }, + { type = "Ural-375", x = -125, z = -135, heading = 125 }, + { type = "Sandbox", x = 350, z = 0, heading = 270 }, + { type = "Sandbox", x = -350, z = 0, heading = 90 }, + { type = "Sandbox", x = 247, z = 247, heading = 315 }, + { type = "Sandbox", x = -247, z = 247, heading = 45 }, + { type = "Sandbox", x = 247, z = -247, heading = 225 }, + { type = "Sandbox", x = -247, z = -247, heading = 135 }, }, }, } @@ -859,8 +801,8 @@ CTLD.HoverCoachConfig = { arrivalDist = 1000, -- m: start guidance "You're close…" closeDist = 100, -- m: reduce speed / set AGL guidance precisionDist = 8, -- m: start precision hints - captureHoriz = 10, -- m: horizontal sweet spot radius - captureVert = 10, -- m: vertical sweet spot tolerance around AGL window + captureHoriz = 15, -- m: horizontal sweet spot radius + captureVert = 15, -- m: vertical sweet spot tolerance around AGL window aglMin = 5, -- m: hover window min AGL aglMax = 20, -- m: hover window max AGL maxGS = 8/3.6, -- m/s: 8 km/h for precision, used for errors @@ -4184,6 +4126,36 @@ function CTLD:ClearMapDrawings() self._MapMarkup = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } end +function CTLD:_updateMobileMASHDrawing(mashId) + local data = CTLD._mashZones and CTLD._mashZones[mashId] + if not data or data.side ~= self.Side or not data.isMobile then return end + if not (self.Config.MapDraw and self.Config.MapDraw.Enabled and self.Config.MapDraw.DrawMASHZones) then return end + + local zoneName = data.displayName or mashId + if self._ZoneActive.MASH[zoneName] == false then return end + + -- Remove old drawing + self:_removeZoneDrawing('MASH', zoneName) + + -- Redraw at new position + local md = self.Config.MapDraw + local opts = { + OutlineColor = md.OutlineColor, + LineType = (md.LineTypes and md.LineTypes.MASH) or md.LineType or 1, + FillColor = (md.FillColors and md.FillColors.MASH) or nil, + FontSize = md.FontSize, + ReadOnly = (md.ReadOnly ~= false), + LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.MASH) or 'MASH', + LabelOffsetX = md.LabelOffsetX, + LabelOffsetFromEdge = md.LabelOffsetFromEdge, + LabelOffsetRatio = md.LabelOffsetRatio, + ForAll = (md.ForAll == true), + } + if data.zone then + self:_drawZoneCircleAndLabel('MASH', data.zone, opts) + end +end + function CTLD:_removeZoneDrawing(kind, zname) if not (self._MapMarkup and self._MapMarkup[kind] and self._MapMarkup[kind][zname]) then return end local ids = self._MapMarkup[kind][zname] @@ -4310,13 +4282,14 @@ function CTLD:DrawZonesOnMap() local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(pos.x, pos.z) or { x = pos.x, y = pos.z } zoneObj = ZONE_RADIUS:New(zoneName, v2, data.radius or 500) else - local posCopy = { x = pos.x, z = pos.z } + -- Create zone that references data.position directly for live updates zoneObj = {} function zoneObj:GetName() return zoneName end function zoneObj:GetPointVec3() - return { x = posCopy.x, y = 0, z = posCopy.z } + local currentPos = data.position or { x = 0, z = 0 } + return { x = currentPos.x, y = 0, z = currentPos.z } end function zoneObj:GetRadius() return data.radius or 500 @@ -7179,9 +7152,10 @@ function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) counts[reqKey] = (counts[reqKey] or 0) - (qty or 0) end _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) + _logInfo(string.format('[BUILD_DEBUG] Built key=%s desc=%s isFOB=%s isMobileMASH=%s', tostring(recipeKey), tostring(def.description), tostring(def.isFOB), tostring(def.isMobileMASH))) if def.isFOB then pcall(function() self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, def, hdg) end) end if def.isMobileMASH then - _logDebug(string.format('[MobileMASH] BuildSpecificAtGroup invoking _CreateMobileMASH for key %s at (%.1f, %.1f)', tostring(recipeKey), spawnAt.x or -1, spawnAt.z or -1)) + _logInfo(string.format('[MobileMASH] BuildSpecificAtGroup invoking _CreateMobileMASH for key %s at (%.1f, %.1f)', tostring(recipeKey), spawnAt.x or -1, spawnAt.z or -1)) local ok, err = pcall(function() self:_CreateMobileMASH(g, { x = spawnAt.x, z = spawnAt.z }, def) end) if not ok then _logError(string.format('[MobileMASH] _CreateMobileMASH invocation failed: %s', tostring(err))) @@ -8785,6 +8759,14 @@ function CTLD:BuildAtGroup(group, opts) self:_CreateFOBPickupZone({ x = actualSpawn.x, z = actualSpawn.z }, cat, hdg) end) end + -- If this was a Mobile MASH, create the tracking zone + if cat.isMobileMASH then + _logInfo(string.format('[MobileMASH] BuildAtGroup invoking _CreateMobileMASH for key %s at (%.1f, %.1f)', tostring(recipeKey), actualSpawn.x or -1, actualSpawn.z or -1)) + local ok, err = pcall(function() self:_CreateMobileMASH(g, { x = actualSpawn.x, z = actualSpawn.z }, cat) end) + if not ok then + _logError(string.format('[MobileMASH] _CreateMobileMASH invocation failed: %s', tostring(err))) + end + end -- Assign optional behavior for built vehicles/groups local behavior = opts and opts.behavior or nil if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then @@ -9690,7 +9672,27 @@ function CTLD:ScanGroundAutoLoad() if groundCfg.RequirePickupZone then local inPickupZone = self:_isUnitInsidePickupZone(unit, true) - if not inPickupZone and groundCfg.AllowInFOBZones then + -- Explicitly exclude FARP service zones from auto-load + local inFARPZone = false + local unitPoint = unit:GetPointVec3() + for farpZoneName, farpInfo in pairs(CTLD._farpZones or {}) do + local farpZone = farpInfo.zone + if farpZone then + local farpPoint = farpZone:GetPointVec3() + local dx = (farpPoint.x - unitPoint.x) + local dz = (farpPoint.z - unitPoint.z) + local d = math.sqrt(dx*dx + dz*dz) + local farpRadius = self:_getZoneRadius(farpZone) + if d <= farpRadius then + inFARPZone = true + break + end + end + end + + if inFARPZone then + inValidZone = false -- Never auto-load in FARP zones + elseif not inPickupZone and groundCfg.AllowInFOBZones then -- Check FOB zones too for _, fobZone in ipairs(self.FOBZones or {}) do local fname = fobZone:GetName() @@ -10700,20 +10702,20 @@ function CTLD:FindNearestFOBZone(point) end -- Spawn static objects for a FARP stage -function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) +function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalitionId) if not (CTLD.FARPConfig and CTLD.FARPConfig.StageLayouts[stage]) then _logError(string.format('Invalid FARP stage %d or missing layout config', stage)) return false end local layout = CTLD.FARPConfig.StageLayouts[stage] - local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = coalition } + local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = coalitionId } - _logInfo(string.format('Spawning FARP Stage %d statics for zone %s (coalition %d)', stage, zoneName, coalition)) + _logInfo(string.format('Spawning FARP Stage %d statics for zone %s (coalition %d)', stage, zoneName, coalitionId)) -- Get coalition name for DCS - -- Note: 'coalition' parameter is a number (1=red, 2=blue), not the coalition table - local coalitionName = (coalition == 2) and 'blue' or 'red' + -- Note: 'coalitionId' parameter is a number (1=red, 2=blue), not the coalition table + local coalitionName = (coalitionId == 2) and 'blue' or 'red' for _, obj in ipairs(layout) do -- Calculate world position from relative offset @@ -10724,6 +10726,20 @@ function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) -- Generate unique name local staticName = string.format('FARP_%s_S%d_%s_%d', zoneName, stage, obj.type:gsub('%s+', '_'), math.random(10000, 99999)) + -- Determine category and shape_name based on object type + local category = "Fortifications" + local shapeName = "" + local linkUnit = nil + local linkOffset = false + local callsignID = math.random(1, 99) + + if obj.type == "FARP" then + category = "Heliports" + shapeName = "FARP" + linkUnit = 0 + linkOffset = true + end + -- Create static object data local staticData = { ["type"] = obj.type, @@ -10731,15 +10747,24 @@ function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) ["heading"] = math.rad(obj.heading or 0), ["x"] = worldX, ["y"] = worldZ, - ["category"] = "Fortifications", + ["category"] = category, ["canCargo"] = false, - ["shape_name"] = "", + ["shape_name"] = shapeName, ["rate"] = 100, } + -- Add FARP-specific data + if obj.type == "FARP" then + staticData["linkUnit"] = linkUnit + staticData["linkOffset"] = linkOffset + staticData["callsign_id"] = callsignID + staticData["frequencyList"] = {127.5, 129.5, 121.5} + staticData["modulation"] = 0 + end + -- Spawn the static local success, staticObj = pcall(function() - return coalition.addStaticObject(coalition, staticData) + return coalition.addStaticObject(coalitionId, staticData) end) if success and staticObj then @@ -10751,7 +10776,7 @@ function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) end farpData.stage = stage - farpData.coalition = coalition + farpData.coalition = coalitionId CTLD._farpData[zoneName] = farpData _logInfo(string.format('FARP Stage %d complete for zone %s - spawned %d statics', stage, zoneName, #farpData.statics)) @@ -10808,7 +10833,8 @@ function CTLD:UpgradeFARP(group, zoneName) end local center = zone:GetVec2() - local centerPoint = { x = center.x, z = center.y } + -- Offset FARP 80m north from FOB zone center to avoid spawned trucks + local centerPoint = { x = center.x, z = center.y + 80 } -- Deduct salvage CTLD._salvagePoints[self.Side] = currentSalvage - upgradeCost @@ -10834,7 +10860,7 @@ function CTLD:UpgradeFARP(group, zoneName) services = table.concat(services, ', ') }) - -- Create or update FARP service zone + -- Create or update FARP service zone (use same offset centerPoint) self:CreateFARPServiceZone(zoneName, centerPoint, nextStage) _logInfo(string.format('%s upgraded FOB %s to FARP Stage %d (cost: %d salvage)', @@ -10857,6 +10883,13 @@ function CTLD:CreateFARPServiceZone(zoneName, centerPoint, stage) local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(centerPoint.x, centerPoint.z) or { x = centerPoint.x, y = centerPoint.z } local serviceZone = ZONE_RADIUS:New(farpZoneName, v2, radius) + -- Enable scanning for the zone (scan for units and helicopters) + if serviceZone.Scan then + pcall(function() + serviceZone:Scan({Object.Category.UNIT, Object.Category.STATIC}) + end) + end + CTLD._farpZones[farpZoneName] = { zone = serviceZone, side = self.Side, @@ -10876,56 +10909,115 @@ function CTLD:StartFARPServices(farpZoneName) if not farpInfo then return end local selfref = self + CTLD._farpServiceState = CTLD._farpServiceState or {} - -- Service scheduler runs every 5 seconds + -- Service scheduler runs every 2 seconds SCHEDULER:New(nil, function() local zone = farpInfo.zone if not zone then return end local stage = farpInfo.stage - local units = zone:GetScannedUnits() + local now = timer.getTime() - for _, unit in ipairs(units or {}) do + -- Scan zone for units + local zoneVec3 = zone:GetPointVec3() + local radius = selfref:_getZoneRadius(zone) + local volS = { + id = world.VolumeType.SPHERE, + params = { + point = zoneVec3, + radius = radius + } + } + + local foundUnits = {} + world.searchObjects(Object.Category.UNIT, volS, function(obj) + local unit = UNIT:Find(obj) if unit and unit:IsAlive() then local unitCoalition = unit:GetCoalition() - -- Only service friendly units if unitCoalition == farpInfo.side then - local unitType = unit:GetTypeName() - local group = unit:GetGroup() + if unit:IsAir() or unit:IsGround() then + table.insert(foundUnits, unit) + end + end + end + return true + end) + + for _, unit in ipairs(foundUnits) do + local dcsUnit = unit:GetDCSObject() + if dcsUnit then + local uname = unit:GetName() + local agl = unit:GetAltitude() - land.getHeight(unit:GetPointVec2()) + local vel = unit:GetVelocityKMH() + + -- Check if aircraft is on ground (low AGL and low speed) + local onGround = (agl < 5) and (vel < 5) + + if onGround then + CTLD._farpServiceState[uname] = CTLD._farpServiceState[uname] or { lastService = 0 } + local state = CTLD._farpServiceState[uname] - -- Service helicopters and ground vehicles - if group and (unit:IsHelicopter() or unit:IsGround()) then + -- Service every 3 seconds to avoid spam + if (now - state.lastService) >= 3 then + local serviced = false + -- Stage 2+: Refuel if stage >= 2 then - -- Trigger refuel (DCS built-in command) - pcall(function() - local controller = unit:GetUnit():getController() - if controller then - controller:setCommand({ - id = 'RefuelInFlight', - params = {} - }) - end - end) + local fuel = dcsUnit:getFuel() + if fuel < 0.95 then + -- Add 10% fuel per service tick (takes ~30 seconds for full refuel) + dcsUnit:setFuel(math.min(1.0, fuel + 0.10)) + serviced = true + end end - -- Stage 3: Rearm and Repair + -- Stage 3: Rearm if stage >= 3 then pcall(function() - local dcsUnit = unit:GetUnit() - if dcsUnit then - -- Note: DCS doesn't have direct Lua API for ground rearm/repair - -- This simulates the presence of the service zone - -- In practice, DCS may auto-service units near FARP statics + -- Rearm all pylons to full + local ammo = dcsUnit:getAmmo() + if ammo then + for _, wpn in ipairs(ammo) do + if wpn.count then + -- Set to full ammo (this is a workaround - DCS has limited rearm API) + dcsUnit:setAmmo(wpn.type_name, wpn.count + 100) + end + end + serviced = true end end) + + -- Repair (restore hit points) + local life = dcsUnit:getLife() + local life0 = dcsUnit:getLife0() + if life and life0 and life < life0 then + -- Repair 20% per tick (takes ~15 seconds for full repair) + dcsUnit:setLife(math.min(life0, life + (life0 * 0.20))) + serviced = true + end end + + if serviced then + state.lastService = now + local gname = unit:GetGroup():GetName() + if stage >= 3 then + MESSAGE:New(string.format('FARP: Servicing %s (Refuel/Rearm/Repair)', unit:GetTypeName()), 5):ToGroup(GROUP:FindByName(gname)) + else + MESSAGE:New(string.format('FARP: Refueling %s', unit:GetTypeName()), 5):ToGroup(GROUP:FindByName(gname)) + end + end + end + else + -- Aircraft not on ground, reset service timer + if CTLD._farpServiceState[uname] then + CTLD._farpServiceState[uname].lastService = 0 end end end end - end, {}, 0, 5) -- Start immediately, repeat every 5 seconds + end, {}, 0, 2) -- Start immediately, repeat every 2 seconds _logDebug(string.format('FARP service scheduler started for %s', farpZoneName)) end @@ -11121,7 +11213,12 @@ function CTLD:InitMEDEVAC() -- Initialize salvage pools if CTLD.MEDEVAC.Salvage and CTLD.MEDEVAC.Salvage.Enabled then - CTLD._salvagePoints[self.Side] = CTLD._salvagePoints[self.Side] or 0 + local before = CTLD._salvagePoints[self.Side] + -- Check instance config for InitialSalvage override + local initialValue = (self.Config.MEDEVAC and self.Config.MEDEVAC.InitialSalvage) or 0 + CTLD._salvagePoints[self.Side] = CTLD._salvagePoints[self.Side] or initialValue + local after = CTLD._salvagePoints[self.Side] + env.info(string.format('[InitMEDEVAC] Side=%s Salvage BEFORE=%s InitialValue=%s AFTER=%s', tostring(self.Side), tostring(before), tostring(initialValue), tostring(after))) end -- Setup event handler for unit deaths @@ -12937,8 +13034,11 @@ function CTLD:_TryUseSalvageForCrate(group, crateKey, catalogEntry) if not cfg or not cfg.Enabled then return false end if not cfg.AutoApply then return false end - -- Check if item has salvage value - local salvageCost = (catalogEntry and catalogEntry.salvageValue) or 0 + -- Check if item has salvage value (use same fallback logic as MEDEVAC) + local salvageCost = catalogEntry.salvageValue + if not salvageCost then + salvageCost = catalogEntry.required or cfg.DefaultValue or 1 + end if salvageCost <= 0 then return false end -- Check if we have enough salvage @@ -12981,7 +13081,12 @@ function CTLD:_CanUseSalvageForCrate(crateKey, catalogEntry, quantity) if not cfg.AutoApply then return false end quantity = quantity or 1 - local salvageCost = ((catalogEntry and catalogEntry.salvageValue) or 0) * quantity + -- Check if item has salvage value (use same fallback logic as MEDEVAC) + local salvageCost = catalogEntry.salvageValue + if not salvageCost then + salvageCost = catalogEntry.required or cfg.DefaultValue or 1 + end + salvageCost = salvageCost * quantity if salvageCost <= 0 then return false end local available = CTLD._salvagePoints[self.Side] or 0 @@ -13266,6 +13371,7 @@ function CTLD:ShowSalvagePoints(group) end local salvage = CTLD._salvagePoints[self.Side] or 0 + env.info('ShowSalvagePoints: self.Side = ' .. tostring(self.Side) .. ', CTLD._salvagePoints[self.Side] = ' .. tostring(CTLD._salvagePoints and CTLD._salvagePoints[self.Side] or 'nil') .. ', salvage = ' .. tostring(salvage)) local lines = {} table.insert(lines, '=== Coalition Salvage Points ===') @@ -13641,18 +13747,19 @@ end -- Create a Mobile MASH zone and start announcements function CTLD:_CreateMobileMASH(group, position, catalogDef) - local cfg = CTLD.MEDEVAC + _logInfo('[MobileMASH] _CreateMobileMASH called') + local cfg = self.Config.MEDEVAC if not cfg or not cfg.Enabled then - _logDebug('[MobileMASH] Config missing or MEDEVAC disabled; aborting mobile deployment') + _logInfo('[MobileMASH] Config missing or MEDEVAC disabled; aborting mobile deployment') return end if not cfg.MobileMASH or not cfg.MobileMASH.Enabled then - _logDebug('[MobileMASH] MobileMASH feature disabled in config; aborting') + _logInfo('[MobileMASH] MobileMASH feature disabled in config; aborting') return end if not position or not position.x or not position.z then - _logError('[MobileMASH] Missing build position; aborting Mobile MASH deployment') + _logInfo('[MobileMASH] Missing build position; aborting Mobile MASH deployment') return end @@ -13661,7 +13768,7 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) local okPreview, namePreview = pcall(function() return group:getName() end) if okPreview and namePreview and namePreview ~= '' then groupNamePreview = namePreview end end - _logVerbose(string.format('[MobileMASH] Build requested for group %s at (%.1f, %.1f)', groupNamePreview, position.x or 0, position.z or 0)) + _logInfo(string.format('[MobileMASH] Build requested for group %s at (%.1f, %.1f)', groupNamePreview, position.x or 0, position.z or 0)) local function safeGetName(g) if not g then return nil end @@ -13681,12 +13788,12 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) _logError('[MobileMASH] Unable to determine coalition side; aborting Mobile MASH deployment') return end - _logDebug(string.format('[MobileMASH] Using coalition side %s (%s)', tostring(side), tostring(catalogDef.side or self.Side))) + _logInfo(string.format('[MobileMASH] Using coalition side %s (%s)', tostring(side), tostring(catalogDef.side or self.Side))) CTLD._mobileMASHCounter = CTLD._mobileMASHCounter or { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0 } CTLD._mobileMASHCounter[side] = (CTLD._mobileMASHCounter[side] or 0) + 1 local index = CTLD._mobileMASHCounter[side] - _logDebug(string.format('[MobileMASH] Assigned deployment index %d for side %s', index, tostring(side))) + _logInfo(string.format('[MobileMASH] Assigned deployment index %d for side %s', index, tostring(side))) local mashId = string.format('MOBILE_MASH_%d_%d', side, index) local displayName @@ -13695,13 +13802,13 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) else displayName = string.format('Mobile MASH %d', index) end - _logDebug(string.format('[MobileMASH] mashId=%s displayName=%s recipeDesc=%s', mashId, tostring(displayName), tostring(catalogDef.description))) + _logInfo(string.format('[MobileMASH] mashId=%s displayName=%s recipeDesc=%s', mashId, tostring(displayName), tostring(catalogDef.description))) local initialPos = { x = position.x, z = position.z } local radius = cfg.MobileMASH.ZoneRadius or 500 local beaconFreq = cfg.MobileMASH.BeaconFrequency or '30.0 FM' local mashGroupName = safeGetName(group) - _logDebug(string.format('[MobileMASH] Initial position (%.1f, %.1f) radius %.1f freq %s groupName=%s', initialPos.x or 0, initialPos.z or 0, radius, tostring(beaconFreq), tostring(mashGroupName))) + _logInfo(string.format('[MobileMASH] Initial position (%.1f, %.1f) radius %.1f freq %s groupName=%s', initialPos.x or 0, initialPos.z or 0, radius, tostring(beaconFreq), tostring(mashGroupName))) local function buildZoneObject(name, r, pos) if ZONE_RADIUS and VECTOR2 and VECTOR2.New then @@ -13844,7 +13951,7 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) } CTLD._mashZones[mashId] = mashData - _logDebug(string.format('[MobileMASH] Registered mashId=%s displayName=%s zoneRadius=%.1f freq=%s', mashId, displayName, radius, tostring(beaconFreq))) + _logInfo(string.format('[MobileMASH] Registered mashId=%s displayName=%s zoneRadius=%.1f freq=%s', mashId, displayName, radius, tostring(beaconFreq))) self._ZoneDefs = self._ZoneDefs or { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {} } self._ZoneDefs.MASHZones = self._ZoneDefs.MASHZones or {} @@ -13857,31 +13964,31 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) -- Add zone to MASHZones array so it's recognized by the system self.MASHZones = self.MASHZones or {} table.insert(self.MASHZones, zoneObj) - _logDebug(string.format('[MobileMASH] Added zone to MASHZones array, total count: %d', #self.MASHZones)) + _logInfo(string.format('[MobileMASH] Added zone to MASHZones array, total count: %d', #self.MASHZones)) - local md = self.Config and self.Config.MapDraw or {} - if md.Enabled then - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('DrawZonesOnMap failed after Mobile MASH creation: %s', tostring(err))) + -- Add to MEDEVAC zones if MEDEVAC is active + if self.MEDEVAC and self.MEDEVAC.AddZone then + local ok, err = pcall(function() + self.MEDEVAC:AddZone(displayName, zoneObj) + end) + if ok then + _logInfo(string.format('[MobileMASH] Added zone to MEDEVAC system: %s', displayName)) + -- Refresh MEDEVAC menu to include the new zone + pcall(function() self.MEDEVAC:__Start(1) end) + else + _logDebug(string.format('[MobileMASH] Could not add to MEDEVAC system: %s', tostring(err))) end - else - local circleId = _nextMarkupId() - local textId = _nextMarkupId() - local p = { x = initialPos.x, y = 0, z = initialPos.z } + end - local colors = cfg.MASHZoneColors or {} - local borderColor = colors.border or {1, 1, 0, 0.85} - local fillColor = colors.fill or {1, 0.75, 0.8, 0.25} - - trigger.action.circleToCoalition(side, circleId, p, radius, borderColor, fillColor, 1, true, "") - - local textPos = { x = p.x, y = 0, z = p.z - radius - 50 } - trigger.action.textToCoalition(side, textId, textPos, {1,1,1,0.9}, {0,0,0,0}, 18, true, displayName) - - mashData.circleId = circleId - mashData.textId = textId - _logDebug(string.format('[MobileMASH] Drawn map circleId=%d textId=%d', circleId, textId)) + -- Auto-draw the new zone on the map using the update function + -- This ensures the drawing is tracked properly and can be updated/removed later + local md = self.Config and self.Config.MapDraw or {} + if md.Enabled and md.DrawMASHZones then + _logInfo('[MobileMASH] Drawing new Mobile MASH zone on map') + local ok, err = pcall(function() self:_updateMobileMASHDrawing(mashId) end) + if not ok then + _logError(string.format('_updateMobileMASHDrawing failed after Mobile MASH creation: %s', tostring(err))) + end end local gridStr = self:_GetMGRSString(initialPos) @@ -13930,7 +14037,9 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) -- Create a separate frequent position update scheduler for mobile MASH tracking -- This ensures the zone follows the vehicle even if announcements are infrequent local ctldInstance = self - local positionUpdateInterval = 5 -- Update position every 5 seconds + local positionUpdateInterval = 15 -- Update position every 15 seconds + local mapRedrawInterval = 15 -- Redraw map every 15 seconds + local updatesSinceRedraw = 0 local posScheduler = SCHEDULER:New(nil, function() local ok, err = pcall(function() if not groupIsAlive() then @@ -13949,13 +14058,20 @@ function CTLD:_CreateMobileMASH(group, position, catalogDef) end end _logDebug(string.format('[MobileMASH] Position updated for %s at (%.1f, %.1f)', displayName, vec3.x, vec3.z)) + + -- Redraw map only every 120 seconds + updatesSinceRedraw = updatesSinceRedraw + positionUpdateInterval + if updatesSinceRedraw >= mapRedrawInterval then + pcall(function() ctldInstance:_updateMobileMASHDrawing(mashId) end) + updatesSinceRedraw = 0 + end end end) if not ok then _logError('Mobile MASH position update scheduler error: '..tostring(err)) end end, {}, positionUpdateInterval, positionUpdateInterval) mashData.positionScheduler = posScheduler - _logDebug(string.format('[MobileMASH] Position update scheduler started every %ds', positionUpdateInterval)) + _logDebug(string.format('[MobileMASH] Position update scheduler started every %ds (map redraw every %ds)', positionUpdateInterval, mapRedrawInterval)) if EVENTHANDLER then local ctldInstance = self @@ -14025,6 +14141,12 @@ function CTLD:_RemoveMobileMASH(mashId) end end + -- Remove from MEDEVAC system if possible + if self.MEDEVAC and self.MEDEVAC.RemoveZone then + pcall(function() self.MEDEVAC:RemoveZone(name) end) + _logDebug(string.format('[MobileMASH] Attempted to remove zone from MEDEVAC system: %s', name)) + end + -- Send destruction message local msg = _fmtTemplate(CTLD.Messages.medevac_mash_destroyed, { mash_id = string.match(mashId, 'MOBILE_MASH_%d+_(%d+)') or '?' @@ -14700,6 +14822,19 @@ function CTLD:CreateSalvageZoneAtGroup(group) local coord = COORDINATE:NewFromVec3(pos) local radius = cfg.DefaultZoneRadius or 300 + -- Check for nearby salvage cargo (prevent zone placement within 1km of cargo) + local minDistance = 1000 -- 1km + for crateName, meta in pairs(CTLD._salvageCrates or {}) do + if meta and meta.position then + local cratePos = meta.position + local distance = math.sqrt((pos.x - cratePos.x)^2 + (pos.z - cratePos.z)^2) + if distance < minDistance then + _msgGroup(group, string.format('Cannot create salvage zone within %.0fm of existing salvage cargo. Nearest cargo is %.0fm away.', minDistance, distance)) + return + end + end + end + self._DynamicSalvageZones = self._DynamicSalvageZones or {} self._DynamicSalvageQueue = self._DynamicSalvageQueue or {} diff --git a/Moose_CTLD_Init_DualCoalitions.lua b/Moose_CTLD_Init_DualCoalitions.lua index 0afdd12..4ddd479 100644 --- a/Moose_CTLD_Init_DualCoalitions.lua +++ b/Moose_CTLD_Init_DualCoalitions.lua @@ -25,18 +25,29 @@ local blueCfg = { 'UH-1H','Mi-8MTV2','Mi-24P','SA342M','SA342L','SA342Minigun','UH-60L','CH-47Fbl1','CH-47F','Mi-17','GazelleAI' }, -- Optional: drive zone activation from mission flags (preferred: set per-zone below via flag/activeWhen) - - MapDraw = { - Enabled = true, - DrawMASHZones = true, -- Enable MASH zone drawing - }, - - Zones = { + + MEDEVAC = { + Enabled = true, + InitialSalvage = 25, -- Starting salvage points for this coalition + MobileMASH = { + Enabled = true, + ZoneRadius = 300, + BeaconFrequency = '32.0 FM', + AnnouncementInterval = 300, -- Announce position every 5 minutes + }, + }, + + MapDraw = { + Enabled = true, + DrawMASHZones = true, -- Enable MASH zone drawing + }, + + Zones = { PickupZones = { { name = 'ALPHA', flag = 9001, activeWhen = 0 } }, DropZones = { { name = 'BRAVO', flag = 9002, activeWhen = 0 } }, FOBZones = { { name = 'CHARLIE', flag = 9003, activeWhen = 0 } }, - MASHZones = { { name = 'MASH Alpha', freq = '251.0 AM', radius = 500, flag = 9010, activeWhen = 0 } }, - SalvageDropZones = { { name = 'S1', flag = 9020, radius = 500, activeWhen = 0 } }, + MASHZones = { { name = 'MASH Alpha', freq = '251.0 AM', radius = 300, flag = 9010, activeWhen = 0 } }, + SalvageDropZones = { { name = 'S1', flag = 9020, radius = 300, activeWhen = 0 } }, }, BuildRequiresGroundCrates = true, } @@ -45,6 +56,7 @@ if blueCfg.Zones and blueCfg.Zones.MASHZones and blueCfg.Zones.MASHZones[1] then env.info('[DEBUG] blueCfg.Zones.MASHZones[1].name: ' .. tostring(blueCfg.Zones.MASHZones[1].name)) end ctldBlue = _MOOSE_CTLD:New(blueCfg) +env.info('[CTLD_INIT] After BLUE init, salvage = ' .. tostring((CTLD._salvagePoints and CTLD._salvagePoints[coalition.side.BLUE]) or 'nil')) local redCfg = { CoalitionSide = coalition.side.RED, @@ -55,17 +67,28 @@ local redCfg = { }, -- Optional: drive zone activation for RED via per-zone flag/activeWhen - MapDraw = { - Enabled = true, - DrawMASHZones = true, -- Enable MASH zone drawing - }, - - Zones = { + MEDEVAC = { + Enabled = true, + InitialSalvage = 25, -- Starting salvage points for this coalition + MobileMASH = { + Enabled = true, + ZoneRadius = 300, + BeaconFrequency = '30.0 FM', + AnnouncementInterval = 1800, -- Announce position every 30 minutes + }, + }, + + MapDraw = { + Enabled = true, + DrawMASHZones = true, -- Enable MASH zone drawing + }, + + Zones = { PickupZones = { { name = 'DELTA', flag = 9101, activeWhen = 0 } }, DropZones = { { name = 'ECHO', flag = 9102, activeWhen = 0 } }, FOBZones = { { name = 'FOXTROT', flag = 9103, activeWhen = 0 } }, - MASHZones = { { name = 'MASH Bravo', freq = '252.0 AM', radius = 500, flag = 9111, activeWhen = 0 } }, - SalvageDropZones = { { name = 'S2', flag = 9020, radius = 500, activeWhen = 0 } }, + MASHZones = { { name = 'MASH Bravo', freq = '252.0 AM', radius = 300, flag = 9111, activeWhen = 0 } }, + SalvageDropZones = { { name = 'S2', flag = 9020, radius = 300, activeWhen = 0 } }, }, BuildRequiresGroundCrates = true, } @@ -74,6 +97,7 @@ if redCfg.Zones and redCfg.Zones.MASHZones and redCfg.Zones.MASHZones[1] then env.info('[DEBUG] redCfg.Zones.MASHZones[1].name: ' .. tostring(redCfg.Zones.MASHZones[1].name)) end ctldRed = _MOOSE_CTLD:New(redCfg) +env.info('[CTLD_INIT] After RED init, salvage = ' .. tostring((CTLD._salvagePoints and CTLD._salvagePoints[coalition.side.RED]) or 'nil')) -- Merge catalog into both CTLD instances if catalog was loaded env.info('[init_mission_dual_coalition] Checking for catalog: '..((_CTLD_EXTRACTED_CATALOG and 'FOUND') or 'NOT FOUND')) @@ -93,6 +117,7 @@ else env.info('[init_mission_dual_coalition] WARNING: _CTLD_EXTRACTED_CATALOG not found - catalog not loaded!') env.info('[init_mission_dual_coalition] Available globals: '..((_G._CTLD_EXTRACTED_CATALOG and 'in _G') or 'not in _G')) end +env.info('[CTLD_INIT] End of init - BLUE salvage: ' .. tostring(CTLD._salvagePoints and CTLD._salvagePoints[coalition.side.BLUE] or 'nil') .. ', RED salvage: ' .. tostring(CTLD._salvagePoints and CTLD._salvagePoints[coalition.side.RED] or 'nil')) else env.info('[init_mission_dual_coalition] Moose or CTLD missing; skipping CTLD init') end diff --git a/Moose_CTLD_NoBattle.miz b/Moose_CTLD_NoBattle.miz new file mode 100644 index 0000000..219adec Binary files /dev/null and b/Moose_CTLD_NoBattle.miz differ diff --git a/Moose_CTLD_Pure.miz b/Moose_CTLD_Pure.miz index e556863..641a32d 100644 Binary files a/Moose_CTLD_Pure.miz and b/Moose_CTLD_Pure.miz differ diff --git a/catalogs/Moose_CTLD_Catalog.lua b/catalogs/Moose_CTLD_Catalog.lua index 940e181..2e7c7a2 100644 --- a/catalogs/Moose_CTLD_Catalog.lua +++ b/catalogs/Moose_CTLD_Catalog.lua @@ -268,20 +268,14 @@ cat['BLUE_MQ9'] = { menuCategory='Drones', menu='MQ-9 Reaper - JTA cat['RED_WINGLOONG'] = { menuCategory='Drones', menu='WingLoong-I - JTAC', description='WingLoong-I JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=RED, category=Group.Category.AIRPLANE, build=singleAirUnit('WingLoong-I'), roles={'JTAC'}, jtac={ platform='air' } } -- FOB crates (Support) — three small crates build a FOB site -cat['FOB_SMALL'] = { hidden=true, description='FOB small crate', dcsCargoType='container_cargo', required=1, initialStock=12, side=nil, category=Group.Category.GROUND, build=function(point, headingDeg) - -- spawns a harmless placeholder truck for visibility; consumed by FOB_SITE build - return singleUnit('Ural-375')(point, headingDeg) -end } +cat['FOB_SMALL'] = { hidden=true, description='FOB small crate', dcsCargoType='container_cargo', required=1, initialStock=12, side=nil, category=Group.Category.GROUND } cat['FOB_SITE'] = { menuCategory='Support', menu='FOB Crates - All', description='FOB Site', isFOB=true, dcsCargoType='container_cargo', requires={ FOB_SMALL=3 }, initialStock=0, side=nil, category=Group.Category.GROUND, build=multiUnits({ {type='HEMTT TFFT'}, {type='Ural-375 PBU', dx=10, dz=8}, {type='Ural-375', dx=-10, dz=8} }) } -- Mobile MASH (Support) — three crates build a Mobile MASH unit -cat['MOBILE_MASH_SMALL'] = { hidden=true, description='Mobile MASH crate', dcsCargoType='container_cargo', required=1, initialStock=6, side=nil, category=Group.Category.GROUND, build=function(point, headingDeg) - -- spawns placeholder truck for visibility; consumed by MOBILE_MASH build - return singleUnit('Ural-375')(point, headingDeg) -end } -cat['BLUE_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Blue Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=BLUE, category=Group.Category.GROUND, build=singleUnit('M-113') } -cat['RED_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Red Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=RED, category=Group.Category.GROUND, build=singleUnit('BTR_D') } +cat['MOBILE_MASH_SMALL'] = { hidden=true, description='Mobile MASH crate', dcsCargoType='container_cargo', required=1, initialStock=3, side=nil, category=Group.Category.GROUND } +cat['BLUE_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Blue Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=BLUE, category=Group.Category.GROUND, build=multiUnits({ {type='M-113'}, {type='M-113', dx=10, dz=8}, {type='M-113', dx=-10, dz=8} }) } +cat['RED_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Red Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=RED, category=Group.Category.GROUND, build=multiUnits({ {type='BTR_D'}, {type='BTR_D', dx=10, dz=8}, {type='BTR_D', dx=-10, dz=8} }) } -- ========================= -- Troop Type Definitions diff --git a/catalogs/Moose_CTLD_Catalog_LowCounts.lua b/catalogs/Moose_CTLD_Catalog_LowCounts.lua index e91b5dc..d357d73 100644 --- a/catalogs/Moose_CTLD_Catalog_LowCounts.lua +++ b/catalogs/Moose_CTLD_Catalog_LowCounts.lua @@ -268,20 +268,14 @@ cat['BLUE_MQ9'] = { menuCategory='Drones', menu='MQ-9 Reaper - JTA cat['RED_WINGLOONG'] = { menuCategory='Drones', menu='WingLoong-I - JTAC', description='WingLoong-I JTAC', dcsCargoType='container_cargo', required=1, initialStock=1, side=RED, category=Group.Category.AIRPLANE, build=singleAirUnit('WingLoong-I'), roles={'JTAC'}, jtac={ platform='air' } } -- FOB crates (Support) — three small crates build a FOB site -cat['FOB_SMALL'] = { hidden=true, description='FOB small crate', dcsCargoType='container_cargo', required=1, initialStock=6, side=nil, category=Group.Category.GROUND, build=function(point, headingDeg) - -- spawns a harmless placeholder truck for visibility; consumed by FOB_SITE build - return singleUnit('Ural-375')(point, headingDeg) -end } +cat['FOB_SMALL'] = { hidden=true, description='FOB small crate', dcsCargoType='container_cargo', required=1, initialStock=6, side=nil, category=Group.Category.GROUND } cat['FOB_SITE'] = { menuCategory='Support', menu='FOB Crates - All', description='FOB Site', isFOB=true, dcsCargoType='container_cargo', requires={ FOB_SMALL=3 }, initialStock=0, side=nil, category=Group.Category.GROUND, build=multiUnits({ {type='HEMTT TFFT'}, {type='Ural-375 PBU', dx=10, dz=8}, {type='Ural-375', dx=-10, dz=8} }) } -- Mobile MASH (Support) — three crates build a Mobile MASH unit -cat['MOBILE_MASH_SMALL'] = { hidden=true, description='Mobile MASH crate', dcsCargoType='container_cargo', required=1, initialStock=3, side=nil, category=Group.Category.GROUND, build=function(point, headingDeg) - -- spawns placeholder truck for visibility; consumed by MOBILE_MASH build - return singleUnit('Ural-375')(point, headingDeg) -end } -cat['BLUE_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Blue Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=BLUE, category=Group.Category.GROUND, build=singleUnit('M-113') } -cat['RED_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Red Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=RED, category=Group.Category.GROUND, build=singleUnit('BTR_D') } +cat['MOBILE_MASH_SMALL'] = { hidden=true, description='Mobile MASH crate', dcsCargoType='container_cargo', required=1, initialStock=3, side=nil, category=Group.Category.GROUND } +cat['BLUE_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Blue Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=BLUE, category=Group.Category.GROUND, build=multiUnits({ {type='M-113'}, {type='M-113', dx=10, dz=8}, {type='M-113', dx=-10, dz=8} }) } +cat['RED_MOBILE_MASH'] = { menuCategory='Support', menu='Mobile MASH - All', description='Red Mobile MASH Unit', isMobileMASH=true, dcsCargoType='container_cargo', requires={ MOBILE_MASH_SMALL=3 }, initialStock=0, side=RED, category=Group.Category.GROUND, build=multiUnits({ {type='BTR_D'}, {type='BTR_D', dx=10, dz=8}, {type='BTR_D', dx=-10, dz=8} }) } -- ========================= -- Troop Type Definitions diff --git a/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua b/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua new file mode 100644 index 0000000..7ea4de6 --- /dev/null +++ b/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua @@ -0,0 +1,14911 @@ +-- Pure-MOOSE, template-free CTLD-style logistics & troop transport +-- Drop-in script: no MIST, no mission editor templates required +-- Dependencies: Moose.lua must be loaded before this script +-- Author: Copilot (generated) +-- +-- LOGGING SYSTEM: +-- LogLevel configuration controls verbosity: 0=NONE, 1=ERROR, 2=INFO (default), 3=VERBOSE, 4=DEBUG +-- Set LogLevel in config to reduce log spam on production servers. See LOGGING_GUIDE.md for details. + +-- Contract +-- Inputs: Config table or defaults. No ME templates needed. Zones may be named ME trigger zones or provided via coordinates in config. +-- Outputs: F10 menus for helo/transport groups; crate spawning/building; troop load/unload; optional JTAC hookup (via FAC module); +-- Error modes: missing Moose -> abort; unknown crate key -> message; spawn blocked in enemy airbase; zone missing -> message. +-- +-- Orignal Author of CTLD: Ciribob +-- Moose adaptation: Lathe, Copilot, F99th-TracerFacer + +-- #region Config +_DEBUG = true +local CTLD = {} +CTLD.Version = '1.0.2' +CTLD.__index = CTLD +CTLD._lastSalvageInterval = CTLD._lastSalvageInterval or 0 +CTLD._playerUnitPrefs = CTLD._playerUnitPrefs or {} + +local _msgGroup, _msgCoalition +local _log, _logError, _logInfo, _logVerbose, _logDebug, _logImmediate + +-- General CTLD event messages (non-hover). Tweak freely. +CTLD.Messages = { + -- Crates + crate_spawn_requested = "Request received—spawning {type} crate at {zone}.", + pickup_zone_required = "Move within {zone_dist} {zone_dist_u} of a Supply Zone to request crates. Bearing {zone_brg}° to nearest zone.", + no_pickup_zones = "No Pickup Zones are configured for this coalition. Ask the mission maker to add supply zones or disable the pickup zone requirement.", + crate_re_marked = "Re-marking crate {id} with {mark}.", + crate_expired = "Crate {id} expired and was removed.", + crate_max_capacity = "Max load reached ({total}). Drop or build before picking up more.", + crate_aircraft_capacity = "Aircraft capacity reached ({current}/{max} crates). Your {aircraft} can only carry {max} crates.", + troop_aircraft_capacity = "Aircraft capacity reached. Your {aircraft} can only carry {max} troops (you need {count}).", + crate_spawned = "Crate’s live! {type} [{id}]. Bearing {brg}° range {rng} {rng_u}.\nCall for vectors if you need a hand.\n\nTo load: HOVER within 25m at 5-20m AGL, or LAND within 35m and hold for 25s.", + + -- Drops + drop_initiated = "Dropping {count} crate(s) here…", + dropped_crates = "Dropped {count} crate(s) at your location.", + no_loaded_crates = "No loaded crates to drop.", + + -- Build + build_insufficient_crates = "Insufficient crates to build {build}.", + build_requires_ground = "You have {total} crate(s) onboard—drop them first to build here.", + build_started = "Building {build} at your position…", + build_success = "{build} deployed to the field!", + build_success_coalition = "{player} deployed {build} to the field!", + build_failed = "Build failed: {reason}.", + fob_restricted = "FOB building is restricted to designated FOB zones.", + auto_fob_built = "FOB auto-built at {zone}.", + + -- Troops + troops_loaded = "Loaded {count} troops—ready to deploy.", + troops_unloaded = "Deployed {count} troops.", + troops_unloaded_coalition = "{player} deployed {count} troops.", + troops_fast_roped = "Fast-roped {count} troops into the field!", + troops_fast_roped_coalition = "{player} fast-roped {count} troops from {aircraft}!", + no_troops = "No troops onboard.", + troops_deploy_failed = "Deploy failed: {reason}.", + troop_pickup_zone_required = "Move inside a Supply Zone to load troops. Nearest zone is {zone_dist}, {zone_dist_u} away bearing {zone_brg}°.", + troop_load_must_land = "Must be on the ground to load troops. Land and reduce speed to < {max_speed} {speed_u}.", + troop_load_too_fast = "Ground speed too high for loading. Reduce to < {max_speed} {speed_u} (current: {current_speed} {speed_u}).", + troop_unload_altitude_too_high = "Too high for fast-rope deployment. Maximum: {max_agl} m AGL (current: {current_agl} m). Land or descend.", + troop_unload_altitude_too_low = "Too low for safe fast-rope. Minimum: {min_agl} m AGL (current: {current_agl} m). Climb or land.", + + -- Ground auto-load + ground_load_started = "{ground_line}\nHold position for {seconds}s to load {count} crate(s)...", + ground_load_progress = "{ground_line}\n{remaining}s remaining. Hold position.", + ground_load_complete = "{ground_line}\nLoaded {count} crate(s).", + ground_load_aborted = "Ground load aborted: aircraft moved or lifted off.", + ground_load_no_zone = "Ground auto-load requires being inside a Pickup Zone. Nearest zone: {zone_dist} {zone_dist_u} at {zone_brg}°.", + ground_load_no_crates = "No crates within {radius}m to load.", + + -- Coach & nav + vectors_to_crate = "Nearest crate {id}: bearing {brg}°, range {rng} {rng_u}.", + vectors_to_pickup_zone = "Nearest supply zone {zone}: bearing {brg}°, range {rng} {rng_u}.", + coach_enabled = "Hover Coach enabled.", + coach_disabled = "Hover Coach disabled.", + + -- Hover Coach guidance + coach_arrival = "You’re close—nice and easy. Hover at 5–20 meters.", + coach_close = "Reduce speed below 15 km/h and set 5–20 m AGL.", + coach_hint = "{hints} GS {gs} {gs_u}.", + coach_too_fast = "Too fast for pickup: GS {gs} {gs_u}. Reduce below 8 km/h.", + coach_too_high = "Too high: AGL {agl} {agl_u}. Target 5–20 m.", + coach_too_low = "Too low: AGL {agl} {agl_u}. Maintain at least 5 m.", + coach_drift = "Outside pickup window. Re-center within 25 m.", + coach_hold = "Oooh, right there! HOLD POSITION…", + coach_loaded = "Crate is hooked! Nice flying!", + coach_hover_lost = "Movement detected—recover hover to load.", + coach_abort = "Hover lost. Reacquire within 25 m, GS < 8 km/h, AGL 5–20 m.", + + -- Zone state changes + zone_activated = "{kind} Zone {zone} is now ACTIVE.", + zone_deactivated = "{kind} Zone {zone} is now INACTIVE.", + + -- Attack/Defend announcements + attack_enemy_announce = "{unit_name} deployed by {player} has spotted an enemy {enemy_type} at {brg}°, {rng} {rng_u}. Moving to engage!", + attack_base_announce = "{unit_name} deployed by {player} is moving to capture {base_name} at {brg}°, {rng} {rng_u}.", + attack_no_targets = "{unit_name} deployed by {player} found no targets within {rng} {rng_u}. Holding position.", + + jtac_onstation = "JTAC {jtac} on station. CODE {code}.", + jtac_new_target = "JTAC {jtac} lasing {target}. CODE {code}. POS {grid}.", + jtac_target_lost = "JTAC {jtac} lost target. Reacquiring.", + jtac_target_destroyed = "JTAC {jtac} reports target destroyed.", + jtac_idle = "JTAC {jtac} scanning for targets.", + + -- Zone restrictions + drop_forbidden_in_pickup = "Cannot drop crates inside a Supply Zone. Move outside the zone boundary.", + troop_deploy_forbidden_in_pickup = "Cannot deploy troops inside a Supply Zone. Move outside the zone boundary.", + drop_zone_too_close_to_pickup = "Drop Zone creation blocked: too close to Supply Zone {zone} (need at least {need} {need_u}; current {dist} {dist_u}). Fly further away and try again.", + + -- MEDEVAC messages + medevac_crew_spawned = "MEDEVAC REQUEST: {vehicle} crew at {grid}. {crew_size} personnel awaiting rescue. Salvage value: {salvage}.", + medevac_crew_loaded = "Rescued {vehicle} crew ({crew_size} personnel). {vehicle} will respawn shortly.", + medevac_vehicle_respawned = "{vehicle} repaired and returned to the field at original location!", + medevac_crew_delivered_mash = "{player} delivered {vehicle} crew to MASH. Earned {salvage} salvage points! Coalition total: {total}.", + medevac_crew_timeout = "MEDEVAC FAILED: {vehicle} crew at {grid} KIA - no rescue attempted. Vehicle lost.", + medevac_crew_killed = "MEDEVAC FAILED: {vehicle} crew killed in action. Vehicle lost.", + medevac_crew_killed_lines = { + "Stranded Crew ({vehicle} @ {grid}): We're taking heavy fire—where's that bird— *static*", + "Stranded Crew ({vehicle} @ {grid}): Ahh— we're under fire— we can't h— *boom*", + "Stranded Crew ({vehicle} @ {grid}): They're right on top of us— we won't ho— *static*", + "Stranded Crew ({vehicle} @ {grid}): Rounds incoming— this is it boys— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): If you hear this, we didn't make— *static*", + "Stranded Crew ({vehicle} @ {grid}): No more cover! We can't hold— *boom*", + "Stranded Crew ({vehicle} @ {grid}): This is it— tell them we tried— *static*", + "Stranded Crew ({vehicle} @ {grid}): They're all around us— oh God— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): Taking direct hits— we're not walking out of— *static*", + "Stranded Crew ({vehicle} @ {grid}): Where's that ride— we're pinned— we can't— *boom*", + -- New Mo-blame lines + "Stranded Crew ({vehicle} @ {grid}): Mo said this LZ was 'low threat'—remind him how that turned out— *static*", + "Stranded Crew ({vehicle} @ {grid}): Copy, still no bird—did Mo forget to file the MEDEVAC again?— *boom*", + "Stranded Crew ({vehicle} @ {grid}): We only took this route because Mo said it was 'shortcut friendly'— *static*", + "Stranded Crew ({vehicle} @ {grid}): Tell Mo his ‘won’t be hot for long’ brief was a lie— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): Command, if Mo planned this exfil, we’d like a refund— *static*", + "Stranded Crew ({vehicle} @ {grid}): Mo promised we’d be home for chow—guess we’re staying for artillery instead— *boom*", + "Stranded Crew ({vehicle} @ {grid}): We’re still at {grid}—unless Mo ‘optimized’ the coordinates again— *static*", + "Stranded Crew ({vehicle} @ {grid}): Whoever let Mo pick this landing zone owes us new helmets— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): Be advised, Mo’s ‘safe corridor’ is mostly explosions right now— *static*", + "Stranded Crew ({vehicle} @ {grid}): If the bird’s lost, check if Mo touched the map— *boom*", + "Stranded Crew ({vehicle} @ {grid}): This is what we get for trusting Mo’s weather call— *static*", + "Stranded Crew ({vehicle} @ {grid}): Mo swore enemy armor ‘never comes this far’—they’re waving at us— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): We’re painted from every angle—next time, maybe don’t let Mo do the route card— *static*", + "Stranded Crew ({vehicle} @ {grid}): If Mo filed our grid as a coffee break, we’re gonna haunt him— *boom*", + "Stranded Crew ({vehicle} @ {grid}): Radio check—anyone but Mo on comms? We’d like a real pickup— *static*", + "Stranded Crew ({vehicle} @ {grid}): Mo said ‘in and out, easy day’—we’re on hour one of ‘not easy’— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): Still no rotors—ask Mo if he scheduled this MEDEVAC for tomorrow— *static*", + "Stranded Crew ({vehicle} @ {grid}): If Mo’s running the flight schedule, tell him we’re fresh out of patience— *boom*", + "Stranded Crew ({vehicle} @ {grid}): Mo briefed ‘minimal contact’—we’re currently meeting the entire enemy battalion— *static*", + "Stranded Crew ({vehicle} @ {grid}): Rescue bird, if you’re circling, that’s Mo’s navigation, not ours— *explosion*", + "Stranded Crew ({vehicle} @ {grid}): We popped smoke twice—maybe Mo told them to ignore it— *static*", + "Stranded Crew ({vehicle} @ {grid}): If they ask how we got stuck here, just say ‘Mo’—they’ll understand— *boom*", + "Stranded Crew ({vehicle} @ {grid}): Mo said ‘what’s the worst that could happen’—tell him he’s about to find out— *static*", + }, + medevac_no_requests = "No active MEDEVAC requests.", + medevac_vectors = "MEDEVAC: {vehicle} crew bearing {brg}°, range {rng} {rng_u}. Time remaining: {time_remain} mins.", + medevac_salvage_status = "Coalition Salvage Points: {points}. Use salvage to build out-of-stock items.", + medevac_salvage_used = "Built {item} using {salvage} salvage points. Remaining: {remaining}.", + medevac_salvage_insufficient = "Out of stock. Requires {need} salvage points (need {deficit} more). Earn salvage by delivering MEDEVAC crews to MASH or sling-loading enemy wreckage.", + medevac_crew_warn_15min = "WARNING: {vehicle} crew at {grid} - rescue window expires in 15 minutes!", + medevac_crew_warn_5min = "URGENT: {vehicle} crew at {grid} - rescue window expires in 5 minutes!", + medevac_unload_hold = "MEDEVAC: Stay grounded in the MASH zone for {seconds} seconds to offload casualties.", + + -- Sling-Load Salvage messages + slingload_salvage_spawned = "SALVAGE OPPORTUNITY: Enemy wreckage at {grid}. Weight: {weight}kg, Est. Value: {reward}pts. {time_remain} remaining to collect!", + slingload_salvage_delivered = "{player} delivered {weight}kg salvage for {reward} points ({condition})! Coalition total: {total}", + slingload_salvage_expired = "SALVAGE LOST: Crate {id} at {grid} deteriorated.", + slingload_salvage_damaged = "CAUTION: Salvage crate damaged in transit. Value reduced to {reward}pts.", + slingload_salvage_vectors = "Nearest salvage crate {id}: bearing {brg}°, range {rng} {rng_u}. Weight: {weight}kg, Value: {reward}pts.", + slingload_salvage_no_crates = "No active salvage crates available.", + slingload_salvage_zone_created = "Salvage Collection Zone '{zone}' created at your position (radius: {radius}m).", + slingload_salvage_zone_activated = "Salvage Collection Zone '{zone}' is now ACTIVE.", + slingload_salvage_zone_deactivated = "Salvage Collection Zone '{zone}' is now INACTIVE.", + slingload_salvage_warn_30min = "SALVAGE REMINDER: Crate {id} at {grid} expires in 30 minutes. Weight: {weight}kg.", + slingload_manual_crates_registered = "Registered {count} pre-placed salvage crate(s) from mission editor.", + + -- FARP System messages + farp_upgrade_started = "Upgrading FOB to FARP Stage {stage}... Building in progress.", + farp_upgrade_complete = "{player} upgraded FOB to FARP Stage {stage}! Services available: {services}", + farp_upgrade_insufficient_salvage = "Insufficient salvage to upgrade to FARP Stage {stage}. Need {need} points (have {current}). Deliver crews to MASH or sling-load salvage!", + farp_status = "FOB Status: FARP Stage {stage}/{max_stage}\nServices: {services}\nNext upgrade: {next_cost} salvage (Stage {next_stage})", + farp_status_maxed = "FOB Status: FARP Stage {stage}/{max_stage} (FULLY UPGRADED)\nServices: {services}", + farp_not_at_fob = "You must be near a FOB Pickup Zone to upgrade it to a FARP.", + farp_already_maxed = "This FOB is already at maximum FARP stage (Stage 3).", + farp_service_available = "FARP services available: Rearm, Refuel, Repair for ground vehicles and helicopters within {radius}m.", + slingload_salvage_warn_5min = "SALVAGE URGENT: Crate {id} at {grid} expires in 5 minutes!", + slingload_salvage_hooked_in_zone = "Salvage crate {id} is inside {zone}. Release the sling to complete delivery.", + slingload_salvage_wrong_zone = "Salvage crate {id} is sitting in {zone_type} zone {zone}. Take it to an active Salvage zone for credit.", + slingload_salvage_received_quips = { + "{player}: Leroy just whispered that's the smoothest receiving job he's seen since Jenkins tried the backwards hover.", + "Jenkins radios {player}, 'Keep receiving cargo like that and Mo might finally stop polishing his hook.'", + "Mo mutters, 'I thought we were receiving ammo, not a love letter, {player}.'", + "Leroy is yelling 'RECEIVE IT' like we're back at basic—nice work, {player}.", + "Jenkins keeps a scoreboard titled 'Most dramatic receiving'. Congrats, {player}, you're on top.", + "Mo claims he can hear the crate blush when {player} talks about receiving it.", + "Leroy swears the secret to receiving cargo is wink twice and yell 'Jenkins!'—apparently {player} nailed it.", + "Jenkins asked if {player} offers receiving lessons; I told him to practice on Mo's toolbox first.", + "Mo's diary entry tonight: '{player} received cargo and my rotor wash feelings.'", + "Leroy just gave {player} a 'World's Best Receiver' sticker; don't ask where he kept it.", + "Jenkins would like to know how {player} made receiving cargo look like interpretive dance.", + "Mo says the crate purred when {player} set it down. I blame Leroy's commentary.", + "Leroy keeps chanting 'receiver of the year' while pointing at {player}. Jenkins is jealous.", + "Jenkins tried to high five {player} mid-receive; Mo tackled him for safety reasons (probably).", + "Mo claims {player}'s receiving form violates at least three flirtation regulations.", + "Leroy just filed paperwork changing {player}'s callsign to 'Receiver Prime'.", + "Jenkins bet Mo twenty bucks {player} would blush while receiving. Pay up, Mo.", + "Mo suggests engraving {player}'s name on the salvage zone after that receive.", + "Leroy is teaching a receiving clinic now, curriculum: 'Be {player}'.", + "Jenkins whispered, 'Was it good for you, cargo?' right after {player} touched down.", + "Mo's clipboard has doodles of {player} receiving crates with heroic sparkles.", + "Leroy just threw fake rose petals over the zone as {player} finished receiving.", + "Jenkins swears he heard a saxophone during that receive. You owe the band, {player}.", + "Mo radioed, 'Easy on the receiving, {player}. Jenkins can't handle that much elegance.'", + "Leroy said {player}'s receiving style is 'spicy yet responsible'. I'm both impressed and confused.", + "Jenkins taped a 'Certified Receiver' badge on {player}'s dash. It's glittery. You're stuck with it.", + "Mo reckons {player} could receive a crate blindfolded; Leroy begged to test that theory.", + "Leroy keeps shouting 'stick the landing' but {player} already did—twice.", + "Jenkins requested that {player} narrate the next receiving so he can take notes.", + "Mo says if {player} receives one more crate like that, he'll start writing poetry.", + "Leroy just named this salvage drop 'Operation Receive and Believe' in {player}'s honor.", + "Jenkins said '{player} receives cargo like it's a surprise party and everyone's invited.'", + "Mo's new checklist: 1) Fuel 2) Ammo 3) Compliment {player}'s receiving technique.", + "Leroy tried to slow clap mid-receive; Jenkins confiscated his gloves.", + "Jenkins asked if {player} rehearsed that receive with mirrors. Mo said 'jealous much?'.", + "Mo beams, 'If receiving had judges, {player} just earned a 10 from Leroy and a dramatic sigh from Jenkins.'", + "Leroy told {player} to teach Jenkins how to receive without dropping his dignity.", + "Jenkins insists {player}'s receiving aura smells like jet fuel and confidence.", + "Mo logged today's weather as 'partly cloudy with a 100% chance of {player} receiving nicely.'", + "Leroy carved '{player} receives like a legend' into the ready room table. Again.", + "Jenkins wrote a haiku about that receiving: 'Crate softly descends / {player} grins / Mo steals the pen.'", + "Mo radioed 'next crate volunteers to be received by {player} only'. That's a policy now.", + "Leroy just asked supply for a 'Receiver of Renown' patch for {player}.", + "Jenkins claims he can feel the cargo sigh happily whenever {player} receives it.", + "Mo's schedule: 0900 watch {player} receive cargo, 0915 tease Jenkins about it.", + "Leroy calls {player}'s receiving style 'buttery smooth'. Jenkins now refuses to eat butter.", + "Jenkins left a sticky note on the crate: 'Thanks for receiving me gently, {player}.'", + "Mo wants to rename the salvage zone 'The {player} Receiving Lounge'.", + "Leroy drew hearts on the map where {player} usually receives cargo. Tactical? Maybe.", + "Jenkins petitioned command to play theme music every time {player} receives.", + "Mo refuses to stop chuckling about the way {player} said 'receiving' over comms.", + "Leroy says {player}'s receiving vibe is 'half heroics, half mischief'. Jenkins says 'all jealous'.", + "Jenkins asked if {player} could autograph the crate before sending it to depots.", + "Mo declared 'receiving is believing' after watching {player} today.", + "Leroy reenacting {player}'s receiving on the ramp is the finest comedy we've had all week.", + "Jenkins keeps practicing saying 'receiving' with the same swagger {player} has.", + "Mo whispers, 'Every time {player} receives cargo, a wrench somewhere falls in love.'", + "Leroy filed a noise complaint: 'Jenkins screaming while {player} receives is distracting.'", + "Jenkins now greets crates with 'prepare to be received by {player}—lucky you.'", + "Mo replaced the zone sign with 'Please announce yourself before {player} receives you.'", + "Leroy says the salvage crate winked at {player}. Jenkins is skeptical but also jealous.", + "Jenkins claims {player}'s receiving technique could calm a startled MANPAD crew.", + "Mo has a coffee mug that reads 'I watched {player} receive cargo and all I got was this latte.'", + "Leroy tried to choreograph a receiving dance for {player}; Jenkins tripped at step two.", + "Jenkins keeps bragging he called dibs on being {player}'s receiving hype man.", + "Mo scribbled 'receiving champion' under {player}'s name on the roster.", + "Leroy says {player}'s receiving voice has more bass than the AWACS channel.", + "Jenkins asked supply for velvet ropes to keep crowds away while {player} receives.", + "Mo's new ringtone is {player} saying 'receiving'. Leroy set it as his alarm too.", + "Leroy's advice to rookies: 'Just copy whatever {player} does when receiving.'", + "Jenkins says he needs sunglasses to watch {player} receive because it's that dazzling.", + "Mo keeps muttering 'receiving goals' every time {player} touches down in the zone.", + "Leroy left chalk arrows around the FARP labeled 'This way to watch {player} receive.'", + "Jenkins insists the crate asked for {player}'s comm frequency after that receive.", + "Mo bet the maintenance crew that {player} could receive cargo upside down. Please don't try it.", + "Leroy's latest call sign suggestion for {player}: 'Receiver Supreme'.", + "Jenkins laminated a card that says 'Ask me about {player}'s receiving technique.'", + "Mo says the wind socks lean toward {player} out of respect whenever receiving begins.", + "Leroy drew a cartoon of {player} receiving cargo while Jenkins fans them with a checklist.", + "Jenkins keeps replaying the cockpit tape of {player} saying 'receiving' on loop.", + "Mo told supply to send extra polish for the pad because {player} only receives on shiny decks.", + "Leroy swears he heard the crate whisper 'thanks, {player}' as it touched down.", + "Jenkins offers motivational speeches to crates before {player} arrives, just to set the mood.", + "Mo added 'compliment {player}'s receiving tone' to the preflight checklist.", + "Leroy refuses to call it unloading anymore; it's 'the {player} receiving ritual'.", + "Jenkins keeps practicing finger guns for when {player} calls 'receiving'.", + "Mo doodled {player} surfing on a crate labeled 'Receiving Champion'.", + "Leroy renamed the F10 menu option to 'Let {player} receive it'. QA is furious.", + "Jenkins says whenever {player} receives cargo, somewhere a sim pilot sheds a proud tear.", + "Mo now greets every crate with 'Ready to be received {player}-style?'.", + "Leroy told the intel guys to log {player}'s receiving pattern as a morale asset.", + "Jenkins claims {player}'s receiving aura smells like hydraulic fluid and heroics.", + "Mo keeps a chalkboard tally titled '{player}'s Receives vs Jenkins' Complaints'. Receives win.", + "Leroy is convinced {player}'s receiving callouts add five knots to every rotorcraft nearby.", + "Jenkins practices saying 'nice receiving' in the mirror so he doesn't squeak on comms.", + "Mo decorated the salvage zone with disco lights for the next time {player} receives.", + "Leroy asked PAO for a documentary called 'Receiving with {player}'.", + "Jenkins swears {player}'s receiving cadence syncs perfectly with the base siren. Spooky.", + "Mo now ends every debrief with 'and that's how {player} receives cargo, folks.'", + "Leroy wrote a limerick about {player} receiving; Jenkins begged him not to recite it.", + "Jenkins told the new guy 'if {player} says receiving, salute the crate.'", + "Mo keeps drawing diagrams titled 'Ideal Receiving Cones' with {player} stick figures.", + "Leroy asked meteorology for a forecast of '{player} receiving with 100% sass'.", + "Jenkins says hearing {player} say 'receiving' cured his fear of autorotations.", + "Mo replaced the salvage beacon tone with {player} humming the receiving tune.", + "Leroy told Jenkins, 'If you can't receive like {player}, at least clap rhythmically.'", + "Jenkins is workshopping a catchphrase: '{player} receives, Mo believes, Leroy deceives.'", + "Mo says the only thing smoother than {player}'s receiving is Leroy's questionable pickup lines.", + "Leroy wants to build a statue of {player} holding a crate; Jenkins suggested maybe start with a sticker.", + "Jenkins insists {player}'s receiving voice lowers enemy morale by 10%. Science-ish.", + "Mo has a bingo card of {player}'s receiving compliments; he's already got blackout.", + "Leroy now ends every briefing with 'remember, let {player} receive first'.", + "Jenkins swears his headset auto-tunes whenever {player} says 'receiving'.", + "Mo just said, 'If receiving had medals, {player} would need a bigger flight suit.'", + "Leroy warns crates, 'If {player} receives you, expect fireworks and Jenkins squealing.'", + "Jenkins' new mantra: 'Breathe in rotor wash, breathe out {player}'s receiving tips.'", + "Mo says {player}'s receiving swagger should be issued with the flight manual.", + "Leroy doodled {player} riding a crate labeled 'Receiving Express'—Mo framed it.", + "Jenkins told logistics, 'Mark that crate FRAGILE, {player} receives with feelings.'", + "Mo now pronounces 'receiving' with extra syllables whenever {player} is on station.", + "Leroy thinks {player}'s receiving just added three morale points to the whole AO.", + "Jenkins said he'd write a sonnet about {player}'s receiving if he knew what a sonnet was.", + "Mo just asked if we could get {player} to receive the mail too, because wow.", + "Leroy polished the landing zone so {player}'s next receiving feels fancy.", + "Jenkins is holding up scorecards every time {player} says 'receiving'. The numbers keep climbing.", + "Mo claims {player}'s receiving is now a controlled substance in three states.", + "Leroy told everyone to clear the pad—'{player} needs room to receive with flair!'.", + "Jenkins thinks {player}'s receiving tone should replace the standard 'cargo secure' call.", + "Mo concluded today's briefing with 'Step 1: Let {player} receive. Step 2: Profit.'", + "Leroy keeps whispering 'look how {player} receives' like it's a wildlife documentary.", + "Jenkins renamed his playlist 'Songs to Receive Cargo Like {player}'.", + "Mo's checklist scribble says 'Remember to compliment {player} after receiving'.", + "Leroy just added 'receiving swagger' to the SOP because of {player}.", + "Jenkins insists {player}'s receiving posture cured his back pain. Untested claim.", + "Mo now uses '{player}-level receiving' as a unit of measurement for excellence.", + "Leroy stuck a note on the crate: 'You were received by {player}. You're welcome.'", + "Jenkins laughed so hard at {player}'s receiving banter that he nearly dropped his clipboard.", + "Mo just declared {player} the patron saint of receiving, halo made of tie-down straps.", + "Leroy vows to shout 'RECEIVE IT' every time {player} descends, purely for morale.", + "Jenkins says {player}'s receiving swagger should be bottled and issued to recruits.", + }, + medevac_unload_aborted = "MEDEVAC: Unload aborted - {reason}. Land and hold for {seconds} seconds.", + + -- Mobile MASH messages + medevac_mash_deployed = "Mobile MASH {mash_id} deployed at {grid}. Beacon: {freq}. Delivering MEDEVAC crews here earns salvage points.", + medevac_mash_announcement = "Mobile MASH {mash_id} available at {grid}. Beacon: {freq}.", + medevac_mash_destroyed = "Mobile MASH {mash_id} destroyed! No longer accepting deliveries.", + mash_announcement = "MASH {name} operational at {grid}. Accepting MEDEVAC deliveries for salvage credit. Monitoring {freq} AM.", + mash_vectors = "Nearest MASH: {name} at bearing {brg}°, range {rng} {rng_u}.", + mash_no_zones = "No MASH zones available.", +} + +-- #endregion Messaging + +CTLD.Config = { + -- === Instance & Access === + CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side) + CountryId = nil, -- optional explicit country id for spawned groups; falls back per coalition + AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB) + 'UH-1H','Mi-8MTV2','Mi-24P','SA342M','SA342L','SA342Minigun','Ka-50','Ka-50_3','AH-64D_BLK_II','UH-60L','CH-47Fbl1','CH-47F','Mi-17','GazelleAI' + }, + -- === Runtime & Messaging === + -- Logging control: set the desired level of detail for env.info logging to DCS.log + -- 0 = NONE - No logging at all (production servers) + -- 1 = ERROR - Only critical errors and warnings + -- 2 = INFO - Important state changes, initialization, cleanup (default for production) + -- 3 = VERBOSE - Detailed operational info (zone validation, menus, builds, MEDEVAC events) + -- 4 = DEBUG - Everything including hover checks, crate pickups, detailed troop spawns + LogLevel = 4, -- lowered from DEBUG (4) to INFO (2) for production performance + MessageDuration = 15, -- seconds for on-screen messages + + -- Debug toggles for detailed crate proximity logging (useful when tuning hover coach / ground autoload) + DebugHoverCrates = false, + DebugHoverCratesInterval = 1.0, -- seconds between hover debug log bursts (per aircraft) + DebugHoverCratesStep = 25, -- log again when nearest crate distance changes by this many meters + DebugGroundCrates = false, + DebugGroundCratesInterval = 2.0, -- seconds between ground debug log bursts (per aircraft) + DebugGroundCratesStep = 10, -- log again when nearest crate distance changes by this many meters + + -- === Menu & Catalog === + UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide (leave this alone) + CreateMenuAtMissionStart = false, -- creates empty root menu at mission start to reserve F10 position (populated on player spawn) + RootMenuName = 'CTLD', -- name for the root F10 menu; menu ordering depends on script load order in mission editor + UseCategorySubmenus = true, -- if true, organize crate requests by category submenu (menuCategory) + UseBuiltinCatalog = false, -- start with the shipped catalog (true) or expect mission to load its own (false) + + -- === Transport Capacity === + -- Default capacities for aircraft not listed in AircraftCapacities table + -- Used as fallback for any transport aircraft without specific limits defined + DefaultCapacity = { + maxCrates = 4, -- reasonable middle ground + maxTroops = 12, -- moderate squad size + maxWeightKg = 2000, -- default weight capacity in kg (omit to disable weight modeling) + }, + + -- Per-aircraft capacity limits (realistic cargo/troop capacities) + -- Set maxCrates = 0 and maxTroops = 0 for attack helicopters with no cargo capability + -- If an aircraft type is not listed here, it will use DefaultCapacity values + -- maxWeightKg: optional weight capacity in kilograms (if omitted, only count limits apply) + -- requireGround: optional override for ground requirement (true = must land, false = can load in hover/flight) + -- maxGroundSpeed: optional override for max ground speed during loading (m/s) + AircraftCapacities = { + -- Small/Light Helicopters (very limited capacity) + ['SA342M'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, -- Gazelle - tiny observation/scout helo + ['SA342L'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, + ['SA342Minigun'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, + ['GazelleAI'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, + + -- Attack Helicopters (no cargo capacity - combat only) + ['Ka-50'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark - single seat attack + ['Ka-50_3'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark 3 + ['AH-64D_BLK_II'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Apache - attack/recon only + ['Mi-24P'] = { maxCrates = 2, maxTroops = 8, maxWeightKg = 1000 }, -- Hind - attack helo but has small troop bay + + -- Light Utility Helicopters (moderate capacity) + ['UH-1H'] = { maxCrates = 3, maxTroops = 11, maxWeightKg = 1800 }, -- Huey - classic light transport + + -- Medium Transport Helicopters (good capacity) + ['Mi-8MTV2'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip - Russian medium transport + ['Mi-17'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip variant + ['UH-60L'] = { maxCrates = 4, maxTroops = 11, maxWeightKg = 4000 }, -- Black Hawk - medium utility + + -- Heavy Lift Helicopters (maximum capacity) + ['CH-47Fbl1'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook - heavy lift beast + ['CH-47F'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook variant + + -- Fixed Wing Transport (limited capacity) + -- NOTE: C-130 has requireGround configurable - set to false if you want to allow in-flight loading (unrealistic but flexible) + ['C-130'] = { maxCrates = 20, maxTroops = 92, maxWeightKg = 20000, requireGround = true, maxGroundSpeed = 1.0 }, -- C-130 Hercules - tactical airlifter (must be fully stopped) + ['C-17A'] = { maxCrates = 30, maxTroops = 150, maxWeightKg = 77500, requireGround = true, maxGroundSpeed = 1.0 }, -- C-17 Globemaster III - strategic airlifter + }, + + -- === Loading & Deployment Rules === + RequireGroundForTroopLoad = true, -- must be landed to load troops (prevents loading while hovering) + RequireGroundForVehicleLoad = true, -- must be landed to load vehicles (C-130/large transports) + MaxGroundSpeedForLoading = 2.0, -- meters/second limit while loading (roughly 4 knots) + + -- Fast-rope deployment (allows troop unload while hovering at safe altitude) + EnableFastRope = true, -- if true, troops can fast-rope from hovering helicopters + FastRopeMaxHeight = 20, -- meters AGL: maximum altitude for fast-rope deployment + FastRopeMinHeight = 5, -- meters AGL: minimum altitude for fast-rope deployment (too low = collision risk) + + -- Safety offsets to avoid spawning units too close to player aircraft + BuildSpawnOffset = 40, -- meters: shift build point forward from the aircraft (0 = spawn centered on aircraft) + TroopSpawnOffset = 25, -- meters: shift troop unload point forward from the aircraft + DropCrateForwardOffset = 35, -- meters: drop loaded crates this far in front of the aircraft + + -- === Build & Crate Handling === + BuildRequiresGroundCrates = true, -- required crates must be on the ground (not still carried) + BuildRadius = 100, -- meters around build point to collect crates + BuildDispersionRadius = 30, -- meters: randomize spawn positions within this radius (Build All mode only; 0 = disable) + RestrictFOBToZones = false, -- only allow FOB recipes inside configured FOBZones + AutoBuildFOBInZones = false, -- auto-build FOB recipes when required crates are inside a FOB zone + CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable + + -- Build safety + BuildConfirmEnabled = false, -- require a second confirmation within a short window before building + BuildConfirmWindowSeconds = 30, -- seconds allowed between first and second "Build Here" press + BuildCooldownEnabled = true, -- impose a cooldown before allowing another build by the same group + BuildCooldownSeconds = 0, -- seconds of cooldown after a successful build per group + + -- === Pickup & Drop Zone Rules === + RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone + RequirePickupZoneForTroopLoad = true, -- troops can only be loaded while inside a Supply (Pickup) Zone + PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request + ForbidDropsInsidePickupZones = true, -- block crate drops while inside a Pickup Zone + ForbidTroopDeployInsidePickupZones = true, -- block troop deploy while inside a Pickup Zone + ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; false blocks all configured pickup zones + + -- Dynamic Drop Zone settings + DropZoneRadius = 500, -- meters: radius used when creating a Drop Zone via the admin menu at player position + MinDropZoneDistanceFromPickup = 2000, -- meters: minimum distance from nearest Pickup Zone required to create a dynamic Drop Zone (0 to disable) + MinDropDistanceActivePickupOnly = true, -- when true, only ACTIVE pickup zones are considered for the minimum distance check + + -- === Pickup Zone Spawn Placement === + PickupZoneSpawnRandomize = true, -- spawn crates at a random point within the pickup zone (avoids stacking) + PickupZoneSpawnEdgeBuffer = 20, -- meters: keep spawns at least this far inside the zone edge + PickupZoneSpawnMinOffset = 75, -- meters: keep spawns at least this far from the exact center + CrateSpawnMinSeparation = 7, -- meters: try not to place a new crate closer than this to an existing one + CrateSpawnSeparationTries = 6, -- attempts to find a non-overlapping position before accepting best effort + CrateClusterSpacing = 8, -- meters: spacing used when clustering crates within a bundle + PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones + + -- Crate Smoke Settings + -- NOTE: Individual smoke effects last ~5 minutes (DCS hardcoded, cannot be changed) + -- These settings control whether/how often NEW smoke is spawned, not how long each smoke lasts + CrateSmoke = { + Enabled = true, -- spawn smoke when crates are created; if false, no smoke at all + AutoRefresh = false, -- automatically spawn new smoke every RefreshInterval seconds + RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true) + MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit) + OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke + OffsetRandom = true, -- if true, randomize horizontal offset direction; if false, always offset north + OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible) + }, + + -- === Autonomous Assets === + -- Air-spawn settings for CTLD-built drones (AIRPLANE catalog entries like MQ-9 / WingLoong) + DroneAirSpawn = { + Enabled = true, -- when true, AIRPLANE catalog items that opt-in can spawn in the air at a set altitude + AltitudeMeters = 5000, -- default spawn altitude ASL (meters) + SpeedMps = 120 -- default initial speed in m/s + }, + + JTAC = { + Enabled = true, + Verbose = false, -- when true, emit detailed JTAC registration & target scan logs + AutoLase = { + Enabled = true, + SearchRadius = 8000, -- meters to scan for enemy targets + RefreshSeconds = 15, -- seconds between active target updates + IdleRescanSeconds = 30, -- seconds between scans when no target locked + LostRetrySeconds = 10, -- wait before trying to reacquire after transport/line-of-sight loss + TransportHoldSeconds = 10, -- defer lase loop while JTAC is in transport (group empty) + }, + Smoke = { + Enabled = true, + ColorBlue = trigger.smokeColor.Orange, + ColorRed = trigger.smokeColor.Green, + RefreshSeconds = 300, -- seconds between smoke refreshes on active targets + OffsetMeters = 5, -- random offset radius for smoke placement + }, + LaserCodes = { '1688','1677','1666','1113','1115','1111' }, + LockType = 'all', -- 'all' | 'vehicle' | 'troop' + Announcements = { + Enabled = true, + Duration = 15, + }, + + }, + + -- === Combat Automation === + AttackAI = { + Enabled = true, -- master switch for attack behavior + TroopSearchRadius = 6000, -- meters: when deploying troops with Attack, search radius for targets/bases + VehicleSearchRadius = 12000, -- meters: when building vehicles with Attack, search radius + PrioritizeEnemyBases = true, -- if true, prefer enemy-held bases over ground units when both are in range + -- Smart omniscient targeting: when true, LOS / DCS detection quirks are ignored for target *selection*. + -- The script will always pick the nearest valid enemy/base within the configured radius and order a move + -- toward it. DCS AI LOS still governs when units can actually fire once they get there. + SmartTargeting = true, + TroopAdvanceSpeedKmh = 20, -- movement speed for troops when ordered to attack + VehicleAdvanceSpeedKmh = 35, -- movement speed for vehicles when ordered to attack + }, + + -- === Visual Aids === + -- Optional: draw zones on the F10 map using trigger.action.* markup (ME Draw-like) + MapDraw = { + Enabled = true, -- master switch for any map drawings created by this script + DrawPickupZones = true, -- draw Pickup/Supply zones as shaded circles with labels + DrawDropZones = true, -- optionally draw Drop zones + DrawFOBZones = true, -- optionally draw FOB zones + DrawMASHZones = true, -- optionally draw MASH (medical) zones + DrawSalvageZones = true, -- optionally draw Salvage Collection zones + FontSize = 18, -- label text size + ReadOnly = true, -- prevent clients from removing the shapes + ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing) + OutlineColor = {1, 1, 0, 0.85}, -- RGBA 0..1 for outlines (bright yellow) + -- Optional per-kind fill overrides + FillColors = { + Pickup = {0, 1, 0, 0.15}, -- light green fill for Pickup zones + Drop = {0, 0, 0, 0.25}, -- black fill for Drop zones + FOB = {1, 1, 0, 0.15}, -- yellow fill for FOB zones + MASH = {1, 0.75, 0.8, 0.25}, -- pink fill for MASH zones + SalvageDrop = {1, 0, 1, 0.15}, -- magenta fill for Salvage zones + }, + LineType = 1, -- default line type if per-kind is not set (0 None, 1 Solid, 2 Dashed, 3 Dotted, 4 DotDash, 5 LongDash, 6 TwoDash) + LineTypes = { -- override border style per zone kind + Pickup = 3, -- dotted + Drop = 2, -- dashed + FOB = 4, -- dot-dash + MASH = 1, -- solid + SalvageDrop = 2, -- dashed + }, + -- Label placement tuning (simple): + -- Effective extra offset from the circle edge = r * LabelOffsetRatio + LabelOffsetFromEdge + LabelOffsetFromEdge = -50, -- meters beyond the zone radius to place the label (12 o'clock) + LabelOffsetRatio = 0.5, -- fraction of the radius to add to the offset (e.g., 0.1 => +10% of r) + LabelOffsetX = 200, -- meters: horizontal nudge; adjust if text appears left-anchored in your DCS build + -- Per-kind label prefixes + LabelPrefixes = { + Pickup = 'Supply', + Drop = 'Drop', + FOB = 'FOB', + MASH = 'MASH', + SalvageDrop = 'Salvage', + } + }, + + -- === Inventory & Troops === + -- Inventory system (per pickup zone and FOBs) + Inventory = { + Enabled = true, -- master switch for per-location stock control + FOBStockFactor = 0.50, -- starting stock at newly built FOBs relative to pickup-zone initialStock + ShowStockInMenu = true, -- append simple stock hints to menu labels (per current nearest zone) + HideZeroStockMenu = false, -- removed: previously created an "In Stock Here" submenu; now disabled by default + }, + + -- Troop type presets (menu-driven loadable teams) + Troops = { + DefaultType = 'AS', -- default troop type to use when no specific type is chosen + -- Team definitions: loaded from catalog via _CTLD_TROOP_TYPES global + -- If no catalog is loaded, empty table is used (and fallback logic applies) + TroopTypes = {}, + }, + + -- === Zone Tables === + -- Mission makers should populate these arrays with zone definitions + -- Each zone entry can be: { name = 'ZoneName' } or { name = 'ZoneName', flag = 9001, activeWhen = 0, smoke = color, radius = meters } + Zones = { + PickupZones = {}, -- Supply zones where crates/troops can be requested + DropZones = {}, -- Optional Drop/AO zones + FOBZones = {}, -- FOB zones (restrict FOB building to these if RestrictFOBToZones = true) + MASHZones = {}, -- Medical zones for MEDEVAC crew delivery (MASH = Mobile Army Surgical Hospital) + SalvageDropZones = {}, -- Salvage collection zones for sling-load salvage delivery + }, + + -- === Sling-Load Salvage System === + -- Spawn salvageable crates when enemy units are destroyed; deliver to collection zones for rewards + SlingLoadSalvage = { + Enabled = true, + + -- Manual salvage crates (pre-placed in mission editor) + EnableManualCrates = true, -- Scan for and register pre-placed cargo statics as salvage + ManualCratePrefix = 'SALVAGE-', -- Only cargo statics starting with this prefix are registered + + -- Spawn probability when enemy ground units die + SpawnChance = { + [coalition.side.BLUE] = 0.20, -- 20% chance when BLUE unit dies (RED can collect the salvage) + [coalition.side.RED] = 0.20, -- 20% chance when RED unit dies (BLUE can collect the salvage) + }, + + -- Weight classes with spawn probabilities and reward rates + WeightClasses = { + { name = 'Light', min = 500, max = 1000, probability = 0.50, rewardPer500kg = 0.5 }, -- 1-2 pts (reduced from 2) + { name = 'Medium', min = 2501, max = 5000, probability = 0.30, rewardPer500kg = 1 }, -- 5-10 pts (reduced from 3) + { name = 'Heavy', min = 5001, max = 8000, probability = 0.15, rewardPer500kg = 1.5 }, -- 15-24 pts (reduced from 5) + { name = 'SuperHeavy', min = 8001, max = 12000, probability = 0.05, rewardPer500kg = 2 }, -- 32-48 pts (reduced from 8) + }, + + -- Condition-based reward multipliers (based on crate health when delivered) + ConditionMultipliers = { + Undamaged = 1.5, -- >= 90% health + Damaged = 1.0, -- 50-89% health + HeavyDamage = 0.5, -- < 50% health + }, + + CrateLifetime = 3600, -- 1 hour (seconds) + WarningTimes = { 1800, 300 }, -- Warn at 30min and 5min remaining + + -- Visual indicators + SpawnSmoke = false, + SmokeDuration = 120, -- 2 minutes + SmokeColor = trigger.smokeColor.Orange, + MaxActiveCrates = 40, -- hard cap on simultaneously spawned salvage crates per coalition + AdaptiveIntervals = { idle = 10, low = 20, medium = 25, high = 30 }, + + -- Spawn restrictions + MinSpawnDistance = 25, -- meters from death location + MaxSpawnDistance = 45, -- meters from death location + NoSpawnNearPickupZones = true, + NoSpawnNearPickupZoneDistance = 1000, -- meters + NoSpawnNearAirbasesKm = 1, + + DetectionInterval = 5, -- seconds between salvage zone checks + + -- Cargo static types (DCS sling-loadable cargo) + CargoTypes = { + 'container_cargo', + 'ammo_cargo', + 'fueltank_cargo', + 'barrels_cargo', + 'uh1h_cargo', + 'pipes_small_cargo', + 'pipes_big_cargo', + 'tetrapod_cargo', + 'trunks_small_cargo', + 'trunks_long_cargo', + 'oiltank_cargo', + 'f_bar_cargo', + 'm117_cargo', + }, + + -- Salvage Collection Zone defaults + DefaultZoneRadius = 300, + DynamicZoneLifetime = 5400, -- seconds a player-created zone stays active (0 disables auto-expiry) + MaxDynamicZones = 6, -- cap player-created zones per coalition instance (oldest retire first) + ZoneColors = { + border = {1, 0.5, 0, 0.85}, -- orange border + fill = {1, 0.5, 0, 0.15}, -- light orange fill + }, + }, +} + -- #endregion Config + +-- ========================= +-- FARP System Configuration +-- ========================= +-- Progressive FOB->FARP upgrade system with static object layouts +CTLD.FARPConfig = { + Enabled = true, + + -- Salvage costs for each stage upgrade + StageCosts = { + [1] = 3, -- FOB -> Stage 1 FARP (basic pad) + [2] = 5, -- Stage 1 -> Stage 2 (operational fuel) + [3] = 8, -- Stage 2 -> Stage 3 (full forward airbase) + }, + + -- Service zone radius for rearm/refuel at each stage + ServiceRadius = { + [1] = 50, -- Stage 1: basic pad only + [2] = 65, -- Stage 2: fuel operations + [3] = 80, -- Stage 3: full services + }, + + -- Static object layouts for each FARP stage + -- Format: { type = "DCS_Static_Name", x = offset_x, z = offset_z, heading = degrees, height = 0 } + -- Positions are relative to FOB center point + StageLayouts = { + -- Stage 1: Basic FARP Pad (3 salvage) + [1] = { + { type = "FARP CP Blindage", x = 0, z = 25, heading = 180 }, + { type = "FARP Tent", x = 17.3, z = 10, heading = 240 }, + { type = "FARP Tent", x = -17.3, z = 10, heading = 120 }, + { type = "container_20ft", x = 15.4, z = -6.2, heading = 90 }, + { type = "container_20ft", x = -15.4, z = -6.2, heading = 90 }, + { type = "Windsock", x = 0, z = 30, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = 13, z = -10.6, heading = 30 }, + { type = "FARP Ammo Dump Coating", x = -13, z = -10.6, heading = 330 }, + { type = ".Ammunition depot", x = 17, z = 12, heading = 0 }, + { type = ".Ammunition depot", x = -17, z = 12, heading = 0 }, + { type = "BarrelCargo", x = 8, z = -18, heading = 0 }, + { type = "BarrelCargo", x = -8, z = -18, heading = 0 }, + { type = "BarrelCargo", x = 12, z = 20, heading = 0 }, + { type = "BarrelCargo", x = -12, z = 20, heading = 0 }, + { type = "GeneratorF", x = 22, z = -3, heading = 270 }, + { type = "Sandbox", x = 10, z = -16, heading = 0 }, + { type = "Sandbox", x = -10, z = -16, heading = 0 }, + { type = "Sandbox", x = 10, z = 16, heading = 0 }, + { type = "Sandbox", x = -10, z = 16, heading = 0 }, + { type = "Sandbox", x = 18, z = 0, heading = 0 }, + { type = "Sandbox", x = -18, z = 0, heading = 0 }, + }, + + -- Stage 2: Operational FARP - adds fuel capability (5 more salvage) + [2] = { + { type = "M978 HEMTT Tanker", x = 35, z = 0, heading = 270 }, + { type = "M978 HEMTT Tanker", x = -35, z = 0, heading = 90 }, + { type = "FARP Fuel Depot", x = 40, z = -8, heading = 0 }, + { type = "FARP Fuel Depot", x = -40, z = -8, heading = 0 }, + { type = "FARP Tent", x = 26.5, z = 21.7, heading = 210 }, + { type = "FARP Tent", x = -26.5, z = 21.7, heading = 150 }, + { type = "container_40ft", x = 0, z = -35, heading = 0 }, + { type = "container_40ft", x = 8, z = -35, heading = 0 }, + { type = "Hesco_wallperimeter_7", x = 0, z = 42, heading = 0 }, + { type = "Hesco_wallperimeter_7", x = 30, z = 30, heading = 315 }, + { type = "Hesco_wallperimeter_7", x = -30, z = 30, heading = 45 }, + { type = "Red_Flag", x = 0, z = 45, heading = 0 }, + { type = "Red_Flag", x = 45, z = 0, heading = 0 }, + { type = "Red_Flag", x = -45, z = 0, heading = 0 }, + { type = "Red_Flag", x = 0, z = -45, heading = 0 }, + { type = "Ural-375 PBU", x = 32.9, z = -13.1, heading = 225 }, + { type = "Electric power box", x = 28, z = 20, heading = 0 }, + { type = "Electric power box", x = -28, z = 20, heading = 0 }, + { type = "Landmine pot", x = 36, z = 8, heading = 0 }, + { type = "Landmine pot", x = -36, z = 8, heading = 0 }, + { type = "Landmine pot", x = 30, z = -25, heading = 0 }, + { type = "Landmine pot", x = -30, z = -25, heading = 0 }, + { type = "Landmine pot", x = 20, z = 30, heading = 0 }, + { type = "Landmine pot", x = -20, z = 30, heading = 0 }, + { type = "Tetrapod", x = 42, z = 15, heading = 0 }, + { type = "Tetrapod", x = -42, z = 15, heading = 0 }, + { type = "Tetrapod", x = 42, z = -15, heading = 0 }, + { type = "Tetrapod", x = -42, z = -15, heading = 0 }, + { type = "Tetrapod", x = 15, z = 42, heading = 0 }, + { type = "Tetrapod", x = -15, z = 42, heading = 0 }, + { type = "Tetrapod", x = 15, z = -42, heading = 0 }, + { type = "Tetrapod", x = -15, z = -42, heading = 0 }, + { type = "FARP Command Post", x = 0, z = 25, heading = 180 }, + }, + + -- Stage 3: Full Forward Airbase - adds ammo and comms (8 more salvage) + [3] = { + { type = "M939 Heavy", x = 38.9, z = -21.9, heading = 225 }, + { type = "M939 Heavy", x = -38.9, z = -21.9, heading = 135 }, + { type = "Shelter", x = 0, z = -50, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = 48, z = -10, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = -48, z = -10, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = 45, z = -20, heading = 0 }, + { type = "FARP Ammo Dump Coating", x = -45, z = -20, heading = 0 }, + { type = "SKP-11", x = 0, z = 55, heading = 180 }, + { type = "ZiL-131 APA-80", x = 8, z = 52, heading = 180 }, + { type = "Hesco_wallperimeter_1", x = 52, z = 30, heading = 0 }, + { type = "Hesco_wallperimeter_1", x = -52, z = 30, heading = 0 }, + { type = "Hesco_wallperimeter_1", x = 52, z = -30, heading = 0 }, + { type = "Hesco_wallperimeter_1", x = -52, z = -30, heading = 0 }, + { type = "Hesco_wallperimeter_1", x = 30, z = 52, heading = 90 }, + { type = "Hesco_wallperimeter_1", x = -30, z = 52, heading = 90 }, + { type = "Hesco_wallperimeter_1", x = 30, z = -52, heading = 90 }, + { type = "Hesco_wallperimeter_1", x = -30, z = -52, heading = 90 }, + { type = "Hesco_wallperimeter_1", x = 45, z = 40, heading = 45 }, + { type = "Hesco_wallperimeter_1", x = -45, z = 40, heading = 315 }, + { type = "Hesco_wallperimeter_1", x = 45, z = -40, heading = 135 }, + { type = "Hesco_wallperimeter_1", x = -45, z = -40, heading = 225 }, + { type = "FARP Tent", x = 52, z = 0, heading = 270 }, + { type = "FARP Tent", x = -52, z = 0, heading = 90 }, + { type = "FARP Tent", x = 36.8, z = 36.8, heading = 225 }, + { type = "container_20ft", x = -30, z = -38, heading = 45 }, + { type = "container_20ft", x = -35, z = -33, heading = 45 }, + { type = "container_20ft", x = -25, z = -43, heading = 45 }, + { type = "container_20ft", x = -33, z = -45, heading = 135 }, + { type = "GeneratorF", x = 43.1, z = -31.4, heading = 225 }, + { type = "UAZ-469", x = 12, z = 48, heading = 200 }, + { type = "UAZ-469", x = 16, z = 50, heading = 170 }, + { type = "Ural-375", x = -25, z = -35, heading = 45 }, + { type = "Landmine pot", x = 50, z = 15, heading = 0 }, + { type = "Landmine pot", x = -50, z = 15, heading = 0 }, + { type = "Landmine pot", x = 50, z = -15, heading = 0 }, + { type = "Landmine pot", x = -50, z = -15, heading = 0 }, + { type = "Landmine pot", x = 15, z = 50, heading = 0 }, + { type = "Landmine pot", x = -15, z = 50, heading = 0 }, + { type = "Landmine pot", x = 40, z = 30, heading = 0 }, + { type = "Landmine pot", x = -40, z = 30, heading = 0 }, + { type = "Landmine pot", x = 30, z = -40, heading = 0 }, + { type = "Landmine pot", x = -30, z = -40, heading = 0 }, + { type = "Landmine pot", x = 45, z = 25, heading = 0 }, + { type = "Landmine pot", x = -45, z = 25, heading = 0 }, + { type = "billboard_motorized rifle troops", x = 0, z = -58, heading = 0 }, + { type = "Sandbox", x = 38, z = -8, heading = 0 }, + { type = "Sandbox", x = -38, z = -8, heading = 0 }, + { type = "Sandbox", x = 38, z = 8, heading = 0 }, + { type = "Sandbox", x = -38, z = 8, heading = 0 }, + { type = "Black_Tyre", x = 20, z = -48, heading = 0 }, + { type = "Black_Tyre", x = -20, z = -48, heading = 0 }, + { type = "Black_Tyre", x = 24, z = -46, heading = 0 }, + { type = "Black_Tyre", x = -24, z = -46, heading = 0 }, + { type = "Black_Tyre", x = 28, z = -44, heading = 0 }, + { type = "Black_Tyre", x = -28, z = -44, heading = 0 }, + { type = "Black_Tyre", x = 48, z = 20, heading = 0 }, + { type = "Black_Tyre", x = -48, z = 20, heading = 0 }, + { type = "WatchTower", x = -38.9, z = 38.9, heading = 225 }, + { type = "warning_board_c", x = 0, z = 48, heading = 180 }, + { type = "warning_board_c", x = 48, z = 0, heading = 270 }, + { type = "warning_board_c", x = -48, z = 0, heading = 90 }, + { type = "warning_board_c", x = 35, z = -35, heading = 45 }, + { type = "warning_board_c", x = -35, z = -35, heading = 315 }, + { type = "warning_board_c", x = 35, z = 35, heading = 225 }, + }, + }, +} + +-- Immersive Hover Coach configuration (messages, thresholds, throttling) +-- All user-facing text lives here; logic only fills placeholders. +CTLD.HoverCoachConfig = { + enabled = true, -- master switch for hover coaching feature + coachOnByDefault = true, -- per-player default; players can toggle via F10 > Navigation > Hover Coach + + -- Pickup parameters + maxCratesPerLoad = 6, -- maximum crates the aircraft can carry simultaneously + autoPickupDistance = 25, -- meters max search distance for candidate crates + + thresholds = { + arrivalDist = 1000, -- m: start guidance "You're close…" + closeDist = 100, -- m: reduce speed / set AGL guidance + precisionDist = 8, -- m: start precision hints + captureHoriz = 10, -- m: horizontal sweet spot radius + captureVert = 10, -- m: vertical sweet spot tolerance around AGL window + aglMin = 5, -- m: hover window min AGL + aglMax = 20, -- m: hover window max AGL + maxGS = 8/3.6, -- m/s: 8 km/h for precision, used for errors + captureGS = 4/3.6, -- m/s: 4 km/h capture requirement + maxVS = 2.0, -- m/s: absolute vertical speed during capture + driftResetDist = 13, -- m: if beyond, reset precision phase + stabilityHold = 2.0 -- s: hold steady before loading + }, + + throttle = { + coachUpdate = 3.0, -- s between hint updates in precision + generic = 3.0, -- s between non-coach messages + repeatSame = 6.0 -- s before repeating same message key + }, +} + +-- ========================= +-- Ground Auto-Load Configuration +-- ========================= +-- Automatic crate loading while landed (for pilots who prefer not to hover) +CTLD.GroundAutoLoadConfig = { + Enabled = true, -- master switch for ground auto-load feature + LoadDelay = 25, -- seconds to hold position on ground before auto-loading + GroundContactAGL = 3.5, -- meters AGL considered "on the ground" (matches MEDEVAC) + MaxGroundSpeed = 2.0, -- m/s maximum ground speed during loading (~4 knots) + SearchRadius = 35, -- meters to search for nearby crates + AbortGrace = 2, -- seconds of movement/liftoff tolerated before aborting + RequirePickupZone = true, -- MUST be inside a pickup zone to auto-load (prevents drop/re-pickup loops) + AllowInFOBZones = true, -- also allow auto-load in FOB zones (once built) +} + +CTLD.GroundLoadComms = { + ProgressInterval = 5, + Start = { + "Loadmaster: Copy {count} crate(s). Give us {seconds}s to round up the rollers.", + "Ramp boss says {seconds}s and L. Jenkins will have those {count} crate(s) chained down.", + "Crew chief: {count} crate(s) inbound—stay planted for {seconds}s.", + "Forklift mafia deputized Leroy to kidnap {count} crate(s) in {seconds}s.", + "Ground crew brewing a plan: {count} crate(s) in {seconds}s.", + "Cargo gnomes awake—{seconds}s to wrangle {count} crate(s).", + "Engineers counting {count} crate(s); set a timer for {seconds}s.", + "Deck boss: {seconds}s of zen before {count} crate(s) clack aboard.", + "Log cell crunches numbers: {count} crate(s) move in {seconds}s.", + "Supply sergeant wants {seconds}s to line up {count} crate(s).", + "Palettes staged—{count} crate(s) climbing aboard after {seconds}s.", + "Ramp trolls wave: {seconds}s pause, {count} crate(s) prize.", + "Hook teams prepping—{count} crate(s) latched in {seconds}s.", + "Handler: keep rotors calm for {seconds}s; {count} crate(s) en route.", + "Deck boss hums while Jenkins L salts {count} crate(s) in {seconds}s.", + "Logistics whisperer: {seconds}s to sweet-talk {count} crate(s).", + "Crate wrangler: {count} boxen saddled after {seconds}s.", + "Crew phones mom: {count} crate(s) adopt you in {seconds}s.", + "Load team stretching—{seconds}s till {count} crate(s) leap aboard.", + "Forklift rave: {count} crate(s) crowd-surfing in {seconds}s.", + "Ammo guys promise {seconds}s to leash {count} crate(s).", + "Supply goblins: {count} crate(s) conjured after {seconds}s.", + "Winch crew rolling cables—{seconds}s countdown for {count} crate(s).", + "Deck judge bangs gavel: {count} crate(s) filed in {seconds}s.", + "Ramp DJ cues track—{seconds}s jam for {count} crate(s).", + "Hangar rats: {count} crate(s) tango aboard in {seconds}s.", + "Pit crew swapped tires; {seconds}s to fuel {count} crate(s).", + "Clipboard ninja checking boxes—{count} crate(s) ready in {seconds}s.", + "Dock boss sharpening pencils: {count} crate(s) manifest in {seconds}s.", + "Groundlings choreograph {count} crate(s) ballet—{seconds}s rehearsal.", + "Supply monks meditate {seconds}s to summon {count} crate(s).", + "Winch whisperer: {count} crate(s) ascend in {seconds}s.", + "Ramp champion bets {seconds}s for {count} crate(s).", + "Crew lounge evacuated; {count} crate(s) arriving in {seconds}s.", + "Load shack scoreboard: {seconds}s to snag {count} crate(s).", + "Logi wizard scribbles runes—{count} crate(s) appear after {seconds}s.", + "Deck sergeant orders {seconds}s freeze; {count} crate(s) inbound.", + "Cargo penguins waddling—{seconds}s to herd {count} crate(s).", + "Hangar bard plays; {count} crate(s) drop beat in {seconds}s.", + "Ramp dragon yawns: {seconds}s before {count} crate(s) charred.", + "Supply pirates shout—{count} crate(s) plundered in {seconds}s.", + "Ground crew printing receipts—{seconds}s to notarize {count} crate(s).", + "Forklift derby lights up; {count} crate(s) cross line in {seconds}s.", + "Load ninja breathes—{seconds}s later {count} crate(s) vanish aboard.", + "Deck boss bribes gravity; {count} crate(s) float up in {seconds}s.", + "Ammo elves: {seconds}s swirl to gift-wrap {count} crate(s).", + "Ramp philosopher ponders {count} crate(s) for {seconds}s.", + "Crew chef prepping snacks; {count} crate(s) served in {seconds}s.", + "Crate wrangler ties boots—{seconds}s to rope {count} crate(s).", + "Paladin of pallets: {count} crate(s) blessed in {seconds}s.", + "Load doc scribbles—{seconds}s to sign {count} crate(s).", + "Ground squirrels stash {count} crate(s) after {seconds}s.", + "Deck poet recites; {count} crate(s) respond in {seconds}s.", + "Winch gremlin oils gears—{seconds}s for {count} crate(s).", + "Ramp hype crew chants {seconds}s mantra for {count} crate(s).", + "Supply DJ scratching—{count} crate(s) drop bass in {seconds}s.", + "Cargo therapist assures {count} crate(s) in {seconds}s.", + "Crew zookeeper wrangles {count} crate(s) herd—{seconds}s.", + "Deck botanist grows {count} crate(s) vines in {seconds}s.", + "Load astronomer charts {count} crate(s) orbit—{seconds}s.", + "Ramp comedian promises laughs for {seconds}s then {count} crate(s).", + "Hangar historian says {count} crate(s) arrive in {seconds}s per tradition.", + "Supply drummer counts in {seconds}s for {count} crate(s).", + "Deck meteorologist predicts {count} crate(s) storm in {seconds}s.", + "Cargo architect sketches {count} crate(s) stacking plan—{seconds}s.", + "Ramp coder debugs manifest; {count} crate(s) compile in {seconds}s.", + "Load barista pulls espresso—{count} crate(s) perk up in {seconds}s.", + "Ground bard writes sea shanty; {count} crate(s) join in {seconds}s.", + "Supply prankster hides {count} crate(s); reveal in {seconds}s.", + "Ramp alchemist mixes fuel—{count} crate(s) transmute after {seconds}s.", + "Crate whisperer says hold {seconds}s for {count} crate(s).", + "Deck detective tracks {count} crate(s) trail—{seconds}s ETA.", + "Load beekeeper herds {count} crate(s) swarm—{seconds}s.", + "Ground tailor stitches nets; {count} crate(s) fitted in {seconds}s.", + "Supply DJ rewinds—{seconds}s then {count} crate(s) drop.", + "Ramp cloud-gazer sees {count} crate(s) in {seconds}s forecast.", + "Load punster drafts {count} crate(s) jokes—{seconds}s needed.", + "Deck volcanologist warns {count} crate(s) eruption in {seconds}s.", + "Cargo puppeteer choreographs {count} crate(s); show in {seconds}s.", + "Ground cartographer maps {count} crate(s) journey—{seconds}s.", + "Ramp mathematician solves {count} crate(s) problem—{seconds}s.", + "Load meteor chaser counts {seconds}s till {count} crate(s) strike.", + "Supply astronomer spots {count} crate(s) constellation—{seconds}s.", + "Deck lifeguard whistles; {count} crate(s) swim aboard in {seconds}s.", + "Cargo sommelier decants {count} crate(s)—need {seconds}s to breathe.", + "Ramp locksmith picks {count} crate(s) locks—{seconds}s.", + "Load carpenter measures twice; {count} crate(s) cut loose in {seconds}s.", + "Ground geologist drills plan—{count} crate(s) surface in {seconds}s.", + "Supply fireman slides pole; {count} crate(s) rescued in {seconds}s.", + "Ramp hacker breaches {count} crate(s) firewall—{seconds}s.", + "Load illusionist shuffles {count} crate(s)—{seconds}s reveal.", + "Deck astronomer winks: {count} crate(s) align in {seconds}s.", + "Cargo mixologist shakes {count} crate(s); {seconds}s pour time.", + "Ramp weatherman says {count} crate(s) drizzle in {seconds}s.", + "Load surveyor levels deck—{count} crate(s) land in {seconds}s.", + "Ground sculptor chisels path; {count} crate(s) glide in {seconds}s.", + "Supply DJ double-drops; {count} crate(s) drop after {seconds}s.", + "Deck clockmaker rewinds {seconds}s, {count} crate(s) tick in.", + "Cargo cart racer drifts up with {count} crate(s) in {seconds}s.", + "Ramp ringmaster cues circus; {count} crate(s) center ring in {seconds}s.", + }, + Progress = { + "Crew chief: {remaining}s on the clock—don't wiggle.", + "Ramp boss tapping boot while Jenkins L calls {remaining}s remaining.", + "Leroy J. humming; {remaining}s until hooks click.", + "Forklift tires chirp—{remaining}s before stack settles.", + "Cargo chains rattling, {remaining}s left.", + "Handler flashes thumbs-up once Leroy yells go in {remaining}s.", + "Deck boss juggling paperwork—{remaining}s.", + "Ground crew sipping caf, {remaining}s reminder.", + "Winch motors whining: {remaining}s.", + "Clipboard ninja says {remaining}s until signatures.", + "Ramp trolls mid chant—{remaining}s.", + "Load doc verifying straps—{remaining}s.", + "Forklift derby lap {remaining}s.", + "Ammo goblins grumble {remaining}s.", + "Supply bard hits chorus in {remaining}s.", + "Deck meteorologist reads {remaining}s forecast.", + "Cargo AI recalculating—{remaining}s.", + "Ground gremlin twisting wrenches for {remaining}s.", + "Ramp conductor waves baton—{remaining}s of tempo.", + "Load punster drafting joke; {remaining}s left.", + "Deck alchemist stirring {remaining}s.", + "Crate wrangler double-knots—{remaining}s.", + "Forklift jazz solo ends in {remaining}s.", + "Supply DJ building drop—{remaining}s.", + "Ramp philosopher meditates {remaining}s.", + "Load botanist waters nets—{remaining}s.", + "Deck coder compiling manifest—{remaining}s.", + "Ground astronomer counts {remaining}s shooting stars.", + "Cargo weathervane spins {remaining}s.", + "Loader handshake pending {remaining}s.", + "Ramp poet editing stanza—{remaining}s.", + "Load bartender shakes drink—{remaining}s of chill.", + "Deck zookeeper calms crates—{remaining}s.", + "Ground sculptor chisels wedge—{remaining}s.", + "Supply tailor hemming slings—{remaining}s.", + "Ramp locksmith turning tumblers—{remaining}s.", + "Load whisperer soothing pallets for {remaining}s.", + "Deck drummer counting down {remaining}s.", + "Cargo painter adds racing stripes—{remaining}s.", + "Ground beekeeper herding boxes—{remaining}s.", + "Ramp ninja mid flip—{remaining}s.", + "Load DJ scratching vinyl for {remaining}s.", + "Deck geologist sampling dust—{remaining}s.", + "Cargo puppeteer angles strings—{remaining}s.", + "Ground interpreter translating crate beeps—{remaining}s.", + "Ramp comedian holding punchline {remaining}s.", + "Load surfer riding forklift forks—{remaining}s.", + "Deck data nerd buffering {remaining}s.", + "Cargo snowplow clearing pebbles—{remaining}s.", + "Ground pyrotechnician keeps sparks at bay {remaining}s.", + "Ramp snorkeler holding breath {remaining}s.", + "Load astronomer calibrates scope—{remaining}s.", + "Deck spelunker exploring skid row—{remaining}s.", + "Cargo beekeeper suits up—{remaining}s.", + "Ground carpenter setting chalk lines—{remaining}s.", + "Ramp wizard muttering {remaining}s spell.", + "Load falconer whistles—{remaining}s before talons release.", + "Deck tuba blares sustain for {remaining}s.", + "Cargo juggler keeps crates aloft {remaining}s.", + "Ground chemist titrates patience—{remaining}s.", + "Ramp puppeteer cues strings—{remaining}s.", + "Load archaeologist brushes dust—{remaining}s.", + "Deck racer redlines stopwatch {remaining}s.", + "Cargo coder spams F5 for {remaining}s.", + "Ground gardener trims net corners—{remaining}s.", + "Ramp DJ layering loops—{remaining}s.", + "Load glaciologist monitors ice melt—{remaining}s.", + "Deck prankster hides cones for {remaining}s.", + "Cargo scribe inks manifest—{remaining}s.", + "Ground wanderer paces {remaining}s.", + "Ramp baker timing souffle—{remaining}s.", + "Load therapist tells crates to breathe {remaining}s.", + "Deck hype squad chanting {remaining}s.", + "Cargo spelunker checks tie-down caverns—{remaining}s.", + "Ground sherpa hauls straps—{remaining}s.", + "Ramp rebel flicks toothpick {remaining}s.", + "Load fortune teller sees {remaining}s in cards.", + "Deck quatermaster double-counts {remaining}s.", + "Cargo dinosaur roaring softly {remaining}s.", + "Ground mech tunes hydraulics—{remaining}s.", + "Ramp botanist sniffs fuel—{remaining}s.", + "Load journalist scribbles {remaining}s update.", + "Deck gamer farming XP for {remaining}s.", + "Cargo archivist files forms—{remaining}s.", + "Ground referee watches chalk line—{remaining}s.", + "Ramp kite flyer reels string—{remaining}s.", + "Load detective dusts prints—{remaining}s.", + "Deck prank caller rings tower for {remaining}s.", + "Cargo shoemaker taps soles—{remaining}s.", + "Ground chandler pours wax—{remaining}s.", + "Ramp falcon loops {remaining}s.", + "Load diver equalizes ears {remaining}s.", + "Deck astronomer rechecks alignment—{remaining}s.", + "Cargo DJ rewinds sample {remaining}s.", + "Ground quartermaster ties ledger—{remaining}s.", + "Ramp magpie collecting shiny bolts—{remaining}s.", + "Load muralist adds stencil—{remaining}s.", + "Deck tech updates firmware—{remaining}s.", + "Cargo babysitter hushes pallets {remaining}s.", + "Ground marshal gives steady-hand signal for {remaining}s.", + "Hey! Watch what you're doing with those crates! {remaining}s left.", + "Hey! You can't put that there! Over there instead! {remaining}s left.", + "Hey! Is your name Leroy! Because you better be getting those crates loaded! {remaining}s left.", + "Jenkins!!! Get those crates loaded! {remaining}s left.", + "Leroy!!! Stop daydreaming and get those crates loaded! {remaining}s left.", + "Come on Leroy, those crates won't load themselves! {remaining}s left.", + "Hurry up Jenkins, we got a bird waiting! {remaining}s left.", + "Get a move on Leroy, those crates ain't gonna load themselves! {remaining}s left.", + "You call that loading Jenkins? {remaining}s left.", + "Pick up the pace Leroy! {remaining}s left.", + "Faster Jenkins! {remaining}s left.", + "Let's hustle Leroy! {remaining}s left.", + "Time's a-tickin' Jenkins! {remaining}s left.", + "Chop-chop Leroy! {remaining}s left.", + "Jenkins! Stop playing with the cargo! Those dildo's belong to Mo! {remaining}s left.", + "Leroy here sir! We got that cargo right where you wanted it! {remaining}s left.", + "Jenkins! Get back to work! Mo's complaining! {remaining}s left.", + }, + Complete = { + "Crew chief: {count} crate(s) strapped and smiling—clear to lift.", + "Ramp boss reports {count} crate(s) locked tight by Jenkins L.", + "Loadmaster: {count} crate(s) tucked in like kittens.", + "Forklift mafia salutes—{count} crate(s) delivered.", + "Deck boss stamped {count} crate(s) good to go.", + "Cargo goblins vanished—Leroy swears he secured {count} crate(s).", + "Winch team claims victory—{count} crate(s) aboard.", + "Clipboard ninja checked {count} boxes.", + "Ramp trolls cheer: {count} crate(s) riding shotgun.", + "Handler: {count} crate(s) bolted; throttle up.", + "Deck poet pens ode to {count} crate(s) now yours.", + "Supply gnomes wave bye to {count} crate(s).", + "Ground crew says {count} crate(s) ready for adventure.", + "Ramp DJ drops beat—{count} crate(s) locked in rhythm.", + "Load doc signs release: {count} crate(s).", + "Deck alchemist transmuted paperwork—{count} crate(s).", + "Cargo therapist declares {count} crate(s) emotionally stable.", + "Forklift derby trophy goes to {count} crate(s) now aboard.", + "Ramp philosopher satisfied—{count} crate(s) exist on deck.", + "Load botanist pruned nets—{count} crate(s) bloom there.", + "Deck coder returned true: {count} crate(s).", + "Ground bard ends tune with {count} crate(s) crescendo.", + "Ramp prankster can't hide {count} crate(s)—they're on board.", + "Cargo beekeeper counts {count} crate(s) in hive.", + "Deck meteorologist confirms {count} crate(s) high pressure.", + "Load painter signs mural of {count} crate(s) strapped.", + "Ground tailor hemmed slings—{count} crate(s) fitted.", + "Ramp locksmith snapped padlocks on {count} crate(s).", + "Cargo DJ fades track—{count} crate(s) secure.", + "Deck archaeologist labels {count} crate(s) artifact.", + "Load comedian retires bit; {count} crate(s) landing.", + "Ground sculptor polishes {count} crate(s) corners.", + "Ramp astronomer charts {count} crate(s) orbit now stable.", + "Cargo puppeteer bows—{count} crate(s) performance done.", + "Deck detective solved case of {count} crate(s).", + "Load shark fins down: {count} crate(s) fed to cargo bay.", + "Ground sherpa drops pack—{count} crate(s) summit achieved.", + "Ramp hacker logs off—{count} crate(s) uploaded.", + "Cargo barista served {count} crate(s) double-shot of tie downs.", + "Deck referee whistles end—{count} crate(s) win.", + "Load fencer sheathes sword—{count} crate(s) defended.", + "Ground medic clears {count} crate(s) to travel.", + "Ramp monk bows: {count} crate(s) enlightened.", + "Cargo kite now tethered—{count} crate(s).", + "Deck astronomer applauds {count} crate(s) alignment.", + "Load geologist marks {count} crate(s) strata complete.", + "Ground gardener plants {count} crate(s) firmly.", + "Ramp puppet master cuts strings—{count} crate(s) stay.", + "Cargo DJ signs off—{count} crate(s) final mix.", + "Deck beekeeper seals hive with {count} crate(s).", + "Load mathematician tallies {count} crate(s) exact.", + "Ground fireworks canceled—{count} crate(s) safe.", + "Ramp storm chaser says {count} crate(s) in the eye.", + "Cargo sculptor chisels notch—{count} crate(s) nested.", + "Deck tailor stitches last knot on {count} crate(s).", + "Load wizard snaps fingers—{count} crate(s) appear strapped.", + "Ground referee raises flag: {count} crate(s) legal.", + "Ramp brewer clinks mugs—{count} crate(s) on tap.", + "Cargo philosopher logs {count} crate(s) as truth.", + "Deck DJ loops outro—{count} crate(s) seated.", + "Load detective closes file—{count} crate(s) accounted.", + "Ground lifeguard thumbs up—{count} crate(s) afloat.", + "Ramp spelunker resurfaces with {count} crate(s).", + "Cargo dancer nails finale—{count} crate(s).", + "Deck poet rhymes {count} crate(s) with fate.", + "Load dragon goes back to sleep—{count} crate(s) fed.", + "Ground chemist labels vials—{count} crate(s) stable.", + "Ramp tailor satisfied stitchwork on {count} crate(s).", + "Cargo quarterback yells touchdown—{count} crate(s).", + "Deck glaciologist notes {count} crate(s) frozen in place.", + "Load eagle roosts—{count} crate(s) in nest.", + "Ground DJ cues victory sting—{count} crate(s) done.", + "Ramp botanist logs {count} crate(s) in flora guide.", + "Cargo surfer throws shaka—{count} crate(s) ride smooth.", + "Deck juggler bows—{count} crate(s) landed.", + "Load translator confirms {count} crate(s) say thanks.", + "Ground weatherman clears skies—{count} crate(s) shining.", + "Ramp trickster hides clipboard: {count} crate(s) can't hide.", + "Cargo archivist files {count} crate(s) under awesome.", + "Deck beekeeper high-fives {count} crate(s) bees.", + "Load marathoner crosses finish with {count} crate(s).", + "Ground astronomer stamps {count} crate(s) star chart.", + "Ramp baker presents {count} crate(s) pie fresh.", + "Cargo diver surfaces cheering {count} crate(s).", + "Deck data nerd graphs {count} crate(s) success.", + "Load hypnotist snaps fingers—{count} crate(s) obey.", + "Ground marshal rolls wand—{count} crate(s) staged.", + "Ramp timekeeper stops watch at {count} crate(s).", + "Cargo composer final chord—{count} crate(s).", + "Deck quartermaster locks ledger: {count} crate(s).", + "Load astronaut gives thumbs up from cargo bay—{count} crate(s).", + "Ground ninja vanishes leaving {count} crate(s).", + "Ramp botanist labels {count} crate(s) species secure.", + "Cargo conductor yells all aboard—{count} crate(s).", + "Deck muralist signs name under {count} crate(s).", + "Load shepherd counts {count} crate(s) asleep.", + "Ground pirate buries hatchet—{count} crate(s) share plunder.", + "Ramp gamer hits save—{count} crate(s) progress locked.", + "Cargo weaver ties final knot on {count} crate(s).", + "Deck captain stamps log—{count} crate(s) embarked.", + } +} + +-- ========================= +-- MEDEVAC Configuration +-- ========================= +-- #region MEDEVAC Config +CTLD.MEDEVAC = { + Enabled = true, + + -- Crew spawning + -- Per-coalition spawn probabilities for asymmetric scenarios + CrewSurvivalChance = { + [coalition.side.BLUE] = .50, -- probability (0.0-1.0) that BLUE crew survives to spawn MEDEVAC request. 1.0 = 100% (testing), 0.02 = 2% (production) + [coalition.side.RED] = .50, -- probability (0.0-1.0) that RED crew survives to spawn MEDEVAC request + }, + ManPadSpawnChance = { + [coalition.side.BLUE] = 0.1, -- probability (0.0-1.0) that BLUE crew spawns with a MANPADS soldier. 1.0 = 100% (testing), 0.1 = 10% (production) + [coalition.side.RED] = 0.1, -- probability (0.0-1.0) that RED crew spawns with a MANPADS soldier + }, + CrewSpawnDelay = 300, -- seconds after death before crew spawns (gives battle time to clear). 300 = 5 minutes + CrewAnnouncementDelay = 60, -- seconds after spawn before announcing mission to players (verify crew survival). 60 = 1 minute + CrewTimeout = 3600, -- 1 hour max wait before crew is KIA (after spawning) + CrewSpawnOffset = 25, -- meters from death location (toward nearest enemy) + CrewDefaultSize = 2, -- default crew size if not specified in catalog + CrewDefendSelf = true, -- crews will return fire if engaged + + -- Crew protection during announcement delay + CrewImmortalDuringDelay = true, -- make crew immortal (invulnerable) during announcement delay to prevent early death + CrewInvisibleDuringDelay = true, -- make crew invisible to AI during announcement delay (won't be targeted by enemy) + CrewImmortalAfterAnnounce = false, -- if true, crew stays immortal even after announcing mission (easier gameplay) + KeepCrewInvisibleForLifetime = true, -- if true, keep crew invisible to AI for entire mission lifetime + + -- Smoke signals + PopSmokeOnSpawn = true, -- crew pops smoke when they first spawn + PopSmokeOnApproach = true, -- crew pops smoke when rescue helo approaches + PopSmokeOnApproachDistance = 8000, -- meters - distance at which crew detects approaching helo + SmokeCooldown = 900, -- seconds between smoke pops (default 900 = 15 minutes) - prevents spam when helo circles + SmokeColor = { -- smoke colors per coalition + [coalition.side.BLUE] = trigger.smokeColor.Blue, + [coalition.side.RED] = trigger.smokeColor.Red, + }, + SmokeOffsetMeters = 0, -- horizontal offset from crew position (meters) so helicopters don't hover in smoke + SmokeOffsetRandom = true, -- randomize horizontal offset direction (true) or always offset north (false) + SmokeOffsetVertical = 20, -- vertical offset above ground level (meters) for better visibility + + -- Greeting messages when crew detects rescue helo + GreetingMessages = { + "Stranded Crew: We see you, boy that thing is loud! Follow the smoke!", + "Stranded Crew: We hear you coming.. yep, we see you.. bring it on down to the smoke!", + "Stranded Crew: Whew! We sure are glad you're here! Over here by the smoke!", + "Stranded Crew: About damn time! We're over here at the smoke!", + "Stranded Crew: Thank God! We thought you forgot about us! Follow the smoke!", + "Stranded Crew: Hey! We're the good looking ones by the smoke!", + "Stranded Crew: Copy that, we have visual! Popping smoke now!", + "Stranded Crew: Roger, we hear your rotors! Follow the smoke and come get us!", + "Stranded Crew: Finally! My feet are killing me out here! We're at the smoke!", + "Stranded Crew: That's the prettiest sound we've heard all day! Head for the smoke!", + "Stranded Crew: Is that you or are the enemy reinforcements? Just kidding, get down here at the smoke!", + "Stranded Crew: We've been working on our tans, come check it out! Smoke's popped!", + "Stranded Crew: Hope you brought snacks, we're starving! Follow the smoke in!", + "Stranded Crew: Your Uber has arrived? No, YOU'RE our Uber! We're at the smoke!", + "Stranded Crew: Could you be any louder? The whole country knows we're here now! At least follow the smoke!", + "Stranded Crew: Next time, could you not take so long? My coffee got cold! Smoke's up!", + "Stranded Crew: We see you! Don't worry, we only look this bad! Head for the smoke!", + "Stranded Crew: Inbound helo spotted! Someone owes me 20 bucks! Smoke is marking our position!", + "Stranded Crew: Hey taxi! We're at the corner of Blown Up Avenue and Oh Crap Street! Follow the smoke!", + "Stranded Crew: You're a sight for sore eyes! Literally, there's so much dust out here! Smoke's popped!", + "Stranded Crew: Visual contact confirmed! Get your ass down here to the smoke!", + "Stranded Crew: Oh thank hell, a bird! We're ready to get the fuck outta here! Smoke's marking us!", + "Stranded Crew: We hear you! Follow the smoke and the smell of desperation!", + "Stranded Crew: Rotors confirmed! Popping smoke now! Don't leave us hanging!", + "Stranded Crew: That you up there? About time! We've been freezing out here! Look for the smoke!", + "Stranded Crew: Helo inbound! We've got the salvage and the trauma, come get both! Smoke's up!", + "Stranded Crew: Eyes on rescue bird! Someone tell me this isn't a mirage! Follow the smoke!", + "Stranded Crew: We hear those beautiful rotors! Land this thing before we cry! Smoke marks the spot!", + "Stranded Crew: Confirmed visual! If you leave without us, we're keeping the salvage! Smoke's popped!", + "Stranded Crew: Choppers overhead! Finally! We were about to start walking! Head for the smoke!", + "Stranded Crew: That's our ride! Everyone look alive and try not to smell too bad! We're at the smoke!", + "Stranded Crew: Helo spotted! Quick, somebody look professional! Smoke's marking our position!", + "Stranded Crew: You're here! We'd hug you but we're covered in dirt and shame! Follow the smoke!", + "Stranded Crew: Bird inbound! Popping smoke! Someone owes us overtime for this shit!", + "Stranded Crew: Visual on rescue! Get down here before the enemy spots you too! Smoke's up!", + "Stranded Crew: We see you! Follow the smoke and broken dreams!", + "Stranded Crew: Incoming helo! Thank fuck! We're ready to leave this lovely hellscape! Smoke marks us!", + "Stranded Crew: Eyes on bird! We've got salvage, stories, and a desperate need for AC! Look for the smoke!", + "Stranded Crew: That you? Get down here! We've been standing here like idiots for hours! Smoke's popped!", + "Stranded Crew: Helo visual! Popping smoke! Anyone got room for some very tired, very angry crew?", + "Stranded Crew: We see you up there! Don't you dare fly past us! Follow the smoke!", + "Stranded Crew: Rescue inbound! Finally! We were starting to plan a walk home! Smoke's marking us!", + "Stranded Crew: Contact! We have eyes on you! Come get us before we change our minds about this whole military thing! Smoke's up!", + "Stranded Crew: Helo confirmed! Smoke's up! Let's get this reunion started!", + "Stranded Crew: You beautiful bastard! We see you! Get down here to the smoke!", + "Stranded Crew: Visual on rescue! We're ready! Let's get out before our luck runs out! Follow the smoke!", + "Stranded Crew: Bird spotted! Smoke deployed! Hurry before we attract more attention!", + "Stranded Crew: There you are! What took so long? Never mind, just land at the smoke!", + "Stranded Crew: We see you! Follow the smoke and the sound of relieved cursing!", + "Stranded Crew: Helo inbound! Everyone grab your shit! We're leaving this place! Smoke marks the LZ!", + "Stranded Crew: Is that our ride or just someone sightseeing? Either way, smoke's up!", + "Stranded Crew: We've got eyes on you! Come to the smoke before we lose our minds!", + "Stranded Crew: Tally ho! That's military speak for 'follow the damn smoke'!", + "Stranded Crew: You're late! But we'll forgive you if you land at the smoke!", + "Stranded Crew: Helo overhead! Popping smoke! This better not be a drill!", + "Stranded Crew: Contact confirmed! Smoke's marking us! Don't make us wait!", + "Stranded Crew: We hear rotors! Please be friendly! Smoke's up either way!", + "Stranded Crew: Bird inbound! Smoke deployed! Let's make this quick!", + "Stranded Crew: Visual on helo! Follow the smoke to the worst day of our lives!", + "Stranded Crew: You found us! Smoke's marking the spot! Gold star for you!", + "Stranded Crew: Rescue bird spotted! Smoke's up! We're the desperate ones!", + "Stranded Crew: We see you! Land at the smoke before we start charging rent!", + "Stranded Crew: Helo visual! Smoke deployed! This isn't a vacation spot!", + "Stranded Crew: That's you! Finally! Follow the smoke to glory!", + "Stranded Crew: Eyes on rescue! Smoke marks our misery! Come fix it!", + "Stranded Crew: We hear you! Smoke's popped! Let's end this nightmare!", + "Stranded Crew: Contact! Visual! Smoke! All the good stuff! Get down here!", + "Stranded Crew: Rescue inbound! Smoke's up! We've rehearsed this moment!", + "Stranded Crew: You're here! Smoke's marking us! Don't screw this up!", + "Stranded Crew: Helo confirmed! Follow the smoke to the saddest party ever!", + "Stranded Crew: We see you! Smoke's deployed! Land before we cry!", + "Stranded Crew: Bird spotted! Smoke marks us! We're the ones waving frantically!", + "Stranded Crew: Visual contact! Smoke's up! This is not a joke!", + "Stranded Crew: You made it! Follow the smoke! We've got beer money! (Lies, but follow the smoke anyway!)", + "Stranded Crew: Helo inbound! Smoke deployed! Pick us up before our wives find out!", + "Stranded Crew: We hear you! Smoke's marking our stupidity! Come save us from ourselves!", + "Stranded Crew: Contact! Smoke's up! We promise we're worth the fuel!", + "Stranded Crew: Rescue bird! Smoke marks the spot! This is awkward for everyone!", + "Stranded Crew: You're here! Smoke deployed! We'll explain everything later!", + "Stranded Crew: Visual on helo! Follow the smoke to disappointment and gratitude!", + "Stranded Crew: We see you! Smoke's up! Let's never speak of this again!", + "Stranded Crew: Helo spotted! Smoke marks us! We're the embarrassed ones!", + "Stranded Crew: Contact confirmed! Smoke deployed! This wasn't in the manual!", + "Stranded Crew: You found us! Smoke's up! Someone's getting a promotion!", + "Stranded Crew: Bird inbound! Follow the smoke to heroes and idiots!", + "Stranded Crew: We hear you! Smoke's marking us! Please don't tell command!", + "Stranded Crew: Visual! Smoke deployed! We'll buy you drinks forever!", + "Stranded Crew: Helo confirmed! Smoke's up! Best day of our lives right here!", + "Stranded Crew: You're here! Follow the smoke! We're never leaving base again!", + "Stranded Crew: Contact! Smoke marks us! This is our rock bottom!", + "Stranded Crew: Rescue inbound! Smoke deployed! We're upgrading your Yelp review!", + "Stranded Crew: We see you! Smoke's up! Land before the enemy does!", + "Stranded Crew: Bird spotted! Follow the smoke! We've learned our lesson!", + "Stranded Crew: Visual on helo! Smoke's marking us! This is so embarrassing!", + "Stranded Crew: You made it! Smoke deployed! We owe you everything!", + "Stranded Crew: Helo inbound! Smoke marks the spot! Let's go home!", + "Stranded Crew: We hear you! Follow the smoke! We're the lucky ones!", + "Stranded Crew: Contact confirmed! Smoke's up! Thank you, thank you, thank you!", + "Stranded Crew: Rescue bird! Smoke deployed! You're our favorite person ever!", + }, + + -- Request airlift messages (initial mission announcement) + RequestAirLiftMessages = { + "Stranded Crew: This is {vehicle} crew at {grid}. Need pickup ASAP! We have {salvage} salvage to collect.", + "Stranded Crew: Yo, this is {vehicle} survivors at {grid}. Come get us before the bad guys do! {salvage} salvage available.", + "Stranded Crew: {vehicle} crew reporting from {grid}. We're alive but our ride isn't. {salvage} salvage ready for extraction.", + "Stranded Crew: Mayday! {vehicle} crew at {grid}. Send taxi, will pay in salvage! ({salvage} units available)", + "Stranded Crew: This is what's left of {vehicle} crew at {grid}. Pick us up and grab the {salvage} salvage while you're at it!", + "Stranded Crew: {vehicle} survivors here at {grid}. We've got {salvage} salvage and a bad attitude. Come get us!", + "Stranded Crew: Former {vehicle} operators at {grid}. Vehicle's toast but we salvaged {salvage} units. Need immediate evac!", + "Stranded Crew: {vehicle} crew broadcasting from {grid}. Situation: homeless. Salvage: {salvage} units. Mood: not great.", + "Stranded Crew: This is {vehicle} at {grid}. Well, WAS {vehicle}. Now it's scrap. Got {salvage} salvage though!", + "Stranded Crew: Hey! {vehicle} crew at {grid}! Our insurance definitely doesn't cover this. {salvage} salvage available.", + "Stranded Crew: {vehicle} survivors reporting. Grid {grid}. Status: walking. Salvage: {salvage}. Pride: wounded.", + "Stranded Crew: To whom it may concern: {vehicle} crew at {grid} requests immediate pickup. {salvage} salvage awaiting recovery.", + "Stranded Crew: {vehicle} down at {grid}. Crew status: annoyed but alive. Salvage count: {salvage}. Hurry up!", + "Stranded Crew: This is a priority call from {vehicle} crew at {grid}. We got {salvage} salvage and zero patience left!", + "Stranded Crew: {vehicle} operators at {grid}. The vehicle gave up, we didn't. {salvage} salvage ready to go!", + "Stranded Crew: Urgent! {vehicle} crew stranded at {grid}. Got {salvage} salvage and a serious need for extraction!", + "Stranded Crew: {vehicle} here, well, parts of it anyway. Crew at {grid}. Salvage: {salvage}. Morale: questionable.", + "Stranded Crew: {vehicle} down at {grid}. We're fine, vehicle's dead. {salvage} salvage secured. Come get us before we walk home!", + "Stranded Crew: Calling all angels! {vehicle} crew at {grid} needs a lift. Bringing {salvage} salvage as payment!", + "Stranded Crew: {vehicle} crew broadcasting from scenic {grid}. Collected {salvage} salvage. Would not recommend this location!", + "Stranded Crew: This is {vehicle} at {grid}. Vehicle status: spectacular fireball (was). Crew status: could use a ride. Salvage: {salvage}.", + "Stranded Crew: {vehicle} survivors at {grid}. We've got {salvage} salvage and stories you won't believe. Extract us!", + "Stranded Crew: Former {vehicle} crew at {grid}. Current occupants of a smoking crater. {salvage} salvage available!", + "Stranded Crew: {vehicle} operators requesting immediate evac from {grid}. Salvage secured: {salvage} units. Bring beer.", + "Stranded Crew: This is {vehicle} crew. Location: {grid}. Situation: not ideal. Salvage: {salvage}. Need: helicopter. NOW.", + "Stranded Crew: {grid}, party of {crew_size} from {vehicle}. Got {salvage} salvage and nowhere to go. Send help!", + "Stranded Crew: {vehicle} down at {grid}. Crew bailed, grabbed {salvage} salvage, now standing here like idiots. Pick us up!", + "Stranded Crew: Emergency broadcast from {vehicle} crew at {grid}. {salvage} salvage ready. Our ride? Not so much.", + "Stranded Crew: {vehicle} at {grid}. Status report: vehicle's a loss, crew's intact, {salvage} salvage secured. Send taxi!", + "Stranded Crew: Hey command! {vehicle} crew at {grid}. We saved {salvage} salvage but couldn't save the vehicle. Priorities!", + "Stranded Crew: This is {vehicle} broadcasting from {grid}. Crew's good, vehicle's bad, {salvage} salvage available. Get us outta here!", + "Stranded Crew: {vehicle} survivors at {grid} with {salvage} salvage. We're sunburned, pissed off, and ready for extraction!", + "Stranded Crew: Attention: {vehicle} crew at {grid} requires pickup. {salvage} salvage recovered. Hurry before we become salvage too!", + "Stranded Crew: {vehicle} here at {grid}. The good news: {salvage} salvage. The bad news: everything else. Send help!", + "Stranded Crew: From the smoking remains of {vehicle} at {grid}, we bring you {salvage} salvage and a request for immediate evac!", + "Stranded Crew: {vehicle} crew calling from {grid}. Vehicle's done, crew's done waiting. {salvage} salvage ready. Move it!", + "Stranded Crew: This is {vehicle} at {grid}. Collected {salvage} salvage while our ride went up in flames. Worth it?", + "Stranded Crew: {vehicle} operators at {grid}. Got {salvage} salvage and a newfound appreciation for walking. Please send helo!", + "Stranded Crew: {grid} here. {vehicle} crew reporting. Salvage count: {salvage}. Ride count: zero. Help count: needed!", + "Stranded Crew: {vehicle} down at {grid}! Crew up and ready with {salvage} salvage! Someone come get us already!", + "Stranded Crew: This is {vehicle} broadcasting on guard. Position {grid}. {salvage} salvage secured. Crew status: tired of your shit, send pickup!", + "Stranded Crew: {vehicle} crew at {grid} here. We've got {salvage} salvage, bad sunburns, and a dying radio battery. Hurry!", + "Stranded Crew: Emergency call from {grid}! {vehicle} crew alive with {salvage} salvage. Vehicle? Not so lucky. Extract ASAP!", + "Stranded Crew: {vehicle} at {grid}. Mission status: FUBAR. Crew status: alive. Salvage status: {salvage} units ready. Send bird!", + "Stranded Crew: This is {vehicle} crew. We're at {grid} with {salvage} salvage and zero transportation. Someone fix that!", + "Stranded Crew: {vehicle} survivors broadcasting from {grid}. Got the salvage ({salvage} units), lost the vehicle. Fair trade?", + "Stranded Crew: Urgent from {grid}! {vehicle} crew here with {salvage} salvage and rapidly depleting patience. Pick us up!", + "Stranded Crew: {vehicle} down at {grid}. Crew condition: grumpy but mobile. Salvage available: {salvage}. Ride home: none.", + "Stranded Crew: This is {vehicle} calling from {grid}. We're standing in the middle of nowhere with {salvage} salvage. Sound fun?", + "Stranded Crew: {vehicle} crew at {grid}. Salvage recovered: {salvage}. Pride recovered: maybe later. Need pickup now!", + "Stranded Crew: SOS from {grid}! {vehicle} crew requesting airlift! {salvage} salvage secured! This is not a drill!", + "Stranded Crew: {vehicle} survivors at {grid}. Vehicle kaput. Crew intact. {salvage} salvage ready. Send chopper!", + "Stranded Crew: This is {vehicle} crew at {grid}. We walked away from the wreck with {salvage} salvage. Now what?", + "Stranded Crew: Priority message! {vehicle} down at {grid}! Crew needs evac! {salvage} salvage available!", + "Stranded Crew: {vehicle} at {grid}. The vehicle didn't make it but we did. {salvage} salvage waiting. Send help!", + "Stranded Crew: Distress call from {grid}! {vehicle} crew needs immediate pickup! {salvage} salvage on site!", + "Stranded Crew: {vehicle} operators broadcasting from {grid}. Status: stranded. Payload: {salvage} salvage. Request: extraction!", + "Stranded Crew: This is {vehicle} crew. Grid: {grid}. Vehicle: destroyed. Salvage: {salvage}. Spirit: broken. Send pickup!", + "Stranded Crew: {vehicle} down at {grid}. We've got {salvage} salvage and a story that'll make you cringe. Extract us!", + "Stranded Crew: Emergency! {vehicle} crew at {grid}! Vehicle lost! {salvage} salvage recovered! Need airlift stat!", + "Stranded Crew: {vehicle} survivors reporting from {grid}. {salvage} salvage secured. Vehicle unsalvageable. We're not!", + "Stranded Crew: This is {vehicle} at {grid}. Crew escaped with {salvage} salvage. Need immediate extraction before enemy finds us!", + "Stranded Crew: {vehicle} crew broadcasting from {grid}. Got {salvage} salvage. Lost everything else. Please respond!", + "Stranded Crew: Mayday from {grid}! {vehicle} crew needs rescue! {salvage} salvage available! Don't leave us here!", + "Stranded Crew: {vehicle} operators at {grid}. Salvage count: {salvage}. Morale count: negative. Pickup count: zero so far!", + "Stranded Crew: This is {vehicle} crew at {grid}. We managed to save {salvage} salvage. Can you save us?", + "Stranded Crew: {vehicle} down at {grid}! Crew on foot with {salvage} salvage! Send taxi before we start hitchhiking!", + "Stranded Crew: Emergency call! {vehicle} crew at {grid}! {salvage} salvage ready! Vehicle not! We need help!", + "Stranded Crew: {vehicle} survivors broadcasting from {grid}. We're alive, vehicle's not, {salvage} salvage secured. What now?", + "Stranded Crew: This is {vehicle} at {grid}. Crew status: homeless. Salvage status: {salvage} units. Transportation status: needed!", + "Stranded Crew: {vehicle} crew calling from {grid}. We've got {salvage} salvage and no way home. Fix that!", + "Stranded Crew: Priority rescue needed! {vehicle} at {grid}! {salvage} salvage secured! Crew waiting!", + "Stranded Crew: {vehicle} operators from {grid}. Vehicle destroyed. Salvage recovered: {salvage}. Us recovered: not yet!", + "Stranded Crew: This is {vehicle} crew. Location: {grid}. Salvage: {salvage}. Transportation: missing. Patience: running out!", + "Stranded Crew: {vehicle} down at {grid}! We escaped with {salvage} salvage! Send extraction before our luck runs out!", + "Stranded Crew: Emergency broadcast from {grid}! {vehicle} crew needs airlift! {salvage} salvage ready for recovery!", + "Stranded Crew: {vehicle} survivors at {grid}. Got {salvage} salvage. Need helicopter. Preferably soon. Please?", + "Stranded Crew: This is {vehicle} at {grid}. Vehicle: totaled. Crew: intact. Salvage: {salvage}. Ride: requested!", + "Stranded Crew: {vehicle} crew broadcasting from {grid}. We saved {salvage} salvage from the wreck. Now save us!", + "Stranded Crew: Urgent! {vehicle} at {grid}! Crew needs extraction! {salvage} salvage available! Respond ASAP!", + "Stranded Crew: {vehicle} operators from {grid}. Salvage secured: {salvage}. Everything else: lost. Help requested!", + "Stranded Crew: This is {vehicle} crew at {grid}. {salvage} salvage recovered. Now we need to be recovered!", + "Stranded Crew: {vehicle} down at {grid}! Crew survived with {salvage} salvage! Vehicle didn't! Send pickup!", + "Stranded Crew: Emergency call from {grid}! {vehicle} crew requesting immediate evac! {salvage} salvage on hand!", + "Stranded Crew: {vehicle} survivors broadcasting from {grid}. Status: stranded. Cargo: {salvage} salvage. Mood: desperate!", + "Stranded Crew: This is {vehicle} at {grid}. We walked away from disaster with {salvage} salvage. Don't make us walk home!", + "Stranded Crew: {vehicle} crew calling from {grid}. Vehicle: gone. Salvage: {salvage}. Hope: fading. Send help!", + "Stranded Crew: Priority message! {vehicle} at {grid}! Crew needs pickup! {salvage} salvage ready! Time is critical!", + "Stranded Crew: {vehicle} operators from {grid}. We've got {salvage} salvage and no vehicle. Math doesn't work. Send helo!", + "Stranded Crew: This is {vehicle} crew at {grid}. Salvage recovered: {salvage}. Pride recovered: TBD. Pickup needed: definitely!", + "Stranded Crew: {vehicle} down at {grid}! Crew intact with {salvage} salvage! Vehicle scattered across 50 meters! Extract us!", + "Stranded Crew: Emergency from {grid}! {vehicle} crew needs airlift! {salvage} salvage secured! Don't forget about us!", + "Stranded Crew: {vehicle} survivors at {grid}. We managed to grab {salvage} salvage. Can you manage to grab us?", + "Stranded Crew: This is {vehicle} broadcasting from {grid}. Crew safe. Vehicle unsafe. {salvage} salvage ready. Pickup overdue!", + "Stranded Crew: {vehicle} crew at {grid}. We've got {salvage} salvage and regrets. Send extraction before we have more regrets!", + "Stranded Crew: Urgent call from {grid}! {vehicle} crew stranded! {salvage} salvage on site! Need immediate pickup!", + "Stranded Crew: {vehicle} operators from {grid}. Salvage count: {salvage}. Vehicle count: zero. Help count: requested!", + }, + + -- Load messages (shown when crew boards helicopter - initial contact) + LoadMessages = { + "Crew: Alright, we're in! Get us the hell out of here!", + "Crew: Loaded up! Thank God you showed up!", + "Crew: We're aboard! Let's not hang around!", + "Crew: All accounted for! Move it, move it!", + "Crew: Everyone's in! Punch it!", + "Crew: Secure! Get airborne before they spot us!", + "Crew: We're good! Nice flying, now let's go!", + "Crew: Loaded! You're our hero, let's bounce!", + "Crew: In the bird! Hit the gas!", + "Crew: Everyone's aboard! Go go go!", + "Crew: Mounted up! Don't wait for an invitation!", + "Crew: We're in! Holy shit, that was close!", + "Crew: All souls aboard! Time to leave!", + "Crew: Secure! Best thing I've seen all day!", + "Crew: Loaded! You magnificent bastard!", + "Crew: We're good to go! Pedal to the metal!", + "Crew: All in! Get us home, please!", + "Crew: Aboard! This is not a drill, GO!", + "Crew: Everyone's in! You're a lifesaver!", + "Crew: Locked and loaded! Wait, wrong phrase... just go!", + "Crew: All personnel aboard! Outstanding work!", + "Crew: We're in! Never been happier to see a helicopter!", + "Crew: Boarding complete! Let's get the fuck out of here!", + "Crew: Secure! You deserve a medal for this!", + "Crew: Everyone's aboard! Enemy's probably watching!", + "Crew: All in! Don't let us keep you!", + "Crew: Mounted! Nice flying, seriously!", + "Crew: Loaded up! First round's on us!", + "Crew: We're in! Best Uber rating ever!", + "Crew: All aboard! You're a goddamn angel!", + "Crew: Secure! Nicest thing anyone's done for us!", + "Crew: Everyone's in! Time's wasting!", + "Crew: Boarding complete! You rock!", + "Crew: All souls accounted for! Let's roll!", + "Crew: We're good! Get altitude, fast!", + "Crew: Loaded! I could kiss you!", + "Crew: Everyone's aboard! Don't wait around!", + "Crew: All in! Unbelievable timing!", + "Crew: Secure! You're the best!", + "Crew: Mounted up! We owe you big time!", + "Crew: All aboard! Rotors up, let's go!", + "Crew: We're in! Perfect execution!", + "Crew: Loaded! Smoothest pickup ever!", + "Crew: Everyone's good! Haul ass!", + "Crew: All accounted for! Spectacular flying!", + "Crew: Secure! Get us out of this hellhole!", + "Crew: We're aboard! Don't stick around!", + "Crew: All in! You're incredible!", + "Crew: Loaded up! Time to jet!", + "Crew: Everyone's secure! Outstanding!", + "Crew: All aboard! Best day ever!", + "Crew: We're good! Thank fucking God!", + "Crew: Loaded! You beautiful human being!", + "Crew: All personnel in! Fly fly fly!", + "Crew: Secure! Can't thank you enough!", + "Crew: Everyone's aboard! Green light!", + "Crew: All in! You're a legend!", + "Crew: Loaded! Sweet baby Jesus, let's go!", + "Crew: We're aboard! Best rescue ever!", + "Crew: All accounted for! Move out!", + "Crew: Secure! You saved our asses!", + "Crew: Everyone's in! Don't wait!", + "Crew: All aboard! Brilliant work!", + "Crew: Loaded up! We're getting married!", + "Crew: We're good! Absolutely perfect!", + "Crew: All in! Hit the throttle!", + "Crew: Secure! You're amazing!", + "Crew: Everyone's aboard! Let's leave!", + "Crew: All accounted for! Superb!", + "Crew: Loaded! Get us home safe!", + "Crew: We're in! Textbook pickup!", + "Crew: All aboard! Go go go!", + "Crew: Secure! We love you!", + "Crew: Everyone's good! Don't linger!", + "Crew: All in! Professional as hell!", + "Crew: Loaded up! Time to skedaddle!", + "Crew: We're aboard! You're the GOAT!", + "Crew: All souls in! Get moving!", + "Crew: Secure! We're naming our kids after you!", + "Crew: Everyone's aboard! Clear to leave!", + "Crew: All in! Never doubt yourself!", + "Crew: Loaded! You're a fucking hero!", + "Crew: We're good! Exceptional timing!", + "Crew: All accounted for! Hats off!", + "Crew: Secure! Best pilot ever!", + "Crew: Everyone's in! Throttle up!", + "Crew: All aboard! You're the man!", + "Crew: Loaded up! Pure excellence!", + "Crew: We're aboard! Flawless!", + "Crew: All in! Get us airborne!", + "Crew: Secure! Impressive stuff!", + "Crew: Everyone's good! Don't delay!", + "Crew: All accounted for! Top notch!", + "Crew: Loaded! Words can't express our thanks!", + "Crew: We're in! You're certified awesome!", + "Crew: All aboard! Time to split!", + "Crew: Secure! We're forever grateful!", + "Crew: Everyone's aboard! Wheels up!", + "Crew: All in! Couldn't be better!", + "Crew: Loaded up! You're the best pilot we know!", + "Crew: We're good! Clear skies ahead!", + "Crew: All souls aboard! Let's book it!", + "Crew: Secure! You're our guardian angel!", + }, + + -- Loading messages (shown periodically during boarding process) + LoadingMessages = { + "Crew: Hold still, we're getting in...", + "Crew: Watch your head! Coming through!", + "Crew: Almost there, keep it steady...", + "Crew: Just a sec, getting situated...", + "Crew: Loading up, hang tight...", + "Crew: Careful with Jenkins, he's bleeding pretty bad...", + "Crew: Someone grab the salvage!", + "Crew: Easy does it, wounded coming aboard...", + "Crew: Keep it level, we're climbing in...", + "Crew: Steady now, injured personnel...", + "Crew: Oh God, there's so much blood...", + "Crew: Medic! Where's the first aid kit?", + "Crew: Hold position, almost loaded...", + "Crew: Watch the rotor wash!", + "Crew: Someone's unconscious, careful!", + "Crew: Getting the wounded in first...", + "Crew: Steady as she goes...", + "Crew: Holy hell, Mike's leg is fucked up...", + "Crew: Hurry, he's losing blood fast!", + "Crew: Nice and easy, don't rush...", + "Crew: Everyone watch your step...", + "Crew: Loading wounded, give us a second...", + "Crew: Jesus, that's a lot of shrapnel...", + "Crew: Keep those rotors spinning!", + "Crew: Almost done, standby...", + "Crew: Careful, compound fracture here!", + "Crew: Someone's in shock, move it!", + "Crew: Loading gear, then we're good...", + "Crew: Stay put, we're working...", + "Crew: Damn, this guy's a mess...", + "Crew: Getting everyone situated...", + "Crew: Nice flying, keep it steady...", + "Crew: Hold still while we board...", + "Crew: Watch that head wound!", + "Crew: Everyone stay calm...", + "Crew: Getting the critical cases first...", + "Crew: Standby, loading continues...", + "Crew: Someone's got a sucking chest wound!", + "Crew: Keep that bird steady, sir!", + "Crew: Almost there, patience...", + "Crew: Wounded first, then equipment...", + "Crew: Oh fuck, internal bleeding...", + "Crew: Stay with us, buddy!", + "Crew: Loading process underway...", + "Crew: Keep those engines running!", + "Crew: Careful with his arm, it's shattered!", + "Crew: Getting everyone secured...", + "Crew: Hold your position, pilot!", + "Crew: Someone's not breathing right...", + "Crew: Almost done loading...", + "Crew: Watch your footing!", + "Crew: Traumatic amputation, careful!", + "Crew: Everyone grab something!", + "Crew: Standby, still boarding...", + "Crew: Nice hover, keep it up...", + "Crew: Getting the gear stowed...", + "Crew: Oh man, burns everywhere...", + "Crew: Stay conscious, stay with me!", + "Crew: Loading in progress...", + "Crew: Excellent flying, seriously...", + "Crew: Watch out for that wound!", + "Crew: Everyone move carefully...", + "Crew: He's going into shock!", + "Crew: Almost finished boarding...", + "Crew: Keep it stable, we're working...", + "Crew: Jesus, look at his face...", + "Crew: Getting everyone in...", + "Crew: Hold that position!", + "Crew: Someone's barely conscious...", + "Crew: Loading continues, standby...", + "Crew: Perfect hover, captain!", + "Crew: Careful, severe trauma here...", + "Crew: Everyone's moving slow...", + "Crew: Hold on, still loading...", + "Crew: Damn good flying, pilot!", + "Crew: Watch the blood slick!", + "Crew: Getting situated here...", + "Crew: He needs a hospital NOW...", + "Crew: Almost done, keep steady...", + "Crew: Loading wounded personnel...", + "Crew: Stay with us, soldier!", + "Crew: Keep those rotors turning...", + "Crew: Careful, major injuries...", + "Crew: Everyone board carefully...", + "Crew: Hold position, nearly done...", + "Crew: Oh God, the smell...", + "Crew: Loading critical cases...", + "Crew: Steady now, pilot...", + "Crew: Someone's in bad shape...", + "Crew: Almost loaded up...", + "Crew: Nice hover, excellent control...", + "Crew: Watch the shrapnel wounds!", + "Crew: Everyone move slowly...", + "Crew: He's bleeding out!", + "Crew: Getting everyone aboard...", + "Crew: Hold that hover!", + "Crew: Severe burns, careful!", + "Crew: Loading in progress, standby...", + "Crew: Perfect positioning, sir...", + "Crew: Watch those injuries!", + "Crew: Everyone take it easy...", + "Crew: Almost finished here...", + }, + + -- Unloading messages (shown when delivering crew to MASH) + UnloadingMessages = { + "Crew: Hold steady, do not lift - stretchers are rolling out!", + "Crew: Stay put, we're getting the wounded offloaded!", + "Crew: Keep us grounded, medics are still working inside!", + "Crew: We're at MASH! Thank you so much!", + "Crew: Finally! Get these guys to the docs!", + "Crew: Medical team, we need help here!", + "Crew: We made it! Get the wounded inside!", + "Crew: MASH arrival! These guys need immediate attention!", + "Crew: Unloading! Someone call the surgeons!", + "Crew: We're here! Priority casualties!", + "Crew: Made it alive! You're incredible!", + "Crew: MASH delivery! Critical patients!", + "Crew: Get the medics! We got wounded!", + "Crew: Arrived! These guys are in bad shape!", + "Crew: We're down! Medical emergency!", + "Crew: At MASH! Someone help these men!", + "Crew: Delivered! Thank God for you!", + "Crew: We made it! Get stretchers!", + "Crew: Arrival confirmed! Wounded aboard!", + "Crew: Finally here! Need doctors NOW!", + "Crew: MASH drop-off! Several critical!", + "Crew: We're safe! Get the medical team!", + "Crew: Landed! These boys need surgery!", + "Crew: Delivery complete! You saved lives today!", + "Crew: At medical! Urgent care needed!", + "Crew: We're here! Someone's coding!", + "Crew: MASH arrival! Serious trauma cases!", + "Crew: Made it! Outstanding flying!", + "Crew: Delivered safely! Medical assist required!", + "Crew: We're down! Get the surgical team!", + "Crew: At MASH! Multiple wounded!", + "Crew: Arrived! These guys won't last long!", + "Crew: Delivery! We owe you everything!", + "Crew: MASH landing! Emergency cases!", + "Crew: We made it! Immediate medical attention!", + "Crew: Here safe! Call the surgeons!", + "Crew: Delivered! Some really bad injuries!", + "Crew: At medical! They need help fast!", + "Crew: We're here! You're a hero!", + "Crew: MASH drop! Priority patients!", + "Crew: Arrived alive! Medical emergency!", + "Crew: Delivery complete! Get them inside!", + "Crew: We made it! Someone's critical!", + "Crew: At MASH! Severe casualties!", + "Crew: Landed safely! Thank you!", + "Crew: Delivered! Medical team needed!", + "Crew: We're here! These guys are fucked up!", + "Crew: MASH arrival! Get the doctors!", + "Crew: Made it! They're losing blood!", + "Crew: Arrived! Urgent surgical cases!", + "Crew: We're down! Multiple trauma!", + "Crew: At medical! You saved our asses!", + "Crew: Delivery! Several critical injuries!", + "Crew: MASH landing! They need OR stat!", + "Crew: We made it! Heavy casualties!", + "Crew: Here safely! Medical response!", + "Crew: Delivered! Some won't make it without surgery!", + "Crew: At MASH! Emergency personnel needed!", + "Crew: Arrived! These boys need immediate care!", + "Crew: We're here! Call triage!", + "Crew: MASH drop-off! Serious wounds!", + "Crew: Made it alive! Outstanding work!", + "Crew: Delivered safely! Get the medics!", + "Crew: We're down! They're in rough shape!", + "Crew: At medical! Priority one casualties!", + "Crew: Arrived! You're a lifesaver!", + "Crew: Delivery complete! Medical emergency!", + "Crew: MASH landing! Critical patients!", + "Crew: We made it! Someone's not breathing well!", + "Crew: Here! Get them to surgery!", + "Crew: Delivered! Severe trauma aboard!", + "Crew: At MASH! They need doctors now!", + "Crew: Arrived safely! You're amazing!", + "Crew: We're here! Multiple serious injuries!", + "Crew: MASH drop! Get the surgical team!", + "Crew: Made it! Thank fucking God!", + "Crew: Delivered! Several need immediate surgery!", + "Crew: We're down! Medical assist!", + "Crew: At medical! These guys are critical!", + "Crew: Arrived! You deserve a medal!", + "Crew: Delivery! Heavy casualties!", + "Crew: MASH landing! Emergency patients!", + "Crew: We made it! Get help quick!", + "Crew: Here safely! Brilliant flying!", + "Crew: Delivered! Someone's in bad shape!", + "Crew: At MASH! Urgent care!", + "Crew: Arrived alive! Medical emergency!", + "Crew: We're here! They need triage!", + "Crew: MASH drop-off! You saved lives!", + "Crew: Made it! These boys need help!", + "Crew: Delivered safely! Call the doctors!", + "Crew: We're down! Priority casualties!", + "Crew: At medical! You're our hero!", + "Crew: Arrived! Severe wounds here!", + "Crew: Delivery complete! Get medical personnel!", + "Crew: MASH landing! Critical condition!", + "Crew: We made it! They're barely hanging on!", + "Crew: Here! Immediate medical attention!", + "Crew: Delivered! Someone's dying!", + "Crew: At MASH! Get the OR ready!", + "Crew: Arrived safely! We can't thank you enough!", + "Crew: We're here! Emergency surgery needed!", + "Crew: MASH drop! Multiple trauma!", + "Crew: Made it alive! Exceptional flying!", + "Crew: Delivered! They need help now!", + "Crew: We're down! Medical response required!", + }, + + -- Unload completion messages (shown when offload finishes) + UnloadCompleteMessages = { + "MASH: Offload complete! Medical teams have the wounded!", + "MASH: Patients transferred! You're cleared to lift!", + "MASH: All casualties delivered! Incredible flying!", + "MASH: They're inside! Mission accomplished!", + "MASH: Every patient is in triage! Thank you!", + "MASH: Transfer complete! Head back when ready!", + "MASH: Doctors have them! Outstanding job!", + "MASH: Wounded are inside! You saved them!", + "MASH: Hand-off confirmed! You're good to go!", + "MASH: Casualties secure! Medical team standing by!", + "MASH: Delivery confirmed! Take a breather, pilot!", + "MASH: All stretchers filled! We are done here!", + "MASH: Hospital staff has the patients! Great work!", + "MASH: Unload complete! You nailed that landing!", + "MASH: MASH has control! You're clear, thank you!", + "MASH: Every survivor is inside! Hell yes!", + "MASH: Docs have them! Back to the fight when ready!", + "MASH: Handoff complete! You earned the praise!", + "MASH: Medical team secured the wounded! Legend!", + "MASH: Transfer complete! Outstanding steady hover!", + "MASH: They're in the OR! You rock, pilot!", + "MASH: Casualties delivered! Spin it back up when ready!", + "MASH: MASH confirms receipt! You're a lifesaver!", + "MASH: Every patient is safe! Mission complete!", + }, + + -- Enroute messages (periodic chatter with bearing/distance to MASH) + EnrouteToMashMessages = { + "Crew: Steady hands—{mash} sits at bearing {brg}°, {rng} {rng_u} ahead; patients are trying to nap.", + "Crew: Nav board says {mash} is {brg}° for {rng} {rng_u}; keep it gentle so the IVs stay put.", + "Crew: If you hold {brg}° for {rng} {rng_u}, {mash} will have hot coffee waiting—no promises on taste.", + "Crew: Confirmed, {mash} straight off the nose at {brg}°, {rng} {rng_u}; wounded are counting on you.", + "Crew: Stay on {brg}° for {rng} {rng_u} and we’ll roll into {mash} like heroes instead of hooligans.", + "Crew: Tilt a hair left—{mash} lies {brg}° at {rng} {rng_u}; let’s not overshoot the hospital.", + "Crew: Keep the climb smooth; {mash} is {brg}° at {rng} {rng_u} and the patients already look green.", + "Crew: Plot shows {mash} bearing {brg}°, range {rng} {rng_u}; mother hen wants her chicks delivered.", + "Crew: Hold that heading {brg}° and we’ll be on final to {mash} in {rng} {rng_u}; medics are on standby.", + "Crew: Reminder—{mash} is {brg}° at {rng} {rng_u}; try not to buzz the command tent this run.", + "Crew: Flight doc says keep turbulence down; {mash} sits {brg}° out at {rng} {rng_u}.", + "Crew: Stay focused—{mash} ahead {brg}°, {rng} {rng_u}; every bump costs us more paperwork.", + "Crew: We owe those medics a beer; {mash} is {brg}° for {rng} {rng_u}, so let’s get there in one piece.", + "Crew: Update from ops: {mash} remains {brg}° at {rng} {rng_u}; throttle down before the pad sneaks up.", + "Crew: Patients are asking if this thing comes with a smoother ride—{mash} {brg}°, {rng} {rng_u} to go.", + "Crew: Keep your cool—{mash} is {brg}° at {rng} {rng_u}; med bay is laying out stretchers now.", + "Crew: Good news, {mash} has fresh morphine; bad news, it’s {brg}° and {rng} {rng_u} away—step on it.", + "Crew: Command wants ETA—tell them {mash} is {brg}° for {rng} {rng_u} and we’re hauling wounded and sass.", + "Crew: That squeak you hear is the stretcher—stay on {brg}° for {rng} {rng_u} to {mash}.", + "Crew: Don’t mind the swearing; we’re {rng} {rng_u} from {mash} on bearing {brg}° and the pain meds wore off.", + "Crew: Eyes outside—{mash} sits {brg}° at {rng} {rng_u}; flak gunners better keep their heads down.", + "Crew: Weather’s clear—{mash} is {brg}° out {rng} {rng_u}; let’s not invent new IFR procedures.", + "Crew: Remember your autorotation drills? Neither do we. Fly {brg}° for {rng} {rng_u} to {mash} and keep her humming.", + "Crew: The guy on stretcher two wants to know if {mash} is really {brg}° at {rng} {rng_u}; I told him yes, please prove me right.", + "Crew: Rotor check good; {mash} bearing {brg}°, distance {rng} {rng_u}. Try to act like professionals.", + "Crew: Stay low and fast—{mash} {brg}° {rng} {rng_u}; enemy radios are whining already.", + "Crew: You’re doing great—just keep {brg}° for {rng} {rng_u} and {mash} will take the baton.", + "Crew: Map scribble says {mash} is {brg}° and {rng} {rng_u}; let’s prove cartography still works.", + "Crew: Pilot, the patients voted: less banking, more {mash}. Bearing {brg}°, {rng} {rng_u}.", + "Crew: We cross the line into {mash} territory in {rng} {rng_u} at {brg}°; keep the blades happy.", + "Crew: Hot tip—{mash} chefs saved us soup if we make {brg}° in {rng} {rng_u}; pretty sure it’s edible.", + "Crew: Another bump like that and I’m filing a complaint; {mash} is {brg}° at {rng} {rng_u}, so aim true.", + "Crew: The wounded in back just made side bets on landing—bearing {brg}°, range {rng} {rng_u} to {mash}.", + "Crew: Stay on that compass—{mash} sits {brg}° at {rng} {rng_u}; medics already prepped the triage tent.", + "Crew: Copy tower—{mash} runway metaphorically lies {brg}° and {rng} {rng_u} ahead; no victory rolls.", + "Crew: Someone alert the chaplain—we’re {rng} {rng_u} out from {mash} on {brg}° and our patients could use jokes.", + "Crew: Keep chatter clear—{mash} is {brg}° away at {rng} {rng_u}; let’s land before the morphine fades.", + "Crew: They promised me coffee at {mash} if we stick {brg}° for {rng} {rng_u}; don’t ruin this.", + "Crew: Plotting intercept—{mash} coordinates show {brg}°/{rng} {rng_u}; maintain this track.", + "Crew: I know the gauges say fine but the guys in back disagree; {mash} {brg}°, {rng} {rng_u}.", + "Crew: Remember, no barrel rolls; {mash} lies {brg}° at {rng} {rng_u}, and the surgeon will kill us if we’re late.", + "Crew: Keep the skids level; {mash} is {brg}° and {rng} {rng_u} away begging for customers.", + "Crew: We’re on schedule—{mash} sits {brg}° at {rng} {rng_u}; try not to invent new delays.", + "Crew: Latest wind check says {mash} {brg}°, {rng} {rng_u}; adjust trim before the patients revolt.", + "Crew: The medic in back just promised cookies if we hit {brg}° for {rng} {rng_u} to {mash}.", + "Crew: Hold blades steady—{mash} is {brg}° at {rng} {rng_u}; stretcher straps can only do so much.", + "Crew: Copy you’re bored, but {mash} is {brg}° for {rng} {rng_u}; no scenic detours today.", + "Crew: If you overshoot {mash} by {rng} {rng_u} I’m telling command it was deliberate; target bearing {brg}°.", + "Crew: Serious faces—we’re {rng} {rng_u} out from {mash} on {brg}° and these folks hurt like hell.", + "Crew: Hey pilot, the guy with the busted leg says thanks—just keep {brg}° for {rng} {rng_u} to {mash}.", + "Crew: That was a nice thermal—maybe avoid the next one; {mash} sits {brg}° at {rng} {rng_u}.", + "Crew: Keep those eyes up; {mash} is {brg}° away {rng} {rng_u}; CAS flights are buzzing around.", + "Crew: Reminder: {mash} won’t accept deliveries dumped on the lawn; {brg}° and {rng} {rng_u} to touchdown.", + "Crew: Ops pinged again; told them we’re {rng} {rng_u} from {mash} on heading {brg}° and flying like pros.", + "Crew: We promised the patients a soft landing; {mash} bearing {brg}°, distance {rng} {rng_u}.", + "Crew: Keep the profile low—{mash} is {brg}° at {rng} {rng_u}; AAA spots are grumpy today.", + "Crew: Message from tower: {mash} pad is clear; track {brg}° for {rng} {rng_u} and watch the dust.", + "Crew: Someone in back just yanked an IV—slow the hell down; {mash} {brg}°, {rng} {rng_u}.", + "Crew: We’re so close I can smell antiseptic—{mash} is {brg}° and {rng} {rng_u} from here.", + "Crew: If we shave more time the medics might actually smile; {mash} lies {brg}° at {rng} {rng_u}.", + "Crew: Friendly reminder—{mash} is {brg}° at {rng} {rng_u}; try not to park on their tent again.", + "Crew: The patients voted you best pilot if we hit {mash} at {brg}° in {rng} {rng_u}; don’t blow the election.", + "Crew: I’ve got morphine bets riding on you; {mash} sits {brg}° for {rng} {rng_u}.", + "Crew: Keep your head in the game—{mash} {brg}°, {rng} {rng_u}; enemy gunners love tall rotor masts.", + "Crew: That rattle is the litter, not the engine; {mash} is {brg}° and {rng} {rng_u} out.", + "Crew: Flight lead wants a status—reported {mash} bearing {brg}°, {rng} {rng_u}; keep us honest.", + "Crew: Patient three says thanks for not crashing—yet; {mash} {brg}°, {rng} {rng_u}.", + "Crew: If you see the chaplain waving, you missed—{mash} sits {brg}° at {rng} {rng_u}.", + "Crew: Med bay just radioed; they’re warming blankets. That’s {mash} {brg}° at {rng} {rng_u}.", + "Crew: Stay locked on {brg}° for {rng} {rng_u}; {mash} already cleared a pad.", + "Crew: Little turbulence ahead; {mash} bearing {brg}°, {rng} {rng_u}; grip it and grin.", + "Crew: The guy on the stretcher wants to know if we’re lost—tell him {mash} {brg}°, {rng} {rng_u}.", + "Crew: Hold altitude; {mash} is {brg}° away {rng} {rng_u} and the medics hate surprise autorotations.", + "Crew: Confirming nav—{mash} at {brg}°, {rng} {rng_u}; you keep flying, we’ll keep them calm.", + "Crew: If anyone asks, yes we’re inbound; {mash} sits {brg}° {rng} {rng_u} out.", + "Crew: Think happy thoughts—{mash} is {brg}° at {rng} {rng_u}; patients can smell fear.", + "Crew: Quit sightseeing—{mash} lies {brg}° and {rng} {rng_u}; let’s deliver the meat wagon.", + "Crew: Keep that nose pointed {brg}°; {mash} is only {rng} {rng_u} away and my nerves are shot.", + "Crew: We promised a fast ride; {mash} sits {brg}° at {rng} {rng_u}. No pressure.", + "Crew: You’re lined up perfect—{mash} {brg}°, {rng} {rng_u}; now just keep it that way.", + "Crew: The surgeon texted—he wants his patients now. {mash} bearing {brg}°, {rng} {rng_u}.", + "Crew: The wounded are timing us; {mash} is {brg}° at {rng} {rng_u} so don’t dilly-dally.", + "Crew: Another five minutes and {mash} will start nagging—hold {brg}° for {rng} {rng_u}.", + "Crew: Keep the blade slap mellow; {mash} sits {brg}° at {rng} {rng_u}.", + "Crew: Airspeed’s good; {mash} is {brg}° for {rng} {rng_u}; cue inspirational soundtrack.", + "Crew: Patient four says if we keep {brg}° for {rng} {rng_u}, drinks are on him at {mash}.", + "Crew: Don’t ask why the stretcher smells like smoke; just fly {brg}° {rng} {rng_u} to {mash}.", + "Crew: Tower says we’re clear direct {mash}; bearing {brg}°, {rng} {rng_u}.", + "Crew: If the engine coughs again we’re walking—{mash} sits {brg}° at {rng} {rng_u}; keep the RPM up.", + "Crew: Calm voices only—{mash} sits {brg}° {rng} {rng_u}; the patients listen to tone more than words.", + "Crew: Promise the guys in back we’ll hit {brg}° for {rng} {rng_u} and land like silk at {mash}.", + "Crew: There’s a small bet you’ll flare too high; prove them wrong—{mash} {brg}°, {rng} {rng_u}.", + "Crew: The medic wants you to skip the cowboy routine; {mash} lies {brg}° at {rng} {rng_u}.", + "Crew: That vibration is fine; what’s not fine is missing {mash} at {brg}° in {rng} {rng_u}.", + "Crew: Keep the collective steady—{mash} {brg}°, {rng} {rng_u}; we’re hauling precious cargo.", + "Crew: Someone promised me a hot meal at {mash}; stay on {brg}° for {rng} {rng_u} and make it happen.", + "Crew: The patients say if you wobble again they’re walking; {mash} {brg}°, {rng} {rng_u}.", + "Crew: Hold that horizon—{mash} is {brg}° for {rng} {rng_u}; the doc already scrubbed in.", + "Crew: Eyes on the prize—{mash} {brg}°, {rng} {rng_u}; don’t let the wind push us off.", + "Crew: Finish strong; {mash} sits {brg}° {rng} {rng_u}. Wheels down and we’re heroes again.", + }, + + -- Crew unit types per coalition (fallback if not specified in catalog) + CrewUnitTypes = { + [coalition.side.BLUE] = 'Soldier M4', + [coalition.side.RED] = 'Paratrooper RPG-16', -- Try Russian paratrooper instead + }, + + -- MANPADS unit types per coalition (one random crew member gets this weapon) + ManPadUnitTypes = { + [coalition.side.BLUE] = 'Soldier stinger', + [coalition.side.RED] = 'SA-18 Igla manpad', + }, + + -- Respawn settings + RespawnOnPickup = true, -- if true, vehicle respawns when crew loaded into helo + RespawnOffset = 15, -- meters from original death position + RespawnSameHeading = true, -- preserve original heading + + -- Automatic pickup/unload settings + AutoPickup = { + Enabled = true, -- if true, crews will be picked up automatically when helicopter lands nearby + MaxDistance = 30, -- meters - max distance for automatic crew pickup + CheckInterval = 3, -- seconds between checks for landed helicopters + RequireGroundContact = true, -- when true, helicopter must be firmly on the ground before crews move + GroundContactAGL = 4, -- meters AGL threshold treated as “landed” for ground contact purposes + MaxLandingSpeed = 2, -- m/s ground speed limit while parked; prevents chasing sliding helicopters + LoadDelay = 15, -- seconds crews need to board after reaching helicopter (must stay landed) + SettledAGL = 6.0, -- maximum AGL considered safely settled during boarding hold + AirAbortGrace = 2, -- seconds of hover tolerated during boarding before aborting + }, + + AutoUnload = { + Enabled = true, -- if true, crews automatically unload when landed in MASH zone + UnloadDelay = 15, -- seconds after landing before auto-unload triggers + GroundContactAGL = 3.5, -- meters AGL treated as “on the ground” for auto-unload (taller skids/mod helos) + SettledAGL = 6.0, -- maximum AGL considered safely settled for the unload hold to run (relative to terrain) + MaxLandingSpeed = 2.0, -- m/s ground speed limit while holding to unload + AirAbortGrace = 2, -- seconds of hover wiggle tolerated before aborting the unload hold + }, + + EnrouteMessages = { + Enabled = true, + Interval = 123, -- seconds between in-flight status quips while MEDEVAC patients onboard + }, + + -- Salvage system + Salvage = { + Enabled = true, + PoolType = 'global', -- 'global' = coalition-wide pool + DefaultValue = 1, -- default salvageValue if not in catalog + ShowInStatus = true, -- show salvage points in F10 status menu + AutoApply = true, -- auto-use salvage when out of stock (no manual confirmation) + AllowAnyItem = true, -- can build items that never had inventory using salvage + }, + + -- Map markers for downed crews + MapMarkers = { + Enabled = true, + IconText = '🔴 MEDEVAC', -- prefix for marker text + ShowGrid = true, -- include grid coordinates in marker + ShowTimeRemaining = true, -- show expiration time in marker + ShowSalvageValue = true, -- show salvage value in marker + }, + + -- Warning messages before crew timeout + Warnings = { + { time = 900, message = 'MEDEVAC: {crew} at {grid} has 15 minutes remaining!' }, + { time = 300, message = 'URGENT MEDEVAC: {crew} at {grid} will be KIA in 5 minutes!' }, + }, + + MASHZoneRadius = 500, -- default radius for MASH zones + MASHZoneColors = { + border = {1, 1, 0, 0.85}, -- yellow border + fill = {1, 0.75, 0.8, 0.25}, -- pink fill + }, + + -- Mobile MASH (player-deployable via crates) + MobileMASH = { + Enabled = true, + ZoneRadius = 500, -- radius of Mobile MASH zone in meters + CrateRecipeKey = 'MOBILE_MASH', -- catalog key for building mobile MASH + AnnouncementInterval = 1800, -- 30 mins between announcements + BeaconFrequency = '30.0 FM', -- radio frequency for announcements + Destructible = true, + VehicleTypes = { + [coalition.side.BLUE] = 'M-113', -- Medical variant for BLUE + [coalition.side.RED] = 'BTR_D', -- Medical/transport variant for RED + }, + AutoIncrementName = true, -- "Mobile MASH 1", "Mobile MASH 2"... + }, + + -- Statistics tracking + Statistics = { + Enabled = true, + TrackByPlayer = false, -- if true, track per-player stats (not yet implemented) + }, +} + +-- ========================= +-- Sling-Load Salvage Configuration (MOVED) +-- ========================= +-- #region SlingLoadSalvage Config +-- NOTE: SlingLoadSalvage configuration has been MOVED into CTLD.Config.SlingLoadSalvage +-- so that it properly gets copied to each CTLD instance via DeepCopy/DeepMerge. +-- The old CTLD.SlingLoadSalvage global definition here is removed to avoid confusion. +-- See CTLD.Config.SlingLoadSalvage above for the actual configuration. +-- #endregion SlingLoadSalvage Config +--=================================================================================================================================================== +-- #endregion MEDEVAC Config + + -- #region State + -- Internal state tables +CTLD._instances = CTLD._instances or {} +CTLD._crates = {} -- [crateName] = { key, zone, side, spawnTime, point } +CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey, weightKg } +CTLD._loadedCrates = {} -- [groupName] = { total=n, totalWeightKg=w, byKey = { key -> count } } +CTLD._loadedTroopTypes = {} -- [groupName] = { total=n, byType = { typeKey -> count }, labels = { typeKey -> label } } +CTLD._deployedTroops = {} -- [groupName] = { typeKey, count, side, spawnTime, point, weightKg } +CTLD._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t } +CTLD._unitLast = {} -- [unitName] = { x, z, t } +CTLD._coachState = {} -- [unitName] = { lastKeyTimes = {key->time}, lastHint = "", phase = "", lastPhaseMsg = 0, target = crateName, holdStart = nil } +CTLD._msgState = { } -- messaging throttle state: [scopeKey] = { lastKeyTimes = { key -> time } } +CTLD._buildConfirm = {} -- [groupName] = time of first build request (awaiting confirmation) +CTLD._buildCooldown = {} -- [groupName] = time of last successful build +CTLD._NextMarkupId = 10000 -- global-ish id generator shared by instances for map drawings +-- Spatial indexing for hover pickup performance +CTLD._spatialGrid = CTLD._spatialGrid or {} -- [gridKey] = { crates = {name->meta}, troops = {name->meta} } +CTLD._spatialGridSize = 500 -- meters per grid cell (tunable based on hover pickup distance) +-- Inventory state +CTLD._stockByZone = CTLD._stockByZone or {} -- [zoneName] = { [crateKey] = count } +CTLD._inStockMenus = CTLD._inStockMenus or {} -- per-group filtered menu handles +CTLD._jtacReservedCodes = CTLD._jtacReservedCodes or { + [coalition.side.BLUE] = {}, + [coalition.side.RED] = {}, + [coalition.side.NEUTRAL] = {}, +} +-- MEDEVAC state +CTLD._medevacCrews = CTLD._medevacCrews or {} -- [crewGroupName] = { vehicleType, side, spawnTime, position, salvageValue, markerID, originalHeading, requestTime, warningsSent } +CTLD._salvagePoints = CTLD._salvagePoints or {} -- [coalition.side] = points (global pool) +CTLD._mashZones = CTLD._mashZones or {} -- [zoneName] = { zone, side, isMobile, unitName (if mobile) } +CTLD._mobileMASHCounter = CTLD._mobileMASHCounter or { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0 } +CTLD._medevacStats = CTLD._medevacStats or { -- [coalition.side] = { spawned, rescued, delivered, timedOut, killed, salvageEarned, vehiclesRespawned } + [coalition.side.BLUE] = { spawned = 0, rescued = 0, delivered = 0, timedOut = 0, killed = 0, salvageEarned = 0, vehiclesRespawned = 0, salvageUsed = 0 }, + [coalition.side.RED] = { spawned = 0, rescued = 0, delivered = 0, timedOut = 0, killed = 0, salvageEarned = 0, vehiclesRespawned = 0, salvageUsed = 0 }, +} +CTLD._medevacUnloadStates = CTLD._medevacUnloadStates or {} -- [groupName] = { startTime, delay, holdAnnounced, nextReminder } +CTLD._medevacLoadStates = CTLD._medevacLoadStates or {} -- [groupName] = { startTime, delay, crewGroupName, crewData, holdAnnounced, nextReminder } +CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} -- [groupName] = { nextSend, lastIndex } + +-- Sling-Load Salvage state +CTLD._salvageCrates = CTLD._salvageCrates or {} -- [crateName] = { side, weight, spawnTime, position, initialHealth, rewardValue, warningsSent, staticObject, crateClass } +CTLD._salvageDropZones = CTLD._salvageDropZones or {} -- [zoneName] = { zone, side, active } +CTLD._salvageStats = CTLD._salvageStats or { -- [coalition.side] = { spawned, delivered, expired, totalWeight, totalReward } + [coalition.side.BLUE] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, + [coalition.side.RED] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, +} +-- One-shot timer tracking for cleanup +CTLD._pendingTimers = CTLD._pendingTimers or {} -- [timerId] = true + +-- FARP System state +CTLD._farpData = CTLD._farpData or {} -- [fobZoneName] = { stage = 1/2/3, statics = {name1, name2...}, coalition = side } +CTLD._farpZones = CTLD._farpZones or {} -- [farpZoneName] = { zone, side, stage } + +local function _distanceXZ(a, b) + if not a or not b then return math.huge end + local dx = (a.x or 0) - (b.x or 0) + local dz = (a.z or 0) - (b.z or 0) + return math.sqrt(dx * dx + dz * dz) +end + +local function _buildSphereVolume(point, radius) + local px = (point and point.x) or 0 + local pz = (point and point.z) or 0 + local py = (point and (point.y or point.alt)) + if py == nil and land and land.getHeight then + local ok, h = pcall(land.getHeight, { x = px, y = pz }) + if ok and type(h) == 'number' then py = h end + end + py = py or 0 + local volId = (world and world.VolumeType and world.VolumeType.SPHERE) or 0 + return { + id = volId, + params = { + point = { x = px, y = py, z = pz }, + radius = radius or 0, + } + } +end + +-- Check if a crate is being sling-loaded by scanning for nearby helicopters +-- Static objects don't have inAir() method, so we check if any unit is carrying it +local function _isCrateHooked(crateObj) + if not crateObj then return false end + + -- For dynamic objects (vehicles), inAir() works + if crateObj.inAir then + local ok, result = pcall(function() return crateObj:inAir() end) + if ok and result then return true end + end + + -- For static objects: check if the crate itself is elevated above ground + -- This indicates it's actually being carried, not just near a helicopter + local cratePos = crateObj:getPoint() + if not cratePos then return false end + + -- Get ground height at crate position + local landHeight = land.getHeight({x = cratePos.x, y = cratePos.z}) + if not landHeight then landHeight = 0 end + + -- If crate is more than 2 meters above ground, it's being carried + -- (accounts for terrain variations and crate size) + local heightAboveGround = cratePos.y - landHeight + if heightAboveGround > 2 then + return true + end + + return false +end + +local function _fmtTemplate(tpl, data) + if not tpl or tpl == '' then return '' end + -- Support placeholder keys with underscores (e.g., {zone_dist_u}) + return (tpl:gsub('{([%w_]+)}', function(k) + local v = data and data[k] + -- If value is missing, leave placeholder intact to aid debugging + if v == nil then return '{'..k..'}' end + return tostring(v) + end)) +end + +function CTLD:_FindNearestFriendlyTransport(position, side, radius) + if not position or not side then return nil end + radius = radius or 600 + local bestGroupName + local bestDist = math.huge + local sphere = _buildSphereVolume(position, radius) + world.searchObjects(Object.Category.UNIT, sphere, function(obj) + if not obj or (not obj.isExist or not obj:isExist()) then return true end + if not obj.getCoalition or obj:getCoalition() ~= side then return true end + local grp = obj.getGroup and obj:getGroup() + if not grp then return true end + local grpName = grp.getName and grp:getName() + if not grpName then return true end + local objPos = obj.getPoint and obj:getPoint() + local dist = _distanceXZ(position, objPos) + if dist < bestDist then + bestDist = dist + bestGroupName = grpName + end + return true + end) + if not bestGroupName then return nil end + local mooseGrp = GROUP:FindByName(bestGroupName) + return mooseGrp +end + +function CTLD:_SendSalvageHint(meta, messageKey, data, position, cooldown) + if not meta or not messageKey then return end + cooldown = cooldown or 10 + meta.hintCooldowns = meta.hintCooldowns or {} + local hintKey = messageKey + if data and data.zone then hintKey = hintKey .. ':' .. data.zone end + local now = timer.getTime() + local last = meta.hintCooldowns[hintKey] or 0 + if (now - last) < cooldown then return end + meta.hintCooldowns[hintKey] = now + + local template = self.Messages and self.Messages[messageKey] + if not template then return end + local text = _fmtTemplate(template, data or {}) + if not text or text == '' then return end + + local recipient = self:_FindNearestFriendlyTransport(position, meta.side, 700) + local recipientLabel + if recipient then + _msgGroup(recipient, text) + recipientLabel = recipient.GetName and recipient:GetName() or 'nearest transport' + else + _msgCoalition(meta.side, text) + recipientLabel = string.format('coalition-%s', meta.side == coalition.side.BLUE and 'BLUE' or 'RED') + end + + local zoneLabel = (data and data.zone) and (' zone '..tostring(data.zone)) or '' + _logInfo(string.format('[SlingLoadSalvage] Hint %s -> %s for crate %s%s', + messageKey, + recipientLabel or 'unknown recipient', + data and data.id or 'unknown', + zoneLabel)) +end + +function CTLD:_CheckCrateZoneHints(crateName, meta, cratePos) + if not meta or not cratePos then return end + local zoneSets = { + { list = self.PickupZones, active = self._ZoneActive and self._ZoneActive.Pickup, label = 'Pickup' }, + { list = self.FOBZones, active = self._ZoneActive and self._ZoneActive.FOB, label = 'FOB' }, + { list = self.DropZones, active = self._ZoneActive and self._ZoneActive.Drop, label = 'Drop' }, + } + + for _, entry in ipairs(zoneSets) do + local zones = entry.list or {} + if #zones > 0 then + for _, zone in ipairs(zones) do + local zoneName = zone:GetName() + local isActive = true + if entry.active and zoneName then + isActive = (entry.active[zoneName] ~= false) + end + if isActive and zone:IsVec3InZone(cratePos) then + self:_SendSalvageHint(meta, 'slingload_salvage_wrong_zone', { + id = crateName, + zone = zoneName, + zone_type = entry.label, + }, cratePos, 15) + return + end + end + end + end +end + + -- #endregion State + +-- ========================= +-- Utilities +-- ========================= + -- #region Utilities + +-- Select a random crate spawn point inside the zone while respecting separation rules. +function CTLD:_computeCrateSpawnPoint(zone, opts) + opts = opts or {} + if not zone or not zone.GetPointVec3 then return nil end + + local centerVec = zone:GetPointVec3() + if not centerVec then return nil end + local center = { x = centerVec.x, z = centerVec.z } + local rZone = self:_getZoneRadius(zone) + + local edgeBuf = math.max(0, opts.edgeBuffer or self.Config.PickupZoneSpawnEdgeBuffer or 10) + local minOff = math.max(0, opts.minOffset or self.Config.PickupZoneSpawnMinOffset or 5) + local extraPad = math.max(0, opts.additionalEdgeBuffer or 0) + local rMax = math.max(0, (rZone or 150) - edgeBuf - extraPad) + if rMax < 0 then rMax = 0 end + + local tries = math.max(1, opts.tries or self.Config.CrateSpawnSeparationTries or 6) + local minSep = opts.minSeparation + if minSep == nil then + minSep = math.max(0, self.Config.CrateSpawnMinSeparation or 7) + end + + local skipSeparation = opts.skipSeparationCheck == true + local ignoreCrates = {} + if opts.ignoreCrates then + for name,_ in pairs(opts.ignoreCrates) do + ignoreCrates[name] = true + end + end + + local preferred = opts.preferredPoint + local usePreferred = (preferred ~= nil) + + local function candidate() + if usePreferred then + usePreferred = false + return { x = preferred.x, z = preferred.z } + end + if (self.Config.PickupZoneSpawnRandomize == false) or rMax <= 0 then + return { x = center.x, z = center.z } + end + local rr + if rMax > minOff then + rr = minOff + math.sqrt(math.random()) * (rMax - minOff) + else + rr = rMax + end + local th = math.random() * 2 * math.pi + return { x = center.x + rr * math.cos(th), z = center.z + rr * math.sin(th) } + end + + local function isClear(pt) + if skipSeparation or minSep <= 0 then return true end + for name, meta in pairs(CTLD._crates) do + if not ignoreCrates[name] and meta and meta.side == self.Side and meta.point then + local dx = (meta.point.x - pt.x) + local dz = (meta.point.z - pt.z) + if (dx*dx + dz*dz) < (minSep*minSep) then + return false + end + end + end + return true + end + + local chosen = candidate() + if not chosen then return nil end + if not isClear(chosen) then + for _ = 1, tries - 1 do + local c = candidate() + if c and isClear(c) then + chosen = c + break + end + end + end + return chosen +end + +-- Build a centered grid of offsets for cluster placement, keeping index 1 at the origin. +function CTLD:_buildClusterOffsets(count, spacing) + local offsets = {} + if count <= 0 then return offsets, 0, 0 end + + offsets[1] = { x = 0, z = 0 } + if count == 1 then return offsets, 1, 1 end + + local perRow = math.ceil(math.sqrt(count)) + local rows = math.ceil(count / perRow) + local positions = {} + + for r = 1, rows do + for c = 1, perRow do + local ox = (c - ((perRow + 1) / 2)) * spacing + local oz = (r - ((rows + 1) / 2)) * spacing + if math.abs(ox) > 0.01 or math.abs(oz) > 0.01 then + positions[#positions + 1] = { x = ox, z = oz } + end + end + end + + table.sort(positions, function(a, b) + local da = a.x * a.x + a.z * a.z + local db = b.x * b.x + b.z * b.z + if da == db then + if a.x == b.x then return a.z < b.z end + return a.x < b.x + end + return da < db + end) + + local idx = 2 + for _,pos in ipairs(positions) do + if idx > count then break end + offsets[idx] = pos + idx = idx + 1 + end + + return offsets, perRow, rows +end + +-- Safe deep copy: prefer MOOSE UTILS.DeepCopy when available; fallback to Lua implementation +local function _deepcopy_fallback(obj, seen) + if type(obj) ~= 'table' then return obj end + seen = seen or {} + if seen[obj] then return seen[obj] end + local res = {} + seen[obj] = res + for k, v in pairs(obj) do + res[_deepcopy_fallback(k, seen)] = _deepcopy_fallback(v, seen) + end + local mt = getmetatable(obj) + if mt then setmetatable(res, mt) end + return res +end + +local function DeepCopy(obj) + if _G.UTILS and type(UTILS.DeepCopy) == 'function' then + return UTILS.DeepCopy(obj) + end + return _deepcopy_fallback(obj) +end + +-- Deep-merge src into dst (recursively). Arrays/lists in src replace dst. +local function DeepMerge(dst, src) + if type(dst) ~= 'table' or type(src) ~= 'table' then return src end + for k, v in pairs(src) do + if type(v) == 'table' then + local isArray = (rawget(v, 1) ~= nil) + if isArray then + dst[k] = DeepCopy(v) + else + dst[k] = DeepMerge(dst[k] or {}, v) + end + else + dst[k] = v + end + end + return dst +end + +local function _trim(value) + if type(value) ~= 'string' then return nil end + return value:match('^%s*(.-)%s*$') +end + +local function _addUniqueString(out, seen, value) + local v = _trim(value) + if not v or v == '' then return end + if not seen[v] then + seen[v] = true + out[#out + 1] = v + end +end + +local function _collectTypesFromBuilder(builder) + local out = {} + if type(builder) ~= 'function' then return out end + local ok, template = pcall(builder, { x = 0, y = 0, z = 0 }, 0) + if not ok or type(template) ~= 'table' then return out end + local units = template.units + if type(units) ~= 'table' then return out end + local seen = {} + for _,unit in pairs(units) do + if type(unit) == 'table' then + _addUniqueString(out, seen, unit.type) + end + end + return out +end + +local _unitTypeCache = {} + +local function _tableHasEntries(tbl) + if type(tbl) ~= 'table' then return false end + for _,_ in pairs(tbl) do return true end + return false +end + +local function _tableSize(tbl) + if type(tbl) ~= 'table' then return 0 end + local count = 0 + for _,_ in pairs(tbl) do count = count + 1 end + return count +end + +local function _isUnitDatabaseReady() + local dbRoot = rawget(_G, 'db') + if type(dbRoot) ~= 'table' then return false, 'missing' end + local unitByType = dbRoot.unit_by_type + if type(unitByType) ~= 'table' then return false, 'no_unit_by_type' end + if _tableHasEntries(unitByType) then return true, 'ok' end + if _tableHasEntries(dbRoot.units) or _tableHasEntries(dbRoot.Units) then + -- Older builds expose data under units/Units before unit_by_type is populated + return true, 'ok' + end + return false, 'empty' +end + +local function _unitTypeExists(typeName) + local key = _trim(typeName) + if not key or key == '' then return false end + if _unitTypeCache[key] ~= nil then return _unitTypeCache[key] end + + local exists = false + local visited = {} + + local dbRoot = rawget(_G, 'db') + + -- Fast-path: common lookup table exposed by DCS + if type(dbRoot) == 'table' and type(dbRoot.unit_by_type) == 'table' then + if dbRoot.unit_by_type[key] ~= nil then + _unitTypeCache[key] = true + return true + end + end + + local function walk(tbl) + if exists or type(tbl) ~= 'table' or visited[tbl] then return end + visited[tbl] = true + + if tbl.type == key or tbl.Type == key or tbl.unitType == key or tbl.typeName == key or tbl.Name == key then + exists = true + return + end + + for k,v in pairs(tbl) do + if type(k) == 'string' and k == key then + exists = true + return + end + if type(v) == 'string' then + if (k == 'type' or k == 'Type' or k == 'unitType' or k == 'typeName' or k == 'Name') and v == key then + exists = true + return + end + elseif type(v) == 'table' then + walk(v) + if exists then return end + end + end + end + + if type(dbRoot) == 'table' then + if dbRoot.units then walk(dbRoot.units) end + if not exists and dbRoot.Units then walk(dbRoot.Units) end + if not exists and dbRoot.unit_by_type then walk(dbRoot.unit_by_type) end + end + + _unitTypeCache[key] = exists + return exists +end + +-- Spatial indexing helpers for performance optimization +local function _getSpatialGridKey(x, z) + local gridSize = CTLD._spatialGridSize or 500 + local gx = math.floor(x / gridSize) + local gz = math.floor(z / gridSize) + return string.format("%d_%d", gx, gz) +end + +local function _addToSpatialGrid(name, meta, itemType) + if not meta or not meta.point then return end + local key = _getSpatialGridKey(meta.point.x, meta.point.z) + CTLD._spatialGrid[key] = CTLD._spatialGrid[key] or { crates = {}, troops = {} } + if itemType == 'crate' then + CTLD._spatialGrid[key].crates[name] = meta + elseif itemType == 'troops' then + CTLD._spatialGrid[key].troops[name] = meta + end +end + +local function _removeFromSpatialGrid(name, point, itemType) + if not point then return end + local key = _getSpatialGridKey(point.x, point.z) + local cell = CTLD._spatialGrid[key] + if cell then + if itemType == 'crate' then + cell.crates[name] = nil + elseif itemType == 'troops' then + cell.troops[name] = nil + end + -- Clean up empty cells + if not next(cell.crates) and not next(cell.troops) then + CTLD._spatialGrid[key] = nil + end + end +end + +local function _getNearbyFromSpatialGrid(x, z, maxDistance) + local gridSize = CTLD._spatialGridSize or 500 + local cellRadius = math.ceil(maxDistance / gridSize) + 1 + local centerGX = math.floor(x / gridSize) + local centerGZ = math.floor(z / gridSize) + + local nearby = { crates = {}, troops = {} } + for dx = -cellRadius, cellRadius do + for dz = -cellRadius, cellRadius do + local key = string.format("%d_%d", centerGX + dx, centerGZ + dz) + local cell = CTLD._spatialGrid[key] + if cell then + for name, meta in pairs(cell.crates) do + nearby.crates[name] = meta + end + for name, meta in pairs(cell.troops) do + nearby.troops[name] = meta + end + end + end + end + return nearby +end + +local function _isIn(list, value) + for _,v in ipairs(list or {}) do if v == value then return true end end + return false +end + +local function _vec3(x, y, z) + return { x = x, y = y, z = z } +end + +local function _distance3d(a, b) + if not a or not b then return math.huge end + local dx = (a.x or 0) - (b.x or 0) + local dy = (a.y or 0) - (b.y or 0) + local dz = (a.z or 0) - (b.z or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) +end + +local function _unitHasAttribute(unit, attr) + if not unit or not attr then return false end + local ok, res = pcall(function() return unit:hasAttribute(attr) end) + return ok and res == true +end + +local function _isDcsInfantry(unit) + if not unit then return false end + local tn = string.lower(unit:getTypeName() or '') + if tn:find('infantry') or tn:find('soldier') or tn:find('paratrooper') or tn:find('manpad') then + return true + end + return _unitHasAttribute(unit, 'Infantry') +end + +local function _hasLineOfSight(fromPos, toPos) + if not (fromPos and toPos) then return false end + local p1 = _vec3(fromPos.x, (fromPos.y or 0) + 2.0, fromPos.z) + local p2 = _vec3(toPos.x, (toPos.y or 0) + 2.0, toPos.z) + local ok, visible = pcall(function() return land.isVisible(p1, p2) end) + return ok and visible == true +end + +local function _jtacTargetScore(unit) + if not unit then return -1 end + if _unitHasAttribute(unit, 'SAM SR') or _unitHasAttribute(unit, 'SAM TR') or _unitHasAttribute(unit, 'SAM CC') or _unitHasAttribute(unit, 'SAM LN') then + return 120 + end + if _unitHasAttribute(unit, 'Air Defence') or _unitHasAttribute(unit, 'AAA') then + return 100 + end + if _unitHasAttribute(unit, 'IR Guided SAM') or _unitHasAttribute(unit, 'SAM') then + return 95 + end + if _unitHasAttribute(unit, 'Artillery') or _unitHasAttribute(unit, 'MLRS') then + return 80 + end + if _unitHasAttribute(unit, 'Armor') or _unitHasAttribute(unit, 'Tanks') then + return 70 + end + if _unitHasAttribute(unit, 'APC') or _unitHasAttribute(unit, 'IFV') then + return 60 + end + if _isDcsInfantry(unit) then + return 20 + end + return 40 +end + +local function _jtacTargetScoreProfiled(unit, profile) + -- Base score first + local base = _jtacTargetScore(unit) + local mult = 1.0 + local attribs = { + sam = _unitHasAttribute(unit, 'SAM') or _unitHasAttribute(unit, 'SAM SR') or _unitHasAttribute(unit, 'SAM TR') or _unitHasAttribute(unit, 'SAM LN') or _unitHasAttribute(unit, 'IR Guided SAM'), + aaa = _unitHasAttribute(unit, 'Air Defence') or _unitHasAttribute(unit, 'AAA'), + armor = _unitHasAttribute(unit, 'Armor') or _unitHasAttribute(unit, 'Tanks'), + ifv = _unitHasAttribute(unit, 'APC') or _unitHasAttribute(unit, 'Infantry Fighting Vehicle'), + arty = _unitHasAttribute(unit, 'Artillery') or _unitHasAttribute(unit, 'MLRS'), + inf = _isDcsInfantry(unit) + } + if profile == 'threat' then + if attribs.sam then mult = 1.6 elseif attribs.aaa then mult = 1.4 elseif attribs.armor then mult = 1.25 elseif attribs.ifv then mult = 1.15 elseif attribs.arty then mult = 1.1 elseif attribs.inf then mult = 0.8 end + elseif profile == 'armor' then + if attribs.armor then mult = 1.5 elseif attribs.ifv then mult = 1.3 elseif attribs.sam then mult = 1.25 elseif attribs.aaa then mult = 1.2 elseif attribs.arty then mult = 1.1 elseif attribs.inf then mult = 0.85 end + elseif profile == 'soft' then + if attribs.aaa then mult = 1.5 elseif attribs.arty then mult = 1.4 elseif attribs.inf then mult = 1.2 elseif attribs.ifv then mult = 1.1 elseif attribs.armor then mult = 1.0 elseif attribs.sam then mult = 0.9 end + elseif profile == 'inf_last' then + if attribs.inf then mult = 0.6 end + else + -- balanced; slight bump to SAM/AAA + if attribs.sam then mult = 1.3 elseif attribs.aaa then mult = 1.2 end + end + return math.floor(base * mult + 0.5) +end + +_msgGroup = function(group, text, t) + if not group then return end + MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToGroup(group) +end + +_msgCoalition = function(side, text, t) + MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToCoalition(side) +end + +-- ========================= +-- Logging Helpers +-- ========================= +-- Log levels: 0=NONE, 1=ERROR, 2=INFO, 3=VERBOSE, 4=DEBUG +local LOG_NONE = 0 +local LOG_ERROR = 1 +local LOG_INFO = 2 +local LOG_VERBOSE = 3 +local LOG_DEBUG = 4 + +local _logLevelLabels = { + [LOG_ERROR] = 'ERROR', + [LOG_INFO] = 'INFO', + [LOG_VERBOSE] = 'VERBOSE', + [LOG_DEBUG] = 'DEBUG', +} + +_log = function(level, msg) + local logLevel = CTLD.Config and CTLD.Config.LogLevel or LOG_INFO + if level > logLevel or level == LOG_NONE then return end + local label = _logLevelLabels[level] or tostring(level) + local text = string.format('[Moose_CTLD][%s] %s', label, tostring(msg)) + if env and env.info then + env.info(text) + else + print(text) + end +end + +_logError = function(msg) _log(LOG_ERROR, msg) end +_logInfo = function(msg) _log(LOG_INFO, msg) end +-- Treat VERBOSE as DEBUG-only to reduce noise unless LogLevel is 4 +_logVerbose = function(msg) _log(LOG_DEBUG, msg) end +_logDebug = function(msg) _log(LOG_DEBUG, msg) end + +-- Emits tagged messages regardless of configured LogLevel (used by explicit debug toggles) +_logImmediate = function(tag, msg) + local text = string.format('[Moose_CTLD][%s] %s', tag or 'DEBUG', tostring(msg)) + if env and env.info then + env.info(text) + else + print(text) + end +end + +local function _debugCrateSight(kind, params) + if not params or not params.unit then return end + CTLD._debugSightState = CTLD._debugSightState or {} + local key = string.format('%s:%s', kind, params.unit) + local state = CTLD._debugSightState[key] or {} + local now = params.now or timer.getTime() + local interval = params.interval or 1.0 + local step = params.step or 10.0 + local name = params.name or 'none' + local distance = params.distance or math.huge + local crateCount = params.count or 0 + local troopCount = params.troops or 0 + local shouldLog = false + + if not state.lastTime or interval <= 0 or (now - state.lastTime) >= interval then + shouldLog = true + end + if state.lastName ~= name then + shouldLog = true + end + if state.lastCount ~= crateCount or state.lastTroops ~= troopCount then + shouldLog = true + end + if distance ~= math.huge then + if not state.lastDist or math.abs(distance - state.lastDist) >= step then + shouldLog = true + end + elseif state.lastDist ~= math.huge then + shouldLog = true + end + + if not shouldLog then return end + + local distText = (distance ~= math.huge) and string.format('d=%.1f', distance) or 'd=n/a' + local summaryParts = { string.format('%d crate(s)', crateCount) } + if troopCount and troopCount > 0 then + table.insert(summaryParts, string.format('%d troop group(s)', troopCount)) + end + local summary = table.concat(summaryParts, ', ') + local noteParts = {} + if params.radius then table.insert(noteParts, string.format('radius=%dm', math.floor(params.radius))) end + if params.note then table.insert(noteParts, params.note) end + local noteText = (#noteParts > 0) and (' [' .. table.concat(noteParts, ' ') .. ']') or '' + local targetLabel = params.targetLabel or 'nearest' + local typeHint = params.typeHint and (' type=' .. params.typeHint) or '' + _logImmediate(kind, string.format('Unit %s tracking %s; %s=%s %s%s%s', + params.unit, summary, targetLabel, name, distText, typeHint, noteText)) + + state.lastTime = now + state.lastName = name + state.lastDist = distance + state.lastCount = crateCount + state.lastTroops = troopCount + CTLD._debugSightState[key] = state +end + +function CTLD:_collectEntryUnitTypes(entry) + local collected = {} + local seen = {} + if type(entry) ~= 'table' then return collected end + _addUniqueString(collected, seen, entry.unitType) + if type(entry.unitTypes) == 'table' then + for _,v in ipairs(entry.unitTypes) do + _addUniqueString(collected, seen, v) + end + end + if entry.build then + local fromBuilder = _collectTypesFromBuilder(entry.build) + for _,v in ipairs(fromBuilder) do + _addUniqueString(collected, seen, v) + end + end + return collected +end + +function CTLD:_validateCatalogUnitTypes() + if self._catalogValidated then return end + if self.Config and self.Config.SkipCatalogValidation then return end + + local dbReady, dbReason = _isUnitDatabaseReady() + if not dbReady then + if not self._catalogValidationDebugLogged then + self._catalogValidationDebugLogged = true + local dbRoot = rawget(_G, 'db') + local unitByTypeType = dbRoot and type(dbRoot.unit_by_type) or 'nil' + local unitsType = dbRoot and type(dbRoot.units) or 'nil' + local unitsAltType = dbRoot and type(dbRoot.Units) or 'nil' + local sampleKey = 'Soldier M4' + local sampleValue = (dbRoot and type(dbRoot.unit_by_type) == 'table') and dbRoot.unit_by_type[sampleKey] or nil + _logDebug(string.format('Catalog validation DB probe: reason=%s db=%s unit_by_type=%s units=%s Units=%s sample[%s]=%s', + tostring(dbReason), type(dbRoot), unitByTypeType, unitsType, unitsAltType, sampleKey, tostring(sampleValue))) + end + if dbReason == 'missing' or dbReason == 'no_unit_by_type' then + _logInfo('Catalog validation skipped: DCS mission scripting environment does not expose the global unit database (db/unit_by_type)') + self._catalogValidated = true + return + end + + self._catalogValidationRetries = (self._catalogValidationRetries or 0) + 1 + local retry = self._catalogValidationRetries + local retryLimit = 60 + if retry > retryLimit then + _logError('Catalog validation skipped: DCS unit database not available after repeated attempts') + self._catalogValidated = true + self._catalogValidationScheduled = nil + return + end + if timer and timer.scheduleFunction and timer.getTime then + if not self._catalogValidationScheduled then + self._catalogValidationScheduled = true + local delay = math.min(10, 1 + retry) + local instance = self + timer.scheduleFunction(function() + instance._catalogValidationScheduled = nil + instance._catalogValidated = nil + instance:_validateCatalogUnitTypes() + return nil + end, {}, timer.getTime() + delay) + end + if retry == 1 or (retry % 5 == 0) then + _logInfo(string.format('Catalog validation deferred: DCS unit database not ready yet (retry %d/%d)', retry, retryLimit)) + end + else + if retry == 1 then + _logInfo('Catalog validation deferred: DCS unit database not ready and timer API unavailable') + end + if retry >= 3 then + _logError('Catalog validation skipped: cannot access DCS unit database or schedule retries') + self._catalogValidated = true + end + end + return + end + + if self._catalogValidationRetries and self._catalogValidationRetries > 0 then + _unitTypeCache = {} + end + self._catalogValidationRetries = 0 + + local missing = {} + + local function markMissing(typeName, source) + local key = _trim(typeName) + if not key or key == '' then return end + local list = missing[key] + if not list then + list = {} + missing[key] = list + end + for _,ref in ipairs(list) do + if ref == source then return end + end + list[#list + 1] = source + end + + for key,entry in pairs(self.Config.CrateCatalog or {}) do + local types = self:_collectEntryUnitTypes(entry) + for _,unitType in ipairs(types) do + if not _unitTypeExists(unitType) then + markMissing(unitType, 'crate:'..tostring(key)) + end + end + end + + local troopDefs = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} + for label,def in pairs(troopDefs) do + local function check(list, suffix) + for _,unitType in ipairs(list or {}) do + if not _unitTypeExists(unitType) then + markMissing(unitType, string.format('troop:%s:%s', tostring(label), suffix)) + end + end + end + check(def.unitsBlue, 'blue') + check(def.unitsRed, 'red') + check(def.units, 'fallback') + end + + if next(missing) then + for typeName, sources in pairs(missing) do + _logError(string.format('Catalog validation: unknown unit type "%s" referenced by %s', typeName, table.concat(sources, ', '))) + end + else + _logInfo('Catalog validation: all referenced unit types resolved in DCS database') + end + + self._catalogValidated = true +end + +-- ========================= +-- Zone and Unit Utilities +-- ========================= + +local function _findZone(z) + if z.name then + local mz = ZONE:FindByName(z.name) + if mz then return mz end + end + if z.coord then + local r = z.radius or 150 + -- Create a Vec2 in a way that works even if MOOSE VECTOR2 class isn't available + local function _mkVec2(x, z) + if VECTOR2 and VECTOR2.New then return VECTOR2:New(x, z) end + -- DCS uses Vec2 with fields x and y + return { x = x, y = z } + end + local v = _mkVec2(z.coord.x, z.coord.z) + return ZONE_RADIUS:New(z.name or ('CTLD_ZONE_'..math.random(10000,99999)), v, r) + end + return nil +end + +local function _getUnitType(unit) + local ud = unit and unit:GetDesc() or nil + return ud and ud.typeName or unit and unit:GetTypeName() +end + +-- Get aircraft capacity limits for crates and troops +-- Returns { maxCrates, maxTroops, maxWeightKg } for the given unit +-- Falls back to DefaultCapacity if aircraft type not specifically configured +local function _getAircraftCapacity(unit) + if not unit then + return { + maxCrates = CTLD.Config.DefaultCapacity.maxCrates or 4, + maxTroops = CTLD.Config.DefaultCapacity.maxTroops or 12, + maxWeightKg = CTLD.Config.DefaultCapacity.maxWeightKg or 2000 + } + end + + local unitType = _getUnitType(unit) + local capacities = CTLD.Config.AircraftCapacities or {} + local specific = capacities[unitType] + + if specific then + return { + maxCrates = specific.maxCrates or 0, + maxTroops = specific.maxTroops or 0, + maxWeightKg = specific.maxWeightKg or 0 + } + end + + -- Fallback to defaults + local defaults = CTLD.Config.DefaultCapacity or {} + return { + maxCrates = defaults.maxCrates or 4, + maxTroops = defaults.maxTroops or 12, + maxWeightKg = defaults.maxWeightKg or 2000 + } +end + +-- Check if a unit is in the air (flying/hovering, not landed) +-- Based on original CTLD logic: uses DCS InAir() API plus velocity threshold +-- Returns: true if airborne, false if landed/grounded +local function _isUnitInAir(unit) + if not unit then return false end + + -- First check: DCS API InAir() - if it says we're on ground, trust it + if not unit:InAir() then + return false + end + + -- Second check: velocity threshold (handles edge cases where InAir() is true but we're stationary on ground) + -- Less than 0.05 m/s (~0.1 knots) = essentially stopped = consider landed + -- NOTE: AI can hold perfect hover, so only apply this check for player-controlled units + local vel = unit:GetVelocity() + if vel and unit:GetPlayerName() then + local vx = vel.x or 0 + local vz = vel.z or 0 + local groundSpeed = math.sqrt((vx * vx) + (vz * vz)) -- horizontal speed in m/s + if groundSpeed < 0.05 then + return false -- stopped on ground + end + end + + return true -- airborne +end + +-- Get ground speed in m/s for a unit +local function _getGroundSpeed(unit) + if not unit then return 0 end + local vel = unit:GetVelocity() + if not vel or not vel.x or not vel.z then return 0 end + return math.sqrt(vel.x * vel.x + vel.z * vel.z) +end + +-- Calculate height above ground level for a unit (meters) +local function _getUnitAGL(unit) + if not unit then return math.huge end + local pos = unit:GetPointVec3() + if not pos then return math.huge end + local terrain = 0 + if land and land.getHeight then + local success, h = pcall(land.getHeight, { x = pos.x, y = pos.z }) + if success and type(h) == 'number' then + terrain = h + end + end + return pos.y - terrain +end + +local function _nearestZonePoint(unit, list) + if not unit or not unit:IsAlive() then return nil end + -- Get unit position using DCS API to avoid dependency on MOOSE point methods + local uname = unit:GetName() + local du = Unit.getByName and Unit.getByName(uname) or nil + if not du or not du:getPoint() then return nil end + local up = du:getPoint() + local ux, uz = up.x, up.z + + local best, bestd = nil, nil + for _, z in ipairs(list or {}) do + local mz = _findZone(z) + local zx, zz + if z and z.name and trigger and trigger.misc and trigger.misc.getZone then + local tz = trigger.misc.getZone(z.name) + if tz and tz.point then zx, zz = tz.point.x, tz.point.z end + end + if (not zx) and mz and mz.GetPointVec3 then + local zp = mz:GetPointVec3() + -- Try to read numeric fields directly to avoid method calls + if zp and type(zp) == 'table' and zp.x and zp.z then zx, zz = zp.x, zp.z end + end + if (not zx) and z and z.coord then + zx, zz = z.coord.x, z.coord.z + end + + if zx and zz then + local dx = (zx - ux) + local dz = (zz - uz) + local d = math.sqrt(dx*dx + dz*dz) + if (not bestd) or d < bestd then best, bestd = mz, d end + end + end + if not best then return nil, nil end + return best, bestd +end + +-- Check if a unit is inside a Pickup Zone. Returns (inside:boolean, zone, dist, radius) +function CTLD:_isUnitInsidePickupZone(unit, activeOnly) + if not unit or not unit:IsAlive() then return false, nil, nil, nil end + local zone, dist + if activeOnly then + zone, dist = self:_nearestActivePickupZone(unit) + else + local defs = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} + zone, dist = _nearestZonePoint(unit, defs) + end + if not zone or not dist then return false, nil, nil, nil end + local r = self:_getZoneRadius(zone) + if not r then return false, zone, dist, nil end + return dist <= r, zone, dist, r +end + +-- Helper: get nearest ACTIVE pickup zone (by configured list), respecting CTLD's active flags +function CTLD:_collectActivePickupDefs() + local out = {} + local added = {} -- Track added zone names to prevent duplicates + + -- From config-defined zones + local defs = (self.Config and self.Config.Zones and self.Config.Zones.PickupZones) or {} + for _, z in ipairs(defs) do + local n = z.name + if (not n) or self._ZoneActive.Pickup[n] ~= false then + table.insert(out, z) + if n then added[n] = true end + end + end + + -- From MOOSE zone objects if present (skip if already added from config) + if self.PickupZones and #self.PickupZones > 0 then + for _, mz in ipairs(self.PickupZones) do + if mz and mz.GetName then + local n = mz:GetName() + if self._ZoneActive.Pickup[n] ~= false and not added[n] then + table.insert(out, { name = n }) + added[n] = true + end + end + end + end + return out +end + +function CTLD:_nearestActivePickupZone(unit) + return _nearestZonePoint(unit, self:_collectActivePickupDefs()) +end + +local function _defaultCountryForSide(side) + if not (country and country.id) then return nil end + if side == coalition.side.BLUE then + return country.id.USA or country.id.CJTF_BLUE + elseif side == coalition.side.RED then + return country.id.RUSSIA or country.id.CJTF_RED + elseif side == coalition.side.NEUTRAL then + return country.id.UN or country.id.CJTF_NEUTRAL or country.id.USA + end + return nil +end + +local function _coalitionAddGroup(side, category, groupData, ctldConfig) + -- Enforce side/category in groupData just to be safe + groupData.category = category + local countryId = ctldConfig and ctldConfig.CountryId + if not countryId then + countryId = _defaultCountryForSide(side) + if ctldConfig then ctldConfig.CountryId = countryId end + end + if countryId then + groupData.country = countryId + end + + -- Apply air-spawn altitude adjustment for AIRPLANE category if DroneAirSpawn is enabled + if category == Group.Category.AIRPLANE and ctldConfig and ctldConfig.DroneAirSpawn and ctldConfig.DroneAirSpawn.Enabled then + if groupData.units and #groupData.units > 0 then + local altAGL = ctldConfig.DroneAirSpawn.AltitudeMeters or 3048 + local speed = ctldConfig.DroneAirSpawn.SpeedMps or 120 + + for _, unit in ipairs(groupData.units) do + -- Get terrain height at spawn location + local terrainHeight = land.getHeight({x = unit.x, y = unit.y}) + -- Set altitude ASL (Above Sea Level) + unit.alt = terrainHeight + altAGL + unit.speed = speed + -- Ensure unit has appropriate spawn type set + unit.alt_type = "BARO" -- Barometric altitude + end + end + end + + local addCountry = countryId or side + return coalition.addGroup(addCountry, category, groupData) +end + +local function _spawnStaticCargo(side, point, cargoType, name) + local static = { + name = name, + type = cargoType, + x = point.x, + y = point.z, + heading = 0, + canCargo = true, + } + return coalition.addStaticObject(side, static) +end + +local function _vec3FromUnit(unit) + local p = unit:GetPointVec3() + return { x = p.x, y = p.y, z = p.z } +end + +-- Update DCS internal cargo weight based on loaded crates and troops +-- This affects aircraft performance (hover, fuel consumption, speed, etc.) +local function _updateCargoWeight(group) + if not group then return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local gname = group:GetName() + local totalWeight = 0 + + -- Add weight from loaded crates + local crateData = CTLD._loadedCrates[gname] + if crateData and crateData.totalWeightKg then + totalWeight = totalWeight + crateData.totalWeightKg + end + + -- Add weight from loaded troops + local troopData = CTLD._troopsLoaded[gname] + if troopData and troopData.weightKg then + totalWeight = totalWeight + troopData.weightKg + end + + -- Call DCS API to set internal cargo weight (affects flight model) + local unitName = unit:GetName() + if unitName and trigger and trigger.action and trigger.action.setUnitInternalCargo then + pcall(function() + trigger.action.setUnitInternalCargo(unitName, totalWeight) + end) + end +end + +-- Unique id generator for map markups (lines/circles/text) +local function _nextMarkupId() + CTLD._NextMarkupId = (CTLD._NextMarkupId or 10000) + 1 + return CTLD._NextMarkupId +end + +local function _spawnCrateSmoke(position, color, config, crateId) + if not position or not color then return end + + -- Parse config with defaults + local enabled = true + local autoRefresh = false + local refreshInterval = 240 + local maxRefreshDuration = 600 + local offsetMeters = 5 + local offsetRandom = true + local offsetVertical = 2 + + if config then + enabled = (config.Enabled ~= false) -- default true + autoRefresh = (config.AutoRefresh == true) + refreshInterval = tonumber(config.RefreshInterval) or 240 + maxRefreshDuration = tonumber(config.MaxRefreshDuration) or 600 + offsetMeters = tonumber(config.OffsetMeters) or 5 + offsetRandom = (config.OffsetRandom ~= false) -- default true + offsetVertical = tonumber(config.OffsetVertical) or 2 + end + + if not enabled then return end + + -- Compute ground-adjusted position with offsets + local sx, sz = position.x, position.z + local sy = position.y or 0 + if sy == 0 and land and land.getHeight then + local ok, h = pcall(land.getHeight, { x = sx, y = sz }) + if ok and type(h) == 'number' then sy = h end + end + + -- Apply lateral and vertical offsets + local ox, oz = 0, 0 + if offsetMeters > 0 then + local angle = offsetRandom and (math.random() * 2 * math.pi) or 0 + ox = offsetMeters * math.cos(angle) + oz = offsetMeters * math.sin(angle) + end + local smokePos = { x = sx + ox, y = sy + offsetVertical, z = sz + oz } + + -- Emit smoke now + local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) + if coord and coord.Smoke then + if color == trigger.smokeColor.Green then + coord:SmokeGreen() + elseif color == trigger.smokeColor.Red then + coord:SmokeRed() + elseif color == trigger.smokeColor.White then + coord:SmokeWhite() + elseif color == trigger.smokeColor.Orange then + coord:SmokeOrange() + elseif color == trigger.smokeColor.Blue then + coord:SmokeBlue() + else + coord:SmokeGreen() + end + else + trigger.action.smoke(smokePos, color) + end + + -- Record smoke meta for global refresh loop instead of per-crate timer + if autoRefresh and crateId and refreshInterval > 0 and maxRefreshDuration > 0 then + CTLD._crates = CTLD._crates or {} + local meta = CTLD._crates[crateId] + if meta then + meta._smoke = meta._smoke or {} + if not meta._smoke.enabled then + meta._smoke.enabled = true + end + meta._smoke.auto = true + meta._smoke.startTime = timer.getTime() + meta._smoke.nextTime = timer.getTime() + refreshInterval + meta._smoke.interval = refreshInterval + meta._smoke.maxDuration = maxRefreshDuration + meta._smoke.color = color + meta._smoke.offsetMeters = offsetMeters + meta._smoke.offsetRandom = offsetRandom + meta._smoke.offsetVertical = offsetVertical + + -- Ensure background ticker(s) are running + if CTLD._ensureBackgroundTasks then + CTLD:_ensureBackgroundTasks() + end + end + end +end + +-- Clean up smoke refresh schedule for a crate +local function _cleanupCrateSmoke(crateId) + if not crateId then return end + -- Clear legacy per-crate schedule if present + CTLD._smokeRefreshSchedules = CTLD._smokeRefreshSchedules or {} + if CTLD._smokeRefreshSchedules[crateId] then + if CTLD._smokeRefreshSchedules[crateId].funcId then + pcall(timer.removeFunction, CTLD._smokeRefreshSchedules[crateId].funcId) + end + CTLD._smokeRefreshSchedules[crateId] = nil + end + -- Clear new smoke meta so the global loop stops refreshing + if CTLD._crates and CTLD._crates[crateId] then + CTLD._crates[crateId]._smoke = nil + end +end + +-- Central schedule registry helpers +function CTLD:_registerSchedule(key, funcId) + self._schedules = self._schedules or {} + if self._schedules[key] then + pcall(timer.removeFunction, self._schedules[key]) + end + self._schedules[key] = funcId +end + +function CTLD:_cancelSchedule(key) + if self._schedules and self._schedules[key] then + pcall(timer.removeFunction, self._schedules[key]) + self._schedules[key] = nil + end +end + +-- Track one-shot timers for cleanup +local function _trackOneShotTimer(id) + if id and CTLD._pendingTimers then + CTLD._pendingTimers[id] = true + end + return id +end + +-- Clean up one-shot timers when they execute +local function _wrapOneShotCallback(callback) + return function(...) + local result = callback(...) + -- If callback returns a time, it's recurring - don't remove + if not result or type(result) ~= 'number' then + local trackedId = nil + for id, _ in pairs(CTLD._pendingTimers or {}) do + if id == callback then + trackedId = id + break + end + end + if trackedId and CTLD._pendingTimers then + CTLD._pendingTimers[trackedId] = nil + end + end + return result + end +end + +local function _removeMenuHandle(menu) + if not menu or type(menu) ~= 'table' then return end + + local function _menuIsRegistered(m) + if not MENU_INDEX then return true end + if not m.Group or not m.MenuText then return true end + local okPath, path = pcall(function() + return MENU_INDEX:ParentPath(m.ParentMenu, m.MenuText) + end) + if not okPath or not path then + return false + end + local okHas, registered = pcall(function() + return MENU_INDEX:HasGroupMenu(m.Group, path) + end) + if not okHas then + return false + end + return registered == m + end + + if menu.Remove and _menuIsRegistered(menu) then + local ok, err = pcall(function() menu:Remove() end) + if not ok then + _logVerbose(string.format('[MenuCleanup] Failed to remove menu %s: %s', tostring(menu.MenuText), tostring(err))) + end + end + + if menu.Destroy then pcall(function() menu:Destroy() end) end + if menu.Delete then pcall(function() menu:Delete() end) end +end + +local function _countTableEntries(t) + if not t then return 0 end + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local function _cleanupGroupMenus(ctld, groupName) + if not (ctld and groupName) then return end + if ctld.MenusByGroup and ctld.MenusByGroup[groupName] then + _removeMenuHandle(ctld.MenusByGroup[groupName]) + ctld.MenusByGroup[groupName] = nil + end + if CTLD._inStockMenus and CTLD._inStockMenus[groupName] then + for _, menu in pairs(CTLD._inStockMenus[groupName]) do + _removeMenuHandle(menu) + end + CTLD._inStockMenus[groupName] = nil + end +end + +local function _clearPerGroupCaches(groupName) + if not groupName then return end + local caches = { + CTLD._troopsLoaded, + CTLD._loadedCrates, + CTLD._loadedTroopTypes, + CTLD._deployedTroops, + CTLD._buildConfirm, + CTLD._buildCooldown, + CTLD._medevacUnloadStates, + CTLD._medevacLoadStates, + CTLD._medevacEnrouteStates, + CTLD._coachOverride, + } + for _, tbl in ipairs(caches) do + if tbl then tbl[groupName] = nil end + end + if CTLD._msgState then + CTLD._msgState['GRP:'..groupName] = nil + end + + -- Note: One-shot timers are now self-cleaning via wrapper, but we log for visibility + _logVerbose(string.format('[CTLD] Cleared caches for group %s', groupName)) +end + +local function _clearPerUnitCachesForGroup(group) + if not group then return end + local units + local ok, res = pcall(function() return group:GetUnits() end) + if ok and type(res) == 'table' then + units = res + else + local okUnit, unit = pcall(function() return group:GetUnit(1) end) + if okUnit and unit then units = { unit } end + end + if not units then return end + for _, unit in ipairs(units) do + if unit then + local uname = unit.GetName and unit:GetName() + if uname then + if CTLD._hoverState then CTLD._hoverState[uname] = nil end + if CTLD._unitLast then CTLD._unitLast[uname] = nil end + if CTLD._coachState then CTLD._coachState[uname] = nil end + if CTLD._groundLoadState then CTLD._groundLoadState[uname] = nil end + end + end + end +end + +local function _groupHasAliveTransport(group, allowedTypes) + if not (group and allowedTypes) then return false end + local units + local ok, res = pcall(function() return group:GetUnits() end) + if ok and type(res) == 'table' then + units = res + else + local okUnit, unit = pcall(function() return group:GetUnit(1) end) + if okUnit and unit then units = { unit } end + end + if not units then return false end + for _, unit in ipairs(units) do + if unit and unit.IsAlive and unit:IsAlive() then + local typ = _getUnitType(unit) + if typ and _isIn(allowedTypes, typ) then + return true + end + end + end + return false +end + +function CTLD:_cleanupTransportGroup(group, groupName) + local gname = groupName + if not gname and group and group.GetName then + gname = group:GetName() + end + if not gname then return end + + _cleanupGroupMenus(self, gname) + _clearPerGroupCaches(gname) + + if group then + _clearPerUnitCachesForGroup(group) + else + local mooseGroup = nil + if GROUP and GROUP.FindByName then + local ok, res = pcall(function() return GROUP:FindByName(gname) end) + if ok then mooseGroup = res end + end + if mooseGroup then _clearPerUnitCachesForGroup(mooseGroup) end + end + + -- Cleanup JTAC registry if this group had JTAC registered + if self._jtacRegistry and self._jtacRegistry[gname] then + self:_cleanupJTACEntry(gname) + end + + _logDebug(string.format('[MenuCleanup] Cleared CTLD state for group %s', gname)) +end + +function CTLD:_removeDynamicSalvageZone(zoneName, reason) + if not zoneName then return end + + if self.SalvageDropZones then + for idx = #self.SalvageDropZones, 1, -1 do + local zone = self.SalvageDropZones[idx] + if zone and zone.GetName and zone:GetName() == zoneName then + table.remove(self.SalvageDropZones, idx) + end + end + end + + if self._ZoneDefs and self._ZoneDefs.SalvageDropZones then + self._ZoneDefs.SalvageDropZones[zoneName] = nil + end + + if self._ZoneActive and self._ZoneActive.SalvageDrop then + self._ZoneActive.SalvageDrop[zoneName] = nil + end + + if self._DynamicSalvageZones then + self._DynamicSalvageZones[zoneName] = nil + end + + if self._DynamicSalvageQueue then + for idx = #self._DynamicSalvageQueue, 1, -1 do + if self._DynamicSalvageQueue[idx] == zoneName then + table.remove(self._DynamicSalvageQueue, idx) + end + end + end + + self:_removeZoneDrawing('SalvageDrop', zoneName) + _logInfo(string.format('[SlingLoadSalvage] Removed dynamic salvage zone %s (%s)', zoneName, reason or 'cleanup')) + local ok, err = pcall(function() self:DrawZonesOnMap() end) + if not ok then + _logError(string.format('[SlingLoadSalvage] DrawZonesOnMap failed after removing %s: %s', zoneName, tostring(err))) + end +end + +function CTLD:_enforceDynamicSalvageZoneLimit() + local cfg = self.Config and self.Config.SlingLoadSalvage or nil + if not cfg or not cfg.Enabled then return end + + local zones = self._DynamicSalvageZones + if not zones then return end + + local lifetime = tonumber(cfg.DynamicZoneLifetime or 0) or 0 + local maxZones = tonumber(cfg.MaxDynamicZones or 0) or 0 + local now = timer and timer.getTime and timer.getTime() or 0 + + if lifetime > 0 then + local expired = {} + for name, meta in pairs(zones) do + if meta then + local expiresAt = meta.expiresAt + if not expiresAt and meta.createdAt then + expiresAt = meta.createdAt + lifetime + end + if expiresAt and now >= expiresAt then + table.insert(expired, name) + end + end + end + for _, zname in ipairs(expired) do + self:_removeDynamicSalvageZone(zname, 'expired') + end + end + + if maxZones > 0 and self._DynamicSalvageQueue then + while #self._DynamicSalvageQueue > maxZones do + local oldest = table.remove(self._DynamicSalvageQueue, 1) + if oldest and zones[oldest] then + self:_removeDynamicSalvageZone(oldest, 'max-cap') + end + end + end +end + +-- Global smoke refresh ticker (single loop for all crates) +function CTLD:_ensureGlobalSmokeTicker() + if self._schedules and self._schedules.smokeTicker then return end + + local function tick() + local now = timer.getTime() + if CTLD and CTLD._crates then + for name, meta in pairs(CTLD._crates) do + if meta and meta._smoke and meta._smoke.auto and meta.point then + local s = meta._smoke + if (now - (s.startTime or now)) > (s.maxDuration or 0) then + meta._smoke = nil + elseif now >= (s.nextTime or 0) then + -- Spawn another puff + local pos = { x = meta.point.x, y = 0, z = meta.point.z } + if land and land.getHeight then + local ok, h = pcall(land.getHeight, { x = pos.x, y = pos.z }) + if ok and type(h) == 'number' then pos.y = h end + end + _spawnCrateSmoke(pos, s.color or trigger.smokeColor.Green, { + Enabled = true, + AutoRefresh = false, -- avoid recursion; we manage nextTime here + OffsetMeters = s.offsetMeters or 0, + OffsetRandom = s.offsetRandom ~= false, + OffsetVertical = s.offsetVertical or 0, + }, name) + s.nextTime = now + (s.interval or 240) + end + end + end + end + return timer.getTime() + 10 -- tick every 10s + end + + local id = timer.scheduleFunction(tick, nil, timer.getTime() + 10) + self:_registerSchedule('smokeTicker', id) +end + +-- Periodic GC to prune stale messaging/coach entries and smoke meta +function CTLD:_ensurePeriodicGC() + if self._schedules and self._schedules.periodicGC then return end + + local function gcTick() + local now = timer.getTime() + + -- Coach state: remove units that no longer exist + if CTLD and CTLD._coachState then + for uname, _ in pairs(CTLD._coachState) do + local u = Unit.getByName(uname) + if not u then CTLD._coachState[uname] = nil end + end + end + + -- Message throttle state: remove dead/missing groups + if CTLD and CTLD._msgState then + for scope, _ in pairs(CTLD._msgState) do + local gname = string.match(scope, '^GRP:(.+)$') + if gname then + local g = Group.getByName(gname) + if not g then CTLD._msgState[scope] = nil end + end + end + end + + -- Smoke meta: prune crates without points or exceeded duration + if CTLD and CTLD._crates then + for name, meta in pairs(CTLD._crates) do + if meta and meta._smoke then + local s = meta._smoke + if (not meta.point) or ((now - (s.startTime or now)) > (s.maxDuration or 0)) then + meta._smoke = nil + end + end + end + end + + -- Enforce MEDEVAC crew limit (keep last 100 requests) + if CTLD and CTLD._medevacCrews then + local crewCount = 0 + local crewList = {} + for crewName, crewData in pairs(CTLD._medevacCrews) do + crewCount = crewCount + 1 + table.insert(crewList, { name = crewName, time = crewData.requestTime or 0 }) + end + if crewCount > 100 then + table.sort(crewList, function(a, b) return a.time < b.time end) + local toRemove = crewCount - 100 + for i = 1, toRemove do + local crewName = crewList[i].name + local crewData = CTLD._medevacCrews[crewName] + if crewData and crewData.markerID then + pcall(function() trigger.action.removeMark(crewData.markerID) end) + end + CTLD._medevacCrews[crewName] = nil + end + env.info(string.format('[CTLD][GC] Pruned %d old MEDEVAC crew entries', toRemove)) + end + end + + -- Enforce salvage crate limit (keep last 50 active crates per side) + if CTLD and CTLD._salvageCrates then + local crateBySide = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {} } + for crateName, crateData in pairs(CTLD._salvageCrates) do + if crateData.side then + table.insert(crateBySide[crateData.side], { name = crateName, time = crateData.spawnTime or 0 }) + end + end + for side, crates in pairs(crateBySide) do + if #crates > 50 then + table.sort(crates, function(a, b) return a.time < b.time end) + local toRemove = #crates - 50 + for i = 1, toRemove do + local crateName = crates[i].name + local crateData = CTLD._salvageCrates[crateName] + if crateData and crateData.staticObject and crateData.staticObject.destroy then + pcall(function() crateData.staticObject:destroy() end) + end + CTLD._salvageCrates[crateName] = nil + end + local sideName = (side == coalition.side.BLUE and 'BLUE') or 'RED' + env.info(string.format('[CTLD][GC] Pruned %d old %s salvage crates', toRemove, sideName)) + end + end + end + + -- Clean up stale pending timer references + if CTLD and CTLD._pendingTimers then + local timerCount = 0 + for _ in pairs(CTLD._pendingTimers) do + timerCount = timerCount + 1 + end + if timerCount > 200 then + -- If we have too many pending timers, clear old ones (they may have fired already) + env.info(string.format('[CTLD][GC] Clearing %d stale timer references', timerCount)) + CTLD._pendingTimers = {} + end + end + + return timer.getTime() + 300 -- every 5 minutes + end + + local id = timer.scheduleFunction(gcTick, nil, timer.getTime() + 300) + self:_registerSchedule('periodicGC', id) +end + +function CTLD:_ensureBackgroundTasks() + if self._bgStarted then return end + self._bgStarted = true + self:_ensureGlobalSmokeTicker() + self:_ensurePeriodicGC() + self:_ensureAdaptiveBackgroundLoop() + self:_ensureSlingLoadEventHandler() + self:_startHourlyDiagnostics() +end + +function CTLD:_startHoverScheduler() + local coachCfg = CTLD.HoverCoachConfig or {} + if not coachCfg.enabled or self.HoverSched then return end + local interval = coachCfg.interval or 0.75 + local startDelay = coachCfg.startDelay or interval + self.HoverSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() self:ScanHoverPickup() end) + if not ok then _logError('HoverSched ScanHoverPickup error: '..tostring(err)) end + end, {}, startDelay, interval) +end + +function CTLD:_startGroundLoadScheduler() + local groundCfg = CTLD.GroundAutoLoadConfig or {} + if not groundCfg.Enabled or self.GroundLoadSched then return end + local interval = 1.0 -- check every second for ground load conditions + self.GroundLoadSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() self:ScanGroundAutoLoad() end) + if not ok then _logError('GroundLoadSched ScanGroundAutoLoad error: '..tostring(err)) end + end, {}, interval, interval) +end + +-- Adaptive background loop consolidating salvage checks and periodic pruning +function CTLD:_ensureAdaptiveBackgroundLoop() + if self._schedules and self._schedules.backgroundLoop then return end + + local function backgroundTick() + local now = timer.getTime() + + -- Salvage crate housekeeping + local cfg = self.Config.SlingLoadSalvage + local activeCrates = 0 + if cfg and cfg.Enabled then + for _, meta in pairs(CTLD._salvageCrates) do + if meta and meta.side == self.Side then + activeCrates = activeCrates + 1 + end + end + + -- Run the standard checker to handle delivery/expiration + local ok, err = pcall(function() self:_CheckSlingLoadSalvageCrates() end) + if not ok then + _logError('[SlingLoadSalvage] backgroundTick error: '..tostring(err)) + end + + -- Stale crate cleanup: destroy any crate that lost its static object reference + for cname, meta in pairs(CTLD._salvageCrates) do + if meta and (not meta.staticObject or (meta.staticObject.destroy and not meta.staticObject:isExist())) then + CTLD._salvageCrates[cname] = nil + end + end + + -- Dynamic salvage zone lifetime enforcement + self:_enforceDynamicSalvageZoneLimit() + end + + -- Hover coach cleanup: remove entries for missing units + if CTLD._coachState then + for uname, _ in pairs(CTLD._coachState) do + if not Unit.getByName(uname) then + CTLD._coachState[uname] = nil + end + end + end + + -- Ensure the hover scan scheduler matches active transport presence + local hasTransports = false + for gname, _ in pairs(self.MenusByGroup or {}) do + local grp = nil + if GROUP and GROUP.FindByName then + local ok, res = pcall(function() return GROUP:FindByName(gname) end) + if ok then grp = res end + end + if grp and grp:IsAlive() then + hasTransports = true + break + end + if not grp and Group and Group.getByName then + local ok, dcsGrp = pcall(function() return Group.getByName(gname) end) + if ok and dcsGrp and dcsGrp:isExist() then + hasTransports = true + break + end + end + end + + if hasTransports then + if (not self.HoverSched) and self._startHoverScheduler then + pcall(function() self:_startHoverScheduler() end) + end + else + if self.HoverSched and self.HoverSched.Stop then + pcall(function() self.HoverSched:Stop() end) + end + self.HoverSched = nil + end + + -- Determine next wake interval based on active salvage crates + local baseInterval = (cfg and cfg.DetectionInterval) or 5 + local adaptive = (cfg and cfg.AdaptiveIntervals) or {} + local nextInterval + if activeCrates == 0 then + nextInterval = adaptive.idle or math.max(baseInterval * 2, 10) + elseif activeCrates <= 20 then + nextInterval = adaptive.low or math.max(baseInterval * 2, 10) + elseif activeCrates <= 35 then + nextInterval = adaptive.medium or math.max(baseInterval * 3, 15) + else + nextInterval = adaptive.high or math.max(baseInterval * 4, 20) + end + nextInterval = math.min(nextInterval, 30) + + CTLD._lastSalvageInterval = nextInterval + + return now + nextInterval + end + + local id = timer.scheduleFunction(function() + local nextTime = backgroundTick() + return nextTime + end, nil, timer.getTime() + 2) + self:_registerSchedule('backgroundLoop', id) +end + +function CTLD:_ensureSlingLoadEventHandler() + -- No event handler needed - we use inAir() checks like original CTLD.lua + if self._slingHandlerRegistered then return end + self._slingHandlerRegistered = true +end + +function CTLD:_startHourlyDiagnostics() + if not (timer and timer.scheduleFunction) then return end + if self._schedules and self._schedules.hourlyDiagnostics then return end + + local function diagTick() + local salvageCount = 0 + for _, _ in ipairs(self.SalvageDropZones or {}) do salvageCount = salvageCount + 1 end + local menuCount = _countTableEntries(self.MenusByGroup) + local dynamicCount = _countTableEntries(self._DynamicSalvageZones) + local sideLabel = (self.Side == coalition.side.BLUE and 'BLUE') + or (self.Side == coalition.side.RED and 'RED') + or (self.Side == coalition.side.NEUTRAL and 'NEUTRAL') + or tostring(self.Side) + + -- Comprehensive memory usage stats + local cratesCount = _countTableEntries(CTLD._crates) + local medevacCrewsCount = _countTableEntries(CTLD._medevacCrews) + local salvageCratesCount = _countTableEntries(CTLD._salvageCrates) + local coachStateCount = _countTableEntries(CTLD._coachState) + local hoverStateCount = _countTableEntries(CTLD._hoverState) + local pendingTimersCount = _countTableEntries(CTLD._pendingTimers) + local msgStateCount = _countTableEntries(CTLD._msgState) + + env.info(string.format('[CTLD][SoakTest][%s] salvageZones=%d dynamicZones=%d menus=%d', + sideLabel, salvageCount, dynamicCount, menuCount)) + env.info(string.format('[CTLD][Memory][%s] crates=%d medevacCrews=%d salvageCrates=%d coachState=%d hoverState=%d pendingTimers=%d msgState=%d', + sideLabel, cratesCount, medevacCrewsCount, salvageCratesCount, coachStateCount, hoverStateCount, pendingTimersCount, msgStateCount)) + + return timer.getTime() + 3600 + end + + local id = timer.scheduleFunction(function() + return diagTick() + end, nil, timer.getTime() + 3600) + self:_registerSchedule('hourlyDiagnostics', id) +end + +local function _ctldHelperGet() + local ctldClass = _G._MOOSE_CTLD or CTLD + if not ctldClass then + env.info('[CTLD] Runtime helper unavailable: CTLD not loaded') + return nil + end + return ctldClass +end + +local function _ctldSideName(side) + if side == coalition.side.BLUE then return 'BLUE' end + if side == coalition.side.RED then return 'RED' end + if side == coalition.side.NEUTRAL then return 'NEUTRAL' end + return tostring(side or 'nil') +end + +local function _ctldFormatSeconds(secs) + if not secs or secs <= 0 then return '0s' end + local minutes = math.floor(secs / 60) + local seconds = math.floor(secs % 60) + if minutes > 0 then + return string.format('%dm%02ds', minutes, seconds) + end + return string.format('%ds', seconds) +end + +if not _G.CTLD_DumpRuntimeStats then + function _G.CTLD_DumpRuntimeStats() + local ctldClass = _ctldHelperGet() + if not ctldClass then return end + + local now = timer and timer.getTime and timer.getTime() or 0 + local salvageBySide = {} + local oldestBySide = {} + for name, meta in pairs(ctldClass._salvageCrates or {}) do + if meta and meta.side then + salvageBySide[meta.side] = (salvageBySide[meta.side] or 0) + 1 + if meta.spawnTime then + local age = now - meta.spawnTime + if age > (oldestBySide[meta.side] or 0) then + oldestBySide[meta.side] = age + end + end + end + end + + local medevacCount = 0 + for _ in pairs(ctldClass._medevacCrews or {}) do + medevacCount = medevacCount + 1 + end + + env.info(string.format('[CTLD] Active salvage crates: BLUE=%d RED=%d', + salvageBySide[coalition.side.BLUE] or 0, + salvageBySide[coalition.side.RED] or 0)) + env.info(string.format('[CTLD] Oldest salvage crate age: BLUE=%s RED=%s', + _ctldFormatSeconds(oldestBySide[coalition.side.BLUE] or 0), + _ctldFormatSeconds(oldestBySide[coalition.side.RED] or 0))) + env.info(string.format('[CTLD] Active MEDEVAC crews: %d', medevacCount)) + + local totalSchedules = 0 + local instanceCount = 0 + for _, inst in ipairs(ctldClass._instances or {}) do + instanceCount = instanceCount + 1 + if inst._schedules then + for _ in pairs(inst._schedules) do + totalSchedules = totalSchedules + 1 + end + end + for key, value in pairs(inst) do + if type(value) == 'table' and value.Stop and key:match('Sched') then + totalSchedules = totalSchedules + 1 + end + end + end + + env.info(string.format('[CTLD] Instances: %d, scheduler objects (approx): %d', instanceCount, totalSchedules)) + env.info(string.format('[CTLD] Last salvage loop interval: %s', _ctldFormatSeconds(ctldClass._lastSalvageInterval or 0))) + end +end + +if not _G.CTLD_DumpCrateAges then + function _G.CTLD_DumpCrateAges() + local ctldClass = _ctldHelperGet() + if not ctldClass then return end + + local crates = {} + local now = timer and timer.getTime and timer.getTime() or 0 + local lifetime = (ctldClass.Config and ctldClass.Config.SlingLoadSalvage and ctldClass.Config.SlingLoadSalvage.CrateLifetime) or 0 + for name, meta in pairs(ctldClass._salvageCrates or {}) do + local age = meta.spawnTime and (now - meta.spawnTime) or 0 + table.insert(crates, { + name = name, + side = meta.side, + age = age, + remaining = math.max(0, lifetime - age), + weight = meta.weight or 0, + }) + end + + table.sort(crates, function(a, b) return a.age > b.age end) + + env.info(string.format('[CTLD] Listing %d salvage crates (lifetime=%ss)', #crates, tostring(lifetime))) + for i = 1, math.min(#crates, 25) do + local c = crates[i] + env.info(string.format('[CTLD] #%02d %s side=%s weight=%dkg age=%s remaining=%s', + i, c.name, _ctldSideName(c.side), c.weight, + _ctldFormatSeconds(c.age), _ctldFormatSeconds(c.remaining))) + end + end +end + +if not _G.CTLD_ListSchedules then + function _G.CTLD_ListSchedules() + local ctldClass = _ctldHelperGet() + if not ctldClass then return end + + local instances = ctldClass._instances or {} + if #instances == 0 then + env.info('[CTLD] No CTLD instances registered') + return + end + + for idx, inst in ipairs(instances) do + local sideLabel = _ctldSideName(inst.Side) + env.info(string.format('[CTLD] Instance #%d side=%s', idx, sideLabel)) + + if inst._schedules then + for key, funcId in pairs(inst._schedules) do + env.info(string.format(' [timer] key=%s funcId=%s', tostring(key), tostring(funcId))) + end + end + + for key, value in pairs(inst) do + if type(value) == 'table' and value.Stop and key:match('Sched') then + local running = 'unknown' + if type(value.IsRunning) == 'function' then + local ok, res = pcall(value.IsRunning, value) + if ok then running = tostring(res) end + end + env.info(string.format(' [sched] key=%s running=%s', tostring(key), running)) + end + end + end + end +end + +-- Spawn smoke for MEDEVAC crews with offset system +-- position: {x, y, z} table +-- smokeColor: trigger.smokeColor enum value +-- config: MEDEVAC config table (for offset settings) +local function _spawnMEDEVACSmoke(position, smokeColor, config) + if not position or not smokeColor then return end + + -- Apply smoke offset system + local smokePos = { + x = position.x, + y = land.getHeight({x = position.x, y = position.z}), + z = position.z + } + + local offsetMeters = (config and config.SmokeOffsetMeters) or 5 + local offsetRandom = (not config or config.SmokeOffsetRandom ~= false) -- default true + local offsetVertical = (config and config.SmokeOffsetVertical) or 2 + + if offsetMeters > 0 then + local angle = 0 -- North by default + if offsetRandom then + angle = math.random() * 2 * math.pi -- Random direction + end + smokePos.x = smokePos.x + offsetMeters * math.cos(angle) + smokePos.z = smokePos.z + offsetMeters * math.sin(angle) + end + smokePos.y = smokePos.y + offsetVertical + + -- Spawn smoke using MOOSE COORDINATE (better appearance) or fallback to trigger.action.smoke + local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) + if coord and coord.Smoke then + if smokeColor == trigger.smokeColor.Green then + coord:SmokeGreen() + elseif smokeColor == trigger.smokeColor.Red then + coord:SmokeRed() + elseif smokeColor == trigger.smokeColor.White then + coord:SmokeWhite() + elseif smokeColor == trigger.smokeColor.Orange then + coord:SmokeOrange() + elseif smokeColor == trigger.smokeColor.Blue then + coord:SmokeBlue() + else + coord:SmokeRed() -- default + end + else + trigger.action.smoke(smokePos, smokeColor) + end +end + +-- Resolve a zone's center (vec3) and radius (meters). +-- Accepts a MOOSE ZONE object returned by _findZone/ZONE:FindByName/ZONE_RADIUS:New +function CTLD:_getZoneCenterAndRadius(mz) + if not mz then return nil, nil end + local name = mz.GetName and mz:GetName() or nil + -- Prefer Mission Editor zone data if available + if name and trigger and trigger.misc and trigger.misc.getZone then + local z = trigger.misc.getZone(name) + if z and z.point and z.radius then + local p = { x = z.point.x, y = z.point.y or 0, z = z.point.z } + return p, z.radius + end + end + -- Fall back to MOOSE zone center + local pv = mz.GetPointVec3 and mz:GetPointVec3() or nil + local p = pv and { x = pv.x, y = pv.y or 0, z = pv.z } or nil + -- Try to fetch a configured radius from our zone defs + local r + if name and self._ZoneDefs then + local d = self._ZoneDefs.PickupZones and self._ZoneDefs.PickupZones[name] + or self._ZoneDefs.DropZones and self._ZoneDefs.DropZones[name] + or self._ZoneDefs.FOBZones and self._ZoneDefs.FOBZones[name] + or self._ZoneDefs.MASHZones and self._ZoneDefs.MASHZones[name] + if d and d.radius then r = d.radius end + end + r = r or (mz.GetRadius and mz:GetRadius()) or 150 + return p, r +end + +-- Draw a circle and label for a zone on the F10 map for this coalition. +-- kind: 'Pickup' | 'Drop' | 'FOB' | 'MASH' +function CTLD:_drawZoneCircleAndLabel(kind, mz, opts) + if not (trigger and trigger.action and trigger.action.circleToAll and trigger.action.textToAll) then return end + opts = opts or {} + local p, r = self:_getZoneCenterAndRadius(mz) + if not p or not r then return end + local side = (opts.ForAll and -1) or self.Side + local outline = opts.OutlineColor or {0,1,0,0.85} + local fill = opts.FillColor or {0,1,0,0.15} + local lineType = opts.LineType or 1 + local readOnly = (opts.ReadOnly ~= false) + local fontSize = opts.FontSize or 18 + local labelPrefix = opts.LabelPrefix or 'Zone' + local zname = (mz.GetName and mz:GetName()) or '(zone)' + local circleId = _nextMarkupId() + local textId = _nextMarkupId() + trigger.action.circleToAll(side, circleId, p, r, outline, fill, lineType, readOnly, "") + local label = string.format('%s: %s', labelPrefix, zname) + -- Place label centered above the circle (12 o'clock). Horizontal nudge via LabelOffsetX. + -- Simple formula: extra offset from edge = r * ratio + fromEdge + local extra = (r * (opts.LabelOffsetRatio or 0.0)) + (opts.LabelOffsetFromEdge or 30) + local nx = p.x + (opts.LabelOffsetX or 0) + local nz = p.z - (r + (extra or 0)) + local textPos = { x = nx, y = 0, z = nz } + trigger.action.textToAll(side, textId, textPos, {1,1,1,0.9}, {0,0,0,0}, fontSize, readOnly, label) + -- Track ids so they can be cleared later + self._MapMarkup = self._MapMarkup or { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + self._MapMarkup[kind] = self._MapMarkup[kind] or {} + self._MapMarkup[kind][zname] = { circle = circleId, text = textId } +end + +function CTLD:ClearMapDrawings() + if not (self._MapMarkup and trigger and trigger.action and trigger.action.removeMark) then return end + for _, byName in pairs(self._MapMarkup) do + for _, ids in pairs(byName) do + if ids.circle then pcall(trigger.action.removeMark, ids.circle) end + if ids.text then pcall(trigger.action.removeMark, ids.text) end + end + end + self._MapMarkup = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } +end + +function CTLD:_removeZoneDrawing(kind, zname) + if not (self._MapMarkup and self._MapMarkup[kind] and self._MapMarkup[kind][zname]) then return end + local ids = self._MapMarkup[kind][zname] + if ids.circle then pcall(trigger.action.removeMark, ids.circle) end + if ids.text then pcall(trigger.action.removeMark, ids.text) end + self._MapMarkup[kind][zname] = nil +end + +-- Public: set a specific zone active/inactive by kind and name +function CTLD:SetZoneActive(kind, name, active, silent) + if not (kind and name) then return end + local k = (kind == 'Pickup' or kind == 'Drop' or kind == 'FOB' or kind == 'MASH') and kind or nil + if not k then return end + self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } + self._ZoneActive[k][name] = (active ~= false) + -- Update drawings for this one zone only + if self.Config.MapDraw and self.Config.MapDraw.Enabled then + -- Find the MOOSE zone object by name + local list = (k=='Pickup' and self.PickupZones) or (k=='Drop' and self.DropZones) or (k=='FOB' and self.FOBZones) or (k=='MASH' and self.MASHZones) or {} + local mz + for _,z in ipairs(list or {}) do if z and z.GetName and z:GetName() == name then mz = z break end end + if self._ZoneActive[k][name] then + if mz then + local md = self.Config.MapDraw + local opts = { + OutlineColor = md.OutlineColor, + FillColor = (md.FillColors and md.FillColors[k]) or nil, + LineType = (md.LineTypes and md.LineTypes[k]) or md.LineType or 1, + FontSize = md.FontSize, + ReadOnly = (md.ReadOnly ~= false), + LabelOffsetX = md.LabelOffsetX, + LabelOffsetFromEdge = md.LabelOffsetFromEdge, + LabelOffsetRatio = md.LabelOffsetRatio, + LabelPrefix = ((md.LabelPrefixes and md.LabelPrefixes[k]) + or (k=='Pickup' and md.LabelPrefix) + or (k..' Zone')) + } + self:_drawZoneCircleAndLabel(k, mz, opts) + end + else + self:_removeZoneDrawing(k, name) + end + end + -- Optional messaging + local stateStr = self._ZoneActive[k][name] and 'ACTIVATED' or 'DEACTIVATED' + _logVerbose(string.format('Zone %s %s (%s)', tostring(name), stateStr, k)) + if not silent then + local msgKey = self._ZoneActive[k][name] and 'zone_activated' or 'zone_deactivated' + _eventSend(self, nil, self.Side, msgKey, { kind = k, zone = name }) + end +end + +function CTLD:DrawZonesOnMap() + local md = self.Config and self.Config.MapDraw or {} + if not md.Enabled then return end + -- Clear previous drawings before re-drawing + self:ClearMapDrawings() + local opts = { + OutlineColor = md.OutlineColor, + LineType = md.LineType, + FontSize = md.FontSize, + ReadOnly = (md.ReadOnly ~= false), + LabelPrefix = md.LabelPrefix or 'Zone', + LabelOffsetX = md.LabelOffsetX, + LabelOffsetFromEdge = md.LabelOffsetFromEdge, + LabelOffsetRatio = md.LabelOffsetRatio, + ForAll = (md.ForAll == true), + } + if md.DrawPickupZones then + for _,mz in ipairs(self.PickupZones or {}) do + local name = mz:GetName() + if self._ZoneActive.Pickup[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.Pickup) or md.LabelPrefix or 'Pickup Zone' + opts.LineType = (md.LineTypes and md.LineTypes.Pickup) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.Pickup) or nil + self:_drawZoneCircleAndLabel('Pickup', mz, opts) + end + end + end + if md.DrawDropZones then + for _,mz in ipairs(self.DropZones or {}) do + local name = mz:GetName() + if self._ZoneActive.Drop[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.Drop) or 'Drop Zone' + opts.LineType = (md.LineTypes and md.LineTypes.Drop) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.Drop) or nil + self:_drawZoneCircleAndLabel('Drop', mz, opts) + end + end + end + if md.DrawFOBZones then + for _,mz in ipairs(self.FOBZones or {}) do + local name = mz:GetName() + if self._ZoneActive.FOB[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.FOB) or 'FOB Zone' + opts.LineType = (md.LineTypes and md.LineTypes.FOB) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.FOB) or nil + self:_drawZoneCircleAndLabel('FOB', mz, opts) + end + end + end + if md.DrawMASHZones then + for _,mz in ipairs(self.MASHZones or {}) do + local name = mz:GetName() + if self._ZoneActive.MASH[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.MASH) or 'MASH' + opts.LineType = (md.LineTypes and md.LineTypes.MASH) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.MASH) or nil + self:_drawZoneCircleAndLabel('MASH', mz, opts) + end + end + if CTLD._mashZones then + for mashId, data in pairs(CTLD._mashZones) do + if data and data.side == self.Side and data.isMobile then + local zoneName = data.displayName or mashId + if self._ZoneActive.MASH[zoneName] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.MASH) or 'MASH' + opts.LineType = (md.LineTypes and md.LineTypes.MASH) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.MASH) or nil + local zoneObj = data.zone + if not (zoneObj and zoneObj.GetPointVec3 and zoneObj.GetRadius) then + local pos = data.position or { x = 0, z = 0 } + if ZONE_RADIUS and VECTOR2 then + local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(pos.x, pos.z) or { x = pos.x, y = pos.z } + zoneObj = ZONE_RADIUS:New(zoneName, v2, data.radius or 500) + else + local posCopy = { x = pos.x, z = pos.z } + zoneObj = {} + function zoneObj:GetName() + return zoneName + end + function zoneObj:GetPointVec3() + return { x = posCopy.x, y = 0, z = posCopy.z } + end + function zoneObj:GetRadius() + return data.radius or 500 + end + end + data.zone = zoneObj + end + if zoneObj then + self:_drawZoneCircleAndLabel('MASH', zoneObj, opts) + end + end + end + end + end + end + if md.DrawSalvageZones then + for _,mz in ipairs(self.SalvageDropZones or {}) do + local name = mz:GetName() + if self._ZoneActive.SalvageDrop[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.SalvageDrop) or 'Salvage Zone' + opts.LineType = (md.LineTypes and md.LineTypes.SalvageDrop) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.SalvageDrop) or self.Config.SlingLoadSalvage.ZoneColors.fill + self:_drawZoneCircleAndLabel('SalvageDrop', mz, opts) + end + end + end +end + +-- Unit preference detection and unit-aware formatting +local function _getPlayerIsMetric(unit) + local ok, isMetric = pcall(function() + local pname = unit and unit.GetPlayerName and unit:GetPlayerName() or nil + if pname and CTLD and CTLD._playerUnitPrefs then + local pref = CTLD._playerUnitPrefs[pname] + if pref == 'metric' then return true end + if pref == 'imperial' then return false end + end + if pname and type(SETTINGS) == 'table' and SETTINGS.Set then + local ps = SETTINGS:Set(pname) + if ps and ps.IsMetric then return ps:IsMetric() end + end + if _SETTINGS and _SETTINGS.IsMetric then return _SETTINGS:IsMetric() end + return true + end) + return (ok and isMetric) and true or false +end + +function CTLD:_SetGroupUnitPreference(group, mode) + if not group or not group:IsAlive() then return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then + _msgGroup(group, 'No active player unit to bind preference.') + return + end + local pname = unit.GetPlayerName and unit:GetPlayerName() or nil + if not pname or pname == '' then + _msgGroup(group, 'Unit preference requires a player-controlled slot.') + return + end + if mode ~= 'metric' and mode ~= 'imperial' and mode ~= nil then + _msgGroup(group, 'Invalid units selection requested.') + return + end + self._playerUnitPrefs = self._playerUnitPrefs or {} + if mode then + self._playerUnitPrefs[pname] = mode + else + self._playerUnitPrefs[pname] = nil + end + local label + if mode == 'metric' then + label = 'metric (meters)' + elseif mode == 'imperial' then + label = 'imperial (nautical miles / feet)' + else + label = 'mission default' + end + _msgGroup(group, string.format('CTLD units preference set to %s for %s.', label, pname)) +end + +function CTLD:_ShowGroupUnitPreference(group) + if not group or not group:IsAlive() then return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then + _msgGroup(group, 'No active player unit to inspect preference.') + return + end + local pname = unit.GetPlayerName and unit:GetPlayerName() or nil + if not pname or pname == '' then + _msgGroup(group, 'Unit preference requires a player-controlled slot.') + return + end + local prefs = self._playerUnitPrefs or {} + local explicit = prefs[pname] + local effective = _getPlayerIsMetric(unit) and 'metric (meters)' or 'imperial (nautical miles / feet)' + local source = explicit and 'CTLD preference' or 'mission default' + if explicit == 'metric' then + effective = 'metric (meters)' + elseif explicit == 'imperial' then + effective = 'imperial (nautical miles / feet)' + end + _msgGroup(group, string.format('%s units setting: %s (source: %s).', pname, effective, source)) +end + +local function _round(n, prec) + local m = 10^(prec or 0) + return math.floor(n * m + 0.5) / m +end + +local function _fmtDistance(meters, isMetric) + if isMetric then + local v = math.max(0, _round(meters, 0)) + return v, 'm' + else + local ft = meters * 3.28084 + -- snap to 5 ft increments for readability + ft = math.max(0, math.floor((ft + 2.5) / 5) * 5) + return ft, 'ft' + end +end + +local function _fmtRange(meters, isMetric) + meters = math.max(0, meters or 0) + if isMetric then + if meters >= 1000 then + local km = meters / 1000 + local prec = (km >= 10) and 0 or 1 + return _round(km, prec), 'km' + end + return _round(meters, 0), 'm' + else + local nm = meters / 1852 + local prec = (nm >= 10) and 0 or 1 + return _round(nm, prec), 'NM' + end +end + +local function _fmtSpeed(mps, isMetric) + if isMetric then + return _round(mps, 1), 'm/s' + else + local fps = mps * 3.28084 + return math.max(0, math.floor(fps + 0.5)), 'ft/s' + end +end + +local function _fmtAGL(meters, isMetric) + return _fmtDistance(meters, isMetric) +end + +-- Coalition utility: return opposite side (BLUE<->RED); NEUTRAL returns RED by default +local function _enemySide(side) + if coalition and coalition.side then + if side == coalition.side.BLUE then return coalition.side.RED end + if side == coalition.side.RED then return coalition.side.BLUE end + end + return coalition.side.RED +end + +-- Find nearest enemy-held base within radius; returns {point=vec3, name=string, dist=meters} +function CTLD:_findNearestEnemyBase(point, radius) + local enemy = _enemySide(self.Side) + local ok, bases = pcall(function() + if coalition and coalition.getAirbases then return coalition.getAirbases(enemy) end + return {} + end) + if not ok or not bases then return nil end + local best + for _,ab in ipairs(bases) do + local p = ab:getPoint() + local dx = (p.x - point.x); local dz = (p.z - point.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= radius and ((not best) or d < best.dist) then + best = { point = { x = p.x, z = p.z }, name = ab:getName() or 'Base', dist = d } + end + end + return best +end + +-- Find nearest enemy ground group centroid within radius; returns {point=vec3, group=GROUP|nil, dcsGroupName=string, dist=meters, type=string} +function CTLD:_findNearestEnemyGround(point, radius) + local enemy = _enemySide(self.Side) + local best + -- Use MOOSE SET_GROUP to enumerate enemy ground groups + local set = SET_GROUP:New():FilterCoalitions(enemy):FilterCategories(Group.Category.GROUND):FilterActive(true):FilterStart() + set:ForEachGroup(function(g) + local alive = g:IsAlive() + if alive then + local c = g:GetCoordinate() + if c then + local v3 = c:GetVec3() + local dx = (v3.x - point.x); local dz = (v3.z - point.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= radius and ((not best) or d < best.dist) then + -- Try to infer a type label from first unit + local ut = 'unit' + local u1 = g:GetUnit(1) + if u1 then ut = _getUnitType(u1) or ut end + best = { point = { x = v3.x, z = v3.z }, group = g, dcsGroupName = g:GetName(), dist = d, type = ut } + end + end + end + end) + return best +end + +-- Order a ground group by name to move toward target point at a given speed (km/h). Uses MOOSE route when available. +function CTLD:_orderGroundGroupToPointByName(groupName, targetPoint, speedKmh) + if not groupName or not targetPoint then return end + -- Pure-MOOSE movement: schedule a small delay so the dynamic group has + -- time to be fully registered in the MOOSE database before we attempt + -- to route it. DCS AI will handle targeting when enemies come into LOS; + -- we only care about advancing toward the chosen objective area. + + local delay = 2 -- seconds + local dest = { x = targetPoint.x, z = targetPoint.z } + timer.scheduleFunction(function() + local mg + local ok = pcall(function() mg = GROUP:FindByName(groupName) end) + if not (ok and mg and mg:IsAlive()) then + _logError(string.format("ATTACK AI: Failed to find group '%s' for routing", groupName or 'nil')) + return + end + + _logDebug(string.format("ATTACK AI: Routing group '%s' to target (%.1f, %.1f) at %d km/h", + groupName, dest.x, dest.z, speedKmh or 25)) + + local vec2 + if VECTOR2 and VECTOR2.New then + vec2 = VECTOR2:New(dest.x, dest.z) + else + vec2 = { x = dest.x, y = dest.z } + end + + local success = pcall(function() + -- Set ROE to allow engaging targets + if mg.OptionROEOpenFire then + mg:OptionROEOpenFire() + _logDebug(string.format("ATTACK AI: Set ROE OpenFire for '%s'", groupName)) + end + -- Set alarm state to Auto (alert and ready) + if mg.OptionAlarmStateAuto then + mg:OptionAlarmStateAuto() + _logDebug(string.format("ATTACK AI: Set AlarmState Auto for '%s'", groupName)) + end + + -- Create a temporary zone at the target point for TaskRouteToZone + -- This is the proven method used by DynamicGroundBattle plugin + local targetCoord = COORDINATE:New(dest.x, 0, dest.z) + local tempZone = ZONE_RADIUS:New("CTLD_TEMP_TARGET_" .. groupName, targetCoord:GetVec2(), 100) + + -- Use TaskRouteToZone with randomization (same as working DGB plugin) + mg:TaskRouteToZone(tempZone, true) + _logDebug(string.format("ATTACK AI: TaskRouteToZone issued for '%s' to (%.1f, %.1f)", groupName, dest.x, dest.z)) + end) + + if not success then + _logError(string.format("ATTACK AI: Failed to issue route commands for group '%s'", groupName)) + end + end, {}, timer.getTime() + delay) +end + +-- Assign attack behavior to a newly spawned ground group by name +function CTLD:_assignAttackBehavior(groupName, originPoint, isVehicle) + if not (self.Config.AttackAI and self.Config.AttackAI.Enabled) then + _logDebug(string.format("ATTACK AI: Disabled or not configured for group '%s'", groupName or 'nil')) + return + end + + _logDebug(string.format("ATTACK AI: Assigning attack behavior to group '%s' (%s)", + groupName or 'nil', isVehicle and 'vehicle' or 'troops')) + + local radius = isVehicle and (self.Config.AttackAI.VehicleSearchRadius or 5000) or (self.Config.AttackAI.TroopSearchRadius or 3000) + local prioBase = (self.Config.AttackAI.PrioritizeEnemyBases ~= false) + local speed = isVehicle and (self.Config.AttackAI.VehicleAdvanceSpeedKmh or 35) or (self.Config.AttackAI.TroopAdvanceSpeedKmh or 20) + local player = 'Player' + + _logDebug(string.format("ATTACK AI: Search radius=%.0fm, prioritizeBase=%s, speed=%d km/h", + radius, tostring(prioBase), speed)) + + -- Try to infer last requesting player from crate/troop context is complex; caller should pass announcements separately when needed. + -- Target selection + -- SmartTargeting: always use omniscient nearest-target logic within radius, ignoring LOS. + -- We still optionally prioritize bases, but we no longer allow LOS/detection quirks to + -- prevent movement when enemies truly exist in the search area. + local target + local pickedBase + + local smart = (self.Config.AttackAI and self.Config.AttackAI.SmartTargeting ~= false) + + if prioBase then + local base = self:_findNearestEnemyBase(originPoint, radius) + if base then + target = { point = base.point, name = base.name, kind = 'base', dist = base.dist } + pickedBase = base + _logDebug(string.format("ATTACK AI: Found enemy base '%s' at %.0fm", base.name, base.dist)) + end + end + + if not target then + -- Primary omniscient search: nearest enemy ground group within radius. + local eg = self:_findNearestEnemyGround(originPoint, radius) + if eg then + target = { point = eg.point, name = eg.dcsGroupName, kind = 'enemy', dist = eg.dist, etype = eg.type } + _logDebug(string.format("ATTACK AI: Found enemy ground '%s' (%s) at %.0fm", eg.dcsGroupName, eg.type or 'unknown', eg.dist)) + end + end + + if not target then + _logDebug(string.format("ATTACK AI: No targets found within %.0fm for group '%s'", radius, groupName)) + end + + -- If SmartTargeting is disabled, simply honor the first hit (base or ground) and allow + -- the caller to fall back to defend when target is nil. + -- When SmartTargeting is enabled (default), we *only* fall back to defend when there are + -- truly no valid enemy bases or ground groups inside the configured radius. + -- (The actual omniscient search is already implemented in _findNearestEnemyBase/_findNearestEnemyGround.) + -- Order movement if we have a target + if target then + self:_orderGroundGroupToPointByName(groupName, target.point, speed) + end + return target -- caller will handle announcement +end + +local function _bearingDeg(from, to) + local dx = (to.x - from.x) + local dz = (to.z - from.z) + local ang = math.deg(math.atan2(dx, dz)) -- 0=N, +CW + if ang < 0 then ang = ang + 360 end + return math.floor(ang + 0.5) +end + +-- Normalize MOOSE/DCS heading to both radians and degrees consistently. +-- Some environments may yield degrees; others radians. This returns (rad, deg). +local function _headingRadDeg(unit) + local h = (unit and unit.GetHeading and unit:GetHeading()) or 0 + local hrad, hdeg + if h and h > (2*math.pi + 0.1) then + -- Looks like degrees + hdeg = h % 360 + hrad = math.rad(hdeg) + else + -- Radians (normalize into [0, 2pi)) + hrad = (h or 0) % (2*math.pi) + hdeg = math.deg(hrad) + end + return hrad, hdeg +end + +local function _projectToBodyFrame(dx, dz, hdg) + -- world (east=X=dx, north=Z=dz) to body frame (fwd/right) + local fwd = dx * math.sin(hdg) + dz * math.cos(hdg) + local right = dx * math.cos(hdg) - dz * math.sin(hdg) + return right, fwd +end + +local function _playerNameFromGroup(group) + if not group then return 'Player' end + local unit = group:GetUnit(1) + local pname = unit and unit.GetPlayerName and unit:GetPlayerName() + if pname and pname ~= '' then return pname end + return group:GetName() or 'Player' +end + +local function _coachSend(self, group, unitName, key, data, isCoach) + local cfg = CTLD.HoverCoachConfig or {} + if cfg.enabled and (not self.HoverSched) then + if self._startHoverScheduler then + self:_startHoverScheduler() + end + end + local now = timer.getTime() + CTLD._coachState[unitName] = CTLD._coachState[unitName] or { lastKeyTimes = {} } + local st = CTLD._coachState[unitName] + local last = st.lastKeyTimes[key] or 0 + local minGap = isCoach and ((cfg.throttle and cfg.throttle.coachUpdate) or 1.5) or ((cfg.throttle and cfg.throttle.generic) or 3.0) + local repeatGap = (cfg.throttle and cfg.throttle.repeatSame) or (minGap * 2) + if last > 0 and (now - last) < minGap then return end + -- prevent repeat spam of identical key too fast (only after first send) + if last > 0 and (now - last) < repeatGap then return end + local tpl = CTLD.Messages and CTLD.Messages[key] + if not tpl then return end + local text = _fmtTemplate(tpl, data) + if text and text ~= '' then + _msgGroup(group, text) + st.lastKeyTimes[key] = now + end +end + +local _groundLoadFallbacks = { + Start = "Ground crew on it—{count} crate(s) ready in {seconds}s.", + Progress = "Loading steady—{remaining}s to go.", + Complete = "Ground load complete—{count} crate(s) secure.", +} + +local function _prepareGroundLoadMessage(self, category, data) + data = data or {} + local comms = self.GroundLoadComms or {} + local pool = comms and comms[category] + local tpl + if pool and #pool > 0 then + tpl = pool[math.random(#pool)] + else + tpl = _groundLoadFallbacks[category] + end + data.ground_line = _fmtTemplate(tpl or '', data) + return data +end + +local function _eventSend(self, group, side, key, data) + local now = timer.getTime() + local scopeKey + if group then scopeKey = 'GRP:'..group:GetName() else scopeKey = 'COAL:'..tostring(side or self.Side) end + CTLD._msgState[scopeKey] = CTLD._msgState[scopeKey] or { lastKeyTimes = {} } + local st = CTLD._msgState[scopeKey] + local last = st.lastKeyTimes[key] or 0 + local cfg = CTLD.HoverCoachConfig + local minGap = (cfg and cfg.throttle and cfg.throttle.generic) or 3.0 + local repeatGap = (cfg and cfg.throttle and cfg.throttle.repeatSame) or (minGap * 2) + if last > 0 and (now - last) < minGap then return end + if last > 0 and (now - last) < repeatGap then return end + local tpl = CTLD.Messages and CTLD.Messages[key] + if not tpl then return end + local text = _fmtTemplate(tpl, data) + if not text or text == '' then return end + if group then _msgGroup(group, text) else _msgCoalition(side or self.Side, text) end + st.lastKeyTimes[key] = now +end + +-- Format helpers for menu labels and recipe info +function CTLD:_recipeTotalCrates(def) + if not def then return 1 end + if type(def.requires) == 'table' then + local n = 0 + for _,qty in pairs(def.requires) do n = n + (qty or 0) end + return math.max(1, n) + end + return math.max(1, def.required or 1) +end + +function CTLD:_friendlyNameForKey(key) + local d = self.Config and self.Config.CrateCatalog and self.Config.CrateCatalog[key] + if not d then return tostring(key) end + return (d.menu or d.description or key) +end + +function CTLD:_formatMenuLabelWithCrates(key, def) + local base = (def and (def.menu or def.description)) or key + local total = self:_recipeTotalCrates(def) + local suffix = (total == 1) and '1 crate' or (tostring(total)..' crates') + -- Optionally append stock for UX; uses nearest pickup zone dynamically + if self.Config.Inventory and self.Config.Inventory.ShowStockInMenu then + local group = nil + -- Try to find any active group menu owner to infer nearest zone; if none, skip hint + for gname,_ in pairs(self.MenusByGroup or {}) do group = GROUP:FindByName(gname); if group then break end end + if group and group:IsAlive() then + local unit = group:GetUnit(1) + if unit and unit:IsAlive() then + local zone, dist = _nearestZonePoint(unit, self.Config.Zones and self.Config.Zones.PickupZones or {}) + if zone and dist and dist <= (self.Config.PickupZoneMaxDistance or 10000) then + local zname = zone:GetName() + -- For composite recipes, show bundle availability based on component stock; otherwise show per-key stock + if def and type(def.requires) == 'table' then + local stockTbl = CTLD._stockByZone[zname] or {} + local bundles = math.huge + for reqKey, qty in pairs(def.requires) do + local have = tonumber(stockTbl[reqKey] or 0) or 0 + local need = tonumber(qty or 0) or 0 + if need > 0 then bundles = math.min(bundles, math.floor(have / need)) end + end + if bundles == math.huge then bundles = 0 end + return string.format('%s (%s) [%s: %d bundle%s]', base, suffix, zname, bundles, (bundles==1 and '' or 's')) + else + local stock = (CTLD._stockByZone[zname] and CTLD._stockByZone[zname][key]) or 0 + return string.format('%s (%s) [%s: %d]', base, suffix, zname, stock) + end + end + end + end + end + return string.format('%s (%s)', base, suffix) +end + +function CTLD:_formatRecipeInfo(key, def) + local lines = {} + local title = self:_friendlyNameForKey(key) + table.insert(lines, string.format('%s', title)) + if def and def.isFOB then table.insert(lines, '(FOB recipe)') end + if def and type(def.requires) == 'table' then + local total = self:_recipeTotalCrates(def) + table.insert(lines, string.format('Requires: %d crate(s) total', total)) + table.insert(lines, 'Breakdown:') + -- stable order + local items = {} + for k,qty in pairs(def.requires) do table.insert(items, {k=k, q=qty}) end + table.sort(items, function(a,b) return tostring(a.k) < tostring(b.k) end) + for _,it in ipairs(items) do + local fname = self:_friendlyNameForKey(it.k) + table.insert(lines, string.format('- %dx %s', it.q or 1, fname)) + end + else + local n = self:_recipeTotalCrates(def) + table.insert(lines, string.format('Requires: %d crate(s)', n)) + end + if def and def.dcsCargoType then + table.insert(lines, string.format('Cargo type: %s', tostring(def.dcsCargoType))) + end + return table.concat(lines, '\n') +end + +-- Determine an approximate radius for a ZONE. Tries MOOSE radius, then trigger zone radius, then configured radius. +function CTLD:_getZoneRadius(zone) + if zone and zone.Radius then return zone.Radius end + local name = zone and zone.GetName and zone:GetName() or nil + if name and trigger and trigger.misc and trigger.misc.getZone then + local z = trigger.misc.getZone(name) + if z and z.radius then return z.radius end + end + if name and self._ZoneDefs and self._ZoneDefs.FOBZones and self._ZoneDefs.FOBZones[name] then + local d = self._ZoneDefs.FOBZones[name] + if d and d.radius then return d.radius end + end + return 150 +end + +-- Check if a 2D point (x,z) lies within any FOB zone; returns (bool, zone) +function CTLD:IsPointInFOBZones(point) + for _,z in ipairs(self.FOBZones or {}) do + local pz = z:GetPointVec3() + local r = self:_getZoneRadius(z) + local dx = (pz.x - point.x) + local dz = (pz.z - point.z) + if (dx*dx + dz*dz) <= (r*r) then return true, z end + end + return false, nil +end + +-- #endregion Utilities + +-- ========================= +-- Construction +-- ========================= +-- #region Construction +function CTLD:New(cfg) + local o = setmetatable({}, self) + o.Config = DeepCopy(CTLD.Config) + if cfg then o.Config = DeepMerge(o.Config, cfg) end + + -- Debug: check if MASH zones survived the merge + _logDebug('After config merge:') + _logDebug(' o.Config.Zones exists: '..tostring(o.Config.Zones ~= nil)) + if o.Config.Zones then + _logDebug(' o.Config.Zones.MASHZones exists: '..tostring(o.Config.Zones.MASHZones ~= nil)) + if o.Config.Zones.MASHZones then + _logDebug(' o.Config.Zones.MASHZones count: '..tostring(#o.Config.Zones.MASHZones)) + end + end + + o.Side = o.Config.CoalitionSide + o.CountryId = o.Config.CountryId or _defaultCountryForSide(o.Side) + o.Config.CountryId = o.CountryId + o.MenuRoots = {} + o.MenusByGroup = {} + o._DynamicSalvageZones = {} + o._DynamicSalvageQueue = {} + o._jtacRegistry = {} + + -- Ground auto-load state tracking + CTLD._groundLoadState = CTLD._groundLoadState or {} + CTLD._groundLoadTimers = CTLD._groundLoadTimers or {} + + -- If caller disabled builtin catalog, clear it before merging any globals + if o.Config.UseBuiltinCatalog == false then + o.Config.CrateCatalog = {} + end + + -- If a global catalog was loaded earlier (via DO SCRIPT FILE), merge it automatically + -- Supported globals: _CTLD_EXTRACTED_CATALOG (our extractor), CTLD_CATALOG, MOOSE_CTLD_CATALOG + do + local globalsToCheck = { '_CTLD_EXTRACTED_CATALOG', 'CTLD_CATALOG', 'MOOSE_CTLD_CATALOG' } + for _,gn in ipairs(globalsToCheck) do + local t = rawget(_G, gn) + if type(t) == 'table' then + o:MergeCatalog(t) + _logInfo('Merged crate catalog from global '..gn) + end + end + end + + -- Load troop types from catalog if available + do + local troopTypes = rawget(_G, '_CTLD_TROOP_TYPES') + if type(troopTypes) == 'table' and next(troopTypes) then + o.Config.Troops.TroopTypes = troopTypes + _logInfo('Loaded troop types from _CTLD_TROOP_TYPES') + else + -- Fallback: catalog not loaded, warn user and provide minimal defaults + _logError('WARNING: _CTLD_TROOP_TYPES not found. Catalog may not be loaded. Using minimal troop fallbacks.') + _logError('Please ensure catalog file is loaded via DO SCRIPT FILE *before* creating CTLD instances.') + -- Minimal fallback troop types to prevent spawning wrong units + o.Config.Troops.TroopTypes = { + AS = { label = 'Assault Squad', size = 8, unitsBlue = { 'Soldier M4' }, unitsRed = { 'Infantry AK' }, units = { 'Infantry AK' } }, + AA = { label = 'MANPADS Team', size = 4, unitsBlue = { 'Soldier stinger' }, unitsRed = { 'SA-18 Igla-S manpad' }, units = { 'Infantry AK' } }, + AT = { label = 'AT Team', size = 4, unitsBlue = { 'Soldier M136' }, unitsRed = { 'Soldier RPG' }, units = { 'Infantry AK' } }, + AR = { label = 'Mortar Team', size = 4, unitsBlue = { '2B11 mortar' }, unitsRed = { '2B11 mortar' }, units = { '2B11 mortar' } }, + } + end + end + + -- Run unit type validation after catalogs/troop types load so issues surface early + o:_validateCatalogUnitTypes() + + o:InitZones() + -- Validate configured zones and warn if missing + o:ValidateZones() + -- Optional: draw configured zones on the F10 map + if o.Config.MapDraw and o.Config.MapDraw.Enabled then + -- Defer a tiny bit to ensure mission environment is fully up + timer.scheduleFunction(function() + pcall(function() o:DrawZonesOnMap() end) + end, {}, timer.getTime() + 1) + end + -- Optional: bind zone activation to mission flags (merge from config table and per-zone flag fields) + do + local merged = {} + -- Collect from explicit bindings (backward compatible) + if o.Config.ZoneEventBindings then + for _,b in ipairs(o.Config.ZoneEventBindings) do table.insert(merged, b) end + end + -- Collect from per-zone entries (preferred) + local function pushFromZones(kind, list) + for _,z in ipairs(list or {}) do + if z and z.name and z.flag then + table.insert(merged, { kind = kind, name = z.name, flag = z.flag, activeWhen = z.activeWhen or 1 }) + end + end + end + pushFromZones('Pickup', o.Config.Zones and o.Config.Zones.PickupZones) + pushFromZones('Drop', o.Config.Zones and o.Config.Zones.DropZones) + pushFromZones('FOB', o.Config.Zones and o.Config.Zones.FOBZones) + pushFromZones('MASH', o.Config.Zones and o.Config.Zones.MASHZones) + pushFromZones('SalvageDrop', o.Config.Zones and o.Config.Zones.SalvageDropZones) + + o._BindingsMerged = merged + if o._BindingsMerged and #o._BindingsMerged > 0 then + o._ZoneFlagState = {} + o._ZoneFlagsPrimed = false + o.ZoneFlagSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() + if not o._ZoneFlagsPrimed then + -- Prime states on first run without spamming messages + for _,b in ipairs(o._BindingsMerged) do + if b and b.flag and b.kind and b.name then + local val = (trigger and trigger.misc and trigger.misc.getUserFlag) and trigger.misc.getUserFlag(b.flag) or 0 + local activeWhen = (b.activeWhen ~= nil) and b.activeWhen or 1 + local shouldBeActive = (val == activeWhen) + local key = tostring(b.kind)..'|'..tostring(b.name) + o._ZoneFlagState[key] = shouldBeActive + o:SetZoneActive(b.kind, b.name, shouldBeActive, true) + end + end + o._ZoneFlagsPrimed = true + return + end + -- Subsequent runs: announce changes + for _,b in ipairs(o._BindingsMerged) do + if b and b.flag and b.kind and b.name then + local val = (trigger and trigger.misc and trigger.misc.getUserFlag) and trigger.misc.getUserFlag(b.flag) or 0 + local activeWhen = (b.activeWhen ~= nil) and b.activeWhen or 1 + local shouldBeActive = (val == activeWhen) + local key = tostring(b.kind)..'|'..tostring(b.name) + if o._ZoneFlagState[key] ~= shouldBeActive then + o._ZoneFlagState[key] = shouldBeActive + o:SetZoneActive(b.kind, b.name, shouldBeActive, false) + end + end + end + end) + if not ok then _logError('ZoneFlagSched error: '..tostring(err)) end + end, {}, 1, 1) + end + end + o:InitMenus() + + -- Initialize inventory for configured pickup zones (seed from catalog initialStock) + if o.Config.Inventory and o.Config.Inventory.Enabled then + pcall(function() o:InitInventory() end) + end + + -- Initialize MEDEVAC system + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + pcall(function() o:InitMEDEVAC() end) + end + + -- Initialize FARP system + if CTLD.FARPConfig and CTLD.FARPConfig.Enabled then + pcall(function() o:InitFARP() end) + end + + -- Initialize manual salvage crates (scan mission editor for pre-placed cargo) + if o.Config.SlingLoadSalvage and o.Config.SlingLoadSalvage.Enabled and o.Config.SlingLoadSalvage.EnableManualCrates then + pcall(function() o:ScanAndRegisterManualSalvageCrates() end) + end + + -- Periodic cleanup for crates + o.Sched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:CleanupCrates() end) + if not ok then _logError('CleanupCrates scheduler error: '..tostring(err)) end + end, {}, 60, 60) + + -- Periodic cleanup for deployed troops (remove dead/missing groups) + o.TroopCleanupSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:CleanupDeployedTroops() end) + if not ok then _logError('CleanupDeployedTroops scheduler error: '..tostring(err)) end + end, {}, 30, 30) + + -- Periodic comprehensive state maintenance (prune orphaned entries) + o.StateMaintSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:PruneOrphanedState() end) + if not ok then _logError('PruneOrphanedState scheduler error: '..tostring(err)) end + end, {}, 120, 120) -- Run every 2 minutes + + -- Optional: auto-build FOBs inside FOB zones when crates present + if o.Config.AutoBuildFOBInZones then + o.AutoFOBSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:AutoBuildFOBCheck() end) + if not ok then _logError('AutoBuildFOBCheck scheduler error: '..tostring(err)) end + end, {}, 10, 10) -- check every 10 seconds (tunable) + end + + -- Optional: hover pickup scanner + local coachCfg = CTLD.HoverCoachConfig or {} + if coachCfg.enabled then + o.HoverSched = nil + o:_startHoverScheduler() + end + + -- Optional: ground auto-load scanner + local groundCfg = CTLD.GroundAutoLoadConfig or {} + if groundCfg.Enabled then + o.GroundLoadSched = nil + o:_startGroundLoadScheduler() + end + + -- MEDEVAC auto-pickup and auto-unload scheduler + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + local checkInterval = (CTLD.MEDEVAC.AutoPickup and CTLD.MEDEVAC.AutoPickup.CheckInterval) or 3 + o.MEDEVACSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:ScanMEDEVACAutoActions() end) + if not ok then _logError('MEDEVAC auto-actions scheduler error: '..tostring(err)) end + end, {}, checkInterval, checkInterval) + end + + if o.Config.JTAC and o.Config.JTAC.Enabled then + local jtacInterval = 5 + if o.Config.JTAC.AutoLase then + local refresh = tonumber(o.Config.JTAC.AutoLase.RefreshSeconds) or 15 + local idle = tonumber(o.Config.JTAC.AutoLase.IdleRescanSeconds) or 30 + jtacInterval = math.max(2, math.min(refresh, idle, 10)) + end + o.JTACSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() o:_tickJTACs() end) + if not ok then _logError('JTAC tick scheduler error: '..tostring(err)) end + end, {}, jtacInterval, jtacInterval) + _logInfo(string.format('JTAC init: Enabled=TRUE AutoLase=%s SearchRadius=%s Refresh=%s IdleRescan=%s LockType=%s Verbose=%s Interval=%.1f', + tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.Enabled ~= false), + tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.SearchRadius), + tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.RefreshSeconds), + tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.IdleRescanSeconds), + tostring(o.Config.JTAC.LockType), + tostring(o.Config.JTAC.Verbose), + jtacInterval)) + end + + table.insert(CTLD._instances, o) + o:_ensureBackgroundTasks() + local versionLabel = CTLD.Version or 'unknown' + _msgCoalition(o.Side, string.format('CTLD %s initialized for coalition', versionLabel)) + return o +end + +function CTLD:InitZones() + self.PickupZones = {} + self.DropZones = {} + self.FOBZones = {} + self.MASHZones = {} + self.SalvageDropZones = {} + self._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {}, SalvageDropZones = {} } + self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + for _,z in ipairs(self.Config.Zones.PickupZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.PickupZones, mz) + local name = mz:GetName() + self._ZoneDefs.PickupZones[name] = z + if self._ZoneActive.Pickup[name] == nil then self._ZoneActive.Pickup[name] = (z.active ~= false) end + end + end + for _,z in ipairs(self.Config.Zones.DropZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.DropZones, mz) + local name = mz:GetName() + self._ZoneDefs.DropZones[name] = z + if self._ZoneActive.Drop[name] == nil then self._ZoneActive.Drop[name] = (z.active ~= false) end + end + end + for _,z in ipairs(self.Config.Zones.FOBZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.FOBZones, mz) + local name = mz:GetName() + self._ZoneDefs.FOBZones[name] = z + if self._ZoneActive.FOB[name] == nil then self._ZoneActive.FOB[name] = (z.active ~= false) end + end + end + for _,z in ipairs(self.Config.Zones.MASHZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.MASHZones, mz) + local name = mz:GetName() + self._ZoneDefs.MASHZones[name] = z + if self._ZoneActive.MASH[name] == nil then self._ZoneActive.MASH[name] = (z.active ~= false) end + end + end + for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.SalvageDropZones, mz) + local name = mz:GetName() + if z and z.side == nil then z.side = self.Side end + self._ZoneDefs.SalvageDropZones[name] = z + if self._ZoneActive.SalvageDrop[name] == nil then self._ZoneActive.SalvageDrop[name] = (z.active ~= false) end + end + end +end + +-- Validate configured zone names exist in the mission; warn coalition if any are missing. +function CTLD:ValidateZones() + local function zoneExistsByName(name) + if not name or name == '' then return false end + if trigger and trigger.misc and trigger.misc.getZone then + local z = trigger.misc.getZone(name) + if z then return true end + end + if ZONE and ZONE.FindByName then + local mz = ZONE:FindByName(name) + if mz then return true end + end + return false + end + + local function sideToStr(s) + if coalition and coalition.side then + if s == coalition.side.BLUE then return 'BLUE' end + if s == coalition.side.RED then return 'RED' end + if s == coalition.side.NEUTRAL then return 'NEUTRAL' end + end + return tostring(s) + end + + local function join(t) + local s = '' + for i,name in ipairs(t) do s = s .. (i>1 and ', ' or '') .. tostring(name) end + return s + end + + local missing = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + local found = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + local coords = { Pickup = 0, Drop = 0, FOB = 0, MASH = 0, SalvageDrop = 0 } + + for _,z in ipairs(self.Config.Zones.PickupZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.Pickup, z.name) else table.insert(missing.Pickup, z.name) end + elseif z.coord then + coords.Pickup = coords.Pickup + 1 + end + end + for _,z in ipairs(self.Config.Zones.DropZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.Drop, z.name) else table.insert(missing.Drop, z.name) end + elseif z.coord then + coords.Drop = coords.Drop + 1 + end + end + for _,z in ipairs(self.Config.Zones.FOBZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.FOB, z.name) else table.insert(missing.FOB, z.name) end + elseif z.coord then + coords.FOB = coords.FOB + 1 + end + end + for _,z in ipairs(self.Config.Zones.MASHZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.MASH, z.name) else table.insert(missing.MASH, z.name) end + elseif z.coord then + coords.MASH = coords.MASH + 1 + end + end + for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.SalvageDrop, z.name) else table.insert(missing.SalvageDrop, z.name) end + elseif z.coord then + coords.SalvageDrop = coords.SalvageDrop + 1 + end + end + + -- Log a concise summary to dcs.log + local sideStr = sideToStr(self.Side) + _logVerbose(string.format('[ZoneValidation][%s] Pickup: configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.PickupZones or {}), #found.Pickup + #missing.Pickup, coords.Pickup, #found.Pickup, #missing.Pickup)) + _logVerbose(string.format('[ZoneValidation][%s] Drop : configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.DropZones or {}), #found.Drop + #missing.Drop, coords.Drop, #found.Drop, #missing.Drop)) + _logVerbose(string.format('[ZoneValidation][%s] FOB : configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.FOBZones or {}), #found.FOB + #missing.FOB, coords.FOB, #found.FOB, #missing.FOB)) + _logVerbose(string.format('[ZoneValidation][%s] MASH : configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.MASHZones or {}), #found.MASH + #missing.MASH, coords.MASH, #found.MASH, #missing.MASH)) + _logVerbose(string.format('[ZoneValidation][%s] Salvage: configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.SalvageDropZones or {}), #found.SalvageDrop + #missing.SalvageDrop, coords.SalvageDrop, #found.SalvageDrop, #missing.SalvageDrop)) + + local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 0) or (#missing.MASH > 0) or (#missing.SalvageDrop > 0) + if anyMissing then + if #missing.Pickup > 0 then + local msg = 'CTLD config warning: Missing Pickup Zones: '..join(missing.Pickup) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end + if #missing.Drop > 0 then + local msg = 'CTLD config warning: Missing Drop Zones: '..join(missing.Drop) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end + if #missing.FOB > 0 then + local msg = 'CTLD config warning: Missing FOB Zones: '..join(missing.FOB) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end + if #missing.MASH > 0 then + local msg = 'CTLD config warning: Missing MASH Zones: '..join(missing.MASH) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end + if #missing.SalvageDrop > 0 then + local msg = 'CTLD config warning: Missing Salvage Drop Zones: '..join(missing.SalvageDrop) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end + else + _logVerbose(string.format('[ZoneValidation][%s] All configured zone names resolved successfully.', sideStr)) + end + + self._MissingZones = missing +end +-- #endregion Construction + +-- ========================= +-- Menus +-- ========================= +-- #region Menus +function CTLD:InitMenus() + if self.Config.UseGroupMenus then + -- Create placeholder menu at mission start to reserve F10 position if requested + if self.Config.CreateMenuAtMissionStart then + -- Create a coalition-level placeholder that will be replaced by per-group menus on birth + self.PlaceholderMenu = MENU_COALITION:New(self.Side, self.Config.RootMenuName or 'CTLD') + MENU_COALITION_COMMAND:New(self.Side, 'Spawn in an aircraft to see options', self.PlaceholderMenu, function() + _msgCoalition(self.Side, 'CTLD menus will appear when you spawn in a transport aircraft.') + end) + end + self:WireBirthHandler() + -- No coalition-level root when using per-group menus; Admin/Help is nested under each group's CTLD menu + else + self.MenuRoot = MENU_COALITION:New(self.Side, self.Config.RootMenuName or 'CTLD') + self:BuildCoalitionMenus(self.MenuRoot) + self:InitCoalitionAdminMenu() + end +end + +function CTLD:WireBirthHandler() + local handler = EVENTHANDLER:New() + handler:HandleEvent(EVENTS.Birth) + handler:HandleEvent(EVENTS.Dead) + handler:HandleEvent(EVENTS.Crash) + handler:HandleEvent(EVENTS.PilotDead) + handler:HandleEvent(EVENTS.Ejection) + handler:HandleEvent(EVENTS.PlayerLeaveUnit) + local selfref = self + + local function hasTrackedState(gname) + if not gname then return false end + if selfref.MenusByGroup and selfref.MenusByGroup[gname] then return true end + local tbls = { + CTLD._inStockMenus, + CTLD._loadedCrates, + CTLD._troopsLoaded, + CTLD._loadedTroopTypes, + CTLD._deployedTroops, + CTLD._buildConfirm, + CTLD._buildCooldown, + CTLD._coachOverride, + CTLD._medevacUnloadStates, + CTLD._medevacLoadStates, + CTLD._medevacEnrouteStates, + } + for _, tbl in ipairs(tbls) do + if tbl and tbl[gname] then return true end + end + if CTLD._msgState and CTLD._msgState['GRP:'..gname] then return true end + return false + end + + local function teardownIfGroupInactive(eventData, reason) + if not eventData then return end + local unit = eventData.IniUnit + local group = eventData.IniGroup or (unit and unit.GetGroup and unit:GetGroup()) or nil + if not group or not group.GetName then return end + if group.GetCoalition and group:GetCoalition() ~= selfref.Side then return end + local gname = group:GetName() + if not gname or gname == '' then return end + if not hasTrackedState(gname) then return end + local allowed = selfref.Config and selfref.Config.AllowedAircraft or {} + if _groupHasAliveTransport(group, allowed) then return end + selfref:_cleanupTransportGroup(group, gname) + if reason then + _logDebug(string.format('[MenuCleanup] Group %s removed due to %s', gname, reason)) + end + end + + local function scheduleDeferredCleanup(group) + if not (group and group.GetName) then return end + local gname = group:GetName() + if not gname or gname == '' then return end + if not hasTrackedState(gname) then return end + if not (timer and timer.scheduleFunction) then return end + timer.scheduleFunction(function(arg) + local name = arg and arg.groupName or nil + if not name then return nil end + if not selfref then return nil end + local grp = GROUP and GROUP:FindByName(name) or nil + local allowed = selfref.Config and selfref.Config.AllowedAircraft or {} + if grp and _groupHasAliveTransport(grp, allowed) then + return nil + end + selfref:_cleanupTransportGroup(grp, name) + _logDebug(string.format('[MenuCleanup] Group %s cleaned up after player left', name)) + return nil + end, { groupName = gname }, timer.getTime() + 3) + end + + function handler:OnEventBirth(eventData) + local unit = eventData.IniUnit + if not unit or not unit:IsAlive() then return end + if unit:GetCoalition() ~= selfref.Side then return end + local typ = _getUnitType(unit) + if not _isIn(selfref.Config.AllowedAircraft, typ) then return end + local grp = unit:GetGroup() + if not grp then return end + local gname = grp:GetName() + if selfref.MenusByGroup[gname] then return end + selfref.MenusByGroup[gname] = selfref:BuildGroupMenus(grp) + _msgGroup(grp, 'CTLD menu available (F10)') + end + + function handler:OnEventDead(eventData) + teardownIfGroupInactive(eventData, 'Dead') + end + + function handler:OnEventCrash(eventData) + teardownIfGroupInactive(eventData, 'Crash') + end + + function handler:OnEventPilotDead(eventData) + teardownIfGroupInactive(eventData, 'PilotDead') + end + + function handler:OnEventEjection(eventData) + teardownIfGroupInactive(eventData, 'Ejection') + end + + function handler:OnEventPlayerLeaveUnit(eventData) + local unit = eventData and eventData.IniUnit + if not unit then return end + if unit.GetCoalition and unit:GetCoalition() ~= selfref.Side then return end + local group = unit.GetGroup and unit:GetGroup() or nil + if not group then return end + scheduleDeferredCleanup(group) + end + + self.BirthHandler = handler +end + +function CTLD:BuildGroupMenus(group) + local root = MENU_GROUP:New(group, self.Config.RootMenuName or 'CTLD') + -- Safe menu command helper: wraps callbacks to prevent silent errors + local function CMD(title, parent, cb) + return MENU_GROUP_COMMAND:New(group, title, parent, function() + local ok, err = pcall(cb) + if not ok then + _logError('Menu error: '..tostring(err)) + MESSAGE:New('CTLD menu error: '..tostring(err), 8):ToGroup(group) + end + end) + end + + -- Initialize per-player coach preference from default + local gname = group:GetName() + CTLD._coachOverride = CTLD._coachOverride or {} + if CTLD._coachOverride[gname] == nil then + local coachCfg = CTLD.HoverCoachConfig or {} + CTLD._coachOverride[gname] = coachCfg.coachOnByDefault + end + + -- Top-level roots per requested structure + local opsRoot = MENU_GROUP:New(group, 'Operations', root) + local logRoot = MENU_GROUP:New(group, 'Logistics', root) + local toolsRoot = MENU_GROUP:New(group, 'Field Tools', root) + local navRoot = MENU_GROUP:New(group, 'Navigation', root) + local adminRoot = MENU_GROUP:New(group, 'Admin/Help', root) + + local prefsMenu = MENU_GROUP:New(group, 'Preferences', adminRoot) + CMD('Use Metric Units', prefsMenu, function() self:_SetGroupUnitPreference(group, 'metric') end) + CMD('Use Imperial Units', prefsMenu, function() self:_SetGroupUnitPreference(group, 'imperial') end) + CMD('Use Mission Default Units', prefsMenu, function() self:_SetGroupUnitPreference(group, nil) end) + CMD('Show My Units Setting', prefsMenu, function() self:_ShowGroupUnitPreference(group) end) + + -- Admin/Help -> Player Guides (moved to top of Admin/Help) + local help = MENU_GROUP:New(group, 'Player Guides', adminRoot) + MENU_GROUP_COMMAND:New(group, 'CTLD Basics (2-minute tour)', help, function() + local lines = {} + table.insert(lines, 'CTLD Basics - 2 minute tour') + table.insert(lines, '') + table.insert(lines, 'Loop: Request -> Deliver -> Build -> Fight') + table.insert(lines, '- Request crates at an ACTIVE Supply Zone (Pickup).') + table.insert(lines, '- Deliver crates to the build point (within Build Radius).') + table.insert(lines, '- Build units or sites with "Build Here" (confirm + cooldown).') + table.insert(lines, '- Optional: set Attack or Defend behavior when building.') + table.insert(lines, '') + table.insert(lines, 'Key concepts:') + table.insert(lines, '- Zones: Pickup (supply), Drop (mission targets), FOB (forward supply).') + table.insert(lines, '- Inventory: stock is tracked per zone; requests consume stock there.') + table.insert(lines, '- FOBs: building one creates a local supply point with seeded stock.') + table.insert(lines, '- Advanced: SAM site repair crates, AI attack orders, EWR/JTAC support.') + MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'Zones - Guide', help, function() + local lines = {} + table.insert(lines, 'CTLD Zones - Guide') + table.insert(lines, '') + table.insert(lines, 'Zone types:') + table.insert(lines, '- Pickup (Supply): Request crates and load troops here. Crate requests require proximity to an ACTIVE pickup zone (default within 10 km).') + table.insert(lines, '- Drop: Mission-defined delivery or rally areas. Some missions may require delivery or deployment at these zones (see briefing).') + table.insert(lines, '- FOB: Forward Operating Base areas. Some recipes (FOB Site) can be built here; if FOB restriction is enabled, FOB-only builds must be inside an FOB zone.') + table.insert(lines, '') + table.insert(lines, 'Colors and map marks:') + table.insert(lines, '- Pickup zone crate spawns are marked with smoke in the configured color. Admin/Help -> Draw CTLD Zones on Map draws zone circles and labels on F10.') + table.insert(lines, '- Use Admin/Help -> Clear CTLD Map Drawings to remove the drawings. Drawings are read-only if configured.') + table.insert(lines, '') + table.insert(lines, 'How to use zones:') + table.insert(lines, '- To request crates: move within the pickup zone distance and use CTLD -> Request Crate.') + table.insert(lines, '- To load troops: must be inside a Pickup zone if troop loading restriction is enabled.') + table.insert(lines, '- Navigation: CTLD -> Coach & Nav -> Vectors to Nearest Pickup Zone gives bearing and range.') + table.insert(lines, '- Activation: Zones can be active/inactive per mission logic; inactive pickup zones block crate requests.') + table.insert(lines, '') + table.insert(lines, string.format('Build Radius: about %d m to collect nearby crates when building.', self.Config.BuildRadius or 100)) + table.insert(lines, string.format('Pickup Zone Max Distance: about %d m to request crates.', self.Config.PickupZoneMaxDistance or 10000)) + MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'Inventory - How It Works', help, function() + local inv = self.Config.Inventory or {} + local enabled = inv.Enabled ~= false + local showHint = inv.ShowStockInMenu == true + local fobPct = math.floor(((inv.FOBStockFactor or 0.25) * 100) + 0.5) + local lines = {} + table.insert(lines, 'CTLD Inventory - How It Works') + table.insert(lines, '') + table.insert(lines, 'Overview:') + table.insert(lines, '- Inventory is tracked per Supply (Pickup) Zone and per FOB. Requests consume stock at that location.') + table.insert(lines, string.format('- Inventory is %s.', enabled and 'ENABLED' or 'DISABLED')) + table.insert(lines, '') + table.insert(lines, 'Starting stock:') + table.insert(lines, '- Each configured Supply Zone is seeded from the catalog initialStock for every crate type at mission start.') + table.insert(lines, string.format('- When you build a FOB, it creates a small Supply Zone with stock seeded at ~%d%% of initialStock.', fobPct)) + table.insert(lines, '') + table.insert(lines, 'Requesting crates:') + table.insert(lines, '- You must be within range of an ACTIVE Supply Zone to request crates; stock is decremented on spawn.') + table.insert(lines, '- If out of stock for a type at that zone, requests are denied for that type until resupplied (mission logic).') + table.insert(lines, '') + table.insert(lines, 'UI hints:') + table.insert(lines, string.format('- Show stock in menu labels: %s.', showHint and 'ON' or 'OFF')) + table.insert(lines, '- Some missions may include an "In Stock Here" list showing only items available at the nearest zone.') + MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) + end) + + MENU_GROUP_COMMAND:New(group, 'Troop Transport & JTAC Use', help, function() + local lines = {} + table.insert(lines, 'Troop Transport & JTAC Use') + table.insert(lines, '') + table.insert(lines, 'Troops:') + table.insert(lines, '- Load inside an ACTIVE Supply Zone (if mission enforces it).') + table.insert(lines, '- Deploy with Defend (hold) or Attack (advance to targets/bases).') + table.insert(lines, '- Attack uses a search radius and moves at configured speed.') + table.insert(lines, '') + table.insert(lines, 'JTAC:') + table.insert(lines, '- Build JTAC units (MRAP/Tigr or drones) to support target marking.') + table.insert(lines, '- JTAC helps with target designation/SA; details depend on mission setup.') + MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'Crates 101: Requesting and Handling', help, function() + local lines = {} + table.insert(lines, 'Crates 101 - Requesting and Handling') + table.insert(lines, '') + table.insert(lines, '- Request crates near an ACTIVE Supply Zone; max distance is configurable.') + table.insert(lines, '- Menu labels show the total crates required for a recipe.') + table.insert(lines, '- Drop crates close together but avoid overlap; smoke marks spawns.') + table.insert(lines, '- Use Coach & Nav tools: vectors to nearest pickup zone, re-mark crate with smoke.') + MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'Hover Pickup & Slingloading', help, function() + local coachCfg = CTLD.HoverCoachConfig or {} + local aglMin = (coachCfg.thresholds and coachCfg.thresholds.aglMin) or 5 + local aglMax = (coachCfg.thresholds and coachCfg.thresholds.aglMax) or 20 + local capGS = (coachCfg.thresholds and coachCfg.thresholds.captureGS) or (4/3.6) + local hold = (coachCfg.thresholds and coachCfg.thresholds.stabilityHold) or 1.8 + local lines = {} + table.insert(lines, 'Hover Pickup & Slingloading') + table.insert(lines, '') + table.insert(lines, string.format('- Hover pickup: hold AGL %d-%d m, speed < %.1f m/s, for ~%.1f s to auto-load.', aglMin, aglMax, capGS, hold)) + table.insert(lines, '- Keep steady within ~15 m of the crate; Hover Coach gives cues if enabled.') + table.insert(lines, '- Slingloading tips: avoid rotor wash over stacks; approach from upwind; re-mark crate with smoke if needed.') + MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'Build System: Build Here and Advanced', help, function() + local br = self.Config.BuildRadius or 100 + local win = self.Config.BuildConfirmWindowSeconds or 10 + local cd = self.Config.BuildCooldownSeconds or 60 + local lines = {} + table.insert(lines, 'Build System - Build Here and Advanced') + table.insert(lines, '') + table.insert(lines, string.format('- Build Here collects crates within ~%d m. Double-press within %d s to confirm.', br, win)) + table.insert(lines, string.format('- Cooldown: about %d s per group after a successful build.', cd)) + table.insert(lines, '- Advanced Build lets you choose Defend (hold) or Attack (move).') + table.insert(lines, '- Static or unsuitable units will hold even if Attack is chosen.') + table.insert(lines, '- FOB-only recipes must be inside an FOB zone when restriction is enabled.') + MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'FOBs: Forward Supply & Why They Matter', help, function() + local fobPct = math.floor(((self.Config.Inventory and self.Config.Inventory.FOBStockFactor or 0.25) * 100) + 0.5) + local lines = {} + table.insert(lines, 'FOBs - Forward Supply and Why They Matter') + table.insert(lines, '') + table.insert(lines, '- Build a FOB by assembling its crate recipe (see Recipe Info).') + table.insert(lines, string.format('- A new local Supply Zone is created and seeded at ~%d%% of initial stock.', fobPct)) + table.insert(lines, '- FOBs shorten logistics legs and increase throughput toward the front.') + table.insert(lines, '- If enabled, FOB-only builds must occur inside FOB zones.') + MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'SAM Sites: Building, Repairing, and Augmenting', help, function() + local br = self.Config.BuildRadius or 100 + local lines = {} + table.insert(lines, 'SAM Sites - Building, Repairing, and Augmenting') + table.insert(lines, '') + table.insert(lines, 'Build:') + table.insert(lines, '- Assemble site recipes using the required component crates (see menu labels). Build Here will place the full site.') + table.insert(lines, '') + table.insert(lines, 'Repair/Augment (merged):') + table.insert(lines, '- Request the matching "Repair/Launcher +1" crate for your site type (HAWK, Patriot, KUB, BUK).') + table.insert(lines, string.format('- Drop repair crate(s) within ~%d m of the site, then use Build Here (confirm window applies).', br)) + table.insert(lines, '- The nearest matching site (within a local search) is respawned fully repaired; +1 launcher per crate, up to caps.') + table.insert(lines, '- Caps: HAWK 6, Patriot 6, KUB 3, BUK 6. Extra crates beyond the cap are not consumed.') + table.insert(lines, '- Must match coalition and site type; otherwise no changes are applied.') + table.insert(lines, '- Respawn is required to apply repairs/augmentation due to DCS limitations.') + table.insert(lines, '') + table.insert(lines, 'Placement tips:') + table.insert(lines, '- Space launchers to avoid masking; keep radars with good line-of-sight; avoid fratricide arcs.') + MESSAGE:New(table.concat(lines, '\n'), 45):ToGroup(group) + end) + MENU_GROUP_COMMAND:New(group, 'MASH & Salvage System', help, function() + local lines = {} + table.insert(lines, 'MASH & Salvage System - Player Guide') + table.insert(lines, '') + table.insert(lines, 'What is it?') + table.insert(lines, '- MASH (Mobile Army Surgical Hospital) zones accept MEDEVAC crew deliveries.') + table.insert(lines, '- When ground vehicles are destroyed, crews spawn nearby and call for rescue.') + table.insert(lines, '- Rescuing crews and delivering them to MASH earns Salvage Points for your coalition.') + table.insert(lines, '- Salvage Points let you build out-of-stock items, keeping logistics flowing.') + table.insert(lines, '') + table.insert(lines, 'How MEDEVAC works:') + table.insert(lines, '- Vehicle destroyed → crew spawns after delay with invulnerability period.') + table.insert(lines, '- MEDEVAC request announced with grid coordinates and salvage value.') + table.insert(lines, '- Crews have a time limit (default 60 minutes); failure = crew KIA and vehicle lost.') + table.insert(lines, '') + table.insert(lines, 'Pickup Methods:') + table.insert(lines, '- AUTO: Land within 500m of crew - they will run to you and board automatically!') + table.insert(lines, '- HOVER: Fly close, hover nearby, load troops normally - system detects MEDEVAC crew.') + table.insert(lines, '- Original vehicle respawns when crew is picked up (if enabled).') + table.insert(lines, '') + table.insert(lines, 'Delivering to MASH:') + table.insert(lines, '- AUTO: Land in any MASH zone - crews unload automatically after 2 seconds.') + table.insert(lines, '- MANUAL: Deploy troops inside MASH zone - salvage points awarded automatically.') + table.insert(lines, '- Coalition message shows points earned and new total.') + table.insert(lines, '') + table.insert(lines, 'Using Salvage Points:') + table.insert(lines, '- When crate requests fail (out of stock), salvage auto-applies if available.') + table.insert(lines, '- Each catalog item has a salvage cost (usually matches its value).') + table.insert(lines, '- Check current salvage: Coach & Nav -> MEDEVAC Status.') + table.insert(lines, '') + table.insert(lines, 'Mobile MASH:') + table.insert(lines, '- Build Mobile MASH crates to deploy field hospitals anywhere.') + table.insert(lines, '- Mobile MASH creates a new delivery zone with radio beacon.') + table.insert(lines, '- Multiple mobile MASHs can be deployed for forward operations.') + table.insert(lines, '') + table.insert(lines, 'Best practices:') + table.insert(lines, '- Monitor MEDEVAC requests: Coach & Nav -> Vectors to Nearest MEDEVAC Crew.') + table.insert(lines, '- Prioritize high-value vehicles (armor, AA) for maximum salvage.') + table.insert(lines, '- Deploy Mobile MASH near active combat zones to reduce delivery time.') + table.insert(lines, '- Coordinate with team: share MEDEVAC locations and salvage status.') + table.insert(lines, '- Watch for warnings: 15min and 5min alerts before crew timeout.') + MESSAGE:New(table.concat(lines, '\n'), 50):ToGroup(group) + end) + + -- Operations -> Troop Transport + local troopsRoot = MENU_GROUP:New(group, 'Troop Transport', opsRoot) + CMD('Load Troops', troopsRoot, function() self:LoadTroops(group) end) + -- Optional typed troop loading submenu + do + local typedRoot = MENU_GROUP:New(group, 'Load Troops (Type)', troopsRoot) + local tcfg = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} + -- Stable order per common roles + local order = { 'AS', 'AA', 'AT', 'AR' } + local seen = {} + local function addItem(key) + local def = tcfg[key] + if not def then return end + local label = (def.label or key) + local size = def.size or 6 + CMD(string.format('%s (%d)', label, size), typedRoot, function() + self:LoadTroops(group, { typeKey = key }) + end) + seen[key] = true + end + for _,k in ipairs(order) do addItem(k) end + -- Add any additional custom types not in the default order + for k,_ in pairs(tcfg) do if not seen[k] then addItem(k) end end + end + do + local tr = (self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000 + CMD('Deploy [Hold Position]', troopsRoot, function() self:UnloadTroops(group, { behavior = 'defend' }) end) + CMD(string.format('Deploy [Attack (%dm)]', tr), troopsRoot, function() self:UnloadTroops(group, { behavior = 'attack' }) end) + end + + -- Operations -> JTAC + do + local jtacRoot = MENU_GROUP:New(group, 'JTAC', opsRoot) + -- Track per-group active JTAC selection + CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} + + -- Select Active JTAC: cycle to nearest or next if already selected + CMD('Select Active JTAC (cycle nearest)', jtacRoot, function() self:JTAC_SelectActiveForGroup(group, { mode = 'nearest' }) end) + + -- Control submenu + local ctl = MENU_GROUP:New(group, 'Control', jtacRoot) + CMD('Pause/Resume Auto-Lase', ctl, function() self:JTAC_TogglePause(group) end) + CMD('Release Current Target', ctl, function() self:JTAC_ReleaseTarget(group) end) + CMD('Force Rescan / Reacquire', ctl, function() self:JTAC_ForceRescan(group) end) + + -- Targeting submenu + local tgt = MENU_GROUP:New(group, 'Targeting', jtacRoot) + local lock = MENU_GROUP:New(group, 'Lock Filter', tgt) + CMD('All', lock, function() self:JTAC_SetLockFilter(group, 'all') end) + CMD('Vehicles only', lock, function() self:JTAC_SetLockFilter(group, 'vehicle') end) + CMD('Troops only', lock, function() self:JTAC_SetLockFilter(group, 'troop') end) + local prof = MENU_GROUP:New(group, 'Priority Profile', tgt) + CMD('Threat (SAM>AAA>Armor>IFV>Arty>Inf)', prof, function() self:JTAC_SetPriority(group, 'threat') end) + CMD('Armor-first', prof, function() self:JTAC_SetPriority(group, 'armor') end) + CMD('Soft-first', prof, function() self:JTAC_SetPriority(group, 'soft') end) + CMD('Infantry-last', prof, function() self:JTAC_SetPriority(group, 'inf_last') end) + + -- Range & Effects submenu + local rng = MENU_GROUP:New(group, 'Range & Effects', jtacRoot) + local sr = MENU_GROUP:New(group, 'Search Radius', rng) + for _,km in ipairs({4,6,8,10,12}) do + CMD(string.format('%d km', km), sr, function() self:JTAC_SetSearchRadius(group, km*1000) end) + end + local sm = MENU_GROUP:New(group, 'Smoke', rng) + CMD('Toggle Smoke On/Off', sm, function() self:JTAC_ToggleSmoke(group) end) + CMD('Color: Blue', sm, function() self:JTAC_SetSmokeColor(group, 'blue') end) + CMD('Color: Orange', sm, function() self:JTAC_SetSmokeColor(group, 'orange') end) + + -- Comms & Laser submenu + local comm = MENU_GROUP:New(group, 'Comms & Laser', jtacRoot) + CMD('Announcements On/Off', comm, function() self:JTAC_ToggleAnnouncements(group) end) + -- Laser code management can be added in phase 2 + + -- Utilities + local util = MENU_GROUP:New(group, 'Utilities', jtacRoot) + CMD('Mark Current Target on Map', util, function() self:JTAC_MarkCurrentTarget(group) end) + -- Rename/Dismiss can be added in phase 2 + + -- Status & Diagnostics (keep at bottom of JTAC) + CMD('List JTAC Status', jtacRoot, function() self:ListJTACStatus(group) end) + CMD('JTAC Diagnostics', jtacRoot, function() self:JTACDiagnostics(group) end) + end + + -- Operations -> MEDEVAC + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + local medevacRoot = MENU_GROUP:New(group, 'MEDEVAC', opsRoot) + CMD('Show Onboard Manifest', medevacRoot, function() self:ShowOnboardManifest(group) end) + + -- List Active MEDEVAC Requests + CMD('List Active MEDEVAC Requests', medevacRoot, function() self:ListActiveMEDEVACRequests(group) end) + + -- Nearest MEDEVAC Location + CMD('Nearest MEDEVAC Location', medevacRoot, function() self:NearestMEDEVACLocation(group) end) + + -- Coalition Salvage Points + CMD('Coalition Salvage Points', medevacRoot, function() self:ShowSalvagePoints(group) end) + + -- Vectors to Nearest MEDEVAC + CMD('Vectors to Nearest MEDEVAC', medevacRoot, function() self:VectorsToNearestMEDEVAC(group) end) + + -- MASH Locations + CMD('MASH Locations', medevacRoot, function() self:ListMASHLocations(group) end) + + -- Pop Smoke at Crew Locations + CMD('Pop Smoke at Crew Locations', medevacRoot, function() self:PopSmokeAtMEDEVACSites(group) end) + + -- Pop Smoke at MASH Zones + CMD('Pop Smoke at MASH Zones', medevacRoot, function() self:PopSmokeAtMASHZones(group) end) + + -- Duplicate guide from Admin/Help -> Player Guides for quick access + MENU_GROUP_COMMAND:New(group, 'MASH & Salvage System - Guide', medevacRoot, function() + local lines = {} + table.insert(lines, 'MASH & Salvage System - Player Guide') + table.insert(lines, '') + table.insert(lines, 'What is it?') + table.insert(lines, '- MASH (Mobile Army Surgical Hospital) zones accept MEDEVAC crew deliveries.') + table.insert(lines, '- When ground vehicles are destroyed, crews spawn nearby and call for rescue.') + table.insert(lines, '- Rescuing crews and delivering them to MASH earns Salvage Points for your coalition.') + table.insert(lines, '- Salvage Points let you build out-of-stock items, keeping logistics flowing.') + table.insert(lines, '') + table.insert(lines, 'How MEDEVAC works:') + table.insert(lines, '- Vehicle destroyed → crew spawns after delay with invulnerability period.') + table.insert(lines, '- MEDEVAC request announced with grid coordinates and salvage value.') + table.insert(lines, '- Crews have a time limit (default 60 minutes); failure = crew KIA and vehicle lost.') + table.insert(lines, '') + table.insert(lines, 'Pickup Methods:') + table.insert(lines, '- AUTO: Land within 500m of crew - they will run to you and board automatically!') + table.insert(lines, '- HOVER: Fly close, hover nearby, load troops normally - system detects MEDEVAC crew.') + table.insert(lines, '- Original vehicle respawns when crew is picked up (if enabled).') + table.insert(lines, '') + table.insert(lines, 'Delivering to MASH:') + table.insert(lines, '- AUTO: Land in any MASH zone - crews unload automatically after 2 seconds.') + table.insert(lines, '- MANUAL: Deploy troops inside MASH zone - salvage points awarded automatically.') + table.insert(lines, '- Coalition message shows points earned and new total.') + table.insert(lines, '') + table.insert(lines, 'Using Salvage Points:') + table.insert(lines, '- When crate requests fail (out of stock), salvage auto-applies if available.') + table.insert(lines, '- Each catalog item has a salvage cost (usually matches its value).') + table.insert(lines, '- Check current salvage: Coach & Nav -> MEDEVAC Status.') + table.insert(lines, '') + table.insert(lines, 'Mobile MASH:') + table.insert(lines, '- Build Mobile MASH crates to deploy field hospitals anywhere.') + table.insert(lines, '- Mobile MASH creates a new delivery zone with radio beacon.') + table.insert(lines, '- Multiple mobile MASHs can be deployed for forward operations.') + table.insert(lines, '') + table.insert(lines, 'Best practices:') + table.insert(lines, '- Monitor MEDEVAC requests: Coach & Nav -> Vectors to Nearest MEDEVAC Crew.') + table.insert(lines, '- Prioritize high-value vehicles (armor, AA) for maximum salvage.') + table.insert(lines, '- Deploy Mobile MASH near active combat zones to reduce delivery time.') + table.insert(lines, '- Coordinate with team: share MEDEVAC locations and salvage status.') + table.insert(lines, '- Watch for warnings: 15min and 5min alerts before crew timeout.') + MESSAGE:New(table.concat(lines, '\n'), 50):ToGroup(group) + end) + + -- Admin/Settings submenu + local medevacAdminRoot = MENU_GROUP:New(group, 'Admin/Settings', medevacRoot) + CMD('Clear All MEDEVAC Missions', medevacAdminRoot, function() self:ClearAllMEDEVACMissions(group) end) + end + + -- Operations -> FARP + if CTLD.FARPConfig and CTLD.FARPConfig.Enabled then + local farpRoot = MENU_GROUP:New(group, 'FARP', opsRoot) + + -- Upgrade FOB to FARP + CMD('Upgrade FOB to FARP', farpRoot, function() self:RequestFARPUpgrade(group) end) + + -- Show FARP Status + CMD('Show FARP Status', farpRoot, function() self:ShowFARPStatus(group) end) + + -- Show Salvage Points + CMD('Coalition Salvage Points', farpRoot, function() self:ShowSalvagePoints(group) end) + + -- FARP System Guide + MENU_GROUP_COMMAND:New(group, 'FARP System - Guide', farpRoot, function() + local lines = {} + table.insert(lines, 'FARP System - Player Guide') + table.insert(lines, '') + table.insert(lines, 'What is FARP?') + table.insert(lines, '- FARP = Forward Arming and Refueling Point') + table.insert(lines, '- Upgrade FOBs into operational FARPs with rearm/refuel capability') + table.insert(lines, '- Progressive stages add equipment and expand services') + table.insert(lines, '- Uses coalition salvage points earned from MEDEVAC and salvage collection') + table.insert(lines, '') + table.insert(lines, 'How to Upgrade:') + table.insert(lines, '1. Build a FOB using normal CTLD mechanics') + table.insert(lines, '2. Earn salvage points (deliver MEDEVAC crews to MASH, sling-load enemy wreckage)') + table.insert(lines, '3. Fly to the FOB pickup zone') + table.insert(lines, '4. Use: Operations -> FARP -> Upgrade FOB to FARP') + table.insert(lines, '5. Each upgrade costs salvage and adds new equipment/services') + table.insert(lines, '') + table.insert(lines, 'FARP Stages:') + table.insert(lines, '') + table.insert(lines, 'Stage 1: Basic FARP Pad (3 salvage)') + table.insert(lines, '- Landing pad with command post') + table.insert(lines, '- Personnel tents and basic supplies') + table.insert(lines, '- Fuel drums and generators') + table.insert(lines, '- Perimeter security (sandbags)') + table.insert(lines, '') + table.insert(lines, 'Stage 2: Operational FARP (5 salvage, 8 total)') + table.insert(lines, '- 2x HEMTT Fuel Trucks - REFUEL CAPABILITY!') + table.insert(lines, '- Large fuel bladders and storage') + table.insert(lines, '- Upgraded command post') + table.insert(lines, '- Defensive barriers (Hesco walls)') + table.insert(lines, '- Support vehicles and power distribution') + table.insert(lines, '- Expanded equipment and tools') + table.insert(lines, '') + table.insert(lines, 'Stage 3: Full Forward Airbase (8 salvage, 16 total)') + table.insert(lines, '- 2x Ammunition Trucks - REARM CAPABILITY!') + table.insert(lines, '- Communications tower (SKP-11 ATC)') + table.insert(lines, '- Large maintenance shelter') + table.insert(lines, '- Complete defensive perimeter') + table.insert(lines, '- Watch tower for security') + table.insert(lines, '- Multiple supply depots') + table.insert(lines, '- Vehicle park with support trucks') + table.insert(lines, '- Unit identification markers') + table.insert(lines, '- Full workshop facilities') + table.insert(lines, '') + table.insert(lines, 'Services Available:') + table.insert(lines, '- Stage 1: Landing zone only') + table.insert(lines, '- Stage 2: Refuel for helicopters & ground vehicles') + table.insert(lines, '- Stage 3: Rearm, Refuel, Repair for all units') + table.insert(lines, '') + table.insert(lines, 'Using FARPs:') + table.insert(lines, '- Land or park within service radius (50-80m depending on stage)') + table.insert(lines, '- Services are automatic for friendly units') + table.insert(lines, '- Helicopters can hover-refuel at Stage 2+') + table.insert(lines, '- Ground vehicles automatically rearm/refuel when stopped in zone') + table.insert(lines, '') + table.insert(lines, 'Strategy Tips:') + table.insert(lines, '- Build FOBs in strategic locations before upgrading') + table.insert(lines, '- Pool salvage as a team for critical FARP upgrades') + table.insert(lines, '- Upgrade forward FOBs to Stage 2 for quick helicopter turnaround') + table.insert(lines, '- Stage 3 FARPs support sustained ground operations') + table.insert(lines, '- Protect your FARPs - they become high-value targets!') + table.insert(lines, '- Check status before upgrading: Operations -> FARP -> Show FARP Status') + table.insert(lines, '') + table.insert(lines, 'Dual Coalition:') + table.insert(lines, '- Each coalition has separate salvage pools') + table.insert(lines, '- FARPs are coalition-specific and only service friendly units') + table.insert(lines, '- Capture enemy territory to deny their FARP network') + MESSAGE:New(table.concat(lines, '\n'), 60):ToGroup(group) + end) + end + + -- Operations (root) -> List JTAC Status (placed at bottom of Operations) + CMD('List JTAC Status', opsRoot, function() self:ListJTACStatus(group) end) + CMD('JTAC Diagnostics', opsRoot, function() self:JTACDiagnostics(group) end) + + -- Logistics -> Crates, Build, and Recipe Details + CMD('Show Onboard Manifest', logRoot, function() self:ShowOnboardManifest(group) end) + local reqRoot = MENU_GROUP:New(group, 'Request Crate', logRoot) + + local crateMgmt = MENU_GROUP:New(group, 'Crate Management', logRoot) + CMD('Drop One Loaded Crate', crateMgmt, function() self:DropLoadedCrates(group, 1) end) + CMD('Drop All Loaded Crates', crateMgmt, function() self:DropLoadedCrates(group, -1) end) + self:_BuildOrRefreshLoadedCrateMenu(group, crateMgmt) + CMD('Re-mark Nearest Crate (Smoke)', crateMgmt, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + local bestName, bestMeta, bestd + for name,meta in pairs(CTLD._crates) do + if meta.side == self.Side then + local dx = (meta.point.x - here.x) + local dz = (meta.point.z - here.z) + local d = math.sqrt(dx*dx + dz*dz) + if (not bestd) or d < bestd then + bestName, bestMeta, bestd = name, meta, d + end + end + end + if bestName and bestMeta then + local sx, sz = bestMeta.point.x, bestMeta.point.z + local sy = 0 + if land and land.getHeight then + local ok, h = pcall(land.getHeight, { x = sx, y = sz }) + if ok and type(h) == 'number' then sy = h end + end + local smokeColor = self.Config.PickupZoneSmokeColor + _spawnCrateSmoke({ x = sx, y = sy, z = sz }, smokeColor, self.Config.CrateSmoke, bestName) + _eventSend(self, group, nil, 'crate_re_marked', { id = bestName, mark = 'smoke' }) + else + _msgGroup(group, 'No friendly crates found to mark.') + end + end) + + local buildRoot = MENU_GROUP:New(group, 'Build Menu', logRoot) + local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 + local buildHereLabel + if cd <= 0 then + buildHereLabel = 'Build All Here' + else + buildHereLabel = string.format('Build Here (w/%ds throttle)', cd) + end + CMD(buildHereLabel, buildRoot, function() self:BuildAtGroup(group) end) + self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) + MENU_GROUP_COMMAND:New(group, 'Refresh Buildable List', buildRoot, function() + self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) + MESSAGE:New('Buildable list refreshed.', 6):ToGroup(group) + end) + + local infoRoot = MENU_GROUP:New(group, 'Recipe Info', logRoot) + if self.Config.UseCategorySubmenus then + local reqSubmenus = {} + local function getRequestSub(catLabel) + if not reqSubmenus[catLabel] then + reqSubmenus[catLabel] = MENU_GROUP:New(group, catLabel, reqRoot) + end + return reqSubmenus[catLabel] + end + + local infoSubs = {} + local function getInfoSub(catLabel) + if not infoSubs[catLabel] then + infoSubs[catLabel] = MENU_GROUP:New(group, catLabel, infoRoot) + end + return infoSubs[catLabel] + end + + local replacementQueue = {} + for key,def in pairs(self.Config.CrateCatalog) do + if not (def and def.hidden) then + local sideOk = (not def.side) or def.side == self.Side + if sideOk then + local catLabel = (def and def.menuCategory) or 'Other' + local reqParent = getRequestSub(catLabel) + local label = self:_formatMenuLabelWithCrates(key, def) + + if def and type(def.requires) == 'table' then + CMD(label, reqParent, function() self:RequestRecipeBundleForGroup(group, key) end) + for reqKey,_ in pairs(def.requires) do + local compDef = self.Config.CrateCatalog[reqKey] + local compSideOk = (not compDef) or (not compDef.side) or compDef.side == self.Side + if compDef and compDef.hidden and compSideOk then + local queue = replacementQueue[catLabel] + if not queue then + queue = { list = {}, seen = {} } + replacementQueue[catLabel] = queue + end + if not queue.seen[reqKey] then + queue.seen[reqKey] = true + table.insert(queue.list, { key = reqKey, def = compDef }) + end + end + end + else + CMD(label, reqParent, function() self:RequestCrateForGroup(group, key) end) + end + + local infoParent = getInfoSub(catLabel) + CMD((def and (def.menu or def.description)) or key, infoParent, function() + local text = self:_formatRecipeInfo(key, def) + _msgGroup(group, text) + end) + end + end + end + + for catLabel,queue in pairs(replacementQueue) do + if queue and queue.list and #queue.list > 0 then + table.sort(queue.list, function(a,b) + local la = (a.def and (a.def.menu or a.def.description)) or a.key + local lb = (b.def and (b.def.menu or b.def.description)) or b.key + return tostring(la) < tostring(lb) + end) + local reqParent = getRequestSub(catLabel) + local replMenu = MENU_GROUP:New(group, 'Replacement Crates', reqParent) + for _,entry in ipairs(queue.list) do + local replLabel = string.format('Replacement: %s', self:_formatMenuLabelWithCrates(entry.key, entry.def)) + CMD(replLabel, replMenu, function() self:RequestCrateForGroup(group, entry.key) end) + end + end + end + else + local replacementList = {} + local replacementSeen = {} + for key,def in pairs(self.Config.CrateCatalog) do + if not (def and def.hidden) then + local sideOk = (not def.side) or def.side == self.Side + if sideOk then + local label = self:_formatMenuLabelWithCrates(key, def) + if def and type(def.requires) == 'table' then + CMD(label, reqRoot, function() self:RequestRecipeBundleForGroup(group, key) end) + for reqKey,_ in pairs(def.requires) do + local compDef = self.Config.CrateCatalog[reqKey] + local compSideOk = (not compDef) or (not compDef.side) or compDef.side == self.Side + if compDef and compDef.hidden and compSideOk and not replacementSeen[reqKey] then + replacementSeen[reqKey] = true + table.insert(replacementList, { key = reqKey, def = compDef }) + end + end + else + CMD(label, reqRoot, function() self:RequestCrateForGroup(group, key) end) + end + + CMD((def and (def.menu or def.description)) or key, infoRoot, function() + local text = self:_formatRecipeInfo(key, def) + _msgGroup(group, text) + end) + end + end + end + + if #replacementList > 0 then + table.sort(replacementList, function(a,b) + local la = (a.def and (a.def.menu or a.def.description)) or a.key + local lb = (b.def and (b.def.menu or b.def.description)) or b.key + return tostring(la) < tostring(lb) + end) + local replMenu = MENU_GROUP:New(group, 'Replacement Crates', reqRoot) + for _,entry in ipairs(replacementList) do + local replLabel = string.format('Replacement: %s', self:_formatMenuLabelWithCrates(entry.key, entry.def)) + CMD(replLabel, replMenu, function() self:RequestCrateForGroup(group, entry.key) end) + end + end + end + + -- Logistics -> Show Inventory at Nearest Pickup Zone/FOB + CMD('Show Inventory at Nearest Zone', logRoot, function() self:ShowNearestZoneInventory(group) end) + + -- Field Tools + CMD('Create Drop Zone (AO)', toolsRoot, function() self:CreateDropZoneAtGroup(group) end) + + -- Salvage Collection Zones submenu + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + local salvageZoneRoot = MENU_GROUP:New(group, 'Salvage Collection Zones', toolsRoot) + CMD('Create Salvage Zone Here', salvageZoneRoot, function() self:CreateSalvageZoneAtGroup(group) end) + CMD('Show Active Salvage Zones', salvageZoneRoot, function() self:ShowActiveSalvageZones(group) end) + CMD('Retire Oldest Salvage Zone', salvageZoneRoot, function() self:RetireOldestDynamicSalvageZone(group) end) + -- Dynamic per-zone management will be added by _rebuildSalvageZoneMenus + end + + local smokeRoot = MENU_GROUP:New(group, 'Smoke My Location', toolsRoot) + local function smokeHere(color) + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local p = unit:GetPointVec3() + -- Use full Vec3 to ensure correct placement + trigger.action.smoke({ x = p.x, y = p.y, z = p.z }, color) + end + MENU_GROUP_COMMAND:New(group, 'Green', smokeRoot, function() smokeHere(trigger.smokeColor.Green) end) + MENU_GROUP_COMMAND:New(group, 'Red', smokeRoot, function() smokeHere(trigger.smokeColor.Red) end) + MENU_GROUP_COMMAND:New(group, 'White', smokeRoot, function() smokeHere(trigger.smokeColor.White) end) + MENU_GROUP_COMMAND:New(group, 'Orange', smokeRoot, function() smokeHere(trigger.smokeColor.Orange) end) + MENU_GROUP_COMMAND:New(group, 'Blue', smokeRoot, function() smokeHere(trigger.smokeColor.Blue) end) + + -- Navigation + local gname = group:GetName() + CMD('Request Vectors to Nearest Crate', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + local bestName, bestMeta, bestd + for name,meta in pairs(CTLD._crates) do + if meta.side == self.Side then + local dx = (meta.point.x - here.x) + local dz = (meta.point.z - here.z) + local d = math.sqrt(dx*dx + dz*dz) + if (not bestd) or d < bestd then + bestName, bestMeta, bestd = name, meta, d + end + end + end + if bestName and bestMeta then + local brg = _bearingDeg(here, bestMeta.point) + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(bestd, isMetric) + _eventSend(self, group, nil, 'vectors_to_crate', { id = bestName, brg = brg, rng = rngV, rng_u = rngU }) + else + _msgGroup(group, 'No friendly crates found.') + end + end) + + -- Sling-Load Salvage vectors + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + CMD('Vectors to Nearest Salvage Crate', navRoot, function() self:ShowNearestSalvageCrate(group) end) + end + + CMD('Vectors to Nearest Pickup Zone', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local zone = nil + local dist = nil + local list = nil + if self.Config and self.Config.Zones and self.Config.Zones.PickupZones then + list = {} + for _,z in ipairs(self.Config.Zones.PickupZones) do + if (not z.name) or self._ZoneActive.Pickup[z.name] ~= false then table.insert(list, z) end + end + elseif self.PickupZones and #self.PickupZones > 0 then + list = {} + for _,mz in ipairs(self.PickupZones) do + if mz and mz.GetName then + local n = mz:GetName() + if self._ZoneActive.Pickup[n] ~= false then table.insert(list, { name = n }) end + end + end + else + list = {} + end + zone, dist = _nearestZonePoint(unit, list) + if not zone then + local allDefs = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} + if allDefs and #allDefs > 0 then + local fbZone, fbDist = _nearestZonePoint(unit, allDefs) + if fbZone then + local up = unit:GetPointVec3(); local zp = fbZone:GetPointVec3() + local from = { x = up.x, z = up.z } + local to = { x = zp.x, z = zp.z } + local brg = _bearingDeg(from, to) + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(fbDist or 0, isMetric) + _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fbZone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) + return + end + end + _eventSend(self, group, nil, 'no_pickup_zones', {}) + return + end + local up = unit:GetPointVec3() + local zp = zone:GetPointVec3() + local from = { x = up.x, z = up.z } + local to = { x = zp.x, z = zp.z } + local brg = _bearingDeg(from, to) + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(dist, isMetric) + _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = zone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) + end) + + -- Navigation -> Smoke Nearest Zone (Pickup/Drop/FOB) + CMD('Smoke Nearest Zone (Pickup/Drop/FOB/MASH)', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + -- Build lists of active zones by kind in a format usable by _nearestZonePoint + local function collectActive(kind) + if kind == 'Pickup' then + return self:_collectActivePickupDefs() + elseif kind == 'Drop' then + local out = {} + for _, mz in ipairs(self.DropZones or {}) do + if mz and mz.GetName then + local n = mz:GetName() + if (self._ZoneActive and self._ZoneActive.Drop and self._ZoneActive.Drop[n] ~= false) then + table.insert(out, { name = n }) + end + end + end + return out + elseif kind == 'FOB' then + local out = {} + for _, mz in ipairs(self.FOBZones or {}) do + if mz and mz.GetName then + local n = mz:GetName() + if (self._ZoneActive and self._ZoneActive.FOB and self._ZoneActive.FOB[n] ~= false) then + table.insert(out, { name = n }) + end + end + end + return out + elseif kind == 'MASH' then + local out = {} + if CTLD._mashZones then + for name, data in pairs(CTLD._mashZones) do + if data and data.side == self.Side and data.zone then + table.insert(out, { name = name }) + end + end + end + return out + end + return {} + end + + local bestKind, bestZone, bestDist + for _, k in ipairs({ 'Pickup', 'Drop', 'FOB', 'MASH' }) do + local list = collectActive(k) + if list and #list > 0 then + local z, d = _nearestZonePoint(unit, list) + if z and d and ((not bestDist) or d < bestDist) then + bestKind, bestZone, bestDist = k, z, d + end + end + end + + if not bestZone then + _msgGroup(group, 'No zones available to smoke.') + return + end + + -- Determine smoke point (zone center) + -- _getZoneCenterAndRadius returns (center, radius); call directly to capture center + local center + if self._getZoneCenterAndRadius then center = select(1, self:_getZoneCenterAndRadius(bestZone)) end + if not center then + local v3 = bestZone:GetPointVec3() + center = { x = v3.x, y = v3.y or 0, z = v3.z } + else + center = { x = center.x, y = center.y or 0, z = center.z } + end + + -- Choose smoke color per kind + local color = trigger.smokeColor.Green -- default + if bestKind == 'Pickup' then + color = self.Config.PickupZoneSmokeColor or trigger.smokeColor.Green + elseif bestKind == 'Drop' then + color = trigger.smokeColor.Red + elseif bestKind == 'FOB' then + color = trigger.smokeColor.White + elseif bestKind == 'MASH' then + color = trigger.smokeColor.Orange + end + + -- Apply smoke offset system (use crate smoke config settings) + local smokeConfig = self.Config.CrateSmoke or {} + local smokePos = { + x = center.x, + y = land.getHeight({x = center.x, y = center.z}), + z = center.z + } + local offsetMeters = tonumber(smokeConfig.OffsetMeters) or 5 + local offsetRandom = (smokeConfig.OffsetRandom ~= false) -- default true + local offsetVertical = tonumber(smokeConfig.OffsetVertical) or 2 + + if offsetMeters > 0 then + local angle = 0 + if offsetRandom then + angle = math.random() * 2 * math.pi + end + smokePos.x = smokePos.x + offsetMeters * math.cos(angle) + smokePos.z = smokePos.z + offsetMeters * math.sin(angle) + end + smokePos.y = smokePos.y + offsetVertical + + -- Use MOOSE COORDINATE smoke for better appearance (tall, thin smoke like cargo smoke) + local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) + if coord and coord.Smoke then + if color == trigger.smokeColor.Green then + coord:SmokeGreen() + elseif color == trigger.smokeColor.Red then + coord:SmokeRed() + elseif color == trigger.smokeColor.White then + coord:SmokeWhite() + elseif color == trigger.smokeColor.Orange then + coord:SmokeOrange() + elseif color == trigger.smokeColor.Blue then + coord:SmokeBlue() + else + coord:SmokeGreen() + end + local distKm = bestDist / 1000 + local distNm = bestDist / 1852 + _msgGroup(group, string.format('Smoked nearest %s zone: %s (%.1f km / %.1f nm)', bestKind, bestZone:GetName(), distKm, distNm)) + elseif trigger and trigger.action and trigger.action.smoke then + -- Fallback to trigger.action.smoke if MOOSE COORDINATE not available + trigger.action.smoke(smokePos, color) + local distKm = bestDist / 1000 + local distNm = bestDist / 1852 + _msgGroup(group, string.format('Smoked nearest %s zone: %s (%.1f km / %.1f nm)', bestKind, bestZone:GetName(), distKm, distNm)) + else + _msgGroup(group, 'Smoke not available in this environment.') + end + end) + + -- Smoke all nearby zones within range + CMD('Smoke All Nearby Zones (5km)', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local maxRange = 5000 -- 5km in meters + + -- Get unit position + local uname = unit:GetName() + local du = Unit.getByName and Unit.getByName(uname) or nil + if not du or not du:getPoint() then + _msgGroup(group, 'Unable to determine your position.') + return + end + local up = du:getPoint() + local ux, uz = up.x, up.z + + -- Helper function to calculate distance and smoke a zone if in range + local function smokeZoneIfInRange(zoneName, zoneObj, zoneType, smokeColor) + if not zoneObj then return false end + + -- Get zone center + local center + if self._getZoneCenterAndRadius then + center = select(1, self:_getZoneCenterAndRadius(zoneObj)) + end + if not center and zoneObj.GetPointVec3 then + local v3 = zoneObj:GetPointVec3() + center = { x = v3.x, y = v3.y or 0, z = v3.z } + end + + if not center then return false end + + -- Calculate distance + local dx = center.x - ux + local dz = center.z - uz + local dist = math.sqrt(dx*dx + dz*dz) + + if dist <= maxRange then + -- Apply smoke offset system + local smokeConfig = self.Config.CrateSmoke or {} + local smokePos = { + x = center.x, + y = land.getHeight({x = center.x, y = center.z}), + z = center.z + } + local offsetMeters = tonumber(smokeConfig.OffsetMeters) or 5 + local offsetRandom = (smokeConfig.OffsetRandom ~= false) + local offsetVertical = tonumber(smokeConfig.OffsetVertical) or 2 + + if offsetMeters > 0 then + local angle = 0 + if offsetRandom then + angle = math.random() * 2 * math.pi + end + smokePos.x = smokePos.x + offsetMeters * math.cos(angle) + smokePos.z = smokePos.z + offsetMeters * math.sin(angle) + end + smokePos.y = smokePos.y + offsetVertical + + -- Spawn smoke + local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) + if coord and coord.Smoke then + if smokeColor == trigger.smokeColor.Green then + coord:SmokeGreen() + elseif smokeColor == trigger.smokeColor.Red then + coord:SmokeRed() + elseif smokeColor == trigger.smokeColor.White then + coord:SmokeWhite() + elseif smokeColor == trigger.smokeColor.Orange then + coord:SmokeOrange() + elseif smokeColor == trigger.smokeColor.Blue then + coord:SmokeBlue() + else + coord:SmokeGreen() + end + else + trigger.action.smoke(smokePos, smokeColor) + end + + return true, dist + end + + return false, dist + end + + -- Helper to get color name + local function getColorName(color) + if color == trigger.smokeColor.Green then return "Green" + elseif color == trigger.smokeColor.Red then return "Red" + elseif color == trigger.smokeColor.White then return "White" + elseif color == trigger.smokeColor.Orange then return "Orange" + elseif color == trigger.smokeColor.Blue then return "Blue" + else return "Unknown" end + end + + local count = 0 + local zones = {} + + -- Check Pickup zones + local pickupDefs = self:_collectActivePickupDefs() + for _, def in ipairs(pickupDefs or {}) do + local mz = _findZone(def) + if mz then + -- Check for zone-specific smoke override, then fall back to config default + local zdef = self._ZoneDefs and self._ZoneDefs.PickupZones and self._ZoneDefs.PickupZones[def.name] + local smokeColor = (zdef and zdef.smoke) or self.Config.PickupZoneSmokeColor or trigger.smokeColor.Green + local smoked, dist = smokeZoneIfInRange(def.name, mz, 'Pickup', smokeColor) + if smoked then + count = count + 1 + local zp = mz:GetPointVec3() + local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) + table.insert(zones, string.format('Pickup: %s - %.1f km @ %03d° (%s)', def.name, dist/1000, brg, getColorName(smokeColor))) + end + end + end + + -- Check Drop zones + for _, mz in ipairs(self.DropZones or {}) do + if mz and mz.GetName then + local n = mz:GetName() + if (self._ZoneActive and self._ZoneActive.Drop and self._ZoneActive.Drop[n] ~= false) then + local smokeColor = trigger.smokeColor.Red + local smoked, dist = smokeZoneIfInRange(n, mz, 'Drop', smokeColor) + if smoked then + count = count + 1 + local zp = mz:GetPointVec3() + local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) + table.insert(zones, string.format('Drop: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) + end + end + end + end + + -- Check FOB zones + for _, mz in ipairs(self.FOBZones or {}) do + if mz and mz.GetName then + local n = mz:GetName() + if (self._ZoneActive and self._ZoneActive.FOB and self._ZoneActive.FOB[n] ~= false) then + local smokeColor = trigger.smokeColor.White + local smoked, dist = smokeZoneIfInRange(n, mz, 'FOB', smokeColor) + if smoked then + count = count + 1 + local zp = mz:GetPointVec3() + local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) + table.insert(zones, string.format('FOB: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) + end + end + end + end + + -- Check MASH zones + if CTLD._mashZones then + for name, data in pairs(CTLD._mashZones) do + if data and data.side == self.Side and data.zone then + local smokeColor = trigger.smokeColor.Orange + local smoked, dist = smokeZoneIfInRange(name, data.zone, 'MASH', smokeColor) + if smoked then + count = count + 1 + local zp = data.zone:GetPointVec3() + local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) + table.insert(zones, string.format('MASH: %s - %.1f km @ %03d° (%s)', name, dist/1000, brg, getColorName(smokeColor))) + end + end + end + end + + -- Check Salvage Drop zones + for _, mz in ipairs(self.SalvageDropZones or {}) do + if mz and mz.GetName then + local n = mz:GetName() + local isActive = true + if self._ZoneActive and self._ZoneActive.SalvageDrop then + isActive = (self._ZoneActive.SalvageDrop[n] ~= false) + end + if isActive then + local zdef = self._ZoneDefs and self._ZoneDefs.SalvageDropZones and self._ZoneDefs.SalvageDropZones[n] + local smokeColor = (zdef and zdef.smoke) or trigger.smokeColor.Orange + local smoked, dist = smokeZoneIfInRange(n, mz, 'SalvageDrop', smokeColor) + if smoked then + count = count + 1 + local zp = mz:GetPointVec3() + local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) + table.insert(zones, string.format('Salvage: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) + end + end + end + end + + if count == 0 then + _msgGroup(group, string.format('No zones found within %.1f km.', maxRange/1000), 10) + else + local msg = string.format('Smoked %d zone(s) within %.1f km:\n%s', count, maxRange/1000, table.concat(zones, '\n')) + _msgGroup(group, msg, 15) + end + end) + + -- Navigation -> MEDEVAC menu items (if MEDEVAC enabled) + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + CMD('Vectors to Nearest MEDEVAC Crew', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local isMetric = _getPlayerIsMetric(unit) + local nearest = nil + local nearestDist = math.huge + + -- Find nearest crew of same coalition + for crewName, crewData in pairs(CTLD._medevacCrews or {}) do + if crewData.side == self.Side and not crewData.pickedUp then + local dx = crewData.position.x - pos.x + local dz = crewData.position.z - pos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist < nearestDist then + nearestDist = dist + nearest = crewData + end + end + end + + if not nearest then + _msgGroup(group, 'No active MEDEVAC requests.') + return + end + + local brg = _bearingDeg({ x = pos.x, z = pos.z }, { x = nearest.position.x, z = nearest.position.z }) + local v, u = _fmtRange(nearestDist, isMetric) + + -- Calculate time remaining until timeout + local cfg = CTLD.MEDEVAC + local timeoutAt = nearest.spawnTime + (cfg.CrewTimeout or 3600) + local timeRemain = math.max(0, math.floor((timeoutAt - timer.getTime()) / 60)) + + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_vectors, { + vehicle = nearest.vehicleType, + brg = brg, + rng = v, + rng_u = u, + time_remain = timeRemain + })) + end) + + CMD('Vectors to Nearest MASH', navRoot, function() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local isMetric = _getPlayerIsMetric(unit) + local nearest = nil + local nearestDist = math.huge + + -- Find nearest MASH of same coalition + for _, mashData in pairs(CTLD._mashZones or {}) do + if mashData.side == self.Side then + local dx = mashData.position.x - pos.x + local dz = mashData.position.z - pos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist < nearestDist then + nearestDist = dist + nearest = mashData + end + end + end + + if not nearest then + _msgGroup(group, 'No active MASH zones.') + return + end + + local brg = _bearingDeg({ x = pos.x, z = pos.z }, { x = nearest.position.x, z = nearest.position.z }) + local v, u = _fmtRange(nearestDist, isMetric) + local mashName = nearest.isMobile and ('Mobile MASH ' .. (nearest.id:match('_(%d+)$') or '?')) or nearest.catalogKey + + _msgGroup(group, string.format('Nearest MASH: %s, bearing %d°, range %s %s', mashName, brg, v, u)) + end) + end + + -- Hover Coach (at end of Navigation submenu) + CMD('Hover Coach: Enable', navRoot, function() + CTLD._coachOverride = CTLD._coachOverride or {} + CTLD._coachOverride[gname] = true + _eventSend(self, group, nil, 'coach_enabled', {}) + end) + CMD('Hover Coach: Disable', navRoot, function() + CTLD._coachOverride = CTLD._coachOverride or {} + CTLD._coachOverride[gname] = false + _eventSend(self, group, nil, 'coach_disabled', {}) + end) + + -- Admin/Help + -- Status & map controls + CMD('Show CTLD Status', adminRoot, function() + local crates = 0 + for _ in pairs(CTLD._crates) do crates = crates + 1 end + local msg = string.format('CTLD Status:\nActive crates: %d\nPickup zones: %d\nDrop zones: %d\nFOB zones: %d\nBuild Confirm: %s (%ds window)\nBuild Cooldown: %s (%ds)' + , crates, #(self.PickupZones or {}), #(self.DropZones or {}), #(self.FOBZones or {}) + , self.Config.BuildConfirmEnabled and 'ON' or 'OFF', self.Config.BuildConfirmWindowSeconds or 0 + , self.Config.BuildCooldownEnabled and 'ON' or 'OFF', self.Config.BuildCooldownSeconds or 0) + + -- Add MEDEVAC info if enabled + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + local activeRequests = 0 + for _, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side and not data.pickedUp then + activeRequests = activeRequests + 1 + end + end + local salvage = CTLD._salvagePoints[self.Side] or 0 + local mashCount = 0 + for _, m in pairs(CTLD._mashZones or {}) do + if m.side == self.Side then mashCount = mashCount + 1 end + end + msg = msg .. string.format('\n\nMEDEVAC:\nActive requests: %d\nMASH zones: %d\nSalvage points: %d', + activeRequests, mashCount, salvage) + end + + MESSAGE:New(msg, 20):ToGroup(group) + end) + CMD('Draw CTLD Zones on Map', adminRoot, function() + self:DrawZonesOnMap() + MESSAGE:New('CTLD zones drawn on F10 map.', 8):ToGroup(group) + end) + CMD('Clear CTLD Map Drawings', adminRoot, function() + self:ClearMapDrawings() + MESSAGE:New('CTLD map drawings cleared.', 8):ToGroup(group) + end) + + -- MEDEVAC Statistics (if enabled) + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CMD('Show MEDEVAC Statistics', adminRoot, function() + local stats = CTLD._medevacStats[self.Side] or {} + local lines = {} + table.insert(lines, 'MEDEVAC Statistics:') + table.insert(lines, '') + table.insert(lines, string.format('Crews spawned: %d', stats.spawned or 0)) + table.insert(lines, string.format('Crews rescued: %d', stats.rescued or 0)) + table.insert(lines, string.format('Delivered to MASH: %d', stats.delivered or 0)) + table.insert(lines, string.format('Timed out: %d', stats.timedOut or 0)) + table.insert(lines, string.format('Killed in action: %d', stats.killed or 0)) + table.insert(lines, '') + table.insert(lines, string.format('Vehicles respawned: %d', stats.vehiclesRespawned or 0)) + table.insert(lines, string.format('Salvage earned: %d', stats.salvageEarned or 0)) + table.insert(lines, string.format('Salvage used: %d', stats.salvageUsed or 0)) + table.insert(lines, string.format('Current salvage: %d', CTLD._salvagePoints[self.Side] or 0)) + + MESSAGE:New(table.concat(lines, '\n'), 30):ToGroup(group) + end) + end + + -- Admin/Help -> Debug + local debugMenu = MENU_GROUP:New(group, 'Debug', adminRoot) + CMD('Enable verbose logging', debugMenu, function() + self.Config.LogLevel = LOG_DEBUG + _logInfo(string.format('[%s] Verbose/Debug logging ENABLED via Admin menu', tostring(self.Side))) + MESSAGE:New('CTLD verbose logging ENABLED (LogLevel=4)', 8):ToGroup(group) + end) + CMD('Normal logging (INFO)', debugMenu, function() + self.Config.LogLevel = LOG_INFO + _logInfo(string.format('[%s] Logging set to INFO level via Admin menu', tostring(self.Side))) + MESSAGE:New('CTLD logging set to INFO (LogLevel=2)', 8):ToGroup(group) + end) + CMD('Minimal logging (ERRORS only)', debugMenu, function() + self.Config.LogLevel = LOG_ERROR + _logInfo(string.format('[%s] Logging set to ERROR-only via Admin menu', tostring(self.Side))) + MESSAGE:New('CTLD logging set to ERRORS only (LogLevel=1)', 8):ToGroup(group) + end) + CMD('Disable all logging', debugMenu, function() + self.Config.LogLevel = LOG_NONE + MESSAGE:New('CTLD logging DISABLED (LogLevel=0)', 8):ToGroup(group) + end) + + -- Admin/Help -> Player Guides (moved earlier) + + return root +end + +-- Create or refresh the filtered "In Stock Here" menu for a group. +-- If rootMenu is provided, (re)create under that. Otherwise, reuse previous stored root. +function CTLD:_BuildOrRefreshInStockMenu(group, rootMenu) + if not (self.Config.Inventory and self.Config.Inventory.Enabled and self.Config.Inventory.HideZeroStockMenu) then return end + if not group or not group:IsAlive() then return end + local gname = group:GetName() + -- remove previous menu if present and rootMenu not explicitly provided + local existing = CTLD._inStockMenus[gname] + if existing and existing.menu and (rootMenu == nil) then + pcall(function() existing.menu:Remove() end) + CTLD._inStockMenus[gname] = nil + end + + local parent = rootMenu or (self.MenusByGroup and self.MenusByGroup[gname]) + if not parent then return end + + -- Create a fresh submenu root + local inRoot = MENU_GROUP:New(group, 'Request Crate (In Stock Here)', parent) + CTLD._inStockMenus[gname] = { menu = inRoot } + + -- Find nearest active pickup zone + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local zone, dist = self:_nearestActivePickupZone(unit) + if not zone then + MENU_GROUP_COMMAND:New(group, 'No active supply zone nearby', inRoot, function() + -- Inform and also provide vectors to nearest configured zone if any + _eventSend(self, group, nil, 'no_pickup_zones', {}) + -- Fallback: try any configured pickup zone (ignoring active state) for helpful vectors + local list = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} + if list and #list > 0 then + local unit = group:GetUnit(1) + if unit and unit:IsAlive() then + local fallbackZone, fallbackDist = _nearestZonePoint(unit, list) + if fallbackZone then + local up = unit:GetPointVec3(); local zp = fallbackZone:GetPointVec3() + local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(fallbackDist or 0, isMetric) + _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fallbackZone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) + end + end + end + end) + -- Still add a refresh item + MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) + return + end + local zname = zone:GetName() + local maxd = self.Config.PickupZoneMaxDistance or 10000 + if not dist or dist > maxd then + MENU_GROUP_COMMAND:New(group, string.format('Nearest zone %s is beyond limit (%.0f m).', zname, dist or 0), inRoot, function() + local isMetric = _getPlayerIsMetric(unit) + local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) + local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() + local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) + _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) + end) + MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) + return + end + + -- Info and refresh commands at top + MENU_GROUP_COMMAND:New(group, string.format('Nearest Supply: %s', zname), inRoot, function() + local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() + local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(dist or 0, isMetric) + _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = zname, brg = brg, rng = rngV, rng_u = rngU }) + end) + MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) + + -- Build commands for items with stock > 0 at this zone; single-unit entries only + local inStock = {} + local stock = CTLD._stockByZone[zname] or {} + for key,def in pairs(self.Config.CrateCatalog or {}) do + local sideOk = (not def.side) or def.side == self.Side + local isSingle = (type(def.requires) ~= 'table') + if sideOk and isSingle then + local cnt = tonumber(stock[key] or 0) or 0 + if cnt > 0 then + table.insert(inStock, { key = key, def = def, cnt = cnt }) + end + end + end + -- Stable sort by menu label for consistency + table.sort(inStock, function(a,b) + local la = (a.def and (a.def.menu or a.def.description)) or a.key + local lb = (b.def and (b.def.menu or b.def.description)) or b.key + return tostring(la) < tostring(lb) + end) + + if #inStock == 0 then + MENU_GROUP_COMMAND:New(group, 'None in stock at this zone', inRoot, function() + _msgGroup(group, string.format('No crates in stock at %s.', zname)) + end) + else + for _,it in ipairs(inStock) do + local base = (it.def and (it.def.menu or it.def.description)) or it.key + local total = self:_recipeTotalCrates(it.def) + local suffix = (total == 1) and '1 crate' or (tostring(total)..' crates') + local label = string.format('%s (%s) [%d available]', base, suffix, it.cnt) + MENU_GROUP_COMMAND:New(group, label, inRoot, function() + self:RequestCrateForGroup(group, it.key) + -- After requesting, refresh to reflect the decremented stock + local id = timer.scheduleFunction(function() self:_BuildOrRefreshInStockMenu(group) end, {}, timer.getTime() + 0.1) + _trackOneShotTimer(id) + end) + end + end +end + +-- Create or refresh the dynamic Build (Advanced) menu for a group. +function CTLD:_BuildOrRefreshBuildAdvancedMenu(group, rootMenu) + if not group or not group:IsAlive() then return end + -- Clear previous dynamic children if any by recreating the submenu root when rootMenu passed + -- We'll remove and recreate inner items by making a temporary child root + local gname = group:GetName() + -- Remove existing dynamic children by creating a fresh inner menu under the provided root + local dynRoot = MENU_GROUP:New(group, 'Buildable Near You', rootMenu) + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + local hdgRad, _ = _headingRadDeg(unit) + local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) + local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } + local radius = self.Config.BuildRadius or 100 + local nearby = self:GetNearbyCrates(here, radius) + local filtered = {} + for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end + nearby = filtered + -- Count by key + local counts = {} + for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end + -- Include carried crates if allowed + if self.Config.BuildRequiresGroundCrates ~= true then + local gname = group:GetName() + local carried = CTLD._loadedCrates[gname] + if carried and carried.byKey then + for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end + end + end + -- FOB restriction context + local insideFOBZone = select(1, self:IsPointInFOBZones(here)) + + -- Build list of buildable recipes + local items = {} + for key,cat in pairs(self.Config.CrateCatalog or {}) do + local sideOk = (not cat.side) or cat.side == self.Side + if sideOk and cat and cat.build then + local ok = false + if type(cat.requires) == 'table' then + ok = true + for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < (qty or 0) then ok = false; break end end + else + ok = ((counts[key] or 0) >= (cat.required or 1)) + end + if ok then + if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone) then + table.insert(items, { key = key, def = cat }) + end + end + end + end + + if #items == 0 then + MENU_GROUP_COMMAND:New(group, 'None buildable here. Drop required crates close to your aircraft.', dynRoot, function() + MESSAGE:New('No buildable items with nearby crates. Use Recipe Info to check requirements.', 10):ToGroup(group) + end) + return + end + + -- Stable ordering by label + table.sort(items, function(a,b) + local la = (a.def and (a.def.menu or a.def.description)) or a.key + local lb = (b.def and (b.def.menu or b.def.description)) or b.key + return tostring(la) < tostring(lb) + end) + + -- Create per-item submenus + local function CMD(title, parent, cb) + return MENU_GROUP_COMMAND:New(group, title, parent, function() + local ok, err = pcall(cb) + if not ok then _logVerbose('BuildAdv menu error: '..tostring(err)); MESSAGE:New('CTLD menu error: '..tostring(err), 8):ToGroup(group) end + end) + end + + for _,it in ipairs(items) do + local label = (it.def and (it.def.menu or it.def.description)) or it.key + local perItem = MENU_GROUP:New(group, label, dynRoot) + local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 + local holdTitle, attackTitle + if cd <= 0 then + holdTitle = 'Build All [Hold Position]' + attackTitle = string.format('Build All [Attack (%dm)]', (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000) + else + holdTitle = string.format('Build (w/%ds throttle) [Hold Position]', cd) + attackTitle = string.format('Build (w/%ds throttle) [Attack (%dm)]', cd, (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000) + end + -- Hold Position + CMD(holdTitle, perItem, function() + self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' }) + end) + -- Attack variant (render even if canAttackMove=false; we message accordingly) + local vr = (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000 + CMD(attackTitle, perItem, function() + if it.def and it.def.canAttackMove == false then + MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) + self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' }) + else + self:BuildSpecificAtGroup(group, it.key, { behavior = 'attack' }) + end + end) + end +end + +-- Build a specific recipe at the group position if crates permit; supports behavior opts +function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local ctld = self + -- Reuse Build cooldown/confirm logic + local now = timer.getTime() + local gname = group:GetName() + if self.Config.BuildCooldownEnabled then + local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 + if cd > 0 then + local last = CTLD._buildCooldown[gname] + if last and (now - last) < cd then + local rem = math.max(0, math.ceil(cd - (now - last))) + _msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem)) + return + end + end + end + if self.Config.BuildConfirmEnabled then + local first = CTLD._buildConfirm[gname] + local win = self.Config.BuildConfirmWindowSeconds or 10 + if not first or (now - first) > win then + CTLD._buildConfirm[gname] = now + _msgGroup(group, string.format('Confirm build: select again within %ds to proceed.', win)) + return + else + CTLD._buildConfirm[gname] = nil + end + end + + local def = self.Config.CrateCatalog[recipeKey] + if not def or not def.build then _msgGroup(group, 'Unknown or unbuildable recipe: '..tostring(recipeKey)); return end + + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + local hdgRad, hdgDeg = _headingRadDeg(unit) + local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) + local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } + local radius = self.Config.BuildRadius or 100 + local nearby = self:GetNearbyCrates(here, radius) + local filtered = {} + for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end + nearby = filtered + if #nearby == 0 and self.Config.BuildRequiresGroundCrates ~= true then + -- still can build using carried crates + elseif #nearby == 0 then + _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) + return + end + + -- Count by key + local counts = {} + for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end + -- Include carried crates + local carried = CTLD._loadedCrates[gname] + if self.Config.BuildRequiresGroundCrates ~= true then + if carried and carried.byKey then for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end end + end + + -- Helper to consume crates of a given key/qty (prefers carried when allowed) + local function consumeCrates(key, qty) + local removed = 0 + if self.Config.BuildRequiresGroundCrates ~= true then + if carried and carried.byKey and (carried.byKey[key] or 0) > 0 then + local take = math.min(qty, carried.byKey[key]) + carried.byKey[key] = carried.byKey[key] - take + if carried.byKey[key] <= 0 then carried.byKey[key] = nil end + carried.total = math.max(0, (carried.total or 0) - take) + removed = removed + take + if take > 0 then ctld:_scheduleLoadedCrateMenuRefresh(group) end + end + end + for _,c in ipairs(nearby) do + if removed >= qty then break end + if c.meta.key == key then + local obj = StaticObject.getByName(c.name) + if obj then obj:destroy() end + _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule + if c.meta and c.meta.point then + _removeFromSpatialGrid(c.name, c.meta.point, 'crate') -- prune hover pickup spatial cache + end + CTLD._crates[c.name] = nil + removed = removed + 1 + end + end + end + + -- FOB restriction check + if def.isFOB and self.Config.RestrictFOBToZones then + local inside = select(1, self:IsPointInFOBZones(here)) + if not inside then _eventSend(self, group, nil, 'fob_restricted', {}); return end + end + + -- Special-case: SAM Site Repair/Augment entries (isRepair) + if def.isRepair == true or tostring(recipeKey):find('_REPAIR', 1, true) then + -- Map recipe key family to a template definition + local function identifyTemplate(key) + if key:find('HAWK', 1, true) then + return { + name='HAWK', side=def.side or self.Side, + baseUnits={ {type='Hawk sr', dx=12, dz=8}, {type='Hawk tr', dx=-12, dz=8}, {type='Hawk pcp', dx=18, dz=12}, {type='Hawk cwar', dx=-18, dz=12} }, + launcherType='Hawk ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6 + } + elseif key:find('PATRIOT', 1, true) then + return { + name='PATRIOT', side=def.side or self.Side, + baseUnits={ {type='Patriot str', dx=14, dz=10}, {type='Patriot ECS', dx=-14, dz=10} }, + launcherType='Patriot ln', launcherStart={dx=0, dz=0}, launcherStep={dx=8, dz=0}, maxLaunchers=6 + } + elseif key:find('KUB', 1, true) then + return { + name='KUB', side=def.side or self.Side, + baseUnits={ {type='Kub 1S91 str', dx=12, dz=8} }, + launcherType='Kub 2P25 ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=3 + } + elseif key:find('BUK', 1, true) then + return { + name='BUK', side=def.side or self.Side, + baseUnits={ {type='SA-11 Buk SR 9S18M1', dx=12, dz=8}, {type='SA-11 Buk CC 9S470M1', dx=-12, dz=8} }, + launcherType='SA-11 Buk LN 9A310M1', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6 + } + end + return nil + end + + local tpl = identifyTemplate(tostring(recipeKey)) + if not tpl then _msgGroup(group, 'No matching SAM site type for repair: '..tostring(recipeKey)); return end + + -- Determine how many repair crates to apply + local cratesAvail = counts[recipeKey] or 0 + if cratesAvail <= 0 then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end + + -- Find nearest existing site group that matches template + local function vec2(u) + local p = u:getPoint(); return { x = p.x, z = p.z } + end + local function dist2(a,b) + local dx, dz = a.x-b.x, a.z-b.z; return math.sqrt(dx*dx+dz*dz) + end + local searchR = math.max(250, (self.Config.BuildRadius or 100) * 10) + local groups = coalition.getGroups(tpl.side, Group.Category.GROUND) or {} + local here2 = { x = here.x, z = here.z } + local bestG, bestD, bestInfo = nil, 1e9, nil + for _,g in ipairs(groups) do + if g and g:isExist() then + local units = g:getUnits() or {} + if #units > 0 then + -- Compute center and count types + local cx, cz = 0, 0 + local byType = {} + for _,u in ipairs(units) do + local pt = u:getPoint(); cx = cx + pt.x; cz = cz + pt.z + local tname = u:getTypeName() or '' + byType[tname] = (byType[tname] or 0) + 1 + end + cx = cx / #units; cz = cz / #units + local d = dist2(here2, { x = cx, z = cz }) + if d <= searchR then + -- Check presence of base units (at least 1 each) + local ok = true + for _,u in ipairs(tpl.baseUnits) do if (byType[u.type] or 0) < 1 then ok = false; break end end + -- Require at least 1 launcher or allow 0 (initial repair to full base)? we'll allow 0 too. + if ok then + if d < bestD then + bestG, bestD = g, d + bestInfo = { byType = byType, center = { x = cx, z = cz }, headingDeg = function() + local h = 0; local leader = units[1]; if leader and leader.isExist and leader:isExist() then h = math.deg(leader:getHeading() or 0) end; return h + end } + end + end + end + end + end + end + + if not bestG then + _msgGroup(group, 'No matching SAM site found nearby to repair/augment.') + return + end + + -- Current launchers in site + local curLaunchers = (bestInfo.byType[tpl.launcherType] or 0) + local maxL = tpl.maxLaunchers or (curLaunchers + cratesAvail) + local canAdd = math.max(0, (maxL - curLaunchers)) + if canAdd <= 0 then + _msgGroup(group, 'SAM site is already at max launchers.') + return + end + local addNum = math.min(cratesAvail, canAdd) + + -- Build new group composition: base units + (curLaunchers + addNum) launchers + local function buildSite(point, headingDeg, side, launcherCount) + local hdg = math.rad(headingDeg or 0) + local function off(dx, dz) + -- rotate offsets by heading + local s, c = math.sin(hdg), math.cos(hdg) + local rx = dx * c + dz * s + local rz = -dx * s + dz * c + return { x = point.x + rx, z = point.z + rz } + end + local units = {} + -- Place launchers in a row starting at launcherStart and stepping by launcherStep + for i=0, (launcherCount-1) do + local dx = (tpl.launcherStart.dx or 0) + (tpl.launcherStep.dx or 0) * i + local dz = (tpl.launcherStart.dz or 0) + (tpl.launcherStep.dz or 0) * i + local p = off(dx, dz) + table.insert(units, { type = tpl.launcherType, name = string.format('CTLD-%s-%d', tpl.launcherType, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg }) + end + -- Place base units at their template offsets + for _,u in ipairs(tpl.baseUnits) do + local p = off(u.dx or 0, u.dz or 0) + table.insert(units, { type = u.type, name = string.format('CTLD-%s-%d', u.type, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg }) + end + return { visible=false, lateActivation=false, tasks={}, task='Ground Nothing', route={}, units=units, name=string.format('CTLD_SITE_%d', math.random(100000,999999)) } + end + + _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) + -- Destroy old group, spawn new one + local oldName = bestG:getName() + local newLauncherCount = curLaunchers + addNum + local center = bestInfo.center + local headingDeg = bestInfo.headingDeg() + if Group.getByName(oldName) then pcall(function() Group.getByName(oldName):destroy() end) end + local gdata = buildSite({ x = center.x, z = center.z }, headingDeg, tpl.side, newLauncherCount) + local newG = _coalitionAddGroup(tpl.side, Group.Category.GROUND, gdata, self.Config) + if not newG then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end + -- Consume used repair crates + consumeCrates(recipeKey, addNum) + _eventSend(self, nil, self.Side, 'build_success_coalition', { build = (def.description or recipeKey), player = _playerNameFromGroup(group) }) + if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end + return + end + + -- Verify counts and build (supports multi-build when cooldown is zero) + if type(def.requires) == 'table' then + local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 + local maxCopies = 1 + if cd <= 0 then + maxCopies = math.huge + for reqKey,qty in pairs(def.requires) do + if (qty or 0) > 0 then + local available = counts[reqKey] or 0 + local copiesForKey = math.floor(available / (qty or 1)) + if copiesForKey < maxCopies then maxCopies = copiesForKey end + end + end + if maxCopies < 1 or maxCopies == math.huge then + _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) + return + end + else + for reqKey,qty in pairs(def.requires) do + if (counts[reqKey] or 0) < (qty or 0) then + _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) + return + end + end + maxCopies = 1 + end + + local built = 0 + while built < maxCopies do + local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, hdgDeg, def.side or self.Side) + _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) + local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) + if not g then + _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) + break + end + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC pre: post-build (composite) key=%s group=%s', tostring(recipeKey), tostring(g:getName()))) + end + self:_maybeRegisterJTAC(recipeKey, def, g) + for reqKey,qty in pairs(def.requires) do + consumeCrates(reqKey, qty or 0) + counts[reqKey] = (counts[reqKey] or 0) - (qty or 0) + end + _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) + if def.isFOB then pcall(function() self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, def, hdg) end) end + if def.isMobileMASH then + _logDebug(string.format('[MobileMASH] BuildSpecificAtGroup invoking _CreateMobileMASH for key %s at (%.1f, %.1f)', tostring(recipeKey), spawnAt.x or -1, spawnAt.z or -1)) + local ok, err = pcall(function() self:_CreateMobileMASH(g, { x = spawnAt.x, z = spawnAt.z }, def) end) + if not ok then + _logError(string.format('[MobileMASH] _CreateMobileMASH invocation failed: %s', tostring(err))) + end + end + -- behavior (applied for each built group) + local behavior = opts and opts.behavior or nil + if behavior == 'attack' and (def.canAttackMove ~= false) and self.Config.AttackAI and self.Config.AttackAI.Enabled then + local t = self:_assignAttackBehavior(g:getName(), spawnAt, true) + local isMetric = _getPlayerIsMetric(group:GetUnit(1)) + if t and t.kind == 'base' then + local brg = _bearingDeg(spawnAt, t.point) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) + elseif t and t.kind == 'enemy' then + local brg = _bearingDeg(spawnAt, t.point) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) + else + local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) + _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) + end + elseif behavior == 'attack' and def.canAttackMove == false then + MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) + end + + built = built + 1 + if cd > 0 then break end + end + + if self.Config.BuildCooldownEnabled and cd > 0 then CTLD._buildCooldown[gname] = now end + return + else + -- single-key + local need = def.required or 1 + local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 + local maxCopies = 1 + if cd <= 0 then + local available = counts[recipeKey] or 0 + maxCopies = math.floor(available / need) + if maxCopies < 1 then + _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) + return + end + else + if (counts[recipeKey] or 0) < need then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end + maxCopies = 1 + end + + local built = 0 + while built < maxCopies do + local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, hdgDeg, def.side or self.Side) + _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) + local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) + if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); break end + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC pre: post-build (single) key=%s group=%s', tostring(recipeKey), tostring(g:getName()))) + end + self:_maybeRegisterJTAC(recipeKey, def, g) + consumeCrates(recipeKey, need) + counts[recipeKey] = (counts[recipeKey] or 0) - need + _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) + -- behavior + local behavior = opts and opts.behavior or nil + if behavior == 'attack' and (def.canAttackMove ~= false) and self.Config.AttackAI and self.Config.AttackAI.Enabled then + local t = self:_assignAttackBehavior(g:getName(), spawnAt, true) + local isMetric = _getPlayerIsMetric(group:GetUnit(1)) + if t and t.kind == 'base' then + local brg = _bearingDeg(spawnAt, t.point) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) + elseif t and t.kind == 'enemy' then + local brg = _bearingDeg(spawnAt, t.point) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) + else + local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) + _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) + end + elseif behavior == 'attack' and def.canAttackMove == false then + MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) + end + + built = built + 1 + if cd > 0 then break end + end + + if self.Config.BuildCooldownEnabled and cd > 0 then CTLD._buildCooldown[gname] = now end + return + end +end + +function CTLD:_definitionIsJTAC(def) + if not def then return false end + if def.isJTAC == true then return true end + if type(def.jtac) == 'table' and def.jtac.enabled ~= false then return true end + if type(def.roles) == 'table' then + for _, role in ipairs(def.roles) do + if tostring(role):upper() == 'JTAC' then + return true + end + end + end + return false +end + +function CTLD:_maybeRegisterJTAC(recipeKey, def, dcsGroup) + if not (self.Config.JTAC and self.Config.JTAC.Enabled) then + if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo('JTAC check: JTAC disabled in config; skipping registration') + end + return + end + if not self:_definitionIsJTAC(def) then + if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then + local hasRoles = (def and type(def.roles) == 'table') and table.concat((function(r) local t={} for i,v in ipairs(r) do t[i]=tostring(v) end return t end)(def.roles),'|') or '(none)' + local hasJTAC = (def and type(def.jtac) == 'table') and 'yes' or 'no' + _logInfo(string.format('JTAC check: definition not JTAC. key=%s jtacTable=%s roles=%s isJTAC=%s', tostring(recipeKey), hasJTAC, hasRoles, tostring(def and def.isJTAC))) + end + return + end + if not dcsGroup then + if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC check: no DCS group to register. key=%s', tostring(recipeKey))) + end + return + end + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC check: attempting registration. key=%s unitType=%s group=%s', tostring(recipeKey), tostring(def and def.unitType or def and def.description or 'n/a'), tostring(dcsGroup and dcsGroup.getName and dcsGroup:getName() or ''))) + end + self:_registerJTACGroup(recipeKey, def, dcsGroup) +end + +function CTLD:_reserveJTACCode(side, groupName) + local pool = self.Config.JTAC and self.Config.JTAC.LaserCodes or { '1688' } + if not CTLD._jtacReservedCodes then + CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } + end + CTLD._jtacReservedCodes[side] = CTLD._jtacReservedCodes[side] or {} + for _, code in ipairs(pool) do + code = tostring(code) + if not CTLD._jtacReservedCodes[side][code] then + CTLD._jtacReservedCodes[side][code] = groupName + return code + end + end + local fallback = tostring(pool[1] or '1688') + _logVerbose(string.format('JTAC laser code pool exhausted for side %s, reusing %s', tostring(side), fallback)) + return fallback +end + +function CTLD:_releaseJTACCode(side, code, groupName) + if not code then return end + code = tostring(code) + if CTLD._jtacReservedCodes and CTLD._jtacReservedCodes[side] then + if CTLD._jtacReservedCodes[side][code] == groupName then + CTLD._jtacReservedCodes[side][code] = nil + end + end +end + +function CTLD:_registerJTACGroup(recipeKey, def, dcsGroup) + if not (dcsGroup and dcsGroup.getName) then return end + local groupName = dcsGroup:getName() + if not groupName then return end + + self:_cleanupJTACEntry(groupName) -- ensure stale entry cleared + + local side = dcsGroup:getCoalition() or self.Side + local code = self:_reserveJTACCode(side, groupName) + local platform = 'ground' + if def and def.jtac and def.jtac.platform then + platform = tostring(def.jtac.platform) + elseif def and def.category == Group.Category.AIRPLANE then + platform = 'air' + end + local cfgSmoke = self.Config.JTAC and self.Config.JTAC.Smoke or {} + local smokeColor = (side == coalition.side.BLUE) and cfgSmoke.ColorBlue or cfgSmoke.ColorRed + + local entry = { + groupName = groupName, + recipeKey = recipeKey, + def = def, + side = side, + code = code, + platform = platform, + smokeColor = smokeColor, + nextScan = timer.getTime() + 2, + smokeNext = 0, + lockType = def and def.jtac and def.jtac.lock, + } + + local friendlyName = (def and self:_friendlyNameForKey(recipeKey)) or groupName + entry.displayName = friendlyName + entry.lastState = 'onstation' + + self._jtacRegistry[groupName] = entry + + self:_announceJTAC('jtac_onstation', entry, { + jtac = friendlyName, + code = code, + }) + + _logInfo(string.format('JTAC registered: group=%s friendlyName=%s code=%s platform=%s verbose=%s', tostring(groupName), tostring(friendlyName), tostring(code), tostring(platform), tostring(self.Config.JTAC and self.Config.JTAC.Verbose))) +end + +function CTLD:_announceJTAC(msgKey, entry, payload) + if not entry then return end + local cfg = self.Config.JTAC and self.Config.JTAC.Announcements + local allowed = true + if entry and entry.announceOverride ~= nil then + allowed = entry.announceOverride == true + else + allowed = (cfg and cfg.Enabled ~= false) + end + if not allowed then return end + local tpl = CTLD.Messages[msgKey] + if not tpl then return end + local data = payload or {} + data.jtac = data.jtac or entry.displayName or entry.groupName + data.code = data.code or entry.code + local text = _fmtTemplate(tpl, data) + if text and text ~= '' then + _msgCoalition(entry.side or self.Side, text, cfg.Duration or self.Config.MessageDuration) + end +end + +function CTLD:_cleanupJTACEntry(groupName) + local entry = self._jtacRegistry and self._jtacRegistry[groupName] + if not entry then return end + self:_cancelJTACSpots(entry) + self:_releaseJTACCode(entry.side or self.Side, entry.code, groupName) + self._jtacRegistry[groupName] = nil +end + +function CTLD:_cancelJTACSpots(entry) + if not entry then return end + if entry.laserSpot then + pcall(function() Spot.destroy(entry.laserSpot) end) + entry.laserSpot = nil + end + if entry.irSpot then + pcall(function() Spot.destroy(entry.irSpot) end) + entry.irSpot = nil + end +end + +function CTLD:_tickJTACs() + if not self._jtacRegistry then return end + if not next(self._jtacRegistry) then return end + local now = timer.getTime() + for groupName, entry in pairs(self._jtacRegistry) do + if not entry.nextScan or now >= entry.nextScan then + local ok, err = pcall(function() + self:_processJTACEntry(groupName, entry, now) + end) + if not ok then + _logError(string.format('JTAC tick error for %s: %s', tostring(groupName), tostring(err))) + entry.nextScan = now + 10 + end + end + end +end + +function CTLD:_processJTACEntry(groupName, entry, now) + local cfg = self.Config.JTAC or {} + local autoCfg = cfg.AutoLase or {} + if autoCfg.Enabled == false then + self:_cancelJTACSpots(entry) + entry.nextScan = now + 30 + return + end + if entry.paused then + self:_cancelJTACSpots(entry) + entry.nextScan = now + 30 + entry.lastState = 'paused' + return + end + local group = Group.getByName(groupName) + if not group or not group:isExist() then + self:_cleanupJTACEntry(groupName) + return + end + + local units = group:getUnits() or {} + if #units == 0 then + self:_cancelJTACSpots(entry) + entry.nextScan = now + (autoCfg.TransportHoldSeconds or 10) + return + end + + local jtacUnit = units[1] + if not jtacUnit or jtacUnit:getLife() <= 0 or not jtacUnit:isActive() then + self:_cleanupJTACEntry(groupName) + return + end + + entry.jtacUnitName = entry.jtacUnitName or jtacUnit:getName() + entry.displayName = entry.displayName or entry.jtacUnitName or groupName + + local jtacPoint = jtacUnit:getPoint() + local searchRadius = tonumber(entry.searchRadiusOverride or autoCfg.SearchRadius) or 8000 + if cfg.Verbose then + _logInfo(string.format('JTAC tick: group=%s unit=%s radius=%.0f pos=(%.0f,%.0f,%.0f)', tostring(groupName), tostring(entry.jtacUnitName or jtacUnit:getName()), searchRadius, jtacPoint.x or -1, jtacPoint.y or -1, jtacPoint.z or -1)) + end + + local current = entry.currentTarget + local targetUnit = nil + local targetStatus = nil + + if current and current.name then + local candidate = Unit.getByName(current.name) + if candidate and candidate:isExist() and candidate:getLife() > 0 then + local tgtPoint = candidate:getPoint() + local dist = _distance3d(tgtPoint, jtacPoint) + if dist <= searchRadius and _hasLineOfSight(jtacPoint, tgtPoint) then + targetUnit = candidate + current.lastSeen = now + current.distance = dist + else + targetStatus = 'lost' + end + else + targetStatus = 'destroyed' + end + if targetStatus then + if targetStatus == 'destroyed' then + if entry.lastState ~= 'destroyed' then + self:_announceJTAC('jtac_target_destroyed', entry, { + jtac = entry.displayName, + target = current.label or current.name, + code = entry.code, + }) + entry.lastState = 'destroyed' + end + else + if entry.lastState ~= 'lost' then + self:_announceJTAC('jtac_target_lost', entry, { + jtac = entry.displayName, + target = current.label or current.name, + }) + entry.lastState = 'lost' + end + end + entry.currentTarget = nil + targetUnit = nil + self:_cancelJTACSpots(entry) + entry.nextScan = now + (targetStatus == 'lost' and (autoCfg.LostRetrySeconds or 10) or 5) + end + end + + if not targetUnit then + local lockPref = entry.lockType or cfg.LockType or 'all' + local selection = self:_findJTACNewTarget(entry, jtacPoint, searchRadius, lockPref) + if cfg.Verbose then + _logInfo(string.format('JTAC scan: group=%s lock=%s found=%s', tostring(groupName), tostring(lockPref), selection and (selection.unit and selection.unit:getTypeName()) or 'nil')) + end + if selection then + targetUnit = selection.unit + entry.currentTarget = { + name = targetUnit:getName(), + label = targetUnit:getTypeName(), + firstSeen = now, + lastSeen = now, + distance = selection.distance, + } + local grid = self:_GetMGRSString(targetUnit:getPoint()) + local newState = 'target:'..(entry.currentTarget.name or '') + if entry.lastState ~= newState then + self:_announceJTAC('jtac_new_target', entry, { + jtac = entry.displayName, + target = targetUnit:getTypeName(), + code = entry.code, + grid = grid, + }) + entry.lastState = newState + end + end + end + + if targetUnit then + self:_updateJTACSpots(entry, jtacUnit, targetUnit) + entry.nextScan = now + (autoCfg.RefreshSeconds or 15) + if cfg.Verbose then + _logInfo(string.format('JTAC lase: group=%s target=%s code=%s', tostring(groupName), tostring(targetUnit and targetUnit:getTypeName()), tostring(entry.code))) + end + else + self:_cancelJTACSpots(entry) + entry.nextScan = now + (autoCfg.IdleRescanSeconds or 30) + if entry.lastState ~= 'idle' then + self:_announceJTAC('jtac_idle', entry, { + jtac = entry.displayName, + }) + entry.lastState = 'idle' + end + end +end + +function CTLD:ListJTACStatus(group) + local lines = {} + table.insert(lines, 'JTAC Status') + table.insert(lines, '') + if not self._jtacRegistry or not next(self._jtacRegistry) then + table.insert(lines, '(none registered)') + else + local now = timer.getTime() + for gname, entry in pairs(self._jtacRegistry) do + local tgt = entry.currentTarget and entry.currentTarget.label or '(idle)' + local age = entry.currentTarget and (now - (entry.currentTarget.firstSeen or now)) or 0 + local nextScan = entry.nextScan and (entry.nextScan - now) or -1 + table.insert(lines, string.format('- %s code=%s plat=%s state=%s target=%s age=%.0fs nextScan=%.0fs', + entry.displayName or gname, tostring(entry.code), tostring(entry.platform), tostring(entry.lastState), tgt, age, nextScan)) + end + end + local text = table.concat(lines, '\n') + if group and group:IsAlive() then + MESSAGE:New(text, 20):ToGroup(group) + else + _msgCoalition(self.Side, text, 20) + end +end + +function CTLD:JTACDiagnostics(group) + local lines = {} + table.insert(lines, 'JTAC Diagnostics') + local cfg = self.Config.JTAC or {} + table.insert(lines, string.format('Enabled=%s Verbose=%s LockType=%s', tostring(cfg.Enabled), tostring(cfg.Verbose), tostring(cfg.LockType))) + local auto = cfg.AutoLase or {} + table.insert(lines, string.format('AutoLase Enabled=%s Radius=%s Refresh=%s IdleRescan=%s LostRetry=%s', tostring(auto.Enabled), tostring(auto.SearchRadius), tostring(auto.RefreshSeconds), tostring(auto.IdleRescanSeconds), tostring(auto.LostRetrySeconds))) + local countCatalog = 0 + local jtacKeys = {} + for key,def in pairs(self.Config.CrateCatalog or {}) do + if self:_definitionIsJTAC(def) then + countCatalog = countCatalog + 1 + table.insert(jtacKeys, key) + end + end + table.insert(lines, string.format('Catalog JTAC Definitions: %d', countCatalog)) + if #jtacKeys > 0 then + table.insert(lines, 'Keys: '..table.concat(jtacKeys, ', ')) + end + local regCount = 0 + for _ in pairs(self._jtacRegistry or {}) do regCount = regCount + 1 end + table.insert(lines, string.format('Registered JTAC Groups: %d', regCount)) + if regCount > 0 then + for gname, entry in pairs(self._jtacRegistry) do + table.insert(lines, string.format(' Reg: %s code=%s state=%s', gname, tostring(entry.code), tostring(entry.lastState))) + end + end + local text = table.concat(lines, '\n') + if group and group:IsAlive() then + MESSAGE:New(text, 25):ToGroup(group) + else + _msgCoalition(self.Side, text, 25) + end +end + +-- ========================= +-- JTAC Controls (per-group active selection) +-- ========================= + +function CTLD:_getActiveJTAC(group) + local gname = group and group:GetName() + if not gname then return nil end + CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} + local key = CTLD._activeJTACByGroup[gname] + if key and self._jtacRegistry and self._jtacRegistry[key] then + return self._jtacRegistry[key] + end + return nil +end + +local function _unitVec2(unit) + local p = unit:GetPointVec3(); return { x = p.x, z = p.z } +end + +function CTLD:JTAC_SelectActiveForGroup(group, opts) + local entries = {} + for name, entry in pairs(self._jtacRegistry or {}) do table.insert(entries, entry) end + if #entries == 0 then MESSAGE:New('No JTACs registered yet.', 8):ToGroup(group); return end + -- choose nearest to player unit + table.sort(entries, function(a,b) + local u = group:GetUnit(1); if not u then return false end + local up = _unitVec2(group:GetUnit(1)) + local function d(e) + local g = Group.getByName(e.groupName); if not g then return 1e12 end + local gu = g:getUnits(); if not gu or #gu==0 then return 1e12 end + local p = gu[1]:getPoint(); local dx = (p.x - up.x); local dz=(p.z - up.z); return math.sqrt(dx*dx+dz*dz) + end + return d(a) < d(b) + end) + local chosen = entries[1] + CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} + CTLD._activeJTACByGroup[group:GetName()] = chosen.groupName + MESSAGE:New(string.format('Active JTAC set to %s (code %s).', chosen.displayName or chosen.groupName, tostring(chosen.code)), 10):ToGroup(group) +end + +function CTLD:JTAC_TogglePause(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + e.paused = not e.paused + local msg = e.paused and 'paused' or 'resumed' + MESSAGE:New(string.format('JTAC %s %s.', e.displayName or e.groupName, msg), 8):ToGroup(group) +end + +function CTLD:JTAC_ReleaseTarget(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + self:_cancelJTACSpots(e) + e.currentTarget = nil + e.nextScan = timer.getTime() + 1 + e.lastState = 'released' + MESSAGE:New('JTAC target released.', 6):ToGroup(group) +end + +function CTLD:JTAC_ForceRescan(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + e.currentTarget = nil + e.nextScan = timer.getTime() + 0.5 + e.lastState = 'rescan' + MESSAGE:New('JTAC rescan queued.', 6):ToGroup(group) +end + +function CTLD:JTAC_SetLockFilter(group, mode) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + e.lockType = (mode or 'all') + e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 + MESSAGE:New(string.format('JTAC lock filter set to %s.', mode), 6):ToGroup(group) +end + +function CTLD:JTAC_SetPriority(group, profile) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + e.priorityProfile = profile or 'balanced' + e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 + MESSAGE:New(string.format('JTAC priority set: %s', profile), 6):ToGroup(group) +end + +function CTLD:JTAC_SetSearchRadius(group, meters) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + e.searchRadiusOverride = tonumber(meters) + e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 + MESSAGE:New(string.format('JTAC search radius set to %dm.', meters or 0), 6):ToGroup(group) +end + +function CTLD:JTAC_ToggleSmoke(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + if e.smokeEnabledOverride == nil then + e.smokeEnabledOverride = not ((self.Config.JTAC and self.Config.JTAC.Smoke and self.Config.JTAC.Smoke.Enabled) ~= false) + else + e.smokeEnabledOverride = not e.smokeEnabledOverride + end + local state = e.smokeEnabledOverride and 'ON' or 'OFF' + MESSAGE:New('JTAC smoke '..state..'.', 6):ToGroup(group) +end + +function CTLD:JTAC_SetSmokeColor(group, which) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + if which == 'blue' then + e.smokeColor = trigger.smokeColor.Blue + elseif which == 'orange' then + e.smokeColor = trigger.smokeColor.Orange + end + MESSAGE:New('JTAC smoke color set.', 6):ToGroup(group) +end + +function CTLD:JTAC_ToggleAnnouncements(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + if e.announceOverride == nil then + local cfg = self.Config.JTAC and self.Config.JTAC.Announcements + e.announceOverride = not (cfg and cfg.Enabled ~= false) + else + e.announceOverride = not e.announceOverride + end + MESSAGE:New('JTAC announcements '..(e.announceOverride and 'ON' or 'OFF')..'.', 6):ToGroup(group) +end + +function CTLD:JTAC_MarkCurrentTarget(group) + local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end + if not e.currentTarget or not e.currentTarget.name then MESSAGE:New('No current target to mark.', 6):ToGroup(group); return end + local u = Unit.getByName(e.currentTarget.name); if not u or not u:isExist() then MESSAGE:New('Target no longer valid.', 6):ToGroup(group); return end + local p = u:getPoint() + CTLD._markId = (CTLD._markId or 900000) + 1 + local text = string.format('JTAC %s target: %s (code %s)', e.displayName or e.groupName, e.currentTarget.label or e.currentTarget.name, tostring(e.code)) + local side = (group and group.GetCoalition and group:GetCoalition()) or e.side or coalition.side.BLUE + pcall(function() trigger.action.markToCoalition(CTLD._markId, text, {x=p.x, y=p.y, z=p.z}, side) end) + MESSAGE:New('Marked current target on map.', 6):ToGroup(group) +end + +function CTLD:_findJTACNewTarget(entry, jtacPoint, radius, lockType) + local enemy = _enemySide(entry and entry.side or self.Side) + local best + local lock = (lockType or 'all'):lower() + local profile = entry and entry.priorityProfile or 'balanced' + local ok, groups = pcall(function() + return coalition.getGroups(enemy, Group.Category.GROUND) or {} + end) + if not ok then + groups = {} + end + + for _, grp in ipairs(groups) do + if grp and grp:isExist() then + local units = grp:getUnits() + if units then + for _, unit in ipairs(units) do + if unit and unit:isExist() and unit:isActive() and unit:getLife() > 0 then + local skip = false + if lock == 'troop' and not _isDcsInfantry(unit) then skip = true end + if lock == 'vehicle' and _isDcsInfantry(unit) then skip = true end + if not skip then + local pos = unit:getPoint() + local dist = _distance3d(pos, jtacPoint) + if dist <= radius and _hasLineOfSight(jtacPoint, pos) then + local score = _jtacTargetScoreProfiled(unit, profile) + if not best or score > best.score or (score == best.score and dist < best.distance) then + best = { unit = unit, score = score, distance = dist } + end + end + end + end + end + end + end + end + + return best +end + +function CTLD:_updateJTACSpots(entry, jtacUnit, targetUnit) + if not (entry and jtacUnit and targetUnit) then return end + local codeNumber = tonumber(entry.code) or 1688 + local targetPoint = targetUnit:getPoint() + targetPoint = _vec3(targetPoint.x, targetPoint.y + 2.0, targetPoint.z) + local origin = { x = 0, y = 2.0, z = 0 } + + if not entry.laserSpot or not entry.irSpot then + local ok, res = pcall(function() + local spots = {} + spots.ir = Spot.createInfraRed(jtacUnit, origin, targetPoint) + spots.laser = Spot.createLaser(jtacUnit, origin, targetPoint, codeNumber) + return spots + end) + if ok and res then + entry.irSpot = entry.irSpot or res.ir + entry.laserSpot = entry.laserSpot or res.laser + else + _logError(string.format('JTAC spot create failed for %s: %s', tostring(entry.groupName), tostring(res))) + end + else + pcall(function() + if entry.laserSpot and entry.laserSpot.setPoint then entry.laserSpot:setPoint(targetPoint) end + if entry.irSpot and entry.irSpot.setPoint then entry.irSpot:setPoint(targetPoint) end + end) + end + + local smokeCfg = self.Config.JTAC and self.Config.JTAC.Smoke or {} + local smokeAllowed = (entry.smokeEnabledOverride ~= nil) and (entry.smokeEnabledOverride == true) or (entry.smokeEnabledOverride == nil and smokeCfg.Enabled) + if smokeAllowed then + local now = timer.getTime() + if not entry.smokeNext or now >= entry.smokeNext then + local color = entry.smokeColor or smokeCfg.ColorBlue or trigger.smokeColor.White + local pos = targetUnit:getPoint() + local offset = tonumber(smokeCfg.OffsetMeters) or 0 + if offset > 0 then + local ang = math.random() * math.pi * 2 + pos.x = pos.x + math.cos(ang) * offset + pos.z = pos.z + math.sin(ang) * offset + end + pcall(function() + trigger.action.smoke({ x = pos.x, y = pos.y, z = pos.z }, color) + end) + entry.smokeNext = now + (smokeCfg.RefreshSeconds or 300) + end + end +end + +function CTLD:BuildCoalitionMenus(root) + -- Optional: implement coalition-level crate spawns at pickup zones + for key,_ in pairs(self.Config.CrateCatalog) do + MENU_COALITION_COMMAND:New(self.Side, 'Spawn '..key..' at nearest Pickup Zone', root, function() + -- Not group-context; skip here + _msgCoalition(self.Side, 'Group menus recommended for crate requests') + end) + end +end + +function CTLD:InitCoalitionAdminMenu() + if self.AdminMenu then return end + -- Ensure we have a coalition-level CTLD parent menu to nest Admin/Help under + local rootCaption = (self.Config and self.Config.UseGroupMenus) and 'CTLD Admin' or 'CTLD' + self.MenuRoot = self.MenuRoot or MENU_COALITION:New(self.Side, rootCaption) + local root = MENU_COALITION:New(self.Side, 'Admin/Help', self.MenuRoot) + -- Player Help submenu (moved to top of Admin/Help) + local helpMenu = MENU_COALITION:New(self.Side, 'Player Help', root) + -- Removed standalone "Repair - How To" in favor of consolidated SAM Sites help + MENU_COALITION_COMMAND:New(self.Side, 'Zones - Guide', helpMenu, function() + local lines = {} + table.insert(lines, 'CTLD Zones - Guide') + table.insert(lines, '') + table.insert(lines, 'Zone types:') + table.insert(lines, '- Pickup (Supply): Request crates and load troops here. Crate requests require proximity to an ACTIVE pickup zone (default within 10 km).') + table.insert(lines, '- Drop: Mission-defined delivery or rally areas. Some missions may require delivery or deployment at these zones (see briefing).') + table.insert(lines, '- FOB: Forward Operating Base areas. Some recipes (FOB Site) can be built here; if FOB restriction is enabled, FOB-only builds must be inside an FOB zone.') + table.insert(lines, '') + table.insert(lines, 'Colors and map marks:') + table.insert(lines, '- Pickup zone crate spawns are marked with smoke in the configured color. Admin/Help -> Draw CTLD Zones on Map draws zone circles and labels on F10.') + table.insert(lines, '- Use Admin/Help -> Clear CTLD Map Drawings to remove the drawings. Drawings are read-only if configured.') + table.insert(lines, '') + table.insert(lines, 'How to use zones:') + table.insert(lines, '- To request crates: move within the pickup zone distance and use CTLD -> Request Crate.') + table.insert(lines, '- To load troops: must be inside a Pickup zone if troop loading restriction is enabled.') + table.insert(lines, '- Navigation: CTLD -> Coach & Nav -> Vectors to Nearest Pickup Zone gives bearing and range.') + table.insert(lines, '- Activation: Zones can be active/inactive per mission logic; inactive pickup zones block crate requests.') + table.insert(lines, '') + table.insert(lines, string.format('- Build Radius: about %d m to collect nearby crates when building.', self.Config.BuildRadius or 100)) + table.insert(lines, string.format('- Pickup Zone Max Distance: about %d m to request crates (configurable).', self.Config.PickupZoneMaxDistance or 10000)) + _msgCoalition(self.Side, table.concat(lines, '\n'), 40) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Inventory - How It Works', helpMenu, function() + local inv = self.Config.Inventory or {} + local enabled = inv.Enabled ~= false + local showHint = inv.ShowStockInMenu == true + local fobPct = math.floor(((inv.FOBStockFactor or 0.25) * 100) + 0.5) + local lines = {} + table.insert(lines, 'CTLD Inventory - How It Works') + table.insert(lines, '') + table.insert(lines, 'Overview:') + table.insert(lines, '- Inventory is tracked per Supply (Pickup) Zone and per FOB. Requests consume stock at that location.') + table.insert(lines, string.format('- Inventory is %s.', enabled and 'ENABLED' or 'DISABLED')) + table.insert(lines, '') + table.insert(lines, 'Starting stock:') + table.insert(lines, '- Each configured Supply Zone is seeded from the catalog initialStock for every crate type at mission start.') + table.insert(lines, string.format('- When you build a FOB, it creates a small Supply Zone with stock seeded at ~%d%% of initialStock.', fobPct)) + table.insert(lines, '') + table.insert(lines, 'Requesting crates:') + table.insert(lines, '- You must be within range of an ACTIVE Supply Zone to request crates; stock is decremented on spawn.') + table.insert(lines, '- If out of stock for a type at that zone, requests are denied for that type until resupplied (mission logic).') + table.insert(lines, '') + table.insert(lines, 'UI hints:') + table.insert(lines, string.format('- Show stock in menu labels: %s.', showHint and 'ON' or 'OFF')) + table.insert(lines, '- Some missions may include an "In Stock Here" list showing only items available at the nearest zone.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 40) + end) + MENU_COALITION_COMMAND:New(self.Side, 'CTLD Basics (2-minute tour)', helpMenu, function() + local isMetric = true + local lines = {} + table.insert(lines, 'CTLD Basics - 2 minute tour') + table.insert(lines, '') + table.insert(lines, 'Loop: Request -> Deliver -> Build -> Fight') + table.insert(lines, '- Request crates at an ACTIVE Supply Zone (Pickup).') + table.insert(lines, '- Deliver crates to the build point (within Build Radius).') + table.insert(lines, '- Build units or sites with "Build Here" (confirm + cooldown).') + table.insert(lines, '- Optional: set Attack or Defend behavior when building.') + table.insert(lines, '') + table.insert(lines, 'Key concepts:') + table.insert(lines, '- Zones: Pickup (supply), Drop (mission targets), FOB (forward supply).') + table.insert(lines, '- Inventory: stock is tracked per zone; requests consume stock there.') + table.insert(lines, '- FOBs: building one creates a local supply point with seeded stock.') + table.insert(lines, '- Advanced: SAM site repair crates, AI attack orders, EWR/JTAC support.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 35) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Troop Transport & JTAC Use', helpMenu, function() + local lines = {} + table.insert(lines, 'Troop Transport & JTAC Use') + table.insert(lines, '') + table.insert(lines, 'Troops:') + table.insert(lines, '- Load inside an ACTIVE Supply Zone (if mission enforces it).') + table.insert(lines, '- Deploy with Defend (hold) or Attack (advance to targets/bases).') + table.insert(lines, '- Attack uses a search radius and moves at configured speed.') + table.insert(lines, '') + table.insert(lines, 'JTAC:') + table.insert(lines, '- Build JTAC units (MRAP/Tigr or drones) to support target marking.') + table.insert(lines, '- JTAC helps with target designation/SA; details depend on mission setup.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 35) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Crates 101: Requesting and Handling', helpMenu, function() + local lines = {} + table.insert(lines, 'Crates 101 - Requesting and Handling') + table.insert(lines, '') + table.insert(lines, '- Request crates near an ACTIVE Supply Zone; max distance is configurable.') + table.insert(lines, '- Menu labels show the total crates required for a recipe.') + table.insert(lines, '- Drop crates close together but avoid overlap; smoke marks spawns.') + table.insert(lines, '- Use Coach & Nav tools: vectors to nearest pickup zone, re-mark crate with smoke.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 35) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Hover Pickup & Slingloading', helpMenu, function() + local coachCfg = CTLD.HoverCoachConfig or {} + local aglMin = (coachCfg.thresholds and coachCfg.thresholds.aglMin) or 5 + local aglMax = (coachCfg.thresholds and coachCfg.thresholds.aglMax) or 20 + local capGS = (coachCfg.thresholds and coachCfg.thresholds.captureGS) or (4/3.6) + local hold = (coachCfg.thresholds and coachCfg.thresholds.stabilityHold) or 1.8 + local lines = {} + table.insert(lines, 'Hover Pickup & Slingloading') + table.insert(lines, '') + table.insert(lines, string.format('- Hover pickup: hold AGL %d-%d m, speed < %.1f m/s, for ~%.1f s to auto-load.', aglMin, aglMax, capGS, hold)) + table.insert(lines, '- Keep steady within ~15 m of the crate; Hover Coach gives cues if enabled.') + table.insert(lines, '- Slingloading tips: avoid rotor wash over stacks; approach from upwind; re-mark crate with smoke if needed.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 35) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Build System: Build Here and Advanced', helpMenu, function() + local br = self.Config.BuildRadius or 100 + local win = self.Config.BuildConfirmWindowSeconds or 10 + local cd = self.Config.BuildCooldownSeconds or 60 + local lines = {} + table.insert(lines, 'Build System - Build Here and Advanced') + table.insert(lines, '') + table.insert(lines, string.format('- Build Here collects crates within ~%d m. Double-press within %d s to confirm.', br, win)) + table.insert(lines, string.format('- Cooldown: about %d s per group after a successful build.', cd)) + table.insert(lines, '- Advanced Build lets you choose Defend (hold) or Attack (move).') + table.insert(lines, '- Static or unsuitable units will hold even if Attack is chosen.') + table.insert(lines, '- FOB-only recipes must be inside an FOB zone when restriction is enabled.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 40) + end) + MENU_COALITION_COMMAND:New(self.Side, 'FOBs: Forward Supply & Why They Matter', helpMenu, function() + local fobPct = math.floor(((self.Config.Inventory and self.Config.Inventory.FOBStockFactor or 0.25) * 100) + 0.5) + local lines = {} + table.insert(lines, 'FOBs - Forward Supply and Why They Matter') + table.insert(lines, '') + table.insert(lines, '- Build a FOB by assembling its crate recipe (see Recipe Info).') + table.insert(lines, string.format('- A new local Supply Zone is created and seeded at ~%d%% of initial stock.', fobPct)) + table.insert(lines, '- FOBs shorten logistics legs and increase throughput toward the front.') + table.insert(lines, '- If enabled, FOB-only builds must occur inside FOB zones.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 35) + end) + MENU_COALITION_COMMAND:New(self.Side, 'SAM Sites: Building, Repairing, and Augmenting', helpMenu, function() + local br = self.Config.BuildRadius or 100 + local lines = {} + table.insert(lines, 'SAM Sites - Building, Repairing, and Augmenting') + table.insert(lines, '') + table.insert(lines, 'Build:') + table.insert(lines, '- Assemble site recipes using the required component crates (see menu labels). Build Here will place the full site.') + table.insert(lines, '') + table.insert(lines, 'Repair/Augment (merged):') + table.insert(lines, '- Request the matching "Repair/Launcher +1" crate for your site type (HAWK, Patriot, KUB, BUK).') + table.insert(lines, string.format('- Drop repair crate(s) within ~%d m of the site, then use Build Here (confirm window applies).', br)) + table.insert(lines, '- The nearest matching site (within a local search) is respawned fully repaired; +1 launcher per crate, up to caps.') + table.insert(lines, '- Caps: HAWK 6, Patriot 6, KUB 3, BUK 6. Extra crates beyond the cap are not consumed.') + table.insert(lines, '- Must match coalition and site type; otherwise no changes are applied.') + table.insert(lines, '- Respawn is required to apply repairs/augmentation due to DCS limitations.') + table.insert(lines, '') + table.insert(lines, 'Placement tips:') + table.insert(lines, '- Space launchers to avoid masking; keep radars with good line-of-sight; avoid fratricide arcs.') + _msgCoalition(self.Side, table.concat(lines, '\n'), 45) + end) + + -- Debug logging controls + local debugMenu = MENU_COALITION:New(self.Side, 'Debug Logging', root) + MENU_COALITION_COMMAND:New(self.Side, 'Enable Verbose (LogLevel 4)', debugMenu, function() + self.Config.LogLevel = LOG_DEBUG + _logInfo(string.format('[%s] Verbose/Debug logging ENABLED via Admin menu', tostring(self.Side))) + _msgCoalition(self.Side, 'CTLD verbose logging ENABLED (LogLevel=4)', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Normal INFO (LogLevel 2)', debugMenu, function() + self.Config.LogLevel = LOG_INFO + _logInfo(string.format('[%s] Logging set to INFO level via Admin menu', tostring(self.Side))) + _msgCoalition(self.Side, 'CTLD logging set to INFO (LogLevel=2)', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Errors Only (LogLevel 1)', debugMenu, function() + self.Config.LogLevel = LOG_ERROR + _logInfo(string.format('[%s] Logging set to ERROR-only via Admin menu', tostring(self.Side))) + _msgCoalition(self.Side, 'CTLD logging: ERRORS only (LogLevel=1)', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Disable All (LogLevel 0)', debugMenu, function() + self.Config.LogLevel = LOG_NONE + _msgCoalition(self.Side, 'CTLD logging DISABLED (LogLevel=0)', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Show CTLD Status (crates/zones)', root, function() + local crates = 0 + for _ in pairs(CTLD._crates) do crates = crates + 1 end + local msg = string.format('CTLD Status:\nActive crates: %d\nPickup zones: %d\nDrop zones: %d\nFOB zones: %d\nBuild Confirm: %s (%ds window)\nBuild Cooldown: %s (%ds)' + , crates, #(self.PickupZones or {}), #(self.DropZones or {}), #(self.FOBZones or {}) + , self.Config.BuildConfirmEnabled and 'ON' or 'OFF', self.Config.BuildConfirmWindowSeconds or 0 + , self.Config.BuildCooldownEnabled and 'ON' or 'OFF', self.Config.BuildCooldownSeconds or 0) + _msgCoalition(self.Side, msg, 20) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Show Coalition Summary', root, function() + self:ShowCoalitionSummary() + end) + MENU_COALITION_COMMAND:New(self.Side, 'Draw CTLD Zones on Map', root, function() + self:DrawZonesOnMap() + _msgCoalition(self.Side, 'CTLD zones drawn on F10 map.', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Clear CTLD Map Drawings', root, function() + self:ClearMapDrawings() + _msgCoalition(self.Side, 'CTLD map drawings cleared.', 8) + end) + -- Player Help submenu (was below; removed there and added above) + self.AdminMenu = root +end +-- #endregion Menus + +-- ========================= +-- Coalition Summary +-- ========================= +-- #region Coalition Summary +function CTLD:ShowCoalitionSummary() + -- Crate counts per type (this coalition) + local perType = {} + local total = 0 + for _,meta in pairs(CTLD._crates) do + if meta.side == self.Side then + perType[meta.key] = (perType[meta.key] or 0) + 1 + total = total + 1 + end + end + local lines = {} + table.insert(lines, string.format('CTLD Coalition Summary (%s)', (self.Side==coalition.side.BLUE and 'BLUE') or (self.Side==coalition.side.RED and 'RED') or 'NEUTRAL')) + -- Crate timeout information first (lifetime is in seconds; 0 disables cleanup) + local lifeSec = tonumber(self.Config.CrateLifetime or 0) or 0 + if lifeSec > 0 then + local mins = math.floor((lifeSec + 30) / 60) + table.insert(lines, string.format('Crate Timeout: %d mins (Crates will despawn to prevent clutter)', mins)) + else + table.insert(lines, 'Crate Timeout: Disabled') + end + table.insert(lines, string.format('Active crates: %d', total)) + if next(perType) then + table.insert(lines, 'Crates by type:') + -- stable order: sort keys alphabetically + local keys = {} + for k,_ in pairs(perType) do table.insert(keys, k) end + table.sort(keys) + for _,k in ipairs(keys) do + table.insert(lines, string.format(' %s: %d', k, perType[k])) + end + else + table.insert(lines, 'Crates by type: (none)') + end + + -- Nearby buildable recipes for each active player + table.insert(lines, '\nBuildable near players:') + local players = coalition.getPlayers(self.Side) or {} + if #players == 0 then + table.insert(lines, ' (no active players)') + else + for _,u in ipairs(players) do + local g = u:getGroup() + local gname = g and g:getName() or u:getName() or 'Group' + local pos = u:getPoint() + local here = { x = pos.x, z = pos.z } + local radius = self.Config.BuildRadius or 100 + local nearby = self:GetNearbyCrates(here, radius) + local counts = {} + for _,c in ipairs(nearby) do if c.meta.side == self.Side then counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end end + -- include carried crates if allowed + if self.Config.BuildRequiresGroundCrates ~= true then + local lc = CTLD._loadedCrates[gname] + if lc and lc.byKey then for k,v in pairs(lc.byKey) do counts[k] = (counts[k] or 0) + v end end + end + local insideFOB, _ = self:IsPointInFOBZones(here) + local buildable = {} + -- composite recipes first + for recipeKey,cat in pairs(self.Config.CrateCatalog) do + if type(cat.requires) == 'table' and cat.build then + if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOB) then + local ok = true + for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < qty then ok = false; break end end + if ok then table.insert(buildable, cat.description or recipeKey) end + end + end + end + -- single-key + for key,cat in pairs(self.Config.CrateCatalog) do + if cat and cat.build and (not cat.requires) then + if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOB) then + if (counts[key] or 0) >= (cat.required or 1) then table.insert(buildable, cat.description or key) end + end + end + end + if #buildable == 0 then + table.insert(lines, string.format(' %s: none', gname)) + else + -- limit to keep message short + local maxShow = 6 + local shown = {} + for i=1, math.min(#buildable, maxShow) do table.insert(shown, buildable[i]) end + local suffix = (#buildable > maxShow) and string.format(' (+%d more)', #buildable - maxShow) or '' + table.insert(lines, string.format(' %s: %s%s', gname, table.concat(shown, ', '), suffix)) + end + end + end + + -- Quick help card + table.insert(lines, '\nQuick Help:') + table.insert(lines, '- Request crates: CTLD → Request Crate (near Pickup Zones).') + table.insert(lines, '- Build: double-press "Build Here" within '..tostring(self.Config.BuildConfirmWindowSeconds or 10)..'s; cooldown '..tostring(self.Config.BuildCooldownSeconds or 60)..'s per group.') + table.insert(lines, '- Hover Coach: CTLD → Coach & Nav → Enable/Disable; vectors to crates/zones available.') + table.insert(lines, '- Manage crates: Drop One/All from CTLD menu; build consumes nearby crates.') + + _msgCoalition(self.Side, table.concat(lines, '\n'), 25) +end +-- #endregion Coalition Summary + +-- ========================= +-- Crates +-- ========================= +-- #region Crates +-- Note: Menu creation lives in the Menus region; this section handles crate request/spawn/nearby/cleanup only. +function CTLD:RequestCrateForGroup(group, crateKey, opts) + opts = opts or {} + local cat = self.Config.CrateCatalog[crateKey] + if not cat then _msgGroup(group, 'Unknown crate type: '..tostring(crateKey)) return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local function _distanceToZone(u, z) + if not (u and z and z.GetPointVec3) then return nil end + local up = u:GetPointVec3() + local zp = z:GetPointVec3() + local dx = (up.x - zp.x) + local dz = (up.z - zp.z) + return math.sqrt(dx*dx + dz*dz) + end + + local defaultZone, defaultDist = self:_nearestActivePickupZone(unit) + local zone = opts.zone or defaultZone + local dist = opts.zoneDist or defaultDist + if zone and (not dist) then + dist = _distanceToZone(unit, zone) + end + + local defs = self:_collectActivePickupDefs() + local hasPickupZones = (#defs > 0) + local maxd = (self.Config.PickupZoneMaxDistance or 10000) + + local zoneName = zone and zone:GetName() or (hasPickupZones and 'nearest zone' or 'NO PICKUP ZONES CONFIGURED') + _eventSend(self, group, nil, 'crate_spawn_requested', { type = tostring(crateKey), zone = zoneName }) + + if not hasPickupZones and self.Config.RequirePickupZoneForCrateRequest then + _eventSend(self, group, nil, 'no_pickup_zones', {}) + return + end + + local spawnPoint + if opts.spawnPoint then + spawnPoint = { x = opts.spawnPoint.x, z = opts.spawnPoint.z } + elseif zone and dist and dist <= maxd then + spawnPoint = self:_computeCrateSpawnPoint(zone, { + minSeparation = opts.minSeparationOverride, + additionalEdgeBuffer = opts.additionalEdgeBuffer, + tries = opts.separationTries, + skipSeparationCheck = opts.skipSeparationCheck, + ignoreCrates = opts.ignoreCrates, + }) + else + if self.Config.RequirePickupZoneForCrateRequest then + local isMetric = _getPlayerIsMetric(unit) + local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) + local brg = 0 + if zone then + local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() + brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) + end + _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) + return + else + local p = unit:GetPointVec3() + spawnPoint = { x = p.x + 10, z = p.z + 10 } + end + end + + if not spawnPoint and zone and dist and dist <= maxd then + local centerVec = zone:GetPointVec3() + if centerVec then + spawnPoint = { x = centerVec.x, z = centerVec.z } + end + end + + if not spawnPoint then + _msgGroup(group, 'Crate spawn failed: unable to resolve spawn point.') + return + end + + local zoneNameForStock = zone and zone:GetName() or nil + if self.Config.Inventory and self.Config.Inventory.Enabled then + if not zoneNameForStock then + _msgGroup(group, 'Crate requests must be at a Supply Zone for stock control.') + return + end + CTLD._stockByZone[zoneNameForStock] = CTLD._stockByZone[zoneNameForStock] or {} + local cur = tonumber(CTLD._stockByZone[zoneNameForStock][crateKey] or 0) or 0 + if cur <= 0 then + if self:_TryUseSalvageForCrate(group, crateKey, cat) then + _logVerbose(string.format('[Salvage] Used salvage to spawn %s', crateKey)) + else + _msgGroup(group, string.format('Out of stock at %s for %s', zoneNameForStock, self:_friendlyNameForKey(crateKey))) + return + end + else + CTLD._stockByZone[zoneNameForStock][crateKey] = cur - 1 + end + end + + local cname = string.format('CTLD_CRATE_%s_%d', crateKey, math.random(100000,999999)) + _spawnStaticCargo(self.Side, { x = spawnPoint.x, z = spawnPoint.z }, cat.dcsCargoType or 'uh1h_cargo', cname) + CTLD._crates[cname] = { + key = crateKey, + side = self.Side, + spawnTime = timer.getTime(), + point = { x = spawnPoint.x, z = spawnPoint.z }, + requester = group:GetName(), + } + + _addToSpatialGrid(cname, CTLD._crates[cname], 'crate') + + if zone and (opts.suppressSmoke ~= true) then + local zdef = (self._ZoneDefs and self._ZoneDefs.PickupZones) and self._ZoneDefs.PickupZones[zone:GetName()] or nil + local smokeColor = (zdef and zdef.smoke) or self.Config.PickupZoneSmokeColor + if smokeColor then + local sx, sz = spawnPoint.x, spawnPoint.z + local sy = 0 + if land and land.getHeight then + local ok, h = pcall(land.getHeight, { x = sx, y = sz }) + if ok and type(h) == 'number' then sy = h end + end + _spawnCrateSmoke({ x = sx, y = sy, z = sz }, smokeColor, self.Config.CrateSmoke, cname) + end + end + + do + local unitPos = unit:GetPointVec3() + local from = { x = unitPos.x, z = unitPos.z } + local to = { x = spawnPoint.x, z = spawnPoint.z } + local brg = _bearingDeg(from, to) + local isMetric = _getPlayerIsMetric(unit) + local rngMeters = math.sqrt(((to.x-from.x)^2)+((to.z-from.z)^2)) + local rngV, rngU = _fmtRange(rngMeters, isMetric) + local data = { + id = cname, + type = tostring(crateKey), + brg = brg, + rng = rngV, + rng_u = rngU, + } + _eventSend(self, group, nil, 'crate_spawned', data) + end + + return cname, spawnPoint, zone +end + +-- Convenience: for composite recipes (def.requires), request all component crates in one go +function CTLD:RequestRecipeBundleForGroup(group, recipeKey) + local def = self.Config.CrateCatalog[recipeKey] + if not def or type(def.requires) ~= 'table' then + -- Fallback to single crate request + return self:RequestCrateForGroup(group, recipeKey) + end + local unit = group and group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + -- Require proximity to an active pickup zone if inventory is enabled or config requires it + local zone, dist = self:_nearestActivePickupZone(unit) + local maxd = (self.Config.PickupZoneMaxDistance or 10000) + if self.Config.RequirePickupZoneForCrateRequest and (not zone or not dist or dist > maxd) then + local isMetric = _getPlayerIsMetric(unit) + local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) + local brg = 0 + if zone then + local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() + brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) + end + _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) + return + end + if (self.Config.Inventory and self.Config.Inventory.Enabled) then + if not zone then + _msgGroup(group, 'Crate bundle requests must be at a Supply Zone for stock control.') + return + end + local zname = zone:GetName() + local stockTbl = CTLD._stockByZone[zname] or {} + -- Pre-check: ensure we can fulfill at least one bundle (check stock or salvage) + for reqKey, qty in pairs(def.requires) do + local have = tonumber(stockTbl[reqKey] or 0) or 0 + local need = tonumber(qty or 0) or 0 + if need > 0 and have < need then + -- Try salvage for the shortfall + local catEntry = self.Config.CrateCatalog[reqKey] + if not self:_CanUseSalvageForCrate(reqKey, catEntry, need - have) then + _msgGroup(group, string.format('Out of stock at %s for %s bundle: need %d x %s', zname, self:_friendlyNameForKey(recipeKey), need, self:_friendlyNameForKey(reqKey))) + return + end + end + end + end + -- Flatten bundle components into a deterministic order + local ordered = {} + local totalCount = 0 + local keys = {} + for reqKey,_ in pairs(def.requires) do table.insert(keys, reqKey) end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + for _,reqKey in ipairs(keys) do + local qty = tonumber(def.requires[reqKey] or 0) or 0 + for _ = 1, qty do + table.insert(ordered, reqKey) + totalCount = totalCount + 1 + end + end + + if totalCount == 0 then return end + + if totalCount == 1 then + self:RequestCrateForGroup(group, ordered[1], { zone = zone, zoneDist = dist }) + return + end + + local baseSeparation = math.max(0, self.Config.CrateSpawnMinSeparation or 7) + local spacing = self.Config.CrateClusterSpacing or baseSeparation + if spacing < baseSeparation then spacing = baseSeparation end + if spacing < 4 then spacing = 4 end + + local offsets, perRow, rows = self:_buildClusterOffsets(totalCount, spacing) + local clusterPad = math.max(((perRow - 1) * spacing) * 0.5, ((rows - 1) * spacing) * 0.5) + local anchor = zone and self:_computeCrateSpawnPoint(zone, { additionalEdgeBuffer = clusterPad }) or nil + + if not anchor then + for _,reqKey in ipairs(ordered) do + self:RequestCrateForGroup(group, reqKey, { zone = zone, zoneDist = dist }) + end + return + end + + local orient = math.random() * 2 * math.pi + local cosA = math.cos(orient) + local sinA = math.sin(orient) + local zoneCenterVec, zoneRadius = self:_getZoneCenterAndRadius(zone) + local zoneCenter = zoneCenterVec and { x = zoneCenterVec.x, z = zoneCenterVec.z } or nil + local safeRadius = nil + if zoneRadius then + safeRadius = math.max(0, zoneRadius - (self.Config.PickupZoneSpawnEdgeBuffer or 10)) + end + + local spawnPoints = {} + for idx = 1, totalCount do + local off = offsets[idx] or { x = 0, z = 0 } + local rx = off.x * cosA - off.z * sinA + local rz = off.x * sinA + off.z * cosA + local point = { x = anchor.x + rx, z = anchor.z + rz } + + if zoneCenter and safeRadius and safeRadius > 0 then + local dx = point.x - zoneCenter.x + local dz = point.z - zoneCenter.z + local distFromCenter = math.sqrt(dx * dx + dz * dz) + if distFromCenter > safeRadius and distFromCenter > 0 then + local scale = safeRadius / distFromCenter + point.x = zoneCenter.x + dx * scale + point.z = zoneCenter.z + dz * scale + end + end + + for name, meta in pairs(CTLD._crates) do + if meta.side == self.Side then + local dx = point.x - meta.point.x + local dz = point.z - meta.point.z + local distSq = dx * dx + dz * dz + if distSq < (baseSeparation * baseSeparation) then + local distNow = math.sqrt(distSq) + local desired = baseSeparation + 0.5 + if distNow < 0.1 then + point.x = point.x + desired + else + local push = desired - distNow + point.x = point.x + (dx / distNow) * push + point.z = point.z + (dz / distNow) * push + end + if zoneCenter and safeRadius and safeRadius > 0 then + local ndx = point.x - zoneCenter.x + local ndz = point.z - zoneCenter.z + local ndist = math.sqrt(ndx * ndx + ndz * ndz) + if ndist > safeRadius and ndist > 0 then + local scale = safeRadius / ndist + point.x = zoneCenter.x + ndx * scale + point.z = zoneCenter.z + ndz * scale + end + end + end + end + end + + spawnPoints[idx] = point + end + + local smokePlaced = false + for idx, reqKey in ipairs(ordered) do + local point = spawnPoints[idx] + self:RequestCrateForGroup(group, reqKey, { + zone = zone, + zoneDist = dist, + spawnPoint = point, + suppressSmoke = smokePlaced, + }) + smokePlaced = true + end +end + +function CTLD:GetNearbyCrates(point, radius) + local result = {} + for name,meta in pairs(CTLD._crates) do + local dx = (meta.point.x - point.x) + local dz = (meta.point.z - point.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= radius then + table.insert(result, { name = name, meta = meta }) + end + end + return result +end + +function CTLD:CleanupCrates() + local now = timer.getTime() + local life = self.Config.CrateLifetime + for name,meta in pairs(CTLD._crates) do + if now - (meta.spawnTime or now) > life then + local obj = StaticObject.getByName(name) + if obj then obj:destroy() end + _cleanupCrateSmoke(name) -- Clean up smoke refresh schedule + _removeFromSpatialGrid(name, meta.point, 'crate') -- Remove from spatial index + CTLD._crates[name] = nil + _logDebug('Cleaned up crate '..name) + -- Notify requester group if still around; else coalition + local gname = meta.requester + local group = gname and GROUP:FindByName(gname) or nil + if group and group:IsAlive() then + _eventSend(self, group, nil, 'crate_expired', { id = name }) + else + _eventSend(self, nil, self.Side, 'crate_expired', { id = name }) + end + end + end +end + +function CTLD:CleanupDeployedTroops() + -- Remove any deployed troop groups that are dead or no longer exist + for troopGroupName, troopMeta in pairs(CTLD._deployedTroops) do + if troopMeta.side == self.Side then + local troopGroup = GROUP:FindByName(troopGroupName) + if not troopGroup or not troopGroup:IsAlive() then + -- Remove from spatial grid if point is available + if troopMeta.point then + _removeFromSpatialGrid(troopGroupName, troopMeta.point, 'troops') + end + CTLD._deployedTroops[troopGroupName] = nil + _logDebug('Cleaned up deployed troop group: '..troopGroupName) + end + end + end +end + +-- Comprehensive state pruning to prevent memory leaks +function CTLD:PruneOrphanedState() + local pruned = 0 + + -- 1. Prune spatial grid entries for non-existent crates/troops + for gridKey, cell in pairs(CTLD._spatialGrid) do + -- Check crates in this cell + for crateName, _ in pairs(cell.crates) do + if not CTLD._crates[crateName] then + cell.crates[crateName] = nil + pruned = pruned + 1 + end + end + -- Check troops in this cell + for troopName, _ in pairs(cell.troops) do + if not CTLD._deployedTroops[troopName] then + cell.troops[troopName] = nil + pruned = pruned + 1 + end + end + -- Remove empty cells + if not next(cell.crates) and not next(cell.troops) then + CTLD._spatialGrid[gridKey] = nil + end + end + + -- 2. Prune JTAC registry for non-existent groups + if self._jtacRegistry then + for gname, _ in pairs(self._jtacRegistry) do + local g = Group.getByName(gname) + if not g or not g:isExist() then + self:_cleanupJTACEntry(gname) + pruned = pruned + 1 + end + end + end + + -- 3. Prune hover/coach state for non-existent units + local function pruneUnitState(stateTbl, label) + if not stateTbl then return end + for unitName, _ in pairs(stateTbl) do + local u = Unit.getByName(unitName) + if not u or not u:isExist() or u:getLife() <= 0 then + stateTbl[unitName] = nil + pruned = pruned + 1 + end + end + end + + pruneUnitState(CTLD._hoverState, 'hover') + pruneUnitState(CTLD._coachState, 'coach') + pruneUnitState(CTLD._groundLoadState, 'groundLoad') + pruneUnitState(CTLD._unitLast, 'unitLast') + + -- 4. Prune group-level state for non-existent groups + local function pruneGroupState(stateTbl, label) + if not stateTbl then return end + for gname, _ in pairs(stateTbl) do + local g = GROUP:FindByName(gname) + if not g or not g:IsAlive() then + stateTbl[gname] = nil + pruned = pruned + 1 + end + end + end + + pruneGroupState(CTLD._troopsLoaded, 'troopsLoaded') + pruneGroupState(CTLD._loadedCrates, 'loadedCrates') + pruneGroupState(CTLD._loadedTroopTypes, 'loadedTroopTypes') + pruneGroupState(CTLD._buildConfirm, 'buildConfirm') + pruneGroupState(CTLD._medevacUnloadStates, 'medevacUnload') + pruneGroupState(CTLD._medevacLoadStates, 'medevacLoad') + pruneGroupState(CTLD._medevacEnrouteStates, 'medevacEnroute') + + -- 5. Prune _inStockMenus for non-existent groups + if CTLD._inStockMenus then + for gname, _ in pairs(CTLD._inStockMenus) do + local g = GROUP:FindByName(gname) + if not g or not g:IsAlive() then + CTLD._inStockMenus[gname] = nil + pruned = pruned + 1 + end + end + end + + if pruned > 0 then + _logVerbose(string.format('[StateMaint] Pruned %d orphaned state entries', pruned)) + end +end +-- #endregion Crates + +-- ========================= +-- Build logic +-- ========================= +-- #region Build logic +function CTLD:BuildAtGroup(group, opts) + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC trace: entering BuildAtGroup for group=%s', tostring(group:GetName()))) + end + -- Build cooldown/confirmation guardrails + local now = timer.getTime() + local gname = group:GetName() + if self.Config.BuildCooldownEnabled then + local last = CTLD._buildCooldown[gname] + if last and (now - last) < (self.Config.BuildCooldownSeconds or 60) then + local rem = math.max(0, math.ceil((self.Config.BuildCooldownSeconds or 60) - (now - last))) + _msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem)) + return + end + end + if self.Config.BuildConfirmEnabled then + local first = CTLD._buildConfirm[gname] + local win = self.Config.BuildConfirmWindowSeconds or 10 + if not first or (now - first) > win then + CTLD._buildConfirm[gname] = now + _msgGroup(group, string.format('Confirm build: select "Build Here" again within %ds to proceed.', win)) + return + else + -- within window; proceed and clear pending + CTLD._buildConfirm[gname] = nil + end + end + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + -- Compute a safe spawn point offset forward from the aircraft to prevent rotor/ground collisions with spawned units + local hdgRad, hdgDeg = _headingRadDeg(unit) + local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) + local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } + local radius = self.Config.BuildRadius + local nearby = self:GetNearbyCrates(here, radius) + -- filter crates to coalition side for this CTLD instance + local filtered = {} + for _,c in ipairs(nearby) do + if c.meta.side == self.Side then table.insert(filtered, c) end + end + nearby = filtered + if #nearby == 0 then + _eventSend(self, group, nil, 'build_insufficient_crates', { build = 'asset' }) + -- Nudge players to use Recipe Info + _msgGroup(group, 'Tip: Use CTLD → Recipe Info to see exact crate requirements for each build.') + return + end + + -- Count by key + local counts = {} + for _,c in ipairs(nearby) do + counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 + end + + -- Include loaded crates carried by this group + local carried = CTLD._loadedCrates[gname] + if self.Config.BuildRequiresGroundCrates ~= true then + if carried and carried.byKey then + for k,v in pairs(carried.byKey) do + counts[k] = (counts[k] or 0) + v + end + end + end + + -- Helper to consume crates of a given key/qty + local function consumeCrates(key, qty) + local removed = 0 + -- Optionally consume from carried crates + if self.Config.BuildRequiresGroundCrates ~= true then + if carried and carried.byKey and (carried.byKey[key] or 0) > 0 then + local take = math.min(qty, carried.byKey[key]) + carried.byKey[key] = carried.byKey[key] - take + if carried.byKey[key] <= 0 then carried.byKey[key] = nil end + carried.total = math.max(0, (carried.total or 0) - take) + removed = removed + take + if take > 0 then ctld:_scheduleLoadedCrateMenuRefresh(group) end + end + end + for _,c in ipairs(nearby) do + if removed >= qty then break end + if c.meta.key == key then + local obj = StaticObject.getByName(c.name) + if obj then obj:destroy() end + _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule + CTLD._crates[c.name] = nil + removed = removed + 1 + end + end + end + + local insideFOBZone, fz = self:IsPointInFOBZones(here) + local fobBlocked = false + + -- Build All mode: when BuildCooldownSeconds = 0, loop and build all available assets + local buildAllMode = (tonumber(self.Config.BuildCooldownSeconds) or 0) == 0 + local builtCount = 0 + local buildLoop = true + + -- Helper to calculate dispersed spawn position for Build All mode + local function getDispersedSpawnPoint(basePoint, isFOB) + -- FOBs always spawn at the designated point (no dispersion) + if isFOB then + return { x = basePoint.x, z = basePoint.z } + end + + -- In Build All mode with dispersion enabled, randomize spawn position + if buildAllMode and (self.Config.BuildDispersionRadius or 0) > 0 then + local dispRadius = self.Config.BuildDispersionRadius + -- Random angle (0-360 degrees) + local angle = math.random() * 2 * math.pi + -- Random distance (0 to dispRadius, with bias toward outer ring for better spread) + local distance = math.sqrt(math.random()) * dispRadius + return { + x = basePoint.x + math.cos(angle) * distance, + z = basePoint.z + math.sin(angle) * distance + } + end + + -- Default: use base point + return { x = basePoint.x, z = basePoint.z } + end + + while buildLoop do + buildLoop = false -- Only loop if we successfully build something in Build All mode + + -- Try composite recipes first (requires is a map of key->qty) + for recipeKey,cat in pairs(self.Config.CrateCatalog) do + if type(cat.requires) == 'table' and cat.build then + if cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone then + fobBlocked = true + else + -- Build caps disabled: rely solely on inventory/catalog control + local ok = true + for reqKey,qty in pairs(cat.requires) do + if (counts[reqKey] or 0) < qty then ok = false; break end + end + if ok then + -- Calculate spawn position (with dispersion for non-FOB builds in Build All mode) + local actualSpawn = getDispersedSpawnPoint(spawnAt, cat.isFOB) + local gdata = cat.build({ x = actualSpawn.x, z = actualSpawn.z }, hdgDeg, cat.side or self.Side) + _eventSend(self, group, nil, 'build_started', { build = cat.description or recipeKey }) + local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) + if g then + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC trace: composite build spawned group=%s recipe=%s', tostring(g:getName()), tostring(recipeKey))) + end + -- Register JTAC if applicable (composite recipe) + self:_maybeRegisterJTAC(recipeKey, cat, g) + for reqKey,qty in pairs(cat.requires) do + consumeCrates(reqKey, qty) + counts[reqKey] = math.max(0, (counts[reqKey] or 0) - qty) + end + builtCount = builtCount + 1 + -- No site cap counters when caps are disabled + _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or recipeKey, player = _playerNameFromGroup(group) }) + -- If this was a FOB, register a new pickup zone with reduced stock + if cat.isFOB then + pcall(function() + self:_CreateFOBPickupZone({ x = actualSpawn.x, z = actualSpawn.z }, cat, hdg) + end) + end + -- Assign optional behavior for built vehicles/groups + local behavior = opts and opts.behavior or nil + if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then + local t = self:_assignAttackBehavior(g:getName(), actualSpawn, true) + local isMetric = _getPlayerIsMetric(group:GetUnit(1)) + if t and t.kind == 'base' then + local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) + elseif t and t.kind == 'enemy' then + local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) + else + local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) + _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) + end + end + if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end + if buildAllMode then + buildLoop = true -- Continue building in Build All mode + break -- Break from recipe loop to restart search + else + return -- Single build mode - return after first build + end + else + _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) + return + end + end + -- continue_composite (Lua 5.1 compatible: no labels) + end + end + end + + -- Then single-key recipes (only if we didn't build a composite) + if not buildLoop or not buildAllMode then + for key,count in pairs(counts) do + local cat = self.Config.CrateCatalog[key] + if cat and cat.build and (not cat.requires) and count >= (cat.required or 1) then + if cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone then + fobBlocked = true + else + -- Build caps disabled: rely solely on inventory/catalog control + -- Calculate spawn position (with dispersion for non-FOB builds in Build All mode) + local actualSpawn = getDispersedSpawnPoint(spawnAt, cat.isFOB) + local gdata = cat.build({ x = actualSpawn.x, z = actualSpawn.z }, hdgDeg, cat.side or self.Side) + _eventSend(self, group, nil, 'build_started', { build = cat.description or key }) + local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) + if g then + if self.Config.JTAC and self.Config.JTAC.Verbose then + _logInfo(string.format('JTAC trace: single build spawned group=%s key=%s', tostring(g:getName()), tostring(key))) + end + -- Register JTAC if applicable (single-unit recipe) + self:_maybeRegisterJTAC(key, cat, g) + consumeCrates(key, cat.required or 1) + counts[key] = math.max(0, (counts[key] or 0) - (cat.required or 1)) + builtCount = builtCount + 1 + -- No single-unit cap counters when caps are disabled + _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or key, player = _playerNameFromGroup(group) }) + -- Assign optional behavior for built vehicles/groups + local behavior = opts and opts.behavior or nil + if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then + local t = self:_assignAttackBehavior(g:getName(), actualSpawn, true) + local isMetric = _getPlayerIsMetric(group:GetUnit(1)) + if t and t.kind == 'base' then + local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) + elseif t and t.kind == 'enemy' then + local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) + else + local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) + _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) + end + end + if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end + if buildAllMode then + buildLoop = true -- Continue building in Build All mode + break -- Break from counts loop to restart search + else + return -- Single build mode - return after first build + end + else + _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) + return + end + end + end + -- continue_single (Lua 5.1 compatible: no labels) + end + end + end + + -- If we built anything in Build All mode, we're done successfully + if builtCount > 0 then + if buildAllMode then + _msgGroup(group, string.format('Build All complete: deployed %d asset(s).', builtCount)) + end + return + end + + if fobBlocked then + _eventSend(self, group, nil, 'fob_restricted', {}) + return + end + + -- Helpful hint if building requires ground crates and player is carrying crates + if self.Config.BuildRequiresGroundCrates == true then + local carried = CTLD._loadedCrates[gname] + if carried and (carried.total or 0) > 0 then + _eventSend(self, group, nil, 'build_requires_ground', { total = carried.total }) + return + end + end + _eventSend(self, group, nil, 'build_insufficient_crates', { build = 'asset' }) + -- Provide a short breakdown of most likely recipes and what is missing + local suggestions = {} + local function pushSuggestion(name, missingStr, haveParts, totalParts) + table.insert(suggestions, { name = name, miss = missingStr, have = haveParts, total = totalParts }) + end + -- consider composite recipes with at least one matching component nearby + for rkey,cat in pairs(self.Config.CrateCatalog) do + if type(cat.requires) == 'table' then + local have, total, missingList = 0, 0, {} + for reqKey,qty in pairs(cat.requires) do + total = total + (qty or 0) + local haveHere = math.min(qty or 0, counts[reqKey] or 0) + have = have + haveHere + local need = math.max(0, (qty or 0) - (counts[reqKey] or 0)) + if need > 0 then + local fname = self:_friendlyNameForKey(reqKey) + table.insert(missingList, string.format('%dx %s', need, fname)) + end + end + if have > 0 and have < total then + local name = cat.description or cat.menu or rkey + pushSuggestion(name, table.concat(missingList, ', '), have, total) + end + else + -- single-key recipe: if some crates present but not enough + local need = (cat and (cat.required or 1)) or 1 + local have = counts[rkey] or 0 + if have > 0 and have < need then + local name = cat.description or cat.menu or rkey + pushSuggestion(name, string.format('%d more crate(s) of %s', need - have, self:_friendlyNameForKey(rkey)), have, need) + end + end + end + table.sort(suggestions, function(a,b) + local ra = (a.total > 0) and (a.have / a.total) or 0 + local rb = (b.total > 0) and (b.have / b.total) or 0 + if ra == rb then return (a.total - a.have) < (b.total - b.have) end + return ra > rb + end) + if #suggestions > 0 then + local maxShow = math.min(2, #suggestions) + for i=1,maxShow do + local s = suggestions[i] + _msgGroup(group, string.format('Missing for %s: %s', s.name, s.miss)) + end + else + _msgGroup(group, 'No matching recipe found with nearby crates. Check Recipe Info for requirements.') + end +end +-- #endregion Build logic + +-- ========================= +-- Loaded crate management +-- ========================= +-- #region Loaded crate management + +-- Update DCS internal cargo weight for a group +function CTLD:_updateCargoWeight(group) + _updateCargoWeight(group) +end + +function CTLD:_addLoadedCrate(group, crateKey) + local gname = group:GetName() + CTLD._loadedCrates[gname] = CTLD._loadedCrates[gname] or { total = 0, totalWeightKg = 0, byKey = {} } + local lc = CTLD._loadedCrates[gname] + lc.total = lc.total + 1 + lc.byKey[crateKey] = (lc.byKey[crateKey] or 0) + 1 + + -- Add weight from catalog + local cat = self.Config.CrateCatalog[crateKey] + local crateWeight = (cat and cat.weightKg) or 0 + lc.totalWeightKg = (lc.totalWeightKg or 0) + crateWeight + + -- Update DCS internal cargo weight + self:_updateCargoWeight(group) + + -- Refresh drop-by-type menu after loading + self:_scheduleLoadedCrateMenuRefresh(group) +end + +function CTLD:_clearLoadedCrateMenuCommands(gname) + self._loadedCrateMenus = self._loadedCrateMenus or {} + local state = self._loadedCrateMenus[gname] + if not state or not state.commands then return end + for _,cmd in ipairs(state.commands) do + if cmd and cmd.Remove then + pcall(function() cmd:Remove() end) + end + end + state.commands = {} +end + +function CTLD:_BuildOrRefreshLoadedCrateMenu(group, parentMenu) + if not group then return end + self._loadedCrateMenus = self._loadedCrateMenus or {} + local gname = group:GetName() + local state = self._loadedCrateMenus[gname] or { commands = {} } + state.parent = parentMenu + state.groupName = gname + self._loadedCrateMenus[gname] = state + + self:_clearLoadedCrateMenuCommands(gname) + + local carried = CTLD._loadedCrates[gname] + local byKey = (carried and carried.byKey) or {} + local keys = {} + for key,count in pairs(byKey) do + if count and count > 0 then table.insert(keys, key) end + end + + local ctld = self + if #keys == 0 then + local cmd = MENU_GROUP_COMMAND:New(group, 'No crates onboard', parentMenu, function() + _msgGroup(group, 'No crates loaded to drop individually.') + end) + table.insert(state.commands, cmd) + return + end + + table.sort(keys, function(a, b) + local fa = ctld:_friendlyNameForKey(a) or a + local fb = ctld:_friendlyNameForKey(b) or b + if fa == fb then return a < b end + return fa < fb + end) + + for _,key in ipairs(keys) do + local count = byKey[key] or 0 + local friendly = ctld:_friendlyNameForKey(key) or key + local title = string.format('Drop %s (%d)', friendly, count) + local cmd = MENU_GROUP_COMMAND:New(group, title, parentMenu, function() + ctld:DropLoadedCrates(group, 1, key) + end) + table.insert(state.commands, cmd) + end +end + +function CTLD:_scheduleLoadedCrateMenuRefresh(group) + if not group then return end + self._loadedCrateMenus = self._loadedCrateMenus or {} + local gname = group:GetName() + local state = self._loadedCrateMenus[gname] + if not state or not state.parent then return end + local ctld = self + local id = timer.scheduleFunction(function() + local g = GROUP:FindByName(gname) + if not g then return end + ctld:_BuildOrRefreshLoadedCrateMenu(g, state.parent) + return + end, {}, timer.getTime() + 0.1) + _trackOneShotTimer(id) +end + +function CTLD:DropLoadedCrates(group, howMany, crateKey) + local gname = group:GetName() + local lc = CTLD._loadedCrates[gname] + if not lc or (lc.total or 0) == 0 then _eventSend(self, group, nil, 'no_loaded_crates', {}) return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + -- Restrict dropping crates inside Pickup Zones if configured + if self.Config.ForbidDropsInsidePickupZones then + local activeOnly = (self.Config.ForbidChecksActivePickupOnly ~= false) + local inside = false + local ok, err = pcall(function() + inside = select(1, self:_isUnitInsidePickupZone(unit, activeOnly)) + end) + if ok and inside then + _eventSend(self, group, nil, 'drop_forbidden_in_pickup', {}) + return + end + end + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + -- Offset drop point forward of the aircraft to avoid rotor/airframe damage + local hdgRad, _ = _headingRadDeg(unit) + local fwd = math.max(0, tonumber(self.Config.DropCrateForwardOffset or 20) or 0) + local dropPt = (fwd > 0) and { x = here.x + math.sin(hdgRad) * fwd, z = here.z + math.cos(hdgRad) * fwd } or { x = here.x, z = here.z } + local initialTotal = lc.total or 0 + local requested = (howMany and howMany > 0) and howMany or initialTotal + + local dropPlan = {} + if crateKey then + local available = lc.byKey[crateKey] or 0 + if available <= 0 then + local friendly = self:_friendlyNameForKey(crateKey) or crateKey + _msgGroup(group, string.format('No %s crates loaded.', friendly)) + return + end + local qty = math.min(requested, available) + table.insert(dropPlan, { key = crateKey, count = qty }) + else + local keys = {} + for key,_ in pairs(lc.byKey) do table.insert(keys, key) end + table.sort(keys, function(a, b) + local fa = self:_friendlyNameForKey(a) or a + local fb = self:_friendlyNameForKey(b) or b + if fa == fb then return a < b end + return fa < fb + end) + local remaining = math.min(requested, initialTotal) + for _,key in ipairs(keys) do + if remaining <= 0 then break end + local available = lc.byKey[key] or 0 + if available > 0 then + local qty = math.min(available, remaining) + table.insert(dropPlan, { key = key, count = qty }) + remaining = remaining - qty + end + end + end + + local totalToDrop = 0 + for _,entry in ipairs(dropPlan) do totalToDrop = totalToDrop + (entry.count or 0) end + if totalToDrop <= 0 then + _msgGroup(group, 'No valid crates selected to drop.') + return + end + + _eventSend(self, group, nil, 'drop_initiated', { count = totalToDrop, key = crateKey }) + -- Warn about crate timeout when dropping + local lifeSec = tonumber(self.Config.CrateLifetime or 0) or 0 + if lifeSec > 0 then + local mins = math.floor((lifeSec + 30) / 60) + _msgGroup(group, string.format('Note: Crates will despawn after %d mins to prevent clutter.', mins)) + end + -- Drop following the prepared plan + for _,entry in ipairs(dropPlan) do + local k = entry.key + local dropNow = entry.count or 0 + if dropNow > 0 then + local cat = self.Config.CrateCatalog[k] + local crateWeight = (cat and cat.weightKg) or 0 + for i=1,dropNow do + local cname = string.format('CTLD_CRATE_%s_%d', k, math.random(100000,999999)) + _spawnStaticCargo(self.Side, dropPt, (cat and cat.dcsCargoType) or 'uh1h_cargo', cname) + CTLD._crates[cname] = { key = k, side = self.Side, spawnTime = timer.getTime(), point = { x = dropPt.x, z = dropPt.z } } + -- Add to spatial index + _addToSpatialGrid(cname, CTLD._crates[cname], 'crate') + lc.byKey[k] = lc.byKey[k] - 1 + if lc.byKey[k] <= 0 then lc.byKey[k] = nil end + lc.total = lc.total - 1 + lc.totalWeightKg = (lc.totalWeightKg or 0) - crateWeight + end + end + end + local actualDropped = initialTotal - (lc.total or 0) + _eventSend(self, group, nil, 'dropped_crates', { count = actualDropped, key = crateKey }) + + -- Update DCS internal cargo weight after dropping + self:_updateCargoWeight(group) + + -- Refresh drop-by-type menu after dropping + self:_scheduleLoadedCrateMenuRefresh(group) + + -- Reiterate timeout after drop completes (players may miss the initial warning) + if lifeSec > 0 then + local mins = math.floor((lifeSec + 30) / 60) + _msgGroup(group, string.format('Reminder: Dropped crates will despawn after %d mins to prevent clutter.', mins)) + end +end + +-- Show inventory at the nearest pickup zone/FOB +function CTLD:ShowNearestZoneInventory(group) + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + -- Find nearest active pickup zone + local zone, dist = self:_nearestActivePickupZone(unit) + + if not zone then + _msgGroup(group, 'No active pickup zones found nearby. Move closer to a supply zone.') + return + end + + local zoneName = zone:GetName() + local isMetric = _getPlayerIsMetric(unit) + local rngV, rngU = _fmtRange(dist, isMetric) + + -- Get inventory for this zone + local stockTbl = CTLD._stockByZone[zoneName] or {} + + -- Build the inventory display + local lines = {} + table.insert(lines, string.format('Inventory at %s', zoneName)) + table.insert(lines, string.format('Distance: %.1f %s', rngV, rngU)) + table.insert(lines, '') + + -- Check if inventory system is enabled + local invEnabled = self.Config.Inventory and self.Config.Inventory.Enabled ~= false + if not invEnabled then + table.insert(lines, 'Inventory tracking is disabled - all items available.') + _msgGroup(group, table.concat(lines, '\n'), 20) + return + end + + -- Count total items and organize by category + local totalItems = 0 + local byCategory = {} + + for key, count in pairs(stockTbl) do + if count > 0 then + local def = self.Config.CrateCatalog[key] + if def and ((not def.side) or def.side == self.Side) then + local cat = (def.menuCategory or 'Other') + byCategory[cat] = byCategory[cat] or {} + table.insert(byCategory[cat], { + key = key, + name = def.menu or def.description or key, + count = count, + isRecipe = (type(def.requires) == 'table') + }) + totalItems = totalItems + count + end + end + end + + if totalItems == 0 then + table.insert(lines, 'No items in stock at this location.') + table.insert(lines, 'Request resupply or move to another zone.') + else + table.insert(lines, string.format('Total items in stock: %d', totalItems)) + table.insert(lines, '') + + -- Sort categories for consistent display + local categories = {} + for cat, _ in pairs(byCategory) do + table.insert(categories, cat) + end + table.sort(categories) + + -- Display items by category + for _, cat in ipairs(categories) do + table.insert(lines, string.format('-- %s --', cat)) + local items = byCategory[cat] + -- Sort items by name + table.sort(items, function(a, b) return a.name < b.name end) + for _, item in ipairs(items) do + if item.isRecipe then + -- For recipes, calculate available bundles + local def = self.Config.CrateCatalog[item.key] + local bundles = math.huge + if def and def.requires then + for reqKey, qty in pairs(def.requires) do + local have = tonumber(stockTbl[reqKey] or 0) or 0 + local need = tonumber(qty or 0) or 0 + if need > 0 then + bundles = math.min(bundles, math.floor(have / need)) + end + end + end + if bundles == math.huge then bundles = 0 end + table.insert(lines, string.format(' %s: %d bundle%s', item.name, bundles, (bundles == 1 and '' or 's'))) + else + table.insert(lines, string.format(' %s: %d', item.name, item.count)) + end + end + table.insert(lines, '') + end + end + + -- Display the inventory + _msgGroup(group, table.concat(lines, '\n'), 30) +end + +-- #endregion Loaded crate management + +-- ========================= +-- Hover pickup scanner +-- ========================= +-- #region Hover pickup scanner +function CTLD:ScanHoverPickup() + local coachCfg = CTLD.HoverCoachConfig or { enabled = false } + if not coachCfg.enabled then return end + + -- iterate all groups that have menus (active transports) + for gname,_ in pairs(self.MenusByGroup or {}) do + local group = GROUP:FindByName(gname) + if group and group:IsAlive() then + local unit = group:GetUnit(1) + if unit and unit:IsAlive() then + -- Allowed type check + local typ = _getUnitType(unit) + if _isIn(self.Config.AllowedAircraft, typ) then + local uname = unit:GetName() + local now = timer.getTime() + local p3 = unit:GetPointVec3() + local ground = land and land.getHeight and land.getHeight({ x = p3.x, y = p3.z }) or 0 + local agl = math.max(0, p3.y - ground) + + -- Skip hover coaching if on the ground (let ground auto-load handle it) + local groundCfg = CTLD.GroundAutoLoadConfig or {} + local groundContactAGL = groundCfg.GroundContactAGL or 3.5 + if agl <= groundContactAGL then + -- On ground, clear hover state and skip hover logic + CTLD._hoverState[uname] = nil + -- Also, when firmly landed, suppress any completed ground-load state + -- so returning to this spot without crates won't keep retriggering. + if CTLD._groundLoadState and CTLD._groundLoadState[uname] and CTLD._groundLoadState[uname].completed then + CTLD._groundLoadState[uname] = nil + end + else + -- speeds (ground/vertical) + local last = CTLD._unitLast[uname] + local gs, vs = 0, 0 + if last and (now > (last.t or 0)) then + local dt = now - last.t + if dt > 0 then + local dx = (p3.x - last.x) + local dz = (p3.z - last.z) + gs = math.sqrt(dx*dx + dz*dz) / dt + if last.agl then vs = (agl - last.agl) / dt end + end + end + CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now, agl = agl } + + -- Use spatial indexing to find nearby crates/troops efficiently + local maxd = coachCfg.autoPickupDistance or 25 + local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxd) + + local friendlyCrateCount = 0 + local friendlyTroopCount = 0 + local bestName, bestMeta, bestd + local bestType = 'crate' + + -- Search nearby crates from spatial grid + for name, meta in pairs(nearby.crates or {}) do + if meta.side == self.Side then + friendlyCrateCount = friendlyCrateCount + 1 + local dx = (meta.point.x - p3.x) + local dz = (meta.point.z - p3.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= maxd and ((not bestd) or d < bestd) then + bestName, bestMeta, bestd = name, meta, d + bestType = 'crate' + end + end + end + + -- Search nearby deployed troops from spatial grid + for troopGroupName, troopMeta in pairs(nearby.troops or {}) do + if troopMeta.side == self.Side then + friendlyTroopCount = friendlyTroopCount + 1 + local troopGroup = GROUP:FindByName(troopGroupName) + if troopGroup and troopGroup:IsAlive() then + local troopPos = troopGroup:GetCoordinate() + if troopPos then + local tp = troopPos:GetVec3() + local dx = (tp.x - p3.x) + local dz = (tp.z - p3.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= maxd and ((not bestd) or d < bestd) then + bestName, bestMeta, bestd = troopGroupName, troopMeta, d + bestType = 'troops' + end + end + else + -- Group doesn't exist or is dead, remove from tracking + _removeFromSpatialGrid(troopGroupName, troopMeta.point, 'troops') + CTLD._deployedTroops[troopGroupName] = nil + end + end + end + + if CTLD.Config and CTLD.Config.DebugHoverCrates and (friendlyCrateCount > 0 or friendlyTroopCount > 0) then + local debugLabel = bestName or 'none' + local debugType = bestType + local debugDist = bestd + if not bestName and friendlyCrateCount > 0 then + debugLabel = 'pending' + debugType = 'crate' + debugDist = math.huge + end + _debugCrateSight('HoverDebug', { + unit = uname, + now = now, + interval = CTLD.Config.DebugHoverCratesInterval, + step = CTLD.Config.DebugHoverCratesStep, + name = debugLabel, + distance = debugDist, + count = friendlyCrateCount, + troops = friendlyTroopCount, + radius = maxd, + typeHint = debugType, + note = string.format('coachGS<=%.1f', coachCfg.thresholds.captureGS or (4/3.6)), + }) + end + + local coachEnabled = coachCfg.enabled + if CTLD._coachOverride and CTLD._coachOverride[gname] ~= nil then + coachEnabled = CTLD._coachOverride[gname] + end + + -- If coach is on, provide phased guidance + if coachEnabled and bestName and bestMeta then + local thresholds = coachCfg.thresholds or {} + local isMetric = _getPlayerIsMetric(unit) + + -- Arrival phase + if bestd <= (thresholds.arrivalDist or 1000) then + _coachSend(self, group, uname, 'coach_arrival', {}, false) + end + + -- Close-in guidance + if bestd <= (thresholds.closeDist or 100) then + _coachSend(self, group, uname, 'coach_close', {}, false) + end + + -- Precision phase + if bestd <= (thresholds.precisionDist or 30) then + local hdg, _ = _headingRadDeg(unit) + local dx = (bestMeta.point.x - p3.x) + local dz = (bestMeta.point.z - p3.z) + local right, fwd = _projectToBodyFrame(dx, dz, hdg) + + -- Horizontal hint formatting + local function hintDir(val, posWord, negWord, toUnits) + local mag = math.abs(val) + local v, u = _fmtDistance(mag, isMetric) + if mag < 0.5 then return nil end + return string.format("%s %d %s", (val >= 0 and posWord or negWord), v, u) + end + local h = {} + local rHint = hintDir(right, 'Right', 'Left') + local fHint = hintDir(fwd, 'Forward', 'Back') + if rHint then table.insert(h, rHint) end + if fHint then table.insert(h, fHint) end + + -- Vertical hint against AGL window + local vHint + local aglMin = thresholds.aglMin or 5 + local aglMax = thresholds.aglMax or 20 + if agl < aglMin then + local dv, du = _fmtAGL(aglMin - agl, isMetric) + vHint = string.format("Up %d %s", dv, du) + elseif agl > aglMax then + local dv, du = _fmtAGL(agl - aglMax, isMetric) + vHint = string.format("Down %d %s", dv, du) + end + if vHint then table.insert(h, vHint) end + + local hints = table.concat(h, ", ") + local gsV, gsU = _fmtSpeed(gs, isMetric) + local data = { hints = (hints ~= '' and (hints..'.') or ''), gs = gsV, gs_u = gsU } + + _coachSend(self, group, uname, 'coach_hint', data, true) + + -- Error prompts (dominant one) + local maxGS = thresholds.maxGS or (8/3.6) + local aglMinT = aglMin + local aglMaxT = aglMax + if gs > maxGS then + local v, u = _fmtSpeed(gs, isMetric) + _coachSend(self, group, uname, 'coach_too_fast', { gs = v, gs_u = u }, false) + elseif agl > aglMaxT then + local v, u = _fmtAGL(agl, isMetric) + _coachSend(self, group, uname, 'coach_too_high', { agl = v, agl_u = u }, false) + elseif agl < aglMinT then + local v, u = _fmtAGL(agl, isMetric) + _coachSend(self, group, uname, 'coach_too_low', { agl = v, agl_u = u }, false) + end + end + end + + -- Auto-load logic using capture thresholds + local capGS = coachCfg.thresholds.captureGS or (4/3.6) + local aglMin = coachCfg.thresholds.aglMin or 5 + local aglMax = coachCfg.thresholds.aglMax or 20 + local speedOK = gs <= capGS + local heightOK = (agl >= aglMin and agl <= aglMax) + + if bestName and bestMeta and speedOK and heightOK then + local withinRadius = bestd <= (coachCfg.thresholds.captureHoriz or 2) + + if withinRadius then + local carried = CTLD._loadedCrates[gname] + local total = carried and carried.total or 0 + local currentWeight = carried and carried.totalWeightKg or 0 + + -- Get aircraft-specific capacity instead of global setting + local capacity = _getAircraftCapacity(unit) + local maxCrates = capacity.maxCrates + local maxTroops = capacity.maxTroops + local maxWeight = capacity.maxWeightKg or 0 + + -- Calculate weight and check capacity based on type + local itemWeight = 0 + local countOK = false + local weightOK = false + + if bestType == 'crate' then + -- Picking up a crate + itemWeight = (bestMeta and self.Config.CrateCatalog[bestMeta.key] and self.Config.CrateCatalog[bestMeta.key].weightKg) or 0 + local wouldBeWeight = currentWeight + itemWeight + countOK = (total < maxCrates) + weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight) + elseif bestType == 'troops' then + -- Picking up troops - check if we can ADD them to existing load + itemWeight = bestMeta.weightKg or 0 + local wouldBeWeight = currentWeight + itemWeight + local troopCount = bestMeta.count or 0 + + -- Check if we already have troops loaded - if so, check if we can add more + local currentTroops = CTLD._troopsLoaded[gname] + local currentTroopCount = currentTroops and currentTroops.count or 0 + local totalTroopCount = currentTroopCount + troopCount + + -- Check total capacity (allow mixing different troop types) + countOK = (totalTroopCount <= maxTroops) + weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight) + + -- Provide feedback if capacity exceeded + if not countOK then + local hs = CTLD._hoverState[uname] + if not hs or hs.messageShown ~= true then + _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d', + currentTroopCount, troopCount, maxTroops)) + if not hs then + CTLD._hoverState[uname] = { messageShown = true } + else + hs.messageShown = true + end + end + end + end + + -- Check both count AND weight limits + if countOK and weightOK then + local hs = CTLD._hoverState[uname] + if not hs or hs.targetCrate ~= bestName or hs.targetType ~= bestType then + CTLD._hoverState[uname] = { targetCrate = bestName, targetType = bestType, startTime = now } + if coachEnabled then _coachSend(self, group, uname, 'coach_hold', {}, false) end + else + -- stability hold timer + local holdNeeded = coachCfg.thresholds.stabilityHold or 1.8 + if (now - hs.startTime) >= holdNeeded then + -- load it + if bestType == 'crate' then + -- Use shared loading function + local success = self:_loadCrateIntoAircraft(group, bestName, bestMeta) + if success then + if coachEnabled then + _coachSend(self, group, uname, 'coach_loaded', {}, false) + else + _msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key))) + end + end + elseif bestType == 'troops' then + -- Pick up the troop group + local troopGroup = GROUP:FindByName(bestName) + if troopGroup then + troopGroup:Destroy() + end + _removeFromSpatialGrid(bestName, bestMeta.point, 'troops') -- Remove from spatial index + CTLD._deployedTroops[bestName] = nil + + -- ADD to existing troops if any, don't overwrite + local currentTroops = CTLD._troopsLoaded[gname] + if currentTroops then + -- Add to existing load (supports mixing types) + local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } + table.insert(troopTypes, { typeKey = bestMeta.typeKey, count = bestMeta.count }) + + CTLD._troopsLoaded[gname] = { + count = currentTroops.count + bestMeta.count, + typeKey = 'Mixed', -- Indicate mixed types + troopTypes = troopTypes, -- Store individual type details + weightKg = currentTroops.weightKg + bestMeta.weightKg + } + self:_refreshLoadedTroopSummaryForGroup(gname) + _msgGroup(group, string.format('Loaded %d more troops (total: %d)', bestMeta.count, CTLD._troopsLoaded[gname].count)) + else + -- First load + CTLD._troopsLoaded[gname] = { + count = bestMeta.count, + typeKey = bestMeta.typeKey, + troopTypes = { { typeKey = bestMeta.typeKey, count = bestMeta.count } }, + weightKg = bestMeta.weightKg + } + self:_refreshLoadedTroopSummaryForGroup(gname) + if coachEnabled then + _msgGroup(group, string.format('Loaded %d troops', bestMeta.count)) + else + _msgGroup(group, string.format('Loaded %d troops', bestMeta.count)) + end + end + + -- Update cargo weight + self:_updateCargoWeight(group) + end + CTLD._hoverState[uname] = nil + end + end + else + -- Aircraft at capacity - notify player with weight/count info + local aircraftType = _getUnitType(unit) or 'aircraft' + if not weightOK then + -- Weight limit exceeded + _msgGroup(group, string.format('Weight capacity reached! Current: %dkg, Item: %dkg, Max: %dkg for %s', + math.floor(currentWeight), math.floor(itemWeight), math.floor(maxWeight), aircraftType)) + elseif bestType == 'crate' then + -- Count limit exceeded for crates + _eventSend(self, group, nil, 'crate_aircraft_capacity', { current = total, max = maxCrates, aircraft = aircraftType }) + elseif bestType == 'troops' then + -- Count limit exceeded for troops + _eventSend(self, group, nil, 'troop_aircraft_capacity', { count = bestMeta.count or 0, max = maxTroops, aircraft = aircraftType }) + end + CTLD._hoverState[uname] = nil + end + else + -- lost precision window + if coachEnabled then _coachSend(self, group, uname, 'coach_hover_lost', {}, false) end + CTLD._hoverState[uname] = nil + end + else + -- reset hover state when outside primary envelope + if CTLD._hoverState[uname] then + if coachEnabled then _coachSend(self, group, uname, 'coach_abort', {}, false) end + end + CTLD._hoverState[uname] = nil + end + end + end + end + end + end +end +-- #endregion Hover pickup scanner + +-- ========================= +-- Ground Auto-Load Scanner +-- ========================= +-- #region Ground auto-load scanner +function CTLD:ScanGroundAutoLoad() + local groundCfg = CTLD.GroundAutoLoadConfig or { Enabled = false } + if not groundCfg.Enabled then return end + + local now = timer.getTime() + local progressMsgInterval = (self.GroundLoadComms and self.GroundLoadComms.ProgressInterval) or 5 + + -- Iterate all groups that have menus (active transports) + for gname, _ in pairs(self.MenusByGroup or {}) do + local group = GROUP:FindByName(gname) + if group and group:IsAlive() then + local unit = group:GetUnit(1) + if unit and unit:IsAlive() then + local typ = _getUnitType(unit) + if _isIn(self.Config.AllowedAircraft, typ) then + local uname = unit:GetName() + + -- Ensure ground-load state table exists + CTLD._groundLoadState = CTLD._groundLoadState or {} + + -- Check if already carrying crates (skip if at capacity) + local carried = CTLD._loadedCrates[gname] + local currentCount = carried and carried.total or 0 + local capacity = _getAircraftCapacity(unit) + + if currentCount < capacity.maxCrates then + -- Check basic requirements: on ground, low speed + local agl = _getUnitAGL(unit) + local gs = _getGroundSpeed(unit) + local onGround = (agl <= (groundCfg.GroundContactAGL or 3.5)) + local slowEnough = (gs <= (groundCfg.MaxGroundSpeed or 2.0)) + + -- If a previous ground auto-load completed while we remain effectively stationary + -- on the ground, avoid auto-starting again until the aircraft has moved or lifted off. + local state = CTLD._groundLoadState[uname] + local canProcess = true + if state and state.completed and onGround and slowEnough then + local p3 = unit:GetPointVec3() + local sx = state.completedPosition and state.completedPosition.x or state.startPosition and state.startPosition.x or p3.x + local sz = state.completedPosition and state.completedPosition.z or state.startPosition and state.startPosition.z or p3.z + local dx = (p3.x - sx) + local dz = (p3.z - sz) + local moved = math.sqrt(dx*dx + dz*dz) + -- Require a small reposition (e.g., taxi or liftoff) before new auto-load cycles + if moved < 20 then + canProcess = false + end + end + + if onGround and slowEnough and canProcess then + -- Check zone requirement + local inValidZone = false + if groundCfg.RequirePickupZone then + local inPickupZone = self:_isUnitInsidePickupZone(unit, true) + + if not inPickupZone and groundCfg.AllowInFOBZones then + -- Check FOB zones too + for _, fobZone in ipairs(self.FOBZones or {}) do + local fname = fobZone:GetName() + if self._ZoneActive.FOB[fname] ~= false then + local fobPoint = fobZone:GetPointVec3() + local unitPoint = unit:GetPointVec3() + local dx = (fobPoint.x - unitPoint.x) + local dz = (fobPoint.z - unitPoint.z) + local d = math.sqrt(dx*dx + dz*dz) + local fobRadius = self:_getZoneRadius(fobZone) + if d <= fobRadius then + inValidZone = true + break + end + end + end + else + inValidZone = inPickupZone + end + else + inValidZone = true -- no zone requirement + end + + if inValidZone then + -- Find nearby crates + local p3 = unit:GetPointVec3() + local searchRadius = groundCfg.SearchRadius or 50 + local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, searchRadius) + + local bestGroundCrate, bestGroundDist = nil, math.huge + local loadableCrates = {} + for name, meta in pairs(nearby.crates or {}) do + if meta.side == self.Side then + local dx = (meta.point.x - p3.x) + local dz = (meta.point.z - p3.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= searchRadius then + local entry = { name = name, meta = meta, dist = d } + table.insert(loadableCrates, entry) + if d < bestGroundDist then + bestGroundCrate, bestGroundDist = entry, d + end + end + end + end + + if CTLD.Config and CTLD.Config.DebugGroundCrates then + local shouldReport = true + local gst = CTLD._groundLoadState and CTLD._groundLoadState[uname] + if gst and gst.reportHold then + -- only emit periodic heartbeat during hold + local last = gst.reportLast or 0 + if (now - last) < (CTLD.Config.DebugGroundCratesInterval or 2.0) then + shouldReport = false + else + CTLD._groundLoadState[uname].reportLast = now + end + end + if shouldReport then + _debugCrateSight('GroundDebug', { + unit = uname, + now = now, + interval = CTLD.Config.DebugGroundCratesInterval, + step = CTLD.Config.DebugGroundCratesStep, + name = bestGroundCrate and bestGroundCrate.name or 'none', + distance = (bestGroundDist ~= math.huge) and bestGroundDist or nil, + count = #loadableCrates, + troops = 0, + radius = searchRadius, + typeHint = 'crate', + note = string.format('freeSlots=%d', math.max(0, capacity.maxCrates - currentCount)) + }) + end + end + + if #loadableCrates > 0 then + -- Sort by distance, load closest first (up to capacity) + table.sort(loadableCrates, function(a, b) return a.dist < b.dist end) + + -- Determine how many we can load + local canLoad = math.min(#loadableCrates, capacity.maxCrates - currentCount) + local cratesToLoad = {} + for i = 1, canLoad do + table.insert(cratesToLoad, loadableCrates[i]) + end + + if #cratesToLoad > 0 then + -- Ground load state machine + local state = CTLD._groundLoadState[uname] + if not state or not state.loading then + -- Start new load sequence + CTLD._groundLoadState[uname] = { + loading = true, + startTime = now, + cratesToLoad = cratesToLoad, + startPosition = { x = p3.x, z = p3.z }, + lastCheckTime = now, + } + local msgData = _prepareGroundLoadMessage(self, 'Start', { + seconds = groundCfg.LoadDelay, + count = #cratesToLoad, + }) + _coachSend(self, group, uname, 'ground_load_started', msgData, false) + else + -- Validate that crates in state still exist + local validCrates = {} + for _, crateInfo in ipairs(state.cratesToLoad or {}) do + -- Check if crate still exists in CTLD._crates + if CTLD._crates[crateInfo.name] then + table.insert(validCrates, crateInfo) + end + end + + -- If no valid crates remain, reset state + if #validCrates == 0 then + CTLD._groundLoadState[uname] = nil + else + -- Update state with only valid crates + state.cratesToLoad = validCrates + + -- Continue existing sequence + local elapsed = now - state.startTime + local remaining = (groundCfg.LoadDelay or 15) - elapsed + + -- Check if moved too much + local dx = (p3.x - state.startPosition.x) + local dz = (p3.z - state.startPosition.z) + local moved = math.sqrt(dx*dx + dz*dz) + if moved > 10 then -- moved more than 10m + _coachSend(self, group, uname, 'ground_load_aborted', {}, false) + CTLD._groundLoadState[uname] = nil + else + -- Progress message every 5 seconds + if (now - state.lastCheckTime) >= progressMsgInterval and remaining > 1 then + local msgData = _prepareGroundLoadMessage(self, 'Progress', { + remaining = math.max(0, math.ceil(remaining)), + }) + _coachSend(self, group, uname, 'ground_load_progress', msgData, false) + state.lastCheckTime = now + state.reportHold = true + end + + -- Check if time elapsed + if remaining <= 0 then + -- Load the crates! + local loadedCount = 0 + for _, crateInfo in ipairs(state.cratesToLoad) do + local success = self:_loadCrateIntoAircraft(group, crateInfo.name, crateInfo.meta) + if success then + loadedCount = loadedCount + 1 + end + end + + if loadedCount > 0 then + local msgData = _prepareGroundLoadMessage(self, 'Complete', { + count = loadedCount, + }) + _coachSend(self, group, uname, 'ground_load_complete', msgData, false) + -- Mark completion and remember where it happened so we don't + -- immediately restart another cycle while still parked. + CTLD._groundLoadState[uname] = { + completed = true, + completedTime = now, + completedPosition = { x = p3.x, z = p3.z }, + reportHold = false, + } + else + -- Nothing actually loaded; clear state fully. + CTLD._groundLoadState[uname] = nil + end + end + end + end -- end of validCrates > 0 + end -- end of state exists check + else + CTLD._groundLoadState[uname] = nil + end + else + CTLD._groundLoadState[uname] = nil + end + else + -- Not in a valid zone + local state = CTLD._groundLoadState[uname] + if state and state.sentZoneWarning ~= true then + -- Send zone requirement message (once) + local nearestZone, nearestDist = self:_nearestActivePickupZone(unit) + if nearestZone and nearestDist then + local isMetric = _getPlayerIsMetric(unit) + local brg = _bearingTo(unit, nearestZone) + local distV, distU = _fmtDistance(nearestDist, isMetric) + _coachSend(self, group, uname, 'ground_load_no_zone', { + zone_dist = distV, + zone_dist_u = distU, + zone_brg = brg + }, false) + end + if not state then + CTLD._groundLoadState[uname] = { sentZoneWarning = true } + else + state.sentZoneWarning = true + end + end + end + else + -- Not on ground or moving too fast, reset state + local state = CTLD._groundLoadState[uname] + if state and state.loading then + -- Was loading but now lifted off or moved + _coachSend(self, group, uname, 'ground_load_aborted', {}, false) + end + CTLD._groundLoadState[uname] = nil + end + else + -- At capacity, no need to check + CTLD._groundLoadState[uname] = nil + end + end + end + end + end +end + +-- Helper: Load a crate into an aircraft (shared by hover and ground load) +function CTLD:_loadCrateIntoAircraft(group, crateName, crateMeta) + if not group or not crateName or not crateMeta then return false end + + local gname = group:GetName() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return false end + + -- Destroy the static object first + local obj = StaticObject.getByName(crateName) + if obj then obj:destroy() end + + -- Clean up crate smoke refresh schedule + _cleanupCrateSmoke(crateName) + + -- Remove from spatial grid and tracking + _removeFromSpatialGrid(crateName, crateMeta.point, 'crate') + CTLD._crates[crateName] = nil + + -- Add to loaded crates using existing method (maintains consistency with rest of code) + self:_addLoadedCrate(group, crateMeta.key) + + -- Update inventory if enabled + if self.Config.Inventory and self.Config.Inventory.Enabled and crateMeta.spawnZone then + pcall(function() self:_updateInventoryOnPickup(crateMeta.spawnZone, crateMeta.key) end) + end + + _logDebug(string.format('[Load] Loaded crate %s (%s) into %s', crateName, crateMeta.key, gname)) + return true +end +-- #endregion Ground auto-load scanner + +-- ========================= +-- Troops +-- ========================= +-- #region Troops +function CTLD:_lookupCrateLabel(crateKey) + if not crateKey then return 'Unknown Crate' end + local cat = self.Config and self.Config.CrateCatalog or {} + local def = cat[crateKey] + if def then + return def.menu or def.description or def.name or def.displayName or crateKey + end + return crateKey +end + +function CTLD:_lookupTroopLabel(typeKey) + if not typeKey or typeKey == '' then return 'Troops' end + local cfg = self.Config and self.Config.Troops and self.Config.Troops.TroopTypes + local def = cfg and cfg[typeKey] + if def and def.label and def.label ~= '' then + return def.label + end + return typeKey +end + +function CTLD:_refreshLoadedTroopSummaryForGroup(groupName) + if not groupName or groupName == '' then return end + local load = CTLD._troopsLoaded[groupName] + if not load or (load.count or 0) == 0 then + CTLD._loadedTroopTypes[groupName] = nil + return + end + + local entries = {} + if load.troopTypes and #load.troopTypes > 0 then + entries = load.troopTypes + else + entries = { { typeKey = load.typeKey, count = load.count } } + end + + local summary = { total = 0, byType = {}, labels = {} } + for _, entry in ipairs(entries) do + local typeKey = entry.typeKey or load.typeKey or 'Troops' + local count = entry.count or 0 + if count > 0 then + summary.byType[typeKey] = (summary.byType[typeKey] or 0) + count + summary.labels[typeKey] = self:_lookupTroopLabel(typeKey) + summary.total = summary.total + count + end + end + + if summary.total > 0 then + CTLD._loadedTroopTypes[groupName] = summary + else + CTLD._loadedTroopTypes[groupName] = nil + end +end + +function CTLD:LoadTroops(group, opts) + local gname = group:GetName() + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + -- Check for MEDEVAC crew pickup first + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + local medevacPickedUp = self:CheckMEDEVACPickup(group) + if medevacPickedUp then + return -- MEDEVAC crew was picked up, don't continue with normal troop loading + end + end + + -- Ground requirement check for troop loading (realistic behavior) + if self.Config.RequireGroundForTroopLoad then + local unitType = _getUnitType(unit) + local capacities = self.Config.AircraftCapacities or {} + local specific = capacities[unitType] + + -- Check per-aircraft override first, then fall back to global config + local requireGround = (specific and specific.requireGround ~= nil) and specific.requireGround or true + + if requireGround then + -- Must be on the ground + if _isUnitInAir(unit) then + local isMetric = _getPlayerIsMetric(unit) + local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0 + local speedVal, speedUnit = _fmtSpeed(maxSpeed, isMetric) + _eventSend(self, group, nil, 'troop_load_must_land', { max_speed = speedVal, speed_u = speedUnit }) + return + end + + -- Check ground speed (must not be taxiing too fast) + local groundSpeed = _getGroundSpeed(unit) + local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0 + + if groundSpeed > maxSpeed then + local isMetric = _getPlayerIsMetric(unit) + local currentVal, currentUnit = _fmtSpeed(groundSpeed, isMetric) + local maxVal, maxUnit = _fmtSpeed(maxSpeed, isMetric) + _eventSend(self, group, nil, 'troop_load_too_fast', { + current_speed = currentVal, + max_speed = maxVal, + speed_u = maxUnit + }) + return + end + end + end + + -- Check for nearby deployed troops first (allow pickup regardless of zone restrictions) + local p3 = unit:GetPointVec3() + local maxPickupDistance = 25 -- meters for ground-based troop pickup (matches hover pickup distance) + local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxPickupDistance) + + local nearbyTroopGroup = nil + local nearbyTroopMeta = nil + local nearbyTroopDist = nil + + -- Search for nearby deployed troops + for troopGroupName, troopMeta in pairs(nearby.troops) do + if troopMeta.side == self.Side then + local troopGroup = GROUP:FindByName(troopGroupName) + if troopGroup and troopGroup:IsAlive() then + local troopPos = troopGroup:GetCoordinate() + if troopPos then + local tp = troopPos:GetVec3() + local dx = (tp.x - p3.x) + local dz = (tp.z - p3.z) + local d = math.sqrt(dx*dx + dz*dz) + if d <= maxPickupDistance and ((not nearbyTroopDist) or d < nearbyTroopDist) then + nearbyTroopGroup = troopGroup + nearbyTroopMeta = troopMeta + nearbyTroopDist = d + end + end + end + end + end + + -- If we found nearby deployed troops, pick them up (bypass zone requirement) + if nearbyTroopGroup and nearbyTroopMeta then + -- Load the deployed troops + local troopCount = nearbyTroopMeta.count or 0 + local typeKey = nearbyTroopMeta.typeKey or 'AS' + local troopWeight = nearbyTroopMeta.weightKg or 0 + + -- Check aircraft capacity + local capacity = _getAircraftCapacity(unit) + local maxTroops = capacity.maxTroops + local maxWeight = capacity.maxWeightKg or 0 + + local currentTroops = CTLD._troopsLoaded[gname] + local currentTroopCount = currentTroops and currentTroops.count or 0 + local totalTroopCount = currentTroopCount + troopCount + + local carried = CTLD._loadedCrates[gname] + local currentWeight = carried and carried.totalWeightKg or 0 + local wouldBeWeight = currentWeight + troopWeight + + -- Check capacity + if totalTroopCount > maxTroops then + local aircraftType = _getUnitType(unit) or 'aircraft' + _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s', + currentTroopCount, troopCount, maxTroops, aircraftType)) + return + end + + if maxWeight > 0 and wouldBeWeight > maxWeight then + local aircraftType = _getUnitType(unit) or 'aircraft' + _msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s', + math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType)) + return + end + + -- Load the troops and remove from deployed tracking + if currentTroops then + local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } + table.insert(troopTypes, { typeKey = typeKey, count = troopCount }) + + CTLD._troopsLoaded[gname] = { + count = totalTroopCount, + typeKey = 'Mixed', + troopTypes = troopTypes, + weightKg = currentTroops.weightKg + troopWeight, + } + else + CTLD._troopsLoaded[gname] = { + count = troopCount, + typeKey = typeKey, + troopTypes = { { typeKey = typeKey, count = troopCount } }, + weightKg = troopWeight, + } + end + + -- Remove from deployed tracking + local troopGroupName = nearbyTroopGroup:GetName() + _removeFromSpatialGrid(troopGroupName, nearbyTroopMeta.point, 'troops') + CTLD._deployedTroops[troopGroupName] = nil + + -- Destroy the troop group + nearbyTroopGroup:Destroy() + + self:_refreshLoadedTroopSummaryForGroup(gname) + _eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount }) + _msgGroup(group, string.format('Picked up %d deployed troops (total onboard: %d)', troopCount, totalTroopCount)) + + return -- Successfully picked up deployed troops, exit function + end + + -- No nearby deployed troops found, enforce pickup zone requirement for spawning new troops + if self.Config.RequirePickupZoneForTroopLoad then + local hasPickupZones = (self.PickupZones and #self.PickupZones > 0) or (self.Config.Zones and self.Config.Zones.PickupZones and #self.Config.Zones.PickupZones > 0) + if not hasPickupZones then + _eventSend(self, group, nil, 'no_pickup_zones', {}) + return + end + local zone, dist = self:_nearestActivePickupZone(unit) + if not zone or not dist then + -- No active pickup zone resolvable; provide helpful vectors to nearest configured zone if any + local list = {} + if self.Config and self.Config.Zones and self.Config.Zones.PickupZones then + for _, z in ipairs(self.Config.Zones.PickupZones) do table.insert(list, z) end + elseif self.PickupZones and #self.PickupZones > 0 then + for _, mz in ipairs(self.PickupZones) do if mz and mz.GetName then table.insert(list, { name = mz:GetName() }) end end + end + local fbZone, fbDist = _nearestZonePoint(unit, list) + if fbZone and fbDist then + local isMetric = _getPlayerIsMetric(unit) + local rZone = self:_getZoneRadius(fbZone) or 0 + local delta = math.max(0, fbDist - rZone) + local v, u = _fmtRange(delta, isMetric) + local up = unit:GetPointVec3(); local zp = fbZone:GetPointVec3() + local brg = _bearingDeg({ x = up.x, z = up.z }, { x = zp.x, z = zp.z }) + _eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) + else + _eventSend(self, group, nil, 'no_pickup_zones', {}) + end + return + end + local inside = false + if zone then + local rZone = self:_getZoneRadius(zone) or 0 + if dist and rZone and dist <= rZone then inside = true end + end + if not inside then + local isMetric = _getPlayerIsMetric(unit) + local rZone = (self:_getZoneRadius(zone) or 0) + local delta = (dist and rZone) and math.max(0, dist - rZone) or 0 + local v, u = _fmtRange(delta, isMetric) + -- Bearing from player to zone center + local up = unit:GetPointVec3() + local zp = zone and zone:GetPointVec3() or nil + local brg = 0 + if zp then + brg = _bearingDeg({ x = up.x, z = up.z }, { x = zp.x, z = zp.z }) + end + _eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) + return + end + end + + -- Determine troop type and composition + local requestedType = (opts and (opts.typeKey or opts.type)) + or (self.Config.Troops and self.Config.Troops.DefaultType) + or 'AS' + local unitsList, label = self:_resolveTroopUnits(requestedType) + local troopDef = (self.Config.Troops and self.Config.Troops.TroopTypes and self.Config.Troops.TroopTypes[requestedType]) or nil + + -- Check if we already have troops (allow mixing different types now) + local currentTroops = CTLD._troopsLoaded[gname] + + -- Check aircraft capacity for troops + local capacity = _getAircraftCapacity(unit) + local maxTroops = capacity.maxTroops + local maxWeight = capacity.maxWeightKg or 0 + local troopCount = #unitsList + + -- Calculate troop weight from catalog + local troopWeight = 0 + if troopDef and troopDef.weightKg then + troopWeight = troopDef.weightKg + elseif troopCount > 0 then + -- Fallback: estimate 100kg per soldier if no weight defined + troopWeight = troopCount * 100 + end + + -- Check current cargo weight and troop count + local carried = CTLD._loadedCrates[gname] + local currentWeight = carried and carried.totalWeightKg or 0 + local currentTroopCount = currentTroops and currentTroops.count or 0 + local totalTroopCount = currentTroopCount + troopCount + local wouldBeWeight = currentWeight + troopWeight + + -- Check total troop count limit + if totalTroopCount > maxTroops then + -- Aircraft cannot carry this many troops total + local aircraftType = _getUnitType(unit) or 'aircraft' + _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s', + currentTroopCount, troopCount, maxTroops, aircraftType)) + return + end + + -- Check weight limit (if enabled) + if maxWeight > 0 and wouldBeWeight > maxWeight then + -- Weight capacity exceeded + local aircraftType = _getUnitType(unit) or 'aircraft' + _msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s', + math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType)) + return + end + + -- ADD to existing troops or create new entry + if currentTroops then + -- Add to existing load (supports mixing types) + local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } + table.insert(troopTypes, { typeKey = requestedType, count = troopCount }) + + CTLD._troopsLoaded[gname] = { + count = totalTroopCount, + typeKey = 'Mixed', -- Indicate mixed types + troopTypes = troopTypes, -- Store individual type details + weightKg = currentTroops.weightKg + troopWeight, + } + self:_refreshLoadedTroopSummaryForGroup(gname) + _eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount }) + _msgGroup(group, string.format('Loaded %d more troops (total: %d)', troopCount, totalTroopCount)) + else + CTLD._troopsLoaded[gname] = { + count = troopCount, + typeKey = requestedType, + troopTypes = { { typeKey = requestedType, count = troopCount } }, + weightKg = troopWeight, + } + self:_refreshLoadedTroopSummaryForGroup(gname) + _eventSend(self, group, nil, 'troops_loaded', { count = troopCount }) + end + + -- Update DCS internal cargo weight + self:_updateCargoWeight(group) +end + +function CTLD:UnloadTroops(group, opts) + local gname = group:GetName() + local load = CTLD._troopsLoaded[gname] + if not load or (load.count or 0) == 0 then _eventSend(self, group, nil, 'no_troops', {}) return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + -- Check for MEDEVAC crew delivery to MASH first + if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then + local medevacStatus = self:CheckMEDEVACDelivery(group, load) + if medevacStatus == 'delivered' then + -- Crew delivered to MASH, clear troops and return + CTLD._troopsLoaded[gname] = nil + CTLD._loadedTroopTypes[gname] = nil + + -- Update DCS internal cargo weight after delivery + self:_updateCargoWeight(group) + + return + elseif medevacStatus == 'pending' then + return + end + end + + -- Determine if unit is in the air and check for fast-rope capability + local isInAir = _isUnitInAir(unit) + local canFastRope = false + local isFastRope = false + + if isInAir then + -- Unit is airborne - check if fast-rope is enabled and if altitude is safe + if self.Config.EnableFastRope then + local p3 = unit:GetPointVec3() + local ground = land and land.getHeight and land.getHeight({x = p3.x, y = p3.z}) or 0 + local agl = p3.y - ground + local maxFastRopeAGL = self.Config.FastRopeMaxHeight or 20 + local minFastRopeAGL = self.Config.FastRopeMinHeight or 5 + + if agl > maxFastRopeAGL then + -- Too high for fast-rope + local isMetric = _getPlayerIsMetric(unit) + local aglDisplay = _fmtAGL(agl, isMetric) + _eventSend(self, group, nil, 'troop_unload_altitude_too_high', { + max_agl = math.floor(maxFastRopeAGL), + current_agl = math.floor(agl) + }) + return + elseif agl < minFastRopeAGL then + -- Too low for safe fast-rope + _eventSend(self, group, nil, 'troop_unload_altitude_too_low', { + min_agl = math.floor(minFastRopeAGL), + current_agl = math.floor(agl) + }) + return + else + -- Within safe fast-rope window + canFastRope = true + isFastRope = true + end + else + -- Fast-rope disabled - must land + _msgGroup(group, "Must land to deploy troops. Fast-rope is disabled.", 10) + return + end + end + + -- Restrict deploying troops inside Pickup Zones if configured + if self.Config.ForbidTroopDeployInsidePickupZones then + local activeOnly = (self.Config.ForbidChecksActivePickupOnly ~= false) + local inside = false + local ok, _ = pcall(function() + inside = select(1, self:_isUnitInsidePickupZone(unit, activeOnly)) + end) + if ok and inside then + _eventSend(self, group, nil, 'troop_deploy_forbidden_in_pickup', {}) + return + end + end + + local p = unit:GetPointVec3() + local here = { x = p.x, z = p.z } + local hdgRad, _ = _headingRadDeg(unit) + -- Offset troop spawn forward to avoid spawning under/near rotors + local troopOffset = math.max(0, tonumber(self.Config.TroopSpawnOffset or 0) or 0) + local center = (troopOffset > 0) and { x = here.x + math.sin(hdgRad) * troopOffset, z = here.z + math.cos(hdgRad) * troopOffset } or { x = here.x, z = here.z } + + -- Build the unit composition - handle mixed troop types + local units = {} + local spacing = 1.8 + local unitIndex = 0 + + if load.troopTypes then + -- Mixed types - spawn each type's units + for _, troopTypeData in ipairs(load.troopTypes) do + local comp, _ = self:_resolveTroopUnits(troopTypeData.typeKey) + for i=1, #comp do + unitIndex = unitIndex + 1 + local dx = (unitIndex-1) * spacing + local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0 + table.insert(units, { + type = tostring(comp[i] or 'Infantry AK'), + name = string.format('CTLD-TROOP-%d', math.random(100000,999999)), + x = center.x + dx, y = center.z + dz, heading = hdgRad + }) + end + end + else + -- Single type (legacy support) + local comp, _ = self:_resolveTroopUnits(load.typeKey) + for i=1, #comp do + unitIndex = unitIndex + 1 + local dx = (unitIndex-1) * spacing + local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0 + table.insert(units, { + type = tostring(comp[i] or 'Infantry AK'), + name = string.format('CTLD-TROOP-%d', math.random(100000,999999)), + x = center.x + dx, y = center.z + dz, heading = hdgRad + }) + end + end + + local groupData = { + visible=false, lateActivation=false, tasks={}, task='Ground Nothing', + units=units, route={}, name=string.format('CTLD_TROOPS_%d', math.random(100000,999999)) + } + local spawned = _coalitionAddGroup(self.Side, Group.Category.GROUND, groupData, self.Config) + if spawned then + -- Track deployed troop groups for later pickup + local troopGroupName = spawned:getName() + CTLD._deployedTroops[troopGroupName] = { + typeKey = load.typeKey, + count = load.count, + side = self.Side, + spawnTime = timer.getTime(), + point = { x = center.x, z = center.z }, + weightKg = load.weightKg or 0, + behavior = opts and opts.behavior or 'defend' + } + -- Add to spatial index for efficient hover pickup + _addToSpatialGrid(troopGroupName, CTLD._deployedTroops[troopGroupName], 'troops') + + CTLD._troopsLoaded[gname] = nil + CTLD._loadedTroopTypes[gname] = nil + + -- Update DCS internal cargo weight after unloading troops + self:_updateCargoWeight(group) + + -- Send appropriate message based on deployment method + if isFastRope then + local aircraftType = _getUnitType(unit) or 'aircraft' + _eventSend(self, nil, self.Side, 'troops_fast_roped_coalition', { + count = #units, + player = _playerNameFromGroup(group), + aircraft = aircraftType + }) + else + _eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) }) + end + + -- Assign optional behavior + local behavior = opts and opts.behavior or nil + _logDebug(string.format("TROOP DEPLOY: Group '%s' spawned with behavior='%s'", + spawned:getName(), tostring(behavior))) + + if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then + _logDebug(string.format("TROOP DEPLOY: Initiating attack behavior for '%s'", spawned:getName())) + local t = self:_assignAttackBehavior(spawned:getName(), center, false) + -- Announce intentions globally + local isMetric = _getPlayerIsMetric(group:GetUnit(1)) + if t and t.kind == 'base' then + local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) + elseif t and t.kind == 'enemy' then + local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z }) + local v, u = _fmtRange(t.dist or 0, isMetric) + _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) + else + local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000, isMetric) + _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) + end + end + else + _eventSend(self, group, nil, 'troops_deploy_failed', { reason = 'DCS group spawn error' }) + end +end +-- #endregion Troops + +-- Internal: resolve troop composition list for a given type key and coalition +function CTLD:_resolveTroopUnits(typeKey) + local tcfg = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} + local def = tcfg[typeKey or 'AS'] or {} + + -- Log warning if troop types are missing + if not def or not def.size then + _logError(string.format('WARNING: Troop type "%s" not found or incomplete. TroopTypes table has %d entries.', + typeKey or 'AS', + (tcfg and type(tcfg) == 'table') and #tcfg or 0)) + end + + local size = tonumber(def.size or 0) or 0 + if size <= 0 then size = 6 end + local pool + if self.Side == coalition.side.BLUE then + pool = def.unitsBlue or def.units + elseif self.Side == coalition.side.RED then + pool = def.unitsRed or def.units + else + pool = def.units + end + if not pool or #pool == 0 then pool = { 'Infantry AK' } end + + -- Debug: Log what units will spawn + local unitList = {} + for i=1,math.min(size, 3) do + table.insert(unitList, pool[((i-1) % #pool) + 1]) + end + _logDebug(string.format('Spawning %d troops for type "%s": %s%s', + size, + typeKey or 'AS', + table.concat(unitList, ', '), + size > 3 and '...' or '')) + + local list = {} + for i=1,size do list[i] = pool[((i-1) % #pool) + 1] end + local label = def.label or typeKey or 'Troops' + return list, label +end + +-- ========================= +-- Public helpers +-- ========================= +-- ========================= +-- Auto-build FOB in zones +-- ========================= +-- #region Auto-build FOB in zones +function CTLD:AutoBuildFOBCheck() + if not (self.FOBZones and #self.FOBZones > 0) then return end + -- Find any FOB recipe definitions + local fobDefs = {} + for key,def in pairs(self.Config.CrateCatalog) do if def.isFOB and def.build then fobDefs[key] = def end end + if next(fobDefs) == nil then return end + + for _,zone in ipairs(self.FOBZones) do + local center = zone:GetPointVec3() + local radius = self:_getZoneRadius(zone) + local nearby = self:GetNearbyCrates({ x = center.x, z = center.z }, radius) + -- filter to this coalition side + local filtered = {} + for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end + nearby = filtered + + if #nearby > 0 then + local counts = {} + for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end + + local function consumeCrates(key, qty) + local removed = 0 + for _,c in ipairs(nearby) do + if removed >= qty then break end + if c.meta.key == key then + local obj = StaticObject.getByName(c.name) + if obj then obj:destroy() end + _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule + CTLD._crates[c.name] = nil + removed = removed + 1 + end + end + end + + local built = false + + -- Prefer composite recipes + for recipeKey,cat in pairs(fobDefs) do + if type(cat.requires) == 'table' then + local ok = true + for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < qty then ok = false; break end end + if ok then + local gdata = cat.build({ x = center.x, z = center.z }, 0, cat.side or self.Side) + local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) + if g then + for reqKey,qty in pairs(cat.requires) do consumeCrates(reqKey, qty) end + _msgCoalition(self.Side, string.format('FOB auto-built at %s', zone:GetName())) + built = true + break -- move to next zone; avoid multiple builds per tick + end + end + end + end + + -- Then single-key FOB recipes + if not built then + for key,cat in pairs(fobDefs) do + if not cat.requires and (counts[key] or 0) >= (cat.required or 1) then + local gdata = cat.build({ x = center.x, z = center.z }, 0, cat.side or self.Side) + local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) + if g then + consumeCrates(key, cat.required or 1) + _msgCoalition(self.Side, string.format('FOB auto-built at %s', zone:GetName())) + built = true + break + end + end + end + end + -- next zone iteration continues automatically + end + end +end +-- #endregion Auto-build FOB in zones + +-- ========================= +-- Public helpers +-- ========================= +-- #region Public helpers +function CTLD:RegisterCrate(key, def) + self.Config.CrateCatalog[key] = def +end + +function CTLD:MergeCatalog(tbl) + for k,v in pairs(tbl or {}) do self.Config.CrateCatalog[k] = v end +end + +-- ========================= +-- Inventory helpers +-- ========================= +-- #region Inventory helpers +function CTLD:InitInventory() + if not (self.Config.Inventory and self.Config.Inventory.Enabled) then return end + -- Seed stock for each configured pickup zone (by name only) + for _,z in ipairs(self.PickupZones or {}) do + local name = z:GetName() + self:_SeedZoneStock(name, 1.0) + end +end + +function CTLD:_SeedZoneStock(zoneName, factor) + if not zoneName then return end + CTLD._stockByZone[zoneName] = CTLD._stockByZone[zoneName] or {} + local f = factor or 1.0 + for key,def in pairs(self.Config.CrateCatalog or {}) do + local n = tonumber(def.initialStock or 0) or 0 + n = math.max(0, math.floor(n * f + 0.0001)) + -- Only seed if not already present (avoid overwriting saved/progress state) + if CTLD._stockByZone[zoneName][key] == nil then + CTLD._stockByZone[zoneName][key] = n + end + end +end + +function CTLD:_CreateFOBPickupZone(point, cat, hdg) + -- Create a small pickup zone at the FOB to act as a supply point + local name = string.format('FOB_PZ_%d', math.random(100000,999999)) + local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(point.x, point.z) or { x = point.x, y = point.z } + local r = 150 + local z = ZONE_RADIUS:New(name, v2, r) + table.insert(self.PickupZones, z) + self._ZoneDefs.PickupZones[name] = { name = name, radius = r, active = true } + self._ZoneActive.Pickup[name] = true + table.insert(self.Config.Zones.PickupZones, { name = name, radius = r, active = true }) + -- Seed FOB stock at fraction of initial pickup stock + local f = (self.Config.Inventory and self.Config.Inventory.FOBStockFactor) or 0.25 + self:_SeedZoneStock(name, f) + _msgCoalition(self.Side, string.format('FOB supply established: %s (stock seeded at %d%%)', name, math.floor(f*100+0.5))) + -- Auto-refresh map drawings so the new FOB pickup zone is visible immediately + if self.Config.MapDraw and self.Config.MapDraw.Enabled then + local ok, err = pcall(function() self:DrawZonesOnMap() end) + if not ok then + _logError(string.format('DrawZonesOnMap failed after FOB creation: %s', tostring(err))) + end + end +end +-- #endregion Inventory helpers + +-- ========================= +-- FARP System +-- ========================= +-- #region FARP + +-- Initialize FARP system (called from CTLD:New) +function CTLD:InitFARP() + if not (CTLD.FARPConfig and CTLD.FARPConfig.Enabled) then return end + _logInfo('FARP system initialized') +end + +-- Get FARP data for a FOB zone +function CTLD:GetFARPData(zoneName) + if not zoneName then return nil end + return CTLD._farpData[zoneName] +end + +-- Find nearest FOB pickup zone to a point +function CTLD:FindNearestFOBZone(point) + local nearestZone = nil + local nearestDist = math.huge + + for _, zone in ipairs(self.PickupZones or {}) do + local zname = zone:GetName() + -- Check if this is a FOB zone (starts with FOB_PZ_) + if zname and zname:match('^FOB_PZ_') then + local zoneCenter = zone:GetVec2() + local dist = ((point.x - zoneCenter.x)^2 + (point.z - zoneCenter.y)^2)^0.5 + local radius = self:_getZoneRadius(zone) + + if dist < (radius + 50) and dist < nearestDist then + nearestZone = zone + nearestDist = dist + end + end + end + + return nearestZone, nearestDist +end + +-- Spawn static objects for a FARP stage +function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) + if not (CTLD.FARPConfig and CTLD.FARPConfig.StageLayouts[stage]) then + _logError(string.format('Invalid FARP stage %d or missing layout config', stage)) + return false + end + + local layout = CTLD.FARPConfig.StageLayouts[stage] + local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = coalition } + + _logInfo(string.format('Spawning FARP Stage %d statics for zone %s (coalition %d)', stage, zoneName, coalition)) + + -- Get coalition name for DCS + -- Note: 'coalition' parameter is a number (1=red, 2=blue), not the coalition table + local coalitionName = (coalition == 2) and 'blue' or 'red' + + for _, obj in ipairs(layout) do + -- Calculate world position from relative offset + local worldX = centerPoint.x + obj.x + local worldZ = centerPoint.z + obj.z + local worldY = land.getHeight({x = worldX, y = worldZ}) + + -- Generate unique name + local staticName = string.format('FARP_%s_S%d_%s_%d', zoneName, stage, obj.type:gsub('%s+', '_'), math.random(10000, 99999)) + + -- Create static object data + local staticData = { + ["type"] = obj.type, + ["name"] = staticName, + ["heading"] = math.rad(obj.heading or 0), + ["x"] = worldX, + ["y"] = worldZ, + ["category"] = "Fortifications", + ["canCargo"] = false, + ["shape_name"] = "", + ["rate"] = 100, + } + + -- Spawn the static + local success, staticObj = pcall(function() + return coalition.addStaticObject(coalition, staticData) + end) + + if success and staticObj then + table.insert(farpData.statics, staticName) + _logDebug(string.format('Spawned FARP static: %s at (%.1f, %.1f)', staticName, worldX, worldZ)) + else + _logError(string.format('Failed to spawn FARP static: %s (%s)', obj.type, tostring(staticObj))) + end + end + + farpData.stage = stage + farpData.coalition = coalition + CTLD._farpData[zoneName] = farpData + + _logInfo(string.format('FARP Stage %d complete for zone %s - spawned %d statics', stage, zoneName, #farpData.statics)) + return true +end + +-- Upgrade a FOB to the next FARP stage +function CTLD:UpgradeFARP(group, zoneName) + if not (CTLD.FARPConfig and CTLD.FARPConfig.Enabled) then + MESSAGE:New('FARP system is disabled.', 10):ToGroup(group) + return + end + + local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = self.Side } + local currentStage = farpData.stage or 0 + local nextStage = currentStage + 1 + + -- Check if already maxed + if nextStage > 3 then + _eventSend(self, group, nil, 'farp_already_maxed', {}) + return + end + + -- Get upgrade cost + local upgradeCost = CTLD.FARPConfig.StageCosts[nextStage] + if not upgradeCost then + MESSAGE:New(string.format('Invalid FARP stage %d', nextStage), 10):ToGroup(group) + return + end + + -- Check salvage points + local currentSalvage = CTLD._salvagePoints[self.Side] or 0 + if currentSalvage < upgradeCost then + _eventSend(self, group, nil, 'farp_upgrade_insufficient_salvage', { + stage = nextStage, + need = upgradeCost, + current = currentSalvage + }) + return + end + + -- Find the zone to get center point + local zone = nil + for _, z in ipairs(self.PickupZones or {}) do + if z:GetName() == zoneName then + zone = z + break + end + end + + if not zone then + MESSAGE:New('FOB zone not found!', 10):ToGroup(group) + return + end + + local center = zone:GetVec2() + local centerPoint = { x = center.x, z = center.y } + + -- Deduct salvage + CTLD._salvagePoints[self.Side] = currentSalvage - upgradeCost + + -- Spawn statics for this stage + _eventSend(self, group, nil, 'farp_upgrade_started', { stage = nextStage }) + + local success = self:SpawnFARPStatics(zoneName, nextStage, centerPoint, self.Side) + + if success then + -- Determine services available + local services = {} + if nextStage >= 1 then table.insert(services, 'Landing Zone') end + if nextStage >= 2 then table.insert(services, 'Refuel') end + if nextStage >= 3 then + table.insert(services, 'Rearm') + table.insert(services, 'Repair') + end + + _eventSend(self, nil, self.Side, 'farp_upgrade_complete', { + player = _playerNameFromGroup(group), + stage = nextStage, + services = table.concat(services, ', ') + }) + + -- Create or update FARP service zone + self:CreateFARPServiceZone(zoneName, centerPoint, nextStage) + + _logInfo(string.format('%s upgraded FOB %s to FARP Stage %d (cost: %d salvage)', + _playerNameFromGroup(group), zoneName, nextStage, upgradeCost)) + else + -- Refund salvage on failure + CTLD._salvagePoints[self.Side] = currentSalvage + MESSAGE:New('FARP upgrade failed! Salvage refunded.', 15):ToGroup(group) + end +end + +-- Create FARP service zone for rearm/refuel +function CTLD:CreateFARPServiceZone(zoneName, centerPoint, stage) + if stage < 2 then return end -- Only stages 2+ have services + + local radius = CTLD.FARPConfig.ServiceRadius[stage] or 50 + local farpZoneName = string.format('%s_FARP_Service', zoneName) + + -- Create zone + local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(centerPoint.x, centerPoint.z) or { x = centerPoint.x, y = centerPoint.z } + local serviceZone = ZONE_RADIUS:New(farpZoneName, v2, radius) + + CTLD._farpZones[farpZoneName] = { + zone = serviceZone, + side = self.Side, + stage = stage, + parentFOB = zoneName + } + + -- Start service scheduler + self:StartFARPServices(farpZoneName) + + _logInfo(string.format('Created FARP service zone %s (radius: %dm, stage: %d)', farpZoneName, radius, stage)) +end + +-- Start FARP service scheduler +function CTLD:StartFARPServices(farpZoneName) + local farpInfo = CTLD._farpZones[farpZoneName] + if not farpInfo then return end + + local selfref = self + + -- Service scheduler runs every 5 seconds + SCHEDULER:New(nil, function() + local zone = farpInfo.zone + if not zone then return end + + local stage = farpInfo.stage + local units = zone:GetScannedUnits() + + for _, unit in ipairs(units or {}) do + if unit and unit:IsAlive() then + local unitCoalition = unit:GetCoalition() + + -- Only service friendly units + if unitCoalition == farpInfo.side then + local unitType = unit:GetTypeName() + local group = unit:GetGroup() + + -- Service helicopters and ground vehicles + if group and (unit:IsHelicopter() or unit:IsGround()) then + -- Stage 2+: Refuel + if stage >= 2 then + -- Trigger refuel (DCS built-in command) + pcall(function() + local controller = unit:GetUnit():getController() + if controller then + controller:setCommand({ + id = 'RefuelInFlight', + params = {} + }) + end + end) + end + + -- Stage 3: Rearm and Repair + if stage >= 3 then + pcall(function() + local dcsUnit = unit:GetUnit() + if dcsUnit then + -- Note: DCS doesn't have direct Lua API for ground rearm/repair + -- This simulates the presence of the service zone + -- In practice, DCS may auto-service units near FARP statics + end + end) + end + end + end + end + end + end, {}, 0, 5) -- Start immediately, repeat every 5 seconds + + _logDebug(string.format('FARP service scheduler started for %s', farpZoneName)) +end + +-- Show FARP status for nearby FOB +function CTLD:ShowFARPStatus(group) + local unit = group:GetUnit(1) + if not unit then return end + + local pos = unit:GetVec3() + local point = { x = pos.x, z = pos.z } + + local fobZone, dist = self:FindNearestFOBZone(point) + + if not fobZone then + _eventSend(self, group, nil, 'farp_not_at_fob', {}) + return + end + + local zoneName = fobZone:GetName() + local farpData = CTLD._farpData[zoneName] or { stage = 0 } + local currentStage = farpData.stage or 0 + + if currentStage >= 3 then + -- Fully upgraded + local services = 'Landing Zone, Refuel, Rearm, Repair' + _eventSend(self, group, nil, 'farp_status_maxed', { + stage = currentStage, + max_stage = 3, + services = services + }) + elseif currentStage > 0 then + -- Partially upgraded + local services = {} + if currentStage >= 1 then table.insert(services, 'Landing Zone') end + if currentStage >= 2 then table.insert(services, 'Refuel') end + if currentStage >= 3 then + table.insert(services, 'Rearm') + table.insert(services, 'Repair') + end + + local nextStage = currentStage + 1 + local nextCost = CTLD.FARPConfig.StageCosts[nextStage] or 0 + + _eventSend(self, group, nil, 'farp_status', { + stage = currentStage, + max_stage = 3, + services = table.concat(services, ', '), + next_cost = nextCost, + next_stage = nextStage + }) + else + -- Base FOB, not yet upgraded + local nextCost = CTLD.FARPConfig.StageCosts[1] or 0 + MESSAGE:New(string.format('FOB Status: Base FOB (not upgraded)\nUpgrade to FARP Stage 1 for %d salvage points.\n\nCurrent salvage: %d', + nextCost, CTLD._salvagePoints[self.Side] or 0), 15):ToGroup(group) + end +end + +-- Request FARP upgrade from menu +function CTLD:RequestFARPUpgrade(group) + local unit = group:GetUnit(1) + if not unit then return end + + local pos = unit:GetVec3() + local point = { x = pos.x, z = pos.z } + + local fobZone, dist = self:FindNearestFOBZone(point) + + if not fobZone then + _eventSend(self, group, nil, 'farp_not_at_fob', {}) + return + end + + local zoneName = fobZone:GetName() + self:UpgradeFARP(group, zoneName) +end + +-- #endregion FARP + +-- ========================= +-- Sling-Load Salvage - Manual Crate Support +-- ========================= +-- #region Manual Salvage Crates + +-- Scan mission editor for pre-placed cargo statics and register them as salvage +function CTLD:ScanAndRegisterManualSalvageCrates() + local cfg = self.Config.SlingLoadSalvage + if not (cfg and cfg.Enabled and cfg.EnableManualCrates) then return end + + local prefix = cfg.ManualCratePrefix or 'SALVAGE-' + local registered = 0 + + _logInfo('[ManualSalvage] Scanning for pre-placed salvage crates...') + + -- Get all static objects in the mission + local allStatics = {} + for _, coalitionSide in pairs({coalition.side.BLUE, coalition.side.RED, coalition.side.NEUTRAL}) do + local groups = coalition.getStaticObjects(coalitionSide) or {} + for _, static in pairs(groups) do + table.insert(allStatics, {obj = static, side = coalitionSide}) + end + end + + for _, staticData in ipairs(allStatics) do + local static = staticData.obj + local staticSide = staticData.side + + if static and static:isExist() then + local staticName = static:getName() + + -- Check if name starts with salvage prefix + if staticName and staticName:sub(1, #prefix) == prefix then + -- Check if it's a slingloadable cargo type + local typeName = static:getTypeName() + local isCargo = false + for _, cargoType in ipairs(cfg.CargoTypes or {}) do + if typeName == cargoType then + isCargo = true + break + end + end + + if isCargo then + -- Parse the name to extract information + -- Expected format: SALVAGE-{B|R}-{WEIGHT}KG-{ID} + -- Example: SALVAGE-B-2000KG-CRASH01 + local sideChar, weightStr, id = staticName:match('^SALVAGE%-([BR])%-(%d+)KG%-(.+)$') + + if sideChar and weightStr then + local collectingSide = (sideChar == 'B') and coalition.side.BLUE or coalition.side.RED + local weight = tonumber(weightStr) or 1000 + + -- Calculate reward based on weight class + local rewardPer500kg = 3 -- default medium rate + for _, wc in ipairs(cfg.WeightClasses or {}) do + if weight >= wc.min and weight <= wc.max then + rewardPer500kg = wc.rewardPer500kg or 3 + break + end + end + local rewardValue = math.floor((weight / 500) * rewardPer500kg) + + -- Get position + local pos = static:getPoint() + local position = {x = pos.x, z = pos.z} + + -- Register the crate (no expiration for manual crates) + CTLD._salvageCrates[staticName] = { + side = collectingSide, + weight = weight, + spawnTime = timer.getTime(), + position = position, + initialHealth = 1.0, + rewardValue = rewardValue, + warningsSent = {}, + staticObject = static, + crateClass = 'Manual', + isManual = true, -- Flag to skip expiration checks + } + + registered = registered + 1 + _logInfo(string.format('[ManualSalvage] Registered: %s (Side=%s, Weight=%dkg, Reward=%dpts)', + staticName, sideChar, weight, rewardValue)) + else + _logVerbose(string.format('[ManualSalvage] Skipping %s - invalid name format (use: SALVAGE-{B|R}-####KG-ID)', staticName)) + end + else + _logVerbose(string.format('[ManualSalvage] Skipping %s - not a cargo type (found: %s)', staticName, tostring(typeName))) + end + end + end + end + + if registered > 0 then + _logInfo(string.format('[ManualSalvage] Registered %d manual salvage crate(s)', registered)) + _msgCoalition(self.Side, _fmtTemplate(self.Messages.slingload_manual_crates_registered, {count = registered})) + else + _logInfo('[ManualSalvage] No manual salvage crates found') + end +end + +-- #endregion Manual Salvage Crates + +-- ========================= +-- MEDEVAC System +-- ========================= +-- #region MEDEVAC + +-- Initialize MEDEVAC system (called from CTLD:New) +function CTLD:InitMEDEVAC() + if not (CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled) then return end + + -- Initialize salvage pools + if CTLD.MEDEVAC.Salvage and CTLD.MEDEVAC.Salvage.Enabled then + CTLD._salvagePoints[self.Side] = CTLD._salvagePoints[self.Side] or 0 + end + + -- Setup event handler for unit deaths + local handler = EVENTHANDLER:New() + handler:HandleEvent(EVENTS.Dead) + local selfref = self + + function handler:OnEventDead(eventData) + -- First check if this is an invulnerable MEDEVAC crew member that needs respawning + local unit = eventData.IniUnit + if unit then + local unitName = unit:GetName() + if unitName then + for crewGroupName, crewData in pairs(CTLD._medevacCrews) do + if unitName:find(crewGroupName, 1, true) then + local now = timer.getTime() + if crewData.invulnerable and now < crewData.invulnerableUntil then + _logVerbose(string.format('[MEDEVAC] Invulnerable crew member %s killed, respawning...', unitName)) + -- Respawn this crew member + local id = timer.scheduleFunction(function() + local grp = Group.getByName(crewGroupName) + if grp and grp:isExist() then + local cfg = CTLD.MEDEVAC + local crewUnitType = cfg.CrewUnitTypes[crewData.side] or ((crewData.side == coalition.side.BLUE) and 'Soldier M4' or 'Paratrooper RPG-16') + -- Use the stored country ID from the original spawn + local countryId = crewData.countryId or ((crewData.side == coalition.side.BLUE) and (country.id.USA or 2) or 18) + + -- Random position near spawn point + local angle = math.random() * 2 * math.pi + local radius = 3 + math.random() * 5 + local spawnX = crewData.position.x + math.cos(angle) * radius + local spawnZ = crewData.position.z + math.sin(angle) * radius + + local newUnitData = { + type = crewUnitType, + name = unitName..'_respawn', + x = spawnX, + y = spawnZ, + heading = math.random() * 2 * math.pi + } + + coalition.addGroup(crewData.side, Group.Category.GROUND, { + visible = false, + lateActivation = false, + tasks = {}, + task = 'Ground Nothing', + route = {}, + units = {newUnitData}, + name = unitName..'_respawn_grp', + country = countryId + }) + + _logVerbose(string.format('[MEDEVAC] Respawned invulnerable crew member %s', unitName)) + end + end, nil, timer.getTime() + 1) + _trackOneShotTimer(id) + return -- Don't process as normal death + end + end + end + end + + -- Next check if this death wiped out an active MEDEVAC crew group + -- Be defensive: unit may be a Moose wrapper or raw DCS unit, and + -- not all objects will expose a "GetGroup" method. + local dcsGroup = (unit and unit.GetGroup and unit:GetGroup()) or (unit and unit.getGroup and unit:getGroup()) or nil + if dcsGroup then + local gName = dcsGroup:GetName() + if gName and CTLD._medevacCrews[gName] then + local anyAlive = false + local units = dcsGroup:GetUnits() + if units then + for _, u in ipairs(units) do + if u and u:IsAlive() then + anyAlive = true + break + end + end + end + if not anyAlive then + selfref:_RemoveMEDEVACCrew(gName, 'killed') + return + end + end + end + end + + -- Normal death processing for vehicle spawning MEDEVAC crews + if not unit then + _logDebug('[MEDEVAC] OnEventDead: No unit in eventData') + return + end + + -- Get the underlying DCS unit to safely extract data + local dcsUnit = unit.DCSUnit or unit + if not dcsUnit then + _logDebug('[MEDEVAC] OnEventDead: No DCS unit') + return + end + + -- Extract coalition from event data if available, otherwise from unit + local unitCoalition = eventData.IniCoalition + if not unitCoalition and unit and unit.GetCoalition then + local success, result = pcall(function() return unit:GetCoalition() end) + if success then + unitCoalition = result + end + end + + if not unitCoalition then + _logDebug('[MEDEVAC] OnEventDead: Could not determine coalition') + return + end + + if unitCoalition ~= selfref.Side then + _logDebug(string.format('[MEDEVAC] OnEventDead: Wrong coalition (unit: %s, CTLD: %s)', tostring(unitCoalition), tostring(selfref.Side))) + return + end + + -- Extract category from event data if available + local unitCategory = eventData.IniCategory or (unit.GetCategory and unit:GetCategory()) + if not unitCategory then + _logDebug('[MEDEVAC] OnEventDead: Could not determine category') + return + end + + if unitCategory ~= Unit.Category.GROUND_UNIT then + _logDebug(string.format('[MEDEVAC] OnEventDead: Not a ground unit (category: %s)', tostring(unitCategory))) + return + end + + -- Extract unit type name + local unitType = eventData.IniTypeName or (unit.GetTypeName and unit:GetTypeName()) + if not unitType then + _logDebug('[MEDEVAC] OnEventDead: Could not determine unit type') + return + end + + _logVerbose(string.format('[MEDEVAC] OnEventDead: Ground unit destroyed - %s', unitType)) + + -- Check if this unit type is eligible for MEDEVAC + local catalogEntry = selfref:_FindCatalogEntryByUnitType(unitType) + + if catalogEntry and catalogEntry.MEDEVAC == true then + _logVerbose(string.format('[MEDEVAC] OnEventDead: %s is MEDEVAC eligible, spawning crew', unitType)) + -- Pass eventData instead of unit to get position/heading safely + selfref:_SpawnMEDEVACCrew(eventData, catalogEntry) + else + if catalogEntry then + _logDebug(string.format('[MEDEVAC] OnEventDead: %s found in catalog but MEDEVAC=%s', unitType, tostring(catalogEntry.MEDEVAC))) + else + _logDebug(string.format('[MEDEVAC] OnEventDead: %s not found in catalog', unitType)) + end + end + + -- Sling-Load Salvage: Check if we should spawn a salvage crate for the OPPOSING coalition + if selfref.Config.SlingLoadSalvage and selfref.Config.SlingLoadSalvage.Enabled then + -- Get unit position + local unitPos = nil + if eventData.initiator and eventData.initiator.getPoint then + local success, point = pcall(function() return eventData.initiator:getPoint() end) + if success and point then + unitPos = point + end + end + + if unitPos then + -- Determine enemy coalition (who can collect this salvage) + local enemySide = (selfref.Side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + selfref:_SpawnSlingLoadSalvageCrate(unitPos, unitType, enemySide, eventData) + else + _logDebug('[SlingLoadSalvage] Could not get unit position for salvage spawn') + end + end + end + + self.MEDEVACHandler = handler + + -- Add hit event handler to prevent damage to invulnerable crews + local hitHandler = EVENTHANDLER:New() + hitHandler:HandleEvent(EVENTS.Hit) + + function hitHandler:OnEventHit(eventData) + local unit = eventData.TgtUnit + if not unit then return end + + local unitName = unit:GetName() + if not unitName then return end + + -- Check if this unit belongs to an invulnerable MEDEVAC crew + for crewGroupName, crewData in pairs(CTLD._medevacCrews) do + if unitName:find(crewGroupName, 1, true) then + -- This unit is part of a MEDEVAC crew, check invulnerability + local now = timer.getTime() + if crewData.invulnerable and now < crewData.invulnerableUntil then + _logVerbose(string.format('[MEDEVAC] Unit %s is invulnerable, preventing damage', unitName)) + -- Can't directly prevent damage in DCS, but log it + -- Infantry is fragile anyway, so invulnerability is more of a "hope they survive" thing + return + end + end + end + end + + self.MEDEVACHitHandler = hitHandler + + -- Start crew timeout checker (runs every 30 seconds) + self.MEDEVACSched = SCHEDULER:New(nil, function() + local ok, err = pcall(function() selfref:_CheckMEDEVACTimeouts() end) + if not ok then _logError('MEDEVAC timeout scheduler error: '..tostring(err)) end + end, {}, 30, 30) + + -- Sling-load salvage is handled by adaptive background loop + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + _logInfo('Sling-Load Salvage system initialized for coalition '..tostring(self.Side)) + end + + -- Initialize MASH zones from config + self:_InitMASHZones() + + _logInfo('MEDEVAC system initialized for coalition '..tostring(self.Side)) +end + +-- Find catalog entry that spawns a given unit type +function CTLD:_FindCatalogEntryByUnitType(unitType) + local catalog = self.Config.CrateCatalog or {} + local catalogSize = 0 + for _ in pairs(catalog) do catalogSize = catalogSize + 1 end + + _logDebug(string.format('[MEDEVAC] Searching catalog for unit type: %s (catalog has %d entries)', unitType, catalogSize)) + + for key, def in pairs(catalog) do + -- Check if this catalog entry builds the unit type + if def.build then + -- Check global lookup table that maps build functions to unit types + if type(def.build) == 'function' and _CTLD_BUILD_UNIT_TYPES and _CTLD_BUILD_UNIT_TYPES[def.build] then + local buildUnitType = _CTLD_BUILD_UNIT_TYPES[def.build] + _logDebug(string.format('[MEDEVAC] Catalog entry %s has unitType=%s (from global lookup)', key, tostring(buildUnitType))) + if buildUnitType == unitType then + _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via global lookup: key=%s', unitType, key)) + return def + end + end + + -- Fallback: Try to extract unit type from build function string (legacy compatibility) + local buildStr = tostring(def.build) + if buildStr:find(unitType, 1, true) then + _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via string search: key=%s', unitType, key)) + return def + end + else + _logDebug(string.format('[MEDEVAC] Catalog entry %s has no build function', key)) + end + + -- Also check if catalog entry has a unitType field directly + if def.unitType and def.unitType == unitType then + _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via def.unitType field: key=%s', unitType, key)) + return def + end + end + + _logDebug(string.format('[MEDEVAC] No catalog entry found for unit type: %s', unitType)) + return nil +end + +-- Spawn MEDEVAC crew when vehicle destroyed +function CTLD:_SpawnMEDEVACCrew(eventData, catalogEntry) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + -- Probability check: does the crew survive to request rescue? + -- Use coalition-specific survival chance + local survivalChance = 0.02 -- default fallback + if cfg.CrewSurvivalChance then + if type(cfg.CrewSurvivalChance) == 'table' then + -- Per-coalition config + survivalChance = cfg.CrewSurvivalChance[self.Side] or 0.02 + else + -- Legacy single value config (backward compatibility) + survivalChance = cfg.CrewSurvivalChance + end + end + + local roll = math.random() + if roll > survivalChance then + -- Crew did not survive + _logVerbose(string.format('[MEDEVAC] Crew did not survive (roll: %.4f > %.4f)', roll, survivalChance)) + return + end + _logVerbose(string.format('[MEDEVAC] Crew survived! (roll: %.4f <= %.4f) - will spawn in 5 minutes after battle clears', roll, survivalChance)) + + -- Extract data from eventData instead of calling methods on dead unit + local unit = eventData.IniUnit + local unitType = eventData.IniTypeName or (unit and unit.GetTypeName and unit:GetTypeName()) + local unitName = eventData.IniUnitName or (unit and unit.GetName and unit:GetName()) or 'Unknown' + + -- Get position - the unit is dead, so we need to get position from the DCS initiator object + local pos = nil + + -- Try the raw DCS initiator object (this should have the last known position) + if eventData.initiator then + _logVerbose('[MEDEVAC] Trying DCS initiator object') + local dcsUnit = eventData.initiator + if dcsUnit and dcsUnit.getPoint then + local success, point = pcall(function() return dcsUnit:getPoint() end) + if success and point then + pos = point + _logVerbose(string.format('[MEDEVAC] Got position from DCS initiator:getPoint(): %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) + end + end + if not pos and dcsUnit and dcsUnit.getPosition then + local success, position = pcall(function() return dcsUnit:getPosition() end) + if success and position and position.p then + pos = position.p + _logVerbose(string.format('[MEDEVAC] Got position from DCS initiator:getPosition().p: %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) + end + end + end + + -- Try IniDCSUnit + if not pos and eventData.IniDCSUnit then + _logVerbose('[MEDEVAC] Trying IniDCSUnit') + local dcsUnit = eventData.IniDCSUnit + if dcsUnit and dcsUnit.getPoint then + local success, point = pcall(function() return dcsUnit:getPoint() end) + if success and point then + pos = point + _logVerbose(string.format('[MEDEVAC] Got position from IniDCSUnit:getPoint(): %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) + end + end + end + + if not pos or not unitType then + _logVerbose(string.format('[MEDEVAC] Cannot spawn crew - missing position (pos=%s) or unit type (type=%s)', tostring(pos), tostring(unitType))) + return + end + + -- Get heading if possible + local heading = 0 + if unit and unit.GetHeading then + local success, result = pcall(function() return unit:GetHeading() end) + if success and result then + heading = result + end + end + + -- Determine crew size + local crewSize = catalogEntry.crewSize or cfg.CrewDefaultSize or 2 + + -- Determine salvage value + local salvageValue = catalogEntry.salvageValue + if not salvageValue then + salvageValue = catalogEntry.required or cfg.Salvage.DefaultValue or 1 + end + + -- Find nearest enemy to spawn crew toward them + local spawnPoint = { x = pos.x, z = pos.z } + local enemySide = (self.Side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + local nearestEnemy = self:_findNearestEnemyGround({ x = pos.x, z = pos.z }, 2000) -- 2km search + + if nearestEnemy and nearestEnemy.point then + -- Calculate direction toward enemy + local dx = nearestEnemy.point.x - pos.x + local dz = nearestEnemy.point.z - pos.z + local dist = math.sqrt(dx*dx + dz*dz) + if dist > 0 then + local dirX = dx / dist + local dirZ = dz / dist + local offset = cfg.CrewSpawnOffset or 15 + spawnPoint.x = pos.x + dirX * offset + spawnPoint.z = pos.z + dirZ * offset + end + else + -- No enemy found, spawn at random offset + local angle = math.random() * 2 * math.pi + local offset = cfg.CrewSpawnOffset or 15 + spawnPoint.x = pos.x + math.cos(angle) * offset + spawnPoint.z = pos.z + math.sin(angle) * offset + end + + -- Prepare spawn data but delay actual spawning by 5 minutes (300 seconds) + local spawnDelay = cfg.CrewSpawnDelay or 300 -- 5 minutes default + local selfref = self + + _logVerbose(string.format('[MEDEVAC] Crew will spawn in %d seconds after battle clears', spawnDelay)) + + local spawnTimerId = timer.scheduleFunction(function() + -- Now spawn the crew after battle has cleared + local crewGroupName = string.format('MEDEVAC_Crew_%s_%d', unitType, math.random(100000, 999999)) + local crewUnitType = catalogEntry.crewType or cfg.CrewUnitTypes[selfref.Side] or ((selfref.Side == coalition.side.BLUE) and 'Soldier M4' or 'Infantry AK') + + _logVerbose(string.format('[MEDEVAC] Coalition: %s, CrewUnitType selected: %s, catalogEntry.crewType=%s, cfg.CrewUnitTypes[side]=%s', + (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'), + crewUnitType, + tostring(catalogEntry.crewType), + tostring(cfg.CrewUnitTypes and cfg.CrewUnitTypes[selfref.Side]) + )) + + -- Determine if crew gets a MANPADS + -- Use coalition-specific MANPADS spawn chance + local manPadChance = 0.1 -- default fallback + if cfg.ManPadSpawnChance then + if type(cfg.ManPadSpawnChance) == 'table' then + -- Per-coalition config + manPadChance = cfg.ManPadSpawnChance[selfref.Side] or 0.1 + else + -- Legacy single value config (backward compatibility) + manPadChance = cfg.ManPadSpawnChance + end + end + local spawnManPad = math.random() <= manPadChance + local manPadIndex = nil + if spawnManPad and crewSize > 1 then + manPadIndex = math.random(1, crewSize) -- Random crew member gets the MANPADS + _logVerbose(string.format('[MEDEVAC] Crew will include MANPADS (unit %d of %d)', manPadIndex, crewSize)) + end + + -- Get country ID from the destroyed unit instead of trying to map coalition to country + -- This is the same approach used by the Medevac_KHASHURI.lua script + local countryId = nil + if eventData.initiator and eventData.initiator.getCountry then + local success, result = pcall(function() return eventData.initiator:getCountry() end) + if success and result then + countryId = result + _logVerbose(string.format('[MEDEVAC] Got country ID %d from destroyed unit', countryId)) + end + end + + -- Fallback if we couldn't get it from the unit + if not countryId then + _logVerbose('[MEDEVAC] WARNING: Could not get country from dead unit, using fallback') + if selfref.Side == coalition.side.BLUE then + countryId = country.id.USA or 2 + else + countryId = country.id.CJTF_RED or 18 -- Use CJTF RED as fallback + end + end + + _logVerbose(string.format('[MEDEVAC] Spawning crew now - coalition=%s, countryId=%d, crewUnitType=%s', + (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'), + countryId, + crewUnitType)) + + local groupData = { + visible = false, + lateActivation = false, + tasks = {}, + task = 'Ground Nothing', + route = {}, + units = {}, + name = crewGroupName + -- Country ID passed directly to coalition.addGroup(), not in groupData + } + + for i = 1, crewSize do + -- Randomize position within a small radius (3-8 meters) for natural scattered appearance + local angle = math.random() * 2 * math.pi + local radius = 3 + math.random() * 5 -- 3-8 meters from center + local offsetX = math.cos(angle) * radius + local offsetZ = math.sin(angle) * radius + + -- Determine unit type (MANPADS or regular crew) + local unitType = crewUnitType + if i == manPadIndex then + unitType = cfg.ManPadUnitTypes[selfref.Side] or crewUnitType + _logVerbose(string.format('[MEDEVAC] Unit %d assigned MANPADS type: %s', i, unitType)) + end + + table.insert(groupData.units, { + type = unitType, + name = string.format('%s_U%d', crewGroupName, i), + x = spawnPoint.x + offsetX, + y = spawnPoint.z + offsetZ, + heading = math.random() * 2 * math.pi -- Random heading for each unit + }) + end + + _logVerbose(string.format('[MEDEVAC] About to call coalition.addGroup with country=%d (coalition=%s)', + countryId, + (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'))) + + -- CRITICAL: First parameter is COUNTRY ID, not coalition ID! + -- This matches Medevac_KHASHURI.lua line 500: coalition.addGroup(_deadUnit:getCountry(), ...) + local crewGroup = coalition.addGroup(countryId, Group.Category.GROUND, groupData) + + if not crewGroup then + _logVerbose('[MEDEVAC] Failed to spawn crew') + return + end + + -- Double-check what coalition the spawned group actually belongs to + local spawnedCoalition = crewGroup:getCoalition() + _logVerbose(string.format('[MEDEVAC] Crew group %s spawned successfully - actual coalition: %s (%d)', + crewGroupName, + (spawnedCoalition == coalition.side.BLUE and 'BLUE' or spawnedCoalition == coalition.side.RED and 'RED' or 'NEUTRAL'), + spawnedCoalition)) + + -- Set crew to hold position and defend themselves + local crewController = crewGroup:getController() + if crewController then + crewController:setOption(AI.Option.Ground.id.ROE, AI.Option.Ground.val.ROE.RETURN_FIRE) + crewController:setOption(AI.Option.Ground.id.ALARM_STATE, AI.Option.Ground.val.ALARM_STATE.RED) + + -- Make crew immortal and/or invisible during announcement delay to prevent early death + if cfg.CrewImmortalDuringDelay then + local setImmortal = { + id = 'SetImmortal', + params = { value = true } + } + Controller.setCommand(crewController, setImmortal) + _logVerbose('[MEDEVAC] Crew set to immortal during announcement delay') + end + + if cfg.CrewInvisibleDuringDelay then + local setInvisible = { + id = 'SetInvisible', + params = { value = true } + } + Controller.setCommand(crewController, setInvisible) + _logVerbose('[MEDEVAC] Crew set to invisible to AI during announcement delay') + end + end + + -- Track crew immediately (but don't make mission available yet) + -- Smoke will be popped AFTER the announcement delay when they actually call for help + local crewData = { + vehicleType = unitType, + side = selfref.Side, + countryId = countryId, -- Store country ID for respawning + spawnTime = timer.getTime(), + position = spawnPoint, + salvageValue = salvageValue, + originalHeading = heading, + requestTime = nil, -- Will be set after announcement delay + warningsSent = {}, + invulnerable = false, + invulnerableUntil = 0, + greetingSent = false + } + CTLD._medevacCrews[crewGroupName] = crewData + + -- Wait before announcing mission (verify crew survival) + local announceDelay = cfg.CrewAnnouncementDelay or 60 + _logVerbose(string.format('[MEDEVAC] Will announce mission in %d seconds if crew survives', announceDelay)) + + local announceTimerId = timer.scheduleFunction(function() + -- Check if crew still exists + local g = Group.getByName(crewGroupName) + if not g or not g:isExist() then + _logVerbose(string.format('[MEDEVAC] Crew %s died before announcement, mission cancelled', crewGroupName)) + CTLD._medevacCrews[crewGroupName] = nil + return + end + + -- Crew survived! Now announce to players and make mission available + _logVerbose(string.format('[MEDEVAC] Crew %s survived, announcing mission', crewGroupName)) + + -- Optionally remove immortality after announcement; visibility is controlled by KeepCrewInvisibleForLifetime + local crewController = g:getController() + if crewController then + -- Remove immortality unless config says to keep it + if cfg.CrewImmortalDuringDelay and not cfg.CrewImmortalAfterAnnounce then + local setMortal = { + id = 'SetImmortal', + params = { value = false } + } + Controller.setCommand(crewController, setMortal) + _logVerbose('[MEDEVAC] Crew immortality removed, now vulnerable') + elseif cfg.CrewImmortalAfterAnnounce then + _logVerbose('[MEDEVAC] Crew remains immortal after announcement (per config)') + end + end + + -- Pop smoke now that they're calling for help + if cfg.PopSmokeOnSpawn then + local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[selfref.Side]) or trigger.smokeColor.Red + _spawnMEDEVACSmoke(spawnPoint, smokeColor, cfg) + _logVerbose(string.format('[MEDEVAC] Crew popped smoke after announcement (color: %d)', smokeColor)) + end + + local grid = selfref:_GetMGRSString(spawnPoint) + + -- Pick random request message + local requestMessages = cfg.RequestAirLiftMessages or { + "Stranded Crew: This is {vehicle} crew at {grid}. Need pickup ASAP! We have {salvage} salvage to collect." + } + local messageTemplate = requestMessages[math.random(1, #requestMessages)] + + -- Replace placeholders + local message = messageTemplate + message = message:gsub("{vehicle}", unitType) + message = message:gsub("{grid}", grid) + message = message:gsub("{crew_size}", tostring(crewSize)) + message = message:gsub("{salvage}", tostring(salvageValue)) + + _msgCoalition(selfref.Side, message, 25) + + -- Now crew is requesting pickup + CTLD._medevacCrews[crewGroupName].requestTime = timer.getTime() + + -- Create map marker + if cfg.MapMarkers and cfg.MapMarkers.Enabled then + local markerID = selfref:_CreateMEDEVACMarker(spawnPoint, unitType, crewSize, salvageValue, crewGroupName) + CTLD._medevacCrews[crewGroupName].markerID = markerID + end + + end, nil, timer.getTime() + announceDelay) + _trackOneShotTimer(announceTimerId) + + end, nil, timer.getTime() + spawnDelay) + _trackOneShotTimer(spawnTimerId) + +end + +-- Create map marker for MEDEVAC crew +function CTLD:_CreateMEDEVACMarker(position, vehicleType, crewSize, salvageValue, crewGroupName) + local cfg = CTLD.MEDEVAC.MapMarkers + if not cfg or not cfg.Enabled then return nil end + + local grid = self:_GetMGRSString(position) + local text = string.format('%s: %s Crew (%d) - Salvage: %d - %s', + cfg.IconText or '🔴 MEDEVAC', + vehicleType, + crewSize, + salvageValue, + grid + ) + + local markerID = _nextMarkupId() + trigger.action.markToCoalition(markerID, text, {x = position.x, y = 0, z = position.z}, self.Side, true) + + return markerID +end + +-- Get MGRS grid string for position +function CTLD:_GetMGRSString(position) + if not position then + return 'N/A' + end + local lat, lon = coord.LOtoLL({x = position.x, y = 0, z = position.z}) + local mgrs = coord.LLtoMGRS(lat, lon) + if mgrs and mgrs.UTMZone and mgrs.MGRSDigraph then + -- Ensure Easting and Northing are numbers + local easting = tonumber(mgrs.Easting) or 0 + local northing = tonumber(mgrs.Northing) or 0 + return string.format('%s%s %05d %05d', mgrs.UTMZone, mgrs.MGRSDigraph, easting, northing) + end + return string.format('%.0f, %.0f', position.x, position.z) +end + +-- Check for MEDEVAC crew timeouts and send warnings +function CTLD:_CheckMEDEVACTimeouts() + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + local now = timer.getTime() + local toRemove = {} + + for crewGroupName, data in pairs(CTLD._medevacCrews) do + if data.side == self.Side then + local requestTime = data.requestTime + if requestTime then -- Only check after crew has requested pickup + local elapsed = now - requestTime + if type(data.warningsSent) ~= 'table' then + data.warningsSent = {} + end + local remaining = (cfg.CrewTimeout or 3600) - elapsed + + -- Check for approaching rescue helos (pop smoke and send greeting with cooldown) + if cfg.PopSmokeOnApproach then + local approachDist = cfg.PopSmokeOnApproachDistance or 5000 + local crewPos = data.position + local smokeCooldown = cfg.SmokeCooldown or 900 -- Default 15 minutes (900 seconds) + local lastSmoke = data.lastSmokeTime or 0 + local canPopSmoke = (now - lastSmoke) >= smokeCooldown + + if canPopSmoke then + -- Check all units of this coalition for nearby transport helos + local coalitionUnits = coalition.getGroups(self.Side, Group.Category.AIRPLANE) + local heloGroups = coalition.getGroups(self.Side, Group.Category.HELICOPTER) + + if heloGroups then + for _, grp in ipairs(heloGroups) do + if grp and grp:isExist() then + local units = grp:getUnits() + if units then + for _, unit in ipairs(units) do + if unit and unit:isExist() and unit:isActive() then + -- Check if this is a transport helo (in AllowedAircraft list) + local unitType = unit:getTypeName() + local isTransport = false + if self.Config.AllowedAircraft then + for _, allowed in ipairs(self.Config.AllowedAircraft) do + if unitType == allowed then + isTransport = true + break + end + end + end + + if isTransport then + local unitPos = unit:getPoint() + if unitPos and crewPos then + local dx = unitPos.x - crewPos.x + local dz = unitPos.z - crewPos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist <= approachDist then + -- Rescue helo detected! Pop smoke and send greeting + local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[self.Side]) or trigger.smokeColor.Red + _spawnMEDEVACSmoke(crewPos, smokeColor, cfg) + + -- Pick random greeting message + local greetings = cfg.GreetingMessages or {"We see you! Over here!"} + local greeting = greetings[math.random(1, #greetings)] + + _msgCoalition(self.Side, string.format('[MEDEVAC] %s crew: "%s"', data.vehicleType, greeting), 10) + + -- Set cooldown timer instead of permanent flag + data.lastSmokeTime = now + local cooldownMins = smokeCooldown / 60 + _logVerbose(string.format('[MEDEVAC] Crew %s detected helo at %.0fm, popped smoke (cooldown: %.0f mins)', + crewGroupName, dist, cooldownMins)) + break + end + end + end + end + end + end + end + if data.lastSmokeTime == now then break end -- Just popped smoke, exit loop + end + end -- if heloGroups then + end -- if canPopSmoke then + end -- if cfg.PopSmokeOnApproach then + + -- Send warnings + if cfg.Warnings then + for _, warning in ipairs(cfg.Warnings) do + local warnTime = warning.time + if remaining <= warnTime and not data.warningsSent[warnTime] then + local grid = self:_GetMGRSString(data.position) + _msgCoalition(self.Side, _fmtTemplate(warning.message, { + crew = data.vehicleType..' crew', + grid = grid + }), 15) + data.warningsSent[warnTime] = true + end + end + end + + -- Check timeout + if remaining <= 0 then + table.insert(toRemove, crewGroupName) + end + end + end + end + + -- Remove timed-out crews + for _, crewGroupName in ipairs(toRemove) do + self:_RemoveMEDEVACCrew(crewGroupName, 'timeout') + end +end + +-- Remove MEDEVAC crew (timeout or death) +function CTLD:_RemoveMEDEVACCrew(crewGroupName, reason) + local data = CTLD._medevacCrews[crewGroupName] + if not data then return end + + -- Remove map marker + if data.markerID then + pcall(function() trigger.action.removeMark(data.markerID) end) + end + + -- Destroy crew group + local g = Group.getByName(crewGroupName) + if g and g:isExist() then + g:destroy() + end + + -- Send message + if reason == 'timeout' then + local grid = self:_GetMGRSString(data.position) + _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_timeout, { + vehicle = data.vehicleType, + grid = grid + }), 15) + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].timedOut = (CTLD._medevacStats[self.Side].timedOut or 0) + 1 + end + elseif reason == 'killed' then + local grid = self:_GetMGRSString(data.position) + local lines = CTLD.Messages.medevac_crew_killed_lines + if lines and #lines > 0 then + local line = lines[math.random(1, #lines)] + _msgCoalition(self.Side, _fmtTemplate(line, { + vehicle = data.vehicleType, + grid = grid, + }), 15) + else + _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_killed, { + vehicle = data.vehicleType, + grid = grid, + }), 15) + end + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].killed = (CTLD._medevacStats[self.Side].killed or 0) + 1 + end + end + + -- Remove from tracking + CTLD._medevacCrews[crewGroupName] = nil + + if data.rescueGroup and CTLD._medevacEnrouteStates then + CTLD._medevacEnrouteStates[data.rescueGroup] = nil + end + + _logVerbose(string.format('[MEDEVAC] Removed crew %s (reason: %s)', crewGroupName, reason or 'unknown')) +end + +-- Check if crew was picked up (called from troop loading system) +function CTLD:CheckMEDEVACPickup(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local searchRadius = 100 -- meters to search for nearby crew + + for crewGroupName, data in pairs(CTLD._medevacCrews) do + if data.side == self.Side and data.requestTime then + local crewPos = data.position + local dx = pos.x - crewPos.x + local dz = pos.z - crewPos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist <= searchRadius then + -- Check if crew group still exists and is being loaded + local crewGroup = Group.getByName(crewGroupName) + if crewGroup and crewGroup:isExist() then + -- Crew was picked up! Handle respawn + self:_HandleMEDEVACPickup(group, crewGroupName, data) + return true + end + end + end + end + + return false +end + +-- Auto-pickup: Send MEDEVAC crews to landed helicopter within range +function CTLD:AutoPickupMEDEVACCrew(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + if not cfg.AutoPickup or not cfg.AutoPickup.Enabled then return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + -- Only work with landed helicopters + if _isUnitInAir(unit) then return end + + local autoCfg = cfg.AutoPickup + local requireGround = (autoCfg.RequireGroundContact ~= false) + if requireGround then + local agl = _getUnitAGL(unit) + if agl > (autoCfg.GroundContactAGL or 3) then + return -- still hovering/high skid - wait for full touchdown + end + local gs = _getGroundSpeed(unit) + if gs > (autoCfg.MaxLandingSpeed or 2) then + return -- helicopter is sliding/taxiing - hold crews until stable + end + end + + local pos = unit:GetPointVec3() + if not pos then return end + local maxDist = autoCfg.MaxDistance or 200 + local groupName = group:GetName() + + -- Skip if helicopter already has an active load hold + if CTLD._medevacLoadStates and CTLD._medevacLoadStates[groupName] then + return + end + + -- Find nearby MEDEVAC crews within pickup range + for crewGroupName, data in pairs(CTLD._medevacCrews) do + if data.side == self.Side and data.requestTime and not data.pickedUp then + local crewPos = data.position + local dx = pos.x - crewPos.x + local dz = pos.z - crewPos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist <= maxDist then + local crewGroup = Group.getByName(crewGroupName) + if crewGroup and crewGroup:isExist() then + -- Crew is close enough - start load hold + local loadCfg = cfg.AutoPickup or {} + local delay = loadCfg.LoadDelay or 15 + local now = timer.getTime() + + -- Check if already in a load hold + local existingState = CTLD._medevacLoadStates[groupName] + if not existingState then + -- Start new load hold + CTLD._medevacLoadStates[groupName] = { + startTime = now, + delay = delay, + crewGroupName = crewGroupName, + crewData = data, + holdAnnounced = true, + nextReminder = now + math.max(1.5, delay / 3), + lastQualified = now, + } + + _msgGroup(group, string.format("MEDEVAC crew from %s is boarding. Hold position for %d seconds...", + data.vehicleType or 'unknown vehicle', delay), 10) + _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold started for %s (delay=%.1fs, crew=%s, dist=%.0fm)', + groupName, delay, crewGroupName, dist)) + end + end + end + end + end +end +-- Auto-unload: Send MEDEVAC crews to landed helicopter within MASH zone +-- Scan all active transport groups for auto-pickup and auto-unload opportunities +function CTLD:ScanMEDEVACAutoActions() + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + -- Progress any ongoing load and unload holds before new scans + self:_UpdateMedevacLoadStates() + self:_UpdateMedevacUnloadStates() + + -- Scan all active transport groups + for gname, _ in pairs(self.MenusByGroup or {}) do + local group = GROUP:FindByName(gname) + if group and group:IsAlive() then + local unit = group:GetUnit(1) + if unit and unit:IsAlive() then + local isAirborne = _isUnitInAir(unit) + + local autoUnloadCfg = cfg.AutoUnload or {} + local aglLimit = autoUnloadCfg.GroundContactAGL or 2 + local agl = _getUnitAGL(unit) + if agl == nil then agl = aglLimit end + local hasGroundContact = (not isAirborne) + or (agl <= aglLimit) + + if not isAirborne then + -- Helicopter is landed according to DCS state + if cfg.AutoPickup and cfg.AutoPickup.Enabled then + self:AutoPickupMEDEVACCrew(group) + end + end + + if cfg.AutoUnload and cfg.AutoUnload.Enabled and hasGroundContact then + -- Reduce log spam: only attempt auto-unload when there are rescued crews onboard + local crews = self:_CollectRescuedCrewsForGroup(group:GetName()) + if crews and #crews > 0 then + self:AutoUnloadMEDEVACCrew(group) + end + end + + self:_TickMedevacEnrouteMessage(group, unit, isAirborne) + else + CTLD._medevacEnrouteStates[gname] = nil + end + else + CTLD._medevacEnrouteStates[gname] = nil + end + end + + -- Finalize unload checks after handling current landings + self:_UpdateMedevacUnloadStates() + + local enrouteStates = CTLD._medevacEnrouteStates + if enrouteStates then + for gname, _ in pairs(enrouteStates) do + if not (self.MenusByGroup and self.MenusByGroup[gname]) then + local group = GROUP:FindByName(gname) + if not group or not group:IsAlive() then + enrouteStates[gname] = nil + end + end + end + end +end + +-- Auto-unload: Automatically unload MEDEVAC crews when landed in MASH zone +function CTLD:AutoUnloadMEDEVACCrew(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + if not cfg.AutoUnload or not cfg.AutoUnload.Enabled then return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + local gname = group:GetName() or 'UNKNOWN' + + -- Early silent exit (reduces log spam): only proceed if there are rescued crews onboard + local earlyCrews = self:_CollectRescuedCrewsForGroup(gname) + if not earlyCrews or #earlyCrews == 0 then return end + + local autoCfg = cfg.AutoUnload or {} + local aglLimit = autoCfg.GroundContactAGL or 2.0 + local gsLimit = autoCfg.MaxLandingSpeed or 2.0 + local settleLimit = autoCfg.SettledAGL or (aglLimit + 2.0) + + local agl = _getUnitAGL(unit) + if agl == nil then agl = 0 end + local gs = _getGroundSpeed(unit) + if gs == nil then gs = 0 end + local inAir = _isUnitInAir(unit) + + -- Treat the helicopter as landed when weight-on-wheels flips or when the skid height is within tolerance. + local hasGroundContact = (not inAir) or (agl <= aglLimit) + if not hasGroundContact then + _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: no ground contact (agl=%.2f, limit=%.2f, inAir=%s)', gname, agl, aglLimit, tostring(inAir))) + return + end + + if inAir and agl > aglLimit then + _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: AGL %.2f above limit %.2f while still airborne', gname, agl, aglLimit)) + return + end + + if gs > gsLimit then + _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: ground speed %.2f above limit %.2f', gname, gs, gsLimit)) + return + end + + if settleLimit and settleLimit > 0 and agl > settleLimit then + _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) + return + end + + local crews = self:_CollectRescuedCrewsForGroup(group:GetName()) + if #crews == 0 then return end + + -- Check if inside MASH zone + local pos = unit:GetPointVec3() + local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) + if not inMASH then + _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: not inside MASH zone (crews=%d)', gname, #crews)) + return + end + + _logVerbose(string.format('[MEDEVAC][AutoUnload] %s qualified for unload in MASH %s (crews=%d, agl=%.2f, gs=%.2f)', + gname, + tostring((mashZone and (mashZone.name or mashZone.unitName)) or 'UNKNOWN'), + #crews, + agl, + gs)) + + -- Begin or maintain the unload hold state + self:_EnsureMedevacUnloadState(group, mashZone, crews, { trigger = 'auto' }) +end + +-- Gather all MEDEVAC crews currently onboard the specified rescue group +function CTLD:_CollectRescuedCrewsForGroup(groupName) + local crews = {} + if not groupName then return crews end + + for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side and data.pickedUp and data.rescueGroup == groupName then + crews[#crews + 1] = { name = crewGroupName, data = data } + end + end + + return crews +end + +-- Periodically deliver enroute status chatter while MEDEVAC patients are onboard +function CTLD:_TickMedevacEnrouteMessage(group, unit, isAirborne, forceSend) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + local enrouteCfg = cfg.EnrouteMessages or {} + if enrouteCfg.Enabled == false then return end + + if not group or not unit or not unit:IsAlive() then + if group then + local gname = group:GetName() + if gname and gname ~= '' then + CTLD._medevacEnrouteStates[gname] = nil + end + end + return + end + + local gname = group:GetName() + if not gname or gname == '' then return end + + local crews = self:_CollectRescuedCrewsForGroup(gname) + if not crews or #crews == 0 then + CTLD._medevacEnrouteStates[gname] = nil + return + end + + if not isAirborne and not forceSend then + return + end + + local interval = enrouteCfg.Interval or 180 + if interval <= 0 then interval = 180 end + + CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} + local now = timer.getTime() + local state = CTLD._medevacEnrouteStates[gname] + + if not state then + state = { nextSend = now + interval, lastIndex = nil } + CTLD._medevacEnrouteStates[gname] = state + end + + if not forceSend and now < (state.nextSend or 0) then + return + end + + local vector = self:_ComputeNearestMASHVector(unit) + if not vector then return end + + local messages = cfg.EnrouteToMashMessages or {} + if #messages == 0 then return end + + local idx = math.random(1, #messages) + if state.lastIndex and #messages > 1 and idx == state.lastIndex then + idx = (idx % #messages) + 1 + end + state.lastIndex = idx + state.nextSend = now + interval + + local text = _fmtTemplate(messages[idx], { + mash = vector.name, + brg = vector.bearing, + rng = vector.rangeValue, + rng_u = vector.rangeUnit + }) + + _msgGroup(group, text, math.min(self.Config.MessageDuration or 15, 18)) +end + +-- Ensure an unload hold state exists for the group and announce if newly started +function CTLD:_EnsureMedevacUnloadState(group, mashZone, crews, opts) + CTLD._medevacUnloadStates = CTLD._medevacUnloadStates or {} + + if not group or not group:IsAlive() then return nil end + + local gname = group:GetName() + local now = timer.getTime() + local cfg = self.MEDEVAC or {} + local cfgAuto = cfg.AutoUnload or {} + local delay = cfgAuto.UnloadDelay or 2 + if delay < 0 then delay = 0 end + + local state = CTLD._medevacUnloadStates[gname] + if not state then + state = { + groupName = gname, + side = self.Side, + startTime = now, + delay = delay, + holdAnnounced = false, + mashZoneName = mashZone and (mashZone.name or mashZone.unitName) or nil, + triggeredBy = opts and opts.trigger or 'auto', + } + CTLD._medevacUnloadStates[gname] = state + self:_AnnounceMedevacUnloadHold(group, state) + _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold started for %s (delay=%0.1fs, trigger=%s, mash=%s, crews=%d)', + gname, + state.delay, + tostring(state.triggeredBy), + tostring(state.mashZoneName or 'UNKNOWN'), + crews and #crews or 0)) + else + state.delay = delay + state.triggeredBy = opts and opts.trigger or state.triggeredBy + if mashZone then + state.mashZoneName = mashZone.name or mashZone.unitName or state.mashZoneName + end + _logDebug(string.format('[MEDEVAC][AutoUnload] Hold refreshed for %s (trigger=%s, crews=%d)', + gname, + tostring(state.triggeredBy), + crews and #crews or 0)) + end + + state.lastQualified = now + state.pendingCrewCount = crews and #crews or state.pendingCrewCount + + return state +end + +-- Notify the pilot that unloading is in progress and set up reminder cadence +function CTLD:_AnnounceMedevacUnloadHold(group, state) + if not group or not state or state.holdAnnounced then return end + + state.holdAnnounced = true + local delay = math.ceil(state.delay or 0) + if delay < 1 then delay = 1 end + + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_hold, { + seconds = delay + }), math.min(delay + 2, 12)) + + local unloadMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadingMessages) or {} + if #unloadMsgs > 0 then + local msg = unloadMsgs[math.random(1, #unloadMsgs)] + _msgGroup(group, msg, math.min(delay, 10)) + end + + local now = timer.getTime() + local spacing = state.delay or 2 + spacing = math.max(1.5, math.min(4, spacing / 2)) + state.nextReminder = now + spacing +end + +-- Send a reminder from the unloading message pool while waiting out the hold +function CTLD:_SendMedevacUnloadReminder(group) + if not group then return end + local unloadMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadingMessages) or {} + if #unloadMsgs == 0 then return end + + local msg = unloadMsgs[math.random(1, #unloadMsgs)] + _msgGroup(group, msg, 6) +end + +-- Inform the pilot that the unload was cancelled and the hold must restart +function CTLD:_NotifyMedevacUnloadAbort(group, state, reasonKey) + if not group or not state or state.abortNotified or not state.holdAnnounced then return end + + local reasonText + if reasonKey == 'air' then + reasonText = 'wheels up too soon' + elseif reasonKey == 'zone' then + reasonText = 'left the MASH zone' + elseif reasonKey == 'agl' then + reasonText = 'climbed above unload height' + elseif reasonKey == 'crew' then + reasonText = 'no MEDEVAC patients onboard' + else + reasonText = 'hold interrupted' + end + + local delay = math.ceil(state.delay or 0) + if delay < 1 then delay = 1 end + + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_aborted, { + reason = reasonText, + seconds = delay + }), 10) + + state.abortNotified = true +end + +-- Finalize the unload, deliver all crews, and celebrate success +function CTLD:_CompleteMedevacUnload(group, crews) + if not group or not group:IsAlive() then return end + if not crews or #crews == 0 then return end + + for _, crew in ipairs(crews) do + self:_DeliverMEDEVACCrewToMASH(group, crew.name, crew.data) + end + + local successMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadCompleteMessages) or {} + if #successMsgs > 0 then + local msg = successMsgs[math.random(1, #successMsgs)] + _msgGroup(group, msg, 10) + end + + _logVerbose(string.format('[MEDEVAC] Auto unload complete for %s (%d crew group(s) delivered)', group:GetName(), #crews)) +end + +-- Send loading reminder message to pilot +function CTLD:_SendMedevacLoadReminder(group) + if not group then return end + local loadingMsgs = (self.MEDEVAC and self.MEDEVAC.LoadingMessages) or {} + if #loadingMsgs == 0 then return end + + local msg = loadingMsgs[math.random(1, #loadingMsgs)] + _msgGroup(group, msg, 6) +end + +-- Inform the pilot that the loading was cancelled and the hold must restart +function CTLD:_NotifyMedevacLoadAbort(group, state, reasonKey) + if not group or not state or state.abortNotified or not state.holdAnnounced then return end + + local reasonText + if reasonKey == 'air' then + reasonText = 'wheels up too soon' + elseif reasonKey == 'agl' then + reasonText = 'climbed above loading height' + elseif reasonKey == 'crew' then + reasonText = 'crew lost contact' + else + reasonText = 'hold interrupted' + end + + local delay = math.ceil(state.delay or 0) + if delay < 1 then delay = 1 end + + _msgGroup(group, string.format("MEDEVAC boarding aborted: %s. Land and hold for %d seconds to restart.", + reasonText, delay), 10) + + state.abortNotified = true +end + +-- Complete the load, pick up crew, and show success message +function CTLD:_CompleteMedevacLoad(group, crewGroupName, crewData) + if not group or not group:IsAlive() then return end + if not crewGroupName or not crewData then return end + + -- Destroy the crew unit + local crewGroup = Group.getByName(crewGroupName) + if crewGroup and crewGroup:isExist() then + crewGroup:destroy() + end + + -- Handle the actual pickup (respawn vehicle, etc.) + self:_HandleMEDEVACPickup(group, crewGroupName, crewData) + + -- Show completion message + local successMsgs = (self.MEDEVAC and self.MEDEVAC.LoadMessages) or {} + if #successMsgs > 0 then + local msg = successMsgs[math.random(1, #successMsgs)] + _msgGroup(group, msg, 10) + end + + _logVerbose(string.format('[MEDEVAC] Auto load complete for %s (crew %s)', group:GetName(), crewGroupName)) +end + +-- Maintain load hold states, handling completion or interruption +function CTLD:_UpdateMedevacLoadStates() + local states = CTLD._medevacLoadStates + if not states or not next(states) then return end + + local now = timer.getTime() + local cfg = self.MEDEVAC or {} + local cfgAuto = cfg.AutoPickup or {} + local aglLimit = cfgAuto.GroundContactAGL or 3 + local settleLimit = cfgAuto.SettledAGL or 6 + local gsLimit = cfgAuto.MaxLandingSpeed or 2 + local airGrace = cfgAuto.AirAbortGrace or 2 + + for gname, state in pairs(states) do + local group = GROUP:FindByName(gname) + if not group or not group:IsAlive() then + states[gname] = nil + _logDebug(string.format('[MEDEVAC][AutoLoad] %s removed: group not alive', gname)) + else + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then + states[gname] = nil + _logDebug(string.format('[MEDEVAC][AutoLoad] %s removed: unit not alive', gname)) + else + local removeState = false + local agl = _getUnitAGL(unit) + local gs = _getGroundSpeed(unit) + + -- Check if crew still exists + local crewGroup = Group.getByName(state.crewGroupName) + if not crewGroup or not crewGroup:isExist() then + _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: crew %s no longer exists', gname, state.crewGroupName)) + removeState = true + else + -- Check distance to crew + local crewUnit = crewGroup:getUnit(1) + if crewUnit then + local crewPos = crewUnit:getPoint() + local heliPos = unit:GetPointVec3() + local dx = heliPos.x - crewPos.x + local dz = heliPos.z - crewPos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist > 40 then + self:_NotifyMedevacLoadAbort(group, state, 'crew') + _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: moved too far from crew (%.1fm)', gname, dist)) + removeState = true + end + end + + if not removeState then + -- Check landing status (similar to unload logic) + local landed = not _isUnitInAir(unit) + if landed then + if settleLimit and settleLimit > 0 and agl > settleLimit then + landed = false + state.highAglSince = state.highAglSince or now + _logDebug(string.format('[MEDEVAC][AutoLoad] %s hold paused: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) + else + state.highAglSince = nil + end + else + state.highAglSince = nil + if agl <= aglLimit and gs <= gsLimit then + landed = true + end + end + + if landed then + state.airborneSince = nil + state.lastQualified = now + + -- Send reminders while holding + if state.nextReminder and now >= state.nextReminder then + self:_SendMedevacLoadReminder(group) + local spacing = state.delay or 2 + spacing = math.max(1.5, math.min(4, spacing / 2)) + state.nextReminder = now + spacing + end + + -- Complete load after delay + if (now - state.startTime) >= state.delay then + self:_CompleteMedevacLoad(group, state.crewGroupName, state.crewData) + _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold complete for %s', gname)) + removeState = true + end + else + state.airborneSince = state.airborneSince or now + if (now - state.airborneSince) >= airGrace then + self:_NotifyMedevacLoadAbort(group, state, 'air') + _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: airborne for %.1fs (grace=%.1f)', + gname, + now - state.airborneSince, + airGrace)) + removeState = true + end + end + end + end + + if removeState then + states[gname] = nil + end + end + end + end +end + +-- Maintain unload hold states, handling completion or interruption +function CTLD:_UpdateMedevacUnloadStates() + local states = CTLD._medevacUnloadStates + if not states or not next(states) then return end + + local now = timer.getTime() + local cfg = self.MEDEVAC or {} + local cfgAuto = cfg.AutoUnload or {} + local aglLimit = cfgAuto.GroundContactAGL or 2 + local gsLimit = cfgAuto.MaxLandingSpeed or 2 + local airGrace = cfgAuto.AirAbortGrace or 2 + + for gname, state in pairs(states) do + -- Multiple CTLD instances share the global unload state table; skip entries owned by the other coalition. + if not state.side or state.side == self.Side then + local group = GROUP:FindByName(gname) + local removeState = false + + if not group or not group:IsAlive() then + removeState = true + else + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then + removeState = true + else + local crews = self:_CollectRescuedCrewsForGroup(gname) + if #crews == 0 then + self:_NotifyMedevacUnloadAbort(group, state, 'crew') + _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: crew list empty', gname)) + removeState = true + else + local agl = _getUnitAGL(unit) + if agl == nil then agl = 0 end + local gs = _getGroundSpeed(unit) + if gs == nil then gs = 0 end + local settleLimit = cfgAuto.SettledAGL or (aglLimit + 2.0) + + local landed = not _isUnitInAir(unit) + if landed then + if settleLimit and settleLimit > 0 and agl > settleLimit then + landed = false + state.highAglSince = state.highAglSince or now + _logDebug(string.format('[MEDEVAC][AutoUnload] %s hold paused: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) + else + state.highAglSince = nil + end + else + state.highAglSince = nil + if agl <= aglLimit and gs <= gsLimit then + landed = true + end + end + + if landed then + state.airborneSince = nil + state.lastQualified = now + local pos = unit:GetPointVec3() + local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) + if not inMASH then + self:_NotifyMedevacUnloadAbort(group, state, 'zone') + _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: left MASH zone', gname)) + removeState = true + else + state.mashZoneName = mashZone and (mashZone.name or mashZone.unitName or state.mashZoneName) + + -- Send reminders while holding + if state.nextReminder and now >= state.nextReminder then + self:_SendMedevacUnloadReminder(group) + local spacing = state.delay or 2 + spacing = math.max(1.5, math.min(4, spacing / 2)) + state.nextReminder = now + spacing + end + + -- Complete unload after delay + if (now - state.startTime) >= state.delay then + self:_CompleteMedevacUnload(group, crews) + _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold complete for %s (crews delivered=%d)', gname, #crews)) + removeState = true + end + end + else + state.airborneSince = state.airborneSince or now + if (now - state.airborneSince) >= airGrace then + self:_NotifyMedevacUnloadAbort(group, state, 'air') + _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: airborne for %.1fs (grace=%.1f)', + gname, + now - state.airborneSince, + airGrace)) + removeState = true + end + end + end + end + end + + if removeState then + states[gname] = nil + end + end + end +end + +-- Handle MEDEVAC crew pickup - respawn vehicle +function CTLD:_HandleMEDEVACPickup(rescueGroup, crewGroupName, crewData) + local cfg = CTLD.MEDEVAC + + -- Remove map marker + if crewData.markerID then + pcall(function() trigger.action.removeMark(crewData.markerID) end) + end + + -- Show initial load message (random from LoadMessages) + local loadMsgs = cfg.LoadMessages or {} + if #loadMsgs > 0 then + local randomLoadMsg = loadMsgs[math.random(1, #loadMsgs)] + _msgGroup(rescueGroup, randomLoadMsg, 5) + end + + -- Show loading progress messages during a brief delay (simulate boarding time) + local loadingDuration = 8 -- seconds for crew to board + local loadingMsgInterval = 2 -- show message every 2 seconds + local loadingMsgs = cfg.LoadingMessages or {} + local gname = rescueGroup:GetName() + + if #loadingMsgs > 0 then + local messageCount = math.floor(loadingDuration / loadingMsgInterval) + for i = 1, messageCount do + local msgId = timer.scheduleFunction(function() + local g = GROUP:FindByName(gname) + if g and g:IsAlive() then + local randomLoadingMsg = loadingMsgs[math.random(1, #loadingMsgs)] + _msgGroup(g, randomLoadingMsg, loadingMsgInterval - 0.5) + end + end, nil, timer.getTime() + (i * loadingMsgInterval)) + _trackOneShotTimer(msgId) + end + end + + -- Schedule final completion after loading duration + local completionId = timer.scheduleFunction(function() + local g = GROUP:FindByName(gname) + if g and g:IsAlive() then + -- Show completion message + _msgGroup(g, _fmtTemplate(CTLD.Messages.medevac_crew_loaded, { + vehicle = crewData.vehicleType, + crew_size = crewData.crewSize + }), 10) + + local unit = g:GetUnit(1) + if unit and unit:IsAlive() then + self:_TickMedevacEnrouteMessage(g, unit, _isUnitInAir(unit), true) + end + end + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].rescued = (CTLD._medevacStats[self.Side].rescued or 0) + 1 + end + + -- Respawn vehicle if enabled + if cfg.RespawnOnPickup then + local respawnId = timer.scheduleFunction(function() + self:_RespawnMEDEVACVehicle(crewData) + end, nil, timer.getTime() + 2) -- 2 second delay for realism + _trackOneShotTimer(respawnId) + end + + -- Mark crew as picked up (for MASH delivery tracking) + crewData.pickedUp = true + crewData.rescueGroup = gname + + _logVerbose(string.format('[MEDEVAC] Crew %s picked up by %s', crewGroupName, gname)) + end, nil, timer.getTime() + loadingDuration) + _trackOneShotTimer(completionId) +end + +-- Respawn the vehicle at original death location +function CTLD:_RespawnMEDEVACVehicle(crewData) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.RespawnOnPickup then return end + + -- Calculate respawn position (offset from original death) + local offset = cfg.RespawnOffset or 15 + local angle = math.random() * 2 * math.pi + local respawnPos = { + x = crewData.position.x + math.cos(angle) * offset, + z = crewData.position.z + math.sin(angle) * offset + } + + local heading = cfg.RespawnSameHeading and (crewData.originalHeading or 0) or 0 + + -- Find catalog entry to get build function + local catalogEntry = nil + local catalogKey = nil + for key, def in pairs(self.Config.CrateCatalog or {}) do + if def and def.MEDEVAC then + local matches = false + + if def.unitType and def.unitType == crewData.vehicleType then + matches = true + end + + if (not matches) and type(def.unitTypes) == 'table' then + for _, unitType in ipairs(def.unitTypes) do + if unitType == crewData.vehicleType then + matches = true + break + end + end + end + + if not matches then + local ok, unitTypes = pcall(function() + return self:_collectEntryUnitTypes(def) + end) + if ok and type(unitTypes) == 'table' then + for _, unitType in ipairs(unitTypes) do + if unitType == crewData.vehicleType then + matches = true + break + end + end + end + end + + if matches then + catalogEntry = def + catalogKey = key + break + end + end + end + + if not catalogEntry or not catalogEntry.build then + _logVerbose('[MEDEVAC] No catalog entry found for respawn: '..crewData.vehicleType) + return + end + + -- Spawn vehicle using catalog build function + local groupData = catalogEntry.build(respawnPos, math.deg(heading)) + if not groupData then + _logVerbose('[MEDEVAC] Failed to generate group data for: '..crewData.vehicleType) + return + end + + if crewData.countryId then + groupData.country = crewData.countryId + end + + local category = catalogEntry.category or Group.Category.GROUND + + local newGroup = coalition.addGroup(self.Side, category, groupData) + + if newGroup then + if catalogKey then + _logVerbose(string.format('[MEDEVAC] Respawn using catalog entry %s for %s', tostring(catalogKey), crewData.vehicleType)) + end + _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_vehicle_respawned, { + vehicle = crewData.vehicleType + }), 10) + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].vehiclesRespawned = (CTLD._medevacStats[self.Side].vehiclesRespawned or 0) + 1 + end + + _logVerbose(string.format('[MEDEVAC] Respawned %s at %.0f, %.0f', crewData.vehicleType, respawnPos.x, respawnPos.z)) + else + _logVerbose('[MEDEVAC] Failed to respawn vehicle: '..crewData.vehicleType) + end +end + +-- Check if troops being unloaded are MEDEVAC crew and if inside MASH zone +function CTLD:CheckMEDEVACDelivery(group, troopData) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return false end + if not cfg.Salvage or not cfg.Salvage.Enabled then return false end + if not group or not group:IsAlive() then return false end + + local gname = group:GetName() + local crews = self:_CollectRescuedCrewsForGroup(gname) + if #crews == 0 then return false end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return false end + + if _isUnitInAir(unit) then + local delay = (cfg.AutoUnload and cfg.AutoUnload.UnloadDelay) or 2 + delay = math.max(1, math.ceil(delay or 0)) + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_hold, { + seconds = delay + }), 10) + return 'pending' + end + + local pos = unit:GetPointVec3() + local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) + if not inMASH then return false end + + self:_EnsureMedevacUnloadState(group, mashZone, crews, { trigger = 'manual' }) + self:_UpdateMedevacUnloadStates() + + local remaining = self:_CollectRescuedCrewsForGroup(gname) + if #remaining == 0 then + return 'delivered' + end + + return 'pending' +end + +-- Deliver MEDEVAC crew to MASH - award salvage points +function CTLD:_DeliverMEDEVACCrewToMASH(group, crewGroupName, crewData) + local cfg = CTLD.MEDEVAC.Salvage + if not cfg or not cfg.Enabled then return end + + -- Award salvage points + CTLD._salvagePoints[self.Side] = (CTLD._salvagePoints[self.Side] or 0) + crewData.salvageValue + + -- Message to coalition (shown after brief delay to let unload message be seen) + local msgId = timer.scheduleFunction(function() + _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_delivered_mash, { + player = _playerNameFromGroup(group), + vehicle = crewData.vehicleType, + salvage = crewData.salvageValue, + total = CTLD._salvagePoints[self.Side] + }), 15) + end, nil, timer.getTime() + 3) + _trackOneShotTimer(msgId) + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].delivered = (CTLD._medevacStats[self.Side].delivered or 0) + 1 + CTLD._medevacStats[self.Side].salvageEarned = (CTLD._medevacStats[self.Side].salvageEarned or 0) + crewData.salvageValue + end + + -- Remove map marker + if crewData.markerID then + pcall(function() trigger.action.removeMark(crewData.markerID) end) + end + + -- Destroy crew group to prevent clutter + local crewGroup = Group.getByName(crewGroupName) + if crewGroup and crewGroup:isExist() then + crewGroup:destroy() + end + + -- Remove crew from tracking + CTLD._medevacCrews[crewGroupName] = nil + + if group and group:IsAlive() then + local gname = group:GetName() + if gname and gname ~= '' then + CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} + CTLD._medevacEnrouteStates[gname] = nil + end + end + + _logVerbose(string.format('[MEDEVAC] Delivered %s crew to MASH - awarded %d salvage (total: %d)', + crewData.vehicleType, crewData.salvageValue, CTLD._salvagePoints[self.Side])) +end + +-- Try to use salvage to spawn a crate when out of stock +function CTLD:_TryUseSalvageForCrate(group, crateKey, catalogEntry) + local cfg = CTLD.MEDEVAC and CTLD.MEDEVAC.Salvage + if not cfg or not cfg.Enabled then return false end + if not cfg.AutoApply then return false end + + -- Check if item has salvage value (use same fallback logic as MEDEVAC) + local salvageCost = catalogEntry.salvageValue + if not salvageCost then + salvageCost = catalogEntry.required or cfg.DefaultValue or 1 + end + if salvageCost <= 0 then return false end + + -- Check if we have enough salvage + local available = CTLD._salvagePoints[self.Side] or 0 + if available < salvageCost then + -- Send insufficient salvage message + local deficit = salvageCost - available + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_salvage_insufficient, { + need = salvageCost, + deficit = deficit + })) + return false + end + + -- Consume salvage + CTLD._salvagePoints[self.Side] = available - salvageCost + + -- Track statistics + if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then + CTLD._medevacStats[self.Side].salvageUsed = (CTLD._medevacStats[self.Side].salvageUsed or 0) + salvageCost + end + + -- Send success message + _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_salvage_used, { + item = self:_friendlyNameForKey(crateKey), + salvage = salvageCost, + remaining = CTLD._salvagePoints[self.Side] + })) + + _logVerbose(string.format('[Salvage] Used %d salvage for %s (remaining: %d)', + salvageCost, crateKey, CTLD._salvagePoints[self.Side])) + + return true +end + +-- Check if salvage can cover a crate request (for bundle pre-checks) +function CTLD:_CanUseSalvageForCrate(crateKey, catalogEntry, quantity) + local cfg = CTLD.MEDEVAC and CTLD.MEDEVAC.Salvage + if not cfg or not cfg.Enabled then return false end + if not cfg.AutoApply then return false end + + quantity = quantity or 1 + -- Check if item has salvage value (use same fallback logic as MEDEVAC) + local salvageCost = catalogEntry.salvageValue + if not salvageCost then + salvageCost = catalogEntry.required or cfg.DefaultValue or 1 + end + salvageCost = salvageCost * quantity + if salvageCost <= 0 then return false end + + local available = CTLD._salvagePoints[self.Side] or 0 + return available >= salvageCost +end + +-- Resolve the 2D position of a MASH zone, handling fixed and mobile variants +function CTLD:_ResolveMASHPosition(mashData, mashKey) + if not mashData then return nil end + + if mashData.position and mashData.position.x and mashData.position.z then + return { x = mashData.position.x, z = mashData.position.z } + end + + local zone = mashData.zone + if zone then + if zone.GetPointVec3 then + local ok, vec3 = pcall(function() return zone:GetPointVec3() end) + if ok and vec3 then + return { x = vec3.x, z = vec3.z } + end + end + if zone.GetPointVec2 then + local ok, vec2 = pcall(function() return zone:GetPointVec2() end) + if ok and vec2 then + return { x = vec2.x, z = vec2.y } + end + end + if zone.GetCoordinate then + local ok, coord = pcall(function() return zone:GetCoordinate() end) + if ok and coord then + local vec3 = coord.GetVec3 and coord:GetVec3() + if vec3 then + return { x = vec3.x, z = vec3.z } + end + end + end + end + + if mashKey and trigger and trigger.misc and trigger.misc.getZone then + local ok, zoneInfo = pcall(function() return trigger.misc.getZone(mashKey) end) + if ok and zoneInfo and zoneInfo.point then + return { x = zoneInfo.point.x, z = zoneInfo.point.z } + end + end + + return nil +end + +-- Find the nearest friendly MASH zone to a given point (x/z expected) +function CTLD:_FindNearestMASHForPoint(point) + if not point then return nil end + + local nearestName, nearestData, nearestPos + local nearestDist = math.huge + + for name, data in pairs(CTLD._mashZones or {}) do + if data.side == self.Side then + local pos = self:_ResolveMASHPosition(data, name) + if pos then + local dx = pos.x - point.x + local dz = pos.z - point.z + local dist = math.sqrt(dx * dx + dz * dz) + if dist < nearestDist then + nearestDist = dist + nearestName = name + nearestData = data + nearestPos = pos + end + end + end + end + + if not nearestData or not nearestPos then + return nil + end + + local displayName = nearestData.displayName or nearestData.catalogKey + if not displayName then + local zone = nearestData.zone + if zone and zone.GetName then + local ok, zname = pcall(function() return zone:GetName() end) + if ok and zname then + displayName = zname + end + end + end + displayName = displayName or nearestName or 'MASH' + + return { + name = displayName, + position = nearestPos, + distance = nearestDist, + data = nearestData, + } +end + +-- Build directional info toward the nearest MASH for a specific unit +function CTLD:_ComputeNearestMASHVector(unit) + if not unit or not unit:IsAlive() then return nil end + local pos = unit:GetPointVec3() + if not pos then return nil end + + local info = self:_FindNearestMASHForPoint({ x = pos.x, z = pos.z }) + if not info or not info.position then return nil end + + local bearing = _bearingDeg({ x = pos.x, z = pos.z }, info.position) + local isMetric = _getPlayerIsMetric(unit) + local rangeValue, rangeUnit = _fmtRange(info.distance, isMetric) + + if rangeUnit == 'm' and rangeValue >= 1000 then + rangeValue = _round(rangeValue / 1000, 1) + rangeUnit = 'km' + end + + local valueText + if math.abs(rangeValue - math.floor(rangeValue)) < 0.05 then + valueText = string.format('%d', math.floor(rangeValue + 0.5)) + else + valueText = string.format('%.1f', rangeValue) + end + + return { + name = info.name, + bearing = bearing, + rangeValue = valueText, + rangeUnit = rangeUnit, + } +end + +-- Check if position is inside any MASH zone +function CTLD:_IsPositionInMASHZone(position) + for zoneName, mashData in pairs(CTLD._mashZones) do + if mashData.side == self.Side then + local zonePos = self:_ResolveMASHPosition(mashData, zoneName) + if zonePos then + local radius = mashData.radius or CTLD.MEDEVAC.MASHZoneRadius or 500 + local dx = position.x - zonePos.x + local dz = position.z - zonePos.z + local dist = math.sqrt(dx*dx + dz*dz) + + if dist <= radius then + return true, mashData + end + end + end + end + return false, nil +end + +-- Initialize MASH zones from config +function CTLD:_InitMASHZones() + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then return end + + _logDebug('_InitMASHZones called for coalition '..tostring(self.Side)) + _logDebug('self.MASHZones count: '..tostring(#(self.MASHZones or {}))) + _logDebug('self.Config.Zones.MASHZones count: '..tostring(#(self.Config.Zones and self.Config.Zones.MASHZones or {}))) + + -- Fixed MASH zones are now initialized via InitZones() in the standard Zones structure + -- This function now focuses on setting up mobile MASH tracking and announcements + + if not CTLD._mashZones then CTLD._mashZones = {} end + + -- Register fixed MASH zones in the global _mashZones table for delivery detection + -- (mobile MASH zones will be added dynamically when built) + for _, mz in ipairs(self.MASHZones or {}) do + local name = mz:GetName() + local zdef = self._ZoneDefs.MASHZones[name] + CTLD._mashZones[name] = { + zone = mz, + side = self.Side, + isMobile = false, + radius = (zdef and zdef.radius) or cfg.MASHZoneRadius or 500, + freq = (zdef and zdef.freq) or nil + } + _logVerbose('[MEDEVAC] Registered fixed MASH zone: '..name) + end +end + +-- ========================= +-- MEDEVAC Menu Functions +-- ========================= + +-- List all active MEDEVAC requests +function CTLD:ListActiveMEDEVACRequests(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local count = 0 + local lines = {} + table.insert(lines, '=== Active MEDEVAC Requests ===') + table.insert(lines, '') + + for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side and data.requestTime then + count = count + 1 + local grid = self:_GetMGRSString(data.position) + local elapsed = timer.getTime() - data.requestTime + local remaining = (cfg.CrewTimeout or 3600) - elapsed + local remainMin = math.floor(remaining / 60) + + table.insert(lines, string.format('%d. %s crew', count, data.vehicleType)) + table.insert(lines, string.format(' Grid: %s', grid)) + table.insert(lines, string.format(' Crew Size: %d', data.crewSize or 2)) + table.insert(lines, string.format(' Salvage: %d points', data.salvageValue or 1)) + table.insert(lines, string.format(' Time Remaining: %d minutes', remainMin)) + table.insert(lines, '') + end + end + + if count == 0 then + table.insert(lines, 'No active MEDEVAC requests.') + table.insert(lines, '') + table.insert(lines, 'MEDEVAC missions appear when friendly vehicles') + table.insert(lines, 'are destroyed and crew survives to call for rescue.') + end + + _msgGroup(group, table.concat(lines, '\n'), 30) +end + +-- Show nearest MEDEVAC location +function CTLD:NearestMEDEVACLocation(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local unit = group:GetUnit(1) + if not unit then return end + + local pos = unit:GetCoordinate() + if not pos then return end + + local nearest = nil + local nearestDist = math.huge + + for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side and data.requestTime then + local dist = math.sqrt((data.position.x - pos.x)^2 + (data.position.z - pos.z)^2) + if dist < nearestDist then + nearestDist = dist + nearest = data + end + end + end + + if not nearest then + _msgGroup(group, 'No active MEDEVAC requests.') + return + end + + local grid = self:_GetMGRSString(nearest.position) + local distKm = nearestDist / 1000 + local distNm = nearestDist / 1852 + local elapsed = timer.getTime() - nearest.requestTime + local remaining = (cfg.CrewTimeout or 3600) - elapsed + local remainMin = math.floor(remaining / 60) + + local lines = {} + table.insert(lines, '=== Nearest MEDEVAC ===') + table.insert(lines, '') + table.insert(lines, string.format('%s crew at %s', nearest.vehicleType, grid)) + table.insert(lines, string.format('Distance: %.1f km / %.1f nm', distKm, distNm)) + table.insert(lines, string.format('Crew Size: %d', nearest.crewSize or 2)) + table.insert(lines, string.format('Salvage Value: %d points', nearest.salvageValue or 1)) + table.insert(lines, string.format('Time Remaining: %d minutes', remainMin)) + + _msgGroup(group, table.concat(lines, '\n'), 20) +end + +-- Show coalition salvage points +function CTLD:ShowSalvagePoints(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local salvage = CTLD._salvagePoints[self.Side] or 0 + + local lines = {} + table.insert(lines, '=== Coalition Salvage Points ===') + table.insert(lines, '') + table.insert(lines, string.format('Current Balance: %d points', salvage)) + table.insert(lines, '') + table.insert(lines, 'Earn salvage by:') + table.insert(lines, '- Rescuing MEDEVAC crews and delivering them to a MASH zone') + table.insert(lines, '') + table.insert(lines, 'Use salvage to:') + table.insert(lines, '- Build items that are out of stock (automatic)') + table.insert(lines, '- Cost = item\'s required crate count') + + _msgGroup(group, table.concat(lines, '\n'), 20) +end + +function CTLD:ShowOnboardManifest(group) + if not group then return end + + local gname = group:GetName() + if not gname or gname == '' then return end + + self:_refreshLoadedTroopSummaryForGroup(gname) + + local lines = { '=== Onboard Manifest ===', '' } + local hasCargo = false + + local crateData = CTLD._loadedCrates[gname] + if crateData and crateData.byKey then + local keys = {} + for crateKey, count in pairs(crateData.byKey) do + if (count or 0) > 0 then + table.insert(keys, crateKey) + end + end + table.sort(keys, function(a, b) + return self:_lookupCrateLabel(a) < self:_lookupCrateLabel(b) + end) + for _, crateKey in ipairs(keys) do + local count = crateData.byKey[crateKey] or 0 + if count > 0 then + table.insert(lines, string.format('Crate: %s x %d', self:_lookupCrateLabel(crateKey), count)) + hasCargo = true + end + end + end + + local troopSummary = CTLD._loadedTroopTypes[gname] + if troopSummary and troopSummary.total and troopSummary.total > 0 then + local typeKeys = {} + for typeKey, _ in pairs(troopSummary.byType) do + if (troopSummary.byType[typeKey] or 0) > 0 then + table.insert(typeKeys, typeKey) + end + end + table.sort(typeKeys, function(a, b) + local la = troopSummary.labels and troopSummary.labels[a] or self:_lookupTroopLabel(a) + local lb = troopSummary.labels and troopSummary.labels[b] or self:_lookupTroopLabel(b) + return la < lb + end) + for _, typeKey in ipairs(typeKeys) do + local count = troopSummary.byType[typeKey] or 0 + if count > 0 then + local label = troopSummary.labels and troopSummary.labels[typeKey] or self:_lookupTroopLabel(typeKey) + table.insert(lines, string.format('Troop: %s x %d', label, count)) + hasCargo = true + end + end + end + + local crews = self:_CollectRescuedCrewsForGroup(gname) + if crews and #crews > 0 then + local crewTotals = {} + for _, crew in ipairs(crews) do + local data = crew.data or {} + local label = data.vehicleType or 'Wounded crew' + local size = data.crewSize or 0 + if size <= 0 then size = 1 end + crewTotals[label] = (crewTotals[label] or 0) + size + end + local labels = {} + for label, _ in pairs(crewTotals) do + table.insert(labels, label) + end + table.sort(labels) + for _, label in ipairs(labels) do + table.insert(lines, string.format('Wounded: %s x %d', label, crewTotals[label])) + end + hasCargo = true + end + + if not hasCargo then + table.insert(lines, 'Nothing onboard.') + end + + local salvage = CTLD._salvagePoints and (CTLD._salvagePoints[self.Side] or 0) or 0 + table.insert(lines, '') + table.insert(lines, string.format('Salvage: %d pts', salvage)) + + _msgGroup(group, table.concat(lines, '\n'), math.min(self.Config.MessageDuration or 20, 25)) +end + +-- Vectors to nearest MEDEVAC (shows top 3 with time remaining) +function CTLD:VectorsToNearestMEDEVAC(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local unit = group:GetUnit(1) + if not unit then return end + + local pos = unit:GetCoordinate() + if not pos then return end + + local heading = unit:GetHeading() or 0 + local isMetric = _getPlayerIsMetric(unit) + + -- Collect all active MEDEVAC requests with distance + local requests = {} + for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side and data.requestTime and not data.pickedUp then + local dist = math.sqrt((data.position.x - pos.x)^2 + (data.position.z - pos.z)^2) + table.insert(requests, { + data = data, + distance = dist + }) + end + end + + if #requests == 0 then + _msgGroup(group, 'No active MEDEVAC requests.') + return + end + + -- Sort by distance (closest first) + table.sort(requests, function(a, b) return a.distance < b.distance end) + + -- Show top 3 (or fewer if less than 3 exist) + local lines = {} + table.insert(lines, 'MEDEVAC VECTORS (nearest 3):') + table.insert(lines, '') + + local maxShow = math.min(3, #requests) + for i = 1, maxShow do + local req = requests[i] + local data = req.data + local dist = req.distance + + local dx = data.position.x - pos.x + local dz = data.position.z - pos.z + local bearing = math.deg(math.atan2(dz, dx)) + if bearing < 0 then bearing = bearing + 360 end + + local relativeBrg = bearing - heading + if relativeBrg < 0 then relativeBrg = relativeBrg + 360 end + if relativeBrg > 180 then relativeBrg = relativeBrg - 360 end + + -- Calculate time remaining + local timeoutAt = data.spawnTime + (cfg.CrewTimeout or 3600) + local timeRemainSec = math.max(0, timeoutAt - timer.getTime()) + local timeRemainMin = math.floor(timeRemainSec / 60) + + -- Format distance + local distV, distU = _fmtRange(dist, isMetric) + + -- Build message for this crew + table.insert(lines, string.format('#%d: %s crew', i, data.vehicleType)) + table.insert(lines, string.format(' BRG %03d° (%+.0f° rel) | RNG %s %s', + math.floor(bearing + 0.5), relativeBrg, distV, distU)) + table.insert(lines, string.format(' Time left: %d min | Salvage: %d pts', + timeRemainMin, data.salvageValue or 1)) + + if i < maxShow then + table.insert(lines, '') + end + end + + _msgGroup(group, table.concat(lines, '\n'), 20) +end + +-- List MASH locations +function CTLD:ListMASHLocations(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local unit = group:GetUnit(1) + local playerPos = unit and unit:GetCoordinate() + local playerVec3 = nil + if playerPos then + if playerPos.GetVec3 then + local ok, vec = pcall(function() return playerPos:GetVec3() end) + if ok then playerVec3 = vec end + elseif playerPos.x and playerPos.z then + playerVec3 = playerPos + end + end + + local count = 0 + local lines = {} + table.insert(lines, '=== MASH Locations ===') + table.insert(lines, '') + + for name, data in pairs(CTLD._mashZones or {}) do + if data.side == self.Side then + count = count + 1 + + -- Get position from zone object + local position = nil + if data.position then + position = data.position + elseif data.zone and data.zone.GetCoordinate then + local coord = data.zone:GetCoordinate() + if coord then + position = {x = coord.x, z = coord.z} + end + end + + local grid = position and self:_GetMGRSString(position) or 'Unknown' + local typeStr = data.isMobile and 'Mobile' or 'Fixed' + local radius = tonumber(data.radius) or 500 + + local label = data.displayName or name + table.insert(lines, string.format('%d. MASH %s (%s)', count, label, typeStr)) + table.insert(lines, string.format(' Grid: %s', grid)) + table.insert(lines, string.format(' Radius: %d m', radius)) + + if playerVec3 and position then + local dist = math.sqrt((position.x - playerVec3.x)^2 + (position.z - playerVec3.z)^2) + local distKm = dist / 1000 + table.insert(lines, string.format(' Distance: %.1f km', distKm)) + end + + if data.freq then + local freq = tonumber(data.freq) + if freq then + table.insert(lines, string.format(' Beacon: %.2f MHz', freq)) + else + table.insert(lines, string.format(' Beacon: %s', tostring(data.freq))) + end + end + + table.insert(lines, '') + end + end + + if count == 0 then + table.insert(lines, 'No MASH zones configured.') + table.insert(lines, '') + table.insert(lines, 'MASH zones are where you deliver rescued') + table.insert(lines, 'MEDEVAC crews to earn salvage points.') + else + table.insert(lines, 'Deliver rescued crews to any MASH to earn salvage.') + end + + _msgGroup(group, table.concat(lines, '\n'), 30) +end + +-- Pop smoke at all active MEDEVAC sites +function CTLD:PopSmokeAtMEDEVACSites(group) + _logVerbose('[MEDEVAC] PopSmokeAtMEDEVACSites called') + + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _logVerbose('[MEDEVAC] MEDEVAC system not enabled') + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + if not CTLD._medevacCrews then + _logVerbose('[MEDEVAC] No _medevacCrews table') + _msgGroup(group, 'No active MEDEVAC requests to mark with smoke.') + return + end + + local count = 0 + _logVerbose(string.format('[MEDEVAC] Checking %d crew entries', CTLD._medevacCrews and table.getn(CTLD._medevacCrews) or 0)) + + for crewGroupName, data in pairs(CTLD._medevacCrews) do + if data and data.side == self.Side and data.requestTime and data.position then + count = count + 1 + _logVerbose(string.format('[MEDEVAC] Popping smoke for crew %s', crewGroupName)) + + local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[self.Side]) or trigger.smokeColor.Red + _spawnMEDEVACSmoke(data.position, smokeColor, cfg) + end + end + + _logVerbose(string.format('[MEDEVAC] Popped smoke at %d locations', count)) + + if count == 0 then + _msgGroup(group, 'No active MEDEVAC requests to mark with smoke.') + else + _msgGroup(group, string.format('Smoke popped at %d MEDEVAC location(s).', count), 10) + end +end + +-- Pop smoke at MASH zones (delivery locations) +function CTLD:PopSmokeAtMASHZones(group) + _logVerbose('[MEDEVAC] PopSmokeAtMASHZones called') + + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _logVerbose('[MEDEVAC] MEDEVAC system not enabled') + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + if not CTLD._mashZones then + _msgGroup(group, 'No MASH zones configured.') + return + end + + local count = 0 + local smokeColor = trigger.smokeColor.Orange + + for name, data in pairs(CTLD._mashZones) do + if data and data.side == self.Side then + -- Get position from zone object + local position = nil + if data.position then + position = data.position + elseif data.zone and data.zone.GetCoordinate then + local coord = data.zone:GetCoordinate() + if coord then + position = {x = coord.x, z = coord.z} + end + end + + if position then + count = count + 1 + _spawnMEDEVACSmoke(position, smokeColor, cfg) + _logVerbose(string.format('[MEDEVAC] Popped smoke at MASH zone: %s', name)) + end + end + end + + if count == 0 then + _msgGroup(group, 'No MASH zones found for your coalition.') + else + _msgGroup(group, string.format('Smoke popped at %d MASH zone(s).', count), 10) + end +end + +-- Clear all MEDEVAC missions (admin function) +function CTLD:ClearAllMEDEVACMissions(group) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _msgGroup(group, 'MEDEVAC system is not enabled.') + return + end + + local count = 0 + + for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do + if data.side == self.Side then + count = count + 1 + self:_RemoveMEDEVACCrew(crewGroupName, 'admin_clear') + end + end + + _msgGroup(group, string.format('Cleared %d MEDEVAC mission(s).', count), 10) + _logVerbose(string.format('[MEDEVAC] Admin cleared %d MEDEVAC missions for coalition %s', count, self.Side)) +end + +-- #endregion MEDEVAC + +-- #region Mobile MASH + +-- Create a Mobile MASH zone and start announcements +function CTLD:_CreateMobileMASH(group, position, catalogDef) + local cfg = CTLD.MEDEVAC + if not cfg or not cfg.Enabled then + _logDebug('[MobileMASH] Config missing or MEDEVAC disabled; aborting mobile deployment') + return + end + if not cfg.MobileMASH or not cfg.MobileMASH.Enabled then + _logDebug('[MobileMASH] MobileMASH feature disabled in config; aborting') + return + end + + if not position or not position.x or not position.z then + _logError('[MobileMASH] Missing build position; aborting Mobile MASH deployment') + return + end + + local groupNamePreview = 'unknown' + if group then + local okPreview, namePreview = pcall(function() return group:getName() end) + if okPreview and namePreview and namePreview ~= '' then groupNamePreview = namePreview end + end + _logVerbose(string.format('[MobileMASH] Build requested for group %s at (%.1f, %.1f)', groupNamePreview, position.x or 0, position.z or 0)) + + local function safeGetName(g) + if not g then return nil end + if g.getName then + local ok, name = pcall(function() return g:getName() end) + if ok and name and name ~= '' then return name end + end + if g.GetName then + local ok, name = pcall(function() return g:GetName() end) + if ok and name and name ~= '' then return name end + end + return nil + end + + local side = catalogDef.side or self.Side + if not side then + _logError('[MobileMASH] Unable to determine coalition side; aborting Mobile MASH deployment') + return + end + _logDebug(string.format('[MobileMASH] Using coalition side %s (%s)', tostring(side), tostring(catalogDef.side or self.Side))) + + CTLD._mobileMASHCounter = CTLD._mobileMASHCounter or { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0 } + CTLD._mobileMASHCounter[side] = (CTLD._mobileMASHCounter[side] or 0) + 1 + local index = CTLD._mobileMASHCounter[side] + _logDebug(string.format('[MobileMASH] Assigned deployment index %d for side %s', index, tostring(side))) + + local mashId = string.format('MOBILE_MASH_%d_%d', side, index) + local displayName + if cfg.MobileMASH.AutoIncrementName == false then + displayName = catalogDef.description or mashId + else + displayName = string.format('Mobile MASH %d', index) + end + _logDebug(string.format('[MobileMASH] mashId=%s displayName=%s recipeDesc=%s', mashId, tostring(displayName), tostring(catalogDef.description))) + + local initialPos = { x = position.x, z = position.z } + local radius = cfg.MobileMASH.ZoneRadius or 500 + local beaconFreq = cfg.MobileMASH.BeaconFrequency or '30.0 FM' + local mashGroupName = safeGetName(group) + _logDebug(string.format('[MobileMASH] Initial position (%.1f, %.1f) radius %.1f freq %s groupName=%s', initialPos.x or 0, initialPos.z or 0, radius, tostring(beaconFreq), tostring(mashGroupName))) + + local function buildZoneObject(name, r, pos) + if ZONE_RADIUS and VECTOR2 and VECTOR2.New then + local ok, zoneObj = pcall(function() + local v2 = VECTOR2:New(pos.x, pos.z) + return ZONE_RADIUS:New(name, v2, r) + end) + if ok and zoneObj then + _logDebug('[MobileMASH] Created ZONE_RADIUS object for mobile MASH') + return zoneObj + end + if not ok then + _logDebug(string.format('[MobileMASH] ZONE_RADIUS creation failed: %s', tostring(zoneObj))) + end + end + local posCopy = { x = pos.x, z = pos.z } + _logDebug('[MobileMASH] Falling back to table-based zone representation') + local zoneObj = {} + function zoneObj:GetName() + return name + end + function zoneObj:GetPointVec3() + return { x = posCopy.x, y = 0, z = posCopy.z } + end + function zoneObj:GetRadius() + return r + end + function zoneObj:SetPointVec3(vec3) + if vec3 and vec3.x and vec3.z then + posCopy.x = vec3.x + posCopy.z = vec3.z + end + end + function zoneObj:SetVec2(vec2) + if vec2 and vec2.x and vec2.y then + posCopy.x = vec2.x + posCopy.z = vec2.y + end + end + return zoneObj + end + + local rawGroupHandle = group + + local function finalizeMobileMASH() + _logVerbose(string.format('[MobileMASH] Finalizing Mobile MASH %s', mashId)) + local mashGroupMoose = nil + if GROUP and GROUP.FindByName and not mashGroupName then + local ok, found = pcall(function() + -- coalition.addGroup sometimes renames groups; scan by coalition + if rawGroupHandle and rawGroupHandle.getName then + return GROUP:FindByName(rawGroupHandle:getName()) + end + return nil + end) + if ok and found then + mashGroupMoose = found + mashGroupName = mashGroupName or safeGetName(found) + end + elseif GROUP and GROUP.FindByName and mashGroupName then + local ok, found = pcall(function() return GROUP:FindByName(mashGroupName) end) + if ok and found then mashGroupMoose = found end + end + + local function resolveRawGroup() + if rawGroupHandle and rawGroupHandle.isExist and rawGroupHandle:isExist() then + return rawGroupHandle + end + if mashGroupName and Group and Group.getByName then + local ok, g = pcall(function() return Group.getByName(mashGroupName) end) + if ok and g then + rawGroupHandle = g + if g.isExist and g:isExist() then + return rawGroupHandle + end + elseif not ok then + _logDebug(string.format('[MobileMASH] resolveRawGroup Group.getByName error: %s', tostring(g))) + end + end + return nil + end + + local function groupIsAlive() + if mashGroupMoose and mashGroupMoose.IsAlive then + local ok, alive = pcall(function() return mashGroupMoose:IsAlive() end) + if ok and alive then return true end + if not ok then + _logDebug(string.format('[MobileMASH] groupIsAlive Moose check error: %s', tostring(alive))) + end + end + local g = resolveRawGroup() + if not g then return false end + local units = g:getUnits() + if not units then return false end + for _, u in ipairs(units) do + if u and u.isExist and u:isExist() then + return true + end + end + return false + end + + local function groupVec3() + if mashGroupMoose and mashGroupMoose.GetCoordinate then + local ok, coord = pcall(function() return mashGroupMoose:GetCoordinate() end) + if ok and coord then + local vec3 = coord.GetVec3 and coord:GetVec3() + if vec3 then return vec3 end + end + if not ok then + _logDebug(string.format('[MobileMASH] groupVec3 Moose coordinate error: %s', tostring(coord))) + end + end + local g = resolveRawGroup() + if g then + local units = g:getUnits() + if units and units[1] and units[1].getPoint then + local ok, point = pcall(function() return units[1]:getPoint() end) + if ok and point then return point end + end + end + return nil + end + + local zoneObj = buildZoneObject(displayName, radius, initialPos) + CTLD._mashZones = CTLD._mashZones or {} + + local mashData = { + id = mashId, + displayName = displayName, + position = { x = initialPos.x, z = initialPos.z }, + radius = radius, + side = side, + group = mashGroupMoose or rawGroupHandle, + groupName = mashGroupName, + isMobile = true, + catalogKey = catalogDef.description or 'Mobile MASH', + zone = zoneObj, + freq = beaconFreq, + } + + CTLD._mashZones[mashId] = mashData + _logDebug(string.format('[MobileMASH] Registered mashId=%s displayName=%s zoneRadius=%.1f freq=%s', mashId, displayName, radius, tostring(beaconFreq))) + + self._ZoneDefs = self._ZoneDefs or { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {} } + self._ZoneDefs.MASHZones = self._ZoneDefs.MASHZones or {} + self._ZoneDefs.MASHZones[displayName] = { name = displayName, radius = radius, active = true, freq = beaconFreq } + + self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } + self._ZoneActive.MASH = self._ZoneActive.MASH or {} + self._ZoneActive.MASH[displayName] = true + + -- Add zone to MASHZones array so it's recognized by the system + self.MASHZones = self.MASHZones or {} + table.insert(self.MASHZones, zoneObj) + _logDebug(string.format('[MobileMASH] Added zone to MASHZones array, total count: %d', #self.MASHZones)) + + -- Add to MEDEVAC zones if MEDEVAC is active + if self.MEDEVAC then + self.MEDEVAC:AddZone(displayName, zoneObj) + _logInfo(string.format('[MobileMASH] Added zone to MEDEVAC system: %s', displayName)) + -- Refresh MEDEVAC menu to include the new zone + self.MEDEVAC:__Start(1) + end + + local md = self.Config and self.Config.MapDraw or {} + if md.Enabled then + local ok, err = pcall(function() self:DrawZonesOnMap() end) + if not ok then + _logError(string.format('DrawZonesOnMap failed after Mobile MASH creation: %s', tostring(err))) + end + else + local circleId = _nextMarkupId() + local textId = _nextMarkupId() + local p = { x = initialPos.x, y = 0, z = initialPos.z } + + local colors = cfg.MASHZoneColors or {} + local borderColor = colors.border or {1, 1, 0, 0.85} + local fillColor = colors.fill or {1, 0.75, 0.8, 0.25} + + trigger.action.circleToCoalition(side, circleId, p, radius, borderColor, fillColor, 1, true, "") + + local textPos = { x = p.x, y = 0, z = p.z - radius - 50 } + trigger.action.textToCoalition(side, textId, textPos, {1,1,1,0.9}, {0,0,0,0}, 18, true, displayName) + + mashData.circleId = circleId + mashData.textId = textId + _logDebug(string.format('[MobileMASH] Drawn map circleId=%d textId=%d', circleId, textId)) + end + + local gridStr = self:_GetMGRSString(initialPos) + trigger.action.outTextForCoalition(side, _fmtTemplate(CTLD.Messages.medevac_mash_deployed, { + mash_id = index, + grid = gridStr, + freq = beaconFreq, + }), 30) + _logInfo(string.format('[MobileMASH] Mobile MASH "%s" registered at %s', displayName, gridStr)) + + if cfg.MobileMASH.AnnouncementInterval and cfg.MobileMASH.AnnouncementInterval > 0 then + local ctldInstance = self + local scheduler = SCHEDULER:New(nil, function() + local ok, err = pcall(function() + if not groupIsAlive() then + ctldInstance:_RemoveMobileMASH(mashId) + return + end + + local vec3 = groupVec3() + if vec3 then + mashData.position = { x = vec3.x, z = vec3.z } + if mashData.zone then + if mashData.zone.SetPointVec3 then + mashData.zone:SetPointVec3({ x = vec3.x, y = vec3.y or 0, z = vec3.z }) + elseif mashData.zone.SetVec2 then + mashData.zone:SetVec2({ x = vec3.x, y = vec3.z }) + end + end + local currentGrid = ctldInstance:_GetMGRSString({ x = vec3.x, z = vec3.z }) + trigger.action.outTextForCoalition(side, _fmtTemplate(CTLD.Messages.medevac_mash_announcement, { + mash_id = index, + grid = currentGrid, + freq = beaconFreq, + }), 20) + _logDebug(string.format('[MobileMASH] Announcement tick for %s at grid %s', displayName, tostring(currentGrid))) + end + end) + if not ok then _logError('Mobile MASH announcement scheduler error: '..tostring(err)) end + end, {}, cfg.MobileMASH.AnnouncementInterval, cfg.MobileMASH.AnnouncementInterval) + + mashData.scheduler = scheduler + _logDebug(string.format('[MobileMASH] Announcement scheduler started every %.1fs', cfg.MobileMASH.AnnouncementInterval)) + end + + -- Create a separate frequent position update scheduler for mobile MASH tracking + -- This ensures the zone follows the vehicle even if announcements are infrequent + local ctldInstance = self + local positionUpdateInterval = 5 -- Update position every 5 seconds + local posScheduler = SCHEDULER:New(nil, function() + local ok, err = pcall(function() + if not groupIsAlive() then + ctldInstance:_RemoveMobileMASH(mashId) + return + end + + local vec3 = groupVec3() + if vec3 then + mashData.position = { x = vec3.x, z = vec3.z } + if mashData.zone then + if mashData.zone.SetPointVec3 then + mashData.zone:SetPointVec3({ x = vec3.x, y = vec3.y or 0, z = vec3.z }) + elseif mashData.zone.SetVec2 then + mashData.zone:SetVec2({ x = vec3.x, y = vec3.z }) + end + end + _logDebug(string.format('[MobileMASH] Position updated for %s at (%.1f, %.1f)', displayName, vec3.x, vec3.z)) + end + end) + if not ok then _logError('Mobile MASH position update scheduler error: '..tostring(err)) end + end, {}, positionUpdateInterval, positionUpdateInterval) + + mashData.positionScheduler = posScheduler + _logDebug(string.format('[MobileMASH] Position update scheduler started every %ds', positionUpdateInterval)) + + if EVENTHANDLER then + local ctldInstance = self + local eventHandler = EVENTHANDLER:New() + eventHandler:HandleEvent(EVENTS.Dead) + + function eventHandler:OnEventDead(EventData) + local killedName = EventData.IniGroupName or (EventData.IniGroup and EventData.IniGroup:GetName()) + if killedName and killedName == mashGroupName then + ctldInstance:_RemoveMobileMASH(mashId) + end + end + + mashData.eventHandler = eventHandler + _logDebug(string.format('[MobileMASH] Event handler registered for group %s', tostring(mashGroupName))) + end + end + + if timer and timer.scheduleFunction and timer.getTime then + _logDebug('[MobileMASH] Scheduling finalizeMobileMASH via timer') + timer.scheduleFunction(function(_args, _time) + local ok, err = pcall(finalizeMobileMASH) + if not ok then + _logError(string.format('[MobileMASH] finalize failed: %s', tostring(err))) + end + return nil + end, {}, timer.getTime() + 0.2) + else + _logDebug('[MobileMASH] timer.scheduleFunction unavailable, running finalizeMobileMASH inline') + local ok, err = pcall(finalizeMobileMASH) + if not ok then + _logError(string.format('[MobileMASH] finalize failed: %s', tostring(err))) + end + end +end + +-- Remove a Mobile MASH zone (on destruction or manual removal) +function CTLD:_RemoveMobileMASH(mashId) + if not CTLD._mashZones then return end + + local mash = CTLD._mashZones[mashId] + if mash then + -- Stop schedulers + if mash.scheduler then + mash.scheduler:Stop() + end + if mash.positionScheduler then + mash.positionScheduler:Stop() + end + + -- Remove map drawings + if mash.circleId then trigger.action.removeMark(mash.circleId) end + if mash.textId then trigger.action.removeMark(mash.textId) end + local name = mash.displayName or mashId + if self._ZoneDefs and self._ZoneDefs.MASHZones then self._ZoneDefs.MASHZones[name] = nil end + if self._ZoneActive and self._ZoneActive.MASH then self._ZoneActive.MASH[name] = nil end + self:_removeZoneDrawing('MASH', name) + + -- Remove from MASHZones array + if self.MASHZones and mash.zone then + for i = #self.MASHZones, 1, -1 do + if self.MASHZones[i] == mash.zone then + table.remove(self.MASHZones, i) + _logDebug(string.format('[MobileMASH] Removed zone from MASHZones array, remaining count: %d', #self.MASHZones)) + break + end + end + end + + -- Send destruction message + local msg = _fmtTemplate(CTLD.Messages.medevac_mash_destroyed, { + mash_id = string.match(mashId, 'MOBILE_MASH_%d+_(%d+)') or '?' + }) + trigger.action.outTextForCoalition(mash.side, msg, 20) + + -- Remove from table + CTLD._mashZones[mashId] = nil + _logVerbose(string.format('[MobileMASH] Removed MASH %s', mashId)) + if self.Config and self.Config.MapDraw and self.Config.MapDraw.Enabled then + pcall(function() self:DrawZonesOnMap() end) + end + end +end + +-- #endregion Mobile MASH + +-- #endregion Inventory helpers + +-- Create a new Drop Zone (AO) at the player's current location and draw it on the map if enabled +function CTLD:CreateDropZoneAtGroup(group) + if not group or not group:IsAlive() then return end + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + -- Prevent creating a Drop Zone inside or too close to a Pickup Zone + -- 1) Block if inside a (potentially active-only) pickup zone + local activeOnlyForInside = (self.Config and self.Config.ForbidChecksActivePickupOnly ~= false) + local inside, pz, distInside, pr = self:_isUnitInsidePickupZone(unit, activeOnlyForInside) + if inside then + local isMetric = _getPlayerIsMetric(unit) + local curV, curU = _fmtRange(distInside or 0, isMetric) + local needV, needU = _fmtRange(self.Config.MinDropZoneDistanceFromPickup or 10000, isMetric) + _eventSend(self, group, nil, 'drop_zone_too_close_to_pickup', { + zone = (pz and pz.GetName and pz:GetName()) or '(pickup)', + need = needV, need_u = needU, + dist = curV, dist_u = curU, + }) + return + end + -- 2) Enforce a minimum distance from the nearest pickup zone (configurable) + local minD = tonumber(self.Config and self.Config.MinDropZoneDistanceFromPickup) or 0 + if minD > 0 then + local considerActive = (self.Config and self.Config.MinDropDistanceActivePickupOnly ~= false) + local nearestZone, nearestDist + if considerActive then + nearestZone, nearestDist = self:_nearestActivePickupZone(unit) + else + local list = (self.Config and self.Config.Zones and self.Config.Zones.PickupZones) or {} + nearestZone, nearestDist = _nearestZonePoint(unit, list) + end + if nearestZone and nearestDist and nearestDist < minD then + local isMetric = _getPlayerIsMetric(unit) + local needV, needU = _fmtRange(minD, isMetric) + local curV, curU = _fmtRange(nearestDist, isMetric) + _eventSend(self, group, nil, 'drop_zone_too_close_to_pickup', { + zone = (nearestZone and nearestZone.GetName and nearestZone:GetName()) or '(pickup)', + need = needV, need_u = needU, + dist = curV, dist_u = curU, + }) + return + end + end + local p = unit:GetPointVec3() + local baseName = group:GetName() or 'GROUP' + local safe = tostring(baseName):gsub('%W', '') + local name = string.format('AO_%s_%d', safe, math.random(100000,999999)) + local r = tonumber(self.Config and self.Config.DropZoneRadius) or 250 + local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(p.x, p.z) or { x = p.x, y = p.z } + local mz = ZONE_RADIUS:New(name, v2, r) + -- Register in runtime and config so other features can find it + self.DropZones = self.DropZones or {} + table.insert(self.DropZones, mz) + self._ZoneDefs = self._ZoneDefs or { PickupZones = {}, DropZones = {}, FOBZones = {} } + self._ZoneDefs.DropZones[name] = { name = name, radius = r, active = true } + self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {} } + self._ZoneActive.Drop[name] = true + self.Config.Zones = self.Config.Zones or { PickupZones = {}, DropZones = {}, FOBZones = {} } + self.Config.Zones.DropZones = self.Config.Zones.DropZones or {} + table.insert(self.Config.Zones.DropZones, { name = name, radius = r, active = true }) + -- Draw on map if configured + local md = self.Config and self.Config.MapDraw or {} + if md.Enabled and (md.DrawDropZones ~= false) then + local ok, err = pcall(function() self:DrawZonesOnMap() end) + if not ok then + _logError(string.format('DrawZonesOnMap failed after creating drop zone %s: %s', name, tostring(err))) + end + end + MESSAGE:New(string.format('Drop Zone created: %s (r≈%dm)', name, r), 10):ToGroup(group) +end + +function CTLD:AddPickupZone(z) + local mz = _findZone(z) + if mz then table.insert(self.PickupZones, mz); table.insert(self.Config.Zones.PickupZones, z) end +end + +function CTLD:AddDropZone(z) + local mz = _findZone(z) + if mz then table.insert(self.DropZones, mz); table.insert(self.Config.Zones.DropZones, z) end +end + +function CTLD:SetAllowedAircraft(list) + self.Config.AllowedAircraft = DeepCopy(list) +end + +-- Explicit cleanup handler for mission end +-- Call this to properly shut down all CTLD schedulers and clear state +function CTLD:Cleanup() + _logInfo('Cleanup initiated - stopping all schedulers and clearing state') + + -- Stop all smoke refresh schedulers + if CTLD._smokeRefreshSchedules then + for crateId, schedule in pairs(CTLD._smokeRefreshSchedules) do + if schedule.funcId then + pcall(function() timer.removeFunction(schedule.funcId) end) + end + end + CTLD._smokeRefreshSchedules = {} + end + + -- Stop all Mobile MASH schedulers + if CTLD._mashZones then + for mashId, mash in pairs(CTLD._mashZones) do + if mash.scheduler then + pcall(function() mash.scheduler:Stop() end) + end + if mash.eventHandler then + -- Event handlers clean themselves up, but we can nil the reference + mash.eventHandler = nil + end + end + end + + -- Stop any MEDEVAC timeout checkers or other schedulers + -- (If you add schedulers in the future, stop them here) + if self.MEDEVACSched then + pcall(function() self.MEDEVACSched:Stop() end) + self.MEDEVACSched = nil + end + if self.SalvageSched then + pcall(function() self.SalvageSched:Stop() end) + self.SalvageSched = nil + end + + -- Clear spatial grid + CTLD._spatialGrid = {} + + -- Clear state tables (optional - helps with memory in long-running missions) + CTLD._crates = {} + CTLD._troopsLoaded = {} + CTLD._loadedCrates = {} + CTLD._loadedTroopTypes = {} + CTLD._deployedTroops = {} + CTLD._hoverState = {} + CTLD._unitLast = {} + CTLD._coachState = {} + CTLD._msgState = {} + CTLD._buildConfirm = {} + CTLD._buildCooldown = {} + CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } + if self._loadedCrateMenus then + for _,state in pairs(self._loadedCrateMenus) do + if state and state.commands then + for _,cmd in ipairs(state.commands) do + if cmd and cmd.Remove then pcall(function() cmd:Remove() end) end + end + end + end + self._loadedCrateMenus = {} + end + + -- Clear salvage state + if CTLD._salvageCrates then + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + end + CTLD._salvageCrates = {} + end + if self.JTACSched then + pcall(function() self.JTACSched:Stop() end) + self.JTACSched = nil + end + if self._jtacRegistry then + for groupName in pairs(self._jtacRegistry) do + self:_cleanupJTACEntry(groupName) + end + self._jtacRegistry = {} + end + + _logInfo('Cleanup complete') +end + +-- Register mission end event to auto-cleanup +-- This ensures resources are properly released +if not CTLD._cleanupHandlerRegistered then + CTLD._cleanupHandlerRegistered = true + + local cleanupHandler = EVENTHANDLER:New() + cleanupHandler:HandleEvent(EVENTS.MissionEnd) + + function cleanupHandler:OnEventMissionEnd(EventData) + _logInfo('Mission end detected - initiating cleanup') + -- Cleanup all instances + for _, instance in pairs(CTLD._instances or {}) do + if instance and instance.Cleanup then + pcall(function() instance:Cleanup() end) + end + end + -- Also call static cleanup + if CTLD.Cleanup then + pcall(function() CTLD:Cleanup() end) + end + end +end + +-- #endregion Public helpers + +-- ========================= +-- Sling-Load Salvage System +-- ========================= +-- #region SlingLoadSalvage + +-- Spawn a salvage crate when an enemy ground unit dies +function CTLD:_SpawnSlingLoadSalvageCrate(unitPos, unitTypeName, enemySide, eventData) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + -- Check spawn chance for this coalition + local spawnChance = cfg.SpawnChance[enemySide] or 0.15 + if math.random() > spawnChance then + _logVerbose(string.format('[SlingLoadSalvage] Spawn roll failed (%.2f chance)', spawnChance)) + return + end + + -- Check spawn restrictions + if cfg.NoSpawnNearPickupZones then + local minDist = cfg.NoSpawnNearPickupZoneDistance or 1000 + for _, zone in ipairs(self.PickupZones or {}) do + local zoneName = zone:GetName() + if zoneName and (self._ZoneActive.Pickup[zoneName] ~= false) then + local zonePos = zone:GetPointVec3() + local dist = math.sqrt((unitPos.x - zonePos.x)^2 + (unitPos.z - zonePos.z)^2) + if dist < minDist then + _logVerbose('[SlingLoadSalvage] Too close to pickup zone, aborting spawn') + return + end + end + end + end + + if cfg.NoSpawnNearAirbasesKm and cfg.NoSpawnNearAirbasesKm > 0 then + local airbases = coalition.getAirbases(enemySide) + if airbases then + local minDistKm = cfg.NoSpawnNearAirbasesKm * 1000 + for _, ab in ipairs(airbases) do + local abPos = ab:getPoint() + local dist = math.sqrt((unitPos.x - abPos.x)^2 + (unitPos.z - abPos.z)^2) + if dist < minDistKm then + _logVerbose('[SlingLoadSalvage] Too close to airbase, aborting spawn') + return + end + end + end + end + + -- Select weight class + local totalProb = 0 + for _, wc in ipairs(cfg.WeightClasses) do + totalProb = totalProb + wc.probability + end + local roll = math.random() * totalProb + local cumulative = 0 + local selectedClass = cfg.WeightClasses[1] -- fallback + for _, wc in ipairs(cfg.WeightClasses) do + cumulative = cumulative + wc.probability + if roll <= cumulative then + selectedClass = wc + break + end + end + + local weight = math.random(selectedClass.min, selectedClass.max) + local rewardValue = math.floor((weight / 500) * selectedClass.rewardPer500kg) + + -- Calculate spawn position + local minDist = cfg.MinSpawnDistance or 10 + local maxDist = cfg.MaxSpawnDistance or 25 + local distance = minDist + math.random() * (maxDist - minDist) + local angle = math.random() * 2 * math.pi + local spawnPos = { + x = unitPos.x + math.cos(angle) * distance, + z = unitPos.z + math.sin(angle) * distance + } + + -- Get land height + local landHeight = land.getHeight({ x = spawnPos.x, y = spawnPos.z }) + + -- Select cargo type based on weight + local cargoType + if weight < 1500 then + -- Light: barrels or ammo pallets + local lightTypes = { 'barrels_cargo', 'ammo_cargo' } + cargoType = lightTypes[math.random(1, #lightTypes)] + elseif weight < 2500 then + -- Medium: fuel tanks or containers + local mediumTypes = { 'fueltank_cargo', 'container_cargo', 'ammo_cargo' } + cargoType = mediumTypes[math.random(1, #mediumTypes)] + else + -- Heavy: large containers only + cargoType = 'container_cargo' + end + + -- Create unique crate name + -- Use prefix that matches the coalition allowed to collect this crate + local sidePrefix = (enemySide == coalition.side.BLUE) and 'B' or 'R' + local crateName = string.format('SALVAGE-%s-%04dKG-%06d', sidePrefix, weight, math.random(100000, 999999)) + + -- Enforce active salvage crate cap before spawning + if cfg.MaxActiveCrates then + local activeCount = 0 + for cname, meta in pairs(CTLD._salvageCrates or {}) do + if meta and meta.side == enemySide then + activeCount = activeCount + 1 + end + end + if activeCount >= cfg.MaxActiveCrates then + _logVerbose(string.format('[SlingLoadSalvage] Max active crates (%d) reached for side %d; skipping spawn', cfg.MaxActiveCrates, enemySide)) + return + end + end + + -- Spawn the static cargo + -- Spawn the crate for the coalition that can recover it (enemySide) + local countryId = nil + if CTLD._instances then + for _, inst in ipairs(CTLD._instances) do + if inst and inst.Side == enemySide and inst.CountryId then + countryId = inst.CountryId + break + end + end + end + if not countryId then + countryId = _defaultCountryForSide(enemySide) + end + + local staticData = { + ['type'] = cargoType, + ['name'] = crateName, + ['x'] = spawnPos.x, + ['y'] = spawnPos.z, + ['heading'] = math.random() * 2 * math.pi, + ['canCargo'] = true, + ['mass'] = weight, + } + + local success, staticObj = pcall(function() + return coalition.addStaticObject(countryId, staticData) + end) + + if not success or not staticObj then + _logError('[SlingLoadSalvage] Failed to spawn salvage crate: ' .. tostring(staticObj)) + return + end + + -- Store crate metadata + CTLD._salvageCrates[crateName] = { + side = enemySide, + weight = weight, + spawnTime = timer.getTime(), + position = spawnPos, + initialHealth = 1.0, + rewardValue = rewardValue, + warningsSent = {}, + staticObject = staticObj, + crateClass = selectedClass.name, + } + + -- Update stats + if not CTLD._salvageStats[enemySide] then + CTLD._salvageStats[enemySide] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 } + end + CTLD._salvageStats[enemySide].spawned = CTLD._salvageStats[enemySide].spawned + 1 + + -- Spawn smoke if enabled (use unified crate smoke offset logic) + if cfg.SpawnSmoke then + local smokeColor = cfg.SmokeColor or trigger.smokeColor.Orange + -- Reuse crate smoke offset parameters but force Enabled for salvage spawn event + local baseCfg = self.Config.CrateSmoke or {} + local smokeConfig = { + Enabled = true, -- always allow initial salvage smoke when SlingLoadSalvage.SpawnSmoke = true + AutoRefresh = false, -- do not auto-refresh salvage smoke unless we explicitly add support later + RefreshInterval = baseCfg.RefreshInterval, + MaxRefreshDuration = baseCfg.MaxRefreshDuration, + OffsetMeters = baseCfg.OffsetMeters, + OffsetRandom = (baseCfg.OffsetRandom ~= false), + OffsetVertical = baseCfg.OffsetVertical, + } + -- Provide a position table compatible with _spawnCrateSmoke (y = ground height) + _spawnCrateSmoke({ x = spawnPos.x, y = landHeight, z = spawnPos.z }, smokeColor, smokeConfig, crateName) + end + + -- Calculate expiration time + local lifetime = cfg.CrateLifetime or 10800 + local timeRemainMin = math.floor(lifetime / 60) + local timeRemainHrs = math.floor(timeRemainMin / 60) + local timeRemainStr + if timeRemainHrs >= 1 then + timeRemainStr = string.format("%d hr%s", timeRemainHrs, timeRemainHrs > 1 and "s" or "") + else + timeRemainStr = string.format("%d min%s", timeRemainMin, timeRemainMin > 1 and "s" or "") + end + local grid = self:_GetMGRSString(spawnPos) + + -- Announce to coalition + local msg = _fmtTemplate(self.Messages.slingload_salvage_spawned, { + grid = grid, + weight = weight, + reward = rewardValue, + time_remain = timeRemainStr, + }) + _msgCoalition(enemySide, msg) + + _logInfo(string.format('[SlingLoadSalvage] Spawned %s: weight=%dkg, reward=%dpts at %s', + crateName, weight, rewardValue, grid)) +end + +-- Check salvage crates for delivery and cleanup +function CTLD:_CheckSlingLoadSalvageCrates() + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local now = timer.getTime() + local cratesToRemove = {} + + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.side == self.Side then + local elapsed = now - meta.spawnTime + local lifetime = cfg.CrateLifetime or 10800 + + -- Check for expiration (skip for manual crates) + if elapsed >= lifetime and not meta.isManual then + table.insert(cratesToRemove, crateName) + + -- Update stats + CTLD._salvageStats[meta.side].expired = CTLD._salvageStats[meta.side].expired + 1 + + -- Announce expiration + local grid = self:_GetMGRSString(meta.position) + local msg = _fmtTemplate(self.Messages.slingload_salvage_expired, { + id = crateName, + grid = grid, + }) + _msgCoalition(meta.side, msg) + + -- Remove the static object + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + + _logVerbose(string.format('[SlingLoadSalvage] Crate %s expired', crateName)) + + else + -- Check for warnings (skip for manual crates) + if not meta.isManual then + local remaining = lifetime - elapsed + for _, warnTime in ipairs(cfg.WarningTimes or { 1800, 300 }) do + if remaining <= warnTime and not meta.warningsSent[warnTime] then + meta.warningsSent[warnTime] = true + local grid = self:_GetMGRSString(meta.position) + local msgKey = (warnTime >= 1800) and 'slingload_salvage_warn_30min' or 'slingload_salvage_warn_5min' + local msg = _fmtTemplate(self.Messages[msgKey], { + id = crateName, + grid = grid, + weight = meta.weight, + }) + _msgCoalition(meta.side, msg) + end + end + end + + -- Check if crate is in a salvage zone + if meta.staticObject and meta.staticObject:isExist() then + local cratePos = meta.staticObject:getPoint() + if cratePos then + -- Check all salvage zones for this coalition + for _, zone in ipairs(self.SalvageDropZones or {}) do + local zoneName = zone:GetName() + local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] + + if zoneDef and zoneDef.side == meta.side and (self._ZoneActive.SalvageDrop[zoneName] ~= false) then + -- cratePos is a DCS Vec3 table, so use the direct Vec3 helper to avoid GetVec2 calls + if zone:IsVec3InZone(cratePos) then + -- Simple CTLD.lua style: just check if crate is in air + local crateHooked = _isCrateHooked(meta.staticObject) + + if not crateHooked then + -- Crate is on the ground in the zone - deliver it! + _logInfo(string.format('[SlingLoadSalvage] Delivering %s', crateName)) + self:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) + table.insert(cratesToRemove, crateName) + break + else + -- Crate is still hooked - send hint and wait for release + self:_SendSalvageHint(meta, 'slingload_salvage_hooked_in_zone', { + id = crateName, + zone = zoneName, + }, cratePos, 8) + _logDebug(string.format('[SlingLoadSalvage] Crate %s still hooked, waiting for release', crateName)) + end + end + end + end + -- Provide guidance if crate is lingering inside other zone types + self:_CheckCrateZoneHints(crateName, meta, cratePos) + end + else + -- Crate no longer exists (destroyed or removed) + table.insert(cratesToRemove, crateName) + _logVerbose(string.format('[SlingLoadSalvage] Crate %s no longer exists', crateName)) + end + end + end + end + + -- Remove processed crates + for _, crateName in ipairs(cratesToRemove) do + CTLD._salvageCrates[crateName] = nil + end +end + +-- Deliver a salvage crate and award points +function CTLD:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) + local cfg = self.Config.SlingLoadSalvage + + -- Check crate health for condition multiplier + local healthRatio = 1.0 + if meta.staticObject and meta.staticObject.getLife then + local success, currentLife = pcall(function() return meta.staticObject:getLife() end) + if success and currentLife then + local success2, maxLife = pcall(function() return meta.staticObject:getLife0() end) + if success2 and maxLife and maxLife > 0 then + healthRatio = currentLife / maxLife + end + end + end + + -- Determine condition multiplier + local conditionMult = cfg.ConditionMultipliers.Damaged or 1.0 + local conditionLabel = "Damaged" + if healthRatio >= 0.9 then + conditionMult = cfg.ConditionMultipliers.Undamaged or 1.5 + conditionLabel = "Undamaged" + elseif healthRatio < 0.5 then + conditionMult = cfg.ConditionMultipliers.HeavyDamage or 0.5 + conditionLabel = "Heavy Damage" + end + + -- Calculate final reward + local finalReward = math.floor(meta.rewardValue * conditionMult) + + -- Award salvage points + CTLD._salvagePoints[meta.side] = (CTLD._salvagePoints[meta.side] or 0) + finalReward + + -- Update stats + CTLD._salvageStats[meta.side].delivered = CTLD._salvageStats[meta.side].delivered + 1 + CTLD._salvageStats[meta.side].totalWeight = CTLD._salvageStats[meta.side].totalWeight + meta.weight + CTLD._salvageStats[meta.side].totalReward = CTLD._salvageStats[meta.side].totalReward + finalReward + + -- Find the player who delivered (nearest transport helo in zone) + local playerName = "Unknown Pilot" + local deliveryUnit = nil + for _, zone in ipairs(self.SalvageDropZones or {}) do + if zone:GetName() == zoneName then + -- Find nearby friendly helicopters + local zonePos = zone:GetPointVec3() + local radius = self:_getZoneRadius(zone) or 300 + local nearbyUnits = {} + + -- Search for units in the zone + local sphere = _buildSphereVolume(zonePos, radius) + + local foundUnits = {} + world.searchObjects(Object.Category.UNIT, sphere, function(obj) + if obj and obj:isExist() and obj.getCoalition then + local objCoal = obj:getCoalition() + if objCoal == meta.side and obj.getGroup then + local grp = obj:getGroup() + if grp then + local grpName = grp:getName() + table.insert(foundUnits, { unit = obj, group = grp, groupName = grpName }) + end + end + end + return true + end) + + -- Find player name from group + if #foundUnits > 0 then + deliveryUnit = foundUnits[1].unit + local grpName = foundUnits[1].groupName + if grpName then + -- Try to extract player name from group + local mooseGrp = GROUP:FindByName(grpName) + if mooseGrp then + local unit1 = mooseGrp:GetUnit(1) + if unit1 then + local pName = unit1:GetPlayerName() + if pName and pName ~= '' then + playerName = pName + else + playerName = grpName + end + end + end + end + end + break + end + end + + -- Announce delivery + local msg = _fmtTemplate(self.Messages.slingload_salvage_delivered, { + player = playerName, + weight = meta.weight, + reward = finalReward, + condition = conditionLabel, + total = CTLD._salvagePoints[meta.side], + }) + local quip + local quipPool = self.Messages.slingload_salvage_received_quips + if quipPool and #quipPool > 0 then + local template = quipPool[math.random(#quipPool)] + if template and template ~= '' then + quip = _fmtTemplate(template, { + player = playerName, + zone = zoneName, + reward = finalReward, + weight = meta.weight, + condition = conditionLabel, + total = CTLD._salvagePoints[meta.side], + coalition = (self.Side == coalition.side.BLUE) and 'BLUE' or 'RED', + }) + end + end + if quip and quip ~= '' then + msg = msg .. '\n' .. quip + end + _msgCoalition(meta.side, msg) + + -- Remove the crate + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + + _logInfo(string.format('[SlingLoadSalvage] %s delivered %s: %dkg, %dpts (%s), total=%d', + playerName, crateName, meta.weight, finalReward, conditionLabel, CTLD._salvagePoints[meta.side])) +end + +-- Menu: Create Salvage Zone at group position +function CTLD:CreateSalvageZoneAtGroup(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then + _msgGroup(group, 'Sling-Load Salvage system is disabled.') + return + end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local coord = COORDINATE:NewFromVec3(pos) + local radius = cfg.DefaultZoneRadius or 300 + + -- Check for nearby salvage cargo (prevent zone placement within 1km of cargo) + local minDistance = 1000 -- 1km + for crateName, meta in pairs(CTLD._salvageCrates or {}) do + if meta and meta.position then + local cratePos = meta.position + local distance = math.sqrt((pos.x - cratePos.x)^2 + (pos.z - cratePos.z)^2) + if distance < minDistance then + _msgGroup(group, string.format('Cannot create salvage zone within %.0fm of existing salvage cargo. Nearest cargo is %.0fm away.', minDistance, distance)) + return + end + end + end + + self._DynamicSalvageZones = self._DynamicSalvageZones or {} + self._DynamicSalvageQueue = self._DynamicSalvageQueue or {} + + -- Pre-clean existing dynamics so limit checks are up to date + self:_enforceDynamicSalvageZoneLimit() + + -- Generate unique zone name + local zoneName = string.format('SalvageZone-%s-%d', (self.Side == coalition.side.BLUE and 'BLUE' or 'RED'), + math.random(1000, 9999)) + + -- Create MOOSE zone + local zone = ZONE_RADIUS:New(zoneName, coord:GetVec2(), radius) + + -- Add to instance zones + table.insert(self.SalvageDropZones, zone) + local now = timer and timer.getTime and timer.getTime() or 0 + local lifetime = tonumber(cfg.DynamicZoneLifetime or 0) or 0 + local expiresAt = (lifetime > 0) and (now + lifetime) or nil + + self._ZoneDefs.SalvageDropZones[zoneName] = { + name = zoneName, + side = self.Side, + active = true, + radius = radius, + dynamic = true, + createdAt = now, + expiresAt = expiresAt, + } + self._ZoneActive.SalvageDrop[zoneName] = true + + -- Track dynamic zone metadata for cleanup enforcement + self._DynamicSalvageZones[zoneName] = { + zone = zone, + createdAt = now, + expiresAt = expiresAt, + radius = radius, + } + table.insert(self._DynamicSalvageQueue, zoneName) + + -- Enforce limits after registering the new zone + self:_enforceDynamicSalvageZoneLimit() + if not self._DynamicSalvageZones[zoneName] then + _msgGroup(group, 'Unable to create salvage zone (limit reached). Older zones were not cleared in time.') + return + end + + -- Announce + local msg = _fmtTemplate(self.Messages.slingload_salvage_zone_created, { + zone = zoneName, + radius = radius, + }) + if lifetime > 0 then + msg = msg .. string.format(' Expires in %s.', _ctldFormatSeconds(lifetime)) + end + local maxZones = tonumber(cfg.MaxDynamicZones or 0) or 0 + if maxZones > 0 then + msg = msg .. string.format(' Active zone cap: %d.', maxZones) + end + _msgGroup(group, msg) + + _logInfo(string.format('[SlingLoadSalvage] Created zone %s at %s', zoneName, coord:ToStringLLDMS())) + + local ok, err = pcall(function() self:DrawZonesOnMap() end) + if not ok then + _logError(string.format('[SlingLoadSalvage] DrawZonesOnMap failed after creating %s: %s', zoneName, tostring(err))) + end +end + +function CTLD:RetireOldestDynamicSalvageZone(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + self._DynamicSalvageQueue = self._DynamicSalvageQueue or {} + if #self._DynamicSalvageQueue == 0 then + if group then _msgGroup(group, 'No dynamic salvage zones to retire.') end + return + end + + local oldest = self._DynamicSalvageQueue[1] + if not oldest then + if group then _msgGroup(group, 'No dynamic salvage zones to retire.') end + return + end + + local exists = self._DynamicSalvageZones and self._DynamicSalvageZones[oldest] + if exists then + self:_removeDynamicSalvageZone(oldest, 'manual-retire') + if group then + _msgGroup(group, string.format('Retired salvage zone: %s', oldest)) + end + else + -- Remove stale entry from queue and notify user + table.remove(self._DynamicSalvageQueue, 1) + if group then _msgGroup(group, 'Oldest salvage zone already retired.') end + end +end + +-- Menu: Show active salvage zones +function CTLD:ShowActiveSalvageZones(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local activeZones = {} + for _, zone in ipairs(self.SalvageDropZones or {}) do + local zoneName = zone:GetName() + if self._ZoneActive.SalvageDrop[zoneName] ~= false then + local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] + if zoneDef and zoneDef.side == self.Side then + table.insert(activeZones, zoneName) + end + end + end + + if #activeZones == 0 then + _msgGroup(group, 'No active Salvage Collection Zones configured.') + else + local msg = 'Active Salvage Collection Zones:\n' .. table.concat(activeZones, '\n') + _msgGroup(group, msg) + end +end + +-- Menu: Show nearest salvage crate vectors +function CTLD:ShowNearestSalvageCrate(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local here = { x = pos.x, z = pos.z } + + local nearestName, nearestMeta, nearestDist = nil, nil, math.huge + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.side == self.Side then + local dx = meta.position.x - here.x + local dz = meta.position.z - here.z + local dist = math.sqrt(dx*dx + dz*dz) + if dist < nearestDist then + nearestDist = dist + nearestName = crateName + nearestMeta = meta + end + end + end + + if not nearestName then + local msg = self.Messages.slingload_salvage_no_crates or 'No active salvage crates available.' + _msgGroup(group, msg) + return + end + + local brg = _bearingDeg(here, nearestMeta.position) + local isMetric = _getPlayerIsMetric(unit) + local rng, rngU = _fmtRange(nearestDist, isMetric) + + local msg = _fmtTemplate(self.Messages.slingload_salvage_vectors, { + id = nearestName, + brg = brg, + rng = rng, + rng_u = rngU, + weight = nearestMeta.weight, + reward = nearestMeta.rewardValue, + }) + _msgGroup(group, msg) +end + +-- #endregion SlingLoadSalvage + +-- #endregion Public helpers + +-- ========================= +-- Return factory +-- ========================= +-- #region Export +_MOOSE_CTLD = CTLD +return CTLD +-- #endregion Export + +