diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.8.miz b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.8.miz new file mode 100644 index 0000000..7da6a78 Binary files /dev/null and b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.8.miz differ diff --git a/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.9.miz b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.9.miz new file mode 100644 index 0000000..daaae0e Binary files /dev/null and b/DCS_Afgainistan/Insurgent_Sandstorm/F99th-Insurgent_Sandstorm_2.0.9.miz differ diff --git a/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.5.miz b/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.5.miz new file mode 100644 index 0000000..5161f98 Binary files /dev/null and b/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.5.miz differ diff --git a/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.6.miz b/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.6.miz new file mode 100644 index 0000000..46341f1 Binary files /dev/null and b/DCS_Kola/Operation_Polar_Shield/F99th-Operation_Polar_Shield_1.3.6.miz differ diff --git a/DCS_Normandy/Normandie_-_The_Big_MAP_14_Juin_1944.miz b/DCS_Normandy/Normandie_-_The_Big_MAP_14_Juin_1944.miz new file mode 100644 index 0000000..b02db64 Binary files /dev/null and b/DCS_Normandy/Normandie_-_The_Big_MAP_14_Juin_1944.miz differ diff --git a/DCS_Normandy/Operation Escort/F99th-Operation Escort.miz b/DCS_Normandy/Operation Escort/F99th-Operation Escort.miz new file mode 100644 index 0000000..61ac361 Binary files /dev/null and b/DCS_Normandy/Operation Escort/F99th-Operation Escort.miz differ diff --git a/DCS_Normandy/Operation Escort/Moose_DualCoalitionZoneCapture.lua b/DCS_Normandy/Operation Escort/Moose_DualCoalitionZoneCapture.lua new file mode 100644 index 0000000..3b3c739 --- /dev/null +++ b/DCS_Normandy/Operation Escort/Moose_DualCoalitionZoneCapture.lua @@ -0,0 +1,1143 @@ +-- Refactored version with configurable zone ownership + +-- ========================================== +-- MESSAGE AND TIMING CONFIGURATION +-- ========================================== +local MESSAGE_CONFIG = { + STATUS_BROADCAST_FREQUENCY = 3602, -- Zone status broadcast cadence (seconds) + STATUS_BROADCAST_START_DELAY = 10, -- Delay before first broadcast (seconds) + COLOR_VERIFICATION_FREQUENCY = 240, -- Zone color verification cadence (seconds) + COLOR_VERIFICATION_START_DELAY = 60, -- Delay before first color check (seconds) + TACTICAL_UPDATE_FREQUENCY = 180, -- Tactical marker update cadence (seconds) + TACTICAL_UPDATE_START_DELAY = 30, -- Delay before first tactical update (seconds) + STATUS_MESSAGE_DURATION = 15, -- How long general status messages stay onscreen + VICTORY_MESSAGE_DURATION = 300, -- How long victory/defeat alerts stay onscreen + CAPTURE_MESSAGE_DURATION = 15, -- Duration for capture/guard/empty notices + ATTACK_MESSAGE_DURATION = 15 -- Duration for attack alerts +} + +-- ========================================== +-- ZONE COLOR CONFIGURATION (Centralized) +-- ========================================== +-- Colors are in RGB format: {Red, Green, Blue} where each value is 0.0 to 1.0 +local ZONE_COLORS = { + -- Blue coalition zones + BLUE_CAPTURED = {0, 0, 1}, -- Blue (owned by Blue) + BLUE_ATTACKED = {0, 1, 1}, -- Cyan (owned by Blue, under attack) + + -- Red coalition zones + RED_CAPTURED = {1, 0, 0}, -- Red (owned by Red) + RED_ATTACKED = {1, 0.5, 0}, -- Orange (owned by Red, under attack) + + -- Neutral/Empty zones + EMPTY = {0, 1, 0} -- Green (no owner) +} + +-- Helper to get the appropriate color for a zone based on state/ownership +local function GetZoneColor(zoneCapture) + local zoneCoalition = zoneCapture:GetCoalition() + local state = zoneCapture:GetCurrentState() + + -- Priority 1: Attacked overrides ownership color + if state == "Attacked" then + if zoneCoalition == coalition.side.BLUE then + return ZONE_COLORS.BLUE_ATTACKED + elseif zoneCoalition == coalition.side.RED then + return ZONE_COLORS.RED_ATTACKED + end + end + + -- Priority 2: Empty/neutral + if state == "Empty" then + return ZONE_COLORS.EMPTY + end + + -- Priority 3: Ownership color + if zoneCoalition == coalition.side.BLUE then + return ZONE_COLORS.BLUE_CAPTURED + elseif zoneCoalition == coalition.side.RED then + return ZONE_COLORS.RED_CAPTURED + end + + -- Fallback + return ZONE_COLORS.EMPTY +end + +-- ========================================== +-- ZONE CONFIGURATION +-- ========================================== +-- Mission makers: Edit this table to define zones and their initial ownership +-- Just list the zone names under RED, BLUE, or NEUTRAL coalition +-- The script will automatically create and configure all zones +-- Make sure the zone names match exactly with those defined in the mission editor +-- Zones must be defined in the mission editor as trigger zones named "Capture " +-- Note: Red/Blue/Neutral zones defined below are only setting their initial ownership state. +-- If there are existing units in the zone at mission start, ownership may change based on unit presence. + + +local ZONE_CONFIG = { + -- Zones that start under RED coalition control + -- IMPORTANT: Use the EXACT zone names from the mission editor (including "Capture " prefix if present) + RED = { + "Capture Zone-1", + "Capture Zone-2", + "Capture Zone-3", + + -- Add more zone names here for RED starting zones + }, + + -- Zones that start under BLUE coalition control + BLUE = { + "Capture Zone-4", + "Capture Zone-5", + "Capture Zone-6", + }, + + -- Zones that start neutral (empty/uncontrolled) + NEUTRAL = { + + } +} + +-- Advanced settings (usually don't need to change these) +local ZONE_SETTINGS = { + guardDelay = 1, -- Delay before entering Guard state after capture + scanInterval = 30, -- How often to scan for units in the zone (seconds) + captureScore = 200 -- Points awarded for capturing a zone +} + +-- ========================================== +-- END OF CONFIGURATION +-- ========================================== + +-- Build Command Center and Mission for Blue Coalition +local blueHQ = GROUP:FindByName("BLUEHQ") +if blueHQ then + US_CC = COMMANDCENTER:New(blueHQ, "USA HQ") + US_Mission = MISSION:New(US_CC, "Zone Capture Example Mission", "Primary", "", coalition.side.BLUE) + US_Score = SCORING:New("Zone Capture Example Mission") + --US_Mission:AddScoring(US_Score) + --US_Mission:Start() + env.info("Blue Coalition Command Center and Mission started successfully") +else + env.info("ERROR: BLUEHQ group not found! Blue mission will not start.") +end + +--Build Command Center and Mission Red +local redHQ = GROUP:FindByName("REDHQ") +if redHQ then + RU_CC = COMMANDCENTER:New(redHQ, "Russia HQ") + RU_Mission = MISSION:New(RU_CC, "Zone Capture Example Mission", "Primary", "Hold what we have, take what we don't.", coalition.side.RED) + --RU_Score = SCORING:New("Zone Capture Example Mission") + --RU_Mission:AddScoring(RU_Score) + RU_Mission:Start() + env.info("Red Coalition Command Center and Mission started successfully") +else + env.info("ERROR: REDHQ group not found! Red mission will not start.") +end + + +-- Setup BLUE Missions +do -- BLUE Mission + + US_Mission_Capture_Airfields = MISSION:New( US_CC, "Capture the Zones", "Primary", + "Capture the Zones marked on your F10 map.\n" .. + "Destroy enemy ground forces in the surrounding area, " .. + "then occupy each capture zone with a platoon.\n " .. + "Your orders are to hold position until all capture zones are taken.\n" .. + "Use the map (F10) for a clear indication of the location of each capture zone.\n" .. + "Note that heavy resistance can be expected at the airbases!\n" + , coalition.side.BLUE) + + --US_Score = SCORING:New( "Capture Airfields" ) + + --US_Mission_Capture_Airfields:AddScoring( US_Score ) + + US_Mission_Capture_Airfields:Start() + +end + +-- Setup RED Missions +do -- RED Mission + + RU_Mission_Capture_Airfields = MISSION:New( RU_CC, "Defend the Motherland", "Primary", + "Defend Russian airfields and recapture lost territory.\n" .. + "Eliminate enemy forces in capture zones and " .. + "maintain control with ground units.\n" .. + "Your orders are to prevent the enemy from capturing all strategic zones.\n" .. + "Use the map (F10) for a clear indication of the location of each capture zone.\n" .. + "Expect heavy NATO resistance!\n" + , coalition.side.RED) + + --RU_Score = SCORING:New( "Defend Territory" ) + + --RU_Mission_Capture_Airfields:AddScoring( RU_Score ) + + RU_Mission_Capture_Airfields:Start() + +end + + +-- Logging configuration: toggle logging behavior for this module +-- Set `CAPTURE_ZONE_LOGGING.enabled = false` to silence module logs +if not CAPTURE_ZONE_LOGGING then + CAPTURE_ZONE_LOGGING = { enabled = false, prefix = "[CAPTURE Module]" } +end + +local function log(message, detailed) + if CAPTURE_ZONE_LOGGING.enabled then + -- Preserve the previous prefixing used across the module + if CAPTURE_ZONE_LOGGING.prefix then + env.info(tostring(CAPTURE_ZONE_LOGGING.prefix) .. " " .. tostring(message)) + else + env.info(tostring(message)) + end + end +end + + +-- ========================================== +-- ZONE INITIALIZATION SYSTEM +-- ========================================== + +-- Storage for all zone capture objects and metadata +-- NOTE: These are exported as globals for plugin compatibility (e.g., Moose_DynamicGroundBattle_Plugin.lua) +zoneCaptureObjects = {} -- Global: accessible by other scripts +zoneNames = {} -- Global: accessible by other scripts +local zoneMetadata = {} -- Stores coalition ownership info + +-- Function to initialize all zones from configuration +local function InitializeZones() + log("[INIT] Starting zone initialization from configuration...") + + local totalZones = 0 + + -- Process each coalition's zones + for coalitionName, zones in pairs(ZONE_CONFIG) do + local coalitionSide = nil + + -- Map coalition name to DCS coalition constant + if coalitionName == "RED" then + coalitionSide = coalition.side.RED + elseif coalitionName == "BLUE" then + coalitionSide = coalition.side.BLUE + elseif coalitionName == "NEUTRAL" then + coalitionSide = coalition.side.NEUTRAL + else + log(string.format("[INIT] WARNING: Unknown coalition '%s' in ZONE_CONFIG", coalitionName)) + end + + if coalitionSide then + for _, zoneName in ipairs(zones) do + log(string.format("[INIT] Creating zone: %s (Coalition: %s)", zoneName, coalitionName)) + + -- Create the MOOSE zone object (using exact name from config) + local zone = ZONE:New(zoneName) + + if zone then + -- Create the zone capture coalition object + local zoneCapture = ZONE_CAPTURE_COALITION:New(zone, coalitionSide) + + if zoneCapture then + -- Configure the zone + zoneCapture:__Guard(ZONE_SETTINGS.guardDelay) + zoneCapture:Start(ZONE_SETTINGS.scanInterval, ZONE_SETTINGS.scanInterval) + + -- Store in our data structures + table.insert(zoneCaptureObjects, zoneCapture) + table.insert(zoneNames, zoneName) + zoneMetadata[zoneName] = { + coalition = coalitionSide, + index = #zoneCaptureObjects + } + + totalZones = totalZones + 1 + log(string.format("[INIT] ✓ Zone '%s' initialized successfully", zoneName)) + else + log(string.format("[INIT] ✗ ERROR: Failed to create ZONE_CAPTURE_COALITION for '%s'", zoneName)) + end + else + log(string.format("[INIT] ✗ ERROR: Zone '%s' not found in mission editor!", zoneName)) + log(string.format("[INIT] Make sure you have a trigger zone named exactly: '%s'", zoneName)) + end + end + end + end + + log(string.format("[INIT] Zone initialization complete. Total zones created: %d", totalZones)) + return totalZones +end + +-- Initialize all zones +local totalZones = InitializeZones() + + +-- Global cached unit set - created once and maintained automatically by MOOSE +local CachedUnitSet = nil + +-- Utility guard to safely test whether a unit is inside a zone without throwing +local function IsUnitInZone(unit, zone) + if not unit or not zone then + return false + end + + local ok, point = pcall(function() + return unit:GetPointVec3() + end) + + if not ok or not point then + return false + end + + local inZone = false + pcall(function() + inZone = zone:IsPointVec3InZone(point) + end) + + return inZone +end + +-- Initialize the cached unit set once +local function InitializeCachedUnitSet() + if not CachedUnitSet then + CachedUnitSet = SET_UNIT:New() + :FilterCategories({"ground", "plane", "helicopter"}) -- Only scan relevant unit types + :FilterStart() -- Keep the set updated by MOOSE without recreating it + log("[PERFORMANCE] Initialized cached unit set for zone scanning") + end +end + +local function GetZoneForceStrengths(ZoneCapture) + if not ZoneCapture then + return { red = 0, blue = 0, neutral = 0 } + end + + local success, zone = pcall(function() + return ZoneCapture:GetZone() + end) + + if not success or not zone then + return { red = 0, blue = 0, neutral = 0 } + end + + local redCount = 0 + local blueCount = 0 + local neutralCount = 0 + + InitializeCachedUnitSet() + + if CachedUnitSet then + CachedUnitSet:ForEachUnit(function(unit) + if unit and unit:IsAlive() and IsUnitInZone(unit, zone) then + local unitCoalition = unit:GetCoalition() + if unitCoalition == coalition.side.RED then + redCount = redCount + 1 + elseif unitCoalition == coalition.side.BLUE then + blueCount = blueCount + 1 + elseif unitCoalition == coalition.side.NEUTRAL then + neutralCount = neutralCount + 1 + end + end + end) + end + + log(string.format("[TACTICAL] Zone %s scan result: R:%d B:%d N:%d", + ZoneCapture:GetZoneName(), redCount, blueCount, neutralCount)) + + return { + red = redCount, + blue = blueCount, + neutral = neutralCount + } +end + +local function GetEnemyUnitMGRSCoords(ZoneCapture, enemyCoalition) + if not ZoneCapture or not enemyCoalition then + return {} + end + + local success, zone = pcall(function() + return ZoneCapture:GetZone() + end) + + if not success or not zone then + return {} + end + + InitializeCachedUnitSet() + + local coords = {} + local totalUnits = 0 + local enemyUnits = 0 + local unitsWithCoords = 0 + + if CachedUnitSet then + CachedUnitSet:ForEachUnit(function(unit) + if unit and unit:IsAlive() and IsUnitInZone(unit, zone) then + totalUnits = totalUnits + 1 + local unitCoalition = unit:GetCoalition() + + if unitCoalition == enemyCoalition then + enemyUnits = enemyUnits + 1 + local coord = unit:GetCoordinate() + + if coord then + local mgrs = nil + local success_mgrs = false + + success_mgrs, mgrs = pcall(function() + return coord:ToStringMGRS(5) + end) + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + return coord:ToStringMGRS() + end) + end + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + return coord:ToMGRS() + end) + end + + if not success_mgrs or not mgrs then + success_mgrs, mgrs = pcall(function() + local lat, lon = coord:GetLLDDM() + return string.format("N%s E%s", lat, lon) + end) + end + + if success_mgrs and mgrs then + unitsWithCoords = unitsWithCoords + 1 + local unitType = unit:GetTypeName() or "Unknown" + table.insert(coords, { + name = unit:GetName(), + type = unitType, + mgrs = mgrs + }) + else + log(string.format("[TACTICAL DEBUG] All coordinate methods failed for unit %s", unit:GetName() or "unknown")) + end + else + log(string.format("[TACTICAL DEBUG] No coordinate for unit %s", unit:GetName() or "unknown")) + end + end + end + end) + end + + log(string.format("[TACTICAL DEBUG] %s - Total units scanned: %d, Enemy units: %d, units with MGRS: %d", + ZoneCapture:GetZoneName(), totalUnits, enemyUnits, unitsWithCoords)) + + log(string.format("[TACTICAL] Found %d enemy units with coordinates in %s", + #coords, ZoneCapture:GetZoneName())) + + return coords +end + +local function CreateTacticalInfoMarker(ZoneCapture) + -- Validate ZoneCapture + if not ZoneCapture then + log("[TACTICAL ERROR] ZoneCapture object is nil") + return + end + + -- Safely get the zone with error handling + local ok, zone = pcall(function() return ZoneCapture:GetZone() end) + if not ok or not zone then + log("[TACTICAL ERROR] Failed to get zone from ZoneCapture object") + return + end + + local forces = GetZoneForceStrengths(ZoneCapture) + local zoneName = ZoneCapture:GetZoneName() + + -- Build coalition-specific tactical info text + local function buildTacticalText(viewerCoalition) + local text = string.format("TACTICAL: %s\nForces: R:%d B:%d", zoneName, forces.red, forces.blue) + if forces.neutral and forces.neutral > 0 then + text = text .. string.format(" C:%d", forces.neutral) + end + + -- Append TGTS for the enemy of the viewer, capped at 10 units + local enemyCoalition = (viewerCoalition == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + local enemyCount = (enemyCoalition == coalition.side.RED) and (forces.red or 0) or (forces.blue or 0) + if enemyCount > 0 and enemyCount <= 10 then + local enemyCoords = GetEnemyUnitMGRSCoords(ZoneCapture, enemyCoalition) + log(string.format("[TACTICAL DEBUG] Building marker text for %s viewer: %d enemy units", (viewerCoalition==coalition.side.BLUE and "BLUE" or "RED"), #enemyCoords)) + if #enemyCoords > 0 then + text = text .. "\nTGTS:" + for i, unit in ipairs(enemyCoords) do + if i <= 10 then + local shortType = (unit.type or "Unknown"):gsub("^%w+%-", ""):gsub("%s.*", "") + local cleanMgrs = (unit.mgrs or ""):gsub("^MGRS%s+", ""):gsub("%s+", " ") + if i == 1 then + text = text .. string.format(" %s@%s", shortType, cleanMgrs) + else + text = text .. string.format(", %s@%s", shortType, cleanMgrs) + end + end + end + if #enemyCoords > 10 then + text = text .. string.format(" (+%d)", #enemyCoords - 10) + end + end + end + + return text + end + + local tacticalTextBLUE = buildTacticalText(coalition.side.BLUE) + local tacticalTextRED = buildTacticalText(coalition.side.RED) + + -- Debug: Log what will be displayed + log(string.format("[TACTICAL DEBUG] Marker text (BLUE) for %s:\n%s", zoneName, tacticalTextBLUE)) + log(string.format("[TACTICAL DEBUG] Marker text (RED) for %s:\n%s", zoneName, tacticalTextRED)) + + -- Create tactical marker offset from zone center + local coord = zone:GetCoordinate() + if coord then + local offsetCoord = coord:Translate(200, 45) -- 200m NE + + local function removeMarker(markerID) + if not markerID then + return + end + + local removed = pcall(function() + offsetCoord:RemoveMark(markerID) + end) + + if not removed then + removed = pcall(function() + trigger.action.removeMark(markerID) + end) + end + + if not removed then + pcall(function() + coord:RemoveMark(markerID) + end) + end + end + + -- Remove legacy single marker if present + if ZoneCapture.TacticalMarkerID then + log(string.format("[TACTICAL] Removing old marker ID %d for %s", ZoneCapture.TacticalMarkerID, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID) + ZoneCapture.TacticalMarkerID = nil + end + + -- BLUE Coalition Marker + if ZoneCapture.TacticalMarkerID_BLUE then + log(string.format("[TACTICAL] Removing old BLUE marker ID %d for %s", ZoneCapture.TacticalMarkerID_BLUE, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID_BLUE) + ZoneCapture.TacticalMarkerID_BLUE = nil + end + local successBlue, markerIDBlue = pcall(function() + return offsetCoord:MarkToCoalition(tacticalTextBLUE, coalition.side.BLUE) + end) + if successBlue and markerIDBlue then + ZoneCapture.TacticalMarkerID_BLUE = markerIDBlue + pcall(function() offsetCoord:SetMarkReadOnly(markerIDBlue, true) end) + log(string.format("[TACTICAL] Created BLUE marker for %s", zoneName)) + else + log(string.format("[TACTICAL] Failed to create BLUE marker for %s", zoneName)) + end + + -- RED Coalition Marker + if ZoneCapture.TacticalMarkerID_RED then + log(string.format("[TACTICAL] Removing old RED marker ID %d for %s", ZoneCapture.TacticalMarkerID_RED, zoneName)) + removeMarker(ZoneCapture.TacticalMarkerID_RED) + ZoneCapture.TacticalMarkerID_RED = nil + end + local successRed, markerIDRed = pcall(function() + return offsetCoord:MarkToCoalition(tacticalTextRED, coalition.side.RED) + end) + if successRed and markerIDRed then + ZoneCapture.TacticalMarkerID_RED = markerIDRed + pcall(function() offsetCoord:SetMarkReadOnly(markerIDRed, true) end) + log(string.format("[TACTICAL] Created RED marker for %s", zoneName)) + else + log(string.format("[TACTICAL] Failed to create RED marker for %s", zoneName)) + end + end +end + +-- Event handler functions - define them separately for each zone +local function OnEnterGuarded(ZoneCapture, From, Event, To) + if From ~= To then + local Coalition = ZoneCapture:GetCoalition() + if Coalition == coalition.side.BLUE then + ZoneCapture:Smoke( SMOKECOLOR.Blue ) + -- Update zone visual markers to BLUE + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.BLUE_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is under protection of the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + else + ZoneCapture:Smoke( SMOKECOLOR.Red ) + -- Update zone visual markers to RED + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.RED_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "%s is under protection of Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + end + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) + end +end + +local function OnEnterEmpty(ZoneCapture) + ZoneCapture:Smoke( SMOKECOLOR.Green ) + -- Update zone visual markers to GREEN (neutral) + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.EMPTY + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) +end + +local function OnEnterAttacked(ZoneCapture) + ZoneCapture:Smoke( SMOKECOLOR.White ) + -- Update zone visual markers based on owner (attacked state) + ZoneCapture:UndrawZone() + local Coalition = ZoneCapture:GetCoalition() + local color + if Coalition == coalition.side.BLUE then + color = ZONE_COLORS.BLUE_ATTACKED + US_CC:MessageTypeToCoalition( string.format( "%s is under attack by Russia", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + else + color = ZONE_COLORS.RED_ATTACKED + RU_CC:MessageTypeToCoalition( string.format( "%s is under attack by the USA", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + end + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) +end + +-- Victory condition monitoring for BOTH coalitions +local function CheckVictoryCondition() + local blueZonesCount = 0 + local redZonesCount = 0 + local totalZones = #zoneCaptureObjects + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + if zoneCoalition == coalition.side.BLUE then + blueZonesCount = blueZonesCount + 1 + elseif zoneCoalition == coalition.side.RED then + redZonesCount = redZonesCount + 1 + end + end + end + + log(string.format("[VICTORY CHECK] Blue owns %d/%d zones, Red owns %d/%d zones", + blueZonesCount, totalZones, redZonesCount, totalZones)) + + -- Check for BLUE victory + if blueZonesCount >= totalZones then + log("[VICTORY] All zones captured by BLUE! Triggering victory sequence...") + + US_CC:MessageTypeToCoalition( + "VICTORY! All capture zones have been secured by coalition forces!\n\n" .. + "Operation Polar Shield is complete. Outstanding work!\n" .. + "Mission will end in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + RU_CC:MessageTypeToCoalition( + "DEFEAT! All strategic positions have been lost to coalition forces.\n\n" .. + "Operation Polar Shield has failed. Mission ending in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + -- Add victory celebration effects + for _, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + zoneCapture:Smoke( SMOKECOLOR.Blue ) + local zone = zoneCapture:GetZone() + if zone then + zone:FlareZone( FLARECOLOR.Blue, 90, 60 ) + end + end + end + + SCHEDULER:New( nil, function() + log("[VICTORY] Ending mission due to complete zone capture by BLUE") + trigger.action.setUserFlag("BLUE_VICTORY", 1) + + US_CC:MessageTypeToCoalition( + string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), + MESSAGE.Type.Information, 10 + ) + end, {}, 60 ) + + return true + end + + -- Check for RED victory + if redZonesCount >= totalZones then + log("[VICTORY] All zones captured by RED! Triggering victory sequence...") + + RU_CC:MessageTypeToCoalition( + "VICTORY! All strategic positions secured for the Motherland!\n\n" .. + "NATO forces have been repelled. Outstanding work!\n" .. + "Mission will end in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + US_CC:MessageTypeToCoalition( + "DEFEAT! All capture zones have been lost to Russian forces.\n\n" .. + "Operation Polar Shield has failed. Mission ending in 60 seconds.", + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + -- Add victory celebration effects + for _, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + zoneCapture:Smoke( SMOKECOLOR.Red ) + local zone = zoneCapture:GetZone() + if zone then + zone:FlareZone( FLARECOLOR.Red, 90, 60 ) + end + end + end + + SCHEDULER:New( nil, function() + log("[VICTORY] Ending mission due to complete zone capture by RED") + trigger.action.setUserFlag("RED_VICTORY", 1) + + RU_CC:MessageTypeToCoalition( + string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), + MESSAGE.Type.Information, 10 + ) + end, {}, 60 ) + + return true + end + + return false -- Victory not yet achieved by either side +end + +local function OnEnterCaptured(ZoneCapture) + local Coalition = ZoneCapture:GetCoalition() + if Coalition == coalition.side.BLUE then + -- Update zone visual markers to BLUE for captured + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.BLUE_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + RU_CC:MessageTypeToCoalition( string.format( "%s is captured by the USA, we lost it!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + US_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + else + -- Update zone visual markers to RED for captured + ZoneCapture:UndrawZone() + local color = ZONE_COLORS.RED_CAPTURED + ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) + US_CC:MessageTypeToCoalition( string.format( "%s is captured by Russia, we lost it!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + RU_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + end + + ZoneCapture:AddScore( "Captured", "Zone captured: Extra points granted.", ZONE_SETTINGS.captureScore ) + ZoneCapture:__Guard( 30 ) + + -- Create/update tactical information marker + CreateTacticalInfoMarker(ZoneCapture) + + -- Check victory condition after any zone capture + CheckVictoryCondition() +end + +-- Set up event handlers for each zone with proper MOOSE methods and debugging +for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneName = zoneNames[i] or ("Zone " .. i) + + -- Proper MOOSE event handlers for ZONE_CAPTURE_COALITION + zoneCapture.OnEnterGuarded = OnEnterGuarded + zoneCapture.OnEnterEmpty = OnEnterEmpty + zoneCapture.OnEnterAttacked = OnEnterAttacked + zoneCapture.OnEnterCaptured = OnEnterCaptured + + -- Debug: Check if the underlying zone exists + local success, zone = pcall(function() return zoneCapture:GetZone() end) + if success and zone then + log("✓ Zone '" .. zoneName .. "' successfully created and linked") + + -- Get initial coalition color for this zone + local initialCoalition = zoneCapture:GetCoalition() + local colorRGB = ZONE_COLORS.EMPTY + + if initialCoalition == coalition.side.RED then + colorRGB = ZONE_COLORS.RED_CAPTURED + elseif initialCoalition == coalition.side.BLUE then + colorRGB = ZONE_COLORS.BLUE_CAPTURED + end + + -- Initialize zone borders with appropriate initial color + local drawSuccess, drawError = pcall(function() + zone:DrawZone(-1, {0, 0, 0}, 1, colorRGB, 0.2, 2, true) + end) + + if not drawSuccess then + log("⚠ Zone 'Capture " .. zoneName .. "' border drawing failed: " .. tostring(drawError)) + -- Alternative: Try simpler zone marking + pcall(function() + if initialCoalition == coalition.side.RED then + zone:SmokeZone(SMOKECOLOR.Red, 30) + elseif initialCoalition == coalition.side.BLUE then + zone:SmokeZone(SMOKECOLOR.Blue, 30) + else + zone:SmokeZone(SMOKECOLOR.Green, 30) + end + end) + else + local coalitionName = "NEUTRAL" + if initialCoalition == coalition.side.RED then + coalitionName = "RED" + elseif initialCoalition == coalition.side.BLUE then + coalitionName = "BLUE" + end + log("✓ Zone '" .. zoneName .. "' border drawn successfully with " .. coalitionName .. " initial color") + end + else + log("✗ ERROR: Zone '" .. zoneName .. "' not found in mission editor!") + log(" Make sure you have a trigger zone named exactly: '" .. zoneName .. "'") + end + else + log("✗ ERROR: Zone capture object " .. i .. " (" .. (zoneNames[i] or "Unknown") .. ") is nil!") + end +end + +-- ========================================== +-- VICTORY MONITORING SYSTEM +-- ========================================== + +-- Function to get current zone ownership status +local function GetZoneOwnershipStatus() + local status = { + blue = 0, + red = 0, + neutral = 0, + total = #zoneCaptureObjects, + zones = {} + } + + -- Explicitly reference the global coalition table to avoid parameter shadowing + local coalitionTable = _G.coalition or coalition + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + + -- Get the current state of the zone + local currentState = zoneCapture:GetCurrentState() + local stateString = "" + + -- Determine status based on coalition and state + if zoneCoalition == coalitionTable.side.BLUE then + status.blue = status.blue + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "BLUE (Under Attack)" + else + status.zones[zoneName] = "BLUE" + end + elseif zoneCoalition == coalitionTable.side.RED then + status.red = status.red + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "RED (Under Attack)" + else + status.zones[zoneName] = "RED" + end + else + status.neutral = status.neutral + 1 + if currentState == "Attacked" then + status.zones[zoneName] = "NEUTRAL (Under Attack)" + else + status.zones[zoneName] = "NEUTRAL" + end + end + end + end + + return status +end + +-- Function to broadcast zone status report to BOTH coalitions +local function BroadcastZoneStatus() + local status = GetZoneOwnershipStatus() + + -- Build coalition-neutral report + local reportMessage = string.format( + "ZONE CONTROL REPORT:\n" .. + "Blue Coalition: %d/%d zones\n" .. + "Red Coalition: %d/%d zones\n" .. + "Neutral: %d/%d zones", + status.blue, status.total, + status.red, status.total, + status.neutral, status.total + ) + + -- Add detailed zone status + local detailMessage = "\nZONE DETAILS:\n" + for zoneName, owner in pairs(status.zones) do + detailMessage = detailMessage .. string.format("• %s: %s\n", zoneName, owner) + end + + local fullMessage = reportMessage .. detailMessage + + -- Broadcast to BOTH coalitions with their specific victory progress + local totalZones = math.max(status.total, 1) + local blueProgressPercent = math.floor((status.blue / totalZones) * 100) + local blueFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", blueProgressPercent) + US_CC:MessageTypeToCoalition( blueFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) + + local redProgressPercent = math.floor((status.red / totalZones) * 100) + local redFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", redProgressPercent) + RU_CC:MessageTypeToCoalition( redFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) + + log("[ZONE STATUS] " .. reportMessage:gsub("\n", " | ")) + + return status +end + +-- Periodic zone monitoring (every 5 minutes) for BOTH coalitions +local ZoneMonitorScheduler = SCHEDULER:New( nil, function() + local status = BroadcastZoneStatus() + + -- Check if BLUE is close to victory (80% or more zones captured) + if status.blue >= math.floor(status.total * 0.8) and status.blue < status.total then + US_CC:MessageTypeToCoalition( + string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", + status.total - status.blue), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + RU_CC:MessageTypeToCoalition( + string.format("CRITICAL SITUATION! Coalition forces control %d/%d zones! We must recapture territory!", + status.blue, status.total), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + end + + -- Check if RED is close to victory (80% or more zones captured) + if status.red >= math.floor(status.total * 0.8) and status.red < status.total then + RU_CC:MessageTypeToCoalition( + string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", + status.total - status.red), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + + US_CC:MessageTypeToCoalition( + string.format("CRITICAL SITUATION! Russian forces control %d/%d zones! We must recapture territory!", + status.red, status.total), + MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION + ) + end + +end, {}, MESSAGE_CONFIG.STATUS_BROADCAST_START_DELAY, MESSAGE_CONFIG.STATUS_BROADCAST_FREQUENCY ) + +-- Periodic zone color verification system (every 2 minutes) +local ZoneColorVerificationScheduler = SCHEDULER:New( nil, function() + log("[ZONE COLORS] Running periodic zone color verification...") + + -- Verify each zone's visual marker matches its CURRENT STATE (not just coalition) + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + local currentState = zoneCapture:GetCurrentState() + + local zoneColor = GetZoneColor(zoneCapture) + + -- Force redraw the zone with correct color based on CURRENT STATE + zoneCapture:UndrawZone() + zoneCapture:DrawZone(-1, {0, 0, 0}, 1, zoneColor, 0.2, 2, true) + + -- Log the color assignment for debugging + local colorName = "UNKNOWN" + if currentState == "Attacked" then + colorName = (zoneCoalition == coalition.side.BLUE) and "LIGHT BLUE (Blue Attacked)" or "ORANGE (Red Attacked)" + elseif currentState == "Empty" then + colorName = "GREEN (Empty)" + elseif zoneCoalition == coalition.side.BLUE then + colorName = "BLUE (Owned)" + elseif zoneCoalition == coalition.side.RED then + colorName = "RED (Owned)" + else + colorName = "GREEN (Fallback)" + end + log(string.format("[ZONE COLORS] %s: Set to %s", zoneName, colorName)) + end + end + +end, {}, MESSAGE_CONFIG.COLOR_VERIFICATION_START_DELAY, MESSAGE_CONFIG.COLOR_VERIFICATION_FREQUENCY ) + +-- Periodic tactical marker update system with change detection +local __lastForceCountsByZone = {} +local TacticalMarkerUpdateScheduler = SCHEDULER:New( nil, function() + log("[TACTICAL] Running periodic tactical marker update (change-detected)...") + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneName = zoneCapture.GetZoneName and zoneCapture:GetZoneName() or (zoneNames[i] or ("Zone " .. i)) + local counts = GetZoneForceStrengths(zoneCapture) + local last = __lastForceCountsByZone[zoneName] + local changed = (not last) + or (last.red ~= counts.red) + or (last.blue ~= counts.blue) + or (last.neutral ~= counts.neutral) + + if changed then + __lastForceCountsByZone[zoneName] = { + red = counts.red, + blue = counts.blue, + neutral = counts.neutral + } + CreateTacticalInfoMarker(zoneCapture) + end + end + end + +end, {}, MESSAGE_CONFIG.TACTICAL_UPDATE_START_DELAY, MESSAGE_CONFIG.TACTICAL_UPDATE_FREQUENCY ) + +-- Function to refresh all zone colors based on current ownership +local function RefreshAllZoneColors() + log("[ZONE COLORS] Refreshing all zone visual markers...") + + for i, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture then + local zoneCoalition = zoneCapture:GetCoalition() + local zoneName = zoneNames[i] or ("Zone " .. i) + local currentState = zoneCapture:GetCurrentState() + + -- Get color for current state/ownership + local zoneColor = GetZoneColor(zoneCapture) + + -- Clear existing drawings + zoneCapture:UndrawZone() + + -- Redraw with correct color + zoneCapture:DrawZone(-1, {0, 0, 0}, 1, zoneColor, 0.2, 2, true) + + -- Log the color assignment for debugging + local colorName = "UNKNOWN" + if currentState == "Attacked" then + colorName = (zoneCoalition == coalition.side.BLUE) and "LIGHT BLUE (Blue Attacked)" or "ORANGE (Red Attacked)" + elseif currentState == "Empty" then + colorName = "GREEN (Empty)" + elseif zoneCoalition == coalition.side.BLUE then + colorName = "BLUE (Owned)" + elseif zoneCoalition == coalition.side.RED then + colorName = "RED (Owned)" + else + colorName = "GREEN (Fallback)" + end + log(string.format("[ZONE COLORS] %s: Set to %s", zoneName, colorName)) + end + end + + -- Notify BOTH coalitions + US_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) + RU_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) +end + +-- Manual zone status commands for players (F10 radio menu) - BOTH COALITIONS +local function SetupZoneStatusCommands() + -- Add F10 radio menu commands for BLUE coalition + if US_CC then + -- Use MenuManager to create zone control menu under Mission Options + local USMenu = MenuManager and MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "Zone Control") + or MENU_COALITION:New( coalition.side.BLUE, "Zone Control" ) + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Get Zone Status Report", USMenu, BroadcastZoneStatus ) + + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Check Victory Progress", USMenu, function() + local status = GetZoneOwnershipStatus() + local totalZones = math.max(status.total, 1) + local progressPercent = math.floor((status.blue / totalZones) * 100) + + US_CC:MessageTypeToCoalition( + string.format( + "VICTORY PROGRESS: %d%%\n" .. + "Zones Captured: %d/%d\n" .. + "Remaining: %d zones\n\n" .. + "%s", + progressPercent, + status.blue, status.total, + status.total - status.blue, + progressPercent >= 100 and "MISSION COMPLETE!" or + progressPercent >= 80 and "ALMOST THERE!" or + progressPercent >= 50 and "GOOD PROGRESS!" or + "KEEP FIGHTING!" + ), + MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION + ) + end ) + + -- Add command to refresh zone colors (troubleshooting tool) + MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Refresh Zone Colors", USMenu, RefreshAllZoneColors ) + end + + -- Add F10 radio menu commands for RED coalition + if RU_CC then + -- Use MenuManager to create zone control menu under Mission Options + local RUMenu = MenuManager and MenuManager.CreateCoalitionMenu(coalition.side.RED, "Zone Control") + or MENU_COALITION:New( coalition.side.RED, "Zone Control" ) + MENU_COALITION_COMMAND:New( coalition.side.RED, "Get Zone Status Report", RUMenu, BroadcastZoneStatus ) + + MENU_COALITION_COMMAND:New( coalition.side.RED, "Check Victory Progress", RUMenu, function() + local status = GetZoneOwnershipStatus() + local totalZones = math.max(status.total, 1) + local progressPercent = math.floor((status.red / totalZones) * 100) + + RU_CC:MessageTypeToCoalition( + string.format( + "VICTORY PROGRESS: %d%%\n" .. + "Zones Captured: %d/%d\n" .. + "Remaining: %d zones\n\n" .. + "%s", + progressPercent, + status.red, status.total, + status.total - status.red, + progressPercent >= 100 and "MISSION COMPLETE!" or + progressPercent >= 80 and "ALMOST THERE!" or + progressPercent >= 50 and "GOOD PROGRESS!" or + "KEEP FIGHTING!" + ), + MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION + ) + end ) + + -- Add command to refresh zone colors (troubleshooting tool) + MENU_COALITION_COMMAND:New( coalition.side.RED, "Refresh Zone Colors", RUMenu, RefreshAllZoneColors ) + end +end + +-- Initialize zone status monitoring +SCHEDULER:New( nil, function() + log("[VICTORY SYSTEM] Initializing zone monitoring system...") + + -- Initialize performance optimization caches + InitializeCachedUnitSet() + + SetupZoneStatusCommands() + + -- Initial status report + SCHEDULER:New( nil, function() + log("[VICTORY SYSTEM] Broadcasting initial zone status...") + BroadcastZoneStatus() + end, {}, 30 ) -- Initial report after 30 seconds + +end, {}, 5 ) -- Initialize after 5 seconds + +log("[VICTORY SYSTEM] Zone capture victory monitoring system loaded successfully!") +log(string.format("[CONFIG] Loaded %d zones from configuration", totalZones)) diff --git a/DCS_Normandy/Operation Escort/Moose_DynamicGroundBattle_Plugin.lua b/DCS_Normandy/Operation Escort/Moose_DynamicGroundBattle_Plugin.lua new file mode 100644 index 0000000..1ccd479 --- /dev/null +++ b/DCS_Normandy/Operation Escort/Moose_DynamicGroundBattle_Plugin.lua @@ -0,0 +1,1278 @@ +--[[ + Script: Moose_DynamicGroundBattle_Plugin.lua + Written by: [F99th-TracerFacer] + Version: 1.0.0 + Date: 15 November 2024 + Description: Warehouse-driven ground unit spawning system that works as a plugin with Moose_DualCoalitionZoneCapture.lua + + This script handles: + - Warehouse-based reinforcement system + - Dynamic spawn frequency based on warehouse survival + - Automated AI tasking to patrol nearest enemy zones + - Zone garrison system (defenders stay in captured zones) + - Optional infantry patrol control + - Warehouse status intel markers + - CTLD troop integration + + What this script DOES NOT do: + - Zone capture logic (handled by Moose_DualCoalitionZoneCapture.lua) + - Win conditions (handled by Moose_DualCoalitionZoneCapture.lua) + - Zone coloring/messaging (handled by Moose_DualCoalitionZoneCapture.lua) + + Load Order (in Mission Editor Triggers): + 1. DO SCRIPT FILE Moose_.lua + 2. DO SCRIPT FILE Moose_DualCoalitionZoneCapture.lua + 3. DO SCRIPT FILE Moose_DynamicGroundBattle_Plugin.lua <-- This file + 4. DO SCRIPT FILE CTLD.lua (optional) + 5. DO SCRIPT FILE CSAR.lua (optional) + + Requirements: + - MOOSE framework must be loaded first + - Moose_DualCoalitionZoneCapture.lua must be loaded BEFORE this script + - Zone configuration comes from DualCoalitionZoneCapture's ZONE_CONFIG + - Groups and warehouses must exist in mission editor (see below) + + Warehouse System & Spawn Frequency Behavior: + 1. Each side has warehouses defined in `redWarehouses` and `blueWarehouses` tables + 2. Spawn frequency dynamically adjusts based on alive warehouses: + - 100% alive = 100% spawn rate (base frequency) + - 50% alive = 50% spawn rate (2x delay) + - 0% alive = no spawns (critical attrition) + 3. Map markers show warehouse locations and nearby units + 4. Updated every UPDATE_MARK_POINTS_SCHED seconds + + AI Task Assignment: + - Groups spawn in friendly zones + - Each zone maintains a minimum garrison (defenders) that patrol only their zone + - Non-defender groups patrol toward nearest enemy zone + - Election system assigns defenders automatically based on zone needs + - Defenders are never reassigned and stay permanently in their zone + - Reassignment occurs every ASSIGN_TASKS_SCHED seconds for non-defenders only + - Only stationary units get new orders (moving units are left alone) + - CTLD-dropped troops automatically integrate + + Groups to Create in Mission Editor (all LATE ACTIVATE): + RED SIDE: + - Infantry Templates: RedInfantry1, RedInfantry2, RedInfantry3, RedInfantry4, RedInfantry5, RedInfantry6 + - Armor Templates: RedArmor1, RedArmor2, RedArmor3, RedArmor4, RedArmor5, RedArmor6 + - Spawn Groups: Names defined by RED_INFANTRY_SPAWN_GROUP and RED_ARMOR_SPAWN_GROUP variables (default: RedInfantryGroup, RedArmorGroup) + - Warehouses (Static Objects): RedWarehouse1-1, RedWarehouse2-1, RedWarehouse3-1, etc. + + BLUE SIDE: + - Infantry Templates: BlueInfantry1, BlueInfantry2, BlueInfantry3, BlueInfantry4, BlueInfantry5, BlueInfantry6 + - Armor Templates: BlueArmor1, BlueArmor2, BlueArmor3, BlueArmor4, BlueArmor5 + - Spawn Groups: Names defined by BLUE_INFANTRY_SPAWN_GROUP and BLUE_ARMOR_SPAWN_GROUP variables (default: BlueInfantryGroup, BlueArmorGroup) + - Warehouses (Static Objects): BlueWarehouse1-1, BlueWarehouse2-1, BlueWarehouse3-1, etc. + + NOTE: Warehouse names use the static "Unit Name" in mission editor, not the "Name" field! + NOTE: Spawn groups should be simple groups set to LATE ACTIVATE. You can customize their names in the USER CONFIGURATION section. + + Integration with DualCoalitionZoneCapture: + - This script reads zoneCaptureObjects and zoneNames from DualCoalitionZoneCapture + - Spawns occur in zones controlled by the appropriate coalition + - AI tasks units to patrol zones from DualCoalitionZoneCapture's ZONE_CONFIG +--]] + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- USER CONFIGURATION SECTION +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +-- Zone Garrison (Defender) Settings +local DEFENDERS_PER_ZONE = 2 -- Minimum number of groups that will garrison each friendly zone (recommended: 2) +local ALLOW_DEFENDER_ROTATION = false -- If true, fresh units can replace existing defenders when zone is over-garrisoned +local DEFENDER_PATROL_INTERVAL = 3600 -- How often defenders may get a patrol task (seconds, e.g. 3600 = 1 hour) + +-- Infantry Patrol Settings +local MOVING_INFANTRY_PATROLS = false -- Set to false to disable infantry movement (they spawn and hold position) + +-- Warehouse Marker Settings +local ENABLE_WAREHOUSE_MARKERS = true -- Enable/disable warehouse map markers (disabled by default if you have other marker systems) +local UPDATE_MARK_POINTS_SCHED = 300 -- Update warehouse markers every 300 seconds (5 minutes) +local MAX_WAREHOUSE_UNIT_LIST_DISTANCE = 5000 -- Max distance to search for units near warehouses for markers + +-- Warehouse Status Message Settings +local ENABLE_WAREHOUSE_STATUS_MESSAGES = true -- Enable/disable periodic warehouse status announcements +local WAREHOUSE_STATUS_MESSAGE_FREQUENCY = 1800 -- How often to announce warehouse status (seconds, default: 1800 = 30 minutes) + +-- Spawn Frequency and Limits +-- Red Side Settings +local INIT_RED_INFANTRY = 15 -- Initial number of Red Infantry groups +local MAX_RED_INFANTRY = 100 -- Maximum number of Red Infantry groups +local SPAWN_SCHED_RED_INFANTRY = 1200 -- Base spawn frequency for Red Infantry (seconds) + +local INIT_RED_ARMOR = 30 -- Initial number of Red Armor groups +local MAX_RED_ARMOR = 500 -- Maximum number of Red Armor groups +local SPAWN_SCHED_RED_ARMOR = 200 -- Base spawn frequency for Red Armor (seconds) + +-- Blue Side Settings +local INIT_BLUE_INFANTRY = 15 -- Initial number of Blue Infantry groups +local MAX_BLUE_INFANTRY = 100 -- Maximum number of Blue Infantry groups +local SPAWN_SCHED_BLUE_INFANTRY = 1200 -- Base spawn frequency for Blue Infantry (seconds) + +local INIT_BLUE_ARMOR = 30 -- Initial number of Blue Armor groups +local MAX_BLUE_ARMOR = 500 -- Maximum number of Blue Armor groups +local SPAWN_SCHED_BLUE_ARMOR = 200 -- Base spawn frequency for Blue Armor (seconds) + +local ASSIGN_TASKS_SCHED = 900 -- How often to reassign tasks to idle groups (seconds) + +-- Per-side cadence scalars (tune to make one side faster/slower without touching base frequencies) +local RED_INFANTRY_CADENCE_SCALAR = 1.0 +local RED_ARMOR_CADENCE_SCALAR = 1.0 +local BLUE_INFANTRY_CADENCE_SCALAR = 1.0 +local BLUE_ARMOR_CADENCE_SCALAR = 1.0 + +-- When a side loses every warehouse we pause spawning and re-check after this delay +local NO_WAREHOUSE_RECHECK_DELAY = 180 + +-- Spawn Group Names (these are the base groups SPAWN:New() uses for spawning) +local RED_INFANTRY_SPAWN_GROUP = "RedInfantryGroup" +local RED_ARMOR_SPAWN_GROUP = "RedArmorGroup" +local BLUE_INFANTRY_SPAWN_GROUP = "BlueInfantryGroup" +local BLUE_ARMOR_SPAWN_GROUP = "BlueArmorGroup" + +-- AI Tasking Behavior +-- Note: DCS engine can crash with "CREATING PATH MAKES TOO LONG" if units try to path too far +-- Keep these values conservative to reduce pathfinding load and avoid server crashes +local MAX_ATTACK_DISTANCE = 22000 -- Maximum distance in meters for attacking enemy zones. Units won't attack zones farther than this. (25km ≈ 13.5nm) +local ATTACK_RETRY_COOLDOWN = 1800 -- Seconds a group will wait before re-attempting an attack if no valid enemy zone was found (30 minutes) + +-- Define warehouses for each side +local redWarehouses = { + STATIC:FindByName("RedWarehouse1-1"), + STATIC:FindByName("RedWarehouse2-1"), + STATIC:FindByName("RedWarehouse3-1"), + STATIC:FindByName("RedWarehouse4-1"), + STATIC:FindByName("RedWarehouse5-1"), + STATIC:FindByName("RedWarehouse6-1"), + STATIC:FindByName("RedWarehouse7-1"), +} + +local blueWarehouses = { + STATIC:FindByName("BlueWarehouse1-1"), + STATIC:FindByName("BlueWarehouse2-1"), + STATIC:FindByName("BlueWarehouse3-1"), + STATIC:FindByName("BlueWarehouse4-1"), + STATIC:FindByName("BlueWarehouse5-1"), + STATIC:FindByName("BlueWarehouse6-1"), +} + +-- Define unit templates (these groups must exist in mission editor as LATE ACTIVATE) +local redInfantryTemplates = { + "RedInfantry1", + "RedInfantry2", + "RedInfantry3", + "RedInfantry4", + "RedInfantry5", + "RedInfantry6" +} + +local redArmorTemplates = { + "RedArmor1", + "RedArmor2", + "RedArmor3", + "RedArmor4", + "RedArmor5", + "RedArmor6" +} + +local blueInfantryTemplates = { + "BlueInfantry1", + "BlueInfantry2", + "BlueInfantry3", + "BlueInfantry4", + "BlueInfantry5", + "BlueInfantry6" +} + +local blueArmorTemplates = { + "BlueArmor1", + "BlueArmor2", + "BlueArmor3", + "BlueArmor4", + "BlueArmor5" +} + + + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- DO NOT EDIT BELOW THIS LINE +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +env.info("[DGB PLUGIN] Dynamic Ground Battle Plugin initializing...") + +-- Validate that DualCoalitionZoneCapture is loaded +if not zoneCaptureObjects or not zoneNames then + env.error("[DGB PLUGIN] ERROR: Moose_DualCoalitionZoneCapture.lua must be loaded BEFORE this plugin!") + env.error("[DGB PLUGIN] Make sure zoneCaptureObjects and zoneNames are available.") + return +end + +-- Validate warehouses exist +local function ValidateWarehouses(warehouses, label) + local foundCount = 0 + local missingCount = 0 + + for i, wh in ipairs(warehouses) do + if wh then + foundCount = foundCount + 1 + env.info(string.format("[DGB PLUGIN] %s warehouse %d: %s (OK)", label, i, wh:GetName())) + else + missingCount = missingCount + 1 + env.warning(string.format("[DGB PLUGIN] %s warehouse at index %d NOT FOUND in mission editor!", label, i)) + end + end + + env.info(string.format("[DGB PLUGIN] %s warehouses: %d found, %d missing", label, foundCount, missingCount)) + return foundCount > 0 +end + +-- Validate unit templates exist +local function ValidateTemplates(templates, label) + local foundCount = 0 + local missingCount = 0 + + for i, templateName in ipairs(templates) do + local group = GROUP:FindByName(templateName) + if group then + foundCount = foundCount + 1 + env.info(string.format("[DGB PLUGIN] %s template %d: %s (OK)", label, i, templateName)) + else + missingCount = missingCount + 1 + env.warning(string.format("[DGB PLUGIN] %s template '%s' NOT FOUND in mission editor!", label, templateName)) + end + end + + env.info(string.format("[DGB PLUGIN] %s templates: %d found, %d missing", label, foundCount, missingCount)) + return foundCount > 0 +end + +env.info("[DGB PLUGIN] Validating configuration...") + +-- Validate all warehouses +local redWarehousesValid = ValidateWarehouses(redWarehouses, "Red") +local blueWarehousesValid = ValidateWarehouses(blueWarehouses, "Blue") + +if not redWarehousesValid then + env.warning("[DGB PLUGIN] WARNING: No valid Red warehouses found! Red spawning will be disabled.") +end + +if not blueWarehousesValid then + env.warning("[DGB PLUGIN] WARNING: No valid Blue warehouses found! Blue spawning will be disabled.") +end + +-- Validate all templates +local redInfantryValid = ValidateTemplates(redInfantryTemplates, "Red Infantry") +local redArmorValid = ValidateTemplates(redArmorTemplates, "Red Armor") +local blueInfantryValid = ValidateTemplates(blueInfantryTemplates, "Blue Infantry") +local blueArmorValid = ValidateTemplates(blueArmorTemplates, "Blue Armor") + +if not redInfantryValid then + env.warning("[DGB PLUGIN] WARNING: No valid Red Infantry templates found! Red Infantry spawning will fail.") +end + +if not redArmorValid then + env.warning("[DGB PLUGIN] WARNING: No valid Red Armor templates found! Red Armor spawning will fail.") +end + +if not blueInfantryValid then + env.warning("[DGB PLUGIN] WARNING: No valid Blue Infantry templates found! Blue Infantry spawning will fail.") +end + +if not blueArmorValid then + env.warning("[DGB PLUGIN] WARNING: No valid Blue Armor templates found! Blue Armor spawning will fail.") +end + +env.info("[DGB PLUGIN] Found " .. #zoneCaptureObjects .. " zones from DualCoalitionZoneCapture") + +-- Track active markers to prevent memory leaks +local activeMarkers = {} + +-- Zone Garrison Tracking System +-- Structure: zoneGarrisons[zoneName] = { defenders = {groupName1, groupName2, ...}, lastUpdate = timestamp } +local zoneGarrisons = {} + +-- Group garrison assignments +-- Structure: groupGarrisonAssignments[groupName] = zoneName (or nil if not a defender) +local groupGarrisonAssignments = {} + +-- Track all groups spawned by this plugin +-- Structure: spawnedGroups[groupName] = true +local spawnedGroups = {} + +-- Track per-group attack cooldowns to avoid hammering the pathfinder for problematic routes +-- Structure: groupAttackCooldown[groupName] = nextAllowedTime (DCS timer.getTime()) +local groupAttackCooldown = {} + +-- Reusable SET_GROUP to prevent repeated creation within a single function call +local function getAllGroups() + -- Only return groups that were spawned by this plugin + local groupSet = SET_GROUP:New() + for groupName, _ in pairs(spawnedGroups) do + local group = GROUP:FindByName(groupName) + if group and group:IsAlive() then + groupSet:AddGroup(group) + else + -- Clean up dead groups from tracking table to prevent memory bloat + if group == nil or not group:IsAlive() then + spawnedGroups[groupName] = nil + groupGarrisonAssignments[groupName] = nil + groupAttackCooldown[groupName] = nil + end + end + end + return groupSet +end + +-- Function to get zones controlled by a specific coalition +local function GetZonesByCoalition(targetCoalition) + local zones = {} + + for idx, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture and zoneCapture:GetCoalition() == targetCoalition then + local zone = zoneCapture:GetZone() + if zone then + table.insert(zones, zone) + end + end + end + + env.info(string.format("[DGB PLUGIN] Found %d zones for coalition %d", #zones, targetCoalition)) + return zones +end + +-- Helper to count warehouse availability +local function GetWarehouseStats(warehouses) + local alive = 0 + local total = 0 + + for _, warehouse in ipairs(warehouses) do + if warehouse then + total = total + 1 + local life = warehouse:GetLife() + if life and life > 0 then + alive = alive + 1 + end + end + end + + return alive, total +end + +-- Function to calculate spawn frequency based on warehouse survival +local function CalculateSpawnFrequency(warehouses, baseFrequency, cadenceScalar) + local aliveWarehouses, totalWarehouses = GetWarehouseStats(warehouses) + cadenceScalar = cadenceScalar or 1 + + if totalWarehouses == 0 then + return baseFrequency * cadenceScalar + end + + if aliveWarehouses == 0 then + return nil -- Pause spawning until logistics return + end + + local frequency = baseFrequency * cadenceScalar * (totalWarehouses / aliveWarehouses) + return frequency +end + +-- Function to calculate spawn frequency as a percentage +local function CalculateSpawnFrequencyPercentage(warehouses) + local aliveWarehouses, totalWarehouses = GetWarehouseStats(warehouses) + + if totalWarehouses == 0 then + return 0 + end + + local percentage = (aliveWarehouses / totalWarehouses) * 100 + return math.floor(percentage) +end + +-- Function to add warehouse markers on the map +local function addMarkPoints(warehouses, coalition) + for _, warehouse in ipairs(warehouses) do + if warehouse then + local warehousePos = warehouse:GetVec3() + local details + + if coalition == 2 then -- Blue viewing + if warehouse:GetCoalition() == 2 then + details = "Warehouse: " .. warehouse:GetName() .. "\nThis warehouse needs to be protected.\n" + else + details = "Warehouse: " .. warehouse:GetName() .. "\nThis is a primary target as it is directly supplying enemy units.\n" + end + elseif coalition == 1 then -- Red viewing + if warehouse:GetCoalition() == 1 then + details = "Warehouse: " .. warehouse:GetName() .. "\nThis warehouse needs to be protected.\n" + else + details = "Warehouse: " .. warehouse:GetName() .. "\nThis is a primary target as it is directly supplying enemy units.\n" + end + end + + local coordinate = COORDINATE:NewFromVec3(warehousePos) + local marker = MARKER:New(coordinate, details):ToCoalition(coalition):ReadOnly() + table.insert(activeMarkers, marker) + end + end +end + +-- Function to update warehouse markers +local function updateMarkPoints() + -- Clean up old markers first + for i = #activeMarkers, 1, -1 do + local marker = activeMarkers[i] + if marker then + marker:Remove() + end + activeMarkers[i] = nil + end + + addMarkPoints(redWarehouses, 2) -- Blue coalition sees red warehouses + addMarkPoints(blueWarehouses, 2) -- Blue coalition sees blue warehouses + addMarkPoints(redWarehouses, 1) -- Red coalition sees red warehouses + addMarkPoints(blueWarehouses, 1) -- Red coalition sees blue warehouses + + env.info(string.format("[DGB PLUGIN] Updated warehouse markers (%d total)", #activeMarkers)) +end + +-- Function to check if a group contains infantry units +local function IsInfantryGroup(group) + for _, unit in ipairs(group:GetUnits()) do + local unitTypeName = unit:GetTypeName() + if unitTypeName:find("Infantry") or unitTypeName:find("Soldier") or unitTypeName:find("Paratrooper") then + return true + end + end + return false +end + +-- Function to check if a group is assigned as a zone defender +local function IsDefender(group) + if not group then return false end + local groupName = group:GetName() + return groupGarrisonAssignments[groupName] ~= nil +end + +-- Function to get garrison info for a zone +local function GetZoneGarrison(zoneName) + if not zoneGarrisons[zoneName] then + zoneGarrisons[zoneName] = { + defenders = {}, + lastUpdate = timer.getTime() + } + end + return zoneGarrisons[zoneName] +end + +-- Function to count alive defenders in a zone +local function CountAliveDefenders(zoneName) + local garrison = GetZoneGarrison(zoneName) + local aliveCount = 0 + local deadDefenders = {} + + for _, groupName in ipairs(garrison.defenders) do + local group = GROUP:FindByName(groupName) + if group and group:IsAlive() then + aliveCount = aliveCount + 1 + else + -- Mark for cleanup + table.insert(deadDefenders, groupName) + end + end + + -- Clean up dead defenders + for _, deadGroupName in ipairs(deadDefenders) do + for i, groupName in ipairs(garrison.defenders) do + if groupName == deadGroupName then + table.remove(garrison.defenders, i) + groupGarrisonAssignments[deadGroupName] = nil + env.info(string.format("[DGB PLUGIN] Removed destroyed defender %s from zone %s", deadGroupName, zoneName)) + break + end + end + end + + return aliveCount +end + +-- Function to elect a group as a zone defender +local function ElectDefender(group, zone, reason) + if not group or not zone then return false end + + local groupName = group:GetName() + local zoneName = zone:GetName() + + -- Check if already a defender + if IsDefender(group) then + return false + end + + local garrison = GetZoneGarrison(zoneName) + + -- Add to garrison + table.insert(garrison.defenders, groupName) + groupGarrisonAssignments[groupName] = zoneName + garrison.lastUpdate = timer.getTime() + + -- Record last patrol time for this defender so we can give them + -- an occasional "stretch their legs" patrol without hammering pathfinding. + garrison.lastPatrolTime = garrison.lastPatrolTime or {} + garrison.lastPatrolTime[groupName] = timer.getTime() + + env.info(string.format("[DGB PLUGIN] Elected %s as defender of zone %s (%s)", groupName, zoneName, reason)) + return true +end + +-- Function to check if a zone needs more defenders +local function ZoneNeedsDefenders(zoneName) + local aliveDefenders = CountAliveDefenders(zoneName) + return aliveDefenders < DEFENDERS_PER_ZONE +end + +-- Function to handle defender rotation (replace old defender with fresh unit) +local function TryDefenderRotation(group, zone) + if not ALLOW_DEFENDER_ROTATION then return false end + + local zoneName = zone:GetName() + local garrison = GetZoneGarrison(zoneName) + + -- Count idle groups in zone (including current group) + local idleGroups = {} + local allGroups = getAllGroups() + + allGroups:ForEachGroup(function(g) + if g and g:IsAlive() and g:GetCoalition() == group:GetCoalition() then + if g:IsCompletelyInZone(zone) then + local velocity = g:GetVelocityVec3() + local speed = math.sqrt(velocity.x^2 + velocity.y^2 + velocity.z^2) + if speed <= 0.5 then + table.insert(idleGroups, g) + end + end + end + end) + + -- Only rotate if we have more than DEFENDERS_PER_ZONE idle units + if #idleGroups > DEFENDERS_PER_ZONE then + -- Find oldest defender to replace + local oldestDefender = nil + local oldestDefenderGroup = nil + + for _, defenderName in ipairs(garrison.defenders) do + local defenderGroup = GROUP:FindByName(defenderName) + if defenderGroup and defenderGroup:IsAlive() then + if not oldestDefender then + oldestDefender = defenderName + oldestDefenderGroup = defenderGroup + end + break -- Just take the first one for rotation + end + end + + if oldestDefender and oldestDefenderGroup:GetName() ~= group:GetName() then + -- Remove old defender + for i, defenderName in ipairs(garrison.defenders) do + if defenderName == oldestDefender then + table.remove(garrison.defenders, i) + groupGarrisonAssignments[oldestDefender] = nil + env.info(string.format("[DGB PLUGIN] Rotated out defender %s from zone %s", oldestDefender, zoneName)) + break + end + end + + -- Elect new defender + ElectDefender(group, zone, "rotation") + + -- Old defender becomes mobile force + return true + end + end + + return false +end + +local function AssignTasks(group, currentZoneCapture) + -- This function is no longer needed as its logic has been integrated into AssignTasksToGroups +end + +-- Function to assign tasks to all groups +local function AssignTasksToGroups() + env.info("[DGB PLUGIN] ============================================") + env.info("[DGB PLUGIN] Starting task assignment cycle...") + local allGroups = getAllGroups() + local tasksAssigned = 0 + local defendersActive = 0 + local mobileAssigned = 0 + local groupsProcessed = 0 + local groupsSkipped = 0 + + -- Create a quick lookup table for zone objects by name + local zoneLookup = {} + for _, zc in ipairs(zoneCaptureObjects) do + local zone = zc:GetZone() + if zone then + zoneLookup[zone:GetName()] = { zone = zone, capture = zc } + end + end + + allGroups:ForEachGroup(function(group) + if not group or not group:IsAlive() then return end + + groupsProcessed = groupsProcessed + 1 + local groupName = group:GetName() + local groupCoalition = group:GetCoalition() + env.info(string.format("[DGB PLUGIN] Processing group %s (coalition %d)", groupName, groupCoalition)) + + -- 1. HANDLE DEFENDERS + if IsDefender(group) then + defendersActive = defendersActive + 1 + + -- Very slow, in-zone patrol for defenders, at most once per DEFENDER_PATROL_INTERVAL. + -- This keeps them mostly static while adding some life, without constantly re-pathing. + local zoneName = groupGarrisonAssignments[groupName] + local garrison = zoneName and GetZoneGarrison(zoneName) or nil + local lastPatrolTime = garrison and garrison.lastPatrolTime and garrison.lastPatrolTime[groupName] or 0 + local now = timer.getTime() + + if garrison and zoneName and now - lastPatrolTime >= DEFENDER_PATROL_INTERVAL then + local zoneInfo = zoneLookup[zoneName] + if zoneInfo and zoneInfo.zone then + env.info(string.format("[DGB PLUGIN] %s: Defender patrol in zone %s", groupName, zoneName)) + -- Use simpler patrol method to reduce pathfinding memory + local zoneCoord = zoneInfo.zone:GetCoordinate() + if zoneCoord then + local patrolPoint = zoneCoord:GetRandomCoordinateInRadius(zoneInfo.zone:GetRadius() * 0.5) + local speed = IsInfantryGroup(group) and 15 or 25 -- km/h - slow patrol + group:RouteGroundTo(patrolPoint, speed, "Vee", 1) + end + garrison.lastPatrolTime[groupName] = now + tasksAssigned = tasksAssigned + 1 + else + env.info(string.format("[DGB PLUGIN] %s: Defender holding (zone not found)", groupName)) + end + else + env.info(string.format("[DGB PLUGIN] %s: Defender holding (patrol not due)", groupName)) + end + + return -- Defenders do not get any other tasks + end + + -- 2. HANDLE MOBILE FORCES (NON-DEFENDERS) + + -- Skip infantry if movement is disabled + if IsInfantryGroup(group) and not MOVING_INFANTRY_PATROLS then + env.info(string.format("[DGB PLUGIN] %s: Skipped (infantry movement disabled)", groupName)) + groupsSkipped = groupsSkipped + 1 + return + end + + -- Don't reassign if already moving + local velocity = group:GetVelocityVec3() + local speed = math.sqrt(velocity.x^2 + velocity.y^2 + velocity.z^2) + env.info(string.format("[DGB PLUGIN] %s: Current speed %.2f m/s", groupName, speed)) + if speed > 0.5 then + env.info(string.format("[DGB PLUGIN] %s: Skipped (already moving)", groupName)) + groupsSkipped = groupsSkipped + 1 + return + end + + -- Find which zone the group is in + local currentZone = nil + local currentZoneCapture = nil + for _, zc in ipairs(zoneCaptureObjects) do + local zone = zc:GetZone() + if zone and group:IsCompletelyInZone(zone) then + currentZone = zone + currentZoneCapture = zc + break + end + end + + -- 3. HANDLE GROUPS IN FRIENDLY ZONES + if currentZone and currentZoneCapture:GetCoalition() == groupCoalition then + local zoneName = currentZone:GetName() + + -- PRIORITY 1: If the zone is under attack, all non-defenders should help defend it + local zoneState = currentZoneCapture.GetCurrentState and currentZoneCapture:GetCurrentState() or nil + if zoneState == "Attacked" then + env.info(string.format("[DGB PLUGIN] %s defending contested zone %s", groupName, zoneName)) + -- Use simpler routing to reduce pathfinding memory + local zoneCoord = currentZone:GetCoordinate() + if zoneCoord then + local defendPoint = zoneCoord:GetRandomCoordinateInRadius(currentZone:GetRadius() * 0.6) + local speed = IsInfantryGroup(group) and 30 or 50 -- km/h - faster response + group:RouteGroundTo(defendPoint, speed, "Vee", 2) + end + tasksAssigned = tasksAssigned + 1 + mobileAssigned = mobileAssigned + 1 + return + end + + -- PRIORITY 2: Elect as defender if zone needs one (before attacking) + if ZoneNeedsDefenders(zoneName) then + if ElectDefender(group, currentZone, "zone under-garrisoned") then + tasksAssigned = tasksAssigned + 1 + defendersActive = defendersActive + 1 + return + end + end + + -- PRIORITY 3: Defender rotation (if enabled and zone is over-garrisoned) + if TryDefenderRotation(group, currentZone) then + tasksAssigned = tasksAssigned + 1 + defendersActive = defendersActive + 1 + return -- Rotated in as a defender, task is set + end + end + + -- 4. PATROL TO NEAREST ENEMY ZONE (for all mobile forces, regardless of current location) + -- Respect per-group attack cooldown to avoid hammering the pathfinder for problematic routes + local now = timer.getTime() + local nextAllowed = groupAttackCooldown[groupName] + if nextAllowed and now < nextAllowed then + env.info(string.format("[DGB PLUGIN] %s: Attack on cooldown for another %.0fs", groupName, nextAllowed - now)) + groupsSkipped = groupsSkipped + 1 + return + end + + local closestEnemyZone = nil + local closestDistance = math.huge + local groupCoordinate = group:GetCoordinate() + + for _, zc in ipairs(zoneCaptureObjects) do + local zoneCoalition = zc:GetCoalition() + if zoneCoalition ~= groupCoalition and zoneCoalition ~= coalition.side.NEUTRAL then + local zone = zc:GetZone() + if zone then + local distance = groupCoordinate:Get2DDistance(zone:GetCoordinate()) + if distance < closestDistance and distance <= MAX_ATTACK_DISTANCE then + closestDistance = distance + closestEnemyZone = zone + end + end + end + end + + if closestEnemyZone then + env.info(string.format("[DGB PLUGIN] %s: Attacking enemy zone %s (%.1fkm away)", + groupName, closestEnemyZone:GetName(), closestDistance / 1000)) + + -- Use simpler waypoint-based routing instead of TaskRouteToZone to reduce pathfinding memory load + -- This prevents the "CREATING PATH MAKES TOO LONG" memory buildup + local zoneCoord = closestEnemyZone:GetCoordinate() + if zoneCoord then + local randomPoint = zoneCoord:GetRandomCoordinateInRadius(closestEnemyZone:GetRadius() * 0.7) + local speed = IsInfantryGroup(group) and 20 or 40 -- km/h + group:RouteGroundTo(randomPoint, speed, "Vee", 1) + end + + tasksAssigned = tasksAssigned + 1 + mobileAssigned = mobileAssigned + 1 + return -- Task assigned, done with this group + end + + -- 5. FALLBACK: No valid enemy zone within range - set cooldown to avoid repeated failed attempts + groupAttackCooldown[groupName] = now + ATTACK_RETRY_COOLDOWN + if closestDistance > MAX_ATTACK_DISTANCE and closestDistance < math.huge then + env.info(string.format("[DGB PLUGIN] %s: No enemy zones within range (closest is %.1fkm away, max is %.1fkm). Putting attacks on cooldown for %ds", + groupName, closestDistance / 1000, MAX_ATTACK_DISTANCE / 1000, ATTACK_RETRY_COOLDOWN)) + else + env.info(string.format("[DGB PLUGIN] %s: No tasks available (no enemy zones found). Putting attacks on cooldown for %ds", groupName, ATTACK_RETRY_COOLDOWN)) + end + end) + + env.info(string.format("[DGB PLUGIN] Task assignment complete. Processed: %d, Skipped: %d, Tasked: %d (%d defenders, %d mobile)", + groupsProcessed, groupsSkipped, tasksAssigned, defendersActive, mobileAssigned)) + env.info("[DGB PLUGIN] ============================================") +end + +-- Function to monitor and announce warehouse status +local function MonitorWarehouses() + local blueWarehousesAlive, blueWarehouseTotal = GetWarehouseStats(blueWarehouses) + local redWarehousesAlive, redWarehouseTotal = GetWarehouseStats(redWarehouses) + + local redSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(redWarehouses) + local blueSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(blueWarehouses) + + if ENABLE_WAREHOUSE_STATUS_MESSAGES then + local msg = "[Warehouse Status]\n" + msg = msg .. "Red warehouses alive: " .. redWarehousesAlive .. " Reinforcements: " .. redSpawnFrequencyPercentage .. "%\n" + msg = msg .. "Blue warehouses alive: " .. blueWarehousesAlive .. " Reinforcements: " .. blueSpawnFrequencyPercentage .. "%\n" + MESSAGE:New(msg, 30):ToAll() + end + + env.info(string.format("[DGB PLUGIN] Warehouse status - Red: %d/%d (%d%%), Blue: %d/%d (%d%%)", + redWarehousesAlive, redWarehouseTotal, redSpawnFrequencyPercentage, + blueWarehousesAlive, blueWarehouseTotal, blueSpawnFrequencyPercentage)) +end + +-- Function to count active units by coalition and type +local function CountActiveUnits(targetCoalition) + local infantry = 0 + local armor = 0 + local total = 0 + local defenders = 0 + local mobile = 0 + + local allGroups = getAllGroups() + + allGroups:ForEachGroup(function(group) + if group and group:IsAlive() and group:GetCoalition() == targetCoalition then + total = total + 1 + + if IsDefender(group) then + defenders = defenders + 1 + else + mobile = mobile + 1 + end + + if IsInfantryGroup(group) then + infantry = infantry + 1 + else + armor = armor + 1 + end + end + end) + + return { + total = total, + infantry = infantry, + armor = armor, + defenders = defenders, + mobile = mobile + } +end + +-- Function to get garrison status across all zones +local function GetGarrisonStatus(targetCoalition) + local garrisonedZones = 0 + local underGarrisonedZones = 0 + local totalFriendlyZones = 0 + + for idx, zoneCapture in ipairs(zoneCaptureObjects) do + if zoneCapture:GetCoalition() == targetCoalition then + totalFriendlyZones = totalFriendlyZones + 1 + local zone = zoneCapture:GetZone() + if zone then + local zoneName = zone:GetName() + local defenderCount = CountAliveDefenders(zoneName) + + if defenderCount >= DEFENDERS_PER_ZONE then + garrisonedZones = garrisonedZones + 1 + else + underGarrisonedZones = underGarrisonedZones + 1 + end + end + end + end + + return { + totalZones = totalFriendlyZones, + garrisoned = garrisonedZones, + underGarrisoned = underGarrisonedZones + } +end + +-- Function to display comprehensive system statistics +local function ShowSystemStatistics(playerCoalition) + -- Get warehouse stats + local redWarehousesAlive, redWarehouseTotal = GetWarehouseStats(redWarehouses) + local blueWarehousesAlive, blueWarehouseTotal = GetWarehouseStats(blueWarehouses) + + -- Get unit counts + local redUnits = CountActiveUnits(coalition.side.RED) + local blueUnits = CountActiveUnits(coalition.side.BLUE) + + -- Get garrison info + local redGarrison = GetGarrisonStatus(coalition.side.RED) + local blueGarrison = GetGarrisonStatus(coalition.side.BLUE) + + -- Get spawn frequencies + local redSpawnFreqPct = CalculateSpawnFrequencyPercentage(redWarehouses) + local blueSpawnFreqPct = CalculateSpawnFrequencyPercentage(blueWarehouses) + + -- Calculate actual spawn intervals + local redInfantryInterval = CalculateSpawnFrequency(redWarehouses, SPAWN_SCHED_RED_INFANTRY, RED_INFANTRY_CADENCE_SCALAR) + local redArmorInterval = CalculateSpawnFrequency(redWarehouses, SPAWN_SCHED_RED_ARMOR, RED_ARMOR_CADENCE_SCALAR) + local blueInfantryInterval = CalculateSpawnFrequency(blueWarehouses, SPAWN_SCHED_BLUE_INFANTRY, BLUE_INFANTRY_CADENCE_SCALAR) + local blueArmorInterval = CalculateSpawnFrequency(blueWarehouses, SPAWN_SCHED_BLUE_ARMOR, BLUE_ARMOR_CADENCE_SCALAR) + + -- Build comprehensive report + local msg = "═══════════════════════════════════════\n" + msg = msg .. "DYNAMIC GROUND BATTLE - SYSTEM STATUS\n" + msg = msg .. "═══════════════════════════════════════\n\n" + + -- Configuration Section + msg = msg .. "【CONFIGURATION】\n" + msg = msg .. " Defenders per Zone: " .. DEFENDERS_PER_ZONE .. "\n" + msg = msg .. " Defender Rotation: " .. (ALLOW_DEFENDER_ROTATION and "ENABLED" or "DISABLED") .. "\n" + msg = msg .. " Infantry Movement: " .. (MOVING_INFANTRY_PATROLS and "ENABLED" or "DISABLED") .. "\n" + msg = msg .. " Task Reassignment: Every " .. ASSIGN_TASKS_SCHED .. "s\n" + msg = msg .. " Warehouse Markers: " .. (ENABLE_WAREHOUSE_MARKERS and "ENABLED" or "DISABLED") .. "\n\n" + + -- Spawn Limits Section + msg = msg .. "【SPAWN LIMITS】\n" + msg = msg .. " Red Infantry: " .. INIT_RED_INFANTRY .. "/" .. MAX_RED_INFANTRY .. "\n" + msg = msg .. " Red Armor: " .. INIT_RED_ARMOR .. "/" .. MAX_RED_ARMOR .. "\n" + msg = msg .. " Blue Infantry: " .. INIT_BLUE_INFANTRY .. "/" .. MAX_BLUE_INFANTRY .. "\n" + msg = msg .. " Blue Armor: " .. INIT_BLUE_ARMOR .. "/" .. MAX_BLUE_ARMOR .. "\n\n" + + -- Red Coalition Section + msg = msg .. "【RED COALITION】\n" + msg = msg .. " Warehouses: " .. redWarehousesAlive .. "/" .. redWarehouseTotal .. " (" .. redSpawnFreqPct .. "%)\n" + msg = msg .. " Active Units: " .. redUnits.total .. " (" .. redUnits.infantry .. " inf, " .. redUnits.armor .. " armor)\n" + msg = msg .. " Defenders: " .. redUnits.defenders .. " | Mobile: " .. redUnits.mobile .. "\n" + msg = msg .. " Controlled Zones: " .. redGarrison.totalZones .. "\n" + msg = msg .. " - Garrisoned: " .. redGarrison.garrisoned .. "\n" + msg = msg .. " - Under-Garrisoned: " .. redGarrison.underGarrisoned .. "\n" + + if redInfantryInterval then + msg = msg .. " Infantry Spawn: " .. math.floor(redInfantryInterval) .. "s\n" + else + msg = msg .. " Infantry Spawn: PAUSED (no warehouses)\n" + end + + if redArmorInterval then + msg = msg .. " Armor Spawn: " .. math.floor(redArmorInterval) .. "s\n\n" + else + msg = msg .. " Armor Spawn: PAUSED (no warehouses)\n\n" + end + + -- Blue Coalition Section + msg = msg .. "【BLUE COALITION】\n" + msg = msg .. " Warehouses: " .. blueWarehousesAlive .. "/" .. blueWarehouseTotal .. " (" .. blueSpawnFreqPct .. "%)\n" + msg = msg .. " Active Units: " .. blueUnits.total .. " (" .. blueUnits.infantry .. " inf, " .. blueUnits.armor .. " armor)\n" + msg = msg .. " Defenders: " .. blueUnits.defenders .. " | Mobile: " .. blueUnits.mobile .. "\n" + msg = msg .. " Controlled Zones: " .. blueGarrison.totalZones .. "\n" + msg = msg .. " - Garrisoned: " .. blueGarrison.garrisoned .. "\n" + msg = msg .. " - Under-Garrisoned: " .. blueGarrison.underGarrisoned .. "\n" + + if blueInfantryInterval then + msg = msg .. " Infantry Spawn: " .. math.floor(blueInfantryInterval) .. "s\n" + else + msg = msg .. " Infantry Spawn: PAUSED (no warehouses)\n" + end + + if blueArmorInterval then + msg = msg .. " Armor Spawn: " .. math.floor(blueArmorInterval) .. "s\n\n" + else + msg = msg .. " Armor Spawn: PAUSED (no warehouses)\n\n" + end + + -- System Info + msg = msg .. "【SYSTEM INFO】\n" + msg = msg .. " Total Zones: " .. #zoneCaptureObjects .. "\n" + msg = msg .. " Active Garrisons: " .. (redGarrison.garrisoned + blueGarrison.garrisoned) .. "\n" + msg = msg .. " Total Active Units: " .. (redUnits.total + blueUnits.total) .. "\n" + + -- Memory and Performance Tracking + local totalSpawnedGroups = 0 + for _ in pairs(spawnedGroups) do + totalSpawnedGroups = totalSpawnedGroups + 1 + end + + local luaMemoryKB = collectgarbage("count") + msg = msg .. " Tracked Groups: " .. totalSpawnedGroups .. "\n" + msg = msg .. " Lua Memory: " .. string.format("%.1f MB", luaMemoryKB / 1024) .. "\n" + + -- Warning if memory is high + if luaMemoryKB > 512000 then -- More than 500MB + msg = msg .. " ⚠️ WARNING: High memory usage!\n" + end + + -- Warning if too many groups + if totalSpawnedGroups > 200 then + msg = msg .. " ⚠️ WARNING: High group count!\n" + end + + msg = msg .. "\n" + msg = msg .. "═══════════════════════════════════════" + + MESSAGE:New(msg, 45):ToCoalition(playerCoalition) + + env.info("[DGB PLUGIN] System statistics displayed to coalition " .. playerCoalition) +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- INITIALIZATION +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +-- Get initial zone lists for each coalition +local redZones = GetZonesByCoalition(coalition.side.RED) +local blueZones = GetZonesByCoalition(coalition.side.BLUE) + +-- Calculate and display initial spawn frequency percentages +local redSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(redWarehouses) +local blueSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(blueWarehouses) + +MESSAGE:New("Red reinforcement capacity: " .. redSpawnFrequencyPercentage .. "%", 30):ToRed() +MESSAGE:New("Blue reinforcement capacity: " .. blueSpawnFrequencyPercentage .. "%", 30):ToBlue() + +-- Initialize spawners +env.info("[DGB PLUGIN] Initializing spawn systems...") + +-- Note: Spawn zones will be dynamically updated based on zone capture states +-- We'll use a function to get current friendly zones on each spawn +local function GetRedZones() + return GetZonesByCoalition(coalition.side.RED) +end + +local function GetBlueZones() + return GetZonesByCoalition(coalition.side.BLUE) +end + +-- Validate spawn groups exist before creating spawners +local spawnGroups = { + {name = RED_INFANTRY_SPAWN_GROUP, label = "Red Infantry Spawn Group"}, + {name = RED_ARMOR_SPAWN_GROUP, label = "Red Armor Spawn Group"}, + {name = BLUE_INFANTRY_SPAWN_GROUP, label = "Blue Infantry Spawn Group"}, + {name = BLUE_ARMOR_SPAWN_GROUP, label = "Blue Armor Spawn Group"} +} + +for _, spawnGroup in ipairs(spawnGroups) do + local group = GROUP:FindByName(spawnGroup.name) + if group then + env.info(string.format("[DGB PLUGIN] %s '%s' found (OK)", spawnGroup.label, spawnGroup.name)) + else + env.error(string.format("[DGB PLUGIN] ERROR: %s '%s' NOT FOUND! Create this group in mission editor as LATE ACTIVATE.", spawnGroup.label, spawnGroup.name)) + end +end + +-- Red Infantry Spawner +redInfantrySpawn = SPAWN:New(RED_INFANTRY_SPAWN_GROUP) + :InitRandomizeTemplate(redInfantryTemplates) + :InitLimit(INIT_RED_INFANTRY, MAX_RED_INFANTRY) + +-- Red Armor Spawner +redArmorSpawn = SPAWN:New(RED_ARMOR_SPAWN_GROUP) + :InitRandomizeTemplate(redArmorTemplates) + :InitLimit(INIT_RED_ARMOR, MAX_RED_ARMOR) + +-- Blue Infantry Spawner +blueInfantrySpawn = SPAWN:New(BLUE_INFANTRY_SPAWN_GROUP) + :InitRandomizeTemplate(blueInfantryTemplates) + :InitLimit(INIT_BLUE_INFANTRY, MAX_BLUE_INFANTRY) + +-- Blue Armor Spawner +blueArmorSpawn = SPAWN:New(BLUE_ARMOR_SPAWN_GROUP) + :InitRandomizeTemplate(blueArmorTemplates) + :InitLimit(INIT_BLUE_ARMOR, MAX_BLUE_ARMOR) + +-- Helper to schedule spawns per category. This is a self-rescheduling function. +local function ScheduleSpawner(spawnObject, getZonesFn, warehouses, baseFrequency, label, cadenceScalar) + local function spawnAndReschedule() + -- Calculate the next spawn interval first + local spawnInterval = CalculateSpawnFrequency(warehouses, baseFrequency, cadenceScalar) + + if not spawnInterval then + -- No warehouses. Pause spawning and check again after the delay. + env.info(string.format("[DGB PLUGIN] %s spawn paused (no warehouses). Rechecking in %ds.", label, NO_WAREHOUSE_RECHECK_DELAY)) + SCHEDULER:New(nil, spawnAndReschedule, {}, NO_WAREHOUSE_RECHECK_DELAY) + return + end + + -- Get friendly zones + local friendlyZones = getZonesFn() + if #friendlyZones > 0 then + local chosenZone = friendlyZones[math.random(#friendlyZones)] + local spawnedGroup = spawnObject:SpawnInZone(chosenZone, true) + + if spawnedGroup then + local groupName = spawnedGroup:GetName() + spawnedGroups[groupName] = true + env.info(string.format("[DGB PLUGIN] Spawned %s in zone %s. Task assignment will occur on next cycle.", + groupName, chosenZone:GetName())) + end + else + env.info(string.format("[DGB PLUGIN] %s spawn skipped (no friendly zones).", label)) + end + + -- Schedule the next run + SCHEDULER:New(nil, spawnAndReschedule, {}, spawnInterval) + env.info(string.format("[DGB PLUGIN] Next %s spawn scheduled in %d seconds.", label, math.floor(spawnInterval))) + end + + -- Kick off the first spawn with a random delay to stagger the different spawners + local initialDelay = math.random(5, 15) + SCHEDULER:New(nil, spawnAndReschedule, {}, initialDelay) + env.info(string.format("[DGB PLUGIN] %s spawner initialized. First check in %d seconds.", label, initialDelay)) +end + +-- Schedule spawns (each spawner now runs at its own configured cadence) +if redInfantryValid and redWarehousesValid then + ScheduleSpawner(redInfantrySpawn, GetRedZones, redWarehouses, SPAWN_SCHED_RED_INFANTRY, "Red Infantry", RED_INFANTRY_CADENCE_SCALAR) +end +if redArmorValid and redWarehousesValid then + ScheduleSpawner(redArmorSpawn, GetRedZones, redWarehouses, SPAWN_SCHED_RED_ARMOR, "Red Armor", RED_ARMOR_CADENCE_SCALAR) +end +if blueInfantryValid and blueWarehousesValid then + ScheduleSpawner(blueInfantrySpawn, GetBlueZones, blueWarehouses, SPAWN_SCHED_BLUE_INFANTRY, "Blue Infantry", BLUE_INFANTRY_CADENCE_SCALAR) +end +if blueArmorValid and blueWarehousesValid then + ScheduleSpawner(blueArmorSpawn, GetBlueZones, blueWarehouses, SPAWN_SCHED_BLUE_ARMOR, "Blue Armor", BLUE_ARMOR_CADENCE_SCALAR) +end + +-- Schedule warehouse marker updates +if ENABLE_WAREHOUSE_MARKERS then + SCHEDULER:New(nil, updateMarkPoints, {}, 10, UPDATE_MARK_POINTS_SCHED) +end + +-- Schedule warehouse monitoring +if ENABLE_WAREHOUSE_STATUS_MESSAGES then + SCHEDULER:New(nil, MonitorWarehouses, {}, 30, WAREHOUSE_STATUS_MESSAGE_FREQUENCY) +end + +-- Comprehensive cleanup function to prevent memory accumulation +local function CleanupStaleData() + local cleanedGroups = 0 + local cleanedCooldowns = 0 + local cleanedGarrisons = 0 + + -- Clean up spawnedGroups, groupGarrisonAssignments, and groupAttackCooldown + for groupName, _ in pairs(spawnedGroups) do + local group = GROUP:FindByName(groupName) + if not group or not group:IsAlive() then + spawnedGroups[groupName] = nil + cleanedGroups = cleanedGroups + 1 + + if groupGarrisonAssignments[groupName] then + groupGarrisonAssignments[groupName] = nil + end + + if groupAttackCooldown[groupName] then + groupAttackCooldown[groupName] = nil + cleanedCooldowns = cleanedCooldowns + 1 + end + end + end + + -- Clean up garrison data for zones that changed ownership or have stale defenders + for zoneName, garrison in pairs(zoneGarrisons) do + local zoneStillExists = false + local currentZoneCoalition = nil + + -- Check if zone still exists and get its current owner + for _, zc in ipairs(zoneCaptureObjects) do + local zone = zc:GetZone() + if zone and zone:GetName() == zoneName then + zoneStillExists = true + currentZoneCoalition = zc:GetCoalition() + break + end + end + + if not zoneStillExists then + -- Zone doesn't exist anymore, clean up all garrison data + for _, defenderName in ipairs(garrison.defenders) do + groupGarrisonAssignments[defenderName] = nil + end + zoneGarrisons[zoneName] = nil + cleanedGarrisons = cleanedGarrisons + 1 + else + -- Zone exists, clean up dead defenders from the garrison list + local deadDefenders = {} + for i, defenderName in ipairs(garrison.defenders) do + local group = GROUP:FindByName(defenderName) + if not group or not group:IsAlive() then + table.insert(deadDefenders, i) + groupGarrisonAssignments[defenderName] = nil + end + end + + -- Remove dead defenders in reverse order to maintain indices + for i = #deadDefenders, 1, -1 do + table.remove(garrison.defenders, deadDefenders[i]) + end + + -- Clean up lastPatrolTime for dead defenders + if garrison.lastPatrolTime then + for defenderName, _ in pairs(garrison.lastPatrolTime) do + local group = GROUP:FindByName(defenderName) + if not group or not group:IsAlive() then + garrison.lastPatrolTime[defenderName] = nil + end + end + end + end + end + + -- Force Lua garbage collection to reclaim memory + collectgarbage("collect") + + if cleanedGroups > 0 or cleanedCooldowns > 0 or cleanedGarrisons > 0 then + env.info(string.format("[DGB PLUGIN] Cleanup: Removed %d groups, %d cooldowns, %d garrisons", + cleanedGroups, cleanedCooldowns, cleanedGarrisons)) + end +end + +-- Optional periodic memory usage logging (Lua-only; shows in dcs.log) +local ENABLE_MEMORY_LOGGING = true +local MEMORY_LOG_INTERVAL = 900 -- seconds (15 minutes) +local CLEANUP_INTERVAL = 600 -- seconds (10 minutes) + +local function LogMemoryUsage() + local luaMemoryKB = collectgarbage("count") + local luaMemoryMB = luaMemoryKB / 1024 + + local totalSpawnedGroups = 0 + for _ in pairs(spawnedGroups) do + totalSpawnedGroups = totalSpawnedGroups + 1 + end + + local totalCooldowns = 0 + for _ in pairs(groupAttackCooldown) do + totalCooldowns = totalCooldowns + 1 + end + + local totalGarrisons = 0 + local totalDefenders = 0 + for _, garrison in pairs(zoneGarrisons) do + totalGarrisons = totalGarrisons + 1 + totalDefenders = totalDefenders + #garrison.defenders + end + + local msg = string.format("[DGB PLUGIN] Memory: Lua=%.1f MB, Groups=%d, Cooldowns=%d, Garrisons=%d, Defenders=%d", + luaMemoryMB, totalSpawnedGroups, totalCooldowns, totalGarrisons, totalDefenders) + env.info(msg) +end + +if ENABLE_MEMORY_LOGGING then + SCHEDULER:New(nil, LogMemoryUsage, {}, 60, MEMORY_LOG_INTERVAL) +end + +-- Schedule periodic cleanup +SCHEDULER:New(nil, CleanupStaleData, {}, 120, CLEANUP_INTERVAL) + +-- Schedule task assignments (runs quickly at start, then every ASSIGN_TASKS_SCHED seconds) +SCHEDULER:New(nil, AssignTasksToGroups, {}, 15, ASSIGN_TASKS_SCHED) + +-- Add F10 menu for manual checks (using MenuManager if available) +if MenuManager then + -- Create coalition-specific menus under Mission Options + local blueMenu = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "Ground Battle") + MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Check Warehouse Status", blueMenu, MonitorWarehouses) + MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show System Statistics", blueMenu, function() + ShowSystemStatistics(coalition.side.BLUE) + end) + + local redMenu = MenuManager.CreateCoalitionMenu(coalition.side.RED, "Ground Battle") + MENU_COALITION_COMMAND:New(coalition.side.RED, "Check Warehouse Status", redMenu, MonitorWarehouses) + MENU_COALITION_COMMAND:New(coalition.side.RED, "Show System Statistics", redMenu, function() + ShowSystemStatistics(coalition.side.RED) + end) +else + -- Fallback to root-level mission menu + local missionMenu = MENU_MISSION:New("Ground Battle") + MENU_MISSION_COMMAND:New("Check Warehouse Status", missionMenu, MonitorWarehouses) + MENU_MISSION_COMMAND:New("Show Blue Statistics", missionMenu, function() + ShowSystemStatistics(coalition.side.BLUE) + end) + MENU_MISSION_COMMAND:New("Show Red Statistics", missionMenu, function() + ShowSystemStatistics(coalition.side.RED) + end) +end + +env.info("[DGB PLUGIN] Dynamic Ground Battle Plugin initialized successfully!") +env.info(string.format("[DGB PLUGIN] Zone garrison system: %d defenders per zone", DEFENDERS_PER_ZONE)) +env.info(string.format("[DGB PLUGIN] Defender rotation: %s", ALLOW_DEFENDER_ROTATION and "ENABLED" or "DISABLED")) +env.info(string.format("[DGB PLUGIN] Infantry movement: %s", MOVING_INFANTRY_PATROLS and "ENABLED" or "DISABLED")) +env.info(string.format("[DGB PLUGIN] Warehouse markers: %s", ENABLE_WAREHOUSE_MARKERS and "ENABLED" or "DISABLED")) diff --git a/Moose_.lua b/Moose_.lua index e8f7a42..2678209 100644 --- a/Moose_.lua +++ b/Moose_.lua @@ -1,4 +1,4 @@ -env.info('*** MOOSE GITHUB Commit Hash ID: 2025-11-22T16:18:28+01:00-9e55118d3e8e9c02e3d8b452a38447009159cf37 ***') +env.info('*** MOOSE GITHUB Commit Hash ID: 2025-11-27T18:07:18+01:00-0abb0db2a3e46a509bf3f05ec4960fa84a6fb43c ***') if not MOOSE_DEVELOPMENT_FOLDER then MOOSE_DEVELOPMENT_FOLDER='Scripts' end @@ -403,7 +403,8 @@ Tornado="Tornado", Atlas="A400", Lancer="B1-B", Stratofortress="B-52H", -Hercules="C-130", +Herc="C-130", +Hercules="C-130J-30", Super_Hercules="Hercules", Globemaster="C-17", Greyhound="C-2A", @@ -3104,6 +3105,18 @@ if string.find(type_name,"SA342")and(unit:getDrawArgumentValue(34)==1)then BASE:T(unit_name.." front door(s) are open or doors removed") return true end +if type_name=="C-130J-30"and(unit:getDrawArgumentValue(86)==1)then +BASE:T(unit_name.." rear doors are open") +return true +end +if type_name=="C-130J-30"and(unit:getDrawArgumentValue(87)==1)then +BASE:T(unit_name.." Side door(s) are open") +return true +end +if type_name=="C-130J-30"and(unit:getDrawArgumentValue(88)==1)then +BASE:T(unit_name.." Paratroop door(s) are open") +return true +end if string.find(type_name,"Hercules")and(unit:getDrawArgumentValue(1215)==1 and unit:getDrawArgumentValue(1216)==1)then BASE:T(unit_name.." rear doors are open") return true @@ -4377,6 +4390,7 @@ end function UTILS.SpawnFARPAndFunctionalStatics(Name,Coordinate,FARPType,Coalition,Country,CallSign,Frequency,Modulation,ADF,SpawnRadius,VehicleTemplate,Liquids,Equipment,Airframes,F10Text,DynamicSpawns,HotStart,NumberPads,SpacingX,SpacingY) local function PopulateStorage(Name,liquids,equip,airframes) local newWH=STORAGE:New(Name) +if newWH then if liquids and liquids>0 then newWH:SetLiquid(STORAGE.Liquid.DIESEL,liquids) newWH:SetLiquid(STORAGE.Liquid.GASOLINE,liquids) @@ -4396,6 +4410,7 @@ newWH:SetItem(typename,airframes) end end end +end local farplocation=Coordinate local farptype=FARPType or ENUMS.FARPType.FARP local Coalition=Coalition or coalition.side.BLUE @@ -4460,15 +4475,14 @@ time=timer.getTime(), initiator=Static } world.onEvent(Event) -PopulateStorage(Name.."-1",liquids,equip,airframes) else local newfarp=SPAWNSTATIC:NewFromType(STypeName,"Heliports",Country) newfarp:InitShape(SShapeName) newfarp:InitFARP(callsign,freq,mod,DynamicSpawns,HotStart) local spawnedfarp=newfarp:SpawnFromCoordinate(farplocation,0,Name) table.insert(ReturnObjects,spawnedfarp) -PopulateStorage(Name,liquids,equip,airframes) end +PopulateStorage(Name,liquids,equip,airframes) local FARPStaticObjectsNato={ ["FUEL"]={TypeName="FARP Fuel Depot",ShapeName="GSM Rus",Category="Fortifications"}, ["AMMO"]={TypeName="FARP Ammo Dump Coating",ShapeName="SetkaKP",Category="Fortifications"}, @@ -4984,6 +4998,89 @@ end end return nil end +function UTILS.CreateAirbaseEnum() +local function _savefile(filename,data) +local file=lfs.writedir()..filename +local f=io.open(file,"wb") +if f then +f:write(data) +f:close() +env.info(string.format("Saving to file %s",tostring(file))) +else +env.info(string.format("ERROR: Could not save results to file %s",tostring(file))) +end +end +local airbases=world.getAirbases() +local mapname=env.mission.theatre +local myab={} +for i,_airbase in pairs(airbases)do +local airbase=_airbase +local cat=airbase:getDesc().category +if cat==Airbase.Category.AIRDROME then +local name=airbase:getName() +local key=name +if name=="Airracing Lubeck"then +key="Airracing_Luebeck" +elseif name=="Bad Durkheim"then +key="Bad_Duerkheim" +elseif name=="Buchel"then +key="Buechel" +elseif name=="Buckeburg"then +key="Bueckeburg" +elseif name=="Dusseldorf"then +key="Duesseldorf" +elseif name=="Gutersloh"then +key="Guetersloh" +elseif name=="Kothen"then +key="Koethen" +elseif name=="Larz"then +key="Laerz" +elseif name=="Lubeck"then +key="Luebeck" +elseif name=="Luneburg"then +key="Lueneburg" +elseif name=="Norvenich"then +key="Noervenich" +elseif name=="Ober-Morlen"then +key="Ober_Moerlen" +elseif name=="Peenemunde"then +key="Peenemuende" +elseif name=="Pottschutthohe"then +key="Pottschutthoehe" +elseif name=="Schonefeld"then +key="Schoenefeld" +elseif name=="Weser Wumme"then +key="Weser_Wuemme" +elseif name=="Zollschen"then +key="Zoellschen" +elseif name=="Zweibrucken"then +key="Zweibruecken" +end +key=key:gsub(" ","_") +key=key:gsub("-","_") +key=key:gsub("'","_") +key=UTILS.ReplaceIllegalCharacters(key,"_") +local entry={} +entry.key=key +entry.name=name +table.insert(myab,entry) +end +end +table.sort(myab,function(a,b)return a.name