mirror of
https://github.com/iTracerFacer/DCS_MissionDev.git
synced 2025-12-03 04:14:46 +00:00
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
853 lines
41 KiB
Lua
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
|
|
|