Stability and Design Improvements

Robust Spawner Initialization: I've added validation to ensure that spawners are only initialized if their required unit templates and warehouses are present in the mission. This prevents the script from attempting to run spawning logic that is destined to fail, making the system more resilient to mission editor configuration errors.

Clearer Tasking Logic: The task assignment logic is now more explicit and follows a clear order of priorities for units:

Defend Assigned Zone: A unit assigned as a defender will always patrol its home zone.
Reinforce Contested Zone: If a zone is under attack, any available non-defenders will automatically assist in its defense.
Fill Garrison: If a zone needs defenders, an available unit will be elected to become a permanent defender.
Attack Enemy: If a unit has no defensive tasks, it will be assigned to patrol towards the nearest enemy-held zone.
This commit is contained in:
iTracerFacer 2025-11-17 06:19:28 -06:00
parent 924757919f
commit f76d741588
3 changed files with 158 additions and 164 deletions

View File

@ -285,14 +285,11 @@ local zoneGarrisons = {}
-- Structure: groupGarrisonAssignments[groupName] = zoneName (or nil if not a defender) -- Structure: groupGarrisonAssignments[groupName] = zoneName (or nil if not a defender)
local groupGarrisonAssignments = {} local groupGarrisonAssignments = {}
-- Reusable SET_GROUP to prevent memory leaks from repeated creation -- Reusable SET_GROUP to prevent repeated creation within a single function call
local cachedAllGroups = nil
local function getAllGroups() local function getAllGroups()
if not cachedAllGroups then -- This must be called every time to get a fresh list of all groups, including newly spawned ones.
cachedAllGroups = SET_GROUP:New():FilterActive():FilterStart() -- The :FilterActive() ensures we only work with groups that are currently alive.
env.info("[DGB PLUGIN] Created cached SET_GROUP for performance") return SET_GROUP:New():FilterActive()
end
return cachedAllGroups
end end
-- Function to get zones controlled by a specific coalition -- Function to get zones controlled by a specific coalition
@ -560,25 +557,49 @@ local function TryDefenderRotation(group, zone)
end end
local function AssignTasks(group, currentZoneCapture) local function AssignTasks(group, currentZoneCapture)
if not group or not group.GetCoalition or not group.GetCoordinate or not group.GetVelocityVec3 then -- This function is no longer needed as its logic has been integrated into AssignTasksToGroups
return end
-- Function to assign tasks to all groups
local function AssignTasksToGroups()
env.info("[DGB PLUGIN] Starting task assignment cycle...")
local allGroups = getAllGroups()
local tasksAssigned = 0
local defendersActive = 0
local mobileAssigned = 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 end
-- GARRISON SYSTEM: Defenders never leave their zone allGroups:ForEachGroup(function(group)
if not group or not group:IsAlive() then return end
local groupName = group:GetName()
local groupCoalition = group:GetCoalition()
-- 1. HANDLE DEFENDERS
if IsDefender(group) then if IsDefender(group) then
local assignedZoneName = groupGarrisonAssignments[group:GetName()] defendersActive = defendersActive + 1
if assignedZoneName then local assignedZoneName = groupGarrisonAssignments[groupName]
-- Find the zone object local zoneInfo = zoneLookup[assignedZoneName]
for idx, zoneCapture in ipairs(zoneCaptureObjects) do if zoneInfo then
local zone = zoneCapture:GetZone() -- Ensure defender patrols its assigned zone
if zone and zone:GetName() == assignedZoneName then group:PatrolZones({ zoneInfo.zone }, 20, "Cone", 30, 60)
-- Keep patrolling home zone tasksAssigned = tasksAssigned + 1
group:PatrolZones({zone}, 20, "Cone", 30, 60)
return
end end
return -- Defenders do not get any other tasks
end end
end
-- If we get here, the defender's zone was lost or not found, but they still stay put -- 2. HANDLE MOBILE FORCES (NON-DEFENDERS)
-- Skip infantry if movement is disabled
if IsInfantryGroup(group) and not MOVING_INFANTRY_PATROLS then
return return
end end
@ -589,105 +610,76 @@ local function AssignTasks(group, currentZoneCapture)
return return
end end
local groupCoalition = group:GetCoalition() -- Find which zone the group is in
local groupCoordinate = group:GetCoordinate() local currentZone = nil
local currentZone = currentZoneCapture and currentZoneCapture:GetZone() or nil
-- GARRISON SYSTEM: Check if current zone needs defenders
if currentZoneCapture and currentZone and currentZoneCapture:GetCoalition() == groupCoalition then
local zoneName = currentZone:GetName()
-- Try to elect as defender if zone needs one
if ZoneNeedsDefenders(zoneName) then
if ElectDefender(group, currentZone, "zone under-garrisoned") then
return
end
else
-- Try rotation if enabled
if TryDefenderRotation(group, currentZone) then
return
end
end
-- If the zone is under attack, all units help defend (even non-defenders)
local zoneState = currentZoneCapture.GetCurrentState and currentZoneCapture:GetCurrentState() or nil
if zoneState == "Attacked" then
env.info(string.format("[DGB PLUGIN] %s defending contested zone %s", group:GetName(), currentZone:GetName()))
group:PatrolZones({ currentZone }, 20, "Cone", 30, 60)
return
end
end
local closestZone = nil
local closestDistance = math.huge
-- Find nearest enemy zone
for idx, zoneCapture in ipairs(zoneCaptureObjects) do
local zoneCoalition = zoneCapture:GetCoalition()
if zoneCoalition ~= groupCoalition and zoneCoalition ~= coalition.side.NEUTRAL then
local zone = zoneCapture:GetZone()
if zone then
local zoneCoordinate = zone:GetCoordinate()
local distance = groupCoordinate:Get2DDistance(zoneCoordinate)
if distance < closestDistance then
closestDistance = distance
closestZone = zone
end
end
end
end
if closestZone then
env.info(string.format("[DGB PLUGIN] %s patrolling %s", group:GetName(), closestZone:GetName()))
group:PatrolZones({closestZone}, 20, "Cone", 30, 60)
end
end
-- Function to assign tasks to all groups
local function AssignTasksToGroups()
env.info("[DGB PLUGIN] Starting task assignment cycle...")
local allGroups = getAllGroups()
local tasksAssigned = 0
local defendersActive = 0
allGroups:ForEachGroup(function(group)
if group and group:IsAlive() then
-- Check if group is in a friendly zone
local groupCoalition = group:GetCoalition()
local inFriendlyZone = false
local currentZoneCapture = nil local currentZoneCapture = nil
for _, zc in ipairs(zoneCaptureObjects) do
for idx, zoneCapture in ipairs(zoneCaptureObjects) do local zone = zc:GetZone()
if zoneCapture:GetCoalition() == groupCoalition then
local zone = zoneCapture:GetZone()
if zone and group:IsCompletelyInZone(zone) then if zone and group:IsCompletelyInZone(zone) then
inFriendlyZone = true currentZone = zone
currentZoneCapture = zoneCapture currentZoneCapture = zc
break break
end end
end end
end
if inFriendlyZone then -- Only assign tasks to groups inside a friendly zone
-- Skip infantry if movement is disabled (unless they're defenders) if not currentZone or currentZoneCapture:GetCoalition() ~= groupCoalition then
if IsInfantryGroup(group) and not MOVING_INFANTRY_PATROLS and not IsDefender(group) then
return return
end end
-- Count defenders local zoneName = currentZone:GetName()
if IsDefender(group) then
defendersActive = defendersActive + 1 -- 3. DEFENDER ELECTION & ROTATION
-- Try to elect as defender if zone needs one
if ZoneNeedsDefenders(zoneName) then
if ElectDefender(group, currentZone, "zone under-garrisoned") then
tasksAssigned = tasksAssigned + 1
return -- Now a defender, task is set
end
elseif TryDefenderRotation(group, currentZone) then
tasksAssigned = tasksAssigned + 1
return -- Rotated in as a defender, task is set
end end
AssignTasks(group, currentZoneCapture) -- 4. DEFEND CONTESTED ZONE
-- 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:PatrolZones({ currentZone }, 20, "Cone", 30, 60)
tasksAssigned = tasksAssigned + 1 tasksAssigned = tasksAssigned + 1
mobileAssigned = mobileAssigned + 1
return
end end
-- 5. PATROL TO NEAREST ENEMY ZONE
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 then
closestDistance = distance
closestEnemyZone = zone
end
end
end
end
if closestEnemyZone then
env.info(string.format("[DGB PLUGIN] %s patrolling towards enemy zone %s", groupName, closestEnemyZone:GetName()))
group:PatrolZones({ closestEnemyZone }, 20, "Cone", 30, 60)
tasksAssigned = tasksAssigned + 1
mobileAssigned = mobileAssigned + 1
end end
end) end)
env.info(string.format("[DGB PLUGIN] Task assignment complete. %d groups tasked (%d defenders).", tasksAssigned, defendersActive)) env.info(string.format("[DGB PLUGIN] Task assignment complete. %d groups tasked (%d defenders, %d mobile).", tasksAssigned, defendersActive, mobileAssigned))
end end
-- Function to monitor and announce warehouse status -- Function to monitor and announce warehouse status
@ -941,65 +933,67 @@ blueArmorSpawn = SPAWN:New(BLUE_ARMOR_SPAWN_GROUP)
:InitRandomizeTemplate(blueArmorTemplates) :InitRandomizeTemplate(blueArmorTemplates)
:InitLimit(INIT_BLUE_ARMOR, MAX_BLUE_ARMOR) :InitLimit(INIT_BLUE_ARMOR, MAX_BLUE_ARMOR)
-- Helper to schedule spawns per category so each uses its intended cadence. -- Helper to schedule spawns per category. This is a self-rescheduling function.
local function ScheduleSpawner(spawnObject, getZonesFn, warehouses, baseFrequency, label, cadenceScalar) local function ScheduleSpawner(spawnObject, getZonesFn, warehouses, baseFrequency, label, cadenceScalar)
local lastSpawnTime = 0 local function spawnAndReschedule()
local checkInterval = 10 -- Check every 10 seconds if it's time to spawn -- Calculate the next spawn interval first
local function spawnCheck()
local currentTime = timer.getTime()
local spawnInterval = CalculateSpawnFrequency(warehouses, baseFrequency, cadenceScalar) local spawnInterval = CalculateSpawnFrequency(warehouses, baseFrequency, cadenceScalar)
if not spawnInterval then if not spawnInterval then
-- No warehouses alive - use recheck delay -- No warehouses. Pause spawning and check again after the delay.
spawnInterval = NO_WAREHOUSE_RECHECK_DELAY env.info(string.format("[DGB PLUGIN] %s spawn paused (no warehouses). Rechecking in %ds.", label, NO_WAREHOUSE_RECHECK_DELAY))
if currentTime - lastSpawnTime >= spawnInterval then SCHEDULER:New(nil, spawnAndReschedule, {}, NO_WAREHOUSE_RECHECK_DELAY)
env.info(string.format("[DGB PLUGIN] %s spawn paused (no warehouses alive, will recheck)", label))
lastSpawnTime = currentTime
end
return return
end end
-- Check if enough time has passed to spawn -- Get friendly zones
if currentTime - lastSpawnTime >= spawnInterval then
local friendlyZones = getZonesFn() local friendlyZones = getZonesFn()
local zonesAvailable = #friendlyZones if #friendlyZones > 0 then
local chosenZone = friendlyZones[math.random(#friendlyZones)]
if zonesAvailable > 0 then
local chosenZone = friendlyZones[math.random(zonesAvailable)]
local spawnedGroup = spawnObject:SpawnInZone(chosenZone, false) local spawnedGroup = spawnObject:SpawnInZone(chosenZone, false)
-- Check if the spawn zone needs defenders and auto-elect if so -- Post-spawn logic: if the group was created, check if it should become a defender
if spawnedGroup then if spawnedGroup then
local zoneName = chosenZone:GetName() -- Use a short delay to ensure the group object is fully initialized before we work with it
if ZoneNeedsDefenders(zoneName) then
SCHEDULER:New(nil, function() SCHEDULER:New(nil, function()
local grp = GROUP:FindByName(spawnedGroup:GetName()) local grp = GROUP:FindByName(spawnedGroup:GetName())
if grp and grp:IsAlive() then if grp and grp:IsAlive() then
ElectDefender(grp, chosenZone, "spawn in under-garrisoned zone") local zoneName = chosenZone:GetName()
end -- If the zone needs defenders, this new unit is the perfect candidate
end, {}, 2) -- Delay 2 seconds to ensure group is fully initialized if ZoneNeedsDefenders(zoneName) then
ElectDefender(grp, chosenZone, "spawned in under-garrisoned zone")
end end
end end
end, {}, 2) -- 2-second delay
lastSpawnTime = currentTime end
else else
env.info(string.format("[DGB PLUGIN] %s spawn skipped (no friendly zones)", label)) env.info(string.format("[DGB PLUGIN] %s spawn skipped (no friendly zones).", label))
lastSpawnTime = currentTime -- Reset timer even if no zones available
end
end
end end
-- Single scheduler that runs continuously at fixed check interval -- Schedule the next run
local scheduler = SCHEDULER:New(nil, spawnCheck, {}, math.random(5, 15), checkInterval) SCHEDULER:New(nil, spawnAndReschedule, {}, spawnInterval)
return scheduler 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 end
-- Schedule spawns (each spawner now runs at its own configured cadence) -- Schedule spawns (each spawner now runs at its own configured cadence)
ScheduleSpawner(redInfantrySpawn, GetRedZones, redWarehouses, SPAWN_SCHED_RED_INFANTRY, "Red Infantry", RED_INFANTRY_CADENCE_SCALAR) if redInfantryValid and redWarehousesValid then
ScheduleSpawner(redArmorSpawn, GetRedZones, redWarehouses, SPAWN_SCHED_RED_ARMOR, "Red Armor", RED_ARMOR_CADENCE_SCALAR) ScheduleSpawner(redInfantrySpawn, GetRedZones, redWarehouses, SPAWN_SCHED_RED_INFANTRY, "Red Infantry", RED_INFANTRY_CADENCE_SCALAR)
ScheduleSpawner(blueInfantrySpawn, GetBlueZones, blueWarehouses, SPAWN_SCHED_BLUE_INFANTRY, "Blue Infantry", BLUE_INFANTRY_CADENCE_SCALAR) end
ScheduleSpawner(blueArmorSpawn, GetBlueZones, blueWarehouses, SPAWN_SCHED_BLUE_ARMOR, "Blue Armor", BLUE_ARMOR_CADENCE_SCALAR) 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 -- Schedule warehouse marker updates
if ENABLE_WAREHOUSE_MARKERS then if ENABLE_WAREHOUSE_MARKERS then