--[[ ═══════════════════════════════════════════════════════════════════════════════ 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 = { "Afrikanda", "Kalevala", "Poduzhemye", "Severomorsk-1", "Severomorsk-3", "Murmansk International", "Kilpyavr", "Olenya", "Monchegorsk" }, -- 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 = { "Banak", "Kittila", "Alta", "Sodankyla", "Enontekio", "Kirkenes", "Ivalo", "Luostari Pechenga", "Koshka Yavr" }, -- 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