DCS_MissionDev/Moose_TADC/Moose_TADC_CargoDispatcher.lua
iTracerFacer 4b18037c32 Automatically detect when bases are captured
Stop dispatching cargo to captured bases
Stop using captured squadrons for intercepts
Display clear status information to players showing which bases are operational vs captured
Treat base capture as normal mission state, not an error condition
2025-10-20 08:21:48 -05:00

853 lines
41 KiB
Lua

--[[
═══════════════════════════════════════════════════════════════════════════════
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
═══════════════════════════════════════════════════════════════════════════════
]]
--[[
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 = { "Sochi-Adler", "Gudauta", "Sukhumi-Babushara", "Nalchik", "Beslan", "Maykop-Khanskaya" }, -- 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 = { "Batumi", "Kobuleti", "Senaki-Kolkhi", "Kutaisi", "Soganlug" }, -- 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" then
if not (m.group and m.group:IsAlive()) then
log("Cleaning up failed cargo mission: " .. (m.group and m.group:GetName() or "nil group") .. " status: failed")
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:SetSpawnLimit(1)
rat:SetSpawnDelay(1)
-- Ensure RAT takes off immediately from the runway (hot start) instead of staying parked
if rat.SetTakeoffHot then rat:SetTakeoffHot() 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