Moose_DualCoalitionZoneCapture/Moose_DynamicGroundBattle_Plugin.lua
iTracerFacer 7eff0ba7b5 Units will only attack enemy zones within 15km
If no zones are in range, they'll stay as defenders or idle
This prevents the "path too long" error that caused the crash
You can adjust MAX_ATTACK_DISTANCE if needed (lower = more stable, higher = longer range attacks)

New Metrics Added:

Tracked Groups - Total number of groups being managed by the plugin (should match or be close to "Total Active Units")
Lua Memory - Current Lua script memory usage in MB
Warnings:
High memory warning if Lua memory > 500MB
High group count warning if > 200 groups
What to watch for:

Lua Memory steadily increasing over time = memory leak (groups not being cleaned up)
Tracked Groups != Total Active Units = dead groups not being removed from tracking table
Group count approaching MAX limits = might hit spawn caps
This will help you identify if memory is building up before it causes a crash. Check the F10 menu → Show System Statistics periodically during long missions to monitor these values.
2025-11-17 17:19:29 -06:00

1099 lines
46 KiB
Lua

--[[
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 = true -- If true, fresh units can replace existing defenders when zone is over-garrisoned
-- Infantry Patrol Settings
local MOVING_INFANTRY_PATROLS = true -- 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 = 25 -- 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 = 25 -- 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 = 25 -- 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 = 25 -- 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 = 600 -- 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 this value conservative to prevent server crashes from pathfinding issues
local MAX_ATTACK_DISTANCE = 45000 -- Maximum distance in meters for attacking enemy zones. Units won't attack zones farther than this. (45km = 24.3nm)
-- 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")
}
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 = {}
-- 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)
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 _, marker in ipairs(activeMarkers) do
if marker then
marker:Remove()
end
end
activeMarkers = {}
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("[DGB PLUGIN] Updated warehouse markers")
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()
-- Assign patrol task for the zone
group:TaskRouteToZone(zone, true)
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
local assignedZoneName = groupGarrisonAssignments[groupName]
local zoneInfo = zoneLookup[assignedZoneName]
if zoneInfo then
-- Ensure defender routes to and patrols its assigned zone
group:TaskRouteToZone(zoneInfo.zone, true)
tasksAssigned = tasksAssigned + 1
env.info(string.format("[DGB PLUGIN] %s: Defender patrolling zone %s", groupName, assignedZoneName))
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))
group:TaskRouteToZone(currentZone, true)
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)
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))
-- Route to a random point in the enemy zone and patrol
group:TaskRouteToZone(closestEnemyZone, true)
tasksAssigned = tasksAssigned + 1
mobileAssigned = mobileAssigned + 1
return -- Task assigned, done with this group
end
-- 5. FALLBACK: Idle in current zone if no tasks available
if closestDistance > MAX_ATTACK_DISTANCE then
env.info(string.format("[DGB PLUGIN] %s: No enemy zones within range (closest is %.1fkm away, max is %.1fkm)",
groupName, closestDistance / 1000, MAX_ATTACK_DISTANCE / 1000))
else
env.info(string.format("[DGB PLUGIN] %s: No tasks available (no enemy zones found)", groupName))
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_INFANTRY)
-- 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
-- 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"))