Moose_CTLD_Pure/Moose_Convoy.lua
2025-11-21 15:41:22 -06:00

1798 lines
68 KiB
Lua

-------------------------------------------------------------------
-- MOOSE CONVOY ESCORT SCRIPT
-------------------------------------------------------------------
-- A dynamic convoy system where convoys spawn, navigate to player-marked
-- destinations via roads, engage threats, and request air support.
--
-- SETUP:
-- 1. Create late-activated ground group templates listed in CONVOY_TEMPLATE_NAMES
-- (each spawn randomly picks one entry from the list; add/remove names to control variety)
-- 2. Players place F10 map marks containing the configured keywords:
-- • Spawn mark: keyword from CONVOY_SPAWN_KEYWORD near a valid CTLD pickup/FOB zone.
-- • Destination mark: keyword from CONVOY_DEST_KEYWORD.
-- - When PLAYER_CONTROLLED_DESTINATIONS = false, destination marks are rejected and convoys
-- auto-route to the static destinations defined below.
-- - When PLAYER_CONTROLLED_DESTINATIONS = true:
-- • First spawn mark creates a pending convoy for that coalition.
-- • The next destination mark pairs with that pending spawn (respecting MINIMUM_ROUTE_DISTANCE)
-- and launches the convoy.
-- • After a convoy is active, destination marks become redirects: specify the convoy name in
-- the mark text to target it directly; otherwise the closest active convoy of that coalition
-- is retasked.
-- 3. Each convoy locks to its assigned destination. If players drag waypoints in the F10 map the script
-- restores the original route and notifies the owning coalition.
-- 4. While en route the convoy halts on threat detection, requests CAS, and resumes only after threats clear.
-------------------------------------------------------------------
-------------------------------------------------------------------
-- CONFIGURATION SECTION
-------------------------------------------------------------------
-- Logging Configuration
LOGGING_ENABLED = true -- Enable/disable detailed logging
LOGGING_LEVEL = "DEBUG" -- "DEBUG", "INFO", "WARNING", "ERROR" (Set to DEBUG to troubleshoot zone issues)
STARTUP_TEST_REPORT = true -- Report validation results to players on startup
-- Convoy Template Names (must exist as late-activated groups in mission)
CONVOY_TEMPLATE_NAMES = {
"Convoy Template 1", -- Add your template group names here
"Convoy Template 2",
}
-- CTLD Integration (spawns convoys from CTLD pickup/FOB zones)
CTLD_INSTANCE_NAME_BLUE = "ctldBlue" -- Name of your Blue CTLD instance (e.g., "ctldBlue")
CTLD_INSTANCE_NAME_RED = "ctldRed" -- Name of your Red CTLD instance (e.g., "ctldRed")
USE_CTLD_ZONES = true -- If true, uses CTLD pickup/FOB zones as spawn points
SPAWN_ZONE_RADIUS = 500 -- Meters: mark must be within this distance of zone center to spawn
-- Destination Mode
PLAYER_CONTROLLED_DESTINATIONS = false -- If true, players mark destinations; if false, use static destinations below
-- Static Destinations (used when PLAYER_CONTROLLED_DESTINATIONS = false)
-- Define as coordinate tables: { name = "Display Name", lat = latitude, lon = longitude }
-- OR as zone names: { name = "Display Name", zone = "ZoneName" }
STATIC_DESTINATIONS = {
-- Examples:
-- { name = "Main Base", lat = 42.1234, lon = 43.5678 },
{ name = "Convoy Destination", zone = "Convoy Destination" },
}
-- Mark Keywords (case-insensitive, detects if keyword is in mark text)
CONVOY_SPAWN_KEYWORD = "convoy" -- Mark near pickup/FOB zone to spawn convoy
CONVOY_DEST_KEYWORD = "convoy destination" -- Mark to set convoy destination (if player-controlled)
-- Speed Settings (in km/h)
CONVOY_SPEED_ROAD = 60 -- Speed when traveling on roads
CONVOY_SPEED_OFFROAD = 30 -- Speed when traveling off-road
-- Update Intervals (in seconds)
PROGRESS_UPDATE_INTERVAL = 300 -- How often to announce progress (5 mins)
STUCK_ANNOUNCE_INTERVAL = 900 -- How often to announce stuck position (15 mins)
THREAT_CHECK_INTERVAL = 3 -- Faster sweeps so we tag threats before contact
-- Distance Thresholds (in meters)
DESTINATION_REACHED_DISTANCE = 100 -- Distance to consider destination reached
MINIMUM_ROUTE_DISTANCE = 5000 -- Minimum distance from spawn to destination (prevents exploits)
THREAT_DETECTION_RANGE = 10000 -- Detect armor ahead of effective weapon range
THREAT_CLEARED_RANGE = 11000 -- Give a little buffer before calling area safe
ROUTE_CHECK_INTERVAL = 3 -- Seconds between route integrity scans
ROUTE_DEVIATION_THRESHOLD = 750 -- Meters away from mission destination before re-routing
ROUTE_REISSUE_MIN_INTERVAL = 6 -- Seconds between automatic route reapplications
-- Smoke Settings
FRIENDLY_SMOKE_COLOR = SMOKECOLOR.Green -- Smoke color for convoy position
ENEMY_SMOKE_COLOR = SMOKECOLOR.Red -- Smoke color for enemy position
SMOKE_ON_CONTACT = true -- Pop smoke when contact is made
SMOKE_ON_CLEAR = false -- Pop smoke when threats cleared
-- Radio Settings
CONVOY_RADIO_FREQUENCY = 256.0 -- Radio frequency for convoy comms (MHz)
USE_RADIO_MESSAGES = true -- Use radio messages (vs just message to all)
-- Coalition Settings - Automatically detected from player placing the mark
-- Both coalitions can have convoys simultaneously
-------------------------------------------------------------------
-- END CONFIGURATION
-------------------------------------------------------------------
-------------------------------------------------------------------
-- GLOBAL VARIABLES
-------------------------------------------------------------------
MOOSE_CONVOY = {
Version = "1.0.0",
Convoys = {}, -- Active convoy tracking
ConvoyCounter = 0,
CTLDInstanceBlue = nil, -- Reference to Blue CTLD instance
CTLDInstanceRed = nil, -- Reference to Red CTLD instance
PickupZones = {}, -- Cached CTLD pickup zones (both coalitions)
FOBZones = {}, -- Cached CTLD FOB zones (both coalitions)
ValidationResults = {}, -- Stores startup validation results
}
-------------------------------------------------------------------
-- HELPER FUNCTIONS
-------------------------------------------------------------------
--- Logging function with level support
-- @param #string level Log level ("DEBUG", "INFO", "WARNING", "ERROR")
-- @param #string message Log message
function MOOSE_CONVOY:Log(level, message)
if not LOGGING_ENABLED then
return
end
local levels = { DEBUG = 1, INFO = 2, WARNING = 3, ERROR = 4 }
local currentLevel = levels[LOGGING_LEVEL] or 2
local msgLevel = levels[level] or 2
if msgLevel >= currentLevel then
local prefix = string.format("[MOOSE_CONVOY][%s]", level)
local fullMessage = string.format("%s %s", prefix, message)
if level == "ERROR" then
env.error(fullMessage)
elseif level == "WARNING" then
env.warning(fullMessage)
else
env.info(fullMessage)
end
end
end
--- Safely resolve the name of a UNIT (handles both MOOSE and raw DCS objects)
-- @param unit The unit object
-- @return #string|nil The unit name or nil if unavailable
function MOOSE_CONVOY:GetUnitName(unit)
if not unit then return nil end
if unit.GetName then
local ok, name = pcall(function() return unit:GetName() end)
if ok and name then return name end
end
if unit.getName then
local ok, name = pcall(function() return unit:getName() end)
if ok and name then return name end
end
return nil
end
--- Build a MOOSE coordinate from either a UNIT or raw vec3 provider
-- @param entity The unit or object with position information
-- @return #COORDINATE|nil Coordinate when resolved successfully
function MOOSE_CONVOY:GetUnitCoordinate(entity)
if not entity then return nil end
if entity.IsAlive and not entity:IsAlive() then
return nil
end
if entity.GetCoordinate then
local ok, coord = pcall(function() return entity:GetCoordinate() end)
if ok and coord then return coord end
end
if entity.getPoint then
local ok, vec3 = pcall(function() return entity:getPoint() end)
if ok and vec3 then
return COORDINATE:NewFromVec3(vec3)
end
end
return nil
end
--- Get CTLD zones for convoy spawning from both coalition instances
function MOOSE_CONVOY:GetCTLDZones()
if not USE_CTLD_ZONES then
self:Log("DEBUG", "CTLD zones disabled in configuration")
return {}, {}
end
-- Get CTLD instances
if not self.CTLDInstanceBlue and CTLD_INSTANCE_NAME_BLUE then
self.CTLDInstanceBlue = _G[CTLD_INSTANCE_NAME_BLUE]
self:Log("DEBUG", "Retrieved Blue CTLD instance: " .. tostring(CTLD_INSTANCE_NAME_BLUE) .. " = " .. tostring(self.CTLDInstanceBlue ~= nil))
end
if not self.CTLDInstanceRed and CTLD_INSTANCE_NAME_RED then
self.CTLDInstanceRed = _G[CTLD_INSTANCE_NAME_RED]
self:Log("DEBUG", "Retrieved Red CTLD instance: " .. tostring(CTLD_INSTANCE_NAME_RED) .. " = " .. tostring(self.CTLDInstanceRed ~= nil))
end
if not self.CTLDInstanceBlue and not self.CTLDInstanceRed then
self:Log("ERROR", "No CTLD instances found. Check CTLD_INSTANCE_NAME_BLUE and CTLD_INSTANCE_NAME_RED config.")
return {}, {}
end
local pickupZones = {}
local fobZones = {}
-- Process Blue CTLD instance
if self.CTLDInstanceBlue then
self:Log("DEBUG", "Processing Blue CTLD zones...")
local bluePickup, blueFOB = self:ProcessCTLDInstance(self.CTLDInstanceBlue, "BLUE")
for _, zone in ipairs(bluePickup) do table.insert(pickupZones, zone) end
for _, zone in ipairs(blueFOB) do table.insert(fobZones, zone) end
else
self:Log("WARNING", "Blue CTLD instance not found")
end
-- Process Red CTLD instance
if self.CTLDInstanceRed then
self:Log("DEBUG", "Processing Red CTLD zones...")
local redPickup, redFOB = self:ProcessCTLDInstance(self.CTLDInstanceRed, "RED")
for _, zone in ipairs(redPickup) do table.insert(pickupZones, zone) end
for _, zone in ipairs(redFOB) do table.insert(fobZones, zone) end
else
self:Log("WARNING", "Red CTLD instance not found")
end
self:Log("INFO", string.format("Zone retrieval complete: %d pickup, %d FOB zones found", #pickupZones, #fobZones))
return pickupZones, fobZones
end
--- Process a single CTLD instance to extract zones
-- @param #table ctldInstance The CTLD instance
-- @param #string coalitionName Coalition name for logging
-- @return #table pickupZones, #table fobZones
function MOOSE_CONVOY:ProcessCTLDInstance(ctldInstance, coalitionName)
local pickupZones = {}
local fobZones = {}
if not ctldInstance or not ctldInstance.Config then
self:Log("WARNING", coalitionName .. " CTLD instance has no Config")
return pickupZones, fobZones
end
self:Log("DEBUG", coalitionName .. " CTLD.Config.Zones exists: " .. tostring(ctldInstance.Config.Zones ~= nil))
if not ctldInstance.Config.Zones then
return pickupZones, fobZones
end
-- Get pickup zones
if ctldInstance.Config.Zones.PickupZones then
self:Log("DEBUG", coalitionName .. " PickupZones count: " .. #ctldInstance.Config.Zones.PickupZones)
for i, zoneDef in ipairs(ctldInstance.Config.Zones.PickupZones) do
self:Log("DEBUG", string.format("%s PickupZone[%d]: name=%s", coalitionName, i, tostring(zoneDef.name)))
if zoneDef.name then
local zone = ZONE:FindByName(zoneDef.name)
if zone then
table.insert(pickupZones, {
name = zoneDef.name,
zone = zone,
coord = zone:GetCoordinate(),
radius = zone:GetRadius(),
coalition = coalitionName,
})
self:Log("DEBUG", string.format("✓ %s Pickup zone '%s' loaded successfully", coalitionName, zoneDef.name))
else
self:Log("WARNING", string.format("✗ %s Pickup zone '%s' not found in mission", coalitionName, zoneDef.name))
end
end
end
else
self:Log("DEBUG", coalitionName .. " has no PickupZones defined")
end
-- Get FOB zones
if ctldInstance.Config.Zones.FOBZones then
self:Log("DEBUG", coalitionName .. " FOBZones count: " .. #ctldInstance.Config.Zones.FOBZones)
for i, zoneDef in ipairs(ctldInstance.Config.Zones.FOBZones) do
self:Log("DEBUG", string.format("%s FOBZone[%d]: name=%s", coalitionName, i, tostring(zoneDef.name)))
if zoneDef.name then
local zone = ZONE:FindByName(zoneDef.name)
if zone then
table.insert(fobZones, {
name = zoneDef.name,
zone = zone,
coord = zone:GetCoordinate(),
radius = zone:GetRadius(),
coalition = coalitionName,
})
self:Log("DEBUG", string.format("✓ %s FOB zone '%s' loaded successfully", coalitionName, zoneDef.name))
else
self:Log("WARNING", string.format("✗ %s FOB zone '%s' not found in mission", coalitionName, zoneDef.name))
end
end
end
else
self:Log("DEBUG", coalitionName .. " has no FOBZones defined")
end
return pickupZones, fobZones
end
--- Find nearest CTLD zone to a coordinate
-- @param #table coordinate MOOSE coordinate
-- @return #table|nil Zone data, #number distance
function MOOSE_CONVOY:FindNearestCTLDZone(coordinate)
local allZones = {}
-- Combine pickup and FOB zones
for _, z in ipairs(self.PickupZones) do
table.insert(allZones, z)
end
for _, z in ipairs(self.FOBZones) do
table.insert(allZones, z)
end
local nearestZone = nil
local nearestDistance = 999999999
for _, zoneData in ipairs(allZones) do
local dist = coordinate:Get2DDistance(zoneData.coord)
if dist < nearestDistance then
nearestDistance = dist
nearestZone = zoneData
end
end
return nearestZone, nearestDistance
end
--- Locate an active convoy that owns a unit by name
-- @param #string unitName The unit name to resolve
-- @return #table|nil Convoy instance when found
function MOOSE_CONVOY:GetConvoyByUnitName(unitName)
if not unitName then return nil end
for _, convoy in pairs(self.Convoys or {}) do
if convoy and convoy.UnitNames and convoy.UnitNames[unitName] then
return convoy
end
end
return nil
end
--- Determine current speed of the convoy in km/h (first alive unit sample)
-- @return #number Speed in km/h
function MOOSE_CONVOY:GetCurrentSpeedKmh()
if not self.Group then return 0 end
local units = self.Group:GetUnits()
if not units then return 0 end
for _, unitObj in pairs(units) do
if unitObj and unitObj:IsAlive() then
local vec3
local ok = pcall(function() vec3 = unitObj:GetVelocityVec3() end)
if ok and vec3 then
local speedMps = math.sqrt((vec3.x or 0)^2 + (vec3.y or 0)^2 + (vec3.z or 0)^2)
return speedMps * 3.6
end
end
end
return 0
end
--- Apply or reapply the route towards the current destination
-- @param #string reason Context for logging
-- @param #boolean force Ignore contact holds
-- @param #boolean bypassThrottle Ignore minimum interval checks
-- @return #boolean true when a route command was issued
function MOOSE_CONVOY:ApplyRouteToDestination(reason, force, bypassThrottle)
if not self.Group or not self.Group:IsAlive() or not self.DestPoint then
return false
end
if self.InContact and not force then
self.RouteNeedsRefresh = true
return false
end
local now = timer.getTime()
if not bypassThrottle then
local last = self.LastRouteReissue or 0
if last > 0 and (now - last) < ROUTE_REISSUE_MIN_INTERVAL then
return false
end
end
self.LastRouteReissue = now
self.RouteNeedsRefresh = false
if self.DestPoint.GetVec2 then
self.ExpectedRouteEndpoint = self.DestPoint:GetVec2()
else
self.ExpectedRouteEndpoint = nil
end
self.Group:RouteGroundOnRoad(self.DestPoint, CONVOY_SPEED_ROAD)
self:Log("INFO", string.format("%s route command issued (%s)", self.Name, reason or "ROUTE"))
return true
end
--- Retrieve the current DCS route endpoint for comparison
-- @return #table|nil Vec2 of last waypoint or nil if unavailable
function MOOSE_CONVOY:GetRouteEndpointVec2()
if not self.Group then return nil end
local dcsGroup = self.Group:GetDCSObject()
if not dcsGroup then return nil end
local controller = dcsGroup:getController()
if not controller then return nil end
local ok, task = pcall(function()
return controller:getTask()
end)
if not ok or not task or task.id ~= "Mission" then
return nil
end
local route = task.params and task.params.route and task.params.route.points
if not route or #route == 0 then
return nil
end
local lastPoint = route[#route]
if not lastPoint or not lastPoint.x or not lastPoint.y then
return nil
end
return { x = lastPoint.x, y = lastPoint.y }
end
--- Detect and correct manual route edits while convoy is in motion
function MOOSE_CONVOY:MonitorRouteIntegrity()
if not self.DestPoint or not self.Group or not self.Group:IsAlive() then
return
end
local now = timer.getTime()
local lastCheck = self.LastRouteCheck or 0
if lastCheck > 0 and (now - lastCheck) < ROUTE_CHECK_INTERVAL then
return
end
self.LastRouteCheck = now
local expected = self.ExpectedRouteEndpoint
if not expected and self.DestPoint and self.DestPoint.GetVec2 then
expected = self.DestPoint:GetVec2()
self.ExpectedRouteEndpoint = expected
end
if not expected then
return
end
local routeEnd = self:GetRouteEndpointVec2()
if not routeEnd then
self:Log("WARNING", string.format("%s route task missing - reapplying destination", self.Name))
if self:ApplyRouteToDestination("ROUTE_RESTORE", false, true) then
self:AnnounceRouteOverride()
end
return
end
local dx = (expected.x or 0) - (routeEnd.x or 0)
local dy = (expected.y or 0) - (routeEnd.y or 0)
local distance = math.sqrt(dx * dx + dy * dy)
if distance > ROUTE_DEVIATION_THRESHOLD then
self:Log("WARNING", string.format("%s route deviation detected (%.0fm)", self.Name, distance))
if self:ApplyRouteToDestination("ROUTE_CORRECTION", false, true) then
self:AnnounceRouteOverride(distance)
end
end
end
--- Notify players that we reclaimed the route (throttled)
-- @param #number deviationMeters Distance deviation detected
function MOOSE_CONVOY:AnnounceRouteOverride(deviationMeters)
local now = timer.getTime()
local last = self.LastRouteWarning or 0
if last > 0 and (now - last) < 20 then
return
end
self.LastRouteWarning = now
local deviationText = deviationMeters and string.format(" (override %.0fm)", deviationMeters) or ""
local messageText = string.format("%s - Mission route restored%s. Convoy continues to assigned destination.", self.Name, deviationText)
local coalitionID = self.Coalition
if coalitionID then
MESSAGE:New(messageText, 15):ToCoalition(coalitionID)
else
self:MessageToAll(messageText)
end
end
--- Update convoy destination based on a new coordinate
-- @param newCoordinate #COORDINATE Destination coordinate
-- @param params #table Optional parameters: label, coalition, reason
function MOOSE_CONVOY:RedirectTo(newCoordinate, params)
if not newCoordinate then
return false
end
self.DestPoint = newCoordinate
if newCoordinate.GetVec2 then
self.ExpectedRouteEndpoint = newCoordinate:GetVec2()
end
if params and params.label and params.label ~= "" then
self.DestinationName = params.label
end
local currentPos = self.Group and self.Group:GetCoordinate()
if currentPos then
self.InitialDistance = currentPos:Get2DDistance(newCoordinate)
end
self.LastProgressTime = timer.getTime()
self.RouteNeedsRefresh = true
local reason = (params and params.reason) or "PLAYER_REDIRECT"
self:Log("INFO", string.format("%s destination updated (%s)", self.Name, reason))
if not self.InContact then
self:ApplyRouteToDestination(reason, false, true)
end
local destLL = newCoordinate:ToStringLLDMS()
local labelText = params and params.label and params.label ~= "" and params.label or nil
local messageText
if labelText then
messageText = string.format("%s - Redirected to '%s'. Coordinates: %s", self.Name, labelText, destLL)
else
messageText = string.format("%s - Redirected to new player destination. Coordinates: %s", self.Name, destLL)
end
if self.InContact then
messageText = messageText .. " Holding until threats clear."
end
if params and params.notify ~= false then
if params and params.coalition then
MESSAGE:New(messageText, 15):ToCoalition(params.coalition)
else
self:MessageToAll(messageText)
end
end
return true
end
--- Force the convoy to remain stopped while in a hold, reissuing orders if needed
-- @param reason #string Context for logging/messaging
function MOOSE_CONVOY:EnforceHold(reason)
if not self.Group or not self.Group:IsAlive() then
return
end
self.Group:RouteStop()
local now = timer.getTime()
local last = self.LastHoldEnforce or 0
if last > 0 and (now - last) < 8 then
return -- keep messages from spamming players
end
self.LastHoldEnforce = now
local holdLabel = (self.ContactReason == "UNDER_FIRE") and "TAKING FIRE" or "ENEMY SPOTTED"
local distanceInfo = ""
if self.ContactPosition and self.EnemyPosition then
local dist = self.ContactPosition:Get2DDistance(self.EnemyPosition)
if dist then
distanceInfo = string.format(" Enemy %.1f km.", dist / 1000)
end
end
local msg
if reason == "MOVEMENT" then
msg = string.format("%s - HOLD ORDER ACTIVE (%s). Movement blocked until CAS clears the route.%s", self.Name, holdLabel, distanceInfo)
elseif reason == "INITIAL" then
-- Already announced contact; just ensure players know the stop is intentional without repeating the full call
msg = string.format("%s - Holding position (%s). Await CAS before resuming.%s", self.Name, holdLabel, distanceInfo)
else
msg = string.format("%s - Maintaining defensive hold (%s). Awaiting CAS.%s", self.Name, holdLabel, distanceInfo)
end
if msg then
self:MessageToAll(msg)
end
end
--- Ensure we are listening for combat events so convoys can react when hit
function MOOSE_CONVOY:SetupEventHandlers()
if self.EventHandler then
return
end
local handler = EVENTHANDLER:New()
handler:HandleEvent(EVENTS.Hit)
handler:HandleEvent(EVENTS.Dead)
function handler:OnEventHit(EventData)
MOOSE_CONVOY:HandleUnitHit(EventData)
end
function handler:OnEventDead(EventData)
MOOSE_CONVOY:HandleUnitDead(EventData)
end
self.EventHandler = handler
self:Log("INFO", "Combat event handler registered")
end
--- Translate a HIT event into an under-fire hold for the owning convoy
-- @param EventData MOOSE event payload
function MOOSE_CONVOY:HandleUnitHit(EventData)
if not EventData then return end
local targetUnit = EventData.TgtUnit or EventData.Target or EventData.TargetUnit
local unitName = self:GetUnitName(targetUnit) or EventData.TgtUnitName
if not unitName then return end
local convoy = self:GetConvoyByUnitName(unitName)
if not convoy then return end
local attacker = EventData.IniUnit or EventData.IniDCSUnit or EventData.WeaponOwner
convoy:OnUnderFire(attacker, targetUnit, EventData)
end
--- Translate a DEAD event into an under-fire hold and cleanup
-- @param EventData MOOSE event payload
function MOOSE_CONVOY:HandleUnitDead(EventData)
if not EventData then return end
local deadUnit = EventData.IniUnit or EventData.Target or EventData.TgtUnit
local unitName = self:GetUnitName(deadUnit) or EventData.IniUnitName
if not unitName then return end
local convoy = self:GetConvoyByUnitName(unitName)
if not convoy then return end
convoy.UnitNames[unitName] = nil
convoy:CacheUnitNames()
local attacker = EventData.TgtUnit or EventData.WeaponOwner
if attacker then
local attackerName = self:GetUnitName(attacker)
if attackerName and convoy.UnitNames and convoy.UnitNames[attackerName] then
attacker = nil
end
end
convoy:OnUnderFire(attacker, deadUnit, EventData)
end
-- CONVOY CLASS
--- Creates a new convoy object
-- @param #string templateName The template group name
-- @param #table spawnPoint Coordinate where convoy spawns
-- @param #table destPoint Coordinate where convoy goes
-- @param #number convoyCoalition The coalition of the convoy
-- @return #table Convoy object
function MOOSE_CONVOY:NewConvoy(templateName, spawnPoint, destPoint, convoyCoalition)
MOOSE_CONVOY.ConvoyCounter = MOOSE_CONVOY.ConvoyCounter + 1
local coalitionName = convoyCoalition == coalition.side.BLUE and "BLUE" or "RED"
local convoy = {
ID = MOOSE_CONVOY.ConvoyCounter,
Name = string.format("%s Convoy-%03d", coalitionName, MOOSE_CONVOY.ConvoyCounter),
TemplateName = templateName,
SpawnPoint = spawnPoint,
DestPoint = destPoint,
Coalition = convoyCoalition,
Group = nil,
Status = "SPAWNING",
LastProgressTime = 0,
LastStuckAnnounce = 0,
StartTime = timer.getTime(),
InitialDistance = 0,
InContact = false,
ContactPosition = nil,
EnemyPosition = nil,
SchedulerID = nil,
ContactReason = nil,
UnitNames = {},
LastUnderFireAlert = 0,
LastHoldEnforce = 0,
LastRouteReissue = 0,
LastRouteCheck = 0,
RouteNeedsRefresh = false,
ExpectedRouteEndpoint = nil,
LastRouteWarning = 0,
}
if destPoint and destPoint.GetVec2 then
convoy.ExpectedRouteEndpoint = destPoint:GetVec2()
end
setmetatable(convoy, self)
self.__index = self
return convoy
end
--- Spawn the convoy at the spawn point
function MOOSE_CONVOY:Spawn()
MOOSE_CONVOY:Log("INFO", string.format("Spawning convoy %s", self.Name))
-- Create spawn object
local spawn = SPAWN:New(self.TemplateName)
spawn:InitCoalition(self.Coalition)
spawn:OnSpawnGroup(
function(spawnedGroup)
self:OnSpawned(spawnedGroup)
end,
self
)
-- Spawn the group from the requested coordinate
self.Group = spawn:SpawnFromCoordinate(self.SpawnPoint)
if self.Group then
self.Status = "SPAWNING"
self:MessageToAll(string.format("%s spawned. Moving to staging area.", self.Name))
else
MOOSE_CONVOY:Log("ERROR", string.format("Failed to spawn convoy from template %s", self.TemplateName))
end
end
--- Refresh cached unit name lookups for this convoy
function MOOSE_CONVOY:CacheUnitNames()
self.UnitNames = {}
if not self.Group then return end
local units = self.Group:GetUnits()
if not units then return end
for _, unitObj in pairs(units) do
if unitObj:IsAlive() then
local uname = MOOSE_CONVOY:GetUnitName(unitObj)
if uname then
self.UnitNames[uname] = true
end
end
end
end
--- Called when convoy is spawned
function MOOSE_CONVOY:OnSpawned(group)
MOOSE_CONVOY:Log("INFO", string.format("Convoy %s spawned successfully", self.Name))
self.Group = group
self:CacheUnitNames()
-- Calculate initial distance
local currentPos = self.Group:GetCoordinate()
self.InitialDistance = currentPos:Get2DDistance(self.DestPoint)
-- Start the journey
self:StartJourney()
end
--- Start the convoy journey to destination
function MOOSE_CONVOY:StartJourney()
MOOSE_CONVOY:Log("INFO", string.format("%s starting journey - Distance: %.1f km", self.Name, self.InitialDistance / 1000))
self.Status = "MOVING"
self.LastProgressTime = timer.getTime()
-- Set convoy to move to destination on roads
local currentPos = self.Group:GetCoordinate()
-- Route to destination using roads
self:ApplyRouteToDestination("INITIAL_ROUTE", true, true)
self:MessageToAll(string.format("%s departing. Destination: %.1f km away. Proceeding via road network.",
self.Name,
self.InitialDistance / 1000))
-- Start monitoring scheduler
self:StartMonitoring()
-- Immediate threat check so we halt even if enemies are already inside the bubble
self:CheckForThreats()
end
--- Start monitoring the convoy status
function MOOSE_CONVOY:StartMonitoring()
MOOSE_CONVOY:Log("DEBUG", string.format("%s monitoring started - Check interval: %ds", self.Name, THREAT_CHECK_INTERVAL))
-- Schedule periodic checks
self.SchedulerID = SCHEDULER:New(nil,
function()
self:Update()
end,
{}, 1, THREAT_CHECK_INTERVAL
)
end
--- Update convoy status (called periodically)
function MOOSE_CONVOY:Update()
if not self.Group or not self.Group:IsAlive() then
self:OnDestroyed()
return
end
local currentTime = timer.getTime()
local currentPos = self.Group:GetCoordinate()
local distanceRemaining = currentPos:Get2DDistance(self.DestPoint)
-- Check if reached destination
if distanceRemaining < DESTINATION_REACHED_DISTANCE then
self:OnReachedDestination()
return
end
-- Check for threats
if self.Status == "MOVING" then
self:CheckForThreats()
elseif self.Status == "CONTACT" or self.Status == "STUCK" then
self:CheckThreatsCleared()
if self.InContact then
local speed = self:GetCurrentSpeedKmh()
if speed > 1.5 then
self:EnforceHold("MOVEMENT")
end
end
end
-- Progress updates
if self.Status == "MOVING" then
if currentTime - self.LastProgressTime > PROGRESS_UPDATE_INTERVAL then
self:AnnounceProgress()
self.LastProgressTime = currentTime
end
end
-- Stuck announcements
if self.Status == "STUCK" or self.Status == "CONTACT" then
if currentTime - self.LastStuckAnnounce > STUCK_ANNOUNCE_INTERVAL then
self:AnnounceStuck()
self.LastStuckAnnounce = currentTime
end
end
if self.Status == "MOVING" and not self.InContact then
self:MonitorRouteIntegrity()
elseif not self.InContact and self.RouteNeedsRefresh then
self:ApplyRouteToDestination("PENDING_RESTORE", false, true)
end
end
--- Check for nearby threats
function MOOSE_CONVOY:CheckForThreats()
MOOSE_CONVOY:Log("DEBUG", string.format("%s threat scan tick", self.Name))
local currentPos = self.Group:GetCoordinate()
local enemyCoalition = (self.Coalition == coalition.side.RED) and "blue" or "red"
local scanSet = SET_GROUP:New()
:FilterCoalitions(enemyCoalition)
:FilterCategoryGround()
:FilterActive(true)
:FilterOnce()
local threatsFound = false
local closestThreat = nil
local closestDistance = THREAT_DETECTION_RANGE
scanSet:ForEachGroup(function(group)
if group and group:IsAlive() then
local coord = group:GetCoordinate()
if coord then
local distance = currentPos:Get2DDistance(coord)
local groupName = group:GetName() or "<enemy group>"
MOOSE_CONVOY:Log("DEBUG", string.format("%s scanning group %s at %.0fm", self.Name, groupName, distance))
if distance < THREAT_DETECTION_RANGE then
threatsFound = true
if distance < closestDistance then
closestDistance = distance
closestThreat = group
end
end
end
end
end)
if threatsFound and closestThreat then
local enemyUnit = closestThreat:GetUnit(1) or closestThreat
if closestThreat.GetCoordinate then
self.EnemyPosition = closestThreat:GetCoordinate()
end
MOOSE_CONVOY:Log("WARNING", string.format("%s detected threat at %.0fm", self.Name, closestDistance))
self:OnContactWithEnemy(enemyUnit, "DETECTION")
else
MOOSE_CONVOY:Log("DEBUG", string.format("%s scan clear (no threats within %.1f km)", self.Name, THREAT_DETECTION_RANGE / 1000))
end
end
--- Called when convoy makes contact with enemy
-- @param enemyUnit The detected or attacking enemy unit (can be nil)
-- @param reason #string Reason for the hold ("DETECTION" vs "UNDER_FIRE")
function MOOSE_CONVOY:OnContactWithEnemy(enemyUnit, reason)
reason = reason or "DETECTION"
local isEscalation = self.InContact and self.ContactReason ~= "UNDER_FIRE" and reason == "UNDER_FIRE"
if self.InContact and not isEscalation then
return
end
local currentPos = self.Group and self.Group:GetCoordinate()
if not currentPos then
return
end
local enemyCoord = MOOSE_CONVOY:GetUnitCoordinate(enemyUnit) or self.EnemyPosition
if enemyCoord then
self.EnemyPosition = enemyCoord
end
if not isEscalation then
MOOSE_CONVOY:Log("WARNING", string.format("%s entering hold - reason: %s", self.Name, reason))
self.Status = "CONTACT"
self.InContact = true
self.ContactReason = reason
self.LastStuckAnnounce = timer.getTime()
self.ContactPosition = currentPos
self.LastHoldEnforce = 0
self.Group:RouteStop()
self.RouteNeedsRefresh = true
if SMOKE_ON_CONTACT then
currentPos:Smoke(FRIENDLY_SMOKE_COLOR)
if self.EnemyPosition then
self.EnemyPosition:Smoke(ENEMY_SMOKE_COLOR)
end
end
else
MOOSE_CONVOY:Log("WARNING", string.format("%s hold escalated to UNDER_FIRE", self.Name))
self.ContactReason = "UNDER_FIRE"
self.LastStuckAnnounce = timer.getTime()
self.LastHoldEnforce = 0
self.RouteNeedsRefresh = true
if SMOKE_ON_CONTACT and self.ContactPosition then
self.ContactPosition:Smoke(FRIENDLY_SMOKE_COLOR)
end
if SMOKE_ON_CONTACT and self.EnemyPosition then
self.EnemyPosition:Smoke(ENEMY_SMOKE_COLOR)
end
end
local contactCoord = self.ContactPosition or currentPos
local bearing
local distance
if contactCoord and self.EnemyPosition then
bearing = contactCoord:HeadingTo(self.EnemyPosition)
distance = contactCoord:Get2DDistance(self.EnemyPosition)
end
local holdLabel = (self.ContactReason == "UNDER_FIRE") and "HOLD STATUS: TAKING FIRE" or "HOLD STATUS: ENEMY SPOTTED"
local bearingText = bearing and string.format("%03d", bearing) or nil
local leadMessage
if self.ContactReason == "UNDER_FIRE" then
if bearing and distance then
leadMessage = string.format("%s - TAKING FIRE! Enemy %s degrees, distance %.1f km. Holding position and requesting immediate CAS!", self.Name, bearingText, distance / 1000)
else
leadMessage = string.format("%s - TAKING FIRE! Holding position and requesting immediate CAS!", self.Name)
end
else
if bearing and distance then
leadMessage = string.format("%s - Enemy spotted ahead %s degrees, distance %.1f km. Holding short and requesting CAS support.", self.Name, bearingText, distance / 1000)
else
leadMessage = string.format("%s - Enemy activity detected ahead. Holding short and requesting CAS support.", self.Name)
end
end
self:MessageToAll(leadMessage)
if self.EnemyPosition then
local enemyLL = self.EnemyPosition:ToStringLLDMS()
self:MessageToAll(string.format("%s - %s. Enemy position: %s. Smoke: Green on convoy, Red on enemy.", self.Name, holdLabel, enemyLL))
else
self:MessageToAll(string.format("%s - %s. Enemy position unknown, search for smoke markers.", self.Name, holdLabel))
end
end
--- Escalate the convoy into an under-fire hold based on combat events
-- @param attackerUnit Unit that fired on the convoy (may be nil)
-- @param friendlyUnit Unit that was hit or destroyed (may be nil)
-- @param EventData Original MOOSE event payload for reference
function MOOSE_CONVOY:OnUnderFire(attackerUnit, friendlyUnit, EventData)
if not self.Group or not self.Group:IsAlive() then
return
end
local now = timer.getTime()
if self.ContactReason == "UNDER_FIRE" and (now - (self.LastUnderFireAlert or 0)) < 10 then
return
end
self.LastUnderFireAlert = now
if friendlyUnit and (not self.ContactPosition or not self.InContact) then
local friendlyCoord = MOOSE_CONVOY:GetUnitCoordinate(friendlyUnit)
if friendlyCoord then
self.ContactPosition = friendlyCoord
end
end
local attacker = attackerUnit
local attackerName = MOOSE_CONVOY:GetUnitName(attacker)
if attackerName and self.UnitNames and self.UnitNames[attackerName] then
attacker = nil -- Attacker is one of ours; ignore to avoid bad bearing data
end
MOOSE_CONVOY:Log("WARNING", string.format("%s registering under-fire event", self.Name))
self:OnContactWithEnemy(attacker, "UNDER_FIRE")
end
--- Check if threats have been cleared
function MOOSE_CONVOY:CheckThreatsCleared()
MOOSE_CONVOY:Log("DEBUG", string.format("%s checking threat clearance", self.Name))
local currentPos = self.Group:GetCoordinate()
local enemyCoalition = (self.Coalition == coalition.side.RED) and "blue" or "red"
local scanSet = SET_GROUP:New()
:FilterCoalitions(enemyCoalition)
:FilterCategoryGround()
:FilterActive(true)
:FilterOnce()
local threatsRemain = false
scanSet:ForEachGroup(function(group)
if group and group:IsAlive() then
local coord = group:GetCoordinate()
if coord then
local distance = currentPos:Get2DDistance(coord)
local groupName = group:GetName() or "<enemy group>"
MOOSE_CONVOY:Log("DEBUG", string.format("%s clearance check sees %s at %.0fm", self.Name, groupName, distance))
if distance < THREAT_CLEARED_RANGE then
threatsRemain = true
end
end
end
end)
if not threatsRemain then
self:OnThreatsCleared()
else
MOOSE_CONVOY:Log("DEBUG", string.format("%s still has threats within %.1f km", self.Name, THREAT_CLEARED_RANGE / 1000))
end
end
--- Called when threats are cleared
function MOOSE_CONVOY:OnThreatsCleared()
if not self.InContact then
return
end
MOOSE_CONVOY:Log("INFO", string.format("%s threats cleared - resuming movement", self.Name))
self.Status = "MOVING"
self.InContact = false
self.ContactReason = nil
self.EnemyPosition = nil
self.ContactPosition = nil
self.LastUnderFireAlert = 0
self.LastHoldEnforce = 0
-- Pop smoke on current position
if SMOKE_ON_CLEAR then
local currentPos = self.Group:GetCoordinate()
currentPos:Smoke(FRIENDLY_SMOKE_COLOR)
end
self:MessageToAll(string.format(
"%s - Area clear! Threats eliminated. Resuming movement to destination. Thank you for the support!",
self.Name
))
-- Resume journey
self.LastProgressTime = timer.getTime()
local currentPos = self.Group:GetCoordinate()
self:ApplyRouteToDestination("THREATS_CLEARED", true, true)
end
--- Announce convoy is stuck and needs help
function MOOSE_CONVOY:AnnounceStuck()
local currentPos = self.Group:GetCoordinate()
local currentLL = currentPos:ToStringLLDMS()
local distanceRemaining = currentPos:Get2DDistance(self.DestPoint)
local followUp
if self.ContactReason == "UNDER_FIRE" then
followUp = string.format("%s - Still taking fire! Position: %s. Destination %.1f km away. Need CAS immediately!", self.Name, currentLL, distanceRemaining / 1000)
else
followUp = string.format("%s - Still holding for CAS. Position: %s. Destination %.1f km away. Awaiting air support to clear the route.", self.Name, currentLL, distanceRemaining / 1000)
end
self:MessageToAll(followUp)
-- Pop smoke again
currentPos:Smoke(FRIENDLY_SMOKE_COLOR)
end
--- Announce progress update
function MOOSE_CONVOY:AnnounceProgress()
local currentPos = self.Group:GetCoordinate()
local distanceRemaining = currentPos:Get2DDistance(self.DestPoint)
if distanceRemaining > self.InitialDistance then
-- Convoy may have to backtrack to hit the road network; update baseline so progress never goes negative
self.InitialDistance = distanceRemaining
end
local distanceTraveled = self.InitialDistance - distanceRemaining
local percentComplete = 0
if self.InitialDistance > 0 then
percentComplete = (distanceTraveled / self.InitialDistance) * 100
end
if percentComplete < 0 then percentComplete = 0 end
if percentComplete > 100 then percentComplete = 100 end
-- Calculate ETA (rough estimate based on average speed)
local timeElapsed = timer.getTime() - self.StartTime
local avgSpeed = 0
if timeElapsed > 0 then
avgSpeed = distanceTraveled / timeElapsed -- m/s
end
local eta = 0
if avgSpeed > 0 then
eta = distanceRemaining / avgSpeed / 60 -- minutes
end
self:MessageToAll(string.format(
"%s - Progress update: %.1f%% complete. Distance remaining: %.1f km. ETA: %d minutes.",
self.Name,
percentComplete,
distanceRemaining / 1000,
eta
))
end
--- Called when convoy reaches destination
function MOOSE_CONVOY:OnReachedDestination()
MOOSE_CONVOY:Log("INFO", string.format("%s reached destination successfully", self.Name))
self.Status = "COMPLETED"
local timeElapsed = timer.getTime() - self.StartTime
local minutes = math.floor(timeElapsed / 60)
self:MessageToAll(string.format(
"%s - Destination reached! Mission successful. Transit time: %d minutes. Thanks for the escort!",
self.Name,
minutes
))
-- Pop green smoke
local currentPos = self.Group:GetCoordinate()
currentPos:Smoke(FRIENDLY_SMOKE_COLOR)
-- Stop monitoring
if self.SchedulerID then
self.SchedulerID:Stop()
end
-- Remove from active convoys after delay
SCHEDULER:New(nil,
function()
MOOSE_CONVOY.Convoys[self.ID] = nil
if self.Group and self.Group:IsAlive() then
self.Group:Destroy()
end
end,
{}, 30
)
end
--- Called when convoy is destroyed
function MOOSE_CONVOY:OnDestroyed()
MOOSE_CONVOY:Log("WARNING", string.format("%s was destroyed", self.Name))
self.Status = "DESTROYED"
self:MessageToAll(string.format(
"%s - DESTROYED! Convoy has been eliminated. Mission failed.",
self.Name
))
-- Stop monitoring
if self.SchedulerID then
self.SchedulerID:Stop()
end
-- Remove from active convoys
MOOSE_CONVOY.Convoys[self.ID] = nil
end
--- Send message to all players
function MOOSE_CONVOY:MessageToAll(message)
MESSAGE:New(message, 15):ToAll()
MOOSE_CONVOY:Log("DEBUG", string.format("Message broadcast: %s", message))
end
-------------------------------------------------------------------
-- MARK POINT HANDLER
-------------------------------------------------------------------
--- Handle mark point events
function MOOSE_CONVOY:MarkHandler(EventData)
local markText = EventData.text or ""
local markPos = EventData.coordinate
local markCoalition = EventData.coalition -- Get coalition of player who placed mark
local markID = EventData.MarkID -- Get mark ID for deletion
-- Ignore empty marks or marks with only whitespace
if not markText or markText == "" or markText:match("^%s*$") then
return
end
local markTextLower = string.lower(markText)
-- Handle spectator/game master marks - default to Blue coalition
if not markCoalition or markCoalition == 0 then
markCoalition = coalition.side.BLUE
MOOSE_CONVOY:Log("DEBUG", "Mark placed by spectator/neutral - defaulting to Blue coalition")
end
-- Check if this mark contains our convoy keywords
local hasSpawnKeyword = string.find(markTextLower, string.lower(CONVOY_SPAWN_KEYWORD))
local hasDestKeyword = string.find(markTextLower, string.lower(CONVOY_DEST_KEYWORD))
-- Only process marks that contain our keywords
if not hasSpawnKeyword and not hasDestKeyword then
return -- Not a convoy-related mark, ignore silently
end
-- This is a convoy mark, log it
MOOSE_CONVOY:Log("DEBUG", string.format("Mark detected: '%s' (Coalition: %d, ID: %s)", markText, markCoalition, tostring(markID)))
-- Check for spawn keyword
if hasSpawnKeyword then
MOOSE_CONVOY:HandleSpawnMark(markPos, markText, markCoalition, markID)
end
-- Check for destination keyword
if hasDestKeyword then
MOOSE_CONVOY:HandleDestinationMark(markPos, markText, markCoalition, markID)
end
end
--- Handle convoy spawn mark
function MOOSE_CONVOY:HandleSpawnMark(coordinate, markText, markCoalition, markID)
MOOSE_CONVOY:Log("INFO", string.format("Processing spawn mark for coalition %d", markCoalition))
local coalitionName = markCoalition == coalition.side.BLUE and "Blue" or "Red"
-- If CTLD zones are required, check if mark is near a valid zone
if USE_CTLD_ZONES then
local nearestZone, distance = self:FindNearestCTLDZone(coordinate)
if not nearestZone or distance > SPAWN_ZONE_RADIUS then
local msg = string.format(
"Convoy spawn denied: Must mark within %d meters of a Supply or FOB zone. Nearest zone: %s (%.0fm away)",
SPAWN_ZONE_RADIUS,
nearestZone and nearestZone.name or "None",
distance or 0
)
MESSAGE:New(msg, 15):ToCoalition(markCoalition)
MOOSE_CONVOY:Log("WARNING", msg)
-- Delete the mark since it was processed (even if rejected)
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed invalid spawn mark ID: " .. tostring(markID))
end
return
end
-- Use zone center as spawn point
coordinate = nearestZone.coord
MOOSE_CONVOY:Log("INFO", string.format("Using zone '%s' as spawn point (distance: %.0fm)", nearestZone.name, distance))
end
-- Pick a random template
local templateName = CONVOY_TEMPLATE_NAMES[math.random(#CONVOY_TEMPLATE_NAMES)]
-- Store spawn point for pairing with destination (keyed by coalition)
if not MOOSE_CONVOY.PendingSpawns then
MOOSE_CONVOY.PendingSpawns = {}
end
MOOSE_CONVOY.PendingSpawns[markCoalition] = {
Template = templateName,
Position = coordinate,
Coalition = markCoalition,
SpawnMarkID = markID,
}
if PLAYER_CONTROLLED_DESTINATIONS then
MESSAGE:New(string.format("%s convoy spawn point recorded. Place a second mark with '%s' when ready.", coalitionName, CONVOY_DEST_KEYWORD), 10):ToCoalition(markCoalition)
else
-- Static destinations - auto-select or show menu
self:HandleStaticDestination(markCoalition)
end
end
--- Determine if a convoy is active and controllable
-- @param convoy #table Convoy object
-- @return #boolean
function MOOSE_CONVOY:IsConvoyActive(convoy)
if not convoy or convoy.Status == "DESTROYED" or convoy.Status == "COMPLETED" then
return false
end
if not convoy.Group or not convoy.Group:IsAlive() then
return false
end
return true
end
--- Locate a convoy to redirect based on mark data
-- @param coalitionID #number Coalition placing the mark
-- @param coordinate #COORDINATE Target coordinate
-- @param markText #string Mark text for optional matching
-- @return #table|nil Convoy selected for redirect
function MOOSE_CONVOY:FindConvoyForRedirection(coalitionID, coordinate, markText)
local markLower = markText and string.lower(markText) or ""
-- Exact name match takes priority
for _, convoy in pairs(self.Convoys or {}) do
if convoy and convoy.Coalition == coalitionID and self:IsConvoyActive(convoy) then
local convoyName = convoy.Name and string.lower(convoy.Name) or nil
if convoyName and markLower ~= "" and string.find(markLower, convoyName, 1, true) then
return convoy
end
end
end
-- Otherwise pick the convoy closest to the desired destination
local bestConvoy = nil
local bestDistance = 1e12
for _, convoy in pairs(self.Convoys or {}) do
if convoy and convoy.Coalition == coalitionID and self:IsConvoyActive(convoy) then
local convoyCoord = convoy.Group:GetCoordinate()
if convoyCoord then
local distance = coordinate:Get2DDistance(convoyCoord)
if distance < bestDistance then
bestDistance = distance
bestConvoy = convoy
end
end
end
end
return bestConvoy
end
--- Extract a human-readable label from the mark text for messaging
-- @param markText #string
-- @return #string|nil
function MOOSE_CONVOY:ExtractDestinationLabel(markText)
if not markText or markText == "" then
return nil
end
local text = markText
local keyword = CONVOY_DEST_KEYWORD or ""
if keyword ~= "" then
local lowerText = string.lower(text)
local lowerKeyword = string.lower(keyword)
local idx = lowerText:find(lowerKeyword, 1, true)
if idx then
local before = text:sub(1, idx - 1)
local after = text:sub(idx + #keyword)
text = before .. after
end
end
text = text:gsub("%s+", " ")
text = text:match("^%s*(.-)%s*$") or ""
if text == "" then
return nil
end
return text
end
--- Handle convoy destination mark
function MOOSE_CONVOY:HandleDestinationMark(coordinate, markText, markCoalition, markID)
MOOSE_CONVOY:Log("INFO", string.format("Processing destination mark for coalition %d", markCoalition))
if not PLAYER_CONTROLLED_DESTINATIONS then
MESSAGE:New("Convoy destinations are controlled by mission designer. Remove mark.", 10):ToCoalition(markCoalition)
-- Delete the mark
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed invalid destination mark ID: " .. tostring(markID))
end
return
end
if MOOSE_CONVOY.PendingSpawns and MOOSE_CONVOY.PendingSpawns[markCoalition] then
-- We have a spawn point for this coalition, validate and create the convoy
local pendingSpawn = MOOSE_CONVOY.PendingSpawns[markCoalition]
-- Check minimum distance
local distance = pendingSpawn.Position:Get2DDistance(coordinate)
if distance < MINIMUM_ROUTE_DISTANCE then
local msg = string.format(
"Convoy route too short: %.1f km. Minimum distance: %.1f km. Mark a destination further away.",
distance / 1000,
MINIMUM_ROUTE_DISTANCE / 1000
)
MESSAGE:New(msg, 15):ToCoalition(markCoalition)
MOOSE_CONVOY:Log("WARNING", msg)
-- Delete the mark even though it was rejected
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed invalid destination mark ID: " .. tostring(markID))
end
return
end
-- Delete the mark before creating convoy
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed destination mark ID: " .. tostring(markID))
end
if pendingSpawn.SpawnMarkID then
trigger.action.removeMark(pendingSpawn.SpawnMarkID)
MOOSE_CONVOY:Log("DEBUG", "Removed paired spawn mark ID: " .. tostring(pendingSpawn.SpawnMarkID))
end
self:CreateConvoy(pendingSpawn, coordinate, markCoalition)
else
local redirectConvoy = self:FindConvoyForRedirection(markCoalition, coordinate, markText)
if redirectConvoy then
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed redirect destination mark ID: " .. tostring(markID))
end
local label = self:ExtractDestinationLabel(markText)
redirectConvoy:RedirectTo(coordinate, {
label = label,
coalition = markCoalition,
reason = "PLAYER_MARK",
})
else
local coalitionName = markCoalition == coalition.side.BLUE and "Blue" or "Red"
MESSAGE:New(string.format("No active %s convoy available to redirect.", coalitionName), 10):ToCoalition(markCoalition)
if markID then
trigger.action.removeMark(markID)
MOOSE_CONVOY:Log("DEBUG", "Removed unused destination mark ID: " .. tostring(markID))
end
end
end
end
--- Handle static destination mode (mission designer controlled)
function MOOSE_CONVOY:HandleStaticDestination(markCoalition)
if not MOOSE_CONVOY.PendingSpawns or not MOOSE_CONVOY.PendingSpawns[markCoalition] then
return
end
local pendingSpawn = MOOSE_CONVOY.PendingSpawns[markCoalition]
-- If no static destinations defined, error
if #STATIC_DESTINATIONS == 0 then
MESSAGE:New("No static destinations configured. Contact mission designer.", 15):ToCoalition(markCoalition)
MOOSE_CONVOY.PendingSpawns[markCoalition] = nil
return
end
-- Pick a random destination
local destDef = STATIC_DESTINATIONS[math.random(#STATIC_DESTINATIONS)]
local destCoord = nil
if destDef.zone then
-- Zone-based destination
local zone = ZONE:FindByName(destDef.zone)
if zone then
destCoord = zone:GetCoordinate()
else
MOOSE_CONVOY:Log("ERROR", string.format("Static destination zone '%s' not found", destDef.zone))
end
elseif destDef.lat and destDef.lon then
-- Coordinate-based destination (defaults altitude to ground level when omitted)
destCoord = COORDINATE:NewFromLLDD(destDef.lat, destDef.lon, destDef.alt or 0)
end
if not destCoord then
MESSAGE:New("Invalid destination configuration. Contact mission designer.", 15):ToCoalition(markCoalition)
MOOSE_CONVOY.PendingSpawns[markCoalition] = nil
return
end
-- Validate minimum distance
local distance = pendingSpawn.Position:Get2DDistance(destCoord)
if distance < MINIMUM_ROUTE_DISTANCE then
MESSAGE:New(string.format("Selected destination too close (%.1f km). Trying another...", distance / 1000), 10):ToCoalition(markCoalition)
-- Could implement retry logic here
MOOSE_CONVOY.PendingSpawns[markCoalition] = nil
return
end
-- Create convoy with static destination
if pendingSpawn.SpawnMarkID then
trigger.action.removeMark(pendingSpawn.SpawnMarkID)
MOOSE_CONVOY:Log("DEBUG", "Removed spawn mark ID after static destination assignment: " .. tostring(pendingSpawn.SpawnMarkID))
end
self:CreateConvoy(pendingSpawn, destCoord, markCoalition, destDef.name)
end
--- Create and spawn a convoy
function MOOSE_CONVOY:CreateConvoy(pendingSpawn, destination, markCoalition, destName)
local convoy = MOOSE_CONVOY:NewConvoy(
pendingSpawn.Template,
pendingSpawn.Position,
destination,
pendingSpawn.Coalition
)
convoy.DestinationName = destName -- Optional name for static destinations
local distance = pendingSpawn.Position:Get2DDistance(destination)
MOOSE_CONVOY:Log("INFO", string.format("Creating convoy %s - Template: %s, Distance: %.1f km",
convoy.Name, pendingSpawn.Template, distance / 1000))
MOOSE_CONVOY.Convoys[convoy.ID] = convoy
convoy:Spawn()
-- Clear pending spawn for this coalition
MOOSE_CONVOY.PendingSpawns[markCoalition] = nil
end
-------------------------------------------------------------------
-- INITIALIZATION
-------------------------------------------------------------------
--- Validate configuration and templates
-- @return #boolean success True if all validations pass
function MOOSE_CONVOY:ValidateConfiguration()
self:Log("INFO", "Starting configuration validation...")
local allPassed = true
local results = {}
-- Test 1: Validate convoy templates
self:Log("INFO", "Test 1: Validating convoy templates...")
local templateTest = { name = "Convoy Templates", passed = true, details = {} }
if #CONVOY_TEMPLATE_NAMES == 0 then
templateTest.passed = false
templateTest.details[#templateTest.details + 1] = "ERROR: No convoy templates configured"
self:Log("ERROR", "No convoy templates defined in CONVOY_TEMPLATE_NAMES")
allPassed = false
else
for _, templateName in ipairs(CONVOY_TEMPLATE_NAMES) do
local templateGroup = GROUP:FindByName(templateName)
if not templateGroup then
templateTest.passed = false
templateTest.details[#templateTest.details + 1] = string.format("ERROR: Template '%s' not found", templateName)
self:Log("ERROR", string.format("Template group '%s' not found in mission", templateName))
allPassed = false
else
templateTest.details[#templateTest.details + 1] = string.format("OK: Template '%s' found", templateName)
self:Log("INFO", string.format("Template '%s' validated successfully", templateName))
end
end
end
table.insert(results, templateTest)
-- Test 2: Validate CTLD integration (if enabled)
if USE_CTLD_ZONES then
self:Log("INFO", "Test 2: Validating CTLD integration...")
local ctldTest = { name = "CTLD Integration", passed = true, details = {} }
self.PickupZones, self.FOBZones = self:GetCTLDZones()
local totalZones = #self.PickupZones + #self.FOBZones
if totalZones == 0 then
ctldTest.passed = false
ctldTest.details[#ctldTest.details + 1] = "WARNING: No CTLD zones found"
self:Log("WARNING", "No CTLD zones found - check CTLD_INSTANCE_NAME or disable USE_CTLD_ZONES")
allPassed = false
else
ctldTest.details[#ctldTest.details + 1] = string.format("OK: Loaded %d pickup zones, %d FOB zones", #self.PickupZones, #self.FOBZones)
self:Log("INFO", string.format("CTLD zones loaded: %d pickup, %d FOB", #self.PickupZones, #self.FOBZones))
end
table.insert(results, ctldTest)
else
self:Log("INFO", "Test 2: CTLD integration disabled")
end
-- Test 3: Validate destination configuration
self:Log("INFO", "Test 3: Validating destination configuration...")
local destTest = { name = "Destination Config", passed = true, details = {} }
if PLAYER_CONTROLLED_DESTINATIONS then
destTest.details[#destTest.details + 1] = "OK: Player-controlled destinations enabled"
self:Log("INFO", "Destinations: Player-controlled mode active")
else
if #STATIC_DESTINATIONS == 0 then
destTest.passed = false
destTest.details[#destTest.details + 1] = "ERROR: No static destinations defined"
self:Log("ERROR", "PLAYER_CONTROLLED_DESTINATIONS is false but STATIC_DESTINATIONS is empty")
allPassed = false
else
destTest.details[#destTest.details + 1] = string.format("OK: %d static destinations configured", #STATIC_DESTINATIONS)
self:Log("INFO", string.format("Static destinations: %d configured", #STATIC_DESTINATIONS))
end
end
table.insert(results, destTest)
-- Test 4: Validate configuration parameters
self:Log("INFO", "Test 4: Validating configuration parameters...")
local configTest = { name = "Config Parameters", passed = true, details = {} }
if CONVOY_SPEED_ROAD <= 0 or CONVOY_SPEED_ROAD > 150 then
configTest.passed = false
configTest.details[#configTest.details + 1] = string.format("WARNING: CONVOY_SPEED_ROAD (%d) outside normal range", CONVOY_SPEED_ROAD)
self:Log("WARNING", "CONVOY_SPEED_ROAD value seems unusual")
end
if DESTINATION_REACHED_DISTANCE < 50 or DESTINATION_REACHED_DISTANCE > 500 then
configTest.details[#configTest.details + 1] = string.format("INFO: DESTINATION_REACHED_DISTANCE (%d) may be too small/large", DESTINATION_REACHED_DISTANCE)
self:Log("INFO", "DESTINATION_REACHED_DISTANCE value noted")
end
if #configTest.details == 0 then
configTest.details[#configTest.details + 1] = "OK: All parameters within acceptable ranges"
end
self:Log("INFO", "Configuration parameters validated")
table.insert(results, configTest)
-- Test 5: Validate mark keywords
self:Log("INFO", "Test 5: Validating mark keywords...")
local keywordTest = { name = "Mark Keywords", passed = true, details = {} }
if not CONVOY_SPAWN_KEYWORD or CONVOY_SPAWN_KEYWORD == "" then
keywordTest.passed = false
keywordTest.details[#keywordTest.details + 1] = "ERROR: CONVOY_SPAWN_KEYWORD not defined"
self:Log("ERROR", "CONVOY_SPAWN_KEYWORD is empty or nil")
allPassed = false
else
keywordTest.details[#keywordTest.details + 1] = string.format("OK: Spawn keyword: '%s'", CONVOY_SPAWN_KEYWORD)
self:Log("INFO", "Spawn keyword validated")
end
if PLAYER_CONTROLLED_DESTINATIONS and (not CONVOY_DEST_KEYWORD or CONVOY_DEST_KEYWORD == "") then
keywordTest.passed = false
keywordTest.details[#keywordTest.details + 1] = "ERROR: CONVOY_DEST_KEYWORD not defined"
self:Log("ERROR", "CONVOY_DEST_KEYWORD is empty or nil")
allPassed = false
elseif PLAYER_CONTROLLED_DESTINATIONS then
keywordTest.details[#keywordTest.details + 1] = string.format("OK: Destination keyword: '%s'", CONVOY_DEST_KEYWORD)
self:Log("INFO", "Destination keyword validated")
end
table.insert(results, keywordTest)
self.ValidationResults = results
self:Log("INFO", string.format("Configuration validation complete. Overall result: %s", allPassed and "PASSED" or "FAILED"))
return allPassed, results
end
--- Report validation results to players
function MOOSE_CONVOY:ReportValidationResults(allPassed, results)
if not STARTUP_TEST_REPORT then
return
end
local report = {}
report[#report + 1] = "═══════════════════════════════════════"
report[#report + 1] = "MOOSE CONVOY SYSTEM v" .. self.Version
report[#report + 1] = "STARTUP VALIDATION REPORT"
report[#report + 1] = "═══════════════════════════════════════"
report[#report + 1] = ""
for _, test in ipairs(results) do
local status = test.passed and "✓ PASSED" or "✗ FAILED"
report[#report + 1] = string.format("%s: %s", test.name, status)
for _, detail in ipairs(test.details) do
report[#report + 1] = " " .. detail
end
report[#report + 1] = ""
end
report[#report + 1] = "═══════════════════════════════════════"
if allPassed then
report[#report + 1] = "✓ ALL TESTS PASSED"
report[#report + 1] = "System ready for operations."
report[#report + 1] = "Place marks to spawn convoys."
else
report[#report + 1] = "✗ SOME TESTS FAILED"
report[#report + 1] = "Check configuration and fix errors."
end
report[#report + 1] = "═══════════════════════════════════════"
local reportText = table.concat(report, "\n")
MESSAGE:New(reportText, 30):ToAll()
self:Log("INFO", "Validation report displayed to players")
end
--- Initialize the convoy system
function MOOSE_CONVOY:Initialize()
self:Log("INFO", "Initializing convoy escort system v" .. MOOSE_CONVOY.Version)
-- Validate configuration
local allPassed, results = self:ValidateConfiguration()
-- Report results to players
self:ReportValidationResults(allPassed, results)
-- Ensure combat events are wired before convoys spawn
self:SetupEventHandlers()
-- Set up mark event handler using world.event
self:Log("INFO", "Setting up mark event handler...")
local ConvoyMarkHandler = {}
function ConvoyMarkHandler:onEvent(event)
MOOSE_CONVOY.PendingMarks = MOOSE_CONVOY.PendingMarks or {}
if event.id == world.event.S_EVENT_MARK_REMOVED then
MOOSE_CONVOY.PendingMarks[event.idx] = nil
if MOOSE_CONVOY.PendingSpawns then
for coalitionID, pending in pairs(MOOSE_CONVOY.PendingSpawns) do
if pending and pending.SpawnMarkID == event.idx then
MOOSE_CONVOY:Log("DEBUG", string.format("Spawn mark ID %s removed before pairing - clearing pending spawn for coalition %s", tostring(event.idx), tostring(coalitionID)))
MOOSE_CONVOY.PendingSpawns[coalitionID] = nil
end
end
end
return
end
if event.id ~= world.event.S_EVENT_MARK_ADDED and event.id ~= world.event.S_EVENT_MARK_CHANGE then
return
end
local markText = event.text or ""
if markText == "" or markText:match("^%s*$") then
if event.pos then
MOOSE_CONVOY.PendingMarks[event.idx] = event.pos
end
MOOSE_CONVOY:Log("DEBUG", string.format("Mark event %s received without text yet (ID: %s) - waiting for update", tostring(event.id), tostring(event.idx)))
return
end
local markVec3 = event.pos or MOOSE_CONVOY.PendingMarks[event.idx]
if not markVec3 then
MOOSE_CONVOY:Log("WARNING", string.format("Mark event %s missing position data (ID: %s) - cannot process", tostring(event.id), tostring(event.idx)))
return
end
env.info("[MOOSE_CONVOY] RAW MARK EVENT DETECTED!")
env.info(string.format("[MOOSE_CONVOY] Mark text: '%s', Coalition: %s, initiator: %s",
tostring(event.text),
tostring(event.coalition),
tostring(event.initiator)))
-- Convert to MOOSE-style EventData
local EventData = {
text = event.text,
coalition = event.coalition,
MarkID = event.idx,
coordinate = COORDINATE:NewFromVec3(markVec3)
}
MOOSE_CONVOY.PendingMarks[event.idx] = nil
MOOSE_CONVOY:MarkHandler(EventData)
end
world.addEventHandler(ConvoyMarkHandler)
self:Log("INFO", "Mark event handler registered successfully")
MESSAGE:New("CONVOY SYSTEM: Mark handler active. Place marks with 'convoy' to test.", 15):ToAll()
if allPassed then
self:Log("INFO", "Initialization complete - all systems operational")
else
self:Log("WARNING", "Initialization complete with warnings/errors - check configuration")
end
end
-------------------------------------------------------------------
-- START THE SYSTEM
-------------------------------------------------------------------
-- Initialize when mission starts
MOOSE_CONVOY:Initialize()
MOOSE_CONVOY:Log("INFO", "Moose_Convoy.lua script loaded successfully")