diff --git a/Moose_Convoy.lua b/Moose_Convoy.lua new file mode 100644 index 0000000..36c932f --- /dev/null +++ b/Moose_Convoy.lua @@ -0,0 +1,1797 @@ +------------------------------------------------------------------- +-- 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")