diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.5.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.5.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.5.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.5.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.6.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.6.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.6.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.6.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.8.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.8.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.8.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.8.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.9.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.9.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.0.9.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.0.9.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.0.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.0.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.0.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.0.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.1.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.1.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.1.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.1.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.2.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.2.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.2.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.2.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.3.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.3.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.3.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.3.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.4.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.4.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.4.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.4.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.5.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.5.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.5.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.5.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.6.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.6.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.6.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.6.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.7.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.7.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.7.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.7.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.8.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.8.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.8.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.8.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.9.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.9.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.1.9.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.1.9.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.0.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.0.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.0.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.0.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.1.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.1.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.1.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.1.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.2.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.2.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.2.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.2.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.3.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.3.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.3.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.3.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.4.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.4.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.4.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.4.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.5.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.5.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.5.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.5.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.6.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.6.miz similarity index 100% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.6.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.6.miz diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.7.miz b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.7.miz similarity index 97% rename from DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.7.miz rename to DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.7.miz index da80911..f1c1cfd 100644 Binary files a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_1.2.7.miz and b/DCS_Afgainistan/Insurgent_Sandstorm/Archive/F99th-Insurgent_Sandstorm_1.2.7.miz differ diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.0.miz b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.0.miz new file mode 100644 index 0000000..5fcb710 Binary files /dev/null and b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.0.miz differ diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_CTLD_Init_DualCoalitions.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_CTLD_Init_DualCoalitions.lua new file mode 100644 index 0000000..c873ad2 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_CTLD_Init_DualCoalitions.lua @@ -0,0 +1,122 @@ +-- init_mission_dual_coalition.lua +-- Use in Mission Editor with DO SCRIPT FILE load order: +-- 1) Moose.lua +-- 2) Moose_CTLD_Pure/Moose_CTLD.lua +-- 3) Moose_CTLD_Pure/catalogs/CrateCatalog_CTLD_Extract.lua -- optional but recommended catalog with BLUE+RED items (_CTLD_EXTRACTED_CATALOG) +-- 4) Moose_CTLD_Pure/Moose_CTLD_FAC.lua -- optional FAC/RECCE support +-- 5) DO SCRIPT: dofile on this file OR paste the block below directly +-- +-- IMPORTANT: F10 menu ordering depends on script execution order! +-- Load this initialization script BEFORE other mission scripts (TADC, CVN, Intel, etc.) +-- to ensure CTLD and FAC appear earlier in the F10 menu. +-- +-- Zones you should create in the Mission Editor (as trigger zones): +-- BLUE: PICKUP_BLUE_MAIN, DROP_BLUE_1, FOB_BLUE_A +-- RED : PICKUP_RED_MAIN, DROP_RED_1, FOB_RED_A +-- Adjust names below if you use different zone names. + + +-- Create CTLD instances only if Moose and CTLD are available +if _MOOSE_CTLD and _G.BASE then +local blueCfg = { + CoalitionSide = coalition.side.BLUE, + PickupZoneSmokeColor = trigger.smokeColor.Green, + AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB) + '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 = { + PickupZones = { { name = 'S1', flag = 9001, activeWhen = 0 }, + { name = "S2", flag = 9004, activeWhen = 0 }, + { name = "S3", flag = 9005, 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 } }, + }, + BuildRequiresGroundCrates = true, +} +env.info('[DEBUG] blueCfg.Zones.MASHZones count: ' .. tostring(blueCfg.Zones and blueCfg.Zones.MASHZones and #blueCfg.Zones.MASHZones or 'NIL')) +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) + +local redCfg = { + CoalitionSide = coalition.side.RED, + PickupZoneSmokeColor = trigger.smokeColor.Green, + AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB) + 'UH-1H','Mi-8MTV2','Mi-24P','SA342M','SA342L','SA342Minigun','UH-60L','CH-47Fbl1','CH-47F','Mi-17','GazelleAI' + + }, + -- Optional: drive zone activation for RED via per-zone flag/activeWhen + + MapDraw = { + Enabled = true, + DrawMASHZones = true, -- Enable MASH zone drawing + }, + + Zones = { + PickupZones = { { name = 'ReadLoadZone1', flag = 9101, activeWhen = 0 }, + { name = "ReadLoadZone2", flag = 9104, activeWhen = 0 }, + { name = "ReadLoadZone3", flag = 9105, 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 } }, + }, + BuildRequiresGroundCrates = true, +} +env.info('[DEBUG] redCfg.Zones.MASHZones count: ' .. tostring(redCfg.Zones and redCfg.Zones.MASHZones and #redCfg.Zones.MASHZones or 'NIL')) +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) + +-- 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')) +if _CTLD_EXTRACTED_CATALOG then + local count = 0 + for k,v in pairs(_CTLD_EXTRACTED_CATALOG) do count = count + 1 end + env.info('[init_mission_dual_coalition] Catalog has '..tostring(count)..' entries') + env.info('[init_mission_dual_coalition] Merging catalog into CTLD instances') + ctldBlue:MergeCatalog(_CTLD_EXTRACTED_CATALOG) + ctldRed:MergeCatalog(_CTLD_EXTRACTED_CATALOG) + env.info('[init_mission_dual_coalition] Catalog merged successfully') + -- Verify merge + local blueCount = 0 + for k,v in pairs(ctldBlue.Config.CrateCatalog) do blueCount = blueCount + 1 end + env.info('[init_mission_dual_coalition] BLUE catalog now has '..tostring(blueCount)..' entries') +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 +else + env.info('[init_mission_dual_coalition] Moose or CTLD missing; skipping CTLD init') +end + + +-- Optional: FAC/RECCE for both sides (requires Moose_CTLD_FAC.lua) +if _MOOSE_CTLD_FAC and _G.BASE and ctldBlue and ctldRed then + facBlue = _MOOSE_CTLD_FAC:New(ctldBlue, { + CoalitionSide = coalition.side.BLUE, + Arty = { Enabled = false }, + }) + -- facBlue:AddRecceZone({ name = 'RECCE_BLUE_1' }) + facBlue:Run() + + facRed = _MOOSE_CTLD_FAC:New(ctldRed, { + CoalitionSide = coalition.side.RED, + Arty = { Enabled = false }, + }) + -- facRed:AddRecceZone({ name = 'RECCE_RED_1' }) + facRed:Run() +else + env.info('[init_mission_dual_coalition] FAC not initialized (missing Moose/CTLD/FAC or CTLD not created)') +end diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_DualCoalitionZoneCapture.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_DualCoalitionZoneCapture.lua new file mode 100644 index 0000000..5f7ee65 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_DualCoalitionZoneCapture.lua @@ -0,0 +1,1149 @@ +-- Refactored version with configurable zone ownership + +-- ========================================== +-- MESSAGE AND TIMING CONFIGURATION +-- ========================================== +local MESSAGE_CONFIG = { + STATUS_BROADCAST_FREQUENCY = 3602, -- Zone status broadcast cadence (seconds) + STATUS_BROADCAST_START_DELAY = 10, -- Delay before first broadcast (seconds) + COLOR_VERIFICATION_FREQUENCY = 240, -- Zone color verification cadence (seconds) + COLOR_VERIFICATION_START_DELAY = 60, -- Delay before first color check (seconds) + TACTICAL_UPDATE_FREQUENCY = 180, -- Tactical marker update cadence (seconds) + TACTICAL_UPDATE_START_DELAY = 30, -- Delay before first tactical update (seconds) + STATUS_MESSAGE_DURATION = 15, -- How long general status messages stay onscreen + VICTORY_MESSAGE_DURATION = 300, -- How long victory/defeat alerts stay onscreen + CAPTURE_MESSAGE_DURATION = 15, -- Duration for capture/guard/empty notices + ATTACK_MESSAGE_DURATION = 15 -- Duration for attack alerts +} + +-- ========================================== +-- ZONE COLOR CONFIGURATION (Centralized) +-- ========================================== +-- Colors are in RGB format: {Red, Green, Blue} where each value is 0.0 to 1.0 +local ZONE_COLORS = { + -- Blue coalition zones + BLUE_CAPTURED = {0, 0, 1}, -- Blue (owned by Blue) + BLUE_ATTACKED = {0, 1, 1}, -- Cyan (owned by Blue, under attack) + + -- Red coalition zones + RED_CAPTURED = {1, 0, 0}, -- Red (owned by Red) + RED_ATTACKED = {1, 0.5, 0}, -- Orange (owned by Red, under attack) + + -- Neutral/Empty zones + EMPTY = {0, 1, 0} -- Green (no owner) +} + +-- Helper to get the appropriate color for a zone based on state/ownership +local function GetZoneColor(zoneCapture) + local zoneCoalition = zoneCapture:GetCoalition() + local state = zoneCapture:GetCurrentState() + + -- Priority 1: Attacked overrides ownership color + if state == "Attacked" then + if zoneCoalition == coalition.side.BLUE then + return ZONE_COLORS.BLUE_ATTACKED + elseif zoneCoalition == coalition.side.RED then + return ZONE_COLORS.RED_ATTACKED + end + end + + -- Priority 2: Empty/neutral + if state == "Empty" then + return ZONE_COLORS.EMPTY + end + + -- Priority 3: Ownership color + if zoneCoalition == coalition.side.BLUE then + return ZONE_COLORS.BLUE_CAPTURED + elseif zoneCoalition == coalition.side.RED then + return ZONE_COLORS.RED_CAPTURED + end + + -- Fallback + return ZONE_COLORS.EMPTY +end + +-- ========================================== +-- ZONE CONFIGURATION +-- ========================================== +-- Mission makers: Edit this table to define zones and their initial ownership +-- Just list the zone names under RED, BLUE, or NEUTRAL coalition +-- The script will automatically create and configure all zones +-- Make sure the zone names match exactly with those defined in the mission editor +-- Zones must be defined in the mission editor as trigger zones named "Capture " +-- Note: Red/Blue/Neutral zones defined below are only setting their initial ownership state. +-- If there are existing units in the zone at mission start, ownership may change based on unit presence. + + +local ZONE_CONFIG = { + -- Zones that start under RED coalition control + -- IMPORTANT: Use the EXACT zone names from the mission editor (including "Capture " prefix if present) + RED = { + "FrontLine1", + "FrontLine2", + "FrontLine3", + "FrontLine4", + "FrontLine5", + "FrontLine6", + "FrontLine7", + "FrontLine8", + + -- Add more zone names here for RED starting zones + }, + + -- Zones that start under BLUE coalition control + BLUE = { + "FrontLine9", + "FrontLine10", + "FrontLine11", + "FrontLine12", + "FrontLine13", + "FrontLine14", + "FrontLine15", + "FrontLine16", + -- Add more zone names here for BLUE starting zones + }, + + -- Zones that start neutral (empty/uncontrolled) + NEUTRAL = { + + } +} + +-- Advanced settings (usually don't need to change these) +local ZONE_SETTINGS = { + guardDelay = 1, -- Delay before entering Guard state after capture + scanInterval = 30, -- How often to scan for units in the zone (seconds) + captureScore = 200 -- Points awarded for capturing a zone +} + +-- ========================================== +-- END OF CONFIGURATION +-- ========================================== + +-- Build Command Center and Mission for Blue Coalition +local blueHQ = GROUP:FindByName("BLUEHQ") +if blueHQ then + US_CC = COMMANDCENTER:New(blueHQ, "USA HQ") + US_Mission = MISSION:New(US_CC, "Zone Capture Example Mission", "Primary", "", coalition.side.BLUE) + US_Score = SCORING:New("Zone Capture Example Mission") + --US_Mission:AddScoring(US_Score) + --US_Mission:Start() + env.info("Blue Coalition Command Center and Mission started successfully") +else + env.info("ERROR: BLUEHQ group not found! Blue mission will not start.") +end + +--Build Command Center and Mission Red +local redHQ = GROUP:FindByName("REDHQ") +if redHQ then + RU_CC = COMMANDCENTER:New(redHQ, "Russia HQ") + RU_Mission = MISSION:New(RU_CC, "Zone Capture Example Mission", "Primary", "Hold what we have, take what we don't.", coalition.side.RED) + --RU_Score = SCORING:New("Zone Capture Example Mission") + --RU_Mission:AddScoring(RU_Score) + RU_Mission:Start() + env.info("Red Coalition Command Center and Mission started successfully") +else + env.info("ERROR: REDHQ group not found! Red mission will not start.") +end + + +-- Setup BLUE Missions +do -- BLUE Mission + + US_Mission_Capture_Airfields = MISSION:New( US_CC, "Capture the Zones", "Primary", + "Capture the Zones marked on your F10 map.\n" .. + "Destroy enemy ground forces in the surrounding area, " .. + "then occupy each capture zone with a platoon.\n " .. + "Your orders are to hold position until all capture zones are taken.\n" .. + "Use the map (F10) for a clear indication of the location of each capture zone.\n" .. + "Note that heavy resistance can be expected at the airbases!\n" + , coalition.side.BLUE) + + --US_Score = SCORING:New( "Capture Airfields" ) + + --US_Mission_Capture_Airfields:AddScoring( US_Score ) + + US_Mission_Capture_Airfields:Start() + +end + +-- Setup RED Missions +do -- RED Mission + + RU_Mission_Capture_Airfields = MISSION:New( RU_CC, "Defend the Motherland", "Primary", + "Defend Russian airfields and recapture lost territory.\n" .. + "Eliminate enemy forces in capture zones and " .. + "maintain control with ground units.\n" .. + "Your orders are to prevent the enemy from capturing all strategic zones.\n" .. + "Use the map (F10) for a clear indication of the location of each capture zone.\n" .. + "Expect heavy NATO resistance!\n" + , coalition.side.RED) + + --RU_Score = SCORING:New( "Defend Territory" ) + + --RU_Mission_Capture_Airfields:AddScoring( RU_Score ) + + RU_Mission_Capture_Airfields:Start() + +end + + +-- Logging configuration: toggle logging behavior for this module +-- Set `CAPTURE_ZONE_LOGGING.enabled = false` to silence module logs +if not CAPTURE_ZONE_LOGGING then + CAPTURE_ZONE_LOGGING = { enabled = false, prefix = "[CAPTURE Module]" } +end + +local function log(message, detailed) + if CAPTURE_ZONE_LOGGING.enabled then + -- Preserve the previous prefixing used across the module + if CAPTURE_ZONE_LOGGING.prefix then + env.info(tostring(CAPTURE_ZONE_LOGGING.prefix) .. " " .. tostring(message)) + else + env.info(tostring(message)) + end + end +end + + +-- ========================================== +-- ZONE INITIALIZATION SYSTEM +-- ========================================== + +-- Storage for all zone capture objects and metadata +local zoneCaptureObjects = {} +local zoneNames = {} +local zoneMetadata = {} -- Stores coalition ownership info + +-- Function to initialize all zones from configuration +local function InitializeZones() + log("[INIT] Starting zone initialization from configuration...") + + local totalZones = 0 + + -- Process each coalition's zones + for coalitionName, zones in pairs(ZONE_CONFIG) do + local coalitionSide = nil + + -- Map coalition name to DCS coalition constant + if coalitionName == "RED" then + coalitionSide = coalition.side.RED + elseif coalitionName == "BLUE" then + coalitionSide = coalition.side.BLUE + elseif coalitionName == "NEUTRAL" then + coalitionSide = coalition.side.NEUTRAL + else + log(string.format("[INIT] WARNING: Unknown coalition '%s' in ZONE_CONFIG", coalitionName)) + end + + if coalitionSide then + for _, zoneName in ipairs(zones) do + log(string.format("[INIT] Creating zone: %s (Coalition: %s)", zoneName, coalitionName)) + + -- Create the MOOSE zone object (using exact name from config) + local zone = ZONE:New(zoneName) + + if zone then + -- Create the zone capture coalition object + local zoneCapture = ZONE_CAPTURE_COALITION:New(zone, coalitionSide) + + if zoneCapture then + -- Configure the zone + zoneCapture:__Guard(ZONE_SETTINGS.guardDelay) + zoneCapture:Start(ZONE_SETTINGS.scanInterval, ZONE_SETTINGS.scanInterval) + + -- Store in our data structures + table.insert(zoneCaptureObjects, zoneCapture) + table.insert(zoneNames, zoneName) + zoneMetadata[zoneName] = { + coalition = coalitionSide, + index = #zoneCaptureObjects + } + + totalZones = totalZones + 1 + log(string.format("[INIT] ✓ Zone '%s' initialized successfully", zoneName)) + else + log(string.format("[INIT] ✗ ERROR: Failed to create ZONE_CAPTURE_COALITION for '%s'", zoneName)) + end + else + log(string.format("[INIT] ✗ ERROR: Zone '%s' not found in mission editor!", zoneName)) + log(string.format("[INIT] Make sure you have a trigger zone named exactly: '%s'", zoneName)) + end + end + end + end + + log(string.format("[INIT] Zone initialization complete. Total zones created: %d", totalZones)) + return totalZones +end + +-- Initialize all zones +local totalZones = InitializeZones() + + +-- Global cached unit set - created once and maintained automatically by MOOSE +local CachedUnitSet = nil + +-- Utility guard to safely test whether a unit is inside a zone without throwing +local function IsUnitInZone(unit, zone) + if not unit or not zone then + return false + end + + local ok, point = pcall(function() + return unit:GetPointVec3() + end) + + if not ok or not point then + return false + end + + local inZone = false + pcall(function() + inZone = zone:IsPointVec3InZone(point) + end) + + return inZone +end + +-- Initialize the cached unit set once +local function InitializeCachedUnitSet() + if not CachedUnitSet then + CachedUnitSet = SET_UNIT:New() + :FilterCategories({"ground", "plane", "helicopter"}) -- Only scan relevant unit types + :FilterStart() -- Keep the set updated by MOOSE without recreating it + log("[PERFORMANCE] Initialized cached unit set for zone scanning") + end +end + +local function GetZoneForceStrengths(ZoneCapture) + if not ZoneCapture then + return { red = 0, blue = 0, neutral = 0 } + end + + local success, zone = pcall(function() + return ZoneCapture:GetZone() + end) + + if not success or not zone then + return { red = 0, blue = 0, neutral = 0 } + end + + local redCount = 0 + local blueCount = 0 + local neutralCount = 0 + + InitializeCachedUnitSet() + + if CachedUnitSet then + CachedUnitSet:ForEachUnit(function(unit) + if unit and unit:IsAlive() and IsUnitInZone(unit, zone) then + local unitCoalition = unit:GetCoalition() + if unitCoalition == coalition.side.RED then + redCount = redCount + 1 + elseif unitCoalition == coalition.side.BLUE then + blueCount = blueCount + 1 + elseif unitCoalition == coalition.side.NEUTRAL then + neutralCount = neutralCount + 1 + end + end + end) + end + + log(string.format("[TACTICAL] Zone %s scan result: R:%d B:%d N:%d", + ZoneCapture:GetZoneName(), redCount, blueCount, neutralCount)) + + return { + red = redCount, + blue = blueCount, + neutral = neutralCount + } +end + +local function GetEnemyUnitMGRSCoords(ZoneCapture, enemyCoalition) + if not ZoneCapture or not enemyCoalition then + return {} + end + + local success, zone = pcall(function() + return ZoneCapture:GetZone() + end) + + if not success or not zone then + return {} + end + + InitializeCachedUnitSet() + + local coords = {} + local totalUnits = 0 + local enemyUnits = 0 + local unitsWithCoords = 0 + + if CachedUnitSet then + CachedUnitSet:ForEachUnit(function(unit) + if unit and unit:IsAlive() and IsUnitInZone(unit, zone) then + totalUnits = totalUnits + 1 + local unitCoalition = unit:GetCoalition() + + if unitCoalition == enemyCoalition then + enemyUnits = enemyUnits + 1 + local coord = unit:GetCoordinate() + + if coord then + local mgrs = nil + local success_mgrs = false + + success_mgrs, mgrs = pcall(function() + return coord:ToStringMGRS(5) + end) + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + return coord:ToStringMGRS() + end) + end + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + return coord:ToMGRS() + end) + end + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + local lat, lon = coord:GetLLDDM() + return string.format("N%s E%s", lat, lon) + end) + end + + if success_mgrs and mgrs then + unitsWithCoords = unitsWithCoords + 1 + local unitType = unit:GetTypeName() or "Unknown" + table.insert(coords, { + name = unit:GetName(), + type = unitType, + mgrs = mgrs + }) + else + log(string.format("[TACTICAL DEBUG] All coordinate methods failed for unit %s", unit:GetName() or "unknown")) + end + else + log(string.format("[TACTICAL DEBUG] No coordinate for unit %s", unit:GetName() or "unknown")) + end + end + end + end) + end + + log(string.format("[TACTICAL DEBUG] %s - Total units scanned: %d, Enemy units: %d, units with MGRS: %d", + ZoneCapture:GetZoneName(), totalUnits, enemyUnits, unitsWithCoords)) + + log(string.format("[TACTICAL] Found %d enemy units with coordinates in %s", + #coords, ZoneCapture:GetZoneName())) + + return coords +end + +local function CreateTacticalInfoMarker(ZoneCapture) + -- Validate ZoneCapture + if not ZoneCapture then + log("[TACTICAL ERROR] ZoneCapture object is nil") + return + end + + -- Safely get the zone with error handling + local ok, zone = pcall(function() return ZoneCapture:GetZone() end) + if not ok or not zone then + log("[TACTICAL ERROR] Failed to get zone from ZoneCapture object") + return + end + + local forces = GetZoneForceStrengths(ZoneCapture) + local zoneName = ZoneCapture:GetZoneName() + + -- Build coalition-specific tactical info text + local function buildTacticalText(viewerCoalition) + local text = string.format("TACTICAL: %s\nForces: R:%d B:%d", zoneName, forces.red, forces.blue) + if forces.neutral and forces.neutral > 0 then + text = text .. string.format(" C:%d", forces.neutral) + end + + -- Append TGTS for the enemy of the viewer, capped at 10 units + local enemyCoalition = (viewerCoalition == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + local enemyCount = (enemyCoalition == coalition.side.RED) and (forces.red or 0) or (forces.blue or 0) + if enemyCount > 0 and enemyCount <= 10 then + local enemyCoords = GetEnemyUnitMGRSCoords(ZoneCapture, enemyCoalition) + log(string.format("[TACTICAL DEBUG] Building marker text for %s viewer: %d enemy units", (viewerCoalition==coalition.side.BLUE and "BLUE" or "RED"), #enemyCoords)) + if #enemyCoords > 0 then + text = text .. "\nTGTS:" + for i, unit in ipairs(enemyCoords) do + if i <= 10 then + local shortType = (unit.type or "Unknown"):gsub("^%w+%-", ""):gsub("%s.*", "") + local cleanMgrs = (unit.mgrs or ""):gsub("^MGRS%s+", ""):gsub("%s+", " ") + if i == 1 then + text = text .. string.format(" %s@%s", shortType, cleanMgrs) + else + text = text .. string.format(", %s@%s", shortType, cleanMgrs) + end + end + end + if #enemyCoords > 10 then + text = text .. string.format(" (+%d)", #enemyCoords - 10) + end + end + end + + return text + end + + local tacticalTextBLUE = buildTacticalText(coalition.side.BLUE) + local tacticalTextRED = buildTacticalText(coalition.side.RED) + + -- Debug: Log what will be displayed + log(string.format("[TACTICAL DEBUG] Marker text (BLUE) for %s:\n%s", zoneName, tacticalTextBLUE)) + log(string.format("[TACTICAL DEBUG] Marker text (RED) for %s:\n%s", zoneName, tacticalTextRED)) + + -- Create tactical marker offset from zone center + local coord = zone:GetCoordinate() + if coord then + local offsetCoord = coord:Translate(200, 45) -- 200m NE + + local function removeMarker(markerID) + if not markerID then + return + end + + local removed = pcall(function() + offsetCoord:RemoveMark(markerID) + end) + + if not removed then + removed = pcall(function() + trigger.action.removeMark(markerID) + end) + end + + if not removed then + pcall(function() + coord:RemoveMark(markerID) + end) + end + end + + -- Remove legacy single marker if present + if ZoneCapture.TacticalMarkerID then + log(string.format("[TACTICAL] Removing old marker ID %d for %s", ZoneCapture.TacticalMarkerID, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID) + ZoneCapture.TacticalMarkerID = nil + end + + -- BLUE Coalition Marker + if ZoneCapture.TacticalMarkerID_BLUE then + log(string.format("[TACTICAL] Removing old BLUE marker ID %d for %s", ZoneCapture.TacticalMarkerID_BLUE, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID_BLUE) + ZoneCapture.TacticalMarkerID_BLUE = nil + end + local successBlue, markerIDBlue = pcall(function() + return offsetCoord:MarkToCoalition(tacticalTextBLUE, coalition.side.BLUE) + end) + if successBlue and markerIDBlue then + ZoneCapture.TacticalMarkerID_BLUE = markerIDBlue + pcall(function() offsetCoord:SetMarkReadOnly(markerIDBlue, true) end) + log(string.format("[TACTICAL] Created BLUE marker for %s", zoneName)) + else + log(string.format("[TACTICAL] Failed to create BLUE marker for %s", zoneName)) + end + + -- RED Coalition Marker + if ZoneCapture.TacticalMarkerID_RED then + log(string.format("[TACTICAL] Removing old RED marker ID %d for %s", ZoneCapture.TacticalMarkerID_RED, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID_RED) + ZoneCapture.TacticalMarkerID_RED = nil + end + local successRed, markerIDRed = pcall(function() + return offsetCoord:MarkToCoalition(tacticalTextRED, coalition.side.RED) + end) + if successRed and markerIDRed then + ZoneCapture.TacticalMarkerID_RED = markerIDRed + pcall(function() offsetCoord:SetMarkReadOnly(markerIDRed, true) end) + log(string.format("[TACTICAL] Created RED marker for %s", zoneName)) + else + log(string.format("[TACTICAL] Failed to create RED marker for %s", zoneName)) + end + end +end + +-- Event handler functions - define them separately for each zone +local function OnEnterGuarded(ZoneCapture, From, Event, To) + if From ~= To then + local Coalition = ZoneCapture:GetCoalition() + if Coalition == coalition.side.BLUE then + ZoneCapture:Smoke( SMOKECOLOR.Blue ) + -- Update zone visual markers to BLUE + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.BLUE_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is under protection of the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + else + ZoneCapture:Smoke( SMOKECOLOR.Red ) + -- Update zone visual markers to RED + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.RED_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "%s is under protection of Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + end + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) + end +end + +local function OnEnterEmpty(ZoneCapture) + ZoneCapture:Smoke( SMOKECOLOR.Green ) + -- Update zone visual markers to GREEN (neutral) + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.EMPTY + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) +end + +local function OnEnterAttacked(ZoneCapture) + ZoneCapture:Smoke( SMOKECOLOR.White ) + -- Update zone visual markers based on owner (attacked state) + ZoneCapture:UndrawZone() + local Coalition = ZoneCapture:GetCoalition() + local color + if Coalition == coalition.side.BLUE then + color = ZONE_COLORS.BLUE_ATTACKED + US_CC:MessageTypeToCoalition( string.format( "%s is under attack by Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + else + color = ZONE_COLORS.RED_ATTACKED + RU_CC:MessageTypeToCoalition( string.format( "%s is under attack by the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + end + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) +end + +-- Victory condition monitoring for BOTH coalitions +local function CheckVictoryCondition() + local blueZonesCount = 0 + local redZonesCount = 0 + local totalZones = #zoneCaptureObjects + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + if zoneCoalition == coalition.side.BLUE then + blueZonesCount = blueZonesCount + 1 + elseif zoneCoalition == coalition.side.RED then + redZonesCount = redZonesCount + 1 + end + end + end + + log(string.format("[VICTORY CHECK] Blue owns %d/%d zones, Red owns %d/%d zones", + blueZonesCount, totalZones, redZonesCount, totalZones)) + + -- Check for BLUE victory + if blueZonesCount >= totalZones then + log("[VICTORY] All zones captured by BLUE! Triggering victory sequence...") + + US_CC:MessageTypeToCoalition( + "VICTORY! All capture zones have been secured by coalition forces!\n\n" .. + "Operation Polar Shield is complete. Outstanding work!\n" .. + "Mission will end in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + RU_CC:MessageTypeToCoalition( + "DEFEAT! All strategic positions have been lost to coalition forces.\n\n" .. + "Operation Polar Shield has failed. Mission ending in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + -- Add victory celebration effects + for _, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + zoneCapture:Smoke( SMOKECOLOR.Blue ) + local zone = zoneCapture:GetZone() + if zone then + zone:FlareZone( FLARECOLOR.Blue, 90, 60 ) + end + end + end + + SCHEDULER:New( nil, function() + log("[VICTORY] Ending mission due to complete zone capture by BLUE") + trigger.action.setUserFlag("BLUE_VICTORY", 1) + + US_CC:MessageTypeToCoalition( + string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), + MESSAGE.Type.Information, 10 + ) + end, {}, 60 ) + + return true + end + + -- Check for RED victory + if redZonesCount >= totalZones then + log("[VICTORY] All zones captured by RED! Triggering victory sequence...") + + RU_CC:MessageTypeToCoalition( + "VICTORY! All strategic positions secured for the Motherland!\n\n" .. + "NATO forces have been repelled. Outstanding work!\n" .. + "Mission will end in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + US_CC:MessageTypeToCoalition( + "DEFEAT! All capture zones have been lost to Russian forces.\n\n" .. + "Operation Polar Shield has failed. Mission ending in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + -- Add victory celebration effects + for _, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + zoneCapture:Smoke( SMOKECOLOR.Red ) + local zone = zoneCapture:GetZone() + if zone then + zone:FlareZone( FLARECOLOR.Red, 90, 60 ) + end + end + end + + SCHEDULER:New( nil, function() + log("[VICTORY] Ending mission due to complete zone capture by RED") + trigger.action.setUserFlag("RED_VICTORY", 1) + + RU_CC:MessageTypeToCoalition( + string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), + MESSAGE.Type.Information, 10 + ) + end, {}, 60 ) + + return true + end + + return false -- Victory not yet achieved by either side +end + +local function OnEnterCaptured(ZoneCapture) + local Coalition = ZoneCapture:GetCoalition() + if Coalition == coalition.side.BLUE then + -- Update zone visual markers to BLUE for captured + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.BLUE_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + RU_CC:MessageTypeToCoalition( string.format( "%s is captured by the USA, we lost it!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + else + -- Update zone visual markers to RED for captured + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.RED_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is captured by Russia, we lost it!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + end + + ZoneCapture:AddScore( "Captured", "Zone captured: Extra points granted.", ZONE_SETTINGS.captureScore ) + ZoneCapture:__Guard( 30 ) + + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) + + -- Check victory condition after any zone capture + CheckVictoryCondition() +end + +-- Set up event handlers for each zone with proper MOOSE methods and debugging +for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneName = zoneNames[i] or ("Zone " .. i) + + -- Proper MOOSE event handlers for ZONE_CAPTURE_COALITION + zoneCapture.OnEnterGuarded = OnEnterGuarded + zoneCapture.OnEnterEmpty = OnEnterEmpty + zoneCapture.OnEnterAttacked = OnEnterAttacked + zoneCapture.OnEnterCaptured = OnEnterCaptured + + -- Debug: Check if the underlying zone exists + local success, zone = pcall(function() return zoneCapture:GetZone() end) + if success and zone then + log("✓ Zone '" .. zoneName .. "' successfully created and linked") + + -- Get initial coalition color for this zone + local initialCoalition = zoneCapture:GetCoalition() + local colorRGB = ZONE_COLORS.EMPTY + + if initialCoalition == coalition.side.RED then + colorRGB = ZONE_COLORS.RED_CAPTURED + elseif initialCoalition == coalition.side.BLUE then + colorRGB = ZONE_COLORS.BLUE_CAPTURED + end + + -- Initialize zone borders with appropriate initial color + local drawSuccess, drawError = pcall(function() + zone:DrawZone(-1, {0, 0, 0}, 1, colorRGB, 0.2, 2, true) + end) + + if not drawSuccess then + log("⚠ Zone 'Capture " .. zoneName .. "' border drawing failed: " .. tostring(drawError)) + -- Alternative: Try simpler zone marking + pcall(function() + if initialCoalition == coalition.side.RED then + zone:SmokeZone(SMOKECOLOR.Red, 30) + elseif initialCoalition == coalition.side.BLUE then + zone:SmokeZone(SMOKECOLOR.Blue, 30) + else + zone:SmokeZone(SMOKECOLOR.Green, 30) + end + end) + else + local coalitionName = "NEUTRAL" + if initialCoalition == coalition.side.RED then + coalitionName = "RED" + elseif initialCoalition == coalition.side.BLUE then + coalitionName = "BLUE" + end + log("✓ Zone '" .. zoneName .. "' border drawn successfully with " .. coalitionName .. " initial color") + end + else + log("✗ ERROR: Zone '" .. zoneName .. "' not found in mission editor!") + log(" Make sure you have a trigger zone named exactly: '" .. zoneName .. "'") + end + else + log("✗ ERROR: Zone capture object " .. i .. " (" .. (zoneNames[i] or "Unknown") .. ") is nil!") + end +end + +-- ========================================== +-- VICTORY MONITORING SYSTEM +-- ========================================== + +-- Function to get current zone ownership status +local function GetZoneOwnershipStatus() + local status = { + blue = 0, + red = 0, + neutral = 0, + total = #zoneCaptureObjects, + zones = {} + } + + -- Explicitly reference the global coalition table to avoid parameter shadowing + local coalitionTable = _G.coalition or coalition + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + + -- Get the current state of the zone + local currentState = zoneCapture:GetCurrentState() + local stateString = "" + + -- Determine status based on coalition and state + if zoneCoalition == coalitionTable.side.BLUE then + status.blue = status.blue + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "BLUE (Under Attack)" + else + status.zones[zoneName] = "BLUE" + end + elseif zoneCoalition == coalitionTable.side.RED then + status.red = status.red + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "RED (Under Attack)" + else + status.zones[zoneName] = "RED" + end + else + status.neutral = status.neutral + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "NEUTRAL (Under Attack)" + else + status.zones[zoneName] = "NEUTRAL" + end + end + end + end + + return status +end + +-- Function to broadcast zone status report to BOTH coalitions +local function BroadcastZoneStatus() + local status = GetZoneOwnershipStatus() + + -- Build coalition-neutral report + local reportMessage = string.format( + "ZONE CONTROL REPORT:\n" .. + "Blue Coalition: %d/%d zones\n" .. + "Red Coalition: %d/%d zones\n" .. + "Neutral: %d/%d zones", + status.blue, status.total, + status.red, status.total, + status.neutral, status.total + ) + + -- Add detailed zone status + local detailMessage = "\nZONE DETAILS:\n" + for zoneName, owner in pairs(status.zones) do + detailMessage = detailMessage .. string.format("• %s: %s\n", zoneName, owner) + end + + local fullMessage = reportMessage .. detailMessage + + -- Broadcast to BOTH coalitions with their specific victory progress + local totalZones = math.max(status.total, 1) + local blueProgressPercent = math.floor((status.blue / totalZones) * 100) + local blueFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", blueProgressPercent) + US_CC:MessageTypeToCoalition( blueFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) + + local redProgressPercent = math.floor((status.red / totalZones) * 100) + local redFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", redProgressPercent) + RU_CC:MessageTypeToCoalition( redFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) + + log("[ZONE STATUS] " .. reportMessage:gsub("\n", " | ")) + + return status +end + +-- Periodic zone monitoring (every 5 minutes) for BOTH coalitions +local ZoneMonitorScheduler = SCHEDULER:New( nil, function() + local status = BroadcastZoneStatus() + + -- Check if BLUE is close to victory (80% or more zones captured) + if status.blue >= math.floor(status.total * 0.8) and status.blue < status.total then + US_CC:MessageTypeToCoalition( + string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", + status.total - status.blue), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + RU_CC:MessageTypeToCoalition( + string.format("CRITICAL SITUATION! Coalition forces control %d/%d zones! We must recapture territory!", + status.blue, status.total), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + end + + -- Check if RED is close to victory (80% or more zones captured) + if status.red >= math.floor(status.total * 0.8) and status.red < status.total then + RU_CC:MessageTypeToCoalition( + string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", + status.total - status.red), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + US_CC:MessageTypeToCoalition( + string.format("CRITICAL SITUATION! Russian forces control %d/%d zones! We must recapture territory!", + status.red, status.total), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + end + +end, {}, MESSAGE_CONFIG.STATUS_BROADCAST_START_DELAY, MESSAGE_CONFIG.STATUS_BROADCAST_FREQUENCY ) + +-- Periodic zone color verification system (every 2 minutes) +local ZoneColorVerificationScheduler = SCHEDULER:New( nil, function() + log("[ZONE COLORS] Running periodic zone color verification...") + + -- Verify each zone's visual marker matches its CURRENT STATE (not just coalition) + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + local currentState = zoneCapture:GetCurrentState() + + local zoneColor = GetZoneColor(zoneCapture) + + -- Force redraw the zone with correct color based on CURRENT STATE + zoneCapture:UndrawZone() + zoneCapture:DrawZone(-1, {0, 0, 0}, 1, zoneColor, 0.2, 2, true) + + -- Log the color assignment for debugging + local colorName = "UNKNOWN" + if currentState == "Attacked" then + colorName = (zoneCoalition == coalition.side.BLUE) and "LIGHT BLUE (Blue Attacked)" or "ORANGE (Red Attacked)" + elseif currentState == "Empty" then + colorName = "GREEN (Empty)" + elseif zoneCoalition == coalition.side.BLUE then + colorName = "BLUE (Owned)" + elseif zoneCoalition == coalition.side.RED then + colorName = "RED (Owned)" + else + colorName = "GREEN (Fallback)" + end + log(string.format("[ZONE COLORS] %s: Set to %s", zoneName, colorName)) + end + end + +end, {}, MESSAGE_CONFIG.COLOR_VERIFICATION_START_DELAY, MESSAGE_CONFIG.COLOR_VERIFICATION_FREQUENCY ) + +-- Periodic tactical marker update system with change detection +local __lastForceCountsByZone = {} +local TacticalMarkerUpdateScheduler = SCHEDULER:New( nil, function() + log("[TACTICAL] Running periodic tactical marker update (change-detected)...") + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneName = zoneCapture.GetZoneName and zoneCapture:GetZoneName() or (zoneNames[i] or ("Zone " .. i)) + local counts = GetZoneForceStrengths(zoneCapture) + local last = __lastForceCountsByZone[zoneName] + local changed = (not last) + or (last.red ~= counts.red) + or (last.blue ~= counts.blue) + or (last.neutral ~= counts.neutral) + + if changed then + __lastForceCountsByZone[zoneName] = { + red = counts.red, + blue = counts.blue, + neutral = counts.neutral + } + CreateTacticalInfoMarker(zoneCapture) + end + end + end + +end, {}, MESSAGE_CONFIG.TACTICAL_UPDATE_START_DELAY, MESSAGE_CONFIG.TACTICAL_UPDATE_FREQUENCY ) + +-- Function to refresh all zone colors based on current ownership +local function RefreshAllZoneColors() + log("[ZONE COLORS] Refreshing all zone visual markers...") + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + local currentState = zoneCapture:GetCurrentState() + + -- Get color for current state/ownership + local zoneColor = GetZoneColor(zoneCapture) + + -- Clear existing drawings + zoneCapture:UndrawZone() + + -- Redraw with correct color + zoneCapture:DrawZone(-1, {0, 0, 0}, 1, zoneColor, 0.2, 2, true) + + -- Log the color assignment for debugging + local colorName = "UNKNOWN" + if currentState == "Attacked" then + colorName = (zoneCoalition == coalition.side.BLUE) and "LIGHT BLUE (Blue Attacked)" or "ORANGE (Red Attacked)" + elseif currentState == "Empty" then + colorName = "GREEN (Empty)" + elseif zoneCoalition == coalition.side.BLUE then + colorName = "BLUE (Owned)" + elseif zoneCoalition == coalition.side.RED then + colorName = "RED (Owned)" + else + colorName = "GREEN (Fallback)" + end + log(string.format("[ZONE COLORS] %s: Set to %s", zoneName, colorName)) + end + end + + -- Notify BOTH coalitions + US_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) + RU_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) +end + +-- Manual zone status commands for players (F10 radio menu) - BOTH COALITIONS +local function SetupZoneStatusCommands() + -- Add F10 radio menu commands for BLUE coalition + if US_CC then + local USMenu = MENU_COALITION:New( coalition.side.BLUE, "Zone Control" ) + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Get Zone Status Report", USMenu, BroadcastZoneStatus ) + + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Check Victory Progress", USMenu, function() + local status = GetZoneOwnershipStatus() + local totalZones = math.max(status.total, 1) + local progressPercent = math.floor((status.blue / totalZones) * 100) + + US_CC:MessageTypeToCoalition( + string.format( + "VICTORY PROGRESS: %d%%\n" .. + "Zones Captured: %d/%d\n" .. + "Remaining: %d zones\n\n" .. + "%s", + progressPercent, + status.blue, status.total, + status.total - status.blue, + progressPercent >= 100 and "MISSION COMPLETE!" or + progressPercent >= 80 and "ALMOST THERE!" or + progressPercent >= 50 and "GOOD PROGRESS!" or + "KEEP FIGHTING!" + ), + MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION + ) + end ) + + -- Add command to refresh zone colors (troubleshooting tool) + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Refresh Zone Colors", USMenu, RefreshAllZoneColors ) + end + + -- Add F10 radio menu commands for RED coalition + if RU_CC then + local RUMenu = MENU_COALITION:New( coalition.side.RED, "Zone Control" ) + MENU_COALITION_COMMAND:New( coalition.side.RED, "Get Zone Status Report", RUMenu, BroadcastZoneStatus ) + + MENU_COALITION_COMMAND:New( coalition.side.RED, "Check Victory Progress", RUMenu, function() + local status = GetZoneOwnershipStatus() + local totalZones = math.max(status.total, 1) + local progressPercent = math.floor((status.red / totalZones) * 100) + + RU_CC:MessageTypeToCoalition( + string.format( + "VICTORY PROGRESS: %d%%\n" .. + "Zones Captured: %d/%d\n" .. + "Remaining: %d zones\n\n" .. + "%s", + progressPercent, + status.red, status.total, + status.total - status.red, + progressPercent >= 100 and "MISSION COMPLETE!" or + progressPercent >= 80 and "ALMOST THERE!" or + progressPercent >= 50 and "GOOD PROGRESS!" or + "KEEP FIGHTING!" + ), + MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION + ) + end ) + + -- Add command to refresh zone colors (troubleshooting tool) + MENU_COALITION_COMMAND:New( coalition.side.RED, "Refresh Zone Colors", RUMenu, RefreshAllZoneColors ) + end +end + +-- Initialize zone status monitoring +SCHEDULER:New( nil, function() + log("[VICTORY SYSTEM] Initializing zone monitoring system...") + + -- Initialize performance optimization caches + InitializeCachedUnitSet() + + SetupZoneStatusCommands() + + -- Initial status report + SCHEDULER:New( nil, function() + log("[VICTORY SYSTEM] Broadcasting initial zone status...") + BroadcastZoneStatus() + end, {}, 30 ) -- Initial report after 30 seconds + +end, {}, 5 ) -- Initialize after 5 seconds + +log("[VICTORY SYSTEM] Zone capture victory monitoring system loaded successfully!") +log(string.format("[CONFIG] Loaded %d zones from configuration", totalZones)) diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_InsurgentSandstorm2.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_InsurgentSandstorm2.lua new file mode 100644 index 0000000..387ea22 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_InsurgentSandstorm2.lua @@ -0,0 +1,199 @@ +local ENABLE_SAMS = true -- used for testing purposes. Set to true to enable SAMs, false to disable. +local TAC_DISPLAY = false -- Set to false to disable Tacview display for AI flights (default = false) + +-- How many red/blue aircraft are in the air by default. +local RedA2ADefaultOverhead = 1.5 +local RedDefaultCAP = 1 +local BlueA2ADefaultOverhead = 1.5 +local BlueDefaultCAP = 1 + +-- Create the main mission menu. +missionMenu = MENU_MISSION:New("Mission Menu") + +--Build Command Center and Mission for Blue +US_CC = COMMANDCENTER:New( GROUP:FindByName( "BLUEHQ" ), "USA HQ" ) +US_Mission = MISSION:New( US_CC, "Insurgent Sandstorm", "Primary", "Clear the front lines of enemy activity.", coalition.side.BLUE) +US_Score = SCORING:New( "Insurgent Sandstorm - Blue" ) +US_Mission:AddScoring( US_Score ) +US_Mission:Start() +US_Score:SetMessagesHit(false) +US_Score:SetMessagesDestroy(false) +US_Score:SetMessagesScore(false) + +--Build Command Center and Mission Red +RU_CC = COMMANDCENTER:New( GROUP:FindByName( "REDHQ" ), "Russia HQ" ) +RU_Mission = MISSION:New (RU_CC, "Insurgent Sandstorm", "Primary", "Destroy U.S. and NATO forces.", coalition.side.RED) +RU_Score = SCORING:New("Insurgent Sandstorm - Red") +RU_Mission:AddScoring( RU_Score) +RU_Mission:Start() +RU_Score:SetMessagesHit(false) +RU_Score:SetMessagesDestroy(false) +RU_Score:SetMessagesScore(false) + +------------------------------------------------------------------------------------------------------------------------------------------------------ +-- Setup SAM Systems +------------------------------------------------------------------------------------------------------------------------------------------------------ + +local RED_AA_ZONES = { + ZONE:New("RED-AA-1"), + ZONE:New("RED-AA-2"), + ZONE:New("RED-AA-3"), + ZONE:New("RED-AA-4"), + ZONE:New("RED-AA-5"), + ZONE:New("RED-AA-6"), + ZONE:New("RED-AA-7"), + ZONE:New("RED-AA-8"), + ZONE:New("RED-AA-9"), + ZONE:New("RED-AA-10"), + ZONE:New("RED-AA-11"), + ZONE:New("RED-AA-12"), + ZONE:New("RED-AA-13"), + ZONE:New("RED-AA-14"), + ZONE:New("RED-AA-15"), + ZONE:New("RED-AA-16"), + ZONE:New("RED-AA-17"), + ZONE:New("RED-AA-18"), + ZONE:New("RED-AA-19"), + ZONE:New("RED-AA-20"), + ZONE:New("RED-AA-21"), + ZONE:New("RED-AA-22"), + ZONE:New("RED-AA-23"), + ZONE:New("RED-AA-24"), + ZONE:New("RED-AA-25"), + ZONE:New("RED-AA-26"), + ZONE:New("RED-AA-27"), + ZONE:New("RED-AA-28"), + ZONE:New("RED-AA-29"), + ZONE:New("RED-AA-30"), + ZONE:New("RED-AA-31"), + ZONE:New("RED-AA-32"), + ZONE:New("RED-AA-33"), + ZONE:New("RED-AA-34"), + ZONE:New("RED-AA-35"), + ZONE:New("RED-AA-36"), + ZONE:New("RED-AA-37"), + ZONE:New("RED-AA-38"), + ZONE:New("RED-AA-39"), + ZONE:New("RED-AA-40") + +} + +-- Schedule RED AA spawns using the calculated frequencies +-- Must allow enough room for an entire group to spawn. If the group only has 1 unit and you put 5, 5 will spawn, +-- but if the group has 5 units, and you put 5, only 1 will spawn..if you only put 4, it will never spawn. +-- If you put 10, 2 of them will spawn, etc etc. + +if ENABLE_SAMS then + RED_SA08 = SPAWN:New("RED EWR SA08") + :InitRandomizeZones(RED_AA_ZONES) + :InitLimit(8, 8) + :SpawnScheduled(1800, 0.5) + + -- There are 18 units in this group. Need space for each one in the numbers. So if I want 3 SA10s i'm just rounding up to 60. + RED_SA10 = SPAWN:New("RED EWR AA-SA10-1") + :InitRandomizeZones(RED_AA_ZONES) + :InitLimit(60, 60) + :SpawnScheduled(1800, 0.5) + + -- There are 12 units in this group. Need space for each one in the numbers. So if I want 4 SA11s i'm just rounding up to 48 + RED_SA11 = SPAWN:New("RED EWR AA SA112-1") + :InitRandomizeZones(RED_AA_ZONES) + :InitLimit(36, 36) + :SpawnScheduled(1800, 0.5) + + -- There are 11 units in this group. Need space for each one in the numbers. So if I want 4 SA11s i'm just rounding up to 44 + RED_SA06 = SPAWN:New("RED EWR SA6") + :InitRandomizeZones(RED_AA_ZONES) + :InitLimit(33, 33) + :SpawnScheduled(1800, 0.5) + + RED_SA02 = SPAWN:New("RED EWR SA2") + :InitRandomizeZones(RED_AA_ZONES) + :InitLimit(60, 60) + :SpawnScheduled(1800, 0.5) +end + +------------------------------------------------------------------------------------------------------------------------------------------------------ +-- Setup Air Dispatchers for RED and BLUE +------------------------------------------------------------------------------------------------------------------------------------------------------ + +BLUEBorderZone = ZONE_POLYGON:New( "BLUE BORDER", GROUP:FindByName( "BLUE BORDER" ) ) +BLUEA2ADispatcher = AI_A2A_GCICAP:NewWithBorder( { "BLUE EWR" }, { "FIGHTER SWEEP BLUE" }, 'BLUE BORDER', 'BLUE BORDER', BlueDefaultCAP, 10000, 50000, 75000, 100) +BLUEA2ADispatcher:SetDefaultLandingAtRunway() +BLUEA2ADispatcher:SetDefaultTakeoffInAir() +BLUEA2ADispatcher:SetTacticalDisplay(TAC_DISPLAY) +BLUEA2ADispatcher:SetDefaultFuelThreshold( 0.20 ) +BLUEA2ADispatcher:SetRefreshTimeInterval( 300 ) +BLUEA2ADispatcher:SetDefaultOverhead(BlueA2ADefaultOverhead) +BLUEA2ADispatcher:SetDisengageRadius( 100000 ) +BLUEA2ADispatcher:SetEngageRadius( 50000 ) +BLUEA2ADispatcher:SetGciRadius( 75000 ) + +CCCPBorderZone = ZONE_POLYGON:New( "RED BORDER", GROUP:FindByName( "RED BORDER" ) ) +RedA2ADispatcher = AI_A2A_GCICAP:NewWithBorder( { "RED EWR" }, { "FIGHTER SWEEP RED" }, "RED BORDER", "RED BORDER", RedDefaultCAP, 10000, 50000, 75000, 100) +RedA2ADispatcher:SetDefaultLandingAtRunway() +RedA2ADispatcher:SetDefaultTakeoffInAir() +RedA2ADispatcher:SetTacticalDisplay(TAC_DISPLAY) +RedA2ADispatcher:SetDefaultFuelThreshold( 0.20 ) +RedA2ADispatcher:SetRefreshTimeInterval( 300 ) +RedA2ADispatcher:SetDefaultOverhead(RedA2ADefaultOverhead) +RedA2ADispatcher:SetDisengageRadius( 100000 ) +RedA2ADispatcher:SetEngageRadius( 50000 ) +RedA2ADispatcher:SetGciRadius( 75000 ) + + + +DwyerBorderZone = ZONE_POLYGON:New( "DwyerBorderZone", GROUP:FindByName( "DwyerBorderZone" ) ) +DwyerDispatcher = AI_A2A_GCICAP:NewWithBorder( { "RED EWR" }, { "DwyerBorderCAP" }, "DwyerBorderZone", "DwyerBorderZone", RedDefaultCAP, 10000, 50000, 75000, 100) +DwyerDispatcher:SetDefaultLandingAtRunway() +DwyerDispatcher:SetDefaultTakeoffInAir() +DwyerDispatcher:SetBorderZone( DwyerBorderZone ) +DwyerDispatcher:SetTacticalDisplay(TAC_DISPLAY) +DwyerDispatcher:SetDefaultFuelThreshold( 0.20 ) +DwyerDispatcher:SetRefreshTimeInterval( 300 ) +DwyerDispatcher:SetDefaultOverhead(RedA2ADefaultOverhead) +DwyerDispatcher:SetDisengageRadius( 100000 ) +DwyerDispatcher:SetEngageRadius( 50000 ) +DwyerDispatcher:SetGciRadius( 75000 ) + + +BostZone = ZONE_POLYGON:New( "BostBorderZone", GROUP:FindByName( "BostBorderZone" ) ) +BostDispatcher = AI_A2A_GCICAP:NewWithBorder( { "RED EWR" }, { "BostBorderCAP" }, "BostBorderZone", "BostBorderZone", RedDefaultCAP, 10000, 50000, 75000, 100) +BostDispatcher:SetDefaultLandingAtRunway() +BostDispatcher:SetDefaultTakeoffInAir() +BostDispatcher:SetBorderZone(BostZone) +BostDispatcher:SetTacticalDisplay(TAC_DISPLAY) +BostDispatcher:SetDefaultFuelThreshold( 0.20 ) +BostDispatcher:SetRefreshTimeInterval( 300 ) +BostDispatcher:SetDefaultOverhead(RedA2ADefaultOverhead) +BostDispatcher:SetDisengageRadius( 100000 ) +BostDispatcher:SetEngageRadius( 50000 ) +BostDispatcher:SetGciRadius( 75000 ) + +------------------------------------------------------------------------------------------------------------------------------------------------------ +-- Clean up the airbases of debris and stuck aircraft. +------------------------------------------------------------------------------------------------------------------------------------------------------ + +CleanUpAirports = CLEANUP_AIRBASE:New( { + AIRBASE.Afghanistan.Kandahar, + AIRBASE.Afghanistan.Camp_Bastion + +} ) + + +------------------------------------------------------------------------------------------------------------------------------------------------------ +-- Misc Spawns +------------------------------------------------------------------------------------------------------------------------------------------------------ + +-- Spawn the RED Bunker Buster +Red_Bunker_Buster = SPAWN:New("BUNKER BUSTER") + :InitLimit(1, 99) + :SpawnScheduled(900, 0.5) + + +Red_Bunker_Buster2 = SPAWN:New("BUNKER BUSTER-1") + :InitLimit(1, 99) + :SpawnScheduled(1800, 0.5) + + + diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_MenuManager.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_MenuManager.lua new file mode 100644 index 0000000..c911502 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_MenuManager.lua @@ -0,0 +1,135 @@ +--[[ + Unified F10 Menu Manager + + Purpose: Provides a centralized menu system to organize all mission scripts + into a consistent F10 menu structure. + + Menu Organization: + F10 -> F1: Mission Options (all other scripts go here) + F10 -> F2: CTLD (reserved position) + F10 -> F3: AFAC Control (reserved position) + + Usage: + 1. Load this script FIRST before any other menu-creating scripts + 2. Other scripts should use MenuManager to register their menus + + Example: + -- In your script, instead of: + -- local MyMenu = MENU_COALITION:New(coalition.side.BLUE, "My Script") + + -- Use: + -- local MyMenu = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "My Script") + +]]-- + +MenuManager = {} +MenuManager.Version = "1.1" + +-- Configuration +MenuManager.Config = { + EnableMissionOptionsMenu = true, -- Set to false to disable the parent menu system + MissionOptionsMenuName = "Mission Options", -- Name of the parent menu + Debug = false -- Set to true for debug messages +} + +-- Storage for menu references +MenuManager.Menus = { + Blue = {}, + Red = {}, + Mission = {} +} + +-- Parent menu references (created on first use) +MenuManager.ParentMenus = { + BlueCoalition = nil, + RedCoalition = nil, + Mission = nil +} + +-- Initialize the parent menus +function MenuManager.Initialize() + if MenuManager.Config.EnableMissionOptionsMenu then + -- Create the parent "Mission Options" menu for each coalition + MenuManager.ParentMenus.BlueCoalition = MENU_COALITION:New( + coalition.side.BLUE, + MenuManager.Config.MissionOptionsMenuName + ) + + MenuManager.ParentMenus.RedCoalition = MENU_COALITION:New( + coalition.side.RED, + MenuManager.Config.MissionOptionsMenuName + ) + + -- Note: MENU_MISSION not created to avoid duplicate empty menu + -- Scripts that need mission-wide menus should use MENU_MISSION directly + + if MenuManager.Config.Debug then + env.info("MenuManager: Initialized parent coalition menus") + end + end +end + +-- Create a coalition menu under "Mission Options" +-- @param coalitionSide: coalition.side.BLUE or coalition.side.RED +-- @param menuName: Name of the menu +-- @param parentMenu: (Optional) If provided, creates as submenu of this parent instead of Mission Options +-- @return: MENU_COALITION object +function MenuManager.CreateCoalitionMenu(coalitionSide, menuName, parentMenu) + if MenuManager.Config.EnableMissionOptionsMenu and not parentMenu then + -- Create under Mission Options + local parent = (coalitionSide == coalition.side.BLUE) + and MenuManager.ParentMenus.BlueCoalition + or MenuManager.ParentMenus.RedCoalition + + local menu = MENU_COALITION:New(coalitionSide, menuName, parent) + + if MenuManager.Config.Debug then + local coalitionName = (coalitionSide == coalition.side.BLUE) and "BLUE" or "RED" + env.info(string.format("MenuManager: Created coalition menu '%s' for %s", menuName, coalitionName)) + end + + return menu + else + -- Create as root menu or under provided parent + local menu = MENU_COALITION:New(coalitionSide, menuName, parentMenu) + return menu + end +end + +-- Create a mission menu (not nested under Mission Options, as that causes duplicates) +-- @param menuName: Name of the menu +-- @param parentMenu: (Optional) Parent menu +-- @return: MENU_MISSION object +-- Note: Mission menus are visible to all players and cannot be nested under coalition menus +function MenuManager.CreateMissionMenu(menuName, parentMenu) + -- Always create as root menu or under provided parent + -- Mission menus can't be nested under coalition-specific "Mission Options" + local menu = MENU_MISSION:New(menuName, parentMenu) + + if MenuManager.Config.Debug then + env.info(string.format("MenuManager: Created mission menu '%s'", menuName)) + end + + return menu +end + +-- Helper to disable the parent menu system at runtime +function MenuManager.DisableParentMenus() + MenuManager.Config.EnableMissionOptionsMenu = false + env.info("MenuManager: Parent menu system disabled") +end + +-- Helper to enable the parent menu system at runtime +function MenuManager.EnableParentMenus() + MenuManager.Config.EnableMissionOptionsMenu = true + if not MenuManager.ParentMenus.BlueCoalition then + MenuManager.Initialize() + end + env.info("MenuManager: Parent menu system enabled") +end + +-- Initialize on load +MenuManager.Initialize() + +-- Announcement +env.info(string.format("MenuManager v%s loaded - Mission Options menu system ready", MenuManager.Version)) diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_CargoDispatcher.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_CargoDispatcher.lua new file mode 100644 index 0000000..96a149c --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_CargoDispatcher.lua @@ -0,0 +1,871 @@ +--[[ +═══════════════════════════════════════════════════════════════════════════════ + Moose_TDAC_CargoDispatcher.lua + Automated Logistics System for TADC Squadron Replenishment +═══════════════════════════════════════════════════════════════════════════════ + +DESCRIPTION: + This script monitors RED and BLUE squadrons for low aircraft counts and automatically dispatches CARGO aircraft from a list of supply airfields to replenish them. + It spawns cargo aircraft and routes them to destination airbases. Delivery detection and replenishment is handled by the main TADC system. + +CONFIGURATION: + - Update static templates and airfield lists as needed for your mission. + - Set thresholds and supply airfields in CARGO_SUPPLY_CONFIG. + - Replace static templates with actual group templates from the mission editor for realism. + +REQUIRES: + - MOOSE framework (for SPAWN, AIRBASE, etc.) + - Optional: MIST for deep copy of templates + +═══════════════════════════════════════════════════════════════════════════════ +]] + +-- Single-run guard to prevent duplicate dispatcher loops if script is reloaded +if _G.__TDAC_DISPATCHER_RUNNING then + env.info("[TDAC] CargoDispatcher already running; aborting duplicate load") + return +end +_G.__TDAC_DISPATCHER_RUNNING = true + +--[[ + GLOBAL STATE AND CONFIGURATION + -------------------------------------------------------------------------- + Tracks all active cargo missions and dispatcher configuration. +]] +if not cargoMissions then + cargoMissions = { red = {}, blue = {} } +end + +-- Dispatcher config (interval in seconds) +if not DISPATCHER_CONFIG then + -- default interval (seconds) and a slightly larger grace period to account for slow servers/networks + DISPATCHER_CONFIG = { interval = 60, gracePeriod = 25 } +end + +-- Safety flag: when false, do NOT fall back to spawning from in-memory template tables. +-- Set to true if you understand the tweaked-template warning and accept the risk. +if DISPATCHER_CONFIG.ALLOW_FALLBACK_TO_INMEM_TEMPLATE == nil then + DISPATCHER_CONFIG.ALLOW_FALLBACK_TO_INMEM_TEMPLATE = false +end + +--[[ + CARGO SUPPLY CONFIGURATION + -------------------------------------------------------------------------- + Set supply airfields, cargo template names, and resupply thresholds for each coalition. +]] +local CARGO_SUPPLY_CONFIG = { + red = { + supplyAirfields = { "Farah", "Nimroz", "Herat", "Shindand" }, -- replace with your RED supply airbase names + cargoTemplate = "CARGO_RED_AN26", -- replace with your RED cargo aircraft template name + threshold = 0.90 -- ratio below which to trigger resupply (testing) + }, + blue = { + supplyAirfields = { "Sharana", "Tarinkot" }, -- replace with your BLUE supply airbase names + cargoTemplate = "CARGO_BLUE_C130", -- replace with your BLUE cargo aircraft template name + threshold = 0.90 -- ratio below which to trigger resupply (testing) + } +} + + + +--[[ + UTILITY STUBS + -------------------------------------------------------------------------- + selectRandomAirfield: Picks a random airfield from a list. + announceToCoalition: Stub for in-game coalition messaging. + Replace with your own logic as needed. +]] +if not selectRandomAirfield then + function selectRandomAirfield(airfieldList) + if type(airfieldList) == "table" and #airfieldList > 0 then + return airfieldList[math.random(1, #airfieldList)] + end + return nil + end +end + +-- Stub for announceToCoalition (replace with your own logic if needed) +if not announceToCoalition then + function announceToCoalition(coalitionKey, message) + -- Replace with actual in-game message logic + env.info("[ANNOUNCE] [" .. tostring(coalitionKey) .. "]: " .. tostring(message)) + end +end + + +--[[ + LOGGING + -------------------------------------------------------------------------- + Advanced logging configuration and helper function for debug output. +]] +local ADVANCED_LOGGING = { + enableDetailedLogging = false, + logPrefix = "[TADC Cargo]" +} + +-- Logging function (must be defined before any log() calls) +local function log(message, detailed) + if not detailed or ADVANCED_LOGGING.enableDetailedLogging then + env.info(ADVANCED_LOGGING.logPrefix .. " " .. message) + end +end + +log("═══════════════════════════════════════════════════════════════════════════════", true) +log("Moose_TDAC_CargoDispatcher.lua loaded.", true) +log("═══════════════════════════════════════════════════════════════════════════════", true) + + +-- Provide a safe deepCopy if MIST is not available +local function deepCopy(obj) + if type(obj) ~= 'table' then return obj end + local res = {} + for k, v in pairs(obj) do + if type(v) == 'table' then + res[k] = deepCopy(v) + else + res[k] = v + end + end + return res +end + +-- Dispatch cooldown per airbase (seconds) to avoid repeated immediate retries +local CARGO_DISPATCH_COOLDOWN = DISPATCHER_CONFIG and DISPATCHER_CONFIG.cooldown or 300 -- default 5 minutes +local lastDispatchAttempt = { red = {}, blue = {} } + +local function getCoalitionSide(coalitionKey) + if coalitionKey == 'blue' then return coalition.side.BLUE end + if coalitionKey == 'red' then return coalition.side.RED end + return nil +end + +-- Forward-declare parking check helper so functions defined earlier can call it +local destinationHasSuitableParking + +-- Validate dispatcher configuration: check that supply airfields exist and templates appear valid +local function validateDispatcherConfig() + local problems = {} + + -- Check supply airfields exist + for coalitionKey, cfg in pairs(CARGO_SUPPLY_CONFIG) do + if cfg and cfg.supplyAirfields and type(cfg.supplyAirfields) == 'table' then + for _, abName in ipairs(cfg.supplyAirfields) do + local ok, ab = pcall(function() return AIRBASE:FindByName(abName) end) + if not ok or not ab then + table.insert(problems, string.format("Missing airbase for %s supply list: '%s'", tostring(coalitionKey), tostring(abName))) + end + end + else + table.insert(problems, string.format("Missing or invalid supplyAirfields for coalition '%s'", tostring(coalitionKey))) + end + + -- Check cargo template presence (best-effort using SPAWN:New if available) + if cfg and cfg.cargoTemplate and type(cfg.cargoTemplate) == 'string' and cfg.cargoTemplate ~= '' then + local okSpawn, spawnObj = pcall(function() return SPAWN:New(cfg.cargoTemplate) end) + if not okSpawn or not spawnObj then + -- SPAWN:New may not be available at load time; warn but don't fail hard + table.insert(problems, string.format("Cargo template suspicious or missing: '%s' (coalition: %s)", tostring(cfg.cargoTemplate), tostring(coalitionKey))) + end + else + table.insert(problems, string.format("Missing cargoTemplate for coalition '%s'", tostring(coalitionKey))) + end + end + + if #problems == 0 then + log("TDAC Dispatcher config validation passed ✓", true) + MESSAGE:New("TDAC Dispatcher config validation passed ✓", 15):ToAll() + return true, {} + else + log("TDAC Dispatcher config validation found issues:", true) + MESSAGE:New("TDAC Dispatcher config validation found issues:" .. table.concat(problems, ", "), 15):ToAll() + for _, p in ipairs(problems) do + log(" ✗ " .. p, true) + end + return false, problems + end +end + +-- Expose console helper to run the check manually +function _G.TDAC_RunConfigCheck() + local ok, problems = validateDispatcherConfig() + if ok then + return true, "OK" + else + return false, problems + end +end + + + +--[[ + getSquadronStatus(squadron, coalitionKey) + -------------------------------------------------------------------------- + Returns the current, max, and ratio of aircraft for a squadron. + If you track current aircraft in a table, update this logic accordingly. + Returns: currentCount, maxCount, ratio +]] +local function getSquadronStatus(squadron, coalitionKey) + local current = squadron.current or squadron.count or squadron.aircraft or 0 + local max = squadron.max or squadron.aircraft or 1 + if squadron.templateName and _G.squadronAircraftCounts and _G.squadronAircraftCounts[coalitionKey] then + current = _G.squadronAircraftCounts[coalitionKey][squadron.templateName] or current + end + local ratio = (max > 0) and (current / max) or 0 + return current, max, ratio +end + + + +--[[ + hasActiveCargoMission(coalitionKey, airbaseName) + -------------------------------------------------------------------------- + Returns true if there is an active (not completed/failed) cargo mission for the given airbase. + Failed missions are immediately removed from tracking to allow retries. +]] +local function hasActiveCargoMission(coalitionKey, airbaseName) + for i = #cargoMissions[coalitionKey], 1, -1 do + local mission = cargoMissions[coalitionKey][i] + if mission.destination == airbaseName then + -- Remove completed or failed missions immediately to allow retries + if mission.status == "completed" or mission.status == "failed" then + log("Removing " .. mission.status .. " cargo mission for " .. airbaseName .. " from tracking") + table.remove(cargoMissions[coalitionKey], i) + else + -- Consider mission active only if the group is alive OR we're still within the grace window + local stillActive = false + if mission.group and mission.group.IsAlive and mission.group:IsAlive() then + stillActive = true + else + local pending = mission._pendingStartTime + local grace = mission._gracePeriod or DISPATCHER_CONFIG.gracePeriod or 8 + if pending and (timer.getTime() - pending) <= grace then + stillActive = true + end + end + if stillActive then + log("Active cargo mission found for " .. airbaseName .. " (" .. coalitionKey .. ")") + return true + end + end + end + end + log("No active cargo mission for " .. airbaseName .. " (" .. coalitionKey .. ")") + return false +end + +--[[ + trackCargoMission(coalitionKey, mission) + -------------------------------------------------------------------------- + Adds a new cargo mission to the tracking table and logs it. +]] +local function trackCargoMission(coalitionKey, mission) + table.insert(cargoMissions[coalitionKey], mission) + log("Tracking new cargo mission: " .. (mission.group and mission.group:GetName() or "nil group") .. " from " .. mission.origin .. " to " .. mission.destination) +end + +--[[ + cleanupCargoMissions() + -------------------------------------------------------------------------- + Removes failed cargo missions from the tracking table if their group is no longer alive. +]] +local function cleanupCargoMissions() + for _, coalitionKey in ipairs({"red", "blue"}) do + for i = #cargoMissions[coalitionKey], 1, -1 do + local m = cargoMissions[coalitionKey][i] + if m.status == "failed" or m.status == "completed" then + if not (m.group and m.group:IsAlive()) then + log("Cleaning up " .. m.status .. " cargo mission: " .. (m.group and m.group:GetName() or "nil group")) + table.remove(cargoMissions[coalitionKey], i) + end + end + end + end +end + +--[[ + dispatchCargo(squadron, coalitionKey) + -------------------------------------------------------------------------- + Spawns a cargo aircraft from a supply airfield to the destination squadron airbase. + Uses static templates for each coalition, assigns a unique group name, and sets a custom route. + Tracks the mission and schedules route assignment with a delay to ensure group is alive. +]] +local function dispatchCargo(squadron, coalitionKey) + local config = CARGO_SUPPLY_CONFIG[coalitionKey] + local origin + local attempts = 0 + local maxAttempts = 10 + local coalitionSide = getCoalitionSide(coalitionKey) + + repeat + origin = selectRandomAirfield(config.supplyAirfields) + attempts = attempts + 1 + + -- Ensure origin is not the same as destination + if origin == squadron.airbaseName then + origin = nil + else + -- Validate that origin airbase exists and is controlled by correct coalition + local originAirbase = AIRBASE:FindByName(origin) + if not originAirbase then + log("WARNING: Origin airbase '" .. tostring(origin) .. "' does not exist. Trying another...") + origin = nil + elseif originAirbase:GetCoalition() ~= coalitionSide then + log("WARNING: Origin airbase '" .. tostring(origin) .. "' is not controlled by " .. coalitionKey .. " coalition. Trying another...") + origin = nil + end + end + until origin or attempts >= maxAttempts + + -- enforce cooldown per destination to avoid immediate retries + lastDispatchAttempt[coalitionKey] = lastDispatchAttempt[coalitionKey] or {} + local last = lastDispatchAttempt[coalitionKey][squadron.airbaseName] + if last and (timer.getTime() - last) < CARGO_DISPATCH_COOLDOWN then + log("Skipping dispatch to " .. squadron.airbaseName .. " (cooldown active)") + return + end + if not origin then + log("No valid origin airfield found for cargo dispatch to " .. squadron.airbaseName .. " (avoiding same origin/destination)") + return + end + local destination = squadron.airbaseName + local cargoTemplate = config.cargoTemplate + -- Safety: check if destination has suitable parking for larger transports. If not, warn in log. + local okParking = true + -- Only check for likely large transports (C-130 / An-26 are large-ish) — keep conservative + if cargoTemplate and (string.find(cargoTemplate:upper(), "C130") or string.find(cargoTemplate:upper(), "C-17") or string.find(cargoTemplate:upper(), "C17") or string.find(cargoTemplate:upper(), "AN26") ) then + okParking = destinationHasSuitableParking(destination) + if not okParking then + log("WARNING: Destination '" .. tostring(destination) .. "' may not have suitable parking for " .. tostring(cargoTemplate) .. ". Skipping dispatch to prevent despawn.") + return + end + end + local groupName = cargoTemplate .. "_to_" .. destination .. "_" .. math.random(1000,9999) + + log("Dispatching cargo: " .. groupName .. " from " .. origin .. " to " .. destination) + + -- Spawn cargo aircraft at origin using the template name ONLY for SPAWN + -- Note: cargoTemplate is a config string; script uses in-file Lua template tables (CARGO_AIRCRAFT_TEMPLATE_*) + log("DEBUG: Attempting spawn for group: '" .. groupName .. "' at airbase: '" .. origin .. "' (using in-file Lua template)", true) + local airbaseObj = AIRBASE:FindByName(origin) + if not airbaseObj then + log("ERROR: AIRBASE:FindByName failed for '" .. tostring(origin) .. "'. Airbase object is nil!") + else + log("DEBUG: AIRBASE object found for '" .. origin .. "'. Proceeding with spawn.", true) + end + -- Select the correct template based on coalition + local templateBase, uniqueGroupName + if coalitionKey == "blue" then + templateBase = CARGO_AIRCRAFT_TEMPLATE_BLUE + uniqueGroupName = "CARGO_C130_DYNAMIC_" .. math.random(1000,9999) + else + templateBase = CARGO_AIRCRAFT_TEMPLATE_RED + uniqueGroupName = "CARGO_AN26_DYNAMIC_" .. math.random(1000,9999) + end + -- Clone the template and set the group/unit name + -- Prepare a mission placeholder. We'll set the group and spawnPos after successful spawn. + local mission = { + group = nil, + origin = origin, + destination = destination, + squadron = squadron, + status = "pending", + -- Anchor a pending start time now to avoid the monitor loop expiring a mission + -- before MOOSE has a chance to finalize the OnSpawnGroup callback. + _pendingStartTime = timer.getTime(), + _spawnPos = nil, + _gracePeriod = DISPATCHER_CONFIG.gracePeriod or 8 + } + + -- Helper to finalize mission after successful spawn + local function finalizeMissionAfterSpawn(spawnedGroup, spawnPos) + mission.group = spawnedGroup + mission._spawnPos = spawnPos + trackCargoMission(coalitionKey, mission) + lastDispatchAttempt[coalitionKey][squadron.airbaseName] = timer.getTime() + end + + -- MOOSE-only spawn-by-name flow + if type(cargoTemplate) ~= 'string' or cargoTemplate == '' then + log("ERROR: cargoTemplate for coalition '" .. tostring(coalitionKey) .. "' must be a valid mission template name string. Aborting dispatch.") + announceToCoalition(coalitionKey, "Resupply mission to " .. destination .. " aborted (invalid cargo template)!") + return + end + + -- Use a per-dispatch RAT object to spawn and route cargo aircraft. + -- Create a unique alias to avoid naming collisions and let RAT handle routing/landing. + local alias = cargoTemplate .. "_TO_" .. destination .. "_" .. tostring(math.random(1000,9999)) + log("DEBUG: Attempting RAT spawn for template: '" .. cargoTemplate .. "' alias: '" .. alias .. "'", true) + + -- Validate destination airbase: RAT's "Airbase doesn't exist" error actually means + -- "Airbase not found OR not owned by the correct coalition" because RAT filters by coalition internally. + -- We perform the same validation here to fail fast with better error messages. + local destAirbase = AIRBASE:FindByName(destination) + local coalitionSide = getCoalitionSide(coalitionKey) + + if not destAirbase then + log("ERROR: Destination airbase '" .. destination .. "' does not exist in DCS (invalid name or not on this map). Skipping dispatch.") + announceToCoalition(coalitionKey, "Resupply mission to " .. destination .. " failed (airbase not found on map)!") + -- Mark mission as failed and cleanup immediately + mission.status = "failed" + return + end + + local destCoalition = destAirbase:GetCoalition() + if destCoalition ~= coalitionSide then + log("INFO: Destination airbase '" .. destination .. "' captured by enemy - cargo dispatch skipped (normal mission state).", true) + -- No announcement to coalition - this is expected behavior when base is captured + -- Mark mission as failed and cleanup immediately + mission.status = "failed" + return + end + + -- Validate origin airbase with same coalition filtering logic + local originAirbase = AIRBASE:FindByName(origin) + if not originAirbase then + log("ERROR: Origin airbase '" .. origin .. "' does not exist in DCS (invalid name or not on this map). Skipping dispatch.") + announceToCoalition(coalitionKey, "Resupply mission from " .. origin .. " failed (airbase not found on map)!") + -- Mark mission as failed and cleanup immediately + mission.status = "failed" + return + end + + local originCoalition = originAirbase:GetCoalition() + if originCoalition ~= coalitionSide then + log("INFO: Origin airbase '" .. origin .. "' captured by enemy - trying another supply source.", true) + -- Don't announce or mark as failed - the dispatcher will try another origin + return + end + + local okNew, rat = pcall(function() return RAT:New(cargoTemplate, alias) end) + if not okNew or not rat then + log("ERROR: RAT:New failed for template '" .. tostring(cargoTemplate) .. "'. Error: " .. tostring(rat)) + if debug and debug.traceback then + log("TRACEBACK: " .. tostring(debug.traceback(rat)), true) + end + announceToCoalition(coalitionKey, "Resupply mission to " .. destination .. " failed (spawn init error)!") + -- Mark mission as failed and cleanup immediately - do NOT track failed RAT spawns + mission.status = "failed" + return + end + + -- Configure RAT for a single, non-respawning dispatch + rat:SetDeparture(origin) + rat:SetDestination(destination) + rat:NoRespawn() + rat:InitUnControlled(false) -- force departing transports to spawn in a controllable state + rat:InitLateActivated(false) + rat:SetSpawnLimit(1) + rat:SetSpawnDelay(1) + + -- CRITICAL: Force takeoff from runway to prevent aircraft getting stuck at parking + -- SetTakeoffRunway() ensures aircraft spawn directly on runway and take off immediately + if rat.SetTakeoffRunway then + rat:SetTakeoffRunway() + log("DEBUG: Configured cargo to take off from runway at " .. origin, true) + else + log("WARNING: SetTakeoffRunway() not available - falling back to SetTakeoffHot()", true) + if rat.SetTakeoffHot then rat:SetTakeoffHot() end + end + + -- Ensure RAT will look for parking and not despawn the group immediately on landing. + -- This makes the group taxi to parking and come to a stop so other scripts (e.g. Load2nd) + -- that detect parked/stopped cargo aircraft can register the delivery. + if rat.SetParkingScanRadius then rat:SetParkingScanRadius(80) end + if rat.SetParkingSpotSafeON then rat:SetParkingSpotSafeON() end + if rat.SetDespawnAirOFF then rat:SetDespawnAirOFF() end + -- Check on runway to ensure proper landing behavior (distance in meters) + if rat.CheckOnRunway then rat:CheckOnRunway(true, 75) end + + rat:OnSpawnGroup(function(spawnedGroup) + -- Mark the canonical start time when MOOSE reports the group exists + mission._pendingStartTime = timer.getTime() + + local spawnPos = nil + local dcsGroup = spawnedGroup:GetDCSObject() + if dcsGroup then + local units = dcsGroup:getUnits() + if units and #units > 0 then + spawnPos = units[1]:getPoint() + end + end + + log("RAT spawned cargo aircraft group: " .. tostring(spawnedGroup:GetName())) + + -- Temporary debug: log group state every 10s for 10 minutes to trace landing/parking behavior + local debugChecks = 60 -- 60 * 10s = 10 minutes + local checkInterval = 10 + local function debugLogState(iter) + if iter > debugChecks then return end + local ok, err = pcall(function() + local name = spawnedGroup:GetName() + local dcs = spawnedGroup:GetDCSObject() + if dcs then + local units = dcs:getUnits() + if units and #units > 0 then + local u = units[1] + local pos = u:getPoint() + -- Use dot accessor to test for function existence; colon-call to invoke + local vel = (u.getVelocity and u:getVelocity()) or {x=0,y=0,z=0} + local speed = math.sqrt((vel.x or 0)^2 + (vel.y or 0)^2 + (vel.z or 0)^2) + local controller = dcs:getController() + local airbaseObj = AIRBASE:FindByName(destination) + local dist = nil + if airbaseObj then + local dest = airbaseObj:GetCoordinate():GetVec2() + local dx = pos.x - dest.x + local dz = pos.z - dest.y + dist = math.sqrt(dx*dx + dz*dz) + end + log(string.format("[TDAC DEBUG] %s state check %d: alive=%s pos=(%.1f,%.1f) speed=%.2f m/s distToDest=%s", name, iter, tostring(spawnedGroup:IsAlive()), pos.x or 0, pos.z or 0, speed, tostring(dist)), true) + else + log(string.format("[TDAC DEBUG] %s state check %d: DCS group has no units", tostring(spawnedGroup:GetName()), iter), true) + end + else + log(string.format("[TDAC DEBUG] %s state check %d: no DCS group object", tostring(spawnedGroup:GetName()), iter), true) + end + end) + if not ok then + log("[TDAC DEBUG] Error during debugLogState: " .. tostring(err), true) + end + timer.scheduleFunction(function() debugLogState(iter + 1) end, {}, timer.getTime() + checkInterval) + end + timer.scheduleFunction(function() debugLogState(1) end, {}, timer.getTime() + checkInterval) + + -- RAT should handle routing/taxi/parking. Finalize mission tracking now. + finalizeMissionAfterSpawn(spawnedGroup, spawnPos) + mission.status = "enroute" + mission._pendingStartTime = timer.getTime() + announceToCoalition(coalitionKey, "CARGO aircraft departing (airborne) for " .. destination .. ". Defend it!") + end) + + local okSpawn, errSpawn = pcall(function() rat:Spawn(1) end) + if not okSpawn then + log("ERROR: rat:Spawn() failed for template '" .. tostring(cargoTemplate) .. "'. Error: " .. tostring(errSpawn)) + if debug and debug.traceback then + log("TRACEBACK: " .. tostring(debug.traceback(errSpawn)), true) + end + announceToCoalition(coalitionKey, "Resupply mission to " .. destination .. " failed (spawn error)!") + -- Mark mission as failed and cleanup immediately - do NOT track failed spawns + mission.status = "failed" + return + end +end + + +-- Parking diagnostics helper +-- Call from DCS console: _G.TDAC_LogAirbaseParking("Luostari Pechenga") +function _G.TDAC_LogAirbaseParking(airbaseName) + if type(airbaseName) ~= 'string' then + log("TDAC Parking helper: airbaseName must be a string", true) + return false + end + local base = AIRBASE:FindByName(airbaseName) + if not base then + log("TDAC Parking helper: AIRBASE:FindByName returned nil for '" .. tostring(airbaseName) .. "'", true) + return false + end + local function spotsFor(term) + local ok, n = pcall(function() return base:GetParkingSpotsNumber(term) end) + if not ok then return nil end + return n + end + local openBig = spotsFor(AIRBASE.TerminalType.OpenBig) + local openMed = spotsFor(AIRBASE.TerminalType.OpenMed) + local openMedOrBig = spotsFor(AIRBASE.TerminalType.OpenMedOrBig) + local runway = spotsFor(AIRBASE.TerminalType.Runway) + log(string.format("TDAC Parking: %s -> OpenBig=%s OpenMed=%s OpenMedOrBig=%s Runway=%s", airbaseName, tostring(openBig), tostring(openMed), tostring(openMedOrBig), tostring(runway)), true) + return true +end + + +-- Pre-dispatch safety check: ensure destination can accommodate larger transport types +destinationHasSuitableParking = function(destination, preferredTermTypes) + local base = AIRBASE:FindByName(destination) + if not base then return false end + preferredTermTypes = preferredTermTypes or { AIRBASE.TerminalType.OpenBig, AIRBASE.TerminalType.OpenMedOrBig, AIRBASE.TerminalType.OpenMed } + for _, term in ipairs(preferredTermTypes) do + local ok, n = pcall(function() return base:GetParkingSpotsNumber(term) end) + if ok and n and n > 0 then + return true + end + end + return false +end + + +--[[ + monitorSquadrons() + -------------------------------------------------------------------------- + Checks all squadrons for each coalition. If a squadron is below the resupply threshold and has no active cargo mission, + triggers a supply request and dispatches a cargo aircraft. + Skips squadrons that are captured or not operational. +]] +local function monitorSquadrons() + for _, coalitionKey in ipairs({"red", "blue"}) do + local config = CARGO_SUPPLY_CONFIG[coalitionKey] + local squadrons = (coalitionKey == "red") and RED_SQUADRON_CONFIG or BLUE_SQUADRON_CONFIG + for _, squadron in ipairs(squadrons) do + -- Skip non-operational squadrons (captured, destroyed, etc.) + if squadron.state and squadron.state ~= "operational" then + log("Squadron " .. squadron.displayName .. " (" .. coalitionKey .. ") is " .. squadron.state .. " - skipping cargo dispatch", true) + else + local current, max, ratio = getSquadronStatus(squadron, coalitionKey) + log("Squadron status: " .. squadron.displayName .. " (" .. coalitionKey .. ") " .. current .. "/" .. max .. " ratio: " .. string.format("%.2f", ratio)) + if ratio <= config.threshold and not hasActiveCargoMission(coalitionKey, squadron.airbaseName) then + log("Supply request triggered for " .. squadron.displayName .. " at " .. squadron.airbaseName) + announceToCoalition(coalitionKey, "Supply requested for " .. squadron.airbaseName .. "! Squadron: " .. squadron.displayName) + dispatchCargo(squadron, coalitionKey) + end + end + end + end +end + +--[[ + monitorCargoMissions() + -------------------------------------------------------------------------- + Monitors all cargo missions, updates their status, and cleans up failed ones. + Handles mission failure after a grace period. +]] +local function monitorCargoMissions() + for _, coalitionKey in ipairs({"red", "blue"}) do + for _, mission in ipairs(cargoMissions[coalitionKey]) do + if mission.group == nil then + log("[DEBUG] Mission group object is nil for mission to " .. tostring(mission.destination), true) + else + log("[DEBUG] Mission group: " .. tostring(mission.group:GetName()) .. ", IsAlive(): " .. tostring(mission.group:IsAlive()), true) + local dcsGroup = mission.group:GetDCSObject() + if dcsGroup then + local units = dcsGroup:getUnits() + if units and #units > 0 then + local pos = units[1]:getPoint() + log(string.format("[DEBUG] Group position: x=%.1f y=%.1f z=%.1f", pos.x, pos.y, pos.z), true) + else + log("[DEBUG] No units found in DCS group for mission to " .. tostring(mission.destination), true) + end + else + log("[DEBUG] DCS group object is nil for mission to " .. tostring(mission.destination), true) + end + end + + local graceElapsed = mission._pendingStartTime and (timer.getTime() - mission._pendingStartTime > (mission._gracePeriod or 8)) + + -- Only allow mission to be failed after grace period, and only if group is truly dead. + -- Some DCS/MOOSE group objects may momentarily report IsAlive() == false while units still exist, so + -- also check DCS object/unit presence before declaring failure. + if (mission.status == "pending" or mission.status == "enroute") and graceElapsed then + local isAlive = mission.group and mission.group:IsAlive() + local dcsGroup = mission.group and mission.group:GetDCSObject() + local unitsPresent = false + if dcsGroup then + local units = dcsGroup:getUnits() + unitsPresent = units and (#units > 0) + end + if not isAlive and not unitsPresent then + mission.status = "failed" + log("Cargo mission failed (after grace period): " .. (mission.group and mission.group:GetName() or "nil group") .. " to " .. mission.destination) + announceToCoalition(coalitionKey, "Resupply mission to " .. mission.destination .. " failed!") + else + log("DEBUG: Mission appears to still have DCS units despite IsAlive=false; skipping failure for " .. tostring(mission.destination), true) + end + end + end + end + cleanupCargoMissions() +end + +--[[ + MAIN DISPATCHER LOOP + -------------------------------------------------------------------------- + Runs the main dispatcher logic on a timer interval. +]] +local function cargoDispatcherMain() + log("═══════════════════════════════════════════════════════════════════════════════", true) + log("Cargo Dispatcher main loop running.", true) + monitorSquadrons() + monitorCargoMissions() + -- Schedule the next run inside a protected call to avoid unhandled errors + timer.scheduleFunction(function() + local ok, err = pcall(cargoDispatcherMain) + if not ok then + log("FATAL: cargoDispatcherMain crashed on scheduled run: " .. tostring(err)) + -- do not reschedule to avoid crash loops + end + end, {}, timer.getTime() + DISPATCHER_CONFIG.interval) +end + +-- Start the dispatcher +local ok, err = pcall(cargoDispatcherMain) +if not ok then + log("FATAL: cargoDispatcherMain crashed on startup: " .. tostring(err)) +end + +log("═══════════════════════════════════════════════════════════════════════════════", true) +-- End Moose_TDAC_CargoDispatcher.lua + + +--[[ + DIAGNOSTIC CONSOLE HELPERS + -------------------------------------------------------------------------- + Functions you can call from the DCS Lua console (F12) to debug issues. +]] + +-- Check airbase coalition ownership for all configured supply airbases +-- Usage: _G.TDAC_CheckAirbaseOwnership() +function _G.TDAC_CheckAirbaseOwnership() + env.info("[TDAC DIAGNOSTIC] ═══════════════════════════════════════") + env.info("[TDAC DIAGNOSTIC] Checking Coalition Ownership of All Supply Airbases") + env.info("[TDAC DIAGNOSTIC] ═══════════════════════════════════════") + + for _, coalitionKey in ipairs({"red", "blue"}) do + local config = CARGO_SUPPLY_CONFIG[coalitionKey] + local expectedCoalition = getCoalitionSide(coalitionKey) + + env.info(string.format("[TDAC DIAGNOSTIC] %s COALITION (expected coalition ID: %s)", coalitionKey:upper(), tostring(expectedCoalition))) + + if config and config.supplyAirfields then + for _, airbaseName in ipairs(config.supplyAirfields) do + local airbase = AIRBASE:FindByName(airbaseName) + if not airbase then + env.info(string.format("[TDAC DIAGNOSTIC] ✗ %-30s - NOT FOUND (invalid name or not on this map)", airbaseName)) + else + local actualCoalition = airbase:GetCoalition() + local coalitionName = "UNKNOWN" + local status = "✗" + + if actualCoalition == coalition.side.NEUTRAL then + coalitionName = "NEUTRAL" + elseif actualCoalition == coalition.side.RED then + coalitionName = "RED" + elseif actualCoalition == coalition.side.BLUE then + coalitionName = "BLUE" + end + + if actualCoalition == expectedCoalition then + status = "✓" + end + + env.info(string.format("[TDAC DIAGNOSTIC] %s %-30s - %s (coalition ID: %s)", status, airbaseName, coalitionName, tostring(actualCoalition))) + end + end + else + env.info("[TDAC DIAGNOSTIC] ERROR: No supply airfields configured!") + end + env.info("[TDAC DIAGNOSTIC] ───────────────────────────────────────") + end + + env.info("[TDAC DIAGNOSTIC] ═══════════════════════════════════════") + env.info("[TDAC DIAGNOSTIC] Check complete. ✓ = Owned by correct coalition, ✗ = Wrong coalition or not found") + return true +end + +-- Check specific airbase coalition ownership +-- Usage: _G.TDAC_CheckAirbase("Olenya") +function _G.TDAC_CheckAirbase(airbaseName) + if type(airbaseName) ~= 'string' then + env.info("[TDAC DIAGNOSTIC] ERROR: airbaseName must be a string") + return false + end + + local airbase = AIRBASE:FindByName(airbaseName) + if not airbase then + env.info(string.format("[TDAC DIAGNOSTIC] Airbase '%s' NOT FOUND (invalid name or not on this map)", airbaseName)) + return false, "not_found" + end + + local actualCoalition = airbase:GetCoalition() + local coalitionName = "UNKNOWN" + + if actualCoalition == coalition.side.NEUTRAL then + coalitionName = "NEUTRAL" + elseif actualCoalition == coalition.side.RED then + coalitionName = "RED" + elseif actualCoalition == coalition.side.BLUE then + coalitionName = "BLUE" + end + + env.info(string.format("[TDAC DIAGNOSTIC] Airbase '%s' - Coalition: %s (ID: %s)", airbaseName, coalitionName, tostring(actualCoalition))) + env.info(string.format("[TDAC DIAGNOSTIC] IsAlive: %s", tostring(airbase:IsAlive()))) + + -- Check parking spots + local function spotsFor(term, termName) + local ok, n = pcall(function() return airbase:GetParkingSpotsNumber(term) end) + if ok and n then + env.info(string.format("[TDAC DIAGNOSTIC] Parking %-15s: %d spots", termName, n)) + end + end + + spotsFor(AIRBASE.TerminalType.OpenBig, "OpenBig") + spotsFor(AIRBASE.TerminalType.OpenMed, "OpenMed") + spotsFor(AIRBASE.TerminalType.OpenMedOrBig, "OpenMedOrBig") + spotsFor(AIRBASE.TerminalType.Runway, "Runway") + + return true, coalitionName, actualCoalition +end + +env.info("[TDAC DIAGNOSTIC] Console helpers loaded:") +env.info("[TDAC DIAGNOSTIC] _G.TDAC_CheckAirbaseOwnership() - Check all supply airbases") +env.info("[TDAC DIAGNOSTIC] _G.TDAC_CheckAirbase('Olenya') - Check specific airbase") +env.info("[TDAC DIAGNOSTIC] _G.TDAC_RunConfigCheck() - Validate dispatcher config") +env.info("[TDAC DIAGNOSTIC] _G.TDAC_LogAirbaseParking('Olenya') - Check parking availability") + + +-- Diagnostic helper: call from DCS console to test spawn-by-name and routing. +-- Example (paste into DCS Lua console): +-- _G.TDAC_CargoDispatcher_TestSpawn("CARGO_BLUE_C130_TEMPLATE", "Kittila", "Luostari Pechenga") +function _G.TDAC_CargoDispatcher_TestSpawn(templateName, originAirbase, destinationAirbase) + log("[TDAC TEST] Starting test spawn for template: " .. tostring(templateName), true) + local ok, err + if type(templateName) ~= 'string' then + env.info("[TDAC TEST] templateName must be a string") + return false, "invalid templateName" + end + local spawnByName = nil + ok, spawnByName = pcall(function() return SPAWN:New(templateName) end) + if not ok or not spawnByName then + log("[TDAC TEST] SPAWN:New failed for template " .. tostring(templateName) .. ". Error: " .. tostring(spawnByName), true) + if debug and debug.traceback then log("TRACEBACK: " .. tostring(debug.traceback(tostring(spawnByName))), true) end + return false, "spawn_new_failed" + end + + spawnByName:OnSpawnGroup(function(spawnedGroup) + log("[TDAC TEST] OnSpawnGroup called for: " .. tostring(spawnedGroup:GetName()), true) + local dcsGroup = spawnedGroup:GetDCSObject() + if dcsGroup then + local units = dcsGroup:getUnits() + if units and #units > 0 then + local pos = units[1]:getPoint() + log(string.format("[TDAC TEST] Spawned pos x=%.1f y=%.1f z=%.1f", pos.x, pos.y, pos.z), true) + end + end + if destinationAirbase then + local okAssign, errAssign = pcall(function() + local base = AIRBASE:FindByName(destinationAirbase) + if base and spawnedGroup and spawnedGroup.RouteToAirbase then + spawnedGroup:RouteToAirbase(base, AI_Task_Land.Runway) + log("[TDAC TEST] RouteToAirbase assigned to " .. tostring(destinationAirbase), true) + else + log("[TDAC TEST] RouteToAirbase not available or base not found", true) + end + end) + if not okAssign then + log("[TDAC TEST] RouteToAirbase pcall failed: " .. tostring(errAssign), true) + if debug and debug.traceback then log("TRACEBACK: " .. tostring(debug.traceback(tostring(errAssign))), true) end + end + end + end) + + ok, err = pcall(function() spawnByName:Spawn() end) + if not ok then + log("[TDAC TEST] spawnByName:Spawn() failed: " .. tostring(err), true) + if debug and debug.traceback then log("TRACEBACK: " .. tostring(debug.traceback(tostring(err))), true) end + return false, "spawn_failed" + end + log("[TDAC TEST] spawnByName:Spawn() returned successfully", true) + return true +end + + +log("═══════════════════════════════════════════════════════════════════════════════", true) +-- End Moose_TDAC_CargoDispatcher.lua + diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_Load2nd.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_Load2nd.lua new file mode 100644 index 0000000..7b06b50 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_Load2nd.lua @@ -0,0 +1,2617 @@ +--[[ +═══════════════════════════════════════════════════════════════════════════════ + UNIVERSAL TADC + Dual-Coalition Tactical Air Defense Controller + Advanced Zone-Based System +═══════════════════════════════════════════════════════════════════════════════ + +DESCRIPTION: +This script provides a sophisticated automated air defense system for BOTH RED and +BLUE coalitions operating independently. Features advanced zone-based area of +responsibility (AOR) management, allowing squadrons to respond differently based +on threat location and priority levels. Perfect for complex scenarios requiring +realistic air defense behavior and tactical depth. + +CORE FEATURES: +• Dual-coalition support with completely independent operation +• Advanced zone-based area of responsibility system (Primary/Secondary/Tertiary) +• Automatic threat detection with intelligent interceptor allocation +• Multi-squadron management with individual cooldowns and aircraft tracking +• Dynamic cargo aircraft replenishment system +• Configurable intercept ratios with zone-specific response modifiers +• Smart interceptor routing, engagement, and RTB (Return to Base) behavior +• Real-time airbase status monitoring (operational/captured/destroyed) +• Comprehensive configuration validation and error reporting +• Asymmetric warfare support with coalition-specific capabilities +• Emergency cleanup systems and safety nets for mission stability + +ADVANCED ZONE SYSTEM: +Each squadron can be configured with up to three zone types: +• PRIMARY ZONE: Main area of responsibility (full response ratio) +• SECONDARY ZONE: Support area (reduced response, optional low-priority filtering) +• TERTIARY ZONE: Emergency/fallback area (enhanced response when base threatened) +• Squadrons will respond based on threat location relative to their zones +• Zone-specific response modifiers can be configured for each squadron +• Zones may overlap between squadrons for layered defense. + +ADVANCED ZONE SETUP: +• Create zones in the mission editor (MOOSE polygons, circles, etc.) +• Assign zone names to squadrons in the configuration (exact match required) +• Leave zones as nil for global threat response (no zone restrictions) +• Each zone is defined by placing a helicopter group with waypoints outlining the area +• The script will create polygon zones from the helicopter waypoints automatically + +Zone response behaviors include: +• Distance-based engagement limits (max range from airbase) +• Priority thresholds for threat classification (major vs minor threats) +• Fallback conditions (auto-switch to tertiary when squadron weakened) +• Response ratio multipliers per zone type +• Low-priority threat filtering in secondary zones + +REPLENISHMENT SYSTEM: +• Automated cargo aircraft detection system that monitors for transport aircraft + flyovers to replenish squadron aircraft counts (fixed wing only): +• Detects cargo aircraft by name patterns (CARGO, TRANSPORT, C130, C-130, AN26, AN-26) +• Monitors flyover proximity to friendly airbases (no landing required) +• Replenishes squadron aircraft up to maximum capacity per airbase +• Prevents duplicate processing of the same cargo delivery +• Coalition-specific replenishment amounts configurable independently +• Supports sustained operations over extended mission duration + +*** This system does not spawn or manage cargo aircraft - it only detects when +your existing cargo aircraft complete deliveries via flyover. Create and route your own +transport missions to maintain squadron strength. Aircraft can deliver supplies by +flying within 3000m of any configured airbase without needing to land. *** + +INTERCEPT RATIO SYSTEM: +Sophisticated threat response calculation with zone-based modifiers: +• Base intercept ratio (e.g., 0.8 = 8 interceptors per 10 threats) +• Zone-specific multipliers (primary: 1.0, secondary: 0.6, tertiary: 1.4) +• Threat size considerations (larger formations get proportional response) +• Squadron selection based on zone priority and proximity +• Aircraft availability and cooldown status factored into decisions + +SETUP INSTRUCTIONS: +1. Load MOOSE framework in mission before this script +2. Configure Squadrons: Create fighter aircraft GROUP templates for both coalitions in mission editor +3. Configure RED squadrons in RED_SQUADRON_CONFIG section +4. Configure BLUE squadrons in BLUE_SQUADRON_CONFIG section +5. Optionally create zones in mission editor for area-of-responsibility using helicopter groups with waypoints. +6. Set coalition behavior parameters in TADC_SETTINGS +7. Configure cargo patterns in ADVANCED_SETTINGS if using replenishment +8. Add this script as "DO SCRIPT" trigger at mission start (after MOOSE loaded) +9. Create and manage cargo aircraft missions for replenishment (optional) + +CONFIGURATION VALIDATION: +Built-in validation system checks for: +• Template existence and proper naming +• Airbase name accuracy and coalition control +• Zone existence in mission editor +• Parameter ranges and logical consistency +• Coalition enablement and squadron availability +• Prevents common configuration errors before mission starts + +TACTICAL SCENARIOS SUPPORTED: +• Balanced air warfare with equal capabilities and symmetric response +• Asymmetric scenarios with different coalition strengths and capabilities +• Layered air defense with overlapping squadron zones +• Border/perimeter defense with primary and fallback positions +• Training missions for AI vs AI air combat observation +• Dynamic frontline battles with shifting territorial control +• Long-duration missions with cargo resupply operations +• Emergency response scenarios with threat priority management + +LOGGING AND MONITORING: +• Real-time threat detection and interceptor launch notifications +• Squadron status reports including aircraft counts and cooldown timers +• Airbase operational status with capture/destruction detection +• Cargo delivery tracking and replenishment confirmations +• Zone-based engagement decisions with detailed reasoning +• Configuration validation results and error reporting +• Performance monitoring with emergency cleanup notifications + +REQUIREMENTS: +• MOOSE framework (https://github.com/FlightControl-Master/MOOSE) +• Fighter aircraft GROUP templates (not UNIT templates) for each coalition +• Airbases must exist in mission and be under correct coalition control +• Zone objects in mission editor (if using zone-based features) +• Proper template naming matching squadron configuration + +AUTHOR: +• Based off MOOSE framework by FlightControl-Master +• Developed and customized by Mission Designer "F99th-TracerFacer" + +VERSION: 1.0 +═══════════════════════════════════════════════════════════════════════════════ +]] + +--[[ +═══════════════════════════════════════════════════════════════════════════════ + MAIN SETTINGS +═══════════════════════════════════════════════════════════════════════════════ +]] + +-- Core TADC behavior settings - applies to BOTH coalitions unless overridden +local TADC_SETTINGS = { + -- Enable/Disable coalitions + enableRed = true, -- Set to false to disable RED TADC + enableBlue = true, -- Set to false to disable BLUE TADC + + -- Timing settings (applies to both coalitions) + checkInterval = 30, -- How often to scan for threats (seconds) + monitorInterval = 30, -- How often to check interceptor status (seconds) + statusReportInterval = 1805, -- How often to report airbase status (seconds) + squadronSummaryInterval = 1800, -- How often to broadcast squadron summary (seconds) + cargoCheckInterval = 15, -- How often to check for cargo deliveries (seconds) + + -- RED Coalition Settings + red = { + maxActiveCAP = 24, -- Maximum RED fighters airborne at once + squadronCooldown = 600, -- RED cooldown after squadron launch (seconds) + interceptRatio = 1.2, -- RED interceptors per threat aircraft + cargoReplenishmentAmount = 4, -- RED aircraft added per cargo delivery + emergencyCleanupTime = 7200, -- RED force cleanup time (seconds) + rtbFlightBuffer = 300, -- RED extra landing time before cleanup (seconds) + }, + + -- BLUE Coalition Settings + blue = { + maxActiveCAP = 24, -- Maximum BLUE fighters airborne at once + squadronCooldown = 600, -- BLUE cooldown after squadron launch (seconds) + interceptRatio = 1.2, -- BLUE interceptors per threat aircraft + cargoReplenishmentAmount = 4, -- BLUE aircraft added per cargo delivery + emergencyCleanupTime = 7200, -- BLUE force cleanup time (seconds) + rtbFlightBuffer = 300, -- BLUE extra landing time before cleanup (seconds) + }, +} + + +--[[ +INTERCEPT RATIO CHART - How many interceptors launch per threat aircraft: + +Threat Size: 1 2 4 8 12 16 (aircraft) +==================================================================== +interceptRatio 0.2: 1 1 1 2 3 4 (conservative) +interceptRatio 0.5: 1 1 2 4 6 8 (light response) +interceptRatio 0.8: 1 2 4 7 10 13 (balanced) <- DEFAULT +interceptRatio 1.0: 1 2 4 8 12 16 (1:1 parity) +interceptRatio 1.2: 2 3 5 10 15 20 (slight advantage) +interceptRatio 1.4: 2 3 6 12 17 23 (good advantage) +interceptRatio 1.6: 2 4 7 13 20 26 (strong response) +interceptRatio 1.8: 2 4 8 15 22 29 (overwhelming) +interceptRatio 2.0: 2 4 8 16 24 32 (overkill) + +TACTICAL EFFECTS: +• 0.2-0.5: Minimal response, may be overwhelmed by large formations +• 0.8-1.0: Realistic parity, creates balanced dogfights +• 1.2-1.4: Coalition advantage, challenging for enemy +• 1.6-1.8: Strong defense, difficult penetration missions +• 1.9-2.0: Nearly impenetrable, may exhaust squadrons quickly + +SQUADRON IMPACT: +• Low ratios (0.2-0.8): Squadrons last longer, sustained defense +• High ratios (1.6-2.0): Rapid squadron depletion, coverage gaps +• Sweet spot (1.0-1.4): Balanced response with good coverage duration + +ASYMMETRIC SCENARIOS: +• Set RED ratio 1.2, BLUE ratio 0.8 = RED advantage +• Set RED ratio 0.6, BLUE ratio 1.4 = BLUE advantage +• Different maxActiveCAP values create capacity imbalances +]] + + +--[[ +═══════════════════════════════════════════════════════════════════════════════ + ADVANCED SETTINGS +═══════════════════════════════════════════════════════════════════════════════ + +These settings control more detailed behavior. Most users won't need to change these. +]] + +local ADVANCED_SETTINGS = { + -- Cargo aircraft detection patterns (aircraft with these names will replenish squadrons (Currently only fixed wing aircraft supported)) + cargoPatterns = {"CARGO", "TRANSPORT", "C130", "C-130", "AN26", "AN-26"}, + + -- Distance from airbase to consider cargo "delivered" via flyover (meters) + -- Aircraft flying within this range will count as supply delivery (no landing required) + cargoLandingDistance = 3000, + -- Distance from airbase to consider a landing as delivered (wheel touchdown) + -- Use a slightly larger radius than 1000m to account for runway offsets from airbase center + cargoLandingEventRadius = 2000, + + -- Velocity below which aircraft is considered "landed" (km/h) + cargoLandedVelocity = 5, + + -- RTB settings + rtbAltitude = 6000, -- Return to base altitude (feet) + rtbSpeed = 430, -- Return to base speed (knots) + + -- Logging settings + enableDetailedLogging = false, -- Set to false to reduce log spam + logPrefix = "[Universal TADC]", -- Prefix for all log messages + -- Proxy/raw-fallback verbose logging (set true to debug proxy behavior) + verboseProxyLogging = false, +} + +--[[ +═══════════════════════════════════════════════════════════════════════════════ + SYSTEM CODE + (DO NOT MODIFY BELOW THIS LINE) +═══════════════════════════════════════════════════════════════════════════════ +]] + + + +-- Internal tracking variables - separate for each coalition +local activeInterceptors = { + red = {}, + blue = {} +} +local lastLaunchTime = { + red = {}, + blue = {} +} +local assignedThreats = { + red = {}, + blue = {} +} +local squadronCooldowns = { + red = {}, + blue = {} +} +squadronAircraftCounts = { + red = {}, + blue = {} +} + +-- Aircraft spawn tracking for stuck detection +local aircraftSpawnTracking = { + red = {}, -- groupName -> {spawnPos, spawnTime, squadron, airbase} + blue = {} +} + +-- Airbase health status +local airbaseHealthStatus = { + red = {}, -- airbaseName -> "operational"|"stuck-aircraft"|"unusable" + blue = {} +} + +local function coalitionKeyFromSide(side) + if side == coalition.side.RED then return "red" end + if side == coalition.side.BLUE then return "blue" end + return nil +end + +local function cleanupInterceptorEntry(interceptorName, coalitionKey) + if not interceptorName or not coalitionKey then return end + if activeInterceptors[coalitionKey] then + activeInterceptors[coalitionKey][interceptorName] = nil + end + if aircraftSpawnTracking[coalitionKey] then + aircraftSpawnTracking[coalitionKey][interceptorName] = nil + end +end + +local function destroyInterceptorGroup(interceptor, coalitionKey, delaySeconds) + if not interceptor then return end + + local name = nil + if interceptor.GetName then + local ok, value = pcall(function() return interceptor:GetName() end) + if ok then name = value end + end + + local resolvedKey = coalitionKey + if not resolvedKey and interceptor.GetCoalition then + local ok, side = pcall(function() return interceptor:GetCoalition() end) + if ok then + resolvedKey = coalitionKeyFromSide(side) + end + end + + local function doDestroy() + if interceptor and interceptor.IsAlive and interceptor:IsAlive() then + pcall(function() interceptor:Destroy() end) + end + if name and resolvedKey then + cleanupInterceptorEntry(name, resolvedKey) + end + end + + if delaySeconds and delaySeconds > 0 then + timer.scheduleFunction(function() + doDestroy() + return + end, {}, timer.getTime() + delaySeconds) + else + doDestroy() + end +end + +local function finalizeCargoMission(cargoGroup, squadron, coalitionKey) + if not cargoMissions or not coalitionKey or not squadron or not squadron.airbaseName then + return + end + + local coalitionBucket = cargoMissions[coalitionKey] + if type(coalitionBucket) ~= "table" then + return + end + + local groupName = nil + if cargoGroup and cargoGroup.GetName then + local ok, value = pcall(function() return cargoGroup:GetName() end) + if ok then groupName = value end + end + + for idx = #coalitionBucket, 1, -1 do + local mission = coalitionBucket[idx] + if mission and mission.destination == squadron.airbaseName then + local missionGroupName = nil + if mission.group and mission.group.GetName then + local ok, value = pcall(function() return mission.group:GetName() end) + if ok then missionGroupName = value end + end + + if not groupName or missionGroupName == groupName then + mission.status = "completed" + mission.completedAt = timer.getTime() + + if mission.group and mission.group.Destroy then + local targetGroup = mission.group + timer.scheduleFunction(function() + pcall(function() + if targetGroup and targetGroup.IsAlive and targetGroup:IsAlive() then + targetGroup:Destroy() + end + end) + return + end, {}, timer.getTime() + 90) + end + + table.remove(coalitionBucket, idx) + end + end + end +end + +-- Logging function +local function log(message, detailed) + if not detailed or ADVANCED_SETTINGS.enableDetailedLogging then + env.info(ADVANCED_SETTINGS.logPrefix .. " " .. message) + end +end + +local function safeCoordinate(object) + if not object or type(object) ~= "table" or not object.GetCoordinate then + return nil + end + local ok, coord = pcall(function() return object:GetCoordinate() end) + if ok and coord then + return coord + end + return nil +end + +-- Performance optimization: Cache SET_GROUP objects to avoid repeated creation +local cachedSets = { + redCargo = nil, + blueCargo = nil, + redAircraft = nil, + blueAircraft = nil +} + +if type(RED_SQUADRON_CONFIG) ~= "table" then + local msg = "CONFIG ERROR: RED_SQUADRON_CONFIG is missing or not loaded. Make sure Moose_TADC_SquadronConfigs_Load1st.lua is loaded before this script." + log(msg, true) + MESSAGE:New(msg, 30):ToAll() +end +if type(BLUE_SQUADRON_CONFIG) ~= "table" then + local msg = "CONFIG ERROR: BLUE_SQUADRON_CONFIG is missing or not loaded. Make sure Moose_TADC_SquadronConfigs_Load1st.lua is loaded before this script." + log(msg, true) + MESSAGE:New(msg, 30):ToAll() +end + +for _, squadron in pairs(RED_SQUADRON_CONFIG) do + if squadron.aircraft and squadron.templateName then + squadronAircraftCounts.red[squadron.templateName] = squadron.aircraft + end +end + +for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + if squadron.aircraft and squadron.templateName then + squadronAircraftCounts.blue[squadron.templateName] = squadron.aircraft + end +end + +-- Squadron resource summary generator + +local function getSquadronResourceSummary(coalitionSide) + local function getStatus(remaining, max, state) + if state == "captured" then return "[CAPTURED]" end + if state == "destroyed" then return "[DESTROYED]" end + if state ~= "operational" then return "[OFFLINE]" end + + local percent = (remaining / max) * 100 + if percent <= 10 then return "[CRITICAL]" end + if percent <= 25 then return "[LOW]" end + return "OK" + end + + local lines = {} + table.insert(lines, "-=[ Tactical Air Defense Controller ]=-\n") + table.insert(lines, "Squadron Resource Summary:\n") + table.insert(lines, "| Squadron | Aircraft Remaining | Status |") + table.insert(lines, "|--------------|--------------------|-------------|") + + if coalitionSide == coalition.side.RED then + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local remaining = squadronAircraftCounts.red[squadron.templateName] or 0 + local max = squadron.aircraft or 0 + local state = squadron.state or "operational" + local status = getStatus(remaining, max, state) + table.insert(lines, string.format("| %-13s | %2d / %-15d | %-11s |", squadron.displayName or squadron.templateName, remaining, max, status)) + end + elseif coalitionSide == coalition.side.BLUE then + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local remaining = squadronAircraftCounts.blue[squadron.templateName] or 0 + local max = squadron.aircraft or 0 + local state = squadron.state or "operational" + local status = getStatus(remaining, max, state) + table.insert(lines, string.format("| %-13s | %2d / %-15d | %-11s |", squadron.displayName or squadron.templateName, remaining, max, status)) + end + end + + table.insert(lines, "\n- [CAPTURED]: Airbase captured by enemy\n- [LOW]: Below 25%\n- [CRITICAL]: Below 10%\n- OK: Above 25%") + return table.concat(lines, "\n") +end + +-- Broadcast squadron summary to all players +local function broadcastSquadronSummary() + if TADC_SETTINGS.enableRed then + local summaryRed = getSquadronResourceSummary(coalition.side.RED) + MESSAGE:New(summaryRed, 20):ToCoalition(coalition.side.RED) + end + if TADC_SETTINGS.enableBlue then + local summaryBlue = getSquadronResourceSummary(coalition.side.BLUE) + MESSAGE:New(summaryBlue, 20):ToCoalition(coalition.side.BLUE) + end +end + +-- Coalition-specific settings helper +local function getCoalitionSettings(coalitionSide) + if coalitionSide == coalition.side.RED then + return TADC_SETTINGS.red, "RED" + elseif coalitionSide == coalition.side.BLUE then + return TADC_SETTINGS.blue, "BLUE" + else + return nil, "UNKNOWN" + end +end + +-- Get squadron config for coalition +local function getSquadronConfig(coalitionSide) + if coalitionSide == coalition.side.RED then + return RED_SQUADRON_CONFIG + elseif coalitionSide == coalition.side.BLUE then + return BLUE_SQUADRON_CONFIG + else + return {} + end +end + +-- Check if coordinate is within a zone +local function isInZone(coordinate, zoneName) + if not zoneName or zoneName == "" then + return false + end + + -- Try to find the zone + local zone = ZONE:FindByName(zoneName) + if zone then + return zone:IsCoordinateInZone(coordinate) + else + -- Try to create polygon zone from helicopter group waypoints if not found + local group = GROUP:FindByName(zoneName) + if group then + -- Create polygon zone using the group's waypoints as vertices + zone = ZONE_POLYGON:NewFromGroupName(zoneName, zoneName) + if zone then + log("Created polygon zone '" .. zoneName .. "' from helicopter waypoints") + return zone:IsCoordinateInZone(coordinate) + else + log("Warning: Could not create polygon zone from group '" .. zoneName .. "' - check waypoints") + end + else + log("Warning: No group named '" .. zoneName .. "' found for zone creation") + end + + log("Warning: Zone '" .. zoneName .. "' not found in mission and could not create from helicopter group", true) + return false + end +end + +-- Get default zone configuration +local function getDefaultZoneConfig() + return { + primaryResponse = 1.0, + secondaryResponse = 0.6, + tertiaryResponse = 1.4, + maxRange = 200, + enableFallback = false, + priorityThreshold = 4, + ignoreLowPriority = false, + } +end + +-- Check if squadron should respond to fallback conditions +local function checkFallbackConditions(squadron, coalitionSide) + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + + -- Check if airbase is under attack (simplified - check if base has low aircraft) + local currentAircraft = squadronAircraftCounts[coalitionKey][squadron.templateName] or 0 + local maxAircraft = squadron.aircraft + local aircraftRatio = currentAircraft / maxAircraft + + -- Trigger fallback if squadron is below 50% strength or base is threatened + if aircraftRatio < 0.5 then + return true + end + + -- Could add more complex conditions here (base under attack, etc.) + return false +end + +-- Get threat zone priority and response ratio for squadron +local function getThreatZonePriority(threatCoord, squadron, coalitionSide) + local zoneConfig = squadron.zoneConfig or getDefaultZoneConfig() + + -- Check distance from airbase first + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local airbaseCoord = airbase:GetCoordinate() + local distance = airbaseCoord:Get2DDistance(threatCoord) / 1852 -- Convert meters to nautical miles + + if distance > zoneConfig.maxRange then + return "none", 0, "out of range (" .. math.floor(distance) .. "nm > " .. zoneConfig.maxRange .. "nm)" + end + end + + -- Check tertiary zone first (highest priority if fallback enabled) + if squadron.tertiaryZone and zoneConfig.enableFallback then + if checkFallbackConditions(squadron, coalitionSide) then + if isInZone(threatCoord, squadron.tertiaryZone) then + return "tertiary", zoneConfig.tertiaryResponse, "fallback zone (enhanced response)" + end + end + end + + -- Check primary zone + if squadron.primaryZone and isInZone(threatCoord, squadron.primaryZone) then + return "primary", zoneConfig.primaryResponse, "primary AOR" + end + + -- Check secondary zone + if squadron.secondaryZone and isInZone(threatCoord, squadron.secondaryZone) then + return "secondary", zoneConfig.secondaryResponse, "secondary AOR" + end + + -- Check tertiary zone (normal priority) + if squadron.tertiaryZone and isInZone(threatCoord, squadron.tertiaryZone) then + return "tertiary", zoneConfig.tertiaryResponse, "tertiary zone" + end + + -- If no zones are defined, use global response + if not squadron.primaryZone and not squadron.secondaryZone and not squadron.tertiaryZone then + return "global", 1.0, "global response (no zones defined)" + end + + -- Outside all defined zones + return "none", 0, "outside defined zones" +end + +-- Startup validation +local function validateConfiguration() + local errors = {} + + -- Check coalition enablement + if not TADC_SETTINGS.enableRed and not TADC_SETTINGS.enableBlue then + table.insert(errors, "Both coalitions disabled - enable at least one in TADC_SETTINGS") + end + + -- Validate RED squadrons if enabled + if TADC_SETTINGS.enableRed then + if #RED_SQUADRON_CONFIG == 0 then + table.insert(errors, "No RED squadrons configured but RED TADC is enabled") + else + for i, squadron in pairs(RED_SQUADRON_CONFIG) do + local prefix = "RED Squadron " .. i .. ": " + + if not squadron.templateName or squadron.templateName == "" or + squadron.templateName == "RED_CAP_SQUADRON_1" or + squadron.templateName == "RED_CAP_SQUADRON_2" then + table.insert(errors, prefix .. "templateName not configured or using default example") + end + + if not squadron.displayName or squadron.displayName == "" then + table.insert(errors, prefix .. "displayName not configured") + end + + if not squadron.airbaseName or squadron.airbaseName == "" or + squadron.airbaseName:find("YOUR_RED_AIRBASE") then + table.insert(errors, prefix .. "airbaseName not configured or using default example") + end + + if not squadron.aircraft or squadron.aircraft <= 0 then + table.insert(errors, prefix .. "aircraft count not configured or invalid") + end + + -- Validate zone configuration if zones are specified + if squadron.primaryZone or squadron.secondaryZone or squadron.tertiaryZone then + if squadron.zoneConfig then + local zc = squadron.zoneConfig + if zc.primaryResponse and (zc.primaryResponse < 0 or zc.primaryResponse > 5) then + table.insert(errors, prefix .. "primaryResponse ratio out of range (0-5)") + end + if zc.secondaryResponse and (zc.secondaryResponse < 0 or zc.secondaryResponse > 5) then + table.insert(errors, prefix .. "secondaryResponse ratio out of range (0-5)") + end + if zc.tertiaryResponse and (zc.tertiaryResponse < 0 or zc.tertiaryResponse > 5) then + table.insert(errors, prefix .. "tertiaryResponse ratio out of range (0-5)") + end + if zc.maxRange and (zc.maxRange < 10 or zc.maxRange > 1000) then + table.insert(errors, prefix .. "maxRange out of range (10-1000 nm)") + end + end + + -- Check if specified zones exist in mission + local zones = {} + if squadron.primaryZone then table.insert(zones, squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, squadron.tertiaryZone) end + + for _, zoneName in ipairs(zones) do + local zoneObj = ZONE:FindByName(zoneName) + if not zoneObj then + -- Check if there's a helicopter unit/group with this name for zone creation + local unit = UNIT:FindByName(zoneName) + local group = GROUP:FindByName(zoneName) + if not unit and not group then + table.insert(errors, prefix .. "zone '" .. zoneName .. "' not found in mission (no zone or helicopter unit named '" .. zoneName .. "')") + end + end + end + end + end + end + end + + -- Validate BLUE squadrons if enabled + if TADC_SETTINGS.enableBlue then + if #BLUE_SQUADRON_CONFIG == 0 then + table.insert(errors, "No BLUE squadrons configured but BLUE TADC is enabled") + else + for i, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local prefix = "BLUE Squadron " .. i .. ": " + + if not squadron.templateName or squadron.templateName == "" or + squadron.templateName == "BLUE_CAP_SQUADRON_1" or + squadron.templateName == "BLUE_CAP_SQUADRON_2" then + table.insert(errors, prefix .. "templateName not configured or using default example") + end + + if not squadron.displayName or squadron.displayName == "" then + table.insert(errors, prefix .. "displayName not configured") + end + + if not squadron.airbaseName or squadron.airbaseName == "" or + squadron.airbaseName:find("YOUR_BLUE_AIRBASE") then + table.insert(errors, prefix .. "airbaseName not configured or using default example") + end + + if not squadron.aircraft or squadron.aircraft <= 0 then + table.insert(errors, prefix .. "aircraft count not configured or invalid") + end + + -- Validate zone configuration if zones are specified + if squadron.primaryZone or squadron.secondaryZone or squadron.tertiaryZone then + if squadron.zoneConfig then + local zc = squadron.zoneConfig + if zc.primaryResponse and (zc.primaryResponse < 0 or zc.primaryResponse > 5) then + table.insert(errors, prefix .. "primaryResponse ratio out of range (0-5)") + end + if zc.secondaryResponse and (zc.secondaryResponse < 0 or zc.secondaryResponse > 5) then + table.insert(errors, prefix .. "secondaryResponse ratio out of range (0-5)") + end + if zc.tertiaryResponse and (zc.tertiaryResponse < 0 or zc.tertiaryResponse > 5) then + table.insert(errors, prefix .. "tertiaryResponse ratio out of range (0-5)") + end + if zc.maxRange and (zc.maxRange < 10 or zc.maxRange > 1000) then + table.insert(errors, prefix .. "maxRange out of range (10-1000 nm)") + end + end + + -- Check if specified zones exist in mission + local zones = {} + if squadron.primaryZone then table.insert(zones, squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, squadron.tertiaryZone) end + + for _, zoneName in ipairs(zones) do + local zoneObj = ZONE:FindByName(zoneName) + if not zoneObj then + -- Check if there's a helicopter unit/group with this name for zone creation + local unit = UNIT:FindByName(zoneName) + local group = GROUP:FindByName(zoneName) + if not unit and not group then + table.insert(errors, prefix .. "zone '" .. zoneName .. "' not found in mission (no zone or helicopter unit named '" .. zoneName .. "')") + end + end + end + end + end + end + end + + -- Report errors + if #errors > 0 then + log("CONFIGURATION ERRORS DETECTED:") + MESSAGE:New("CONFIGURATION ERRORS DETECTED:", 30):ToAll() + for _, error in pairs(errors) do + log(" ✗ " .. error) + MESSAGE:New("CONFIG ERROR: " .. error, 30):ToAll() + end + log("Please fix configuration before using Universal TADC!") + MESSAGE:New("Please fix configuration before using Universal TADC!", 30):ToAll() + return false + else + log("Configuration validation passed ✓") + MESSAGE:New("Universal TADC configuration passed ✓", 10):ToAll() + return true + end +end + +-- Process cargo delivery for a squadron +local function processCargoDelivery(cargoGroup, squadron, coalitionSide, coalitionKey) + -- Simple delivery processor: dedupe by group ID and credit supplies directly. + if not _G.processedDeliveries then + _G.processedDeliveries = {} + end + + -- Use group ID + squadron airbase as dedupe key to avoid double crediting when the same group + -- triggers multiple events or moves between airbases rapidly. + local okId, grpId = pcall(function() return cargoGroup and cargoGroup.GetID and cargoGroup:GetID() end) + local groupIdStr = (okId and grpId) and tostring(grpId) or "" + local deliveryKey = groupIdStr .. "_" .. tostring(squadron.airbaseName) + + -- Diagnostic log: show group name, id, and delivery key when processor invoked + local okName, grpName = pcall(function() return cargoGroup and cargoGroup.GetName and cargoGroup:GetName() end) + local groupNameStr = (okName and grpName) and tostring(grpName) or "" + log("PROCESS CARGO: invoked for group=" .. groupNameStr .. " id=" .. groupIdStr .. " targetAirbase=" .. tostring(squadron.airbaseName) .. " deliveryKey=" .. deliveryKey, true) + + if _G.processedDeliveries[deliveryKey] then + -- Already processed recently, ignore + log("PROCESS CARGO: deliveryKey " .. deliveryKey .. " already processed at " .. tostring(_G.processedDeliveries[deliveryKey]), true) + return + end + + -- Mark processed immediately + _G.processedDeliveries[deliveryKey] = timer.getTime() + + -- Credit the squadron + local currentCount = squadronAircraftCounts[coalitionKey][squadron.templateName] or 0 + local maxCount = squadron.aircraft or 0 + local addAmount = TADC_SETTINGS[coalitionKey].cargoReplenishmentAmount or 0 + local newCount = math.min(currentCount + addAmount, maxCount) + local actualAdded = newCount - currentCount + + if actualAdded > 0 then + squadronAircraftCounts[coalitionKey][squadron.templateName] = newCount + local msg = coalitionKey:upper() .. " CARGO DELIVERY: " .. cargoGroup:GetName() .. " delivered " .. actualAdded .. + " aircraft to " .. (squadron.displayName or squadron.templateName) .. + " (" .. newCount .. "/" .. maxCount .. ")" + log(msg) + MESSAGE:New(msg, 20):ToCoalition(coalitionSide) + USERSOUND:New("Cargo_Delivered.ogg"):ToCoalition(coalitionSide) + else + local msg = coalitionKey:upper() .. " CARGO DELIVERY: " .. (squadron.displayName or squadron.templateName) .. " already at max capacity" + log(msg, true) + MESSAGE:New(msg, 10):ToCoalition(coalitionSide) + USERSOUND:New("Cargo_Delivered.ogg"):ToCoalition(coalitionSide) + end + + finalizeCargoMission(cargoGroup, squadron, coalitionKey) +end + +-- Event handler for cargo aircraft landing (backup for actual landings) +local cargoEventHandler = {} +function cargoEventHandler:onEvent(event) + if event.id == world.event.S_EVENT_LAND then + local unit = event.initiator + + -- Safe unit name retrieval + local unitName = "unknown" + if unit and type(unit) == "table" then + local ok, name = pcall(function() return unit:GetName() end) + if ok and name then + unitName = name + end + end + + log("LANDING EVENT: Received S_EVENT_LAND for unit: " .. unitName, true) + + if unit and type(unit) == "table" and unit.IsAlive and unit:IsAlive() then + local group = unit:GetGroup() + if group and type(group) == "table" and group.IsAlive and group:IsAlive() then + -- Safe group name retrieval + local cargoName = "unknown" + local ok, name = pcall(function() return group:GetName():upper() end) + if ok and name then + cargoName = name + end + + log("LANDING EVENT: Processing group: " .. cargoName, true) + + local isCargoAircraft = false + + -- Check if aircraft name matches cargo patterns + for _, pattern in pairs(ADVANCED_SETTINGS.cargoPatterns) do + if string.find(cargoName, pattern) then + isCargoAircraft = true + log("LANDING EVENT: Matched cargo pattern '" .. pattern .. "' for " .. cargoName, true) + break + end + end + + if isCargoAircraft then + -- Safe coordinate and coalition retrieval + local cargoCoord = nil + local ok, coord = pcall(function() return unit:GetCoordinate() end) + if ok and coord then + cargoCoord = coord + end + + log("LANDING EVENT: Cargo aircraft " .. cargoName .. " at coord: " .. tostring(cargoCoord), true) + + if cargoCoord then + local closestAirbase = nil + local closestDistance = math.huge + local closestSquadron = nil + + -- Search RED squadron configs + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = cargoCoord:Get2DDistance(airbase:GetCoordinate()) + log("LANDING EVENT: Checking distance to " .. squadron.airbaseName .. ": " .. math.floor(distance) .. "m", true) + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + -- Search BLUE squadron configs + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = cargoCoord:Get2DDistance(airbase:GetCoordinate()) + log("LANDING EVENT: Checking distance to " .. squadron.airbaseName .. ": " .. math.floor(distance) .. "m", true) + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + if closestAirbase then + local abCoalition = closestAirbase:GetCoalition() + local coalitionKey = (abCoalition == coalition.side.RED) and "red" or "blue" + if closestDistance < ADVANCED_SETTINGS.cargoLandingEventRadius then + log("LANDING DELIVERY: " .. cargoName .. " landed and delivered at " .. closestSquadron.airbaseName .. " (distance: " .. math.floor(closestDistance) .. "m)") + processCargoDelivery(group, closestSquadron, abCoalition, coalitionKey) + else + log("LANDING DETECTED: " .. cargoName .. " landed but no valid airbase found within range (closest: " .. (closestDistance and math.floor(closestDistance) .. "m" or "none") .. ")") + end + else + log("LANDING DETECTED: " .. cargoName .. " landed but no configured squadron airbases available to check", true) + end + else + log("LANDING EVENT: Could not get coordinates for cargo aircraft " .. cargoName, true) + end + else + log("LANDING EVENT: " .. cargoName .. " is not a cargo aircraft", true) + end + else + log("LANDING EVENT: Group is nil or not alive", true) + end + else + -- Fallback: unit was nil or not alive (race/despawn). Try to retrieve group and name safely + log("LANDING EVENT: Unit is nil or not alive - attempting fallback group retrieval", true) + + local fallbackGroup = nil + local okGetGroup, grp = pcall(function() + if unit and type(unit) == "table" and unit.GetGroup then + return unit:GetGroup() + end + -- Try event.initiator (may be raw DCS object) + if event and event.initiator and type(event.initiator) == 'table' and event.initiator.GetGroup then + return event.initiator:GetGroup() + end + return nil + end) + + if okGetGroup and grp then + fallbackGroup = grp + end + + if fallbackGroup then + -- Try to get group name even if group:IsAlive() is false + local okName, gname = pcall(function() return fallbackGroup:GetName():upper() end) + local cargoName = "unknown" + if okName and gname then + cargoName = gname + end + + log("LANDING EVENT (fallback): Processing group: " .. cargoName, true) + + local isCargoAircraft = false + for _, pattern in pairs(ADVANCED_SETTINGS.cargoPatterns) do + if string.find(cargoName, pattern) then + isCargoAircraft = true + log("LANDING EVENT (fallback): Matched cargo pattern '" .. pattern .. "' for " .. cargoName, true) + break + end + end + + if isCargoAircraft then + -- Try to get coordinate and coalition via multiple safe methods + local cargoCoord = nil + local okCoord, coord = pcall(function() + if unit and unit.GetCoordinate then return unit:GetCoordinate() end + if fallbackGroup and fallbackGroup.GetCoordinate then return fallbackGroup:GetCoordinate() end + return nil + end) + if okCoord and coord then cargoCoord = coord end + + log("LANDING EVENT (fallback): Cargo aircraft " .. cargoName .. " at coord: " .. tostring(cargoCoord), true) + + if cargoCoord then + local closestAirbase = nil + local closestDistance = math.huge + local closestSquadron = nil + + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = cargoCoord:Get2DDistance(airbase:GetCoordinate()) + log("LANDING EVENT (fallback): Checking distance to " .. squadron.airbaseName .. ": " .. math.floor(distance) .. "m", true) + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = cargoCoord:Get2DDistance(airbase:GetCoordinate()) + log("LANDING EVENT (fallback): Checking distance to " .. squadron.airbaseName .. ": " .. math.floor(distance) .. "m", true) + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + if closestAirbase then + local abCoalition = closestAirbase:GetCoalition() + local coalitionKey = (abCoalition == coalition.side.RED) and "red" or "blue" + if closestDistance < ADVANCED_SETTINGS.cargoLandingEventRadius then + log("LANDING DELIVERY (fallback): " .. cargoName .. " landed and delivered at " .. closestSquadron.airbaseName .. " (distance: " .. math.floor(closestDistance) .. "m)") + processCargoDelivery(fallbackGroup, closestSquadron, abCoalition, coalitionKey) + else + log("LANDING DETECTED (fallback): " .. cargoName .. " landed but no valid airbase found within range (closest: " .. (closestDistance and math.floor(closestDistance) .. "m" or "none") .. ")") + end + else + log("LANDING EVENT (fallback): No configured squadron airbases available to check", true) + end + else + log("LANDING EVENT (fallback): Could not get coordinates for cargo aircraft " .. cargoName, true) + end + else + log("LANDING EVENT (fallback): " .. cargoName .. " is not a cargo aircraft", true) + end + else + log("LANDING EVENT: Fallback group retrieval failed", true) + -- Additional fallback: try raw DCS object methods (lowercase) and resolve by name + local okRaw, rawGroup = pcall(function() + if event and event.initiator and type(event.initiator) == 'table' and event.initiator.getGroup then + return event.initiator:getGroup() + end + return nil + end) + + if okRaw and rawGroup then + -- Try to get raw group name + local okRawName, rawName = pcall(function() + if rawGroup.getName then return rawGroup:getName() end + return nil + end) + + if okRawName and rawName then + local rawNameUp = tostring(rawName):upper() + log("LANDING EVENT: Resolved raw DCS group name: " .. rawNameUp, true) + + -- Try to find MOOSE GROUP by that name + local okFind, mooseGroup = pcall(function() return GROUP:FindByName(rawNameUp) end) + if okFind and mooseGroup and type(mooseGroup) == 'table' then + log("LANDING EVENT: Found MOOSE GROUP for raw name: " .. rawNameUp, true) + -- Reuse the fallback logic using mooseGroup + local cargoName = rawNameUp + local isCargoAircraft = false + for _, pattern in pairs(ADVANCED_SETTINGS.cargoPatterns) do + if string.find(cargoName, pattern) then + isCargoAircraft = true + break + end + end + if isCargoAircraft then + -- Try to get coordinate from raw group if possible + local cargoCoord = nil + local okPoint, point = pcall(function() + if rawGroup.getController then + -- Raw DCS unit list -> first unit point + local dcs = rawGroup + if dcs.getUnits then + local units = dcs:getUnits() + if units and #units > 0 and units[1].getPoint then + return units[1]:getPoint() + end + end + end + return nil + end) + if okPoint and point then cargoCoord = point end + + -- If we have a coordinate, find nearest squadron and process + if cargoCoord then + local closestAirbase = nil + local closestDistance = math.huge + local closestSquadron = nil + + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = math.huge + if type(cargoCoord) == 'table' and cargoCoord.Get2DDistance then + local okDist, d = pcall(function() return cargoCoord:Get2DDistance(airbase:GetCoordinate()) end) + if okDist and d then distance = d end + else + local okVec, aVec = pcall(function() return airbase:GetCoordinate():GetVec2() end) + if okVec and aVec and type(aVec) == 'table' then + local cx, cy + if cargoCoord.x and cargoCoord.z then + cx, cy = cargoCoord.x, cargoCoord.z + elseif cargoCoord.x and cargoCoord.y then + cx, cy = cargoCoord.x, cargoCoord.y + elseif cargoCoord[1] and cargoCoord[3] then + cx, cy = cargoCoord[1], cargoCoord[3] + elseif cargoCoord[1] and cargoCoord[2] then + cx, cy = cargoCoord[1], cargoCoord[2] + end + if cx and cy then + local dx = cx - aVec.x + local dy = cy - aVec.y + distance = math.sqrt(dx*dx + dy*dy) + end + end + end + + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = math.huge + if type(cargoCoord) == 'table' and cargoCoord.Get2DDistance then + local okDist, d = pcall(function() return cargoCoord:Get2DDistance(airbase:GetCoordinate()) end) + if okDist and d then distance = d end + else + local okVec, aVec = pcall(function() return airbase:GetCoordinate():GetVec2() end) + if okVec and aVec and type(aVec) == 'table' then + local cx, cy + if cargoCoord.x and cargoCoord.z then + cx, cy = cargoCoord.x, cargoCoord.z + elseif cargoCoord.x and cargoCoord.y then + cx, cy = cargoCoord.x, cargoCoord.y + elseif cargoCoord[1] and cargoCoord[3] then + cx, cy = cargoCoord[1], cargoCoord[3] + elseif cargoCoord[1] and cargoCoord[2] then + cx, cy = cargoCoord[1], cargoCoord[2] + end + if cx and cy then + local dx = cx - aVec.x + local dy = cy - aVec.y + distance = math.sqrt(dx*dx + dy*dy) + end + end + end + + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + if closestAirbase and closestDistance < ADVANCED_SETTINGS.cargoLandingEventRadius then + local abCoalition = closestAirbase:GetCoalition() + local coalitionKey = (abCoalition == coalition.side.RED) and "red" or "blue" + log("LANDING DELIVERY (raw-fallback): " .. rawNameUp .. " landed and delivered at " .. closestSquadron.airbaseName .. " (distance: " .. math.floor(closestDistance) .. "m)") + processCargoDelivery(mooseGroup, closestSquadron, abCoalition, coalitionKey) + else + log("LANDING DETECTED (raw-fallback): " .. rawNameUp .. " landed but no valid airbase found within range (closest: " .. (closestDistance and math.floor(closestDistance) .. "m" or "none") .. ")") + end + else + log("LANDING EVENT: Could not extract coordinate from raw DCS group: " .. tostring(rawName), true) + end + else + log("LANDING EVENT: Raw group " .. tostring(rawName) .. " is not a cargo aircraft", true) + end + else + log("LANDING EVENT: Could not find MOOSE GROUP for raw name: " .. tostring(rawName) .. " - attempting raw-group proxy processing", true) + + -- Even if we can't find a MOOSE GROUP, try to extract coordinates from the raw DCS group + local okPoint2, point2 = pcall(function() + if rawGroup and rawGroup.getUnits then + local units = rawGroup:getUnits() + if units and #units > 0 and units[1].getPoint then + return units[1]:getPoint() + end + end + return nil + end) + + if okPoint2 and point2 then + local cargoCoord = point2 + -- Find nearest configured squadron airbase (RED + BLUE) + local closestAirbase = nil + local closestDistance = math.huge + local closestSquadron = nil + + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = math.huge + local okVec, aVec = pcall(function() return airbase:GetCoordinate():GetVec2() end) + if okVec and aVec and type(aVec) == 'table' then + local cx, cy + if cargoCoord.x and cargoCoord.z then + cx, cy = cargoCoord.x, cargoCoord.z + elseif cargoCoord.x and cargoCoord.y then + cx, cy = cargoCoord.x, cargoCoord.y + elseif cargoCoord[1] and cargoCoord[3] then + cx, cy = cargoCoord[1], cargoCoord[3] + elseif cargoCoord[1] and cargoCoord[2] then + cx, cy = cargoCoord[1], cargoCoord[2] + end + if cx and cy then + local dx = cx - aVec.x + local dy = cy - aVec.y + distance = math.sqrt(dx*dx + dy*dy) + end + end + + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase then + local distance = math.huge + local okVec, aVec = pcall(function() return airbase:GetCoordinate():GetVec2() end) + if okVec and aVec and type(aVec) == 'table' then + local cx, cy + if cargoCoord.x and cargoCoord.z then + cx, cy = cargoCoord.x, cargoCoord.z + elseif cargoCoord.x and cargoCoord.y then + cx, cy = cargoCoord.x, cargoCoord.y + elseif cargoCoord[1] and cargoCoord[3] then + cx, cy = cargoCoord[1], cargoCoord[3] + elseif cargoCoord[1] and cargoCoord[2] then + cx, cy = cargoCoord[1], cargoCoord[2] + end + if cx and cy then + local dx = cx - aVec.x + local dy = cy - aVec.y + distance = math.sqrt(dx*dx + dy*dy) + end + end + + if distance < closestDistance then + closestDistance = distance + closestAirbase = airbase + closestSquadron = squadron + end + end + end + + if closestAirbase and closestDistance < ADVANCED_SETTINGS.cargoLandingEventRadius then + local abCoalition = closestAirbase:GetCoalition() + local coalitionKey = (abCoalition == coalition.side.RED) and "red" or "blue" + + -- Ensure the raw group name actually looks like a cargo aircraft before crediting + local rawNameUpCheck = tostring(rawName):upper() + local isCargoProxy = false + for _, pattern in pairs(ADVANCED_SETTINGS.cargoPatterns) do + if string.find(rawNameUpCheck, pattern) then + isCargoProxy = true + break + end + end + + if not isCargoProxy then + if ADVANCED_SETTINGS.verboseProxyLogging then + log("LANDING IGNORED (raw-proxy): " .. tostring(rawName) .. " is not a cargo-type name, skipping delivery proxy", true) + else + log("LANDING IGNORED (raw-proxy): " .. tostring(rawName) .. " is not a cargo-type name, skipping delivery proxy", true) + end + else + -- Build a small proxy object that exposes GetName and GetID so processCargoDelivery can use it + local cargoProxy = {} + function cargoProxy:GetName() + local okn, nm = pcall(function() + if rawGroup and rawGroup.getName then return rawGroup:getName() end + return tostring(rawName) + end) + return (okn and nm) and tostring(nm) or tostring(rawName) + end + function cargoProxy:GetID() + local okid, id = pcall(function() + if rawGroup and rawGroup.getID then return rawGroup:getID() end + if rawGroup and rawGroup.getID == nil and rawGroup.getController then + -- Try to hash name as fallback unique-ish id + return tostring(rawName) .. "_proxy" + end + return nil + end) + return (okid and id) and id or tostring(rawName) .. "_proxy" + end + + if ADVANCED_SETTINGS.verboseProxyLogging then + log("LANDING DELIVERY (raw-proxy): " .. tostring(rawName) .. " landed and delivered at " .. closestSquadron.airbaseName .. " (distance: " .. math.floor(closestDistance) .. "m) - using proxy object", true) + end + processCargoDelivery(cargoProxy, closestSquadron, abCoalition, coalitionKey) + end + else + if ADVANCED_SETTINGS.verboseProxyLogging then + log("LANDING DETECTED (raw-proxy): " .. tostring(rawName) .. " landed but no valid airbase found within range (closest: " .. (closestDistance and math.floor(closestDistance) .. "m" or "none") .. ")", true) + end + end + else + log("LANDING EVENT: Could not extract coordinate from raw DCS group for proxy processing: " .. tostring(rawName), true) + end + end + else + log("LANDING EVENT: rawGroup:getName() failed", true) + end + else + log("LANDING EVENT: raw DCS group retrieval failed", true) + end + end + end + end +end + +-- Reassign squadron to an alternative airbase when primary airbase has issues +local function reassignSquadronToAlternativeAirbase(squadron, coalitionKey) + local coalitionSide = (coalitionKey == "red") and coalition.side.RED or coalition.side.BLUE + local coalitionName = (coalitionKey == "red") and "RED" or "BLUE" + local squadronConfig = getSquadronConfig(coalitionSide) + + -- Find alternative airbases (other squadrons' airbases that are operational) + local alternativeAirbases = {} + for _, altSquadron in pairs(squadronConfig) do + if altSquadron.airbaseName ~= squadron.airbaseName then + local usable, status = isAirbaseUsable(altSquadron.airbaseName, coalitionSide) + local healthStatus = airbaseHealthStatus[coalitionKey][altSquadron.airbaseName] or "operational" + + if usable and healthStatus == "operational" then + table.insert(alternativeAirbases, altSquadron.airbaseName) + end + end + end + + if #alternativeAirbases > 0 then + -- Select random alternative airbase + local newAirbase = alternativeAirbases[math.random(1, #alternativeAirbases)] + + -- Update squadron configuration (this is a runtime change) + squadron.airbaseName = newAirbase + airbaseHealthStatus[coalitionKey][squadron.airbaseName] = "operational" -- Reset health for new assignment + + log("REASSIGNED: " .. coalitionName .. " Squadron " .. squadron.displayName .. " moved from " .. squadron.airbaseName .. " to " .. newAirbase) + MESSAGE:New(coalitionName .. " Squadron " .. squadron.displayName .. " reassigned to " .. newAirbase .. " due to airbase issues", 20):ToCoalition(coalitionSide) + else + log("WARNING: No alternative airbases available for " .. coalitionName .. " Squadron " .. squadron.displayName) + MESSAGE:New("WARNING: No alternative airbases available for " .. squadron.displayName, 30):ToCoalition(coalitionSide) + end +end + +-- Monitor for stuck aircraft at airbases +local function monitorStuckAircraft() + local currentTime = timer.getTime() + local stuckThreshold = 300 -- 5 minutes before considering aircraft stuck + local movementThreshold = 50 -- meters - aircraft must move at least this far to not be considered stuck + + for _, coalitionKey in ipairs({"red", "blue"}) do + local coalitionName = (coalitionKey == "red") and "RED" or "BLUE" + + for aircraftName, trackingData in pairs(aircraftSpawnTracking[coalitionKey]) do + if trackingData and trackingData.group and trackingData.group:IsAlive() then + local timeSinceSpawn = currentTime - trackingData.spawnTime + + -- Only check aircraft that have been spawned for at least the threshold time + if timeSinceSpawn >= stuckThreshold then + local currentPos = safeCoordinate(trackingData.group) + local spawnPos = trackingData.spawnPos + local distanceMoved = nil + + if currentPos and spawnPos and type(spawnPos) == "table" and spawnPos.Get2DDistance then + local okDist, dist = pcall(function() return spawnPos:Get2DDistance(currentPos) end) + if okDist and dist then + distanceMoved = dist + end + end + + if distanceMoved then + + -- Check if aircraft has moved less than threshold (stuck) + if distanceMoved < movementThreshold then + log("STUCK AIRCRAFT DETECTED: " .. aircraftName .. " at " .. trackingData.airbase .. + " has only moved " .. math.floor(distanceMoved) .. "m in " .. math.floor(timeSinceSpawn/60) .. " minutes") + + -- Mark airbase as having stuck aircraft + airbaseHealthStatus[coalitionKey][trackingData.airbase] = "stuck-aircraft" + + -- Remove the stuck aircraft and clear tracking + pcall(function() trackingData.group:Destroy() end) + cleanupInterceptorEntry(aircraftName, coalitionKey) + + -- Reassign squadron to alternative airbase + reassignSquadronToAlternativeAirbase(trackingData.squadron, coalitionKey) + + MESSAGE:New(coalitionName .. " aircraft stuck at " .. trackingData.airbase .. " - destroyed and squadron reassigned", 15):ToCoalition(coalitionKey == "red" and coalition.side.RED or coalition.side.BLUE) + else + -- Aircraft has moved sufficiently, remove from tracking (no longer needs monitoring) + log("Aircraft " .. aircraftName .. " has moved " .. math.floor(distanceMoved) .. "m - removing from stuck monitoring", true) + aircraftSpawnTracking[coalitionKey][aircraftName] = nil + end + else + log("Stuck monitor: no coordinate data for " .. aircraftName .. "; removing from tracking", true) + aircraftSpawnTracking[coalitionKey][aircraftName] = nil + end + end + else + -- Clean up dead aircraft from tracking + aircraftSpawnTracking[coalitionKey][aircraftName] = nil + end + end + end +end + +-- Send interceptor back to base +local function sendInterceptorHome(interceptor, coalitionSide) + if not interceptor or not interceptor:IsAlive() then + return + end + + -- Find nearest friendly airbase + local interceptorCoord = safeCoordinate(interceptor) + if not interceptorCoord then + log("ERROR: Could not get interceptor coordinates for RTB", true) + return + end + local nearestAirbase = nil + local nearestAirbaseCoord = nil + local shortestDistance = math.huge + local squadronConfig = getSquadronConfig(coalitionSide) + + -- Check all squadron airbases to find the nearest one that's still friendly + for _, squadron in pairs(squadronConfig) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase and airbase:GetCoalition() == coalitionSide and airbase:IsAlive() then + local airbaseCoord = safeCoordinate(airbase) + if airbaseCoord then + local okDist, distance = pcall(function() return interceptorCoord:Get2DDistance(airbaseCoord) end) + if okDist and distance and distance < shortestDistance then + shortestDistance = distance + nearestAirbase = airbase + nearestAirbaseCoord = airbaseCoord + end + end + end + end + + if nearestAirbase and nearestAirbaseCoord then + local airbaseName = "airbase" + local okABName, fetchedABName = pcall(function() return nearestAirbase:GetName() end) + if okABName and fetchedABName then + airbaseName = fetchedABName + end + + local rtbAltitude = ADVANCED_SETTINGS.rtbAltitude * 0.3048 -- Convert feet to meters + local okRtb, rtbCoord = pcall(function() return nearestAirbaseCoord:SetAltitude(rtbAltitude) end) + if not okRtb or not rtbCoord then + log("ERROR: Failed to compute RTB coordinate for " .. airbaseName, true) + return + end + + -- Clear current tasks and route home + pcall(function() interceptor:ClearTasks() end) + local routeOk, routeErr = pcall(function() interceptor:RouteAirTo(rtbCoord, ADVANCED_SETTINGS.rtbSpeed * 0.5144, "BARO") end) + + local _, coalitionName = getCoalitionSettings(coalitionSide) + local interceptorName = "interceptor" + local okName, fetchedName = pcall(function() return interceptor:GetName() end) + if okName and fetchedName then + interceptorName = fetchedName + end + + if not routeOk and routeErr then + log("ERROR: Failed to assign RTB route for " .. interceptorName .. " -> " .. airbaseName .. ": " .. tostring(routeErr), true) + else + log("Sending " .. coalitionName .. " " .. interceptorName .. " back to " .. airbaseName, true) + end + + -- Schedule cleanup after they should have landed + local coalitionSettings = getCoalitionSettings(coalitionSide) + local flightTime = math.ceil(shortestDistance / (ADVANCED_SETTINGS.rtbSpeed * 0.5144)) + coalitionSettings.rtbFlightBuffer + + SCHEDULER:New(nil, function() + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + local name = nil + if interceptor and interceptor.GetName then + local ok, value = pcall(function() return interceptor:GetName() end) + if ok then name = value end + end + if name and activeInterceptors[coalitionKey][name] then + destroyInterceptorGroup(interceptor, coalitionKey, 0) + log("Cleaned up " .. coalitionName .. " " .. name .. " after RTB", true) + end + end, {}, flightTime) + else + local _, coalitionName = getCoalitionSettings(coalitionSide) + log("No friendly airbase found for " .. coalitionName .. " " .. interceptor:GetName() .. ", will clean up normally") + end +end + +-- Check if airbase is still usable +local function isAirbaseUsable(airbaseName, expectedCoalition) + local airbase = AIRBASE:FindByName(airbaseName) + if not airbase then + return false, "not found" + elseif airbase:GetCoalition() ~= expectedCoalition then + local capturedBy = "Unknown" + if airbase:GetCoalition() == coalition.side.RED then + capturedBy = "Red" + elseif airbase:GetCoalition() == coalition.side.BLUE then + capturedBy = "Blue" + else + capturedBy = "Neutral" + end + return false, "captured by " .. capturedBy + elseif not airbase:IsAlive() then + return false, "destroyed" + else + return true, "operational" + end +end + +-- Count active fighters for coalition +local function countActiveFighters(coalitionSide) + local count = 0 + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + + for _, interceptorData in pairs(activeInterceptors[coalitionKey]) do + if interceptorData and interceptorData.group and interceptorData.group:IsAlive() then + count = count + interceptorData.group:GetSize() + end + end + return count +end + +-- Find best squadron to launch for coalition using zone-based priorities +local function findBestSquadron(threatCoord, threatSize, coalitionSide) + local bestSquadron = nil + local bestPriority = "none" + local bestResponseRatio = 0 + local shortestDistance = math.huge + local currentTime = timer.getTime() + local squadronConfig = getSquadronConfig(coalitionSide) + local coalitionSettings, coalitionName = getCoalitionSettings(coalitionSide) + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + local zonePriorityOrder = {"tertiary", "primary", "secondary", "global"} + + -- First pass: find squadrons that can respond to this threat + local availableSquadrons = {} + + for _, squadron in pairs(squadronConfig) do + -- Check basic availability + local squadronAvailable = true + local unavailableReason = "" + + -- Check squadron state first + if squadron.state and squadron.state ~= "operational" then + squadronAvailable = false + if squadron.state == "captured" then + unavailableReason = "airbase captured by enemy" + elseif squadron.state == "destroyed" then + unavailableReason = "airbase destroyed" + else + unavailableReason = "squadron not operational (state: " .. tostring(squadron.state) .. ")" + end + end + + -- Check cooldown + if squadronAvailable and squadronCooldowns[coalitionKey][squadron.templateName] then + local cooldownEnd = squadronCooldowns[coalitionKey][squadron.templateName] + if currentTime < cooldownEnd then + local timeLeft = math.ceil((cooldownEnd - currentTime) / 60) + squadronAvailable = false + unavailableReason = "on cooldown for " .. timeLeft .. "m" + else + -- Cooldown expired, remove it + squadronCooldowns[coalitionKey][squadron.templateName] = nil + log(coalitionName .. " Squadron " .. squadron.displayName .. " cooldown expired, available for launch", true) + end + end + + -- Check aircraft availability + if squadronAvailable then + local availableAircraft = squadronAircraftCounts[coalitionKey][squadron.templateName] or 0 + if availableAircraft <= 0 then + squadronAvailable = false + unavailableReason = "no aircraft available (" .. availableAircraft .. "/" .. squadron.aircraft .. ")" + end + end + + -- Check airbase status + if squadronAvailable then + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if not airbase then + squadronAvailable = false + unavailableReason = "airbase not found" + elseif airbase:GetCoalition() ~= coalitionSide then + squadronAvailable = false + unavailableReason = "airbase no longer under " .. coalitionName .. " control" + elseif not airbase:IsAlive() then + squadronAvailable = false + unavailableReason = "airbase destroyed" + end + end + + -- Check template exists (Note: Templates are validated during SPAWN:New() call) + -- Template validation is handled by MOOSE SPAWN class during actual spawning + + if squadronAvailable then + -- Get zone priority and response ratio + local zonePriority, responseRatio, zoneDescription = getThreatZonePriority(threatCoord, squadron, coalitionSide) + + -- Check if threat meets priority threshold for secondary zones + local zoneConfig = squadron.zoneConfig or getDefaultZoneConfig() + if zonePriority == "secondary" and zoneConfig.ignoreLowPriority then + if threatSize < zoneConfig.priorityThreshold then + log(coalitionName .. " " .. squadron.displayName .. " ignoring low-priority threat in secondary zone (" .. + threatSize .. " < " .. zoneConfig.priorityThreshold .. ")", true) + responseRatio = 0 + zonePriority = "none" + end + end + + if responseRatio > 0 then + local airbase = AIRBASE:FindByName(squadron.airbaseName) + local airbaseCoord = airbase:GetCoordinate() + local distance = airbaseCoord:Get2DDistance(threatCoord) + + table.insert(availableSquadrons, { + squadron = squadron, + zonePriority = zonePriority, + responseRatio = responseRatio, + distance = distance, + zoneDescription = zoneDescription + }) + + log(coalitionName .. " " .. squadron.displayName .. " can respond: " .. zoneDescription .. + " (ratio: " .. responseRatio .. ", distance: " .. math.floor(distance/1852) .. "nm)", true) + else + log(coalitionName .. " " .. squadron.displayName .. " will not respond: " .. zoneDescription, true) + end + else + log(coalitionName .. " " .. squadron.displayName .. " unavailable: " .. unavailableReason, true) + end + end + + -- Second pass: select best squadron by priority and distance + if #availableSquadrons > 0 then + -- Sort by zone priority (higher priority first), then by distance (closer first) + table.sort(availableSquadrons, function(a, b) + -- Get priority indices + local aPriorityIndex = 5 + local bPriorityIndex = 5 + for i, priority in ipairs(zonePriorityOrder) do + if a.zonePriority == priority then aPriorityIndex = i end + if b.zonePriority == priority then bPriorityIndex = i end + end + + -- First sort by priority (lower index = higher priority) + if aPriorityIndex ~= bPriorityIndex then + return aPriorityIndex < bPriorityIndex + end + + -- Then sort by distance (closer is better) + return a.distance < b.distance + end) + + local selected = availableSquadrons[1] + log("Selected " .. coalitionName .. " " .. selected.squadron.displayName .. " for response: " .. + selected.zoneDescription .. " (distance: " .. math.floor(selected.distance/1852) .. "nm)") + + return selected.squadron, selected.responseRatio, selected.zoneDescription + end + + if ADVANCED_SETTINGS.enableDetailedLogging then + log("No " .. coalitionName .. " squadron available for threat at coordinates") + end + return nil, 0, "no available squadrons" +end + +-- Launch interceptor for coalition +local function launchInterceptor(threatGroup, coalitionSide) + if not threatGroup or not threatGroup:IsAlive() then + return + end + + local threatCoord = threatGroup:GetCoordinate() + local threatName = threatGroup:GetName() + local threatSize = threatGroup:GetSize() + local coalitionSettings, coalitionName = getCoalitionSettings(coalitionSide) + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + + -- Check if threat already has interceptors assigned + if assignedThreats[coalitionKey][threatName] then + local assignedInterceptors = assignedThreats[coalitionKey][threatName] + local aliveCount = 0 + + -- Check if assigned interceptors are still alive + if type(assignedInterceptors) == "table" then + for _, interceptor in pairs(assignedInterceptors) do + if interceptor and interceptor:IsAlive() then + aliveCount = aliveCount + 1 + end + end + else + -- Handle legacy single interceptor assignment + if assignedInterceptors and assignedInterceptors:IsAlive() then + aliveCount = 1 + end + end + + if aliveCount > 0 then + return -- Still being intercepted + else + -- All interceptors are dead, clear the assignment + assignedThreats[coalitionKey][threatName] = nil + end + end + + -- Find best squadron using zone-based priority system first + local squadron, zoneResponseRatio, zoneDescription = findBestSquadron(threatCoord, threatSize, coalitionSide) + + if not squadron then + if ADVANCED_SETTINGS.enableDetailedLogging then + log("No " .. coalitionName .. " squadron available") + end + return + end + + -- Calculate how many interceptors to launch using zone-modified ratio + local finalInterceptRatio = coalitionSettings.interceptRatio * zoneResponseRatio + local interceptorsNeeded = math.max(1, math.ceil(threatSize * finalInterceptRatio)) + + -- Check if we have capacity + if countActiveFighters(coalitionSide) + interceptorsNeeded > coalitionSettings.maxActiveCAP then + interceptorsNeeded = coalitionSettings.maxActiveCAP - countActiveFighters(coalitionSide) + if interceptorsNeeded <= 0 then + log(coalitionName .. " max fighters airborne, skipping launch") + return + end + end + if not squadron then + if ADVANCED_SETTINGS.enableDetailedLogging then + log("No " .. coalitionName .. " squadron available") + end + return + end + + -- Limit interceptors to available aircraft + local availableAircraft = squadronAircraftCounts[coalitionKey][squadron.templateName] or 0 + interceptorsNeeded = math.min(interceptorsNeeded, availableAircraft) + + if interceptorsNeeded <= 0 then + log(coalitionName .. " Squadron " .. squadron.displayName .. " has no aircraft to launch") + return + end + + -- Launch multiple interceptors to match threat + local spawn = SPAWN:New(squadron.templateName) + if not spawn then + log("ERROR: Failed to create SPAWN object for " .. coalitionName .. " " .. squadron.templateName) + return + end + spawn:InitCleanUp(900) + + local interceptors = {} + + for i = 1, interceptorsNeeded do + local interceptor = spawn:Spawn() + + if interceptor then + table.insert(interceptors, interceptor) + + -- Wait a moment for initialization + SCHEDULER:New(nil, function() + if interceptor and interceptor:IsAlive() then + -- Set aggressive AI + interceptor:OptionROEOpenFire() + interceptor:OptionROTVertical() + + -- Route to threat + local currentThreatCoord = safeCoordinate(threatGroup) + if currentThreatCoord then + local okIntercept, interceptCoord = pcall(function() + return currentThreatCoord:SetAltitude(squadron.altitude * 0.3048) + end) + if okIntercept and interceptCoord then + pcall(function() + interceptor:RouteAirTo(interceptCoord, squadron.speed * 0.5144, "BARO") + end) + end + + -- Attack the threat + local attackTask = { + id = 'AttackGroup', + params = { + groupId = threatGroup:GetID(), + weaponType = 'Auto', + attackQtyLimit = 0, + priority = 1 + } + } + interceptor:PushTask(attackTask, 1) + end + end + end, {}, 3) + + -- Track the interceptor with squadron info + local interceptorName = "interceptor" + local okName, fetchedName = pcall(function() return interceptor:GetName() end) + if okName and fetchedName then + interceptorName = fetchedName + end + + activeInterceptors[coalitionKey][interceptorName] = { + group = interceptor, + squadron = squadron.templateName, + displayName = squadron.displayName + } + + -- Track spawn position for stuck aircraft detection + local spawnPos = safeCoordinate(interceptor) + if spawnPos then + aircraftSpawnTracking[coalitionKey][interceptorName] = { + spawnPos = spawnPos, + spawnTime = timer.getTime(), + squadron = squadron, + airbase = squadron.airbaseName + } + log("Tracking spawn position for " .. interceptorName .. " at " .. squadron.airbaseName, true) + end + + -- Emergency cleanup (safety net) + SCHEDULER:New(nil, function() + local name = nil + if interceptor and interceptor.GetName then + local ok, value = pcall(function() return interceptor:GetName() end) + if ok then name = value end + end + if name and activeInterceptors[coalitionKey][name] then + log("Emergency cleanup of " .. coalitionName .. " " .. name .. " (should have RTB'd)") + destroyInterceptorGroup(interceptor, coalitionKey, 0) + end + end, {}, coalitionSettings.emergencyCleanupTime) + end + end + + -- Log the launch and track assignment + if #interceptors > 0 then + -- Decrement squadron aircraft count + local currentCount = squadronAircraftCounts[coalitionKey][squadron.templateName] or 0 + squadronAircraftCounts[coalitionKey][squadron.templateName] = math.max(0, currentCount - #interceptors) + local remainingCount = squadronAircraftCounts[coalitionKey][squadron.templateName] + + log("Launched " .. #interceptors .. " x " .. coalitionName .. " " .. squadron.displayName .. " to intercept " .. + threatSize .. " x " .. threatName .. " (" .. zoneDescription .. ", ratio: " .. string.format("%.1f", finalInterceptRatio) .. + ", remaining: " .. remainingCount .. "/" .. squadron.aircraft .. ")") + assignedThreats[coalitionKey][threatName] = interceptors + lastLaunchTime[coalitionKey][threatName] = timer.getTime() + + -- Apply cooldown immediately when squadron launches + local currentTime = timer.getTime() + squadronCooldowns[coalitionKey][squadron.templateName] = currentTime + coalitionSettings.squadronCooldown + local cooldownMinutes = coalitionSettings.squadronCooldown / 60 + log(coalitionName .. " Squadron " .. squadron.displayName .. " LAUNCHED! Applying " .. cooldownMinutes .. " minute cooldown") + end +end + +-- Main threat detection loop for coalition +local function detectThreatsForCoalition(coalitionSide) + local coalitionSettings, coalitionName = getCoalitionSettings(coalitionSide) + local enemyCoalition = (coalitionSide == coalition.side.RED) and "blue" or "red" + local coalitionKey = (coalitionSide == coalition.side.RED) and "red" or "blue" + + log("Scanning for " .. coalitionName .. " threats...", true) + + -- Clean up dead threats from tracking + local currentThreats = {} + + -- Find all enemy aircraft using cached set for performance + local cacheKey = enemyCoalition .. "Aircraft" + if not cachedSets[cacheKey] then + cachedSets[cacheKey] = SET_GROUP:New():FilterCoalitions(enemyCoalition):FilterCategoryAirplane():FilterStart() + end + local enemyAircraft = cachedSets[cacheKey] + local threatCount = 0 + + enemyAircraft:ForEach(function(enemyGroup) + if enemyGroup and enemyGroup:IsAlive() then + threatCount = threatCount + 1 + currentThreats[enemyGroup:GetName()] = true + log("Found " .. coalitionName .. " threat: " .. enemyGroup:GetName() .. " (" .. enemyGroup:GetTypeName() .. ")", true) + + -- Launch interceptor for this threat + launchInterceptor(enemyGroup, coalitionSide) + end + end) + + -- Clean up assignments for threats that no longer exist and send interceptors home + for threatName, assignedInterceptors in pairs(assignedThreats[coalitionKey]) do + if not currentThreats[threatName] then + log("Threat " .. threatName .. " eliminated, sending " .. coalitionName .. " interceptors home...") + + -- Send assigned interceptors back to base + if type(assignedInterceptors) == "table" then + for _, interceptor in pairs(assignedInterceptors) do + if interceptor and interceptor:IsAlive() then + sendInterceptorHome(interceptor, coalitionSide) + end + end + else + -- Handle legacy single interceptor assignment + if assignedInterceptors and assignedInterceptors:IsAlive() then + sendInterceptorHome(assignedInterceptors, coalitionSide) + end + end + + assignedThreats[coalitionKey][threatName] = nil + end + end + + -- Count assigned threats + local assignedCount = 0 + for _ in pairs(assignedThreats[coalitionKey]) do assignedCount = assignedCount + 1 end + + log(coalitionName .. " scan complete: " .. threatCount .. " threats, " .. countActiveFighters(coalitionSide) .. " active fighters, " .. + assignedCount .. " assigned") +end + +-- Main threat detection loop - calls both coalitions +local function detectThreats() + if TADC_SETTINGS.enableRed then + detectThreatsForCoalition(coalition.side.RED) + end + + if TADC_SETTINGS.enableBlue then + detectThreatsForCoalition(coalition.side.BLUE) + end +end + +-- Monitor interceptor groups for cleanup when destroyed +local function monitorInterceptors() + -- Check RED interceptors + if TADC_SETTINGS.enableRed then + for interceptorName, interceptorData in pairs(activeInterceptors.red) do + if interceptorData and interceptorData.group then + if not interceptorData.group:IsAlive() then + -- Interceptor group is destroyed - just clean up tracking + local displayName = interceptorData.displayName + log("RED Interceptor from " .. displayName .. " destroyed: " .. interceptorName, true) + + -- Remove from active tracking + activeInterceptors.red[interceptorName] = nil + end + end + end + end + + -- Check BLUE interceptors + if TADC_SETTINGS.enableBlue then + for interceptorName, interceptorData in pairs(activeInterceptors.blue) do + if interceptorData and interceptorData.group then + if not interceptorData.group:IsAlive() then + -- Interceptor group is destroyed - just clean up tracking + local displayName = interceptorData.displayName + log("BLUE Interceptor from " .. displayName .. " destroyed: " .. interceptorName, true) + + -- Remove from active tracking + activeInterceptors.blue[interceptorName] = nil + end + end + end + end +end + +-- Periodic airbase status check +local function checkAirbaseStatus() + log("=== AIRBASE STATUS REPORT ===") + + local redUsableCount = 0 + local blueUsableCount = 0 + local currentTime = timer.getTime() + + -- Check RED airbases + if TADC_SETTINGS.enableRed then + log("=== RED COALITION STATUS ===") + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + local aircraftCount = squadronAircraftCounts.red[squadron.templateName] or 0 + local maxAircraft = squadron.aircraft + + -- Determine status based on squadron state + local statusPrefix = "✗" + local statusText = "" + local usable = false + + if squadron.state == "operational" then + statusPrefix = "✓" + statusText = "Operational: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + usable = true + elseif squadron.state == "captured" then + -- Determine who captured it + local capturedBy = "enemy" + if airbase and airbase:IsAlive() then + local airbaseCoalition = airbase:GetCoalition() + if airbaseCoalition == coalition.side.BLUE then + capturedBy = "Blue" + elseif airbaseCoalition == coalition.side.NEUTRAL then + capturedBy = "neutral forces" + end + end + statusText = "Captured by " .. capturedBy .. ": " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + elseif squadron.state == "destroyed" then + statusText = "Destroyed: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + else + statusText = "Unknown state: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + end + + -- Add zone information if configured + local zoneStatus = "" + if squadron.primaryZone or squadron.secondaryZone or squadron.tertiaryZone then + local zones = {} + if squadron.primaryZone then table.insert(zones, "P:" .. squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, "S:" .. squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, "T:" .. squadron.tertiaryZone) end + zoneStatus = " Zones: " .. table.concat(zones, " ") + end + + -- Check if squadron is on cooldown (only show for operational squadrons) + local cooldownStatus = "" + if squadron.state == "operational" and squadronCooldowns.red[squadron.templateName] then + local cooldownEnd = squadronCooldowns.red[squadron.templateName] + if currentTime < cooldownEnd then + local timeLeft = math.ceil((cooldownEnd - currentTime) / 60) + cooldownStatus = " (COOLDOWN: " .. timeLeft .. "m)" + end + end + + local fullStatus = statusText .. zoneStatus .. cooldownStatus + + if usable and cooldownStatus == "" and aircraftCount > 0 then + redUsableCount = redUsableCount + 1 + end + + log(statusPrefix .. " " .. squadron.displayName .. " (" .. squadron.airbaseName .. ") - " .. fullStatus) + end + log("RED Status: " .. redUsableCount .. "/" .. #RED_SQUADRON_CONFIG .. " airbases operational") + end + + -- Check BLUE airbases + if TADC_SETTINGS.enableBlue then + log("=== BLUE COALITION STATUS ===") + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + local aircraftCount = squadronAircraftCounts.blue[squadron.templateName] or 0 + local maxAircraft = squadron.aircraft + + -- Determine status based on squadron state + local statusPrefix = "✗" + local statusText = "" + local usable = false + + if squadron.state == "operational" then + statusPrefix = "✓" + statusText = "Operational: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + usable = true + elseif squadron.state == "captured" then + -- Determine who captured it + local capturedBy = "enemy" + if airbase and airbase:IsAlive() then + local airbaseCoalition = airbase:GetCoalition() + if airbaseCoalition == coalition.side.RED then + capturedBy = "Red" + elseif airbaseCoalition == coalition.side.NEUTRAL then + capturedBy = "neutral forces" + end + end + statusText = "Captured by " .. capturedBy .. ": " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + elseif squadron.state == "destroyed" then + statusText = "Destroyed: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + else + statusText = "Unknown state: " .. aircraftCount .. "/" .. maxAircraft .. " aircraft" + end + + -- Add zone information if configured + local zoneStatus = "" + if squadron.primaryZone or squadron.secondaryZone or squadron.tertiaryZone then + local zones = {} + if squadron.primaryZone then table.insert(zones, "P:" .. squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, "S:" .. squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, "T:" .. squadron.tertiaryZone) end + zoneStatus = " Zones: " .. table.concat(zones, " ") + end + + -- Check if squadron is on cooldown (only show for operational squadrons) + local cooldownStatus = "" + if squadron.state == "operational" and squadronCooldowns.blue[squadron.templateName] then + local cooldownEnd = squadronCooldowns.blue[squadron.templateName] + if currentTime < cooldownEnd then + local timeLeft = math.ceil((cooldownEnd - currentTime) / 60) + cooldownStatus = " (COOLDOWN: " .. timeLeft .. "m)" + end + end + + local fullStatus = statusText .. zoneStatus .. cooldownStatus + + if usable and cooldownStatus == "" and aircraftCount > 0 then + blueUsableCount = blueUsableCount + 1 + end + + log(statusPrefix .. " " .. squadron.displayName .. " (" .. squadron.airbaseName .. ") - " .. fullStatus) + end + log("BLUE Status: " .. blueUsableCount .. "/" .. #BLUE_SQUADRON_CONFIG .. " airbases operational") + end +end + +-- Cleanup old delivery records to prevent memory buildup +local function cleanupOldDeliveries() + if _G.processedDeliveries then + local currentTime = timer.getTime() + local cleanupAge = 3600 -- Remove delivery records older than 1 hour + local removedCount = 0 + + for deliveryKey, timestamp in pairs(_G.processedDeliveries) do + if currentTime - timestamp > cleanupAge then + _G.processedDeliveries[deliveryKey] = nil + removedCount = removedCount + 1 + end + end + + if removedCount > 0 then + log("Cleaned up " .. removedCount .. " old cargo delivery records", true) + end + end +end + +-- Update squadron states based on airbase coalition control +local function updateSquadronStates() + -- Update RED squadrons + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase and airbase:IsAlive() then + local airbaseCoalition = airbase:GetCoalition() + if airbaseCoalition == coalition.side.RED then + -- Only update to operational if not already operational (avoid spam) + if squadron.state ~= "operational" then + squadron.state = "operational" + log("RED Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " is now operational") + end + else + -- Airbase captured + if squadron.state ~= "captured" then + squadron.state = "captured" + log("RED Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " has been captured by enemy") + end + end + else + -- Airbase destroyed or not found + if squadron.state ~= "destroyed" then + squadron.state = "destroyed" + log("RED Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " airbase destroyed or not found") + end + end + end + + -- Update BLUE squadrons + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local airbase = AIRBASE:FindByName(squadron.airbaseName) + if airbase and airbase:IsAlive() then + local airbaseCoalition = airbase:GetCoalition() + if airbaseCoalition == coalition.side.BLUE then + -- Only update to operational if not already operational (avoid spam) + if squadron.state ~= "operational" then + squadron.state = "operational" + log("BLUE Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " is now operational") + end + else + -- Airbase captured + if squadron.state ~= "captured" then + squadron.state = "captured" + log("BLUE Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " has been captured by enemy") + end + end + else + -- Airbase destroyed or not found + if squadron.state ~= "destroyed" then + squadron.state = "destroyed" + log("BLUE Squadron " .. squadron.displayName .. " at " .. squadron.airbaseName .. " airbase destroyed or not found") + end + end + end +end + +-- System initialization +local function initializeSystem() + log("Universal Dual-Coalition TADC starting...") + + -- Create zones from late-activated helicopter units (MOOSE method) + -- This allows using helicopters named "RED_BORDER", "BLUE_BORDER" etc. as zone markers + -- Uses the helicopter's waypoints as polygon vertices (standard MOOSE method) + local function createZoneFromUnit(unitName) + -- Try to find as a group first (this is the standard MOOSE way) + local group = GROUP:FindByName(unitName) + if group then + -- Create polygon zone using the group's waypoints as vertices + local zone = ZONE_POLYGON:NewFromGroupName(unitName, unitName) + if zone then + log("Created polygon zone '" .. unitName .. "' from helicopter waypoints") + return zone + else + log("Warning: Could not create polygon zone from group '" .. unitName .. "' - check waypoints") + end + else + log("Warning: No group named '" .. unitName .. "' found for zone creation") + end + return nil + end + + -- Try to create zones for all configured zone names + local zoneNames = {} + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + if squadron.primaryZone then table.insert(zoneNames, squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zoneNames, squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zoneNames, squadron.tertiaryZone) end + end + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + if squadron.primaryZone then table.insert(zoneNames, squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zoneNames, squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zoneNames, squadron.tertiaryZone) end + end + + -- Create zones from helicopters + for _, zoneName in ipairs(zoneNames) do + if not ZONE:FindByName(zoneName) then + createZoneFromUnit(zoneName) + end + end + + -- Validate configuration + if not validateConfiguration() then + log("System startup aborted due to configuration errors!") + return false + end + + -- Initialize squadron states + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + squadron.state = "operational" + end + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + squadron.state = "operational" + end + log("Squadron states initialized") + + -- Log enabled coalitions + local enabledCoalitions = {} + if TADC_SETTINGS.enableRed then + table.insert(enabledCoalitions, "RED (" .. #RED_SQUADRON_CONFIG .. " squadrons)") + end + if TADC_SETTINGS.enableBlue then + table.insert(enabledCoalitions, "BLUE (" .. #BLUE_SQUADRON_CONFIG .. " squadrons)") + end + log("Enabled coalitions: " .. table.concat(enabledCoalitions, ", ")) + + -- Log initial squadron aircraft counts + if TADC_SETTINGS.enableRed then + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local count = squadronAircraftCounts.red[squadron.templateName] + log("Initial RED: " .. squadron.displayName .. " has " .. count .. "/" .. squadron.aircraft .. " aircraft") + end + end + + if TADC_SETTINGS.enableBlue then + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local count = squadronAircraftCounts.blue[squadron.templateName] + log("Initial BLUE: " .. squadron.displayName .. " has " .. count .. "/" .. squadron.aircraft .. " aircraft") + end + end + + -- Start schedulers + -- Set up event handler for cargo landing detection (handled via MOOSE EVENTHANDLER wrapper below) + + -- Re-register world event handler for robust detection (handles raw DCS initiators and race cases) + world.addEventHandler(cargoEventHandler) + + -- MOOSE-style EVENTHANDLER wrapper for readability: logs EventData but does NOT delegate to avoid double-processing + if EVENTHANDLER then + local TADC_CARGO_LANDING_HANDLER = EVENTHANDLER:New() + function TADC_CARGO_LANDING_HANDLER:OnEventLand(EventData) + -- Convert MOOSE EventData to raw world.event format and reuse existing handler logic + if ADVANCED_SETTINGS.enableDetailedLogging then + -- Log presence and types of key fields + local function safeName(obj) + if not obj then return "" end + local ok, n = pcall(function() + if obj.GetName then return obj:GetName() end + if obj.getName then return obj:getName() end + return nil + end) + return (ok and n) and tostring(n) or "" + end + + local iniUnitPresent = EventData.IniUnit ~= nil + local iniGroupPresent = EventData.IniGroup ~= nil + local placePresent = EventData.Place ~= nil + local iniUnitName = safeName(EventData.IniUnit) + local iniGroupName = safeName(EventData.IniGroup) + local placeName = safeName(EventData.Place) + + log("MOOSE LAND EVENT: IniUnitPresent=" .. tostring(iniUnitPresent) .. ", IniUnitName=" .. tostring(iniUnitName) .. ", IniGroupPresent=" .. tostring(iniGroupPresent) .. ", IniGroupName=" .. tostring(iniGroupName) .. ", PlacePresent=" .. tostring(placePresent) .. ", PlaceName=" .. tostring(placeName), true) + end + + local rawEvent = { + id = world.event.S_EVENT_LAND, + initiator = EventData.IniUnit or EventData.IniGroup or nil, + place = EventData.Place or nil, + -- Provide the original EventData for potential fallback use + _moose_original = EventData + } + -- Log and return; the world event handler `cargoEventHandler` will handle the actual processing. + return + end + -- Register the MOOSE handler + TADC_CARGO_LANDING_HANDLER:HandleEvent(EVENTS.Land) + end + + SCHEDULER:New(nil, detectThreats, {}, 5, TADC_SETTINGS.checkInterval) + SCHEDULER:New(nil, monitorInterceptors, {}, 10, TADC_SETTINGS.monitorInterval) + SCHEDULER:New(nil, checkAirbaseStatus, {}, 30, TADC_SETTINGS.statusReportInterval) + SCHEDULER:New(nil, updateSquadronStates, {}, 60, 30) -- Update squadron states every 30 seconds (60 sec initial delay to allow DCS airbase coalition to stabilize) + SCHEDULER:New(nil, cleanupOldDeliveries, {}, 60, 3600) -- Cleanup old delivery records every hour + + -- Start periodic squadron summary broadcast + SCHEDULER:New(nil, broadcastSquadronSummary, {}, 10, TADC_SETTINGS.squadronSummaryInterval) + + log("Universal Dual-Coalition TADC operational!") + log("RED Replenishment: " .. TADC_SETTINGS.red.cargoReplenishmentAmount .. " aircraft per cargo delivery") + log("BLUE Replenishment: " .. TADC_SETTINGS.blue.cargoReplenishmentAmount .. " aircraft per cargo delivery") + + return true +end + + +initializeSystem() + +-- Add F10 menu command for squadron summary +-- Use MenuManager to create coalition-specific menus (not mission-wide) +local menuRootBlue, menuRootRed + +if MenuManager then + menuRootBlue = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "TADC Utilities") + menuRootRed = MenuManager.CreateCoalitionMenu(coalition.side.RED, "TADC Utilities") +else + menuRootBlue = MENU_COALITION:New(coalition.side.BLUE, "TADC Utilities") + menuRootRed = MENU_COALITION:New(coalition.side.RED, "TADC Utilities") +end + +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Squadron Resource Summary", menuRootRed, function() + local summary = getSquadronResourceSummary(coalition.side.RED) + MESSAGE:New(summary, 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Squadron Resource Summary", menuRootBlue, function() + local summary = getSquadronResourceSummary(coalition.side.BLUE) + MESSAGE:New(summary, 20):ToCoalition(coalition.side.BLUE) +end) + +-- 1. Show Airbase Status Report +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Airbase Status Report", menuRootRed, function() + local report = "=== RED Airbase Status ===\n" + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local usable, status = isAirbaseUsable(squadron.airbaseName, coalition.side.RED) + local aircraftCount = squadronAircraftCounts.red[squadron.templateName] or 0 + local maxAircraft = squadron.aircraft + local cooldown = squadronCooldowns.red[squadron.templateName] + local cooldownStatus = "" + if cooldown then + local timeLeft = math.ceil((cooldown - timer.getTime()) / 60) + if timeLeft > 0 then cooldownStatus = " (COOLDOWN: " .. timeLeft .. "m)" end + end + report = report .. string.format("%s: %s | Aircraft: %d/%d%s\n", squadron.displayName, status, aircraftCount, maxAircraft, cooldownStatus) + end + MESSAGE:New(report, 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Airbase Status Report", menuRootBlue, function() + local report = "=== BLUE Airbase Status ===\n" + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local usable, status = isAirbaseUsable(squadron.airbaseName, coalition.side.BLUE) + local aircraftCount = squadronAircraftCounts.blue[squadron.templateName] or 0 + local maxAircraft = squadron.aircraft + local cooldown = squadronCooldowns.blue[squadron.templateName] + local cooldownStatus = "" + if cooldown then + local timeLeft = math.ceil((cooldown - timer.getTime()) / 60) + if timeLeft > 0 then cooldownStatus = " (COOLDOWN: " .. timeLeft .. "m)" end + end + report = report .. string.format("%s: %s | Aircraft: %d/%d%s\n", squadron.displayName, status, aircraftCount, maxAircraft, cooldownStatus) + end + MESSAGE:New(report, 20):ToCoalition(coalition.side.BLUE) +end) + +-- 2. Show Active Interceptors +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Active Interceptors", menuRootRed, function() + local lines = {"Active RED Interceptors:"} + for name, data in pairs(activeInterceptors.red) do + if data and data.group and data.group:IsAlive() then + table.insert(lines, string.format("%s (Squadron: %s, Threat: %s)", name, data.displayName or data.squadron, assignedThreats.red[name] or "N/A")) + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Active Interceptors", menuRootBlue, function() + local lines = {"Active BLUE Interceptors:"} + for name, data in pairs(activeInterceptors.blue) do + if data and data.group and data.group:IsAlive() then + table.insert(lines, string.format("%s (Squadron: %s, Threat: %s)", name, data.displayName or data.squadron, assignedThreats.blue[name] or "N/A")) + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.BLUE) +end) + +-- 3. Show Threat Summary +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Threat Summary", menuRootRed, function() + local lines = {"Detected BLUE Threats:"} + if cachedSets.blueAircraft then + cachedSets.blueAircraft:ForEach(function(group) + if group and group:IsAlive() then + table.insert(lines, string.format("%s (Size: %d)", group:GetName(), group:GetSize())) + end + end) + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Threat Summary", menuRootBlue, function() + local lines = {"Detected RED Threats:"} + if cachedSets.redAircraft then + cachedSets.redAircraft:ForEach(function(group) + if group and group:IsAlive() then + table.insert(lines, string.format("%s (Size: %d)", group:GetName(), group:GetSize())) + end + end) + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.BLUE) +end) + +-- 4. Request Immediate Squadron Summary Broadcast +MENU_COALITION_COMMAND:New(coalition.side.RED, "Broadcast Squadron Summary Now", menuRootRed, function() + local summary = getSquadronResourceSummary(coalition.side.RED) + MESSAGE:New(summary, 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Broadcast Squadron Summary Now", menuRootBlue, function() + local summary = getSquadronResourceSummary(coalition.side.BLUE) + MESSAGE:New(summary, 20):ToCoalition(coalition.side.BLUE) +end) + +-- 5. Show Cargo Delivery Log +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Cargo Delivery Log", menuRootRed, function() + local lines = {"Recent RED Cargo Deliveries:"} + if _G.processedDeliveries then + for key, timestamp in pairs(_G.processedDeliveries) do + if string.find(key, "RED") then + table.insert(lines, string.format("%s at %d", key, timestamp)) + end + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Cargo Delivery Log", menuRootBlue, function() + local lines = {"Recent BLUE Cargo Deliveries:"} + if _G.processedDeliveries then + for key, timestamp in pairs(_G.processedDeliveries) do + if string.find(key, "BLUE") then + table.insert(lines, string.format("%s at %d", key, timestamp)) + end + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.BLUE) +end) + +-- 6. Show Zone Coverage Map +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Zone Coverage Map", menuRootRed, function() + local lines = {"RED Zone Coverage:"} + for _, squadron in pairs(RED_SQUADRON_CONFIG) do + local zones = {} + if squadron.primaryZone then table.insert(zones, "Primary: " .. squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, "Secondary: " .. squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, "Tertiary: " .. squadron.tertiaryZone) end + table.insert(lines, string.format("%s: %s", squadron.displayName, table.concat(zones, ", "))) + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.RED) +end) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Zone Coverage Map", menuRootBlue, function() + local lines = {"BLUE Zone Coverage:"} + for _, squadron in pairs(BLUE_SQUADRON_CONFIG) do + local zones = {} + if squadron.primaryZone then table.insert(zones, "Primary: " .. squadron.primaryZone) end + if squadron.secondaryZone then table.insert(zones, "Secondary: " .. squadron.secondaryZone) end + if squadron.tertiaryZone then table.insert(zones, "Tertiary: " .. squadron.tertiaryZone) end + table.insert(lines, string.format("%s: %s", squadron.displayName, table.concat(zones, ", "))) + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToCoalition(coalition.side.BLUE) +end) + +-- 7. Admin/Debug Commands - Create submenus under each coalition's TADC Utilities +local menuAdminBlue = MENU_COALITION:New(coalition.side.BLUE, "Admin / Debug", menuRootBlue) +local menuAdminRed = MENU_COALITION:New(coalition.side.RED, "Admin / Debug", menuRootRed) + +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Emergency Cleanup Interceptors", menuAdminBlue, function() + local cleaned = 0 + for name, interceptors in pairs(activeInterceptors.red) do + if interceptors and interceptors.group and not interceptors.group:IsAlive() then + cleanupInterceptorEntry(name, "red") + cleaned = cleaned + 1 + end + end + for name, interceptors in pairs(activeInterceptors.blue) do + if interceptors and interceptors.group and not interceptors.group:IsAlive() then + cleanupInterceptorEntry(name, "blue") + cleaned = cleaned + 1 + end + end + MESSAGE:New("Cleaned up " .. cleaned .. " dead interceptor groups.", 20):ToBlue() +end) + +MENU_COALITION_COMMAND:New(coalition.side.RED, "Emergency Cleanup Interceptors", menuAdminRed, function() + local cleaned = 0 + for name, interceptors in pairs(activeInterceptors.red) do + if interceptors and interceptors.group and not interceptors.group:IsAlive() then + cleanupInterceptorEntry(name, "red") + cleaned = cleaned + 1 + end + end + for name, interceptors in pairs(activeInterceptors.blue) do + if interceptors and interceptors.group and not interceptors.group:IsAlive() then + cleanupInterceptorEntry(name, "blue") + cleaned = cleaned + 1 + end + end + MESSAGE:New("Cleaned up " .. cleaned .. " dead interceptor groups.", 20):ToRed() +end) + +-- 9. Show System Uptime/Status +local systemStartTime = timer.getTime() +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show TADC System Status", menuAdminBlue, function() + local uptime = math.floor((timer.getTime() - systemStartTime) / 60) + local status = string.format("TADC System Uptime: %d minutes\nCheck Interval: %ds\nMonitor Interval: %ds\nStatus Report Interval: %ds\nSquadron Summary Interval: %ds\nCargo Check Interval: %ds", uptime, TADC_SETTINGS.checkInterval, TADC_SETTINGS.monitorInterval, TADC_SETTINGS.statusReportInterval, TADC_SETTINGS.squadronSummaryInterval, TADC_SETTINGS.cargoCheckInterval) + MESSAGE:New(status, 20):ToBlue() +end) + +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show TADC System Status", menuAdminRed, function() + local uptime = math.floor((timer.getTime() - systemStartTime) / 60) + local status = string.format("TADC System Uptime: %d minutes\nCheck Interval: %ds\nMonitor Interval: %ds\nStatus Report Interval: %ds\nSquadron Summary Interval: %ds\nCargo Check Interval: %ds", uptime, TADC_SETTINGS.checkInterval, TADC_SETTINGS.monitorInterval, TADC_SETTINGS.statusReportInterval, TADC_SETTINGS.squadronSummaryInterval, TADC_SETTINGS.cargoCheckInterval) + MESSAGE:New(status, 20):ToRed() +end) + +-- 10. Check for Stuck Aircraft (manual trigger) +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Check for Stuck Aircraft", menuAdminBlue, function() + monitorStuckAircraft() + MESSAGE:New("Stuck aircraft check completed", 10):ToBlue() +end) + +MENU_COALITION_COMMAND:New(coalition.side.RED, "Check for Stuck Aircraft", menuAdminRed, function() + monitorStuckAircraft() + MESSAGE:New("Stuck aircraft check completed", 10):ToRed() +end) + +-- 11. Show Airbase Health Status +MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show Airbase Health Status", menuAdminBlue, function() + local lines = {"Airbase Health Status:"} + for _, coalitionKey in ipairs({"red", "blue"}) do + local coalitionName = (coalitionKey == "red") and "RED" or "BLUE" + table.insert(lines, coalitionName .. " Coalition:") + for airbaseName, status in pairs(airbaseHealthStatus[coalitionKey]) do + table.insert(lines, " " .. airbaseName .. ": " .. status) + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToBlue() +end) + +MENU_COALITION_COMMAND:New(coalition.side.RED, "Show Airbase Health Status", menuAdminRed, function() + local lines = {"Airbase Health Status:"} + for _, coalitionKey in ipairs({"red", "blue"}) do + local coalitionName = (coalitionKey == "red") and "RED" or "BLUE" + table.insert(lines, coalitionName .. " Coalition:") + for airbaseName, status in pairs(airbaseHealthStatus[coalitionKey]) do + table.insert(lines, " " .. airbaseName .. ": " .. status) + end + end + MESSAGE:New(table.concat(lines, "\n"), 20):ToRed() +end) + +-- Initialize airbase health status for all configured airbases +for _, coalitionKey in ipairs({"red", "blue"}) do + local squadronConfig = getSquadronConfig(coalitionKey == "red" and coalition.side.RED or coalition.side.BLUE) + for _, squadron in pairs(squadronConfig) do + if not airbaseHealthStatus[coalitionKey][squadron.airbaseName] then + airbaseHealthStatus[coalitionKey][squadron.airbaseName] = "operational" + end + end +end + +-- Set up periodic stuck aircraft monitoring (every 2 minutes) +SCHEDULER:New(nil, monitorStuckAircraft, {}, 120, 120) + + + diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_SquadronConfigs_Load1st.lua b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_SquadronConfigs_Load1st.lua new file mode 100644 index 0000000..e07df13 --- /dev/null +++ b/DCS_Afgainistan/Insurgent_Sandstorm/Moose_TADC_SquadronConfigs_Load1st.lua @@ -0,0 +1,374 @@ + +--[[ THIS FILE MUST BE LOADED BEFORE THE MAIN Moose_TADC.lua SCRIPT +═══════════════════════════════════════════════════════════════════════════════ + SQUADRON CONFIGURATION +═══════════════════════════════════════════════════════════════════════════════ + +INSTRUCTIONS: +1. Create fighter aircraft templates for BOTH coalitions in the mission editor +2. Place them at or near the airbases you want them to operate from +3. Configure RED squadrons in RED_SQUADRON_CONFIG +4. Configure BLUE squadrons in BLUE_SQUADRON_CONFIG + +TEMPLATE NAMING SUGGESTIONS: +• RED: "RED_CAP_Batumi_F15", "RED_INTERCEPT_Senaki_MiG29" +• BLUE: "BLUE_CAP_Nellis_F16", "BLUE_INTERCEPT_Creech_F22" +• Include coalition and airbase name for easy identification + +AIRBASE NAMES: +• Use exact names as they appear in DCS (case sensitive) +• RED examples: "Batumi", "Senaki", "Gudauta" +• BLUE examples: "Nellis AFB", "McCarran International", "Tonopah Test Range" +• Find airbase names in the mission editor + +AIRCRAFT NUMBERS: +• Set realistic numbers based on mission requirements +• Consider aircraft consumption and cargo replenishment +• Balance between realism and gameplay performance + +ZONE-BASED AREAS OF RESPONSIBILITY: +• Create zones in mission editor (MOOSE polygons, circles, etc.) +• primaryZone: Squadron's main area (full response) +• secondaryZone: Backup/support area (reduced response) +• tertiaryZone: Emergency fallback area (enhanced response) +• Leave zones as nil for global threat response +• Multiple squadrons can share overlapping zones +• Use zone names exactly as they appear in mission editor + +ZONE BEHAVIOR EXAMPLES: +• Border Defense: primaryZone = "SECTOR_ALPHA", secondaryZone = "BUFFER_ZONE" +• Base Defense: tertiaryZone = "BASE_PERIMETER", enableFallback = true +• Layered Defense: Different zones per squadron with overlap +• Emergency Response: High tertiaryResponse ratio for critical areas +]] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- RED COALITION SQUADRONS +-- ═══════════════════════════════════════════════════════════════════════════ + +RED_SQUADRON_CONFIG = { + --[[ EXAMPLE RED SQUADRON - CUSTOMIZE FOR YOUR MISSION + { + templateName = "RED_CAP_Batumi_F15", -- Template name from mission editor + displayName = "Batumi F-15C CAP", -- Human-readable name for logs + airbaseName = "Batumi", -- Exact airbase name from DCS + aircraft = 12, -- Maximum aircraft in squadron + skill = AI.Skill.GOOD, -- AI skill level + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER" + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + ]] + + -- ADD YOUR RED SQUADRONS HERE + { + templateName = "Shindand MiG-31", -- Change to your RED template name + displayName = "Shindand MiG-31", -- Change to your preferred name + airbaseName = "Shindand", -- Change to your RED airbase + aircraft = 12, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT, ACE + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + + { + templateName = "Shindand F-14B", -- Change to your RED template name + displayName = "Shindand F-14B", -- Change to your preferred name + airbaseName = "Shindand", -- Change to your RED airbase + aircraft = 12, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + + { + templateName = "Shindand MiG-21", -- Change to your RED template name + displayName = "Shindand MiG-21", -- Change to your preferred name + airbaseName = "Shindand", -- Change to your RED airbase + aircraft = 14, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + + { templateName = "Farah MiG-21", -- Change to your RED template name + displayName = "Farah MiG-21", -- Change to your preferred name + airbaseName = "Farah", -- Change to your RED airbase + aircraft = 14, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + { templateName = "Camp Bastion F-14B", -- Change to your RED template name + displayName = "Camp Bastion F-14B", -- Change to your preferred name + airbaseName = "Camp Bastion", -- Change to your RED airbase + aircraft = 12, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + { templateName = "Camp Bastion Su-27", -- Change to your RED template name + displayName = "Camp Bastion Su-27", -- Change to your preferred name + airbaseName = "Camp Bastion", -- Change to your RED airbase + aircraft = 12, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + { templateName = "Camp Bastion Mig-31", -- Change to your RED template name + displayName = "Camp Bastion Mig-31", -- Change to your preferred name + airbaseName = "Camp Bastion", -- Change to your RED airbase + aircraft = 12, -- Adjust aircraft count + skill = AI.Skill.ACE, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 20000, -- Patrol altitude (feet) + speed = 350, -- Patrol speed (knots) + patrolTime = 25, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "RED_BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = false, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, +} + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BLUE COALITION SQUADRONS +-- ═══════════════════════════════════════════════════════════════════════════ + +BLUE_SQUADRON_CONFIG = { + --[[ EXAMPLE BLUE SQUADRON - CUSTOMIZE FOR YOUR MISSION + { + templateName = "BLUE_CAP_Nellis_F16", -- Template name from mission editor + displayName = "Nellis F-16C CAP", -- Human-readable name for logs + airbaseName = "Nellis AFB", -- Exact airbase name from DCS + aircraft = 14, -- Maximum aircraft in squadron + skill = AI.Skill.EXCELLENT, -- AI skill level + altitude = 22000, -- Patrol altitude (feet) + speed = 380, -- Patrol speed (knots) + patrolTime = 28, -- Time on station (minutes) + type = "FIGHTER" -- Aircraft type + }, + ]] + + -- ADD YOUR BLUE SQUADRONS HERE + + { + templateName = "Kandahar F-4E", -- Change to your BLUE template name + displayName = "Kandahar F-4E", -- Change to your preferred name + airbaseName = "Kandahar", -- Change to your BLUE airbase + aircraft = 18, -- Adjust aircraft count + skill = AI.Skill.EXCELLENT, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 18000, -- Patrol altitude (feet) + speed = 320, -- Patrol speed (knots) + patrolTime = 22, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "BLUE BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = true, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + + { + templateName = "Kandahar F-5E", -- Change to your BLUE template name + displayName = "Kandahar F-5E", -- Change to your preferred name + airbaseName = "Kandahar", -- Change to your BLUE airbase + aircraft = 18, -- Adjust aircraft count + skill = AI.Skill.EXCELLENT, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 18000, -- Patrol altitude (feet) + speed = 320, -- Patrol speed (knots) + patrolTime = 22, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "BLUE BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = true, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } + }, + { templateName = "Kandahar F-16", -- Change to your BLUE template name + displayName = "Kandahar F-16", -- Change to your preferred name + airbaseName = "Kandahar", -- Change to your BLUE airbase + aircraft = 18, -- Adjust aircraft count + skill = AI.Skill.EXCELLENT, -- AVERAGE, GOOD, HIGH, EXCELLENT + altitude = 18000, -- Patrol altitude (feet) + speed = 320, -- Patrol speed (knots) + patrolTime = 22, -- Time on station (minutes) + type = "FIGHTER", + + -- Zone-based Areas of Responsibility (optional - leave nil for global response) + primaryZone = "BLUE BORDER", -- Main responsibility area (zone name from mission editor) + secondaryZone = nil, -- Secondary coverage area (zone name) + tertiaryZone = nil, -- Emergency/fallback zone (zone name) + + -- Zone behavior settings (optional - uses defaults if not specified) + zoneConfig = { + primaryResponse = 1.0, -- Intercept ratio multiplier in primary zone + secondaryResponse = 0.6, -- Intercept ratio multiplier in secondary zone + tertiaryResponse = 1.4, -- Intercept ratio multiplier in tertiary zone + maxRange = 200, -- Maximum engagement range from airbase (nm) + enableFallback = true, -- Auto-switch to tertiary when base threatened + priorityThreshold = 4, -- Min aircraft count for "major threat" + ignoreLowPriority = false, -- Ignore threats below threshold in secondary zones + } +} + +