-
Notifications
You must be signed in to change notification settings - Fork 577
Idle builders auto repair #7338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Flameink
wants to merge
9
commits into
beyond-all-reason:master
Choose a base branch
from
Flameink:flameink/idle_builder_auto_repair
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+383
−0
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
fe71cf0
Initial commit
Flameink a655fcb
Merge branch 'master' into flameink/idle_builder_auto_repair
Flameink fd3155b
Fixed and tested
Flameink d798663
Fixed reclaim behavior
Flameink d4d9668
Add known issue
Flameink dd504dc
Clean up a few prints
Flameink 84176d8
Fix cloak tomfoolery
Flameink 8a2da74
Code style, review comments
Flameink 4e6787b
Review comments
Flameink File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,383 @@ | ||
| local widget = widget ---@type Widget | ||
|
|
||
| function widget:GetInfo() | ||
| return { | ||
| name = "Auto Repair Idle Builders", | ||
| desc = "Idle mobile builders automatically repair nearby damaged allied units within a leash radius based on movement state", | ||
| author = "Flameink", | ||
| date = "2026-03-23", | ||
| license = "GNU GPL, v2 or later", | ||
| layer = 0, | ||
| enabled = true | ||
| } | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Speedups | ||
| ---------------------------------------------------------------- | ||
| local spGetMyTeamID = Spring.GetMyTeamID | ||
| local spGetTeamUnits = Spring.GetTeamUnits | ||
| local spGetUnitDefID = Spring.GetUnitDefID | ||
| local spGetUnitPosition = Spring.GetUnitPosition | ||
| local spGetUnitHealth = Spring.GetUnitHealth | ||
| local spGetUnitStates = Spring.GetUnitStates | ||
| local spGetUnitCommandCount = Spring.GetUnitCommandCount | ||
| local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand | ||
| local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt | ||
| local spGetUnitIsDead = Spring.GetUnitIsDead | ||
| local spGetUnitsInCylinder = Spring.GetUnitsInCylinder | ||
| local spGiveOrderToUnit = Spring.GiveOrderToUnit | ||
| local spValidUnitID = Spring.ValidUnitID | ||
| local spGetGameFrame = Spring.GetGameFrame | ||
| local spGetSelectedUnits = Spring.GetSelectedUnits | ||
| local spGetUnitRulesParam = Spring.GetUnitRulesParam | ||
|
|
||
| local CMD_REPAIR = CMD.REPAIR | ||
| local CMD_MOVE = CMD.MOVE | ||
| local CMD_RECLAIM = CMD.RECLAIM | ||
| local CMD_MOVE_STATE = CMD.MOVE_STATE | ||
| local CMD_WANT_CLOAK = GameCMD.WANT_CLOAK | ||
| local ALLY_UNITS = Spring.ALLY_UNITS | ||
|
|
||
| -- Known issues | ||
| -- This doesn't take into account the width of the unit being repaired, so the hold | ||
| -- position radius is too small. It's good to be conservative, but we should update | ||
| -- this later to properly take the width into account. | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Constants | ||
| ---------------------------------------------------------------- | ||
| local LEASH_EXTRA = { | ||
| [-1] = 0, -- Structure | ||
| [0] = 0, -- hold position | ||
| [1] = 100, -- maneuver | ||
| [2] = 200, -- roam | ||
| } | ||
| local DEFAULT_LEASH_EXTRA = 100 | ||
| local POLL_INTERVAL = Game.gameSpeed | ||
| local RECLAIM_BLACKLIST_DURATION = 60 * Game.gameSpeed | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Static lookup (built once from UnitDefs) | ||
| ---------------------------------------------------------------- | ||
| local isMobileBuilder = {} | ||
| local builderBuildDist = {} | ||
| local cachedUnitDefs = {} | ||
|
|
||
| for unitDefID, unitDef in pairs(UnitDefs) do | ||
| if unitDef.isBuilder and (unitDef.canAssist or unitDef.canResurrect) and unitDef.canMove and not unitDef.isFactory then | ||
| isMobileBuilder[unitDefID] = true | ||
| builderBuildDist[unitDefID] = unitDef.buildDistance | ||
| end | ||
|
|
||
| cachedUnitDefs[unitDefID] = { radius = unitDef.radius } | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Runtime state | ||
| ---------------------------------------------------------------- | ||
| local myTeam = spGetMyTeamID() | ||
|
|
||
| -- [unitID] = { homeX, homeY, homeZ } | ||
| local idleBuilders = {} | ||
|
|
||
| -- [builderID] = { targetID, homeX, homeY, homeZ } | ||
| local activeRepairs = {} | ||
|
|
||
| -- [unitID] = expiryFrame | ||
| local reclaimBlacklist = {} | ||
|
|
||
| -- [reclaimerID] = targetID | ||
| local activeReclaimers = {} | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Helpers | ||
| ---------------------------------------------------------------- | ||
| local function isUnitAlive(unitID) | ||
| return spValidUnitID(unitID) and not spGetUnitIsDead(unitID) | ||
| end | ||
|
|
||
| local function getLeashRadius(unitID) | ||
| local unitDefID = spGetUnitDefID(unitID) | ||
| local buildDist = builderBuildDist[unitDefID] or 0 | ||
| local states = spGetUnitStates(unitID) | ||
| local extra = DEFAULT_LEASH_EXTRA | ||
| if states then | ||
| extra = LEASH_EXTRA[states.movestate] or DEFAULT_LEASH_EXTRA | ||
| end | ||
|
|
||
| return buildDist + extra | ||
| end | ||
|
|
||
| local function sendHome(builderID, info) | ||
| spGiveOrderToUnit(builderID, CMD_MOVE, { info.homeX, info.homeY, info.homeZ }, 0) | ||
| activeRepairs[builderID] = nil | ||
| end | ||
|
|
||
| local function removeBuilder(unitID) | ||
| idleBuilders[unitID] = nil | ||
| activeRepairs[unitID] = nil | ||
| end | ||
|
|
||
| local function removeTarget(unitID) | ||
| for builderID, info in pairs(activeRepairs) do | ||
| if info.targetID == unitID then | ||
| if isUnitAlive(builderID) then | ||
| sendHome(builderID, info) | ||
| else | ||
| activeRepairs[builderID] = nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| local function hasActiveReclaimers(targetID) | ||
| for _, tid in pairs(activeReclaimers) do | ||
| if tid == targetID then return true end | ||
| end | ||
|
|
||
| return false | ||
| end | ||
|
|
||
| local function onReclaimerStopped(reclaimerID) | ||
| local targetID = activeReclaimers[reclaimerID] | ||
| if not targetID then | ||
| return | ||
| end | ||
|
|
||
| activeReclaimers[reclaimerID] = nil | ||
| if not hasActiveReclaimers(targetID) then | ||
| reclaimBlacklist[targetID] = spGetGameFrame() + RECLAIM_BLACKLIST_DURATION | ||
| end | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Setup / teardown | ||
| ---------------------------------------------------------------- | ||
| local function maybeRemoveSelf() | ||
| if Spring.GetSpectatingState() and (spGetGameFrame() > 0) or Spring.IsReplay() then | ||
| widgetHandler:RemoveWidget() | ||
| return true | ||
| end | ||
| end | ||
|
|
||
| function widget:Initialize() | ||
| myTeam = spGetMyTeamID() | ||
| if maybeRemoveSelf() then | ||
| return | ||
| end | ||
|
|
||
| for _, unitID in ipairs(spGetTeamUnits(myTeam)) do | ||
| local unitDefID = spGetUnitDefID(unitID) | ||
| if isMobileBuilder[unitDefID] and spGetUnitCommandCount(unitID) == 0 then | ||
| local x, y, z = spGetUnitPosition(unitID) | ||
| idleBuilders[unitID] = { homeX = x, homeY = y, homeZ = z } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| function widget:Shutdown() | ||
| idleBuilders = {} | ||
| activeRepairs = {} | ||
| reclaimBlacklist = {} | ||
| activeReclaimers = {} | ||
| end | ||
|
|
||
| function widget:PlayerChanged() | ||
| myTeam = spGetMyTeamID() | ||
| if maybeRemoveSelf() then | ||
| return | ||
| end | ||
|
|
||
| idleBuilders = {} | ||
| activeRepairs = {} | ||
| activeReclaimers = {} | ||
| for _, unitID in ipairs(spGetTeamUnits(myTeam)) do | ||
| local unitDefID = spGetUnitDefID(unitID) | ||
| if isMobileBuilder[unitDefID] and spGetUnitCommandCount(unitID) == 0 then | ||
| local x, y, z = spGetUnitPosition(unitID) | ||
| idleBuilders[unitID] = { homeX = x, homeY = y, homeZ = z } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Unit lifecycle | ||
| ---------------------------------------------------------------- | ||
| function widget:UnitIdle(unitID, unitDefID, unitTeam) | ||
| if unitTeam ~= myTeam then | ||
| return | ||
| end | ||
|
|
||
| onReclaimerStopped(unitID) | ||
| if not isMobileBuilder[unitDefID] then | ||
| return | ||
| end | ||
|
|
||
| local x, y, z = spGetUnitPosition(unitID) | ||
| idleBuilders[unitID] = { homeX = x, homeY = y, homeZ = z } | ||
| activeRepairs[unitID] = nil | ||
| end | ||
|
|
||
| function widget:MetaUnitAdded(unitID, unitDefID, unitTeam) | ||
| if spGetUnitIsDead(unitID) or unitTeam ~= myTeam or not isMobileBuilder[unitDefID] then | ||
| return | ||
| end | ||
|
|
||
| if spGetUnitCommandCount(unitID) == 0 then | ||
| local x, y, z = spGetUnitPosition(unitID) | ||
| idleBuilders[unitID] = { homeX = x, homeY = y, homeZ = z } | ||
| end | ||
| end | ||
|
|
||
| function widget:MetaUnitRemoved(unitID) | ||
| removeBuilder(unitID) | ||
| removeTarget(unitID) | ||
| onReclaimerStopped(unitID) | ||
| reclaimBlacklist[unitID] = nil | ||
| end | ||
|
|
||
| function widget:UnitDestroyed(unitID) | ||
| removeBuilder(unitID) | ||
| removeTarget(unitID) | ||
| onReclaimerStopped(unitID) | ||
| reclaimBlacklist[unitID] = nil | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Command interception | ||
| ---------------------------------------------------------------- | ||
| function widget:CommandNotify(cmdID, cmdParams, cmdOpts) | ||
| -- State changes (e.g. movestate) should not disrupt tracking | ||
| if cmdID == CMD_MOVE_STATE or cmdID == CMD_WANT_CLOAK then | ||
| return | ||
| end | ||
|
|
||
| local selectedUnits = spGetSelectedUnits() | ||
|
|
||
| -- Stop tracking reclaimers that received a new command | ||
| for _, unitID in ipairs(selectedUnits) do | ||
| onReclaimerStopped(unitID) | ||
| end | ||
|
|
||
| -- Remove all selected builders from tracking on manual commands | ||
| for _, unitID in ipairs(selectedUnits) do | ||
| if idleBuilders[unitID] or activeRepairs[unitID] then | ||
| removeBuilder(unitID) | ||
| end | ||
| end | ||
|
|
||
| -- Detect reclaim commands targeting a specific unit | ||
| if cmdID == CMD_RECLAIM and cmdParams and cmdParams[1] then | ||
| local targetID = cmdParams[1] | ||
| if targetID > 0 and #cmdParams == 1 and spValidUnitID(targetID) then | ||
| for _, unitID in ipairs(selectedUnits) do | ||
| activeReclaimers[unitID] = targetID | ||
| end | ||
|
|
||
| reclaimBlacklist[targetID] = math.huge | ||
| end | ||
| end | ||
| end | ||
|
|
||
| ---------------------------------------------------------------- | ||
| -- Core loop | ||
| ---------------------------------------------------------------- | ||
| function widget:GameFrame(frame) | ||
| if frame % POLL_INTERVAL ~= 0 then | ||
| return | ||
| end | ||
|
|
||
| -- Phase 1: Clean expired reclaim blacklist entries | ||
| for unitID, expiryFrame in pairs(reclaimBlacklist) do | ||
| if frame >= expiryFrame then | ||
| reclaimBlacklist[unitID] = nil | ||
| end | ||
| end | ||
|
|
||
| -- Phase 2: Monitor active repairs | ||
| for builderID, info in pairs(activeRepairs) do | ||
| local cloakState = spGetUnitRulesParam(builderID, 'wantcloak') | ||
| local wantsCloak = (cloakState and cloakState == 1) | ||
| if not isUnitAlive(builderID) then | ||
| activeRepairs[builderID] = nil | ||
| elseif not isUnitAlive(info.targetID) or reclaimBlacklist[info.targetID] or wantsCloak then | ||
| sendHome(builderID, info) | ||
| else | ||
| local health, maxHealth = spGetUnitHealth(info.targetID) | ||
| if health and health >= maxHealth then | ||
| -- Repair complete | ||
| sendHome(builderID, info) | ||
| else | ||
| local unitDefID = spGetUnitDefID(info.targetID) | ||
| local unitDef = cachedUnitDefs[unitDefID] | ||
| -- Check if target has left leash radius | ||
| local tx, _, tz = spGetUnitPosition(info.targetID) | ||
| local dx, dz = tx - info.homeX, tz - info.homeZ | ||
| local distSq = dx * dx + dz * dz | ||
| local leash = getLeashRadius(builderID) + unitDef.radius | ||
| if distSq > leash * leash then | ||
| sendHome(builderID, info) | ||
| else | ||
| -- Check builder is still repairing (not overridden by player) | ||
| local cmdID = spGetUnitCurrentCommand(builderID, 1) | ||
| if cmdID ~= CMD_REPAIR then | ||
| activeRepairs[builderID] = nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| -- Phase 3: Assign idle builders to repair targets | ||
| for builderID, homePos in pairs(idleBuilders) do | ||
| local cloakState = spGetUnitRulesParam(builderID, 'wantcloak') | ||
| local wantsCloak = (cloakState and cloakState == 1) | ||
| if activeRepairs[builderID] then | ||
| -- Already assigned (shouldn't happen but guard against it) | ||
| elseif wantsCloak then | ||
| -- It's still idle but wantscloak, so don't assign a target | ||
| elseif spGetUnitCommandCount(builderID) > 0 then | ||
| -- No longer idle | ||
| idleBuilders[builderID] = nil | ||
| elseif not isUnitAlive(builderID) then | ||
| idleBuilders[builderID] = nil | ||
| else | ||
| local leash = getLeashRadius(builderID) | ||
| local nearbyUnits = spGetUnitsInCylinder(homePos.homeX, homePos.homeZ, leash, ALLY_UNITS) | ||
|
|
||
| local bestTarget = nil | ||
| local bestDistSq = math.huge | ||
|
|
||
| for _, candidateID in ipairs(nearbyUnits) do | ||
| if candidateID ~= builderID | ||
| and not reclaimBlacklist[candidateID] | ||
| and not spGetUnitIsBeingBuilt(candidateID) | ||
| then | ||
| local health, maxHealth = spGetUnitHealth(candidateID) | ||
| if health and maxHealth and health < maxHealth then | ||
| local tx, _, tz = spGetUnitPosition(candidateID) | ||
| local dx, dz = tx - homePos.homeX, tz - homePos.homeZ | ||
| local distSq = dx * dx + dz * dz | ||
| if distSq < bestDistSq then | ||
| bestDistSq = distSq | ||
| bestTarget = candidateID | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| if bestTarget then | ||
| spGiveOrderToUnit(builderID, CMD_REPAIR, bestTarget) | ||
|
|
||
| activeRepairs[builderID] = { | ||
| targetID = bestTarget, | ||
| homeX = homePos.homeX, | ||
| homeY = homePos.homeY, | ||
| homeZ = homePos.homeZ, | ||
| } | ||
| idleBuilders[builderID] = nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.