DML/modules/cfxGroundTroops.lua
Christian Franz 92dc6ca40f Version 1.0
One Dot Oh!
2022-06-16 21:22:34 +02:00

1165 lines
43 KiB
Lua

cfxGroundTroops = {}
cfxGroundTroops.version = "1.7.6"
cfxGroundTroops.ups = 1
cfxGroundTroops.verbose = false
cfxGroundTroops.requiredLibs = {
"dcsCommon", -- common is of course needed for everything
-- pretty stupid to check for this since we
-- need common to invoke the check, but anyway
"cfxCommander", -- generic data module for weight
-- cfxOwnedZones is optional
}
-- ground troops: a module to manage ground toops. makes groups of ground troops
-- patrol and engage enemies and signal idle
-- understands cfxOwnedZones orders 'attackOwnedZone' and will re-direct
-- troops when a zone was captured by interacting with cfxOwnedZones to
-- find the nearest non-owned zone and direct the group there
-- USAGE
-- Allocate a group in game and issue them marching orders towars a goal
-- then createGroundTroops to allocate a structure used by this
-- module and addTroopsToPool to have them then managed by this
-- module
cfxGroundTroops.deployedTroops = {}
-- version history
-- 1.3.0 - added "wait-" prefix to have toops do nothing
-- - added lazing
-- 1.3.1 - sound for lazing msg is "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav"
-- - lazing --> lasing in text
-- 1.3.2 - set ups to 2
-- 1.4.0 - queued updates except for lazers
-- 1.4.1 - makeTroopsEngageZone now issues hold before moving on 5 seconds later
-- - getTroopReport
-- - include size of group
-- 1.4.2 - uses unitIsInfantry from dcsCommon
-- 1.5.0 - new scheduled updates per troop to reduce processor load
-- - tiebreak code
-- 1.5.1 - small bugfix in scheduled code
-- 1.5.2 - checkSchedule
-- - speed warning in scheduler
-- - go off road when speed warning too much
-- 1.5.3 - monitor troops
-- - managed queue for ground troops
-- - on second switch to offroad now removed from MQ
-- 1.5.4 - removed debugging messages
-- 1.5.5 - removed bug in troop report reading nil destination
-- 1.6.0 - check modules
-- 1.6.1 - troopsCallback management so you can be informed if a
-- troop you have added to the pool is dead or has achieved a goal.
-- callback will list reasons "dead" and "arrived"
-- updateAttackers
-- 1.6.2 - also accept 'lase' as 'laze', translate directly
-- 1.7.0 - now can use groundTroopsConfig zone
-- 1.7.1 - addTroopsDeadCallback() renamed to addTroopsCallback()
-- - invokeCallbacksFor also accepts and passes on data block
-- - troops is always passed in data block as .troops
-- 1.7.2 - callback when group is neutralized on guard orders
-- - callback when group is being engaged under guard orders
-- 1.7.3 - callbacks for lase:tracking and lase:stop
-- 1.7.4 - verbose flag, warnings suppressed
-- 1.7.5 - some troop.group hardening with isExist()
-- 1.7.6 - fixed switchToOffroad
-- an entry into the deployed troop has the following attributes
-- - group - the group
-- - orders: "guard" - will guard the spot and look for enemies in range
-- "patrol" - will walk between way points back and forth
-- "laze" - will stay in place and try to laze visible vehicles in range
-- "attackOwnedZone" - interface to cfxOwnedZones module, seeks out
-- enemy zones to attack and capture them
-- "wait-<some other orders>" do nothing. the "wait" prefix will be removed some time and <some other order> then revealed. Used at least by heloTroops
-- "train" - target dummies. ROE=HOLD, no ground loop
-- "attack" - transition to destination, once there, stop and
-- switch to guard. requires destination zone be sez to a valid cfxZone
-- - coalition - the coalition from the group
-- - enemy - if set, the group this group it is engaging. this means the group is fighting and not idle
-- - name - name of group, dan be freely changed
-- - signature - "cfx" to tell apart from dcs groups
-- - range = range to look for enemies. default is 300m. In "laze" orders, range to laze
-- - lazeTarget - target currently lazing
-- - lazeCode - laser code. default is 1688
--
-- usage:
-- take a dcs group of ground troops and create a cfx ground troop record with
-- createGroundTroops()
-- then add this to the manager with
-- addGroundTroopsToPool()
--
-- you can control what the group is to do by changing the cfx troop attribute orders
-- you can install a callback that will notify you if a troop reached a goal or
-- was killed with addTroopsCallback() which will also give a reason
-- callback pattern is myCallback(reason, theGroup, orders, data) with troop being the
-- group, and orders the original orders, and reason a string containing why the
-- callback was invoked. Currently defined reasons are
-- - "dead" - entire group was killed
-- - "arrived" - at least a part of group arrived at destination (only with some orders)
--
--
-- UPDATE MODELS
-- standard is update all every time: fastest, but may cause
-- performance issues
-- queued will work one every pass (except for lazed), distributing the load much better
-- schedueld installs a callback for each group separately and thus distributes the load over time much better
cfxGroundTroops.queuedUpdates = false -- set to true to process one group per turn. To work this way, scheduledUpdates must be false
cfxGroundTroops.scheduledUpdates = true -- set to false to allow queing of standard updates. overrides queuedUpdates
cfxGroundTroops.monitorNumbers = false -- set to true to debug managed group size
cfxGroundTroops.standardScheduleInterval = 30 -- 30 seconds between calls
cfxGroundTroops.guardUpdateInterval = 30 -- every 30 seconds we check up on guards
cfxGroundTroops.trackingUpdateInterval = 0.5 -- 0.5 seconds for lazer tracking etc
cfxGroundTroops.maxManagedTroops = 67 -- -1 is infinite, any positive number turn on cap on managed troops and palces excess troops in queue
cfxGroundTroops.troopQueue = {} -- FIFO stack
-- return the best tracking interval for this type of orders
--
-- READ CONFIG ZONE TO OVERRIDE SETTING
--
function cfxGroundTroops.readConfigZone()
-- note: must match exactly!!!!
local theZone = cfxZones.getZoneByName("groundTroopsConfig")
if not theZone then
if cfxGroundTroops.verbose then
trigger.action.outText("***gndT: NO config zone!", 30)
end
return
end
-- ok, for each property, load it if it exists
if cfxZones.hasProperty(theZone, "queuedUpdates") then
cfxGroundTroops.queuedUpdates = cfxZones.getBoolFromZoneProperty(theZone, "queuedUpdates", false)
end
if cfxZones.hasProperty(theZone, "scheduledUpdates") then
cfxGroundTroops.scheduledUpdates = cfxZones.getBoolFromZoneProperty(theZone, "scheduledUpdates", false)
end
if cfxZones.hasProperty(theZone, "maxManagedTroops") then
cfxGroundTroops.maxManagedTroops = cfxZones.getNumberFromZoneProperty(theZone, "maxManagedTroops", 65)
end
if cfxZones.hasProperty(theZone, "monitorNumbers") then
cfxGroundTroops.monitorNumbers = cfxZones.getBoolFromZoneProperty(theZone, "monitorNumbers", false)
end
if cfxZones.hasProperty(theZone, "standardScheduleInterval") then
cfxGroundTroops.standardScheduleInterval = cfxZones.getNumberFromZoneProperty(theZone, "standardScheduleInterval", 30)
end
if cfxZones.hasProperty(theZone, "guardUpdateInterval") then
cfxGroundTroops.guardUpdateInterval = cfxZones.getNumberFromZoneProperty(theZone, "guardUpdateInterval", 30)
end
if cfxZones.hasProperty(theZone, "trackingUpdateInterval") then
cfxGroundTroops.trackingUpdateInterval = cfxZones.getNumberFromZoneProperty(theZone, "trackingUpdateInterval", 0.5)
end
if cfxZones.hasProperty(theZone, "verbose") then
cfxGroundTroops.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false)
end
if cfxGroundTroops.verbose then
trigger.action.outText("+++gndT: read config zone!", 30)
end
end
--
-- Callback handling
--
cfxGroundTroops.troopsCallback = {}
function cfxGroundTroops.addTroopsCallback(theCallback)
table.insert(cfxGroundTroops.troopsCallback, theCallback)
end
function cfxGroundTroops.invokeCallbacksFor(reason, troops, data)
if not data then data = {} end
data.troops = troops
for idx, theCB in pairs (cfxGroundTroops.troopsCallback) do
theCB(reason, troops.group, troops.orders, data)
end
end
function cfxGroundTroops.getScheduleInterval(orders)
if orders == "laze" then
return cfxGroundTroops.trackingUpdateInterval
end
return cfxGroundTroops.standardScheduleInterval
end
-- create controller commands to attack a group "enemies"
-- enemies are an attribute of the troop structure
function cfxGroundTroops.makeTroopsEngageEnemies(troop)
local group = troop.group
if not group:isExist() then
trigger.action.outText("+++gndT: troup don't exist, dropping", 30)
return
end
local enemies = troop.enemy
local from = dcsCommon.getGroupLocation(group)
if not from then return end -- the commandos died
local there = dcsCommon.getGroupLocation(enemies)
if not there then return end
-- we lerp to 2/3 of enemy location
there = dcsCommon.vLerp(from, there, 0.66)
local speed = 10 -- m/s = 10 km/h
cfxCommander.makeGroupGoThere(group, there, speed)
local attask = cfxCommander.createAttackGroupCommand(enemies)
cfxCommander.scheduleTaskForGroup(group, attask, 0.5)
end
-- make the troops engage a cfxZone passed in the destination
-- attribute
function cfxGroundTroops.makeTroopsEngageZone(troop)
local group = troop.group
if not group:isExist() then
trigger.action.outText("+++gndT: make engage zone: troops do not exist, exiting", 30)
return
end
local enemyZone = troop.destination -- must be cfxZone
local from = dcsCommon.getGroupLocation(group)
if not from then return end -- the group died
local there = enemyZone.point -- access zone position
if not there then return end
-- we lerp to 102% of enemy location to force overshoot and engagement
--there = dcsCommon.vLerp(from, there, 1.02)
local speed = 14 -- m/s; 10 m/s = 36 km/h
-- we prefer going over roads since we don't know
-- what is there
-- make troops stop in 1 second, then start in 5 seconds to give AI respite
cfxCommander.makeGroupHalt(group, 1) -- 1 second delay
cfxCommander.makeGroupGoTherePreferringRoads(group, there, speed, 5)
-- no attack command since we don't know what is there
-- but mayhaps we should issue weapons free?
-- we'll soon test that by sticking in a troop on the way
-- local attask = cfxCommander.createAttackGroupCommand(enemies)
-- cfxCommander.scheduleTaskForGroup(group, attask, 0.5)
end
function cfxGroundTroops.switchToOffroad(troops)
-- we may need to test if we already did this,
-- but not for now
-- this is called when troops are stuck
-- on their route for longer than allowed
-- we now force a direct approach
local group = troops.group
if not group:isExist() then
return
end
local enemies = troops.destination
local from = dcsCommon.getGroupLocation(group)
if not from then return end -- the commandos died
local there = enemies.point
if not there then return end
local speed = 14 -- m/s; 10 m/s = 36 km/h
cfxCommander.makeGroupHalt(group, 0) -- no delay, halt now
cfxCommander.makeGroupGoThere(group, there, speed, "Off Road", 5)
troops.lastOrderDate = timer.getTime()
troops.speedWarning = 0
end
--
-- update loop for troops that have 'attackOwnedZones' as
-- their orders
-- if they have no destination zone, or the zone they are
-- are heading for is already owned by their side, then look for
-- the closest enemy zone, and cut attack orders to move there
function cfxGroundTroops.getClosestEnemyZone(troop)
local p = dcsCommon.getGroupLocation(troop.group)
local tempZone = cfxZones.createSimpleZone("tz", p, 100)
tempZone.owner = troop.side
local newTarget = cfxOwnedZones.getNearestEnemyOwnedZone(tempZone, true) -- 'true' will also target neutral zones
return newTarget
end
function cfxGroundTroops.updateZoneAttackers(troop)
if not troop then return end
troop.insideDestination = false -- mark as not inside
local newTargetZone = cfxGroundTroops.getClosestEnemyZone(troop)
if not newTargetZone then
-- all target zones are friendly, go to guard mode
-- trigger.action.outTextForCoalition(troop.side, troop.name .. " holding position", 30)
troop.orders = "guard"
return
end
if newTargetZone ~= troop.destination then
-- trigger.action.outTextForCoalition(troop.side, troop.name .. " enroute to " .. newTargetZone.name, 30)
troop.destination = newTargetZone
cfxGroundTroops.makeTroopsEngageZone(troop)
troop.lastOrderDate = timer.getTime()
troop.speedWarning = 0
return
end
-- if we get here, we are under way to troop.destination
-- check if we are inside the zone, and if so, set variable to true
local p = dcsCommon.getGroupLocation(troop.group)
troop.insideDestination = cfxZones.isPointInsideZone(p, troop.destination)
-- if we get here, we need no change
end
-- attackers simply travel to their destination, and then switch to
-- guard orders once they arrive
function cfxGroundTroops.updateAttackers(troop)
if not troop then return end
if not troop.destination then return end
if not troop.group:isExist() then return end
if cfxZones.isGroupPartiallyInZone(troop.group, troop.destination) then
-- we have arrived
-- we could now also initiate a general callback with reason
cfxGroundTroops.invokeCallbacksFor("arrived", troop)
troop.orders = "guard"
return
end
-- if we get here, we need no change
end
-- update loop for a group that has "guard" orders.
-- basically it stands around and looks for enemies
-- until it finds a group, and then engages the enemy
-- when engaged, it is not looking for other enemies
-- 'engaged' means that the troop.enemy attribute is set
function cfxGroundTroops.updateGuards(troop)
if not troop.group:isExist() then
return
end
local theEnemy = troop.enemy
if theEnemy then
-- see if enemy is dead
if not dcsCommon.isGroupAlive(theEnemy) then
troop.enemy = nil
-- yup, zed's dead. next time around, we won't be checking this again
trigger.action.outText(troop.name .. " has neutralized enemy forces", 30)
--DONE: invoke callback for defeating troops
local data = {}
data.enemy = theEnemy
cfxGroundTroops.invokeCallbacksFor("neutralized", troop, data)
return
end
-- yes, we are still engaged
return
end
-- we are currently unengaged. look for an enemy
if not troop.range then troop.range = 300 end
troop.coalition = troop.group:getCoalition()
local enemyCoal = dcsCommon.getEnemyCoalitionFor(troop.coalition)
local cat = Group.Category.GROUND
local p = dcsCommon.getGroupLocation(troop.group)
local enemies, enemyDist = dcsCommon.getClosestLivingGroupToPoint(p, enemyCoal, cat)
local maxRange = troop.range -- meters
-- if we have enemies then schedule a path to go there
if enemies and (enemyDist < maxRange) then
troop.enemy = enemies
--timer.scheduleFunction(cfxGroundTroops.makeGroupEngageEnemies, troop, timer.getTime() + 1.0)
cfxGroundTroops.makeTroopsEngageEnemies(troop)
trigger.action.outText(troop.name .. " is engaging enemy forces at range " .. math.floor(enemyDist) .. "meters", 30)
--DONE: invoke callback for engaging troops, pass data
local data = {}
data.enemy = enemies
cfxGroundTroops.invokeCallbacksFor("engaging", troop, data)
elseif enemies then
--trigger.action.outText(troop.name .. " enemiy out of range: " .. math.floor(enemyDist) .. "meters", 30)
else
--trigger.action.outText(troop.name .. " no enemies", 30)
end
end
--
-- update loop for units that laze targets.
-- they can only laze if they are alive, but update
-- will take care of that, so when we are here, there
-- is at least one of them alive
--
function cfxGroundTroops.findLazeTarget(troop)
local here = troop.group:getUnit(1):getPoint()
troop.coalition = troop.group:getCoalition()
local enemyCoal = dcsCommon.getEnemyCoalitionFor(troop.coalition)
--local enemySide = dcsCommon.getEnemyCoalitionFor(troop.side)
local cat = Group.Category.GROUND
local enemyGroups = dcsCommon.getLivingGroupsAndDistInRangeToPoint(here, troop.range, enemyCoal, cat)
-- we now have a list of possible targets in range
if #enemyGroups < 1 then
-- no targets in range
return nil
end
here = {x = here.x, y = here.y + 2.0, z = here.z} -- raise by 2.0m
-- iterate through the list until we find the first target
-- that fits the bill and return it
-- trigger.action.outText("+++ looking at " .. #enemyGroups .. " laze groups", 30)
for i=1, #enemyGroups do
-- get all units for this group
local aGroup = enemyGroups[i].group -- remember, they are in a {dist, group} tuple
local theUnits = aGroup:getUnits()
-- iterate all units
for udx, aUnit in pairs(theUnits) do
if (aUnit:isExist() and aUnit:getLife() > 1) then
-- unit lives
-- now, we need to filter infantry. we do this by
-- pre-fetching the typeString
--troop.lazeTargetType = aUnit:getTypeName()
-- and checking if the name contains some infantry-
-- typical strings. Idea taken from JTAC script
local isInfantry = dcsCommon.unitIsInfantry(theUnit)
if not isInfantry then
-- this is a vehicle, is it in line of sight?
-- raise the point 2m above ground for both points
-- as done in jtac script
local there = aUnit:getPoint()
there = {x = there.x, y = there.y + 2.0, z = there.z}
if land.isVisible(here, there) then
-- we found a visible vehicle in
-- the nearest group to us in range
-- that is visible!
return aUnit
else
--trigger.action.outText("+++ ".. aUnit:getName() .."cant be seen", 30)
end -- if visible
else
-- trigger.action.outText("+++ ".. aUnit:getName() .." (".. troop.lazeTargetType .. ") is infantry", 30)
end -- if not infantry
end -- if alive
end -- for all units
end -- for all enemy groups
--trigger.action.outText("+++ find nearest laze target did not find anything to laze", 30)
return nil -- no unit found
end
function cfxGroundTroops.lazerOff(troop)
if troop.lazerPointer then
troop.lazerPointer:destroy()
end
troop.lazerPointer = nil
troop.lazingUnit = nil
end
function cfxGroundTroops.trackLazer(troop)
-- the only thing that must be set when entering here is
-- lazeTarget. We set up the rest
if not troop.lazingUnit then
troop.lazingUnit = troop.group:getUnit(1) -- get first unit
if troop.lazingUnit:getLife() < 1 then
trigger.action.outText("+++ LazingUnit is dead, getUnit works differently from what docs say, need to filter for lively units", 30)
end
end
if not troop.lazerPointer then
local there = troop.lazeTarget:getPoint()
troop.lazerPointer = Spot.createLaser(troop.lazingUnit,{x = 0, y = 2, z = 0}, there, 1688)
troop.lazeTargetType = troop.lazeTarget:getTypeName()
trigger.action.outTextForCoalition(troop.side, troop.name .. " tally target - lasing " .. troop.lazeTargetType .. "!", 30)
trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav")
troop.lastLazerSpot = there -- remember last spot
local data = {}
data.enemy = troop.lazeTarget
data.tracker = troop.lazingUnit
cfxGroundTroops.invokeCallbacksFor("lase:tracking", troop, data)
return
end
-- if true then return end
-- if we get here, we update the lazerPointer
local there = troop.lazeTarget:getPoint()
-- we may only want to update the laser spot when dist > trigger
troop.lazerPointer:setPoint(there)
-- we may want to report dist
troop.lastLazerSpot = there
end
function cfxGroundTroops.updateLaze(troop)
-- check if we have a laze target.
-- check if lazing unit was killed, and therefore lost target
if troop.lazingUnit then
-- check that unit still alive
if troop.lazingUnit:isExist() and
troop.lazingUnit:getLife() >= 1 then
else
cfxGroundTroops.lazerOff(troop)
troop.lazeTarget = nil
trigger.action.outTextForCoalition(troop.side, troop.name .. " reports lasing " .. troop.lazeTargetType .. " interrupted. Re-acquiring.", 30)
trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav")
troop.lazingUnit = nil
cfxGroundTroops.invokeCallbacksFor("lase:stop", troop)
return -- we'll re-acquire through a new unit next round
end
end
-- if we get here, a lazing unit
--local here = troop.lazingUnit:getPoint()
if troop.lazeTarget then
-- check if that target is alive and in range
if troop.lazeTarget:isExist() and troop.lazeTarget:getLife() >= 1 then
-- note: when we laze a target, we know that we have a lazing unit
local here = troop.lazingUnit:getPoint()
-- check if it has moved out of range
local there = troop.lazeTarget:getPoint()
if dcsCommon.dist(here, there) > troop.range then
-- troop out of range
trigger.action.outTextForCoalition(troop.side, troop.name .. " lost sight of lazed target " .. troop.lazeTargetType, 30)
trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav")
troop.lazeTarget = nil
cfxGroundTroops.lazerOff(troop)
troop.lazingUnit = nil
cfxGroundTroops.invokeCallbacksFor("lase:stop", troop)
return
end
-- if we get here, we need to update the target point
cfxGroundTroops.trackLazer(troop)
return
else
-- target died
trigger.action.outTextForCoalition(troop.side, troop.name .. " confirms kill for " .. troop.lazeTargetType, 30)
trigger.action.outSoundForCoalition(troop.side, "UI_SCI-FI_Tone_Bright_Dry_20_stereo.wav")
troop.lazeTarget = nil
cfxGroundTroops.lazerOff(troop)
troop.lazingUnit = nil
cfxGroundTroops.invokeCallbacksFor("lase:stop", troop)
return
end
end
-- if we get here, we must look for a laze target
troop.lazeTarget = cfxGroundTroops.findLazeTarget(troop)
if troop.lazeTarget then
cfxGroundTroops.trackLazer(troop) -- will also set up lazing unit
end
end
function cfxGroundTroops.updateWait(troop)
-- currently nothing to do
end
function cfxGroundTroops.updateTroops(troop)
-- if orders start with "wait-" then the troops
-- simply do nothing
if dcsCommon.stringStartsWith(troop.orders, "wait-") then
-- the troops are waiting to be picked update
-- when they are dropped again, thre prefix to
-- their order is removed, and the 'real' orders
-- are revealed. For now, do nothing
cfxGroundTroops.updateWait(troop)
elseif troop.orders == "guard" then
cfxGroundTroops.updateGuards(troop)
elseif troop.orders == "attackOwnedZone" then
cfxGroundTroops.updateZoneAttackers(troop)
elseif troop.orders == "laze" then
cfxGroundTroops.updateLaze(troop)
elseif troop.orders == "attackZone" then
cfxGroundTroops.updateAttackers(troop)
else
trigger.action.outText("+++ updated troops " .. troop.name .. " have unknown orders " .. troop.orders, 30)
end
end
--
-- we have to systems to process during update:
-- once all, and one per turn, with the exception
-- of lazers, who get updated every turn
--
--
-- all at once
--
function cfxGroundTroops.update()
cfxGroundTroops.updateSchedule = timer.scheduleFunction(cfxGroundTroops.update, {}, timer.getTime() + 1/cfxGroundTroops.ups)
-- iterate all my troops and build next
-- versions pool
local liveTroops = {}
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
local group = troop.group
if not dcsCommon.isGroupAlive(group) then
-- group dead. remove from pool
-- this happens by not copying it into the poos
-- trigger.action.outText("+++ removing ground troops " .. troop.name, 30)
cfxGroundTroops.invokeCallbacksFor("dead", troop) -- notify anyone who is interested that we are no longer proccing these
else
-- work with this groop according to its orders
cfxGroundTroops.updateTroops(troop)
-- trigger.action.outText("+++ updated troops " .. troop.name, 30)
-- since group is alive remember it for next loop
--table.insert(liveTroops, troop)
liveTroops[idx] = troop -- do NOT use insert as we have indexed table
end
end
-- liveTroops holds all troops that are still alive and will
-- be revisited next loop
cfxGroundTroops.deployedTroops = liveTroops
end
--
-- UpdateQueued looks for the first unordered (.receivedOrders == false) group
-- and processes them. if orders are 'laze', it will always be ordered
--
function cfxGroundTroops.updateQueued()
cfxGroundTroops.updateSchedule = timer.scheduleFunction(cfxGroundTroops.updateQueued, {}, timer.getTime() + 1/cfxGroundTroops.ups)
-- iterate all my troops and build next
-- versions pool
local liveTroops = {}
local hasOrdered = false -- so far, no orders have been given
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
local group = troop.group
if not dcsCommon.isGroupAlive(group) then
-- group dead. remove from pool
-- this happens by not copying it to liveTroops
-- trigger.action.outText("+++ removing ground troops " .. troop.name, 30)
cfxGroundTroops.invokeCallbacksFor("dead", troop) -- notify anyone who is interested that we are no longer proccing these
else
-- check if this is a lazer
if troop.orders == "laze" then
-- lazers are updated each turn
cfxGroundTroops.updateLaze(troop)
else
if not hasOrdered and not (troop.receivedOrders) then
-- work with this groop according to its orders
cfxGroundTroops.updateTroops(troop)
troop.receivedOrders = true -- this one has received orders
hasOrdered = true
end
end
liveTroops[idx] = troop -- do NOT use insert as we have indexed table
end
end
-- liveTroops holds all troops that are still alive and will
-- be revisited next loop
cfxGroundTroops.deployedTroops = liveTroops
-- if no orders have been passed, clear all troop's .receivedOrders flag
-- and the loop starts anew next loop
if not hasOrdered then
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
troop.receivedOrders = nil
end
end
end
--
-- in updateCheckOnly we simply check the ground queue
-- if there are troops added that need scheduling (i.e. have
-- been passed in by addTroops and schedule them
--
function cfxGroundTroops.updateCheckOnly()
-- re-schedule myself in 1 second
timer.scheduleFunction(cfxGroundTroops.updateCheckOnly, {}, timer.getTime() + 1)
-- iterate through all troops, and
-- see if there are any that have not been scheduled
-- to schedule them for updates in 1 second
-- that will be the first time that they are scheduled,
-- all others will be self-scheduled
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
if not troop.hasBeenScheduled then
local params = {troop}
troop.hasBeenScheduled = true
troop.updateID = timer.scheduleFunction(cfxGroundTroops.updateSingleScheduled, params, timer.getTime() + 1)
--trigger.action.outText("+++groundT: scheduling troops <".. troop.group:getName() .."> with orders <" .. troop.orders .. ">", 30)
end
end
-- note that alive checks are now done during the scheduled
-- update, not every time for all
end
function cfxGroundTroops.updateSingleScheduled(params)
local troops = params[1]
troops.updateID = nil -- erase update id
if not troops then
trigger.action.outText("+++groundT WARNING: nil troop in updateSingle", 30)
return -- no further action required, no longer updates
end
local group = troops.group
-- see if we have been taken out of the pool or updated
-- if so, exit
if not group:isExist() then
-- simply never again look at it.
return
end
if cfxGroundTroops.deployedTroops[troops.group:getName()] ~= troops then
-- trigger.action.outText("+++groundT NOTE: troops <".. troops.group:getName() .."> was removed from pool. Cancel Update", 30)
return -- no further reschedule
end
-- see if scheduling is turned off
if not troops.reschedule then
trigger.action.outText("+++groundT NOTE: no longer updating <".. troops.group:getName() .."> per reschedule param", 30)
return
end
-- now, check if still alive
if not dcsCommon.isGroupAlive(group) then
-- group dead, no longer updates
--trigger.action.outText("+++groundT NOTE: <".. troops.group:getName() .."> dead, removing", 30)
cfxGroundTroops.invokeCallbacksFor("dead", troops) -- notify anyone who is interested that we are no longer proccing these
cfxGroundTroops.removeTroopsFromPool(troops)
return -- nothing else to do
end
-- now, execute the update itself, standard update
--trigger.action.outText("+++groundT: singleU troop <".. troops.group:getName() .."> with orders <" .. troops.orders .. ">", 30)
cfxGroundTroops.updateTroops(troops)
-- check max speed of group. if < 0.1 then note and increase
-- speedWarning. if not, reset speed warning
if troops.orders == "attackOwnedZone" and dcsCommon.getGroupMaxSpeed(troops.group) < 0.1 then
if not troops.speedWarning then troops.speedWarning = 0 end
troops.speedWarning = troops.speedWarning + 1
else
troops.speedWarning = 0 -- reset
end
if troops.speedWarning > 5 then -- make me 5
lastOrder = timer.getTime() - troops.lastOrderDate
--trigger.action.outText("+++groundT WARNING: <".. troops.group:getName() .."> (S:".. troops.side .. ") to " .. troops.destination.name .. ": stopped for " .. troops.speedWarning .. " iters, orderage=" .. lastOrder, 30)
-- this may be a matter of too many waypoints.
-- maybe issue orders to go to their destination directly?
-- now force an order to go directly.
if troops.speedWarning > 5 then
if troops.isOffroad then
-- we already switched to off-road. take me
-- out of the managed queue, I'm not going
-- anywhere
-- trigger.action.outText("+++groundT <".. troops.group:getName() .."> is going nowhere. Removed from managed troops", 30)
cfxGroundTroops.removeTroopsFromPool(troops)
else
cfxGroundTroops.switchToOffroad(troops)
-- trigger.action.outText("+++groundT <".. troops.group:getName() .."> SWITCHED TO OFFROAD", 30)
troops.isOffroad = true -- so we know that we already did that
end
end
end
-- now reschedule updte for my best time
local updateTime = cfxGroundTroops.getScheduleInterval(troops.orders)
troops.updateID = timer.scheduleFunction(cfxGroundTroops.updateSingleScheduled, params, timer.getTime() + updateTime)
end
--
-- PILEUP and TIE BRAKERS
--
-- there may come a situation where troops gather in
-- one zone because the zone isn't won - some other troops
-- are there and noone moves.
-- a tie-break is required
--
-- checkpile up: every so often, we test if we have run into a
-- pileup-situation. this happens if there are more than n
-- units with group-attacker order in the same zone, and that
-- zone is their destination
-- this can be easily detected by the insideDestination flag
-- checkPileUp should be run every minute or so
function cfxGroundTroops.checkPileUp()
-- schedule my next call
--trigger.action.outText("+++groundT: pileup check", 30)
timer.scheduleFunction(cfxGroundTroops.checkPileUp, {}, timer.getTime() + 60)
local thePiles = {}
if not cfxOwnedZones then
-- trigger.action.outText("+++groundT: pileUp - owned zones not yet ready", 30)
return
end
-- create a list of all piles
for idx, oz in pairs(cfxOwnedZones.zones) do
local newPile = {}
newPile[1] = 0 -- no red inZone here
newPile[2] = 0 -- no blue inZone here
newPile.zone = oz -- the zone we are looking at
thePiles[oz] = newPile
end
-- now iterate through all currently alive groups and
-- attribute them to their piles
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
-- get each group and count them if they are inside
-- their destination
if troop.insideDestination and troop.group:isExist() then
local side = troop.group:getCoalition()
local thePile = thePiles[troop.destination]
local theSide = troop.group:getCoalition()
thePile[theSide] = thePile[theSide] + 1 -- we count groups, not units
end
end
-- a pileup happens, if there are more than 3 groups in destination zone
-- with NO other troops present (usually the case)
-- or when there are 5 groups more than the number for the other side
-- so now scan all piles
for idx, thePile in pairs(thePiles) do
-- check red pileup
if thePile[1] > 3 and thePile[2] == 0 then
-- simple pileup. 3 groups, no others except defenders and
-- perhaps transients
cfxGroundTroops.breakTie(thePile, 1)
elseif thePile[1] >= thePile[2] + 5 then
-- numerical pileup
cfxGroundTroops.breakTie(thePile, 1)
end
-- check blue loside
if thePile[2] >= 3 and thePile[1] == 0 then
-- simple pileup. 3 groups, no others except defenders and
-- perhaps transients
cfxGroundTroops.breakTie(thePile, 2)
elseif thePile[2] >= thePile[1] + 5 then
-- numerical pileup
cfxGroundTroops.breakTie(thePile, 2)
end
end
end
function cfxGroundTroops.breakTie(thePile, winner)
trigger.action.outText("+++ groundT: TIEBREAK - winner is " .. winner .. " in zone " .. thePile.zone.name .. ": " .. thePile[1] .. ":" .. thePile[2] , 30)
-- now add some code to do the actual tie breaking: remove all units that
-- are inside the zone and who belong to the other side
local loser = 1 -- red default
local theZone = thePile.zone
if winner == 1 then loser = 2 end
-- now get all ground groups for the losing side
local losingGround = coalition.getGroups(loser, Group.Category.GROUND)
for idx, theGroup in pairs(losingGround) do
-- if alive, check if inside the zone
if theGroup:isExist() and dcsCommon.isGroupAlive(theGroup) then
-- make sure it's not a transient
if not isDeployedGroundTroop(theGroup) then
local p = dcsCommon.getGroupLocation(theGroup)
if cfxZones.isPointInsideZone(p, theZone) then
trigger.action.outText("+++ groundT: TIEBREAK - destroying group " .. theGroup:getName() , 30)
-- we delete this group now
theGroup:destroy()
end
end
end
end
end
--
-- sanity checks for rescheduling
--
function cfxGroundTroops.checkSchedules()
timer.scheduleFunction(cfxGroundTroops.checkSchedules, {}, timer.getTime() + 10)
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
-- check if troop is not scheduled
-- if this happens to a group more than a certain times,
-- it has somehow dropped out of the reschedule
-- plan and needs to be scheduled
if troop.updateID == nil then
troop.unscheduleCount = troop.unscheduleCount + 1
if (troop.unscheduleCount > 1) and troop.group:isExist() then
trigger.action.outText("+++ groundT: unscheduled group " .. troop.group:getName() .. " cnt=" .. troop.unscheduleCount , 30)
end
end
end
end
--
-- REPORTING
--
--
-- get a report of troops as string
--
function cfxGroundTroops.getTroopReport(theSide, ignoreInfantry)
if not ignoreInfantry then ignoreInfantry = false end
local report = "GROUND FORCES REPORT"
for idx, troop in pairs(cfxGroundTroops.deployedTroops) do
if troop.side == theSide and troop.group:isExist() then
local unitNum = troop.group:getSize()
report = report .. "\n" .. troop.name .. " (".. unitNum .."): <" .. troop.orders .. ">"
if troop.orders == "attackOwnedZone" then
if troop.destination then
report = report .. " move towards " .. troop.destination.name
else
report = report .. " (selecting destination)"
end
end
end
end
report = report .. "\n---END REPORT\n"
return report
end
--
-- CREATE / ADD / REMOVE
--
--
-- createGroundTroop
-- use this to create a cfxGroundTroops from a dcs group
--
function cfxGroundTroops.createGroundTroops(inGroup, range, orders)
local newTroops = {}
if not orders then
orders = "guard"
--trigger.action.outText("+++ adding ground troops <".. inGroup:getName() ..">with default orders", 30)
else
--trigger.action.outText("+++ adding ground troops <".. inGroup:getName() ..">with orders " .. orders, 30)
end
if orders:lower() == "lase" then
orders = "laze" -- we use WRONG spelling here, cause we're cool
end
newTroops.insideDestination = false
newTroops.unscheduleCount = 0 -- will count up as we aren't scheduled
newTroops.speedWarning = 0
newTroops.isOffroad = false -- if true, we switched to direct orders, not roads, after standstill
newTroops.group = inGroup
newTroops.orders = orders
newTroops.coalition = inGroup:getCoalition()
newTroops.side = newTroops.coalition -- because we'e been using both.
newTroops.name = inGroup:getName()
newTroops.signature = "cfx" -- to verify this is groundTroop group, not dcs groups
if not range then range = 300 end
newTroops.range = range
return newTroops
end
function cfxGroundTroops.addGroundTroopsToPool(troops) -- troops MUST be a table that I understand, with
if not troops then return end
if troops.signature ~= "cfx" then
trigger.action.outText("+++ adding ground troops with unsupported troop signature", 30)
return
end
troops.reschedule = true -- in case we use scheduled update
-- we now add to internal array. this is worked on by all
-- update meths, on scheduled upadtes, it is only used to
-- pick up, and do the initial schedule, after that they
-- all re-schedule themselves
troops.hasBeenScheduled = false -- so far, no updates
-- hasBeenScheduled is used by updateCheckOnly when scheduled
-- updates are used.
-- now add to actively managed table or queue it if enabled
if cfxGroundTroops.maxManagedTroops > 0 and dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops) >= cfxGroundTroops.maxManagedTroops then
-- we need to queue
table.insert(cfxGroundTroops.troopQueue, troops)
-- trigger.action.outText("enqued " .. troops.group:getName() .. " at pos ".. #cfxGroundTroops.troopQueue ..", manage cap surpassed.", 30)
else
-- add to deployed set
cfxGroundTroops.deployedTroops[troops.group:getName()] = troops
end
end
function cfxGroundTroops.removeTroopsFromPool(troops)
if not troops then return end
if troops.signature ~= "cfx" then return end
if not troops.group:isExist() then
trigger.action.outText("warning: removeFromPool called with inexistant group", 30)
return
end
if cfxGroundTroops.deployedTroops[troops.group:getName()] then
local troop = cfxGroundTroops.deployedTroops[troops.group:getName()]
troops.reschedule = false -- so a reschedule wont update any more
cfxGroundTroops.deployedTroops[troops.group:getName()] = nil
return
end
-- if we get here, we need to check if perhaps the troops
-- are in the queue
for i=1, #cfxGroundTroops.troopQueue do
if cfxGroundTroops.troopQueue[i] == troops then
table.remove(cfxGroundTroops.troopQueue, i)
return
end
end
end
function isDeployedGroundTroop(aGroup)
if not aGroup then return false end
-- see if its already managed
if cfxGroundTroops.deployedTroops[aGroup:getName()] ~= nil then
return true
end
-- see if it's in the queue
for i=1, #cfxGroundTroops.troopQueue do
if cfxGroundTroops.troopQueue[i] == troops then
return true
end
end
-- if we get here, it's neither managed nor queued
return false
-- return cfxGroundTroops.deployedTroops[aGroup:getName()] ~= nil
end
function cfxGroundTroops.getGroundTroopsForGroup(aGroup)
if not (cfxGroundTroops.deployedTroops[aGroup:getName()]) then
-- see if it's queued
for i=1, #cfxGroundTroops.troopQueue do
local troops = cfxGroundTroops.troopQueue[i]
if troops.group == aGroup then
return troops
end
end
if cfxGroundTroops.verbose then
trigger.action.outText("+++gndT - WARNING: cannot find group " .. aGroup:getName() .. " for troop retrieval. Known troops are:", 30)
end
for k,v in pairs(cfxGroundTroops.deployedTroops) do
trigger.action.outText("+++ ".. k .. ": has v: " .. v.name, 30)
end
return nil
end
return cfxGroundTroops.deployedTroops[aGroup:getName()]
end
function cfxGroundTroops.monitorQueues()
timer.scheduleFunction(cfxGroundTroops.monitorQueues, {}, timer.getTime() + 5)
-- calculate the numbers
local num = dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops)
local msg = "+++ gndT - Groups Managed: <" .. num .. ">"
-- display the numbers
if cfxGroundTroops.maxManagedTroops > 0 then
msg = msg .. " capped at " .. cfxGroundTroops.maxManagedTroops .. ", q size is <" .. #cfxGroundTroops.troopQueue .. ">"
end
trigger.action.outText(msg, 30)
end
-- manageQueue: if depth of deployedTroops is below max and we have
-- items in queue, pop off first one and put in managed table
-- checked once every 2 seconds
function cfxGroundTroops.manageQueues()
timer.scheduleFunction(cfxGroundTroops.manageQueues, {}, timer.getTime() + 2)
if cfxGroundTroops.maxManagedTroops < 1 then return end
-- if we get here, we have a limit on managed
-- items
if #cfxGroundTroops.troopQueue < 1 then return end
-- if we here, there are items waiting in the queue
while dcsCommon.getSizeOfTable(cfxGroundTroops.deployedTroops) < cfxGroundTroops.maxManagedTroops and #cfxGroundTroops.troopQueue > 0 do
-- trnasfer items from the front to the managed queue
local theTroops = cfxGroundTroops.troopQueue[1]
table.remove(cfxGroundTroops.troopQueue, 1)
if theTroops.group:isExist() then
cfxGroundTroops.deployedTroops[theTroops.group:getName()] = theTroops
end
-- trigger.action.outText("+++gT: dequed and activaed " .. theTroops.group:getName(), 30)
end
end
function cfxGroundTroops.start()
if not dcsCommon.libCheck("cfx Ground Troops",
cfxGroundTroops.requiredLibs)
then
trigger.action.outText("cf/x Ground Troops aborted: missing libraries", 30)
return false
end
-- read optional config zone
cfxGroundTroops.readConfigZone()
if cfxGroundTroops.scheduledUpdates then
cfxGroundTroops.queuedUpdates = false
cfxGroundTroops.updateCheckOnly()
cfxGroundTroops.checkSchedules() -- check regularly if all troops have been updated by checking their ID
elseif cfxGroundTroops.queuedUpdates then
cfxGroundTroops.updateQueued()
else
cfxGroundTroops.update()
end
-- now install a regular pileup check
timer.scheduleFunction(cfxGroundTroops.checkPileUp, {}, timer.getTime() + 60)
if cfxGroundTroops.monitorNumbers then
timer.scheduleFunction(cfxGroundTroops.monitorQueues, {}, timer.getTime() + 5)
end
if cfxGroundTroops.maxManagedTroops > 0 then
timer.scheduleFunction(cfxGroundTroops.manageQueues, {}, timer.getTime() + 1)
end
trigger.action.outText("cf/x Ground Troops v" .. cfxGroundTroops.version .. " started", 30)
if not cfxOwnedZones then
--trigger.action.outText("+++groundT: pileUp - owned zones not yet ready", 30)
end
return true
end
if not cfxGroundTroops.start() then
cfxGroundTroops = nil
trigger.action.outText("cfxGroundTroops aborted load", 30)
end
--[[--
TO DO
- implement 'patrol' orders!!!
when ordering a new route, issue a command to stop in 1 second
and another with new marching orders in 5 seconds
look at setTask() and resetTask() for controller
- change group logic to set itself up to 'requestOrders' with group as parameter, so they can decide themselves how quickly they want to be re-tasked
- DONE enqueue and dequeue methods with capped ground troops size
- named locs have strategic values attached (default = 1), and distance is divided by strat value to get at priority when rerouting
- difficulty increase: make enemy troops better by raining their spawned level
- check out simple slot block SSB (pre-moose) to see if we can implement slot blocking for downed pilots
- new 'wanda' (wander) module to make airports more lively: zone, have individuals/single vehicle wander around. two waypoints (start and stop), that are zones, and whenever they reach one or are at speed 0, they get a new one. may have pause before they go to next.
variant on above: selection of zones that are somehow connected, and destinations are made between these for patrolling zone. can force order, loop, and ping-pong.
--]]--