------------------------------------------------------------------- -- 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 "" 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 "" 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")