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,89 +557,7 @@ 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
-- GARRISON SYSTEM: Defenders never leave their zone
if IsDefender(group) then
local assignedZoneName = groupGarrisonAssignments[group:GetName()]
if assignedZoneName then
-- Find the zone object
for idx, zoneCapture in ipairs(zoneCaptureObjects) do
local zone = zoneCapture:GetZone()
if zone and zone:GetName() == assignedZoneName then
-- Keep patrolling home zone
group:PatrolZones({zone}, 20, "Cone", 30, 60)
return
end
end
end
-- If we get here, the defender's zone was lost or not found, but they still stay put
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)
if speed > 0.5 then
return
end
local groupCoalition = group:GetCoalition()
local groupCoordinate = group:GetCoordinate()
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 end
-- Function to assign tasks to all groups -- Function to assign tasks to all groups
@ -651,43 +566,120 @@ local function AssignTasksToGroups()
local allGroups = getAllGroups() local allGroups = getAllGroups()
local tasksAssigned = 0 local tasksAssigned = 0
local defendersActive = 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
allGroups:ForEachGroup(function(group) allGroups:ForEachGroup(function(group)
if group and group:IsAlive() then if not group or not group:IsAlive() then return end
-- Check if group is in a friendly zone
local groupCoalition = group:GetCoalition() local groupName = group:GetName()
local inFriendlyZone = false local groupCoalition = group:GetCoalition()
local currentZoneCapture = nil
-- 1. HANDLE DEFENDERS
for idx, zoneCapture in ipairs(zoneCaptureObjects) do if IsDefender(group) then
if zoneCapture:GetCoalition() == groupCoalition then defendersActive = defendersActive + 1
local zone = zoneCapture:GetZone() local assignedZoneName = groupGarrisonAssignments[groupName]
if zone and group:IsCompletelyInZone(zone) then local zoneInfo = zoneLookup[assignedZoneName]
inFriendlyZone = true if zoneInfo then
currentZoneCapture = zoneCapture -- Ensure defender patrols its assigned zone
break group:PatrolZones({ zoneInfo.zone }, 20, "Cone", 30, 60)
tasksAssigned = tasksAssigned + 1
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
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)
if speed > 0.5 then
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
-- Only assign tasks to groups inside a friendly zone
if not currentZone or currentZoneCapture:GetCoalition() ~= groupCoalition then
return
end
local zoneName = currentZone:GetName()
-- 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
-- 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
mobileAssigned = mobileAssigned + 1
return
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
end end
end
if inFriendlyZone then
-- Skip infantry if movement is disabled (unless they're defenders) if closestEnemyZone then
if IsInfantryGroup(group) and not MOVING_INFANTRY_PATROLS and not IsDefender(group) then env.info(string.format("[DGB PLUGIN] %s patrolling towards enemy zone %s", groupName, closestEnemyZone:GetName()))
return group:PatrolZones({ closestEnemyZone }, 20, "Cone", 30, 60)
end tasksAssigned = tasksAssigned + 1
mobileAssigned = mobileAssigned + 1
-- Count defenders
if IsDefender(group) then
defendersActive = defendersActive + 1
end
AssignTasks(group, currentZoneCapture)
tasksAssigned = tasksAssigned + 1
end
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() if #friendlyZones > 0 then
local zonesAvailable = #friendlyZones local chosenZone = friendlyZones[math.random(#friendlyZones)]
local spawnedGroup = spawnObject:SpawnInZone(chosenZone, false)
if zonesAvailable > 0 then
local chosenZone = friendlyZones[math.random(zonesAvailable)] -- Post-spawn logic: if the group was created, check if it should become a defender
local spawnedGroup = spawnObject:SpawnInZone(chosenZone, false) if spawnedGroup then
-- Use a short delay to ensure the group object is fully initialized before we work with it
-- Check if the spawn zone needs defenders and auto-elect if so SCHEDULER:New(nil, function()
if spawnedGroup then local grp = GROUP:FindByName(spawnedGroup:GetName())
local zoneName = chosenZone:GetName() if grp and grp:IsAlive() then
if ZoneNeedsDefenders(zoneName) then local zoneName = chosenZone:GetName()
SCHEDULER:New(nil, function() -- If the zone needs defenders, this new unit is the perfect candidate
local grp = GROUP:FindByName(spawnedGroup:GetName()) if ZoneNeedsDefenders(zoneName) then
if grp and grp:IsAlive() then ElectDefender(grp, chosenZone, "spawned in under-garrisoned zone")
ElectDefender(grp, chosenZone, "spawn in under-garrisoned zone") end
end
end, {}, 2) -- Delay 2 seconds to ensure group is fully initialized
end end
end end, {}, 2) -- 2-second delay
lastSpawnTime = currentTime
else
env.info(string.format("[DGB PLUGIN] %s spawn skipped (no friendly zones)", label))
lastSpawnTime = currentTime -- Reset timer even if no zones available
end end
else
env.info(string.format("[DGB PLUGIN] %s spawn skipped (no friendly zones).", label))
end 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 end
-- Single scheduler that runs continuously at fixed check interval -- Kick off the first spawn with a random delay to stagger the different spawners
local scheduler = SCHEDULER:New(nil, spawnCheck, {}, math.random(5, 15), checkInterval) local initialDelay = math.random(5, 15)
return scheduler 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