local widgetName = "Smarter Nanos" -- used also in Echo() definition function widget:GetInfo() return { name = widgetName, desc = "Highly responsive automatic management for static builders (and Funnelweb).\nReplaces 'Auto Patrol Nanos', 'Auto Patrol Nanos v2', and 'Smart Nanos' widgets -- disable them to use this one.\nFirst priority: to avoid energy stalling; second priority: to avoid metal overflow; third priority: repair own units.\nDoes not assist allied buildings, but repairs allied units if there is nothing better to do.\nTakes into account building priority.\nIf a player issues manual command(s) to a builder, it returns to automatic management upon finishing them.\nAdds two new command buttons to the command panel: 'Enable Automatic Management' (green 'AI' letters) and 'Disable Automatic Management' (red 'AI' letters).\nBy default all the builders are automatically managed until the auto-management is disabled by the latter command.", author = "rollmops, using some ideas from 'Smart Nanos' by TheFatController and 'Auto Patrol Nanos v2' by trepan", date = "Oct 2022", license = "GNU GPL, v2 or later", layer = 0, handler = true, -- for adding custom commands into UI enabled = true, } end -- TL;DR: all the core logic is in widget:GameFrame() -------------------------------------------------------------------------------- -- Speedups -------------------------------------------------------------------------------- local spGetTeamResources = Spring.GetTeamResources local spEcho = Spring.Echo local spGetUnitPosition = Spring.GetUnitPosition local spGetTeamUnits = Spring.GetTeamUnits local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitsInCylinder = Spring.GetUnitsInCylinder local spGetFeaturesInCylinder = Spring.GetFeaturesInCylinder local spGetFeatureResources = Spring.GetFeatureResources local spGiveOrderToUnit = Spring.GiveOrderToUnit local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand local spGetSelectedUnits = Spring.GetSelectedUnits local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitAllyTeam = Spring.GetUnitAllyTeam local spGetUnitRulesParam = Spring.GetUnitRulesParam local spGetUnitFeatureSeparation = Spring.GetUnitFeatureSeparation local spGetSpectatingState = Spring.GetSpectatingState local spIsReplay = Spring.IsReplay local CMD_RECLAIM = CMD.RECLAIM local CMD_REPAIR = CMD.REPAIR local CMD_STOP = CMD.STOP local customCmds = VFS.Include("LuaRules/Configs/customcmds.lua") local CMD_PRIORITY = customCmds.PRIORITY local Game_maxUnits = Game.maxUnits -------------------------------------------------------------------------------- -- Config -------------------------------------------------------------------------------- local gameFramesInterval = 10 -- defines the frequency ( = 30 / gameFramesInterval Hz) of the management actions local debug = false -- change to true to enable console/log messages (see Echo() command definition in Globals section) local buildersNames = { "staticcon", "striderhub", "striderfunnelweb" } -- these types of builders will be managed local energyDefs = { -- structures whose building will be prioritized in case of low energy [ UnitDefNames.energywind.id ] = { cost = UnitDefNames.energywind.cost }, [ UnitDefNames.energysolar.id ] = { cost = UnitDefNames.energysolar.cost }, [ UnitDefNames.energygeo.id ] = { cost = UnitDefNames.energygeo.cost }, [ UnitDefNames.energyheavygeo.id ] = { cost = UnitDefNames.energyheavygeo.cost }, [ UnitDefNames.energyfusion.id ] = { cost = UnitDefNames.energyfusion.cost }, [ UnitDefNames.energysingu.id ] = { cost = UnitDefNames.energysingu.cost }, } local metalDefs = { -- structures whose building will be prioritized in case of high metal [ UnitDefNames.staticcon.id ] = { cost = UnitDefNames.staticcon.cost }, [ UnitDefNames.staticstorage.id ] = { cost = UnitDefNames.staticstorage.cost }, [ UnitDefNames.striderhub.id ] = { cost = UnitDefNames.striderhub.cost }, [ UnitDefNames.factoryamph.id ] = { cost = UnitDefNames.factoryamph.cost }, [ UnitDefNames.factorycloak.id ] = { cost = UnitDefNames.factorycloak.cost }, [ UnitDefNames.factorygunship.id ] = { cost = UnitDefNames.factorygunship.cost }, [ UnitDefNames.factoryhover.id ] = { cost = UnitDefNames.factoryhover.cost }, [ UnitDefNames.factoryjump.id ] = { cost = UnitDefNames.factoryjump.cost }, [ UnitDefNames.factoryplane.id ] = { cost = UnitDefNames.factoryplane.cost }, [ UnitDefNames.factoryshield.id ] = { cost = UnitDefNames.factoryshield.cost }, [ UnitDefNames.factoryship.id ] = { cost = UnitDefNames.factoryship.cost }, [ UnitDefNames.factoryspider.id ] = { cost = UnitDefNames.factoryspider.cost }, [ UnitDefNames.factorytank.id ] = { cost = UnitDefNames.factorytank.cost }, [ UnitDefNames.factoryveh.id ] = { cost = UnitDefNames.factoryveh.cost }, [ UnitDefNames.plateamph.id ] = { cost = UnitDefNames.plateamph.cost }, [ UnitDefNames.platecloak.id ] = { cost = UnitDefNames.platecloak.cost }, [ UnitDefNames.plategunship.id ] = { cost = UnitDefNames.plategunship.cost }, [ UnitDefNames.platehover.id ] = { cost = UnitDefNames.platehover.cost }, [ UnitDefNames.platejump.id ] = { cost = UnitDefNames.platejump.cost }, [ UnitDefNames.plateplane.id ] = { cost = UnitDefNames.plateplane.cost }, [ UnitDefNames.plateshield.id ] = { cost = UnitDefNames.plateshield.cost }, [ UnitDefNames.plateship.id ] = { cost = UnitDefNames.plateship.cost }, [ UnitDefNames.platespider.id ] = { cost = UnitDefNames.platespider.cost }, [ UnitDefNames.platetank.id ] = { cost = UnitDefNames.platetank.cost }, [ UnitDefNames.plateveh.id ] = { cost = UnitDefNames.plateveh.cost }, } -- This list was generated with the help of the following one-liner: -- Zero-K-master/units$ for f in $(ls | grep -P "(factory|plate)" | grep -oP '\w+(?=\.lua)'); do echo -e "\t[ UnitDefNames.$f.id\t] = { cost = UnitDefNames.$f.cost\t},"; done -- define two custom commands ("enable/disable auto-management") to be added to each builder's command list -- each command should have its number. for custom commands as these, put some random numbers, but not already taken in https://springrts.com/wiki/Lua_CMDs or in LuaRules/Configs/customcmds.lua local CMD_ENABLE_MANAGE_BUILDER = 18247 local CMD_DISABLE_MANAGE_BUILDER = 18248 -- define the two commands ( will be applied to selected builders by widget:CommandsChanged ) local cmdEnableManageBuilder = { id = CMD_ENABLE_MANAGE_BUILDER, type = CMDTYPE.ICON, -- expect 0 parameters in return tooltip = 'Enable Automatic Management (Smarter Nanos widget)', action = 'smarter_nanos_on', -- "it can be binded to a key (eg: /bind f fight, will activate FIGHT when f is pressed" <-- LuaRules/Configs/customCmdTypes.lua params = {}, texture = 'LuaUI/Images/commands/states/ai_on.png', -- green 'AI' letters } local cmdDisableManageBuilder = { id = CMD_DISABLE_MANAGE_BUILDER, type = CMDTYPE.ICON, tooltip = 'Disable Automatic Management (Smarter Nanos widget)', action = 'smarter_nanos_off', params = {}, texture = 'LuaUI/Images/commands/states/ai_off.png', -- red 'AI' letters } -------------------------------------------------------------------------------- -- Constants -------------------------------------------------------------------------------- local myTeamID = Spring.GetMyTeamID() local myAllyTeamID = Spring.GetMyAllyTeamID() VFS.Include("LuaRules/Configs/constants.lua") -- for HIDDEN_STORAGE which is 10,000 and should be subtracted from metalStorage and energyStorage provided by spGetTeamResources() to acquire the actual storage value. -------------------------------------------------------------------------------- -- Globals -------------------------------------------------------------------------------- local buildersDefID = {} -- populated by the 'for' loop below, using buildersNames defined in Config section for _,n in pairs( buildersNames ) do local defID = UnitDefNames[n] buildersDefID[ defID.id ] = { name = defID.humanName, range = defID.buildDistance, mobile = not defID.isImmobile } end -- following are set and used in widget:GameFrame local energyState -- current energy state, can be "low" or "fine" local metalState -- current metal state, can be "low", "medium", or "high" local lastEnergyState = "fine" -- what was the previous energy state local lastMetalState = "high" -- what was the previous metal state local function Echo(...) -- if 'debug' (defined in Config section) is true, -- accepts any number of arguments, concatenates them to a space-delimited string, then spEcho() it. if not debug then return end local msg = widgetName..":" for _, s in pairs{...} do msg = msg .. " " .. tostring(s) end spEcho( msg ) end -------------------------------------------------------------------------------- -- Builder Class and its Methods -------------------------------------------------------------------------------- local builders = {} -- holds builders' objects local builderClass = { -- each builder gets its own object derived from this class id, defid, name, range, mobile, -- Boolean pos = {}, managed, -- Boolean, whether the builder is under automated management; -- True by default, can be changed by the custom commands which this widget adds to builders' command panel. manualCommand, -- Boolean, whether the builder is currently doing manual-issued command(s), -- upon finishing which, will return to automated management. myUnitsInRange, currentCmdID, currentCmdParam1, } function builderClass:New( unitID, unitDefID ) -- the second argument is optional local o = {} setmetatable(o, self) self.__index = self o.id = unitID o.defid = unitDefID or spGetUnitDefID( unitID ) o.name = buildersDefID[ o.defid ].name o.range = buildersDefID[ o.defid ].range o.mobile = buildersDefID[ o.defid ].mobile local x,y,z = spGetUnitPosition( unitID ) o.pos = { x = x, y = y, z = z } spGiveOrderToUnit( unitID, CMD_PRIORITY, 1, 0 ) -- set building priority to "normal" o.managed = true o.manualCommand = false Echo( "added:", o.id, o.name, "mobile:", o.mobile, "range =", o.range, "located at", o.pos.x, o.pos.y, o.pos.z ) return o end function builderClass:Stop() spGiveOrderToUnit( self.id, CMD_STOP, 0, 0 ) end -- each following builderClass function returns "true" if is resulted in an order given to a builder, "false" otherwise. -- in the latter case, another function (with lower priority) will be called (see widget:GameFrame). -- there are three general functions: ReclaimFeatures(condition), AssistBuilding(arg), -- and RepairUnits(units); then there are quite a few specific functions which provide a specific task by supplying -- a relevant argument to one of the general functions, for example: RepairOwnUnits() calls RepairUnits(myUnitsInRange) function builderClass:ReclaimFeatures( condition ) -- "condition" is a boolean function of feature's reclaimable energy and metal, defining what to reclaim (e.g., "only metal", "prefer energy" etc.) local featuresInRange = spGetFeaturesInCylinder( self.pos.x, self.pos.z, self.range ) -- this functions returns also features that only their edge is in range, but their center is out of range, so they cannot be reclaimed. Hence additional checkup is needed (see the "if" statement below). -- seems that the similar GetUnitsInCylinder() doesn't suffer from this issue. if not featuresInRange then return false end for _, featureID in pairs( featuresInRange ) do local RemainingMetal, _, RemainingEnergy = spGetFeatureResources( featureID ) local target = featureID + Game_maxUnits -- convert featureID to absoluteID for GiveOrderToUnit() -- check that: -- (1) the feature meets the condition requirement -- (2) the feature is actually in builder's range (see comment about spGetFeaturesInCylinder above) -- features also have "reclaimable" boolean value, but seems that there is no need to check it if condition( RemainingEnergy, RemainingMetal ) and spGetUnitFeatureSeparation( self.id, featureID) < self.range then -- give order to reclaim unless the builder is already reclaiming this feature if not ( self.currentCmdID and self.currentCmdID == CMD_RECLAIM and self.currentCmdParam1 and self.currentCmdParam1 == target ) then spGiveOrderToUnit( self.id, CMD_RECLAIM, target, 0 ) end return true end end return false end function builderClass:ReclaimEnergyOnly() Echo( self.id, ": ReclaimEnergyOnly" ) return self:ReclaimFeatures( function( E, M ) return E > 1 and M < 1 end ) -- some features have some weird very low values of E or M (e.g. on Mescaline, San Pedro mushrooms have 0.001M), -- so always compare to 1, not to 0. end function builderClass:ReclaimEnergyMainly() Echo( self.id, ": ReclaimEnergyMainly" ) return self:ReclaimFeatures( function( E, M ) return E > M end ) end function builderClass:ReclaimMetal() Echo( self.id, ": ReclaimMetal" ) return self:ReclaimFeatures( function( E, M ) return M > 1 and E < 1 end ) end function builderClass:ReclaimAny() Echo( self.id, ": ReclaimAny" ) return self:ReclaimFeatures( function( E, M ) return M > 1 or E > 1 end ) end function builderClass:AssistBuilding( arg ) -- assist is always for own units only. -- if no list of DefIDs is provided, assist any own unit, otherwise only the specified kinds (DefIDs). -- assist only units with building priority no less than minPriority ("normal" if the argument isn't provided). -- choose a unit to assist by its building priority, then by how much metal remains to spend (see table.sort() below). -- since both arguments are optional, the function receives a table of named arguments ( DefIds and minPriority ). if not self.myUnitsInRange then return false end minPriority = arg.minPriority or 1 -- (0-low, 1-normal, 2-high) local unitsToAssist = {} -- this table will be populated with all relevant units, then sorted to find the best. for _, unitID in pairs( self.myUnitsInRange ) do local unitDefID = spGetUnitDefID( unitID ) local _,_,_,_,buildProgress = spGetUnitHealth( unitID ) local buildPriority = spGetUnitRulesParam(unitID, "buildpriority") or 1 -- (0-low, 1-normal, 2-high) -- check that: -- (1) unit is not finished (needs building assist) -- (2) list of DefIds wasn't provided (assist any), or unit's DefId is in the list -- (3) buildPriority is equal or higher to minPriority if buildProgress and buildProgress < 1 and ( not arg.DefIDs or arg.DefIDs[ unitDefID ] ) and buildPriority >= minPriority then local unitCost = arg.DefIDs and arg.DefIDs[unitDefID].cost or UnitDefs[unitDefID].cost local metalToInvest = unitCost * ( 1 - buildProgress ) unitsToAssist[ #unitsToAssist + 1 ] = { unitID = unitID, metalToInvest = metalToInvest, buildPriority = buildPriority } end end if #unitsToAssist == 0 then return false end table.sort( unitsToAssist, function(a,b) -- sort first by building priority (the higher, the better), then by metalToinvest (the less, the better) return a.buildPriority ~= b.buildPriority and a.buildPriority > b.buildPriority or a.metalToInvest < b.metalToInvest end ) local bestUnitToAssist = unitsToAssist[1].unitID if not ( self.currentCmdID and self.currentCmdID == CMD_REPAIR and self.currentCmdParam1 and self.currentCmdParam1 == bestUnitToAssist ) then spGiveOrderToUnit( self.id, CMD_REPAIR, bestUnitToAssist, 0 ) end return true end function builderClass:AssistEnergyStructures() Echo( self.id, ": AssistEnergyStructures" ) return self:AssistBuilding{ DefIDs = energyDefs } end function builderClass:AssistMetalSpending() Echo( self.id, ": AssistMetalSpending" ) return self:AssistBuilding{ DefIDs = metalDefs } end function builderClass:AssistAnyNormal() Echo( self.id, ": AssistAnyNormal" ) return self:AssistBuilding{} end function builderClass:AssistAnyLow() Echo( self.id, ": AssistAnyLow" ) return self:AssistBuilding{ minPriority = 0 } end function builderClass:RepairUnits( units ) -- repair either own or allied units, depending on the argument (units list). -- choose the unit with the least HP deficiency (see table.sort() below) if not units then return false end local unitsToRepair = {} -- this table will be populated with all relevant units, then sorted to find the best. for _, unitID in pairs( units ) do local currentHP, maxHP, _, _, buildProgress = spGetUnitHealth( unitID ) -- check that: -- (1) unit is finished, because we want to repair, not to build -- (2) unit needs repair -- (3) not trying to repair itself if buildProgress == 1 and currentHP < maxHP and unitID ~= self.id then unitsToRepair[ #unitsToRepair + 1 ] = { unitID = unitID, HP_deficiency = maxHP - currentHP } end end if #unitsToRepair == 0 then return false end table.sort( unitsToRepair, function(a,b) return a.HP_deficiency < b.HP_deficiency end ) local fastestRepairUnitID = unitsToRepair[1].unitID if not ( self.currentCmdID and self.currentCmdID == CMD_REPAIR and self.currentCmdParam1 and self.currentCmdParam1 == fastestRepairUnitID ) then spGiveOrderToUnit( self.id, CMD_REPAIR, fastestRepairUnitID, 0 ) end return true end function builderClass:RepairOwnUnits() Echo( self.id, ": RepairOwnUnits" ) return self:RepairUnits( self.myUnitsInRange ) end function builderClass:RepairAllyUnits() Echo( self.id, ": RepairAllyUnits" ) local unitsInRange = spGetUnitsInCylinder( self.pos.x, self.pos.z, self.range ) if not unitsInRange then return false end local allyUnitsInRange = {} for _, unitID in pairs( unitsInRange ) do if spGetUnitAllyTeam( unitID ) == myAllyTeamID then allyUnitsInRange[ #allyUnitsInRange + 1 ] = unitID end end return self:RepairUnits( allyUnitsInRange ) end -------------------------------------------------------------------------------- -- Callins -------------------------------------------------------------------------------- -- all the core logic is in widget:GameFrame function widget:GameFrame(n) if n % gameFramesInterval ~= 1 then return end local metal, metalStorage = spGetTeamResources(myTeamID, "metal") metalStorage = metalStorage - HIDDEN_STORAGE -- see explanation in Constants section local energy, energyStorage = spGetTeamResources(myTeamID, "energy") energyStorage = energyStorage - HIDDEN_STORAGE -- see explanation in Constants section -- lastEnergyState adds some hysteresis to avoid fast switching back and forth around Energy States boundary; -- in other words, moving from "low" to "fine" energy state requires more energy than moving from "fine" to "low", energyState = ( energy < 0.2 * energyStorage or lastEnergyState == "low" and energy < 0.3 * energyStorage ) and "low" or "fine" -- lastMetalState plays a similar role to that of lastEnergyState above metalState = ( metal < 0.3 * metalStorage or lastMetalState == "low" and metal < 0.4 * metalStorage ) and "low" or ( metal > 0.75 * metalStorage or lastMetalState == "high" and metal > 0.65 * metalStorage ) and "high" or "medium" lastEnergyState = energyState lastMetalState = metalState Echo( "Metal State:", metalState, "Energy State:", energyState ) for _, builder in pairs( builders ) do if builder.managed then -- if automatic management was disabled, there is nothing to do local cmdID, _, _, cmdParam1 = spGetUnitCurrentCommand( builder.id ) Echo( builder.id, "cmd:", CMD[cmdID] or cmdID or "none", "managed:", builder.managed, "manualCommand:", builder.manualCommand ) if not builder.manualCommand then -- if the builder is performing manual-issued command(s), leave it alone -- until it becomes idle (see 'elseif' clause of this level below) builder.currentCmdID = cmdID builder.currentCmdParam1 = cmdParam1 if builder.mobile then local x,y,z = spGetUnitPosition( builder.id ) builder.pos = { x = x, y = y, z = z } end builder.myUnitsInRange = spGetUnitsInCylinder( builder.pos.x, builder.pos.z, builder.range, myTeamID ) if energyState == "low" then dummy = -- 'dummy = ...' is used to try each task (they are organized in priority-descending order) in turn; -- if a task was successful (an order was given to the builder), it returns 'true', -- so the attempts will stop here; -- if a task was unsuccessful (e.g., builder:ReclaimEnergyOnly() was called, but there are no -- pure-energy features in the builder's range), the task will return 'false', then -- the next function will be called. -- if all tasks fail, the Stop() command will be issued (always last in the chain). builder:ReclaimEnergyOnly() or metalState == "low" and builder:ReclaimEnergyMainly() or builder:AssistEnergyStructures() or builder:ReclaimEnergyMainly() or builder:Stop() elseif metalState == "high" then dummy = builder:AssistMetalSpending() or builder:AssistAnyNormal() or builder:AssistAnyLow() or builder:RepairOwnUnits() or builder:RepairAllyUnits() or builder:Stop() else dummy = builder:RepairOwnUnits() or builder:ReclaimMetal() or builder:ReclaimAny() or builder:RepairAllyUnits() or builder:AssistAnyNormal() or builder:Stop() end elseif not cmdID then -- builder was on manual-issued command(s) but idle now, therefore -- returns to automated management. -- in this gameFrame set its priority to "normal"; -- a building order will be issued in the next checked frame -- to avoid interference. builder.manualCommand = false spGiveOrderToUnit( builder.id, CMD_PRIORITY, 1, 0 ) end end end end function widget:CommandNotify(cmdID, params, options) -- used to check if one of the two custom commands ("enable/disable auto-management") was issued, -- or, in case of any other command, switch the builder to "manual" state, since -- orders issued by the widget itself will not be caught by this callin Echo("CommandNotify:", CMD[cmdID] or cmdID ) selectedUnits = spGetSelectedUnits() if not selectedUnits then return end local returnValue -- return after "for" loop; "return true" discards the issued command for _, unitID in pairs( selectedUnits ) do if builders[ unitID ] then if cmdID == CMD_DISABLE_MANAGE_BUILDER then Echo( "management disabled for", unitID ) builders[ unitID ].managed = false returnValue = true elseif cmdID == CMD_ENABLE_MANAGE_BUILDER then Echo( "management enabled for", unitID ) spGiveOrderToUnit( unitID, CMD_PRIORITY, 1, 0 ) builders[ unitID ].managed = true returnValue = true else builders[ unitID ].manualCommand = true returnValue = false end end end return returnValue end function widget:CommandsChanged() -- Called when the command descriptions (the "commands" panel) changed, i.e. when selecting or deselecting units of different types, to add the two custom commands, if any builder was selected. selectedUnits = spGetSelectedUnits() if not selectedUnits then return end for _, unitID in pairs( selectedUnits ) do if builders[ unitID ] then local customCommands = widgetHandler.customCommands customCommands[#customCommands+1] = cmdEnableManageBuilder customCommands[#customCommands+1] = cmdDisableManageBuilder return end end end -- remaining is the usual boilerplate function widget:UnitFinished( unitID, unitDefID, unitTeam ) if unitTeam == myTeamID and buildersDefID[ unitDefID ] and not builders[ unitID ] then builders[ unitID ] = builderClass:New( unitID, unitDefID ) end end function widget:UnitGiven(unitID, unitDefID, unitTeam) local _,_,_,_,buildProgress = spGetUnitHealth ( unitID ) if buildProgress and buildProgress == 1 then widget:UnitFinished(unitID, unitDefID, unitTeam) end end function widget:UnitDestroyed(unitID) builders[ unitID ] = nil end function widget:UnitTaken(unitID) widget:UnitDestroyed(unitID) end function widget:Initialize() if spGetSpectatingState() or spIsReplay() then widgetHandler:RemoveWidget(widget) end local myUnits = spGetTeamUnits( myTeamID ) if myUnits then for _, unitID in pairs( myUnits ) do widget:UnitGiven(unitID, spGetUnitDefID( unitID ), myTeamID) end end end function widget:PlayerChanged (playerID) if spGetSpectatingState() then widgetHandler:RemoveWidget(widget) end end