From 7fcd7568b831ef3cea7d4c8cbca646285283e1ea Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 16:22:15 -0500 Subject: [PATCH 01/38] AllowWeaponTarget callin now accepts Allow this allows us to block unwanted targets. --- luarules/gadgets.lua | 24 +++++++------------ .../gadgets/unit_aa_targeting_priority.lua | 3 ++- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua index 622151c47f4..1e1923037f1 100644 --- a/luarules/gadgets.lua +++ b/luarules/gadgets.lua @@ -1710,14 +1710,7 @@ return ((ignore and -1) or 1) end function gadgetHandler:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - -- Calls with no input priority are pure pass/fail tests. - -- These are common, and BAR never disallows any of them. - if not defPriority then - return true -- The second return value is never used. - end - - local allowed = true - local result = 1.0 + local targetPriority = defPriority or 1.0 if targetID == -1 and attackerWeaponNum == -1 then -- The `targetPriority` return value is actually the autotarget search radius, @@ -1725,18 +1718,19 @@ function gadgetHandler:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum for _, g in ipairs(self.UnitAutoTargetRangeList) do defPriority = g:UnitAutoTargetRange(attackerID, defPriority) end - allowed, result = defPriority > 0, defPriority + return defPriority > 0, defPriority else - -- The actual callin. BAR only uses AllowWeaponTarget for the target priority. for _, g in ipairs(self.AllowWeaponTargetList) do - local targetPriority = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if targetPriority then - result = targetPriority + local gadgetTargetAllowed, gadgetTargetPriority = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) + if type(gadgetTargetPriority) == "number" then + targetPriority = gadgetTargetPriority + end + if not gadgetTargetAllowed then + return false, targetPriority end end + return true, targetPriority end - - return allowed, result end function gadgetHandler:UnitAutoTargetRange(attackerID, autoTargetRange) diff --git a/luarules/gadgets/unit_aa_targeting_priority.lua b/luarules/gadgets/unit_aa_targeting_priority.lua index f152612dac9..c52f68c976d 100644 --- a/luarules/gadgets/unit_aa_targeting_priority.lua +++ b/luarules/gadgets/unit_aa_targeting_priority.lua @@ -97,7 +97,8 @@ if gadgetHandler:IsSyncedCode() then function gadget:AllowWeaponTarget(unitID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) local mult = airPriorityMultiplier[spGetUnitDefID(targetID)] if mult then - return defPriority * mult + return true, defPriority * mult end + return true end end From 31a2b22f24499627a10766c19e0b39ad7920e49c Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 18:01:37 -0500 Subject: [PATCH 02/38] new firemode and renaming structure --- language/en/interface.json | 23 +++-- luarules/gadgets/unit_firestate_handler.lua | 104 ++++++++++++++++++++ luaui/Widgets/cmd_user_firestate.lua | 37 +++++++ luaui/Widgets/gui_ordermenu.lua | 59 ++++++++++- luaui/Widgets/gui_unit_firestate_icons.lua | 35 ++++--- modules/customcommands.lua | 1 + modules/firestates.lua | 78 +++++++++++++++ 7 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 luarules/gadgets/unit_firestate_handler.lua create mode 100644 luaui/Widgets/cmd_user_firestate.lua create mode 100644 modules/firestates.lua diff --git a/language/en/interface.json b/language/en/interface.json index 5a4cef7c8a6..215d07f506e 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -262,16 +262,23 @@ "For Sale": "For Sale", "Not For Sale": "Not For Sale", - "Fire at will": "Fire at will", - "Hold fire": "Hold fire", - "Return fire": "Return fire", - "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order)", + "Fire at will": "Aggressive", + "Hold fire": "Passive", + "Return fire": "Return Fire", + "Defensive": "Defensive", + "Fire at all": "Fire At All", + "Fire at will_tooltip": "Automatically engage enemies within range.", + "Hold fire_tooltip": "Only attack with an explicit order.", + "Return fire_tooltip": "Only attack threats targeting this unit.", + "Defensive_tooltip": "Automatically engage enemies within 300 range.", + "Fire at all_tooltip": "Automatically engage enemies and neutral units within range.", + "firestate_tooltip": "Cycle between Passive, Defensive, and Aggressive fire states.", "Hold pos": "Hold pos", "Maneuver": "Maneuver", "Roam": "Roam", "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", "bomber_targeting": "Targeting", - "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", + "bomber_targeting_tooltip": "Passive: Only attack with an explicit order. Aggressive: Idle bombers will seek targets within range.", "Repeat on": "Repeat On", "Repeat off": "Repeat Off", "repeat_tooltip": "Repeat unit command queue", @@ -1681,7 +1688,7 @@ "unitidlebuildericons": "Unit idle builder icons", "unitidlebuildericons_descr": "Shows a sleep icon above builder units that are idle.", "unitfirestateicons": "Unit firing state icons", - "unitfirestateicons_descr": "Shows an icon above units that are either set on Hold Fire or Return Fire.", + "unitfirestateicons_descr": "Shows an icon above units that are set on Passive or Defensive.", "resurrectionhalos": "Resurrected unit halos", "resurrectionhalos_descr": "Adds halos above resurrected units.", "nametags_rank": "Commander name tag rank icon", @@ -1777,8 +1784,8 @@ "smartselect_includejammer_descr": "When rectangle-drag-selecting an area, exclude jammer units (hold Shift to override)", "onlyfighterspatrol": "Only fighters patrol", "onlyfighterspatrol_descr": "Only fighters obey a factory's patrol route after leaving airlab.", - "bombers_default_hold_fire": "Set bombers on hold fire", - "bombers_default_hold_fire_descr": "Produced bombers start on Hold Fire after leaving airlab.", + "bombers_default_hold_fire": "Set bombers on Passive", + "bombers_default_hold_fire_descr": "Produced bombers start on Passive after leaving airlab.", "fightersfly": "Set fighters on Fly mode", "fightersfly_descr": "Setting fighters on Fly mode when created", "prioconturrets": "Priority construction turrets", diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua new file mode 100644 index 00000000000..479e4ac7b32 --- /dev/null +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -0,0 +1,104 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = "Firestate Handler", + desc = "Handles customized firestate behavior", + author = "SethDGamre", + date = "2026.06.28", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE +local Firestates = VFS.Include("modules/firestates.lua") +local DEFENSIVE_FIRE_RANGE = 300 +local DEFENSIVE_FIRE_RANGE_SQ = DEFENSIVE_FIRE_RANGE * DEFENSIVE_FIRE_RANGE +local INLOS = { inlos = true } + +local spGiveOrderToUnit = Spring.GiveOrderToUnit +local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitStates = Spring.GetUnitStates +local spGetUnitRulesParam = Spring.GetUnitRulesParam +local spSetUnitRulesParam = Spring.SetUnitRulesParam +local settingEngineFirestate = false + +local function setUserFirestate(unitID, state) + local engineFirestate = Firestates.engineFirestateFor(state) + if not engineFirestate then + return + end + spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) + settingEngineFirestate = true + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, engineFirestate, 0) + settingEngineFirestate = false +end + +function gadget:AllowCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOptions, cmdTag, playerID, fromSynced, fromLua, fromInsert) + if cmdID == CMD_USER_FIRESTATE then + local state = cmdParams[1] + if Firestates.isUserFacing(state) then + setUserFirestate(unitID, state) + end + return false + end + + if settingEngineFirestate then + return true + end + + if cmdID == CMD_FIRE_STATE then + local state = Firestates.logicalFromEngineFirestate(cmdParams[1]) + spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) + end + return true +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + local firestate = spGetUnitStates(unitID, false) + local state = Firestates.logicalFromEngineFirestate(firestate) + spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) +end + +function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) + if spGetUnitRulesParam(attackerID, Firestates.RULES_PARAM) ~= Firestates.DEFENSIVE then + return true + end + + local attackerX, _, attackerZ = spGetUnitPosition(attackerID) + local targetX, _, targetZ = spGetUnitPosition(targetID) + if not attackerX or not targetX then + return true + end + + local distanceX = attackerX - targetX + local distanceZ = attackerZ - targetZ + if distanceX * distanceX + distanceZ * distanceZ > DEFENSIVE_FIRE_RANGE_SQ then + return false + end + + return true +end + +function gadget:Initialize() + gadgetHandler:RegisterCMDID(CMD_USER_FIRESTATE) + gadgetHandler:RegisterAllowCommand(CMD_FIRE_STATE) + gadgetHandler:RegisterAllowCommand(CMD_USER_FIRESTATE) + for unitDefID, unitDef in pairs(UnitDefs) do + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDefID = weapons[weaponNum].weaponDef + local weaponDef = WeaponDefs[weaponDefID] + if not weaponDef.customParams.bogus then + Script.SetWatchAllowTarget(weaponDefID, true) + end + end + end +end diff --git a/luaui/Widgets/cmd_user_firestate.lua b/luaui/Widgets/cmd_user_firestate.lua new file mode 100644 index 00000000000..75c6d82242e --- /dev/null +++ b/luaui/Widgets/cmd_user_firestate.lua @@ -0,0 +1,37 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "User Firestate", + desc = "Maps user-facing firestate orders to custom firestate states", + author = "SethDGamre", + date = "2026.06.28", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true + } +end + +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE +local Firestates = VFS.Include("modules/firestates.lua") +local spGetSelectedUnits = Spring.GetSelectedUnits +local spGiveOrderToUnitArray = Spring.GiveOrderToUnitArray + +local userFirestateByEngineFirestate = { + [Firestates.ENGINE_HOLD_FIRE] = Firestates.PASSIVE, + [Firestates.ENGINE_RETURN_FIRE] = Firestates.DEFENSIVE, + [Firestates.ENGINE_FIRE_AT_WILL] = Firestates.AGGRESSIVE, +} + +function widget:CommandNotify(cmdID, cmdParams, cmdOptions) + if cmdID ~= CMD_FIRE_STATE or not cmdParams then + return false + end + local userFirestate = userFirestateByEngineFirestate[cmdParams[1]] + if userFirestate == nil then + return false + end + spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { userFirestate }, 0) + return true +end diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index fd36123711b..9b62dd08354 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -22,8 +22,11 @@ local spGetSelectedUnits = Spring.GetSelectedUnits local spGetGameFrame = Spring.GetGameFrame local spGetViewGeometry = Spring.GetViewGeometry local spGetSpectatingState = Spring.GetSpectatingState +local spGetUnitRulesParam = Spring.GetUnitRulesParam +local spGiveOrderToUnitArray = Spring.GiveOrderToUnitArray local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local Firestates = VFS.Include("modules/firestates.lua") local currentLayout local cellZoom = 1 @@ -157,6 +160,8 @@ local lastColorize = -1 local printTextCache = {} local CANCEL_TARGET_CMD_ID = 34924 -- UNIT_CANCEL_TARGET +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE local hiddenCommands = { [CMD.LOAD_ONTO] = true, @@ -336,6 +341,44 @@ local function computeWaitState() end end +local function getUserFirestateCommand(command, unitID) + local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) + local displayIndex + local stateName + local filledStateCount + if Firestates.isUserFacing(userFirestate) then + displayIndex = Firestates.userFacingDisplayIndex(userFirestate) + if userFirestate == Firestates.DEFENSIVE then + stateName = "Defensive" + elseif userFirestate == Firestates.PASSIVE then + stateName = "Hold fire" + else + stateName = "Fire at will" + end + elseif userFirestate == Firestates.RETURN_FIRE then + displayIndex = 1 + stateName = "Return fire" + filledStateCount = 2 + elseif userFirestate == Firestates.FIRE_AT_ALL then + displayIndex = 2 + stateName = "Fire at all" + filledStateCount = 3 + else + return command + end + local patchedCommand = table.copy(command) + patchedCommand.params = { + displayIndex, + "Hold fire", + stateName, + stateName, + } + patchedCommand.userFirestate = userFirestate + patchedCommand.userFacingFirestate = Firestates.isUserFacing(userFirestate) + patchedCommand.filledStateCount = filledStateCount + return patchedCommand +end + local function refreshCommands() local waitCommand -- Clear and reuse temp tables instead of creating new ones @@ -365,8 +408,12 @@ local function refreshCommands() if command.type == CMDTYPE_ICON_BUILDING or (string.find(command.action, 'buildunit_', 1, true) == 1) then -- intentionally empty, no action to take elseif isStateCommand[command.id] then + local patchedCommand = command + if command.id == CMD_FIRE_STATE and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then + patchedCommand = getUserFirestateCommand(command, cachedFirstUnit) + end stateCommandsCount = stateCommandsCount + 1 - stateCommandsTemp[stateCommandsCount] = command + stateCommandsTemp[stateCommandsCount] = patchedCommand elseif command.id == CMD.WAIT then waitCommandCount = 1 waitCommand = command @@ -964,6 +1011,9 @@ local function drawCell(cell, zoom) if isStateCommand[cmd.id] then statecount = #cmd.params - 1 --number of states for the cmd curstate = cmd.params[1] + 1 + if cmd.filledStateCount then + curstate = cmd.filledStateCount + end else statecount = 2 curstate = cachedWaitState @@ -983,7 +1033,7 @@ local function drawCell(cell, zoom) local glowSize = math_floor(stateHeight * 8) local r, g, b, a = 0, 0, 0, 0 for i = 1, statecount do - if i == curstate or i == desiredState then + if i == curstate or i == desiredState or (cmd.filledStateCount and i <= cmd.filledStateCount) then if i == 1 then r, g, b, a = 1, 0.1, 0.1, (i == desiredState and 0.33 or 0.8) elseif i == 2 then @@ -1286,7 +1336,10 @@ function widget:MousePress(x, y, button) if playSounds then Spring.PlaySoundFile(soundButton, 0.6, 'ui') end - if cmd.id and Spring.GetCmdDescIndex(cmd.id) then + if cmd.id == CMD_FIRE_STATE and cmd.userFacingFirestate and clickedCellDesiredState then + local nextFirestate = Firestates.userFacingStateFromDisplayIndex(clickedCellDesiredState) + spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { nextFirestate }, 0) + elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then Spring.SetActiveCommand(Spring.GetCmdDescIndex(cmd.id), button, true, false, Spring.GetModKeyState()) end break diff --git a/luaui/Widgets/gui_unit_firestate_icons.lua b/luaui/Widgets/gui_unit_firestate_icons.lua index 823577301fa..7ec8c610e23 100644 --- a/luaui/Widgets/gui_unit_firestate_icons.lua +++ b/luaui/Widgets/gui_unit_firestate_icons.lua @@ -19,7 +19,7 @@ local showAllHoldFireIcons = false -- else only show for user triggered hold fir -- Localized Spring API -------------------------------------------------------------------------------- local spGetGameFrame = Spring.GetGameFrame -local spGetUnitStates = Spring.GetUnitStates +local spGetUnitRulesParam = Spring.GetUnitRulesParam local spValidUnitID = Spring.ValidUnitID local spGetUnitIsDead = Spring.GetUnitIsDead local spGetSelectedUnits = Spring.GetSelectedUnits @@ -30,6 +30,9 @@ local gaiaTeamID = Spring.GetGaiaTeamID() local HOLD_FIRE = 0 local RETURN_FIRE = 1 +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE +local Firestates = VFS.Include("modules/firestates.lua") -- Textures to display (replace with dedicated icons if available) local holdFireTexture = "LuaUI/Images/holdfire.png" @@ -134,6 +137,15 @@ WG['unitfirestate'].getShowAllHoldFireIcons = function() return showAllHoldFireIcons end +local function getDisplayFireState(unitID) + local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) + if userFirestate == Firestates.PASSIVE then + return HOLD_FIRE + elseif userFirestate == Firestates.DEFENSIVE or userFirestate == Firestates.RETURN_FIRE then + return RETURN_FIRE + end +end + -------------------------------------------------------------------------------- -- Helper: push a unit into a fire-state VBO -------------------------------------------------------------------------------- @@ -221,12 +233,9 @@ function widget:VisibleUnitsChanged(extVisibleUnits, extNumVisibleUnits) if teamID ~= gaiaTeamID then unitToTeam[unitID] = teamID if not crashingUnits[unitID] and not deadAllyTeams[teamToAllyTeam[teamID]] then - local states = spGetUnitStates(unitID) - if states then - local fs = states.firestate - unitFireState[unitID] = fs - applyFireState(unitID, unitDefID, fs, gf) - end + local fs = getDisplayFireState(unitID) + unitFireState[unitID] = fs + applyFireState(unitID, unitDefID, fs, gf) end end end @@ -239,9 +248,7 @@ function widget:VisibleUnitAdded(unitID, unitDefID, unitTeam) visibleUnits[unitID] = unitDefID unitToTeam[unitID] = unitTeam if crashingUnits[unitID] or deadAllyTeams[teamToAllyTeam[unitTeam]] then return end - local states = spGetUnitStates(unitID) - if not states then return end - local fs = states.firestate + local fs = getDisplayFireState(unitID) unitFireState[unitID] = fs applyFireState(unitID, unitDefID, fs, spGetGameFrame()) if holdFireVBO.dirty then uploadAllElements(holdFireVBO) end @@ -275,12 +282,12 @@ function widget:CrashingAircraft(unitID, unitDefID, teamID) end function widget:CommandNotify(cmdID, cmdParams, cmdOpts) - if cmdID ~= CMD.FIRE_STATE or not cmdParams then return false end + if (cmdID ~= CMD_FIRE_STATE and cmdID ~= CMD_USER_FIRESTATE) or not cmdParams then return false end local fs = cmdParams[1] local selectedUnits = spGetSelectedUnits() for i = 1, #selectedUnits do local unitID = selectedUnits[i] - if fs == HOLD_FIRE then + if fs == HOLD_FIRE or fs == Firestates.PASSIVE then manuallyHeldFire[unitID] = true else manuallyHeldFire[unitID] = nil @@ -291,8 +298,8 @@ end function widget:UnitCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOpts, cmdTag, playerID, fromSynced, fromLua) if teamID == gaiaTeamID then return end - if cmdID ~= CMD.FIRE_STATE then return end - local fs = cmdParams[1] + if cmdID ~= CMD_FIRE_STATE and cmdID ~= CMD_USER_FIRESTATE then return end + local fs = getDisplayFireState(unitID) if fs ~= HOLD_FIRE then manuallyHeldFire[unitID] = nil end diff --git a/modules/customcommands.lua b/modules/customcommands.lua index fec20fabbd5..653cbf36421 100644 --- a/modules/customcommands.lua +++ b/modules/customcommands.lua @@ -29,6 +29,7 @@ local gameCommands = { CARRIER_SPAWN_ONOFF = 31200, MORPH = 31210, + USER_FIRESTATE = 31300, MANUAL_LAUNCH = 32102, UNIT_SET_TARGET_NO_GROUND = 34922, -- unit_target_on_the_move UNIT_SET_TARGET = 34923, diff --git a/modules/firestates.lua b/modules/firestates.lua new file mode 100644 index 00000000000..e20edd954f8 --- /dev/null +++ b/modules/firestates.lua @@ -0,0 +1,78 @@ +local firestates = { + RULES_PARAM = "user_firestate", + + PASSIVE = 0, + DEFENSIVE = 1, + RETURN_FIRE = 2, + AGGRESSIVE = 3, + FIRE_AT_ALL = 4, + + ENGINE_HOLD_FIRE = 0, + ENGINE_RETURN_FIRE = 1, + ENGINE_FIRE_AT_WILL = 2, + ENGINE_FIRE_AT_ALL = 3, +} + +local userFacingStateByDisplayIndex = { + [0] = firestates.PASSIVE, + [1] = firestates.DEFENSIVE, + [2] = firestates.AGGRESSIVE, +} + +local displayIndexByUserFacingState = { + [firestates.PASSIVE] = 0, + [firestates.DEFENSIVE] = 1, + [firestates.AGGRESSIVE] = 2, +} + +local engineFirestateByState = { + [firestates.PASSIVE] = firestates.ENGINE_HOLD_FIRE, + [firestates.DEFENSIVE] = firestates.ENGINE_FIRE_AT_WILL, + [firestates.RETURN_FIRE] = firestates.ENGINE_RETURN_FIRE, + [firestates.AGGRESSIVE] = firestates.ENGINE_FIRE_AT_WILL, + [firestates.FIRE_AT_ALL] = firestates.ENGINE_FIRE_AT_ALL, +} + +local stateByEngineFirestate = { + [firestates.ENGINE_HOLD_FIRE] = firestates.PASSIVE, + [firestates.ENGINE_RETURN_FIRE] = firestates.RETURN_FIRE, + [firestates.ENGINE_FIRE_AT_WILL] = firestates.AGGRESSIVE, + [firestates.ENGINE_FIRE_AT_ALL] = firestates.FIRE_AT_ALL, +} + +function firestates.isUserFacing(state) + return displayIndexByUserFacingState[state] ~= nil +end + +function firestates.userFacingDisplayIndex(state) + return displayIndexByUserFacingState[state] +end + +function firestates.userFacingStateFromDisplayIndex(displayIndex) + return userFacingStateByDisplayIndex[displayIndex] +end + +function firestates.nextUserFacing(state, direction) + local displayIndex = displayIndexByUserFacingState[state] + if not displayIndex then + displayIndex = 0 + end + direction = direction or 1 + displayIndex = displayIndex + direction + if displayIndex > 2 then + displayIndex = 0 + elseif displayIndex < 0 then + displayIndex = 2 + end + return userFacingStateByDisplayIndex[displayIndex] +end + +function firestates.engineFirestateFor(state) + return engineFirestateByState[state] +end + +function firestates.logicalFromEngineFirestate(engineFirestate) + return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE +end + +return firestates From 5e52f45c34c4a3230b1bb9e1c01314feb44825be Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 18:05:41 -0500 Subject: [PATCH 03/38] remove defensive behavior logic from firestate handler --- luarules/gadgets/unit_firestate_handler.lua | 34 --------------------- 1 file changed, 34 deletions(-) diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua index 479e4ac7b32..b57dad2f8be 100644 --- a/luarules/gadgets/unit_firestate_handler.lua +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -19,14 +19,10 @@ end local CMD_FIRE_STATE = CMD.FIRE_STATE local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE local Firestates = VFS.Include("modules/firestates.lua") -local DEFENSIVE_FIRE_RANGE = 300 -local DEFENSIVE_FIRE_RANGE_SQ = DEFENSIVE_FIRE_RANGE * DEFENSIVE_FIRE_RANGE local INLOS = { inlos = true } local spGiveOrderToUnit = Spring.GiveOrderToUnit -local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitStates = Spring.GetUnitStates -local spGetUnitRulesParam = Spring.GetUnitRulesParam local spSetUnitRulesParam = Spring.SetUnitRulesParam local settingEngineFirestate = false @@ -67,38 +63,8 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) end -function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if spGetUnitRulesParam(attackerID, Firestates.RULES_PARAM) ~= Firestates.DEFENSIVE then - return true - end - - local attackerX, _, attackerZ = spGetUnitPosition(attackerID) - local targetX, _, targetZ = spGetUnitPosition(targetID) - if not attackerX or not targetX then - return true - end - - local distanceX = attackerX - targetX - local distanceZ = attackerZ - targetZ - if distanceX * distanceX + distanceZ * distanceZ > DEFENSIVE_FIRE_RANGE_SQ then - return false - end - - return true -end - function gadget:Initialize() gadgetHandler:RegisterCMDID(CMD_USER_FIRESTATE) gadgetHandler:RegisterAllowCommand(CMD_FIRE_STATE) gadgetHandler:RegisterAllowCommand(CMD_USER_FIRESTATE) - for unitDefID, unitDef in pairs(UnitDefs) do - local weapons = unitDef.weapons - for weaponNum = 1, #weapons do - local weaponDefID = weapons[weaponNum].weaponDef - local weaponDef = WeaponDefs[weaponDefID] - if not weaponDef.customParams.bogus then - Script.SetWatchAllowTarget(weaponDefID, true) - end - end - end end From d363fd3180618fd4c59347031c6a9bbaa4dd3b1d Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 18:06:08 -0500 Subject: [PATCH 04/38] create defensive firestate behavior gadget --- luarules/gadgets/unit_defensive_firestate.lua | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 luarules/gadgets/unit_defensive_firestate.lua diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua new file mode 100644 index 00000000000..e6ec78c2a08 --- /dev/null +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -0,0 +1,57 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = "Firestate Defensive", + desc = "Limits defensive firestate to nearby targets", + author = "SethDGamre", + date = "2026.06.28", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local Firestates = VFS.Include("modules/firestates.lua") +local DEFENSIVE_FIRE_RANGE = 300 +local DEFENSIVE_FIRE_RANGE_SQ = DEFENSIVE_FIRE_RANGE * DEFENSIVE_FIRE_RANGE + +local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitRulesParam = Spring.GetUnitRulesParam + +function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) + if spGetUnitRulesParam(attackerID, Firestates.RULES_PARAM) ~= Firestates.DEFENSIVE then + return true + end + + local attackerX, _, attackerZ = spGetUnitPosition(attackerID) + local targetX, _, targetZ = spGetUnitPosition(targetID) + if not attackerX or not targetX then + return true + end + + local distanceX = attackerX - targetX + local distanceZ = attackerZ - targetZ + if distanceX * distanceX + distanceZ * distanceZ > DEFENSIVE_FIRE_RANGE_SQ then + return false + end + + return true +end + +function gadget:Initialize() + for unitDefID, unitDef in pairs(UnitDefs) do + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDefID = weapons[weaponNum].weaponDef + local weaponDef = WeaponDefs[weaponDefID] + if not weaponDef.customParams.bogus then + Script.SetWatchAllowTarget(weaponDefID, true) + end + end + end +end From 9c4162b39f72106348f8c3d289eaf355ac7450d0 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 18:41:05 -0500 Subject: [PATCH 05/38] extract dps calc's from gui_info.lua to separate file --- luaui/Widgets/gui_info.lua | 280 ++++++-------------------------- modules/unitdefdps.lua | 324 +++++++++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+), 227 deletions(-) create mode 100644 modules/unitdefdps.lua diff --git a/luaui/Widgets/gui_info.lua b/luaui/Widgets/gui_info.lua index 4355f2512f3..bed1467147b 100644 --- a/luaui/Widgets/gui_info.lua +++ b/luaui/Widgets/gui_info.lua @@ -30,6 +30,7 @@ local emptyInfo = false local showEngineTooltip = false -- straight up display old engine delivered text local iconTypes = VFS.Include("gamedata/icontypes.lua") +local UnitDefDPS = VFS.Include("modules/unitdefdps.lua") local vsx, vsy = Spring.GetViewGeometry() @@ -290,53 +291,7 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].mex = true end local weapons = unitDef.weapons - - - ----------------------------------------------------- - -- Utility functions for calculating weapon values -- - ----------------------------------------------------- - - local function addPrimaryDPS(minDPS, maxDPS) - unitDefInfo[unitDefID].mindps = (unitDefInfo[unitDefID].mindps or 0) + minDPS - unitDefInfo[unitDefID].maxdps = (unitDefInfo[unitDefID].maxdps or 0) + maxDPS - end - - local function addSecondaryDPS(minDPS, maxDPS) - unitDefInfo[unitDefID].maxdps = (unitDefInfo[unitDefID].maxdps or 0) + maxDPS - end - - - local function calculateLaserDPS(def, damage) - local minIntensity = math.max(def.minIntensity, 0.5) - local mindps = minIntensity*(damage * def.salvoSize / def.reload) - local maxdps = damage * def.salvoSize / def.reload - return mindps, maxdps - end - - local function calculateWeaponDPS(def, damage) - local reloadDPS = damage * (def.salvoSize * def.projectiles) / def.reload - local stockpileDPS = damage * (def.salvoSize * def.projectiles) / (def.stockpile and def.stockpileTime/30 or def.reload) - return math_min(reloadDPS, stockpileDPS), math_max(reloadDPS, stockpileDPS) - end - - local function calculateClusterDPS(def, damage) - local munition = unitDef.name .. '_' .. def.customParams.cluster_def - local cmNumber = def.customParams.cluster_number - local cmDamage = WeaponDefNames[munition].damages[0] - - local mainDps = (def.salvoSize * def.projectiles) / def.reload * (damage) - local cmunDps = (def.salvoSize * def.projectiles) / def.reload * (cmNumber * cmDamage) - return mainDps, mainDps + cmunDps - end - - local function calculateAreaDPS(def, damage) - local burst = def.salvoSize * def.projectiles - local impactDps = damage * burst / def.reload - local areaDps = def.customParams.area_onhit_damage -- by definition - local damageMax = math_max(impactDps + areaDps, areaDps * burst * def.customParams.area_onhit_time / def.reload) - return impactDps, damageMax - end - + local dpsState = UnitDefDPS.newState() local function setEnergyAndMetalCosts(def) if def.energyCost > 0 and (not unitDefInfo[unitDefID].energyPerShot or def.energyCost > unitDefInfo[unitDefID].energyPerShot) then @@ -347,19 +302,13 @@ local function refreshUnitInfo() end end - ----------------------------------------------------- - - local unitExempt = false - local function refreshUnitWeaponInfo(i, weaponDef, isPrimaryWeapon) unitDefInfo[unitDefID].weapons[i] = weaponDef if isPrimaryWeapon and weapons[i].onlyTargets['vtol'] then - unitDefInfo[unitDefID].isAaUnit = true -- displays airLOS range + unitDefInfo[unitDefID].isAaUnit = true end - local addDPS = isPrimaryWeapon and addPrimaryDPS or addSecondaryDPS - if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then unitDefInfo[unitDefID].maxCoverage = math.max(unitDefInfo[unitDefID].maxCoverage or 1, weaponDef.coverageRange) @@ -374,81 +323,43 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].shieldRechargeCost = weaponDef.shieldPowerRegenEnergy else - if unitDef.name == 'armamb' or unitDef.name == 'cortoast' then -- weapons with low/high traj, this list is incomplete - unitExempt = true - if i==1 then --Calculating using first weapon only - addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category - end - - elseif - unitDef.customParams.evocomlvl or -- use primary weapon for evolving commanders - unitDef.name == 'armcom' or -- ignore underwater secondary - unitDef.name == 'corcom' or - unitDef.name == 'legcom' or - unitDef.name == 'corkarg' or -- ignore secondary weapons, kick - unitDef.name == 'armguard' or -- ignore high-trajectory modes - unitDef.name == 'corpun' or - unitDef.name == 'legcluster' or - unitDef.name == 'leglob' or - unitDef.name == 'legnavyfrigate' or - unitDef.name == 'armamb' or - unitDef.name == 'cortoast' or + UnitDefDPS.processWeapon(dpsState, unitDef, weapons, i, weaponDef, isPrimaryWeapon) + + if + unitDef.customParams.evocomlvl or + unitDef.name == 'armcom' or + unitDef.name == 'corcom' or + unitDef.name == 'legcom' or + unitDef.name == 'corkarg' or + unitDef.name == 'armguard' or + unitDef.name == 'corpun' or + unitDef.name == 'legcluster' or + unitDef.name == 'leglob' or + unitDef.name == 'legnavyfrigate' or unitDef.name == 'armvang' then - unitExempt = true - if i == 1 then --Calculating using first weapon only + if i == 1 then setEnergyAndMetalCosts(weaponDef) - - if weaponDef.type == "BeamLaser" then - addDPS(calculateLaserDPS(weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.cluster then -- Bullets that shoot other, smaller bullets - addDPS(calculateClusterDPS(weaponDef, weaponDef.damages[0])) - elseif weapons[i].onlyTargets['vtol'] ~= nil then - addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[armorIndex.vtol])) --Damage to air category - else - addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category - end end - - elseif unitDef.name == 'corkorg' then --excluding korstomp from dps calcuation for juggernaut - unitExempt = true - if i==1 then - local defDmg - defDmg = weaponDef.damages[0] --Damage to default armor category - addDPS(calculateWeaponDPS(weaponDef, defDmg)) - end - - if i==2 then + elseif unitDef.name == 'corkorg' then + if i == 2 then setEnergyAndMetalCosts(weaponDef) - addDPS(calculateLaserDPS(weaponDef, weaponDef.damages[0])) end + end - if i==3 then - addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category - end + if weaponDef.type == "BeamLaser" and not dpsState.unitExempt then + setEnergyAndMetalCosts(weaponDef) + end - elseif weaponDef.customParams.area_onhit_damage and weaponDef.customParams.area_onhit_time then - unitExempt = true - addDPS(calculateAreaDPS(weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.cluster then -- Bullets that explode into other, smaller bullets - unitExempt = true - addDPS(calculateClusterDPS(weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.speceffect == "split" then -- Bullets that split into other, smaller bullets - unitExempt = true - local splitd = WeaponDefNames[weaponDef.customParams.speceffect_def].damages[0] - local splitn = weaponDef.customParams.number or 1 - addDPS(calculateWeaponDPS(weaponDef, splitd * splitn)) - elseif weaponDef.customParams.spark_forkdamage then -- Lightning - unitExempt = true - addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - -- Sparks cannot retarget the original unit they hit, so add them as secondary damages. - local forkDamageRate = weaponDef.customParams.spark_forkdamage - addSecondaryDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0] * forkDamageRate)) - if unitExempt and weaponDef.paralyzer then -- DPS => EMP - unitDefInfo[unitDefID].minemp = unitDefInfo[unitDefID].mindps - unitDefInfo[unitDefID].maxemp = unitDefInfo[unitDefID].maxdps - unitDefInfo[unitDefID].mindps = nil - unitDefInfo[unitDefID].maxdps = nil + if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not dpsState.unitExempt then + local defDmg + if weapons[1].onlyTargets['vtol'] ~= nil then + defDmg = weaponDef.damages[armorIndex.vtol] + else + defDmg = weaponDef.damages[0] + end + if defDmg > 0 then + setEnergyAndMetalCosts(weaponDef) end end @@ -457,137 +368,52 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].range = weaponDef.range unitDefInfo[unitDefID].reloadTime = weaponDef.customParams.dronesuesestockpile and weaponDef.stockpileTime - or weaponDef.reload + or weaponDef.reload end - if weaponDef.type == "BeamLaser" and not unitExempt then -- BeamLaser dps calc - - local defDmg - - if weapons[1].onlyTargets['vtol'] ~= nil then --if main weapon isn't dedicated aa, then all weapons calculate using default armor category - defDmg = weaponDef.damages[armorIndex.vtol] - else - defDmg = weaponDef.damages[0] - end - - setEnergyAndMetalCosts(weaponDef) - - if weaponDef.paralyzer ~= true then - - - if weaponDef.customParams then - - if weaponDef.customParams.sweepfire then - unitDefInfo[unitDefID].maxdps = (weaponDef.damages[0] * weaponDef.customParams.sweepfire) / math.max(weaponDef.minIntensity, 0.5) - unitDefInfo[unitDefID].mindps = weaponDef.damages[0] * weaponDef.customParams.sweepfire - else - addDPS(calculateLaserDPS(weaponDef, defDmg)) - end - else - addDPS(calculateLaserDPS(weaponDef, defDmg)) - end - else - -- calculate laser emp dmg - local minIntensity = math.max(weaponDef.minIntensity, 0.5) - local prevMinDps = unitDefInfo[unitDefID].minemp or 0 - local prevMaxDps = unitDefInfo[unitDefID].maxemp or 0 - local mindps = minIntensity*(weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload) - local maxdps = weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload - - unitDefInfo[unitDefID].minemp = mindps + prevMinDps - unitDefInfo[unitDefID].maxemp = maxdps + prevMaxDps - end - elseif weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then -- exclude thor emp missile - local defDmg = weaponDef.damages[0] --Damage to default armor category - local emp = math_floor(defDmg * weaponDef.salvoSize / weaponDef.reload) - unitDefInfo[unitDefID].minemp = emp - unitDefInfo[unitDefID].maxemp = emp + if weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then unitDefInfo[unitDefID].range = weaponDef.range unitDefInfo[unitDefID].reloadTime = weaponDef.reload end - if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not unitExempt then - - local defDmg - - if weapons[1].onlyTargets['vtol'] ~= nil then --if main weapon isn't dedicated aa, then all weapons calculate using default armor category - defDmg = weaponDef.damages[armorIndex.vtol] - else - defDmg = weaponDef.damages[0] - end - - if(defDmg > 0) then - addDPS(calculateWeaponDPS(weaponDef, defDmg)) - setEnergyAndMetalCosts(weaponDef) - end - end end end for i = 1, #weapons do if not unitDefInfo[unitDefID].weapons then unitDefInfo[unitDefID].weapons = {} - unitDefInfo[unitDefID].mindps = 0 - unitDefInfo[unitDefID].maxdps = 0 + dpsState.mindps = 0 + dpsState.maxdps = 0 unitDefInfo[unitDefID].range = 0 unitDefInfo[unitDefID].reloadTime = 0 unitDefInfo[unitDefID].mainWeapon = 0 end local weaponDef = WeaponDefs[weapons[i].weaponDef] - -- Only groups 0 [always active] and 1 [primary weapon set] are aggregated. - -- Others might be checked for abilities still, e.g. antinuke interceptors. if showWeaponGroups[weaponDef.customParams.weapons_group] and weaponDef.customParams.bogus ~= "1" then refreshUnitWeaponInfo(i, weaponDef, weaponDef.customParams.weapons_role ~= "secondary") end end if unitDefInfo[unitDefID].mainWeapon == 0 then - unitDefInfo[unitDefID].mainWeapon = 1 -- All the unit's weapons were fakes. + unitDefInfo[unitDefID].mainWeapon = 1 end - if unitDef.customParams.unitgroup and unitDef.customParams.unitgroup == 'explo' and unitDef.deathExplosion and WeaponDefNames[unitDef.deathExplosion] then - local weapon = WeaponDefs[WeaponDefNames[unitDef.deathExplosion].id] - if weapon then - local dmg = weapon.damages[Game.armorTypes["default"]] - unitDefInfo[unitDefID].mindps = dmg - unitDefInfo[unitDefID].maxdps = dmg - unitDefInfo[unitDefID].reloadTime = nil - end + UnitDefDPS.applyDeathExplosion(dpsState, unitDef) + if dpsState.mindps then + unitDefInfo[unitDefID].mindps = dpsState.mindps end - end - - -- Account for sub-unit damages, namely carriers and drones. - local mins = { "mindps", "minemp", } - local maxs = { "maxdps", "maxemp", } - for unitDefID, unitDef in pairs(UnitDefs) do - local unitInfo = unitDefInfo[unitDefID] - for index, weapon in ipairs(unitDef.weapons) do - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef.customParams.carried_unit and UnitDefNames[weaponDef.customParams.carried_unit] then - local droneCount = weaponDef.customParams.maxunits or 1 - local droneDef = UnitDefNames[weaponDef.customParams.carried_unit] - local droneInfo = unitDefInfo[droneDef.id] - - for _, key in ipairs(mins) do - if droneInfo[key] then - unitInfo[key] = (unitInfo[key] or 0) -- times zero drones == zero - end - end - - for _, key in ipairs(maxs) do - if droneInfo[key] then - unitInfo[key] = (unitInfo[key] or 0) + (droneInfo[key] * droneCount) - end - end - end + if dpsState.maxdps then + unitDefInfo[unitDefID].maxdps = dpsState.maxdps end - end - - -- Convert aggregated values to display formats last to avoid rounding errors. - local summedKeys = { "mindps", "maxdps", "minemp", "maxemp", } - for unitDefID, unitInfo in pairs(unitDefInfo) do - for _, key in pairs(summedKeys) do - if type(unitInfo[key]) == "number" then - unitInfo[key] = math_floor(unitInfo[key]) - end + if dpsState.minemp then + unitDefInfo[unitDefID].minemp = dpsState.minemp + end + if dpsState.maxemp then + unitDefInfo[unitDefID].maxemp = dpsState.maxemp + end + if unitDef.customParams.unitgroup and unitDef.customParams.unitgroup == 'explo' and unitDef.deathExplosion and WeaponDefNames[unitDef.deathExplosion] then + unitDefInfo[unitDefID].reloadTime = nil end end + + UnitDefDPS.applyCarriedUnits(unitDefInfo) + UnitDefDPS.floorValues(unitDefInfo) end local groups, unitGroup = {}, {} -- retrieves from buildmenu in initialize diff --git a/modules/unitdefdps.lua b/modules/unitdefdps.lua new file mode 100644 index 00000000000..e5799c82d72 --- /dev/null +++ b/modules/unitdefdps.lua @@ -0,0 +1,324 @@ +local math_floor = math.floor +local math_min = math.min +local math_max = math.max + +local DEFAULT_WEAPON_GROUPS = { ["0"] = true, ["1"] = true } + +local armorIndex + +local function getArmorIndex() + if not armorIndex then + armorIndex = {} + for ii = 1, #Game.armorTypes do + armorIndex[Game.armorTypes[ii]] = ii + end + end + return armorIndex +end + +local function calculateLaserDPS(def, damage) + local minIntensity = math_max(def.minIntensity, 0.5) + local mindps = minIntensity * (damage * def.salvoSize / def.reload) + local maxdps = damage * def.salvoSize / def.reload + return mindps, maxdps +end + +local function calculateWeaponDPS(def, damage) + local reloadDPS = damage * (def.salvoSize * def.projectiles) / def.reload + local stockpileDPS = damage * (def.salvoSize * def.projectiles) / (def.stockpile and def.stockpileTime / 30 or def.reload) + return math_min(reloadDPS, stockpileDPS), math_max(reloadDPS, stockpileDPS) +end + +local function calculateClusterDPS(unitDef, def, damage) + local munition = unitDef.name .. '_' .. def.customParams.cluster_def + local cmNumber = def.customParams.cluster_number + local cmDamage = WeaponDefNames[munition].damages[0] + + local mainDps = (def.salvoSize * def.projectiles) / def.reload * damage + local cmunDps = (def.salvoSize * def.projectiles) / def.reload * (cmNumber * cmDamage) + return mainDps, mainDps + cmunDps +end + +local function calculateAreaDPS(def, damage) + local burst = def.salvoSize * def.projectiles + local impactDps = damage * burst / def.reload + local areaDps = def.customParams.area_onhit_damage + local damageMax = math_max(impactDps + areaDps, areaDps * burst * def.customParams.area_onhit_time / def.reload) + return impactDps, damageMax +end + +local function addPrimaryDPS(state, minDPS, maxDPS) + state.mindps = (state.mindps or 0) + minDPS + state.maxdps = (state.maxdps or 0) + maxDPS +end + +local function addSecondaryDPS(state, minDPS, maxDPS) + state.maxdps = (state.maxdps or 0) + maxDPS +end + +local function processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon, armorTypes) + local addDPS = isPrimaryWeapon and addPrimaryDPS or addSecondaryDPS + + if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then + return + end + + if weaponDef.shieldRadius and weaponDef.shieldRadius > 0 then + return + end + + if unitDef.name == 'armamb' or unitDef.name == 'cortoast' then + state.unitExempt = true + if weaponIndex == 1 then + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + end + + elseif + unitDef.customParams.evocomlvl or + unitDef.name == 'armcom' or + unitDef.name == 'corcom' or + unitDef.name == 'legcom' or + unitDef.name == 'corkarg' or + unitDef.name == 'armguard' or + unitDef.name == 'corpun' or + unitDef.name == 'legcluster' or + unitDef.name == 'leglob' or + unitDef.name == 'legnavyfrigate' or + unitDef.name == 'armamb' or + unitDef.name == 'cortoast' or + unitDef.name == 'armvang' + then + state.unitExempt = true + if weaponIndex == 1 then + if weaponDef.type == "BeamLaser" then + addDPS(state, calculateLaserDPS(weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.cluster then + addDPS(state, calculateClusterDPS(unitDef, weaponDef, weaponDef.damages[0])) + elseif weapons[weaponIndex].onlyTargets['vtol'] ~= nil then + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[armorTypes.vtol])) + else + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + end + end + + elseif unitDef.name == 'corkorg' then + state.unitExempt = true + if weaponIndex == 1 then + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + end + if weaponIndex == 2 then + addDPS(state, calculateLaserDPS(weaponDef, weaponDef.damages[0])) + end + if weaponIndex == 3 then + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + end + + elseif weaponDef.customParams.area_onhit_damage and weaponDef.customParams.area_onhit_time then + state.unitExempt = true + addDPS(state, calculateAreaDPS(weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.cluster then + state.unitExempt = true + addDPS(state, calculateClusterDPS(unitDef, weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.speceffect == "split" then + state.unitExempt = true + local splitd = WeaponDefNames[weaponDef.customParams.speceffect_def].damages[0] + local splitn = weaponDef.customParams.number or 1 + addDPS(state, calculateWeaponDPS(weaponDef, splitd * splitn)) + elseif weaponDef.customParams.spark_forkdamage then + state.unitExempt = true + addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + local forkDamageRate = weaponDef.customParams.spark_forkdamage + addSecondaryDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0] * forkDamageRate)) + if state.unitExempt and weaponDef.paralyzer then + state.minemp = state.mindps + state.maxemp = state.maxdps + state.mindps = nil + state.maxdps = nil + end + end + + if weaponDef.type == "BeamLaser" and not state.unitExempt then + local defDmg + if weapons[1].onlyTargets['vtol'] ~= nil then + defDmg = weaponDef.damages[armorTypes.vtol] + else + defDmg = weaponDef.damages[0] + end + + if weaponDef.paralyzer ~= true then + if weaponDef.customParams then + if weaponDef.customParams.sweepfire then + state.maxdps = (weaponDef.damages[0] * weaponDef.customParams.sweepfire) / math_max(weaponDef.minIntensity, 0.5) + state.mindps = weaponDef.damages[0] * weaponDef.customParams.sweepfire + else + addDPS(state, calculateLaserDPS(weaponDef, defDmg)) + end + else + addDPS(state, calculateLaserDPS(weaponDef, defDmg)) + end + else + local minIntensity = math_max(weaponDef.minIntensity, 0.5) + local prevMinDps = state.minemp or 0 + local prevMaxDps = state.maxemp or 0 + local mindps = minIntensity * (weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload) + local maxdps = weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload + state.minemp = mindps + prevMinDps + state.maxemp = maxdps + prevMaxDps + end + elseif weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then + local defDmg = weaponDef.damages[0] + local emp = math_floor(defDmg * weaponDef.salvoSize / weaponDef.reload) + state.minemp = emp + state.maxemp = emp + end + + if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not state.unitExempt then + local defDmg + if weapons[1].onlyTargets['vtol'] ~= nil then + defDmg = weaponDef.damages[armorTypes.vtol] + else + defDmg = weaponDef.damages[0] + end + + if defDmg > 0 then + addDPS(state, calculateWeaponDPS(weaponDef, defDmg)) + end + end +end + +local function applyDeathExplosion(state, unitDef) + if unitDef.customParams.unitgroup and unitDef.customParams.unitgroup == 'explo' and unitDef.deathExplosion and WeaponDefNames[unitDef.deathExplosion] then + local weapon = WeaponDefs[WeaponDefNames[unitDef.deathExplosion].id] + if weapon then + local dmg = weapon.damages[Game.armorTypes["default"]] + state.mindps = dmg + state.maxdps = dmg + end + end +end + +local function calculateUnitDefDPS(unitDef, showWeaponGroups) + local armorTypes = getArmorIndex() + local state = { + mindps = nil, + maxdps = nil, + minemp = nil, + maxemp = nil, + unitExempt = false, + } + local weapons = unitDef.weapons + local hasQualifyingWeapons = false + + for weaponIndex = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponIndex].weaponDef] + if showWeaponGroups[weaponDef.customParams.weapons_group] and weaponDef.customParams.bogus ~= "1" then + if not hasQualifyingWeapons then + state.mindps = 0 + state.maxdps = 0 + hasQualifyingWeapons = true + end + processWeapon( + state, + unitDef, + weapons, + weaponIndex, + weaponDef, + weaponDef.customParams.weapons_role ~= "secondary", + armorTypes + ) + end + end + + applyDeathExplosion(state, unitDef) + return state +end + +local function applyCarriedUnits(unitDefInfoTable) + local mins = { "mindps", "minemp" } + local maxs = { "maxdps", "maxemp" } + for unitDefID, unitDef in pairs(UnitDefs) do + local unitInfo = unitDefInfoTable[unitDefID] + if unitInfo then + for _, weapon in ipairs(unitDef.weapons) do + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef.customParams.carried_unit and UnitDefNames[weaponDef.customParams.carried_unit] then + local droneCount = weaponDef.customParams.maxunits or 1 + local droneDef = UnitDefNames[weaponDef.customParams.carried_unit] + local droneInfo = unitDefInfoTable[droneDef.id] + + if droneInfo then + for _, key in ipairs(mins) do + if droneInfo[key] then + unitInfo[key] = (unitInfo[key] or 0) + end + end + + for _, key in ipairs(maxs) do + if droneInfo[key] then + unitInfo[key] = (unitInfo[key] or 0) + (droneInfo[key] * droneCount) + end + end + end + end + end + end + end +end + +local function floorValues(unitDefInfoTable) + local summedKeys = { "mindps", "maxdps", "minemp", "maxemp" } + for _, unitInfo in pairs(unitDefInfoTable) do + for _, key in pairs(summedKeys) do + if type(unitInfo[key]) == "number" then + unitInfo[key] = math_floor(unitInfo[key]) + end + end + end +end + +local UnitDefDPS = {} + +UnitDefDPS.DEFAULT_WEAPON_GROUPS = DEFAULT_WEAPON_GROUPS + +function UnitDefDPS.newState() + return { + mindps = nil, + maxdps = nil, + minemp = nil, + maxemp = nil, + unitExempt = false, + } +end + +function UnitDefDPS.processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon) + processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon, getArmorIndex()) +end + +function UnitDefDPS.applyDeathExplosion(state, unitDef) + applyDeathExplosion(state, unitDef) +end + +function UnitDefDPS.applyCarriedUnits(unitDefInfoTable) + applyCarriedUnits(unitDefInfoTable) +end + +function UnitDefDPS.floorValues(unitDefInfoTable) + floorValues(unitDefInfoTable) +end + +function UnitDefDPS.calculateAll(showWeaponGroups) + showWeaponGroups = showWeaponGroups or DEFAULT_WEAPON_GROUPS + local results = {} + for unitDefID, unitDef in pairs(UnitDefs) do + results[unitDefID] = calculateUnitDefDPS(unitDef, showWeaponGroups) + end + applyCarriedUnits(results) + floorValues(results) + return results +end + +function UnitDefDPS.getEffectiveDPS(unitDef, dpsState) + return dpsState.maxdps or dpsState.mindps or 0 +end + +return UnitDefDPS From 67f7598fe899cc8710f1d4d1be3b1b04bb0cbd26 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 19:58:01 -0500 Subject: [PATCH 06/38] mostly base-functional defensive firestate --- luarules/gadgets/unit_defensive_firestate.lua | 179 +++++++++++++++++- 1 file changed, 173 insertions(+), 6 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index e6ec78c2a08..63390346122 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -17,14 +17,118 @@ if not gadgetHandler:IsSyncedCode() then end local Firestates = VFS.Include("modules/firestates.lua") -local DEFENSIVE_FIRE_RANGE = 300 -local DEFENSIVE_FIRE_RANGE_SQ = DEFENSIVE_FIRE_RANGE * DEFENSIVE_FIRE_RANGE +local UnitDefDPS = VFS.Include("modules/unitdefdps.lua") +local CMD_FIRE_STATE = CMD.FIRE_STATE +local DPS_PENALTY = 0.99 +local MULTI_WEAPON_DPS_PENALTY = 0.66 +local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 +local BEYOND_MAX_RANGE = -1 +local defThreatRanges = {} +local defensiveWatchList = {} local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitRulesParam = Spring.GetUnitRulesParam +local spGetAllUnits = Spring.GetAllUnits +local spEcho = Spring.Echo +local mathDistance2dSquared = math.distance2dSquared + +local function countNonBogusWeapons(unitDef) + local count = 0 + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and not weaponDef.customParams.bogus then + count = count + 1 + end + end + return count +end + +local function getLongestThreatRange(unitDef) + local longestRange = 0 + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and not weaponDef.customParams.bogus then + local range = weaponDef.range or 0 + if range > longestRange then + longestRange = range + end + end + end + if unitDef.canReclaim then + local buildDistance = unitDef.buildDistance or 0 + if buildDistance > longestRange then + longestRange = buildDistance + end + end + return longestRange +end + +local function getMaxWeaponRange(unitDef) + local longestRange = 0 + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and not weaponDef.customParams.bogus then + local range = weaponDef.range or 0 + if range > longestRange then + longestRange = range + end + end + end + return longestRange +end + +local function nameStartsWithCorOrArm(unitName) + local prefix = unitName:sub(1, 3) + return prefix == "cor" or prefix == "arm" +end + +local function setDefensiveWatch(unitID, isDefensive) + if isDefensive then + defensiveWatchList[unitID] = true + else + defensiveWatchList[unitID] = nil + end +end + +local function updateDefensiveWatchFromRulesParam(unitID) + local state = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) + setDefensiveWatch(unitID, state == Firestates.DEFENSIVE) +end + +function gadget:UnitCommand(unitID, unitDefID, unitTeamID, cmdID, cmdParams, cmdOptions, cmdTag, playerID, fromSynced, fromLua) + if cmdID == CMD_FIRE_STATE then + updateDefensiveWatchFromRulesParam(unitID) + end +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + updateDefensiveWatchFromRulesParam(unitID) +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) + defensiveWatchList[unitID] = nil +end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if spGetUnitRulesParam(attackerID, Firestates.RULES_PARAM) ~= Firestates.DEFENSIVE then + if not defensiveWatchList[attackerID] then + return true + end + + local attackerUnitDefID = spGetUnitDefID(attackerID) + local targetUnitDefID = spGetUnitDefID(targetID) + local threatRangeSq = defThreatRanges[attackerUnitDefID] and defThreatRanges[attackerUnitDefID][targetUnitDefID] + if not threatRangeSq then + if not defThreatRanges[attackerUnitDefID] then + return true + end + return false + end + + if threatRangeSq == BEYOND_MAX_RANGE then return true end @@ -34,9 +138,7 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return true end - local distanceX = attackerX - targetX - local distanceZ = attackerZ - targetZ - if distanceX * distanceX + distanceZ * distanceZ > DEFENSIVE_FIRE_RANGE_SQ then + if mathDistance2dSquared(attackerX, attackerZ, targetX, targetZ) > threatRangeSq then return false end @@ -44,7 +146,34 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac end function gadget:Initialize() + local allUnitDefDPS = UnitDefDPS.calculateAll() + local unitThreatRanges = {} + local unitDefsDPS = {} + local attackerSpeeds = {} + local attackerMaxRanges = {} + local targetHealths = {} + local targetSpeeds = {} + local targetUnitDefIDs = {} + for unitDefID, unitDef in pairs(UnitDefs) do + local weaponCount = countNonBogusWeapons(unitDef) + local threatRange = getLongestThreatRange(unitDef) + + local dpsPenaltyMultiplier = tonumber(unitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY + if weaponCount > 1 then + dpsPenaltyMultiplier = MULTI_WEAPON_DPS_PENALTY + end + unitDefsDPS[unitDefID] = UnitDefDPS.getEffectiveDPS(unitDef, allUnitDefDPS[unitDefID]) * dpsPenaltyMultiplier + attackerSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER + attackerMaxRanges[unitDefID] = getMaxWeaponRange(unitDef) + + if weaponCount > 0 or unitDef.canReclaim or unitDef.canKamikaze then + targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID + unitThreatRanges[unitDefID] = threatRange + targetHealths[unitDefID] = unitDef.health or 0 + targetSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER + end + local weapons = unitDef.weapons for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef @@ -54,4 +183,42 @@ function gadget:Initialize() end end end + + for attackerUnitDefID, attackerDPS in pairs(unitDefsDPS) do + if attackerDPS > 0 then + local attackerSpeed = attackerSpeeds[attackerUnitDefID] + local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] + local rangesForAttacker = {} + for targetIndex = 1, #targetUnitDefIDs do + local targetUnitDefID = targetUnitDefIDs[targetIndex] + local targetThreatRange = unitThreatRanges[targetUnitDefID] + local targetSpeed = targetSpeeds[targetUnitDefID] + local targetHealth = targetHealths[targetUnitDefID] + local timeToKill = targetHealth / attackerDPS + local killBuffer + if attackerDPS > targetHealth then + killBuffer = attackerSpeed + targetSpeed + else + killBuffer = (attackerSpeed + targetSpeed) * timeToKill + end + local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) + if threatRange > attackerMaxRange then + rangesForAttacker[targetUnitDefID] = BEYOND_MAX_RANGE + else + rangesForAttacker[targetUnitDefID] = threatRange * threatRange + end + local attackerName = UnitDefs[attackerUnitDefID].name + local targetName = UnitDefs[targetUnitDefID].name + if nameStartsWithCorOrArm(attackerName) and nameStartsWithCorOrArm(targetName) then + local storedRange = rangesForAttacker[targetUnitDefID] + spEcho(attackerName, targetName, storedRange == BEYOND_MAX_RANGE and storedRange or threatRange) + end + end + defThreatRanges[attackerUnitDefID] = rangesForAttacker + end + end + + for _, unitID in ipairs(spGetAllUnits()) do + updateDefensiveWatchFromRulesParam(unitID) + end end From 53f2cde5de4f3c831ce84d3acb154ad1bdcae6c6 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 20:01:48 -0500 Subject: [PATCH 07/38] factories canReclaim = false --- units/ArmBuildings/LandFactories/armaap.lua | 1 + units/ArmBuildings/LandFactories/armalab.lua | 1 + units/ArmBuildings/LandFactories/armap.lua | 1 + units/ArmBuildings/LandFactories/armavp.lua | 1 + units/ArmBuildings/LandFactories/armhaap.lua | 1 + units/ArmBuildings/LandFactories/armhaapuw.lua | 1 + units/ArmBuildings/LandFactories/armhalab.lua | 1 + units/ArmBuildings/LandFactories/armhavp.lua | 1 + units/ArmBuildings/LandFactories/armhp.lua | 1 + units/ArmBuildings/LandFactories/armlab.lua | 1 + units/ArmBuildings/LandFactories/armsaap.lua | 1 + units/ArmBuildings/LandFactories/armsalab.lua | 1 + units/ArmBuildings/LandFactories/armsavp.lua | 1 + units/ArmBuildings/LandFactories/armshltx.lua | 1 + units/ArmBuildings/LandFactories/armvp.lua | 1 + units/ArmBuildings/SeaFactories/armamsub.lua | 1 + units/ArmBuildings/SeaFactories/armasy.lua | 1 + units/ArmBuildings/SeaFactories/armfhp.lua | 1 + units/ArmBuildings/SeaFactories/armhasy.lua | 1 + units/ArmBuildings/SeaFactories/armplat.lua | 1 + units/ArmBuildings/SeaFactories/armsasy.lua | 1 + units/ArmBuildings/SeaFactories/armshltxuw.lua | 1 + units/ArmBuildings/SeaFactories/armsy.lua | 1 + units/CorBuildings/LandFactories/coraap.lua | 1 + units/CorBuildings/LandFactories/coralab.lua | 1 + units/CorBuildings/LandFactories/corap.lua | 1 + units/CorBuildings/LandFactories/coravp.lua | 1 + units/CorBuildings/LandFactories/corgant.lua | 1 + units/CorBuildings/LandFactories/corhaap.lua | 1 + units/CorBuildings/LandFactories/corhaapuw.lua | 1 + units/CorBuildings/LandFactories/corhalab.lua | 1 + units/CorBuildings/LandFactories/corhavp.lua | 1 + units/CorBuildings/LandFactories/corhp.lua | 1 + units/CorBuildings/LandFactories/corlab.lua | 1 + units/CorBuildings/LandFactories/corsaap.lua | 1 + units/CorBuildings/LandFactories/corsalab.lua | 1 + units/CorBuildings/LandFactories/corsavp.lua | 1 + units/CorBuildings/LandFactories/corvp.lua | 1 + units/CorBuildings/LandFactories/leghaap.lua | 1 + units/CorBuildings/SeaFactories/coramsub.lua | 1 + units/CorBuildings/SeaFactories/corasy.lua | 1 + units/CorBuildings/SeaFactories/corfhp.lua | 1 + units/CorBuildings/SeaFactories/corgantuw.lua | 1 + units/CorBuildings/SeaFactories/corhasy.lua | 1 + units/CorBuildings/SeaFactories/corplat.lua | 1 + units/CorBuildings/SeaFactories/corsasy.lua | 1 + units/CorBuildings/SeaFactories/corsy.lua | 1 + units/Legion/Labs/legaap.lua | 1 + units/Legion/Labs/legadvshipyard.lua | 1 + units/Legion/Labs/legalab.lua | 1 + units/Legion/Labs/legamphlab.lua | 1 + units/Legion/Labs/legap.lua | 1 + units/Legion/Labs/legavp.lua | 1 + units/Legion/Labs/legfhp.lua | 1 + units/Legion/Labs/leggant.lua | 1 + units/Legion/Labs/leggantuw.lua | 1 + units/Legion/Labs/leghalab.lua | 1 + units/Legion/Labs/leghavp.lua | 1 + units/Legion/Labs/leghp.lua | 1 + units/Legion/Labs/leglab.lua | 1 + units/Legion/Labs/legsplab.lua | 1 + units/Legion/Labs/legsy.lua | 1 + units/Legion/Labs/legvp.lua | 1 + units/Scavengers/Buildings/Factories/armapt3.lua | 1 + units/Scavengers/Buildings/Factories/corapt3.lua | 1 + units/Scavengers/Buildings/Factories/legapt3.lua | 1 + 66 files changed, 66 insertions(+) diff --git a/units/ArmBuildings/LandFactories/armaap.lua b/units/ArmBuildings/LandFactories/armaap.lua index 1cd9d75e4c6..b64e657330f 100644 --- a/units/ArmBuildings/LandFactories/armaap.lua +++ b/units/ArmBuildings/LandFactories/armaap.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMAAP.DDS", buildtime = 32000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armalab.lua b/units/ArmBuildings/LandFactories/armalab.lua index 61098dbe623..2b7a9f5f190 100644 --- a/units/ArmBuildings/LandFactories/armalab.lua +++ b/units/ArmBuildings/LandFactories/armalab.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMALAB.DDS", buildtime = 25000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "124 75 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armap.lua b/units/ArmBuildings/LandFactories/armap.lua index 9888046ea8c..317845e5630 100644 --- a/units/ArmBuildings/LandFactories/armap.lua +++ b/units/ArmBuildings/LandFactories/armap.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMAP.DDS", buildtime = 5450, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "134 49 82", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armavp.lua b/units/ArmBuildings/LandFactories/armavp.lua index 20ee1baa2db..52a119eca1e 100644 --- a/units/ArmBuildings/LandFactories/armavp.lua +++ b/units/ArmBuildings/LandFactories/armavp.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMAVP.DDS", buildtime = 27000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "130 86 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhaap.lua b/units/ArmBuildings/LandFactories/armhaap.lua index 5d3edc287b7..9662b78d807 100644 --- a/units/ArmBuildings/LandFactories/armhaap.lua +++ b/units/ArmBuildings/LandFactories/armhaap.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMAAP.DDS", buildtime = 85000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhaapuw.lua b/units/ArmBuildings/LandFactories/armhaapuw.lua index 447f6bf23fd..df4ba2f9684 100644 --- a/units/ArmBuildings/LandFactories/armhaapuw.lua +++ b/units/ArmBuildings/LandFactories/armhaapuw.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMPLAT.DDS", buildtime = 42000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhalab.lua b/units/ArmBuildings/LandFactories/armhalab.lua index 63a967a1043..abaa0c2a4c8 100644 --- a/units/ArmBuildings/LandFactories/armhalab.lua +++ b/units/ArmBuildings/LandFactories/armhalab.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMSHLTX.DDS", buildtime = 85000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhavp.lua b/units/ArmBuildings/LandFactories/armhavp.lua index db636fd5e04..a73bf48c44c 100644 --- a/units/ArmBuildings/LandFactories/armhavp.lua +++ b/units/ArmBuildings/LandFactories/armhavp.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMHAVP.DDS", buildtime = 85000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhp.lua b/units/ArmBuildings/LandFactories/armhp.lua index 5896ba18be2..218d1bcdefd 100644 --- a/units/ArmBuildings/LandFactories/armhp.lua +++ b/units/ArmBuildings/LandFactories/armhp.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumescales = "100 40 90", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/LandFactories/armlab.lua b/units/ArmBuildings/LandFactories/armlab.lua index b4f6bd4a1a7..e2738bff512 100644 --- a/units/ArmBuildings/LandFactories/armlab.lua +++ b/units/ArmBuildings/LandFactories/armlab.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMLAB.DDS", buildtime = 5000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 -1 0", collisionvolumescales = "84 22 84", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsaap.lua b/units/ArmBuildings/LandFactories/armsaap.lua index 594dd4c1015..95a4516755e 100644 --- a/units/ArmBuildings/LandFactories/armsaap.lua +++ b/units/ArmBuildings/LandFactories/armsaap.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGAAP.DDS", buildtime = 52000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsalab.lua b/units/ArmBuildings/LandFactories/armsalab.lua index 2167eba1072..cf711643481 100644 --- a/units/ArmBuildings/LandFactories/armsalab.lua +++ b/units/ArmBuildings/LandFactories/armsalab.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGALAB.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "124 75 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsavp.lua b/units/ArmBuildings/LandFactories/armsavp.lua index 5e9a86f7c4b..f1342af6a88 100644 --- a/units/ArmBuildings/LandFactories/armsavp.lua +++ b/units/ArmBuildings/LandFactories/armsavp.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGAVP.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "130 86 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armshltx.lua b/units/ArmBuildings/LandFactories/armshltx.lua index 02de704f0e0..96acda78e2c 100644 --- a/units/ArmBuildings/LandFactories/armshltx.lua +++ b/units/ArmBuildings/LandFactories/armshltx.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMSHLTX.DDS", buildtime = 62000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armvp.lua b/units/ArmBuildings/LandFactories/armvp.lua index eb5dd21cbd3..7abbbf39c32 100644 --- a/units/ArmBuildings/LandFactories/armvp.lua +++ b/units/ArmBuildings/LandFactories/armvp.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMVP.DDS", buildtime = 5700, canmove = true, + canReclaim = false, collisionvolumescales = "120 34 92", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/SeaFactories/armamsub.lua b/units/ArmBuildings/SeaFactories/armamsub.lua index 7b85e1a6d66..f6daa7558c4 100644 --- a/units/ArmBuildings/SeaFactories/armamsub.lua +++ b/units/ArmBuildings/SeaFactories/armamsub.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMAMSUB.DDS", buildtime = 11100, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 16 0", collisionvolumescales = "96 42 96", collisionvolumetype = "CylY", diff --git a/units/ArmBuildings/SeaFactories/armasy.lua b/units/ArmBuildings/SeaFactories/armasy.lua index b93c05e14c4..4c42aaa9ae3 100644 --- a/units/ArmBuildings/SeaFactories/armasy.lua +++ b/units/ArmBuildings/SeaFactories/armasy.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMASY.DDS", buildtime = 24000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 -9 -2", collisionvolumescales = "192 60 192", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armfhp.lua b/units/ArmBuildings/SeaFactories/armfhp.lua index e12a0fad501..87ad605e287 100644 --- a/units/ArmBuildings/SeaFactories/armfhp.lua +++ b/units/ArmBuildings/SeaFactories/armfhp.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMFHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumescales = "100 40 90", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/SeaFactories/armhasy.lua b/units/ArmBuildings/SeaFactories/armhasy.lua index 6461adb991f..ffbf4ef8681 100644 --- a/units/ArmBuildings/SeaFactories/armhasy.lua +++ b/units/ArmBuildings/SeaFactories/armhasy.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMSHLTXUW.DDS", buildtime = 85000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "256 80 221", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armplat.lua b/units/ArmBuildings/SeaFactories/armplat.lua index 827e3ca0450..0eeb4b46eb8 100644 --- a/units/ArmBuildings/SeaFactories/armplat.lua +++ b/units/ArmBuildings/SeaFactories/armplat.lua @@ -5,6 +5,7 @@ return { buildpic = "ARMPLAT.DDS", buildtime = 12000, canmove = true, + canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5000, diff --git a/units/ArmBuildings/SeaFactories/armsasy.lua b/units/ArmBuildings/SeaFactories/armsasy.lua index 514ae65b553..ef9d3c41bb8 100644 --- a/units/ArmBuildings/SeaFactories/armsasy.lua +++ b/units/ArmBuildings/SeaFactories/armsasy.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMASY.DDS", buildtime = 30000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 -9 -2", collisionvolumescales = "192 60 192", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armshltxuw.lua b/units/ArmBuildings/SeaFactories/armshltxuw.lua index 8f95dca1c00..f523ff4f9ef 100644 --- a/units/ArmBuildings/SeaFactories/armshltxuw.lua +++ b/units/ArmBuildings/SeaFactories/armshltxuw.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMSHLTXUW.DDS", buildtime = 62000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armsy.lua b/units/ArmBuildings/SeaFactories/armsy.lua index ddc54eaaa6f..b23019bbc17 100644 --- a/units/ArmBuildings/SeaFactories/armsy.lua +++ b/units/ArmBuildings/SeaFactories/armsy.lua @@ -4,6 +4,7 @@ return { buildpic = "ARMSY.DDS", buildtime = 5160, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 16 0", collisionvolumescales = "90 60 90", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coraap.lua b/units/CorBuildings/LandFactories/coraap.lua index 88817fad67d..39d810674ec 100644 --- a/units/CorBuildings/LandFactories/coraap.lua +++ b/units/CorBuildings/LandFactories/coraap.lua @@ -5,6 +5,7 @@ return { buildpic = "CORAAP.DDS", buildtime = 32000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coralab.lua b/units/CorBuildings/LandFactories/coralab.lua index 07529c65536..eb491b65feb 100644 --- a/units/CorBuildings/LandFactories/coralab.lua +++ b/units/CorBuildings/LandFactories/coralab.lua @@ -5,6 +5,7 @@ return { buildpic = "CORALAB.DDS", buildtime = 26000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 2 0", collisionvolumescales = "144 56 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corap.lua b/units/CorBuildings/LandFactories/corap.lua index dfd8fde2d07..73004cde97f 100644 --- a/units/CorBuildings/LandFactories/corap.lua +++ b/units/CorBuildings/LandFactories/corap.lua @@ -5,6 +5,7 @@ return { buildpic = "CORAP.DDS", buildtime = 5380, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "128 33 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coravp.lua b/units/CorBuildings/LandFactories/coravp.lua index 93d76333104..8195abf2806 100644 --- a/units/CorBuildings/LandFactories/coravp.lua +++ b/units/CorBuildings/LandFactories/coravp.lua @@ -5,6 +5,7 @@ return { buildpic = "CORAVP.DDS", buildtime = 28000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 8 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corgant.lua b/units/CorBuildings/LandFactories/corgant.lua index fae9c5d6082..7a01f45597a 100644 --- a/units/CorBuildings/LandFactories/corgant.lua +++ b/units/CorBuildings/LandFactories/corgant.lua @@ -4,6 +4,7 @@ return { buildpic = "CORGANT.DDS", buildtime = 68000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhaap.lua b/units/CorBuildings/LandFactories/corhaap.lua index df8e9833613..dfa009a9469 100644 --- a/units/CorBuildings/LandFactories/corhaap.lua +++ b/units/CorBuildings/LandFactories/corhaap.lua @@ -5,6 +5,7 @@ return { buildpic = "CORAAP.DDS", buildtime = 92000, canmove = true, + canReclaim = false, corpse = "DEAD", energycost = 65000, energystorage = 1400, diff --git a/units/CorBuildings/LandFactories/corhaapuw.lua b/units/CorBuildings/LandFactories/corhaapuw.lua index 84835ea4fcb..aaacea4c678 100644 --- a/units/CorBuildings/LandFactories/corhaapuw.lua +++ b/units/CorBuildings/LandFactories/corhaapuw.lua @@ -5,6 +5,7 @@ return { buildpic = "CORPLAT.DDS", buildtime = 42000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhalab.lua b/units/CorBuildings/LandFactories/corhalab.lua index 49e30c05313..422f221f9bc 100644 --- a/units/CorBuildings/LandFactories/corhalab.lua +++ b/units/CorBuildings/LandFactories/corhalab.lua @@ -4,6 +4,7 @@ return { buildpic = "CORGANT.DDS", buildtime = 92000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhavp.lua b/units/CorBuildings/LandFactories/corhavp.lua index ec07114a35c..cfed92bb6e5 100644 --- a/units/CorBuildings/LandFactories/corhavp.lua +++ b/units/CorBuildings/LandFactories/corhavp.lua @@ -4,6 +4,7 @@ return { buildpic = "CORHAVP.DDS", buildtime = 92000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhp.lua b/units/CorBuildings/LandFactories/corhp.lua index b248bb6f487..234d272b666 100644 --- a/units/CorBuildings/LandFactories/corhp.lua +++ b/units/CorBuildings/LandFactories/corhp.lua @@ -4,6 +4,7 @@ return { buildpic = "CORHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 32 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corlab.lua b/units/CorBuildings/LandFactories/corlab.lua index 0cc697d50b0..177b79e9f8f 100644 --- a/units/CorBuildings/LandFactories/corlab.lua +++ b/units/CorBuildings/LandFactories/corlab.lua @@ -5,6 +5,7 @@ return { buildpic = "CORLAB.DDS", buildtime = 5000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "93 92 87", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsaap.lua b/units/CorBuildings/LandFactories/corsaap.lua index a921ca0620e..69a58bf68f4 100644 --- a/units/CorBuildings/LandFactories/corsaap.lua +++ b/units/CorBuildings/LandFactories/corsaap.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGAAP.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsalab.lua b/units/CorBuildings/LandFactories/corsalab.lua index 18a1c216cb8..456e2d29d00 100644 --- a/units/CorBuildings/LandFactories/corsalab.lua +++ b/units/CorBuildings/LandFactories/corsalab.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGALAB.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 2 0", collisionvolumescales = "144 56 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsavp.lua b/units/CorBuildings/LandFactories/corsavp.lua index 71ebef76dca..e6018e7c99c 100644 --- a/units/CorBuildings/LandFactories/corsavp.lua +++ b/units/CorBuildings/LandFactories/corsavp.lua @@ -5,6 +5,7 @@ return { buildpic = "LEGAVP.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 8 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corvp.lua b/units/CorBuildings/LandFactories/corvp.lua index f41af1a853f..302797c33a9 100644 --- a/units/CorBuildings/LandFactories/corvp.lua +++ b/units/CorBuildings/LandFactories/corvp.lua @@ -5,6 +5,7 @@ return { buildpic = "CORVP.DDS", buildtime = 5650, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 40 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/leghaap.lua b/units/CorBuildings/LandFactories/leghaap.lua index e996e4ce417..cc971107815 100644 --- a/units/CorBuildings/LandFactories/leghaap.lua +++ b/units/CorBuildings/LandFactories/leghaap.lua @@ -5,6 +5,7 @@ return { buildpic = "CORHAAP.DDS", buildtime = 92000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/coramsub.lua b/units/CorBuildings/SeaFactories/coramsub.lua index 33585a6aeff..0a9e6655def 100644 --- a/units/CorBuildings/SeaFactories/coramsub.lua +++ b/units/CorBuildings/SeaFactories/coramsub.lua @@ -4,6 +4,7 @@ return { buildpic = "CORAMSUB.DDS", buildtime = 11400, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 -6", collisionvolumescales = "96 30 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corasy.lua b/units/CorBuildings/SeaFactories/corasy.lua index 344dde288e0..d20aaee04ed 100644 --- a/units/CorBuildings/SeaFactories/corasy.lua +++ b/units/CorBuildings/SeaFactories/corasy.lua @@ -4,6 +4,7 @@ return { buildpic = "CORASY.DDS", buildtime = 24000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corfhp.lua b/units/CorBuildings/SeaFactories/corfhp.lua index 6947c84ad74..1534ed16e3a 100644 --- a/units/CorBuildings/SeaFactories/corfhp.lua +++ b/units/CorBuildings/SeaFactories/corfhp.lua @@ -4,6 +4,7 @@ return { buildpic = "CORFHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 32 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corgantuw.lua b/units/CorBuildings/SeaFactories/corgantuw.lua index 5faf6a8a72f..e522cb41914 100644 --- a/units/CorBuildings/SeaFactories/corgantuw.lua +++ b/units/CorBuildings/SeaFactories/corgantuw.lua @@ -4,6 +4,7 @@ return { buildpic = "CORGANTUW.DDS", buildtime = 68000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corhasy.lua b/units/CorBuildings/SeaFactories/corhasy.lua index 8038325e24a..bb6d98c1541 100644 --- a/units/CorBuildings/SeaFactories/corhasy.lua +++ b/units/CorBuildings/SeaFactories/corhasy.lua @@ -4,6 +4,7 @@ return { buildpic = "CORGANTUW.DDS", buildtime = 92000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "260 146 260", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corplat.lua b/units/CorBuildings/SeaFactories/corplat.lua index 74fbd345500..8686a94a024 100644 --- a/units/CorBuildings/SeaFactories/corplat.lua +++ b/units/CorBuildings/SeaFactories/corplat.lua @@ -5,6 +5,7 @@ return { buildpic = "CORPLAT.DDS", buildtime = 11800, canmove = true, + canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5500, diff --git a/units/CorBuildings/SeaFactories/corsasy.lua b/units/CorBuildings/SeaFactories/corsasy.lua index 47cf74bdc78..93fbdbf9072 100644 --- a/units/CorBuildings/SeaFactories/corsasy.lua +++ b/units/CorBuildings/SeaFactories/corsasy.lua @@ -4,6 +4,7 @@ return { buildpic = "CORASY.DDS", buildtime = 51000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corsy.lua b/units/CorBuildings/SeaFactories/corsy.lua index a1ca8c52e21..a1a8061c95b 100644 --- a/units/CorBuildings/SeaFactories/corsy.lua +++ b/units/CorBuildings/SeaFactories/corsy.lua @@ -4,6 +4,7 @@ return { buildpic = "CORSY.DDS", buildtime = 5100, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 59 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legaap.lua b/units/Legion/Labs/legaap.lua index 491e83f6cc0..a6712b71ce0 100644 --- a/units/Legion/Labs/legaap.lua +++ b/units/Legion/Labs/legaap.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGAAP.DDS", buildtime = 31050, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "142 50 142", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legadvshipyard.lua b/units/Legion/Labs/legadvshipyard.lua index dbce14109f9..1bc0df3f1c4 100644 --- a/units/Legion/Labs/legadvshipyard.lua +++ b/units/Legion/Labs/legadvshipyard.lua @@ -4,6 +4,7 @@ return { buildpic = "legadvshipyard.DDS", buildtime = 23550, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legalab.lua b/units/Legion/Labs/legalab.lua index 7807f5a902a..1efd6ac6c68 100644 --- a/units/Legion/Labs/legalab.lua +++ b/units/Legion/Labs/legalab.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGALAB.DDS", buildtime = 25200, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "140 52 140", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legamphlab.lua b/units/Legion/Labs/legamphlab.lua index e95862a6c65..014ff39ec4b 100644 --- a/units/Legion/Labs/legamphlab.lua +++ b/units/Legion/Labs/legamphlab.lua @@ -8,6 +8,7 @@ return { buildpic = "legamphlab.DDS", buildtime = 11400, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "98 60 91", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legap.lua b/units/Legion/Labs/legap.lua index 92af62190bc..3cd84f62e51 100644 --- a/units/Legion/Labs/legap.lua +++ b/units/Legion/Labs/legap.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGAP.DDS", buildtime = 6380, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 45 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legavp.lua b/units/Legion/Labs/legavp.lua index cba7584d8c0..935a864f591 100644 --- a/units/Legion/Labs/legavp.lua +++ b/units/Legion/Labs/legavp.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGAVP.DDS", buildtime = 27750, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 5", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legfhp.lua b/units/Legion/Labs/legfhp.lua index 5f49d84be6e..b3155cf753a 100644 --- a/units/Legion/Labs/legfhp.lua +++ b/units/Legion/Labs/legfhp.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGFHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 35 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leggant.lua b/units/Legion/Labs/leggant.lua index 37f10ad0d4e..4751b6b257c 100644 --- a/units/Legion/Labs/leggant.lua +++ b/units/Legion/Labs/leggant.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGGANT.DDS", buildtime = 67300, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leggantuw.lua b/units/Legion/Labs/leggantuw.lua index fa735c610ac..5977a8173dc 100644 --- a/units/Legion/Labs/leggantuw.lua +++ b/units/Legion/Labs/leggantuw.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGGANTUW.DDS", buildtime = 67300, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghalab.lua b/units/Legion/Labs/leghalab.lua index a54e97b98f5..0ab422ffc11 100644 --- a/units/Legion/Labs/leghalab.lua +++ b/units/Legion/Labs/leghalab.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGGANT.DDS", buildtime = 67300, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghavp.lua b/units/Legion/Labs/leghavp.lua index d7198079ef7..1db2e44dc13 100644 --- a/units/Legion/Labs/leghavp.lua +++ b/units/Legion/Labs/leghavp.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGHAVP.DDS", buildtime = 67300, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghp.lua b/units/Legion/Labs/leghp.lua index 8dea17fac9a..6408ff09f10 100644 --- a/units/Legion/Labs/leghp.lua +++ b/units/Legion/Labs/leghp.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGHP.DDS", buildtime = 8700, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 35 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leglab.lua b/units/Legion/Labs/leglab.lua index 495887d284b..bf66f520a95 100644 --- a/units/Legion/Labs/leglab.lua +++ b/units/Legion/Labs/leglab.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGLAB.DDS", buildtime = 5000, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "100 45 100", collisionvolumetype = "CylY", diff --git a/units/Legion/Labs/legsplab.lua b/units/Legion/Labs/legsplab.lua index e359ca2a70f..796507d9753 100644 --- a/units/Legion/Labs/legsplab.lua +++ b/units/Legion/Labs/legsplab.lua @@ -5,6 +5,7 @@ return { buildpic = "legsplab.DDS", buildtime = 11800, canmove = true, + canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5500, diff --git a/units/Legion/Labs/legsy.lua b/units/Legion/Labs/legsy.lua index d84dfc12bc7..9f9fe450761 100644 --- a/units/Legion/Labs/legsy.lua +++ b/units/Legion/Labs/legsy.lua @@ -4,6 +4,7 @@ return { buildpic = "legsy.DDS", buildtime = 5100, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 42 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legvp.lua b/units/Legion/Labs/legvp.lua index 9c53d8876a8..9d457081d58 100644 --- a/units/Legion/Labs/legvp.lua +++ b/units/Legion/Labs/legvp.lua @@ -9,6 +9,7 @@ return { buildpic = "LEGVP.DDS", buildtime = 5700, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 19 0", collisionvolumescales = "101 53 106", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/armapt3.lua b/units/Scavengers/Buildings/Factories/armapt3.lua index d62c4a0f67c..3424500c428 100644 --- a/units/Scavengers/Buildings/Factories/armapt3.lua +++ b/units/Scavengers/Buildings/Factories/armapt3.lua @@ -9,6 +9,7 @@ return { buildpic = "ARMAP.DDS", buildtime = 72400, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 8 1", collisionvolumescales = "268 98 164", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/corapt3.lua b/units/Scavengers/Buildings/Factories/corapt3.lua index 02fb1e225be..3ea556d65e8 100644 --- a/units/Scavengers/Buildings/Factories/corapt3.lua +++ b/units/Scavengers/Buildings/Factories/corapt3.lua @@ -8,6 +8,7 @@ return { buildpic = "CORAP.DDS", buildtime = 72400, canmove = true, + canReclaim = false, collisionvolumeoffsets = "0 -14 -23", collisionvolumescales = "220 66 100", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/legapt3.lua b/units/Scavengers/Buildings/Factories/legapt3.lua index 025705b6c61..25b0f3bd214 100644 --- a/units/Scavengers/Buildings/Factories/legapt3.lua +++ b/units/Scavengers/Buildings/Factories/legapt3.lua @@ -8,6 +8,7 @@ return { buildpic = "LEGAP.DDS", buildtime = 72400, canmove = true, + canReclaim = false, category = "ALL NOTLAND NOWEAPON NOTSUB NOTSHIP NOTAIR NOTHOVER SURFACE EMPABLE", collisionvolumeoffsets = "0 -14 -23", collisionvolumescales = "220 66 100", From 2a46047283951b1244167249b9339f56e880b115 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 20:16:23 -0500 Subject: [PATCH 08/38] cloak behavior done --- luarules/gadgets/unit_defensive_firestate.lua | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index 63390346122..2bed002ef39 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -12,6 +12,12 @@ function gadget:GetInfo() } end +--[[ +todo: +- taking damage needs to aggro radar enemies +- need a customparam for long range units that makes them attack any hostile indescriminately +]] + if not gadgetHandler:IsSyncedCode() then return false end @@ -25,10 +31,12 @@ local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 local BEYOND_MAX_RANGE = -1 local defThreatRanges = {} local defensiveWatchList = {} +local cloakedWatchList = {} local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitRulesParam = Spring.GetUnitRulesParam +local spGetUnitIsCloaked = Spring.GetUnitIsCloaked local spGetAllUnits = Spring.GetAllUnits local spEcho = Spring.Echo local mathDistance2dSquared = math.distance2dSquared @@ -109,8 +117,17 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) updateDefensiveWatchFromRulesParam(unitID) end +function gadget:UnitCloaked(unitID, unitDefID, unitTeam) + cloakedWatchList[unitID] = true +end + +function gadget:UnitDecloaked(unitID, unitDefID, unitTeam) + cloakedWatchList[unitID] = nil +end + function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) defensiveWatchList[unitID] = nil + cloakedWatchList[unitID] = nil end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) @@ -118,6 +135,10 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return true end + if cloakedWatchList[attackerID] then + return false + end + local attackerUnitDefID = spGetUnitDefID(attackerID) local targetUnitDefID = spGetUnitDefID(targetID) local threatRangeSq = defThreatRanges[attackerUnitDefID] and defThreatRanges[attackerUnitDefID][targetUnitDefID] @@ -220,5 +241,8 @@ function gadget:Initialize() for _, unitID in ipairs(spGetAllUnits()) do updateDefensiveWatchFromRulesParam(unitID) + if spGetUnitIsCloaked(unitID) then + cloakedWatchList[unitID] = true + end end end From 062d35146c6efeb4d5674ec63c139d324c370cd1 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 20:45:33 -0500 Subject: [PATCH 09/38] add customparam for always threats at all range override --- luarules/gadgets/unit_defensive_firestate.lua | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index 2bed002ef39..bac28a4af07 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -30,6 +30,7 @@ local MULTI_WEAPON_DPS_PENALTY = 0.66 local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 local BEYOND_MAX_RANGE = -1 local defThreatRanges = {} +local neverHesitateAttackers = {} local defensiveWatchList = {} local cloakedWatchList = {} @@ -195,6 +196,10 @@ function gadget:Initialize() targetSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER end + if unitDef.customParams.defensive_never_hesitate then + neverHesitateAttackers[unitDefID] = true + end + local weapons = unitDef.weapons for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef @@ -206,33 +211,39 @@ function gadget:Initialize() end for attackerUnitDefID, attackerDPS in pairs(unitDefsDPS) do - if attackerDPS > 0 then - local attackerSpeed = attackerSpeeds[attackerUnitDefID] - local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] + if attackerDPS > 0 or neverHesitateAttackers[attackerUnitDefID] then local rangesForAttacker = {} - for targetIndex = 1, #targetUnitDefIDs do - local targetUnitDefID = targetUnitDefIDs[targetIndex] - local targetThreatRange = unitThreatRanges[targetUnitDefID] - local targetSpeed = targetSpeeds[targetUnitDefID] - local targetHealth = targetHealths[targetUnitDefID] - local timeToKill = targetHealth / attackerDPS - local killBuffer - if attackerDPS > targetHealth then - killBuffer = attackerSpeed + targetSpeed - else - killBuffer = (attackerSpeed + targetSpeed) * timeToKill - end - local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) - if threatRange > attackerMaxRange then - rangesForAttacker[targetUnitDefID] = BEYOND_MAX_RANGE - else - rangesForAttacker[targetUnitDefID] = threatRange * threatRange + if neverHesitateAttackers[attackerUnitDefID] then + for targetIndex = 1, #targetUnitDefIDs do + rangesForAttacker[targetUnitDefIDs[targetIndex]] = BEYOND_MAX_RANGE end - local attackerName = UnitDefs[attackerUnitDefID].name - local targetName = UnitDefs[targetUnitDefID].name - if nameStartsWithCorOrArm(attackerName) and nameStartsWithCorOrArm(targetName) then - local storedRange = rangesForAttacker[targetUnitDefID] - spEcho(attackerName, targetName, storedRange == BEYOND_MAX_RANGE and storedRange or threatRange) + else + local attackerSpeed = attackerSpeeds[attackerUnitDefID] + local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] + for targetIndex = 1, #targetUnitDefIDs do + local targetUnitDefID = targetUnitDefIDs[targetIndex] + local targetThreatRange = unitThreatRanges[targetUnitDefID] + local targetSpeed = targetSpeeds[targetUnitDefID] + local targetHealth = targetHealths[targetUnitDefID] + local timeToKill = targetHealth / attackerDPS + local killBuffer + if attackerDPS > targetHealth then + killBuffer = attackerSpeed + targetSpeed + else + killBuffer = (attackerSpeed + targetSpeed) * timeToKill + end + local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) + if threatRange > attackerMaxRange then + rangesForAttacker[targetUnitDefID] = BEYOND_MAX_RANGE + else + rangesForAttacker[targetUnitDefID] = threatRange * threatRange + end + local attackerName = UnitDefs[attackerUnitDefID].name + local targetName = UnitDefs[targetUnitDefID].name + if nameStartsWithCorOrArm(attackerName) and nameStartsWithCorOrArm(targetName) then + local storedRange = rangesForAttacker[targetUnitDefID] + spEcho(attackerName, targetName, storedRange == BEYOND_MAX_RANGE and storedRange or threatRange) + end end end defThreatRanges[attackerUnitDefID] = rangesForAttacker From 762654bf41614a8f191da125cbcbf6457c1b8c02 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 20:46:20 -0500 Subject: [PATCH 10/38] add alldefs customparam adding for always shoot threats all ranges --- gamedata/alldefs_post.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index e3f0447ee72..090386bfac1 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -775,6 +775,24 @@ local function unitDef_Post(name, uDef) end end + -- Defensive firestate: aircraft, long-range, and starburst units engage threats at any range in defensive mode + -- regardless of calculated threat distance (see luarules/gadgets/unit_defensive_firestate.lua). + local DEFENSIVE_NEVER_HESITATE_RANGE = 2000 + + if not customparams.defensive_never_hesitate and next(weapondefs) then + for _, weaponDef in pairs(weapondefs) do + if not weaponDef.customparams.bogus then + if uDef.canfly + or weaponDef.weapontype == "StarburstLauncher" + or (weaponDef.range and weaponDef.range > DEFENSIVE_NEVER_HESITATE_RANGE) + then + customparams.defensive_never_hesitate = true + break + end + end + end + end + -- Suppress engine default piece explosion effects (handled by gfx_death_fire_smoke_gl4 widget) if not uDef.sfxtypes then uDef.sfxtypes = {} From bea661773f4aa8886ecb53be0bcd07daa93d0ba1 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Sun, 28 Jun 2026 21:28:58 -0500 Subject: [PATCH 11/38] always shoot kamikaze, and other stuff --- luarules/gadgets/unit_defensive_firestate.lua | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index bac28a4af07..aefff36b942 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -12,12 +12,6 @@ function gadget:GetInfo() } end ---[[ -todo: -- taking damage needs to aggro radar enemies -- need a customparam for long range units that makes them attack any hostile indescriminately -]] - if not gadgetHandler:IsSyncedCode() then return false end @@ -28,17 +22,24 @@ local CMD_FIRE_STATE = CMD.FIRE_STATE local DPS_PENALTY = 0.99 local MULTI_WEAPON_DPS_PENALTY = 0.66 local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 -local BEYOND_MAX_RANGE = -1 +local ALWAYS_SHOOT = -1 +local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 local defThreatRanges = {} local neverHesitateAttackers = {} local defensiveWatchList = {} local cloakedWatchList = {} +local defensiveHPCheckCooldowns = {} +local radarAggroWatchList = {} +local gameFrame = 0 local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitRulesParam = Spring.GetUnitRulesParam local spGetUnitIsCloaked = Spring.GetUnitIsCloaked local spGetAllUnits = Spring.GetAllUnits +local spGetUnitHealth = Spring.GetUnitHealth +local spGetUnitAllyTeam = Spring.GetUnitAllyTeam +local spGetUnitLosState = Spring.GetUnitLosState local spEcho = Spring.Echo local mathDistance2dSquared = math.distance2dSquared @@ -90,17 +91,40 @@ local function getMaxWeaponRange(unitDef) return longestRange end -local function nameStartsWithCorOrArm(unitName) - local prefix = unitName:sub(1, 3) - return prefix == "cor" or prefix == "arm" -end - local function setDefensiveWatch(unitID, isDefensive) if isDefensive then - defensiveWatchList[unitID] = true + defensiveWatchList[unitID] = spGetUnitHealth(unitID) else defensiveWatchList[unitID] = nil + radarAggroWatchList[unitID] = nil + defensiveHPCheckCooldowns[unitID] = nil + end +end + +local function isRadarOnlyUnknownTarget(los) + return los and los.radar and not los.los and not los.typed +end + +local function checkDefensiveUnitHealth(attackerID) + local nextCheckFrame = defensiveHPCheckCooldowns[attackerID] + if nextCheckFrame and gameFrame < nextCheckFrame then + return + end + + local currentHealth = spGetUnitHealth(attackerID) + if not currentHealth then + return + end + + local lastHealth = defensiveWatchList[attackerID] + if lastHealth and currentHealth < lastHealth then + radarAggroWatchList[attackerID] = true + elseif lastHealth and currentHealth > lastHealth then + radarAggroWatchList[attackerID] = nil end + + defensiveWatchList[attackerID] = currentHealth + defensiveHPCheckCooldowns[attackerID] = gameFrame + HP_CHECK_INTERVAL_FRAMES end local function updateDefensiveWatchFromRulesParam(unitID) @@ -129,6 +153,12 @@ end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) defensiveWatchList[unitID] = nil cloakedWatchList[unitID] = nil + radarAggroWatchList[unitID] = nil + defensiveHPCheckCooldowns[unitID] = nil +end + +function gadget:GameFrame(frame) + gameFrame = frame end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) @@ -140,6 +170,19 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return false end + checkDefensiveUnitHealth(attackerID) + + local allyTeamID = spGetUnitAllyTeam(attackerID) + local los = spGetUnitLosState(targetID, allyTeamID, false) + + if radarAggroWatchList[attackerID] and isRadarOnlyUnknownTarget(los) then + return true + end + + if not los or not los.los then + return false + end + local attackerUnitDefID = spGetUnitDefID(attackerID) local targetUnitDefID = spGetUnitDefID(targetID) local threatRangeSq = defThreatRanges[attackerUnitDefID] and defThreatRanges[attackerUnitDefID][targetUnitDefID] @@ -150,7 +193,7 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return false end - if threatRangeSq == BEYOND_MAX_RANGE then + if threatRangeSq == ALWAYS_SHOOT then return true end @@ -196,7 +239,7 @@ function gadget:Initialize() targetSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER end - if unitDef.customParams.defensive_never_hesitate then + if unitDef.customParams.defensive_never_hesitate or unitDef.canKamikaze then neverHesitateAttackers[unitDefID] = true end @@ -215,7 +258,7 @@ function gadget:Initialize() local rangesForAttacker = {} if neverHesitateAttackers[attackerUnitDefID] then for targetIndex = 1, #targetUnitDefIDs do - rangesForAttacker[targetUnitDefIDs[targetIndex]] = BEYOND_MAX_RANGE + rangesForAttacker[targetUnitDefIDs[targetIndex]] = ALWAYS_SHOOT end else local attackerSpeed = attackerSpeeds[attackerUnitDefID] @@ -234,16 +277,12 @@ function gadget:Initialize() end local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) if threatRange > attackerMaxRange then - rangesForAttacker[targetUnitDefID] = BEYOND_MAX_RANGE + rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT else rangesForAttacker[targetUnitDefID] = threatRange * threatRange end local attackerName = UnitDefs[attackerUnitDefID].name local targetName = UnitDefs[targetUnitDefID].name - if nameStartsWithCorOrArm(attackerName) and nameStartsWithCorOrArm(targetName) then - local storedRange = rangesForAttacker[targetUnitDefID] - spEcho(attackerName, targetName, storedRange == BEYOND_MAX_RANGE and storedRange or threatRange) - end end end defThreatRanges[attackerUnitDefID] = rangesForAttacker From c1c5b4c00b7ee4ab34c15b11cd50a49f7f1d8012 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 18:56:46 -0500 Subject: [PATCH 12/38] rename to Fire At Will, Defend, and Hold Fire --- language/en/interface.json | 20 ++++++++++---------- luaui/Widgets/gui_ordermenu.lua | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 215d07f506e..9b474c3af79 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -262,23 +262,23 @@ "For Sale": "For Sale", "Not For Sale": "Not For Sale", - "Fire at will": "Aggressive", - "Hold fire": "Passive", - "Return fire": "Return Fire", - "Defensive": "Defensive", + "Fire at will": "Fire at will", + "Hold fire": "Hold fire", + "Return fire": "Return fire", + "Defend": "Defend", "Fire at all": "Fire At All", "Fire at will_tooltip": "Automatically engage enemies within range.", "Hold fire_tooltip": "Only attack with an explicit order.", "Return fire_tooltip": "Only attack threats targeting this unit.", - "Defensive_tooltip": "Automatically engage enemies within 300 range.", + "Defend_tooltip": "Only attack threats to this unit.", "Fire at all_tooltip": "Automatically engage enemies and neutral units within range.", - "firestate_tooltip": "Cycle between Passive, Defensive, and Aggressive fire states.", + "firestate_tooltip": "Cycle between Hold fire, Defend, and Fire at will fire states.", "Hold pos": "Hold pos", "Maneuver": "Maneuver", "Roam": "Roam", "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", "bomber_targeting": "Targeting", - "bomber_targeting_tooltip": "Passive: Only attack with an explicit order. Aggressive: Idle bombers will seek targets within range.", + "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", "Repeat on": "Repeat On", "Repeat off": "Repeat Off", "repeat_tooltip": "Repeat unit command queue", @@ -1688,7 +1688,7 @@ "unitidlebuildericons": "Unit idle builder icons", "unitidlebuildericons_descr": "Shows a sleep icon above builder units that are idle.", "unitfirestateicons": "Unit firing state icons", - "unitfirestateicons_descr": "Shows an icon above units that are set on Passive or Defensive.", + "unitfirestateicons_descr": "Shows an icon above units that are set on Hold fire or Defend.", "resurrectionhalos": "Resurrected unit halos", "resurrectionhalos_descr": "Adds halos above resurrected units.", "nametags_rank": "Commander name tag rank icon", @@ -1784,8 +1784,8 @@ "smartselect_includejammer_descr": "When rectangle-drag-selecting an area, exclude jammer units (hold Shift to override)", "onlyfighterspatrol": "Only fighters patrol", "onlyfighterspatrol_descr": "Only fighters obey a factory's patrol route after leaving airlab.", - "bombers_default_hold_fire": "Set bombers on Passive", - "bombers_default_hold_fire_descr": "Produced bombers start on Passive after leaving airlab.", + "bombers_default_hold_fire": "Set bombers on hold fire", + "bombers_default_hold_fire_descr": "Produced bombers start on Hold Fire after leaving airlab.", "fightersfly": "Set fighters on Fly mode", "fightersfly_descr": "Setting fighters on Fly mode when created", "prioconturrets": "Priority construction turrets", diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 9b62dd08354..bc2719c5e88 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -349,7 +349,7 @@ local function getUserFirestateCommand(command, unitID) if Firestates.isUserFacing(userFirestate) then displayIndex = Firestates.userFacingDisplayIndex(userFirestate) if userFirestate == Firestates.DEFENSIVE then - stateName = "Defensive" + stateName = "Defend" elseif userFirestate == Firestates.PASSIVE then stateName = "Hold fire" else From 2813f675ce6b587eca15a3c72edb4adfbf6dc922 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 19:28:35 -0500 Subject: [PATCH 13/38] improve kamikaze, add echo's --- luarules/gadgets/unit_defensive_firestate.lua | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index aefff36b942..8d9a815ae2a 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -43,6 +43,33 @@ local spGetUnitLosState = Spring.GetUnitLosState local spEcho = Spring.Echo local mathDistance2dSquared = math.distance2dSquared +local function getWeaponExplosionRadius(weaponName) + if not weaponName or weaponName == "" then + return 0 + end + + local weaponDefName = WeaponDefNames[weaponName] or WeaponDefNames[string.lower(weaponName)] + if not weaponDefName then + return 0 + end + + local weaponDef = WeaponDefs[weaponDefName.id] + if not weaponDef then + return 0 + end + + return weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 +end + +local function getUnitDefKamikazeDistance(unitDef) + local customParams = unitDef.customParams or {} + return unitDef.kamikazeDistance + or unitDef.kamikazeDist + or unitDef.kamikazedistance + or tonumber(customParams.kamikazedistance) + or 0 +end + local function countNonBogusWeapons(unitDef) local count = 0 local weapons = unitDef.weapons @@ -55,7 +82,51 @@ local function countNonBogusWeapons(unitDef) return count end +local function hasKamikazeWeapon(unitDef) + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + local damageAreaOfEffect = weaponDef and (weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0) or 0 + if weaponDef and not weaponDef.customParams.bogus and weaponDef.name == "Crawlingbomb Dummy Weapon" and damageAreaOfEffect > 0 then + return true + end + end + return false +end + +local function isKamikazeUnitDef(unitDef) + local customParams = unitDef.customParams or {} + return unitDef.canKamikaze + or customParams.instantselfd + or customParams.unitgroup == "explo" + or customParams.mine + or customParams.detonaterange + or hasKamikazeWeapon(unitDef) +end + +local function getKamikazeExplosionRadius(unitDef) + local explosionRadius = math.max( + getWeaponExplosionRadius(unitDef.deathExplosion), + getWeaponExplosionRadius(unitDef.selfDExplosion) + ) + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and not weaponDef.customParams.bogus then + local damageAreaOfEffect = weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 + if damageAreaOfEffect > explosionRadius then + explosionRadius = damageAreaOfEffect + end + end + end + return math.max(explosionRadius, getUnitDefKamikazeDistance(unitDef)) +end + local function getLongestThreatRange(unitDef) + if isKamikazeUnitDef(unitDef) then + return getKamikazeExplosionRadius(unitDef) + end + local longestRange = 0 local weapons = unitDef.weapons for weaponNum = 1, #weapons do @@ -232,14 +303,16 @@ function gadget:Initialize() attackerSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER attackerMaxRanges[unitDefID] = getMaxWeaponRange(unitDef) - if weaponCount > 0 or unitDef.canReclaim or unitDef.canKamikaze then + local isKamikaze = isKamikazeUnitDef(unitDef) + + if weaponCount > 0 or unitDef.canReclaim or isKamikaze then targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID unitThreatRanges[unitDefID] = threatRange targetHealths[unitDefID] = unitDef.health or 0 targetSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER end - if unitDef.customParams.defensive_never_hesitate or unitDef.canKamikaze then + if unitDef.customParams.defensive_never_hesitate or isKamikaze then neverHesitateAttackers[unitDefID] = true end @@ -256,9 +329,12 @@ function gadget:Initialize() for attackerUnitDefID, attackerDPS in pairs(unitDefsDPS) do if attackerDPS > 0 or neverHesitateAttackers[attackerUnitDefID] then local rangesForAttacker = {} + local attackerName = UnitDefs[attackerUnitDefID].name if neverHesitateAttackers[attackerUnitDefID] then for targetIndex = 1, #targetUnitDefIDs do - rangesForAttacker[targetUnitDefIDs[targetIndex]] = ALWAYS_SHOOT + local targetUnitDefID = targetUnitDefIDs[targetIndex] + rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT + spEcho("defThreatRange", attackerName, UnitDefs[targetUnitDefID].name, ALWAYS_SHOOT) end else local attackerSpeed = attackerSpeeds[attackerUnitDefID] @@ -276,13 +352,15 @@ function gadget:Initialize() killBuffer = (attackerSpeed + targetSpeed) * timeToKill end local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) + local storedThreatRange if threatRange > attackerMaxRange then rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT + storedThreatRange = ALWAYS_SHOOT else rangesForAttacker[targetUnitDefID] = threatRange * threatRange + storedThreatRange = threatRange end - local attackerName = UnitDefs[attackerUnitDefID].name - local targetName = UnitDefs[targetUnitDefID].name + spEcho("defThreatRange", attackerName, UnitDefs[targetUnitDefID].name, storedThreatRange) end end defThreatRanges[attackerUnitDefID] = rangesForAttacker From 8dd757266ec30991b14c49a1dff2d946018c0f28 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 20:22:13 -0500 Subject: [PATCH 14/38] ignore non-threats to self --- luarules/gadgets/unit_defensive_firestate.lua | 234 +++++++++++++----- 1 file changed, 177 insertions(+), 57 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index 8d9a815ae2a..14039b7b299 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -23,6 +23,7 @@ local DPS_PENALTY = 0.99 local MULTI_WEAPON_DPS_PENALTY = 0.66 local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 local ALWAYS_SHOOT = -1 +local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 local defThreatRanges = {} local neverHesitateAttackers = {} @@ -43,17 +44,21 @@ local spGetUnitLosState = Spring.GetUnitLosState local spEcho = Spring.Echo local mathDistance2dSquared = math.distance2dSquared -local function getWeaponExplosionRadius(weaponName) +local function getWeaponDefFromName(weaponName) if not weaponName or weaponName == "" then - return 0 + return nil end local weaponDefName = WeaponDefNames[weaponName] or WeaponDefNames[string.lower(weaponName)] if not weaponDefName then - return 0 + return nil end - local weaponDef = WeaponDefs[weaponDefName.id] + return WeaponDefs[weaponDefName.id] +end + +local function getWeaponExplosionRadius(weaponName) + local weaponDef = getWeaponDefFromName(weaponName) if not weaponDef then return 0 end @@ -122,11 +127,7 @@ local function getKamikazeExplosionRadius(unitDef) return math.max(explosionRadius, getUnitDefKamikazeDistance(unitDef)) end -local function getLongestThreatRange(unitDef) - if isKamikazeUnitDef(unitDef) then - return getKamikazeExplosionRadius(unitDef) - end - +local function getMaxWeaponRange(unitDef) local longestRange = 0 local weapons = unitDef.weapons for weaponNum = 1, #weapons do @@ -138,30 +139,172 @@ local function getLongestThreatRange(unitDef) end end end - if unitDef.canReclaim then - local buildDistance = unitDef.buildDistance or 0 - if buildDistance > longestRange then - longestRange = buildDistance + return longestRange +end + +local function unitMatchesWeaponCategories(victimUnitDef, weapon) + local victimCategories = victimUnitDef.modCategories + + if weapon.onlyTargets then + if not victimCategories then + return false + end + local matchesAny = false + for category, _ in pairs(weapon.onlyTargets) do + if victimCategories[category] then + matchesAny = true + break + end + end + if not matchesAny then + return false end end - return longestRange + + return true end -local function getMaxWeaponRange(unitDef) +local function isOffensiveWeapon(weaponDef) + if not weaponDef or weaponDef.customParams.bogus then + return false + end + + if weaponDef.shieldRadius and weaponDef.shieldRadius > 0 then + return false + end + + if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then + return false + end + + return true +end + +local function weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) + if not isOffensiveWeapon(weaponDef) then + return false + end + + if not unitMatchesWeaponCategories(victimUnitDef, weapon) then + return false + end + + if weaponDef.paralyzer then + local victimCategories = victimUnitDef.modCategories + if not victimCategories or not victimCategories.empable then + return false + end + end + + local armorType = victimUnitDef.armorType or 0 + local damage = weaponDef.damages and weaponDef.damages[armorType] + return damage and damage > 0 +end + +local function explosionThreatensUnitDef(weaponName, victimUnitDef) + local weaponDef = getWeaponDefFromName(weaponName) + if not weaponDef then + return false + end + + local armorType = victimUnitDef.armorType or 0 + local damage = weaponDef.damages and weaponDef.damages[armorType] + return damage and damage > 0 +end + +local function canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) + if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then + return true + end + + local weapons = threatUnitDef.weapons + for weaponNum = 1, #weapons do + local weapon = weapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then + return true + end + end + + if isKamikazeUnitDef(threatUnitDef) then + if explosionThreatensUnitDef(threatUnitDef.deathExplosion, victimUnitDef) then + return true + end + + if explosionThreatensUnitDef(threatUnitDef.selfDExplosion, victimUnitDef) then + return true + end + end + + return false +end + +local function getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) + if isKamikazeUnitDef(threatUnitDef) then + return getKamikazeExplosionRadius(threatUnitDef) + end + local longestRange = 0 - local weapons = unitDef.weapons + if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then + local buildDistance = threatUnitDef.buildDistance or 0 + if buildDistance > longestRange then + longestRange = buildDistance + end + end + + local weapons = threatUnitDef.weapons for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and not weaponDef.customParams.bogus then + local weapon = weapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then local range = weaponDef.range or 0 if range > longestRange then longestRange = range end end end + return longestRange end +local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefID, targetUnitDefID, attackerName, attackerDPS, attackerSpeed, attackerMaxRange) + local threatUnitDef = UnitDefs[targetUnitDefID] + local victimUnitDef = UnitDefs[attackerUnitDefID] + + if not canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) then + rangesForAttacker[targetUnitDefID] = NO_THREAT + spEcho("defThreatRange", attackerName, threatUnitDef.name, NO_THREAT) + return + end + + if neverHesitateAttackers[attackerUnitDefID] then + rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT + spEcho("defThreatRange", attackerName, threatUnitDef.name, ALWAYS_SHOOT) + return + end + + local targetThreatRange = getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) + local targetSpeed = (threatUnitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER + local targetHealth = threatUnitDef.health or 0 + local timeToKill = targetHealth / attackerDPS + local killBuffer + if attackerDPS > targetHealth then + killBuffer = attackerSpeed + targetSpeed + else + killBuffer = (attackerSpeed + targetSpeed) * timeToKill + end + local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) + local storedThreatRange + if threatRange > attackerMaxRange then + rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT + storedThreatRange = ALWAYS_SHOOT + else + rangesForAttacker[targetUnitDefID] = threatRange * threatRange + storedThreatRange = threatRange + end + spEcho("defThreatRange", attackerName, threatUnitDef.name, storedThreatRange) +end + local function setDefensiveWatch(unitID, isDefensive) if isDefensive then defensiveWatchList[unitID] = spGetUnitHealth(unitID) @@ -264,6 +407,10 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return false end + if threatRangeSq == NO_THREAT then + return false + end + if threatRangeSq == ALWAYS_SHOOT then return true end @@ -283,17 +430,13 @@ end function gadget:Initialize() local allUnitDefDPS = UnitDefDPS.calculateAll() - local unitThreatRanges = {} local unitDefsDPS = {} local attackerSpeeds = {} local attackerMaxRanges = {} - local targetHealths = {} - local targetSpeeds = {} local targetUnitDefIDs = {} for unitDefID, unitDef in pairs(UnitDefs) do local weaponCount = countNonBogusWeapons(unitDef) - local threatRange = getLongestThreatRange(unitDef) local dpsPenaltyMultiplier = tonumber(unitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY if weaponCount > 1 then @@ -307,9 +450,6 @@ function gadget:Initialize() if weaponCount > 0 or unitDef.canReclaim or isKamikaze then targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID - unitThreatRanges[unitDefID] = threatRange - targetHealths[unitDefID] = unitDef.health or 0 - targetSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER end if unitDef.customParams.defensive_never_hesitate or isKamikaze then @@ -330,38 +470,18 @@ function gadget:Initialize() if attackerDPS > 0 or neverHesitateAttackers[attackerUnitDefID] then local rangesForAttacker = {} local attackerName = UnitDefs[attackerUnitDefID].name - if neverHesitateAttackers[attackerUnitDefID] then - for targetIndex = 1, #targetUnitDefIDs do - local targetUnitDefID = targetUnitDefIDs[targetIndex] - rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT - spEcho("defThreatRange", attackerName, UnitDefs[targetUnitDefID].name, ALWAYS_SHOOT) - end - else - local attackerSpeed = attackerSpeeds[attackerUnitDefID] - local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] - for targetIndex = 1, #targetUnitDefIDs do - local targetUnitDefID = targetUnitDefIDs[targetIndex] - local targetThreatRange = unitThreatRanges[targetUnitDefID] - local targetSpeed = targetSpeeds[targetUnitDefID] - local targetHealth = targetHealths[targetUnitDefID] - local timeToKill = targetHealth / attackerDPS - local killBuffer - if attackerDPS > targetHealth then - killBuffer = attackerSpeed + targetSpeed - else - killBuffer = (attackerSpeed + targetSpeed) * timeToKill - end - local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) - local storedThreatRange - if threatRange > attackerMaxRange then - rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT - storedThreatRange = ALWAYS_SHOOT - else - rangesForAttacker[targetUnitDefID] = threatRange * threatRange - storedThreatRange = threatRange - end - spEcho("defThreatRange", attackerName, UnitDefs[targetUnitDefID].name, storedThreatRange) - end + local attackerSpeed = attackerSpeeds[attackerUnitDefID] + local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] + for targetIndex = 1, #targetUnitDefIDs do + populateDefThreatRangeForPair( + rangesForAttacker, + attackerUnitDefID, + targetUnitDefIDs[targetIndex], + attackerName, + attackerDPS, + attackerSpeed, + attackerMaxRange + ) end defThreatRanges[attackerUnitDefID] = rangesForAttacker end From c9567d97410ee5cb9b6f5653eb971f134497da08 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 20:28:07 -0500 Subject: [PATCH 15/38] fix aa priority bug --- luarules/gadgets/unit_aa_targeting_priority.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/luarules/gadgets/unit_aa_targeting_priority.lua b/luarules/gadgets/unit_aa_targeting_priority.lua index c52f68c976d..0de1e5c8df5 100644 --- a/luarules/gadgets/unit_aa_targeting_priority.lua +++ b/luarules/gadgets/unit_aa_targeting_priority.lua @@ -97,7 +97,7 @@ if gadgetHandler:IsSyncedCode() then function gadget:AllowWeaponTarget(unitID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) local mult = airPriorityMultiplier[spGetUnitDefID(targetID)] if mult then - return true, defPriority * mult + return true, (defPriority or 1.0) * mult end return true end From e19f5e71026efd9ea5d692775340aad25ca9463e Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 20:41:59 -0500 Subject: [PATCH 16/38] make a modoption --- gamedata/alldefs_post.lua | 25 +++++++++++-------- luarules/gadgets/unit_defensive_firestate.lua | 5 ++++ luarules/gadgets/unit_firestate_handler.lua | 5 ++++ luaui/Widgets/cmd_user_firestate.lua | 5 ++++ luaui/Widgets/gui_ordermenu.lua | 5 ++-- luaui/Widgets/gui_unit_firestate_icons.lua | 4 +++ modoptions.lua | 23 +++++++++++++++++ modules/firestates.lua | 5 ++++ 8 files changed, 64 insertions(+), 13 deletions(-) diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index 090386bfac1..aac2496b190 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -777,17 +777,20 @@ local function unitDef_Post(name, uDef) -- Defensive firestate: aircraft, long-range, and starburst units engage threats at any range in defensive mode -- regardless of calculated threat distance (see luarules/gadgets/unit_defensive_firestate.lua). - local DEFENSIVE_NEVER_HESITATE_RANGE = 2000 - - if not customparams.defensive_never_hesitate and next(weapondefs) then - for _, weaponDef in pairs(weapondefs) do - if not weaponDef.customparams.bogus then - if uDef.canfly - or weaponDef.weapontype == "StarburstLauncher" - or (weaponDef.range and weaponDef.range > DEFENSIVE_NEVER_HESITATE_RANGE) - then - customparams.defensive_never_hesitate = true - break + --DEFEND FIRESTATE REWORK: Remove modoption guard; always set defensive_never_hesitate for qualifying units + if modOptions.experimental_defend_firestate then + local DEFENSIVE_NEVER_HESITATE_RANGE = 2000 + + if not customparams.defensive_never_hesitate and next(weapondefs) then + for _, weaponDef in pairs(weapondefs) do + if not weaponDef.customparams.bogus then + if uDef.canfly + or weaponDef.weapontype == "StarburstLauncher" + or (weaponDef.range and weaponDef.range > DEFENSIVE_NEVER_HESITATE_RANGE) + then + customparams.defensive_never_hesitate = true + break + end end end end diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index 14039b7b299..bbdff84e8e3 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -1,3 +1,8 @@ +--DEFEND FIRESTATE REWORK: Remove guard; defensive targeting is always required +if not Spring.GetModOptions().experimental_defend_firestate then + return +end + local gadget = gadget ---@type Gadget function gadget:GetInfo() diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua index b57dad2f8be..cadb5566c86 100644 --- a/luarules/gadgets/unit_firestate_handler.lua +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -1,3 +1,8 @@ +--DEFEND FIRESTATE REWORK: Remove guard; handler is always required +if not Spring.GetModOptions().experimental_defend_firestate then + return +end + local gadget = gadget ---@type Gadget function gadget:GetInfo() diff --git a/luaui/Widgets/cmd_user_firestate.lua b/luaui/Widgets/cmd_user_firestate.lua index 75c6d82242e..a42bcc3c090 100644 --- a/luaui/Widgets/cmd_user_firestate.lua +++ b/luaui/Widgets/cmd_user_firestate.lua @@ -1,3 +1,8 @@ +--DEFEND FIRESTATE REWORK: Remove guard; widget always intercepts firestate clicks +if not Spring.GetModOptions().experimental_defend_firestate then + return +end + local widget = widget ---@type Widget function widget:GetInfo() diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index bc2719c5e88..3270dbf3138 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -341,6 +341,7 @@ local function computeWaitState() end end +--DEFEND FIRESTATE REWORK: Remove modoption guard; always patch firestate labels to Defend local function getUserFirestateCommand(command, unitID) local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) local displayIndex @@ -409,7 +410,7 @@ local function refreshCommands() -- intentionally empty, no action to take elseif isStateCommand[command.id] then local patchedCommand = command - if command.id == CMD_FIRE_STATE and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then + if command.id == CMD_FIRE_STATE and Firestates.isDefendFirestateEnabled() and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then patchedCommand = getUserFirestateCommand(command, cachedFirstUnit) end stateCommandsCount = stateCommandsCount + 1 @@ -1336,7 +1337,7 @@ function widget:MousePress(x, y, button) if playSounds then Spring.PlaySoundFile(soundButton, 0.6, 'ui') end - if cmd.id == CMD_FIRE_STATE and cmd.userFacingFirestate and clickedCellDesiredState then + if cmd.id == CMD_FIRE_STATE and Firestates.isDefendFirestateEnabled() and cmd.userFacingFirestate and clickedCellDesiredState then local nextFirestate = Firestates.userFacingStateFromDisplayIndex(clickedCellDesiredState) spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { nextFirestate }, 0) elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then diff --git a/luaui/Widgets/gui_unit_firestate_icons.lua b/luaui/Widgets/gui_unit_firestate_icons.lua index 7ec8c610e23..8166827a12a 100644 --- a/luaui/Widgets/gui_unit_firestate_icons.lua +++ b/luaui/Widgets/gui_unit_firestate_icons.lua @@ -138,6 +138,10 @@ WG['unitfirestate'].getShowAllHoldFireIcons = function() end local function getDisplayFireState(unitID) + --DEFEND FIRESTATE REWORK: Remove engine fallback; always use user_firestate rules param + if not Firestates.isDefendFirestateEnabled() then + return select(1, Spring.GetUnitStates(unitID, false)) + end local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) if userFirestate == Firestates.PASSIVE then return HOLD_FIRE diff --git a/modoptions.lua b/modoptions.lua index 19987c94950..d5ecdd9369e 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -1816,6 +1816,29 @@ local options = { linkwidth = 350, }, + --DEFEND FIRESTATE REWORK: Remove modoption + link; make Defend behavior unconditional default + { + key = "experimental_defend_firestate", + name = "Defend Firestate", + desc = "Replaces Return fire with Defend on the firestate order menu.\nDefend only engages nearby threats instead of retaliating at any range.", + type = "bool", + section = "options_experimental", + def = false, + }, + + { + key = "experimental_defend_firestate_link", + name = "Feedback thread", + desc = "Discord discussion about the Defend firestate rework.", + section = "options_experimental", + type = "link", + link = "https://discord.com/channels/549281623154229250/1520166826435280998", + width = 215, + column = 1.65, + linkheight = 325, + linkwidth = 350, + }, + { key = "air_rework", name = "Air Rework", diff --git a/modules/firestates.lua b/modules/firestates.lua index e20edd954f8..b448f151113 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -75,4 +75,9 @@ function firestates.logicalFromEngineFirestate(engineFirestate) return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE end +--DEFEND FIRESTATE REWORK: Remove isDefendFirestateEnabled(); assume Defend mode always on +function firestates.isDefendFirestateEnabled() + return Spring.GetModOptions().experimental_defend_firestate +end + return firestates From 3cee3594e4c3cb404fdebb9467c9fa5b3403c7a2 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 21:12:34 -0500 Subject: [PATCH 17/38] add Spring.Echo's for debugging threat ranges --- luarules/gadgets/unit_defensive_firestate.lua | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index bbdff84e8e3..df4317516fa 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -278,13 +278,17 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI if not canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) then rangesForAttacker[targetUnitDefID] = NO_THREAT - spEcho("defThreatRange", attackerName, threatUnitDef.name, NO_THREAT) + if attackerName == "corban" then + spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", NO_THREAT, "(canThreaten=no)") + end return end if neverHesitateAttackers[attackerUnitDefID] then rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT - spEcho("defThreatRange", attackerName, threatUnitDef.name, ALWAYS_SHOOT) + if attackerName == "corban" then + spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", ALWAYS_SHOOT, "(neverHesitate=yes)") + end return end @@ -307,7 +311,50 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI rangesForAttacker[targetUnitDefID] = threatRange * threatRange storedThreatRange = threatRange end - spEcho("defThreatRange", attackerName, threatUnitDef.name, storedThreatRange) + if attackerName == "corban" then + local killBufferBranch = attackerDPS > targetHealth and "instantKill" or "timeToKill" + local targetThreatRangeSource = isKamikazeUnitDef(threatUnitDef) and "kamikazeExplosionRadius" or "weaponOrReclaimRange" + local weaponCount = countNonBogusWeapons(victimUnitDef) + local dpsPenaltyMultiplier = tonumber(victimUnitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY + if weaponCount > 1 then + dpsPenaltyMultiplier = MULTI_WEAPON_DPS_PENALTY + end + spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", storedThreatRange) + spEcho(" logic: canThreaten=yes; neverHesitate=no") + spEcho(" formula: threatRange = sqrt(targetThreatRange^2 + attackerSpeed^2 + killBuffer^2)") + spEcho(" attackerDPS:", attackerDPS, "weaponCount:", weaponCount, "dpsPenalty:", dpsPenaltyMultiplier) + spEcho(" attackerSpeed:", attackerSpeed, "(victim speed", victimUnitDef.speed or 0, "*", THREAT_MOVEMENT_BUFFER_MULTIPLIER .. ")") + spEcho(" targetThreatRange:", targetThreatRange, "(" .. targetThreatRangeSource .. ")") + if isKamikazeUnitDef(threatUnitDef) then + spEcho(" kamikaze explosion radius:", targetThreatRange) + else + if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then + spEcho(" reclaim buildDistance:", threatUnitDef.buildDistance or 0) + end + local weapons = threatUnitDef.weapons + for weaponNum = 1, #weapons do + local weapon = weapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then + spEcho(" threatening weapon:", weaponDef.name, "range:", weaponDef.range or 0) + end + end + end + spEcho(" targetSpeed:", targetSpeed, "(threat speed", threatUnitDef.speed or 0, "*", THREAT_MOVEMENT_BUFFER_MULTIPLIER .. ")") + spEcho(" targetHealth:", targetHealth, "timeToKill:", timeToKill) + spEcho(" killBuffer:", killBuffer, "(" .. killBufferBranch .. ")") + if killBufferBranch == "instantKill" then + spEcho(" killBuffer = attackerSpeed + targetSpeed (attackerDPS > targetHealth)") + else + spEcho(" killBuffer = (attackerSpeed + targetSpeed) * timeToKill") + end + spEcho(" threatRange:", threatRange, "attackerMaxRange:", attackerMaxRange) + if threatRange > attackerMaxRange then + spEcho(" exceedsMaxRange: yes => stored ALWAYS_SHOOT (" .. ALWAYS_SHOOT .. ")") + else + spEcho(" exceedsMaxRange: no => stored linear:", storedThreatRange, "squared in table:", threatRange * threatRange) + end + end end local function setDefensiveWatch(unitID, isDefensive) From 09a8a4be5fb3e5013aa4a0913f0b43069a6a4e4e Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 21:20:23 -0500 Subject: [PATCH 18/38] oneshot math --- luarules/gadgets/unit_defensive_firestate.lua | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index df4317516fa..f83d414e249 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -27,6 +27,7 @@ local CMD_FIRE_STATE = CMD.FIRE_STATE local DPS_PENALTY = 0.99 local MULTI_WEAPON_DPS_PENALTY = 0.66 local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 +local INSTANT_KILL_MOVEMENT_SECONDS = 1 local ALWAYS_SHOOT = -1 local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 @@ -272,6 +273,43 @@ local function getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) return longestRange end +local function getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, targetUnitDef) + if weaponDef.paralyzer then + return 0 + end + + if not weaponThreatensUnitDef(weapon, weaponDef, targetUnitDef) then + return 0 + end + + local armorType = targetUnitDef.armorType or 0 + local damage = weaponDef.damages and weaponDef.damages[armorType] or 0 + local salvoSize = weaponDef.salvoSize or 1 + local projectiles = weaponDef.projectiles or 1 + return damage * salvoSize * projectiles +end + +local function canUnitDefOneShotUnitDef(attackerUnitDef, targetUnitDef) + local targetHealth = targetUnitDef.health or 0 + if targetHealth <= 0 then + return false + end + + local weapons = attackerUnitDef.weapons + for weaponNum = 1, #weapons do + local weapon = weapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef then + local volleyDamage = getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, targetUnitDef) + if volleyDamage >= targetHealth then + return true + end + end + end + + return false +end + local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefID, targetUnitDefID, attackerName, attackerDPS, attackerSpeed, attackerMaxRange) local threatUnitDef = UnitDefs[targetUnitDefID] local victimUnitDef = UnitDefs[attackerUnitDefID] @@ -295,10 +333,11 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI local targetThreatRange = getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) local targetSpeed = (threatUnitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER local targetHealth = threatUnitDef.health or 0 + local canOneShot = canUnitDefOneShotUnitDef(victimUnitDef, threatUnitDef) local timeToKill = targetHealth / attackerDPS local killBuffer - if attackerDPS > targetHealth then - killBuffer = attackerSpeed + targetSpeed + if canOneShot or attackerDPS > targetHealth then + killBuffer = (attackerSpeed + targetSpeed) * INSTANT_KILL_MOVEMENT_SECONDS else killBuffer = (attackerSpeed + targetSpeed) * timeToKill end @@ -312,7 +351,14 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI storedThreatRange = threatRange end if attackerName == "corban" then - local killBufferBranch = attackerDPS > targetHealth and "instantKill" or "timeToKill" + local killBufferBranch + if canOneShot then + killBufferBranch = "oneShot" + elseif attackerDPS > targetHealth then + killBufferBranch = "instantKill" + else + killBufferBranch = "timeToKill" + end local targetThreatRangeSource = isKamikazeUnitDef(threatUnitDef) and "kamikazeExplosionRadius" or "weaponOrReclaimRange" local weaponCount = countNonBogusWeapons(victimUnitDef) local dpsPenaltyMultiplier = tonumber(victimUnitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY @@ -342,9 +388,24 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI end spEcho(" targetSpeed:", targetSpeed, "(threat speed", threatUnitDef.speed or 0, "*", THREAT_MOVEMENT_BUFFER_MULTIPLIER .. ")") spEcho(" targetHealth:", targetHealth, "timeToKill:", timeToKill) + if canOneShot then + local attackerWeapons = victimUnitDef.weapons + for weaponNum = 1, #attackerWeapons do + local weapon = attackerWeapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef then + local volleyDamage = getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, threatUnitDef) + if volleyDamage >= targetHealth then + spEcho(" oneShot weapon:", weaponDef.name, "volleyDamage:", volleyDamage) + end + end + end + end spEcho(" killBuffer:", killBuffer, "(" .. killBufferBranch .. ")") - if killBufferBranch == "instantKill" then - spEcho(" killBuffer = attackerSpeed + targetSpeed (attackerDPS > targetHealth)") + if killBufferBranch == "oneShot" then + spEcho(" killBuffer = (attackerSpeed + targetSpeed) *", INSTANT_KILL_MOVEMENT_SECONDS, "(armor-matched volley >= targetHealth)") + elseif killBufferBranch == "instantKill" then + spEcho(" killBuffer = (attackerSpeed + targetSpeed) *", INSTANT_KILL_MOVEMENT_SECONDS, "(attackerDPS > targetHealth)") else spEcho(" killBuffer = (attackerSpeed + targetSpeed) * timeToKill") end From 9dee9b8426bf6b347249285827bd1c214ce30b6d Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 21:20:35 -0500 Subject: [PATCH 19/38] improve modoption name and description --- modoptions.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modoptions.lua b/modoptions.lua index d5ecdd9369e..098664ea177 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -1816,11 +1816,10 @@ local options = { linkwidth = 350, }, - --DEFEND FIRESTATE REWORK: Remove modoption + link; make Defend behavior unconditional default { key = "experimental_defend_firestate", - name = "Defend Firestate", - desc = "Replaces Return fire with Defend on the firestate order menu.\nDefend only engages nearby threats instead of retaliating at any range.", + name = "Replace Return Fire with Defend fire state.", + desc = "Replaces the fire state Return fire with Defend.\nUnits in Defend mode will shoot threats to themselves on a 1v1 basis.", type = "bool", section = "options_experimental", def = false, From 48aa7ab14a2d8c1679f98ecda1a7032168bcbeeb Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 21:26:37 -0500 Subject: [PATCH 20/38] rename modoption --- modoptions.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modoptions.lua b/modoptions.lua index 098664ea177..704ad8f40d7 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -1818,7 +1818,7 @@ local options = { { key = "experimental_defend_firestate", - name = "Replace Return Fire with Defend fire state.", + name = "New Defend Firestate", desc = "Replaces the fire state Return fire with Defend.\nUnits in Defend mode will shoot threats to themselves on a 1v1 basis.", type = "bool", section = "options_experimental", From 3ca70a7d432b54f159653520e9d8d85e180bca99 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Mon, 29 Jun 2026 21:26:52 -0500 Subject: [PATCH 21/38] remove debug echos --- luarules/gadgets/unit_defensive_firestate.lua | 80 +------------------ 1 file changed, 1 insertion(+), 79 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index f83d414e249..6f0bfdcad6f 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -47,7 +47,6 @@ local spGetAllUnits = Spring.GetAllUnits local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitAllyTeam = Spring.GetUnitAllyTeam local spGetUnitLosState = Spring.GetUnitLosState -local spEcho = Spring.Echo local mathDistance2dSquared = math.distance2dSquared local function getWeaponDefFromName(weaponName) @@ -310,23 +309,17 @@ local function canUnitDefOneShotUnitDef(attackerUnitDef, targetUnitDef) return false end -local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefID, targetUnitDefID, attackerName, attackerDPS, attackerSpeed, attackerMaxRange) +local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefID, targetUnitDefID, attackerDPS, attackerSpeed, attackerMaxRange) local threatUnitDef = UnitDefs[targetUnitDefID] local victimUnitDef = UnitDefs[attackerUnitDefID] if not canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) then rangesForAttacker[targetUnitDefID] = NO_THREAT - if attackerName == "corban" then - spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", NO_THREAT, "(canThreaten=no)") - end return end if neverHesitateAttackers[attackerUnitDefID] then rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT - if attackerName == "corban" then - spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", ALWAYS_SHOOT, "(neverHesitate=yes)") - end return end @@ -342,79 +335,10 @@ local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefI killBuffer = (attackerSpeed + targetSpeed) * timeToKill end local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) - local storedThreatRange if threatRange > attackerMaxRange then rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT - storedThreatRange = ALWAYS_SHOOT else rangesForAttacker[targetUnitDefID] = threatRange * threatRange - storedThreatRange = threatRange - end - if attackerName == "corban" then - local killBufferBranch - if canOneShot then - killBufferBranch = "oneShot" - elseif attackerDPS > targetHealth then - killBufferBranch = "instantKill" - else - killBufferBranch = "timeToKill" - end - local targetThreatRangeSource = isKamikazeUnitDef(threatUnitDef) and "kamikazeExplosionRadius" or "weaponOrReclaimRange" - local weaponCount = countNonBogusWeapons(victimUnitDef) - local dpsPenaltyMultiplier = tonumber(victimUnitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY - if weaponCount > 1 then - dpsPenaltyMultiplier = MULTI_WEAPON_DPS_PENALTY - end - spEcho("defThreatRange", attackerName, "vs", threatUnitDef.name, "=>", storedThreatRange) - spEcho(" logic: canThreaten=yes; neverHesitate=no") - spEcho(" formula: threatRange = sqrt(targetThreatRange^2 + attackerSpeed^2 + killBuffer^2)") - spEcho(" attackerDPS:", attackerDPS, "weaponCount:", weaponCount, "dpsPenalty:", dpsPenaltyMultiplier) - spEcho(" attackerSpeed:", attackerSpeed, "(victim speed", victimUnitDef.speed or 0, "*", THREAT_MOVEMENT_BUFFER_MULTIPLIER .. ")") - spEcho(" targetThreatRange:", targetThreatRange, "(" .. targetThreatRangeSource .. ")") - if isKamikazeUnitDef(threatUnitDef) then - spEcho(" kamikaze explosion radius:", targetThreatRange) - else - if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then - spEcho(" reclaim buildDistance:", threatUnitDef.buildDistance or 0) - end - local weapons = threatUnitDef.weapons - for weaponNum = 1, #weapons do - local weapon = weapons[weaponNum] - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then - spEcho(" threatening weapon:", weaponDef.name, "range:", weaponDef.range or 0) - end - end - end - spEcho(" targetSpeed:", targetSpeed, "(threat speed", threatUnitDef.speed or 0, "*", THREAT_MOVEMENT_BUFFER_MULTIPLIER .. ")") - spEcho(" targetHealth:", targetHealth, "timeToKill:", timeToKill) - if canOneShot then - local attackerWeapons = victimUnitDef.weapons - for weaponNum = 1, #attackerWeapons do - local weapon = attackerWeapons[weaponNum] - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef then - local volleyDamage = getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, threatUnitDef) - if volleyDamage >= targetHealth then - spEcho(" oneShot weapon:", weaponDef.name, "volleyDamage:", volleyDamage) - end - end - end - end - spEcho(" killBuffer:", killBuffer, "(" .. killBufferBranch .. ")") - if killBufferBranch == "oneShot" then - spEcho(" killBuffer = (attackerSpeed + targetSpeed) *", INSTANT_KILL_MOVEMENT_SECONDS, "(armor-matched volley >= targetHealth)") - elseif killBufferBranch == "instantKill" then - spEcho(" killBuffer = (attackerSpeed + targetSpeed) *", INSTANT_KILL_MOVEMENT_SECONDS, "(attackerDPS > targetHealth)") - else - spEcho(" killBuffer = (attackerSpeed + targetSpeed) * timeToKill") - end - spEcho(" threatRange:", threatRange, "attackerMaxRange:", attackerMaxRange) - if threatRange > attackerMaxRange then - spEcho(" exceedsMaxRange: yes => stored ALWAYS_SHOOT (" .. ALWAYS_SHOOT .. ")") - else - spEcho(" exceedsMaxRange: no => stored linear:", storedThreatRange, "squared in table:", threatRange * threatRange) - end end end @@ -582,7 +506,6 @@ function gadget:Initialize() for attackerUnitDefID, attackerDPS in pairs(unitDefsDPS) do if attackerDPS > 0 or neverHesitateAttackers[attackerUnitDefID] then local rangesForAttacker = {} - local attackerName = UnitDefs[attackerUnitDefID].name local attackerSpeed = attackerSpeeds[attackerUnitDefID] local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] for targetIndex = 1, #targetUnitDefIDs do @@ -590,7 +513,6 @@ function gadget:Initialize() rangesForAttacker, attackerUnitDefID, targetUnitDefIDs[targetIndex], - attackerName, attackerDPS, attackerSpeed, attackerMaxRange From 4faa88501c8280261fa60fea65a0291f216af3c8 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Tue, 30 Jun 2026 19:20:15 -0500 Subject: [PATCH 22/38] DPS -> time to kill based on # of shots rework --- luarules/gadgets/unit_defensive_firestate.lua | 311 +++++++----------- 1 file changed, 126 insertions(+), 185 deletions(-) diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defensive_firestate.lua index 6f0bfdcad6f..bcf7e3bb334 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defensive_firestate.lua @@ -21,13 +21,15 @@ if not gadgetHandler:IsSyncedCode() then return false end +--increase safety margin buffer so a pawn can walk through a minefield that's exposed + local Firestates = VFS.Include("modules/firestates.lua") -local UnitDefDPS = VFS.Include("modules/unitdefdps.lua") local CMD_FIRE_STATE = CMD.FIRE_STATE -local DPS_PENALTY = 0.99 -local MULTI_WEAPON_DPS_PENALTY = 0.66 -local THREAT_MOVEMENT_BUFFER_MULTIPLIER = 2 -local INSTANT_KILL_MOVEMENT_SECONDS = 1 +local THREAT_RANGE_BUFFER_MULTIPLIER = 1.25 +local EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER = 1.5 +local REFERENCE_RANGE_FRACTION = 0.75 +local REFERENCE_TARGET_HALF_EXTENT = Game.squareSize * Game.footprintScale +local FALLOFF_WEAPON_TYPES = { BeamLaser = true, Flame = true, LaserCannon = true, LightningCannon = true } local ALWAYS_SHOOT = -1 local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 @@ -80,18 +82,6 @@ local function getUnitDefKamikazeDistance(unitDef) or 0 end -local function countNonBogusWeapons(unitDef) - local count = 0 - local weapons = unitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and not weaponDef.customParams.bogus then - count = count + 1 - end - end - return count -end - local function hasKamikazeWeapon(unitDef) local weapons = unitDef.weapons for weaponNum = 1, #weapons do @@ -132,43 +122,6 @@ local function getKamikazeExplosionRadius(unitDef) return math.max(explosionRadius, getUnitDefKamikazeDistance(unitDef)) end -local function getMaxWeaponRange(unitDef) - local longestRange = 0 - local weapons = unitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and not weaponDef.customParams.bogus then - local range = weaponDef.range or 0 - if range > longestRange then - longestRange = range - end - end - end - return longestRange -end - -local function unitMatchesWeaponCategories(victimUnitDef, weapon) - local victimCategories = victimUnitDef.modCategories - - if weapon.onlyTargets then - if not victimCategories then - return false - end - local matchesAny = false - for category, _ in pairs(weapon.onlyTargets) do - if victimCategories[category] then - matchesAny = true - break - end - end - if not matchesAny then - return false - end - end - - return true -end - local function isOffensiveWeapon(weaponDef) if not weaponDef or weaponDef.customParams.bogus then return false @@ -185,84 +138,84 @@ local function isOffensiveWeapon(weaponDef) return true end -local function weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) - if not isOffensiveWeapon(weaponDef) then +local function weaponDealsDamage(weaponDef) + local damages = weaponDef.damages + if not damages then return false end - if not unitMatchesWeaponCategories(victimUnitDef, weapon) then - return false + if (damages[0] or 0) > 0 then + return true end - if weaponDef.paralyzer then - local victimCategories = victimUnitDef.modCategories - if not victimCategories or not victimCategories.empable then - return false + for index = 1, #damages do + if (damages[index] or 0) > 0 then + return true end end - local armorType = victimUnitDef.armorType or 0 - local damage = weaponDef.damages and weaponDef.damages[armorType] - return damage and damage > 0 + return false end -local function explosionThreatensUnitDef(weaponName, victimUnitDef) - local weaponDef = getWeaponDefFromName(weaponName) - if not weaponDef then - return false - end +local function isCarrierWeapon(weaponDef) + local customParams = weaponDef.customParams + return customParams and customParams.carried_unit and customParams.carried_unit ~= "" +end - local armorType = victimUnitDef.armorType or 0 - local damage = weaponDef.damages and weaponDef.damages[armorType] - return damage and damage > 0 +local function getCarrierWeaponCommandRange(weaponDef) + local customParams = weaponDef.customParams or {} + local engagementRange = tonumber(customParams.engagementrange) + if engagementRange and engagementRange > 0 then + return engagementRange + end + return weaponDef.range or 0 end -local function canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) - if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then +local function isThreateningUnitDef(enemyUnitDef) + if enemyUnitDef.canReclaim then return true end - local weapons = threatUnitDef.weapons - for weaponNum = 1, #weapons do - local weapon = weapons[weaponNum] - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then - return true - end + if isKamikazeUnitDef(enemyUnitDef) then + return true end - if isKamikazeUnitDef(threatUnitDef) then - if explosionThreatensUnitDef(threatUnitDef.deathExplosion, victimUnitDef) then - return true - end - - if explosionThreatensUnitDef(threatUnitDef.selfDExplosion, victimUnitDef) then - return true + local weapons = enemyUnitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and isOffensiveWeapon(weaponDef) then + if isCarrierWeapon(weaponDef) or weaponDealsDamage(weaponDef) then + return true + end end end return false end -local function getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) - if isKamikazeUnitDef(threatUnitDef) then - return getKamikazeExplosionRadius(threatUnitDef) +local function getUnitDefMaxOffensiveRange(enemyUnitDef) + if isKamikazeUnitDef(enemyUnitDef) then + return getKamikazeExplosionRadius(enemyUnitDef) end local longestRange = 0 - if threatUnitDef.canReclaim and victimUnitDef.reclaimable ~= false then - local buildDistance = threatUnitDef.buildDistance or 0 + if enemyUnitDef.canReclaim then + local buildDistance = enemyUnitDef.buildDistance or 0 if buildDistance > longestRange then longestRange = buildDistance end end - local weapons = threatUnitDef.weapons + local weapons = enemyUnitDef.weapons for weaponNum = 1, #weapons do - local weapon = weapons[weaponNum] - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef and weaponThreatensUnitDef(weapon, weaponDef, victimUnitDef) then - local range = weaponDef.range or 0 + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and isOffensiveWeapon(weaponDef) then + local range = 0 + if isCarrierWeapon(weaponDef) then + range = getCarrierWeaponCommandRange(weaponDef) + elseif weaponDealsDamage(weaponDef) then + range = weaponDef.range or 0 + end if range > longestRange then longestRange = range end @@ -272,73 +225,70 @@ local function getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) return longestRange end -local function getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, targetUnitDef) - if weaponDef.paralyzer then - return 0 +local function getWeaponFalloffMultiplier(weaponDef) + if FALLOFF_WEAPON_TYPES[weaponDef.type] then + local minIntensity = weaponDef.minIntensity + if minIntensity and minIntensity > 0 then + return minIntensity + end + end + + return 1 +end + +local function getWeaponHitChance(weaponDef) + local spreadAngle = (weaponDef.accuracy or 0) + (weaponDef.sprayAngle or 0) + local spreadRadius = REFERENCE_RANGE_FRACTION * (weaponDef.range or 0) * spreadAngle + if spreadRadius <= REFERENCE_TARGET_HALF_EXTENT then + return 1 end - if not weaponThreatensUnitDef(weapon, weaponDef, targetUnitDef) then + local ratio = REFERENCE_TARGET_HALF_EXTENT / spreadRadius + return ratio * ratio +end + +local function getWeaponEffectiveDamagePerCycle(weaponDef, armorType) + local damages = weaponDef.damages + local damage = damages and damages[armorType] or 0 + if damage <= 0 then return 0 end - local armorType = targetUnitDef.armorType or 0 - local damage = weaponDef.damages and weaponDef.damages[armorType] or 0 local salvoSize = weaponDef.salvoSize or 1 local projectiles = weaponDef.projectiles or 1 - return damage * salvoSize * projectiles + local falloffMultiplier = getWeaponFalloffMultiplier(weaponDef) + local hitChance = getWeaponHitChance(weaponDef) + return damage * salvoSize * projectiles * falloffMultiplier * hitChance end -local function canUnitDefOneShotUnitDef(attackerUnitDef, targetUnitDef) - local targetHealth = targetUnitDef.health or 0 - if targetHealth <= 0 then - return false - end - - local weapons = attackerUnitDef.weapons - for weaponNum = 1, #weapons do - local weapon = weapons[weaponNum] - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef then - local volleyDamage = getWeaponVolleyDamageAgainstUnitDef(weapon, weaponDef, targetUnitDef) - if volleyDamage >= targetHealth then - return true - end - end +local function getWeaponTimeToKill(weaponDef, enemyHealth, armorType) + local damagePerCycle = getWeaponEffectiveDamagePerCycle(weaponDef, armorType) + if damagePerCycle <= 0 then + return nil end - return false + local cycles = math.ceil(enemyHealth / damagePerCycle) + local reload = weaponDef.reload or 0 + local salvoSize = weaponDef.salvoSize or 1 + local salvoDelay = weaponDef.salvoDelay or 0 + local intraSalvoTime = (salvoSize - 1) * salvoDelay + return math.max(0, cycles - 1) * reload + intraSalvoTime end -local function populateDefThreatRangeForPair(rangesForAttacker, attackerUnitDefID, targetUnitDefID, attackerDPS, attackerSpeed, attackerMaxRange) - local threatUnitDef = UnitDefs[targetUnitDefID] - local victimUnitDef = UnitDefs[attackerUnitDefID] - - if not canUnitDefThreatenUnitDef(threatUnitDef, victimUnitDef) then - rangesForAttacker[targetUnitDefID] = NO_THREAT - return - end - - if neverHesitateAttackers[attackerUnitDefID] then - rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT +local function populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps) + local timeToKill = getWeaponTimeToKill(weaponDef, enemyProps.health, enemyProps.armorType) + if not timeToKill then + rangesForWeapon[enemyUnitDefID] = NO_THREAT return end - local targetThreatRange = getThreatRangeAgainstUnitDef(threatUnitDef, victimUnitDef) - local targetSpeed = (threatUnitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER - local targetHealth = threatUnitDef.health or 0 - local canOneShot = canUnitDefOneShotUnitDef(victimUnitDef, threatUnitDef) - local timeToKill = targetHealth / attackerDPS - local killBuffer - if canOneShot or attackerDPS > targetHealth then - killBuffer = (attackerSpeed + targetSpeed) * INSTANT_KILL_MOVEMENT_SECONDS - else - killBuffer = (attackerSpeed + targetSpeed) * timeToKill - end - local threatRange = math.sqrt(targetThreatRange * targetThreatRange + attackerSpeed * attackerSpeed + killBuffer * killBuffer) - if threatRange > attackerMaxRange then - rangesForAttacker[targetUnitDefID] = ALWAYS_SHOOT + local groundCovered = enemyProps.speed * (timeToKill + EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER) + local threatRange = (enemyProps.reach + groundCovered) * THREAT_RANGE_BUFFER_MULTIPLIER + local weaponRange = weaponDef.range or 0 + if threatRange >= weaponRange then + rangesForWeapon[enemyUnitDefID] = ALWAYS_SHOOT else - rangesForAttacker[targetUnitDefID] = threatRange * threatRange + rangesForWeapon[enemyUnitDefID] = threatRange * threatRange end end @@ -435,12 +385,18 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac end local attackerUnitDefID = spGetUnitDefID(attackerID) + if neverHesitateAttackers[attackerUnitDefID] then + return true + end + + local rangesForWeapon = defThreatRanges[attackerWeaponDefID] + if not rangesForWeapon then + return true + end + local targetUnitDefID = spGetUnitDefID(targetID) - local threatRangeSq = defThreatRanges[attackerUnitDefID] and defThreatRanges[attackerUnitDefID][targetUnitDefID] + local threatRangeSq = rangesForWeapon[targetUnitDefID] if not threatRangeSq then - if not defThreatRanges[attackerUnitDefID] then - return true - end return false end @@ -466,27 +422,21 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac end function gadget:Initialize() - local allUnitDefDPS = UnitDefDPS.calculateAll() - local unitDefsDPS = {} - local attackerSpeeds = {} - local attackerMaxRanges = {} local targetUnitDefIDs = {} + local enemyProps = {} + local offensiveWeaponDefs = {} for unitDefID, unitDef in pairs(UnitDefs) do - local weaponCount = countNonBogusWeapons(unitDef) - - local dpsPenaltyMultiplier = tonumber(unitDef.customParams.dps_penalty_multiplier) or DPS_PENALTY - if weaponCount > 1 then - dpsPenaltyMultiplier = MULTI_WEAPON_DPS_PENALTY - end - unitDefsDPS[unitDefID] = UnitDefDPS.getEffectiveDPS(unitDef, allUnitDefDPS[unitDefID]) * dpsPenaltyMultiplier - attackerSpeeds[unitDefID] = (unitDef.speed or 0) * THREAT_MOVEMENT_BUFFER_MULTIPLIER - attackerMaxRanges[unitDefID] = getMaxWeaponRange(unitDef) - local isKamikaze = isKamikazeUnitDef(unitDef) - if weaponCount > 0 or unitDef.canReclaim or isKamikaze then + if isThreateningUnitDef(unitDef) then targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID + enemyProps[unitDefID] = { + armorType = unitDef.armorType or 0, + health = unitDef.health or 0, + speed = unitDef.speed or 0, + reach = getUnitDefMaxOffensiveRange(unitDef), + } end if unitDef.customParams.defensive_never_hesitate or isKamikaze then @@ -497,29 +447,20 @@ function gadget:Initialize() for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef local weaponDef = WeaponDefs[weaponDefID] - if not weaponDef.customParams.bogus then + if weaponDef and not weaponDef.customParams.bogus and isOffensiveWeapon(weaponDef) then + offensiveWeaponDefs[weaponDefID] = weaponDef Script.SetWatchAllowTarget(weaponDefID, true) end end end - for attackerUnitDefID, attackerDPS in pairs(unitDefsDPS) do - if attackerDPS > 0 or neverHesitateAttackers[attackerUnitDefID] then - local rangesForAttacker = {} - local attackerSpeed = attackerSpeeds[attackerUnitDefID] - local attackerMaxRange = attackerMaxRanges[attackerUnitDefID] - for targetIndex = 1, #targetUnitDefIDs do - populateDefThreatRangeForPair( - rangesForAttacker, - attackerUnitDefID, - targetUnitDefIDs[targetIndex], - attackerDPS, - attackerSpeed, - attackerMaxRange - ) - end - defThreatRanges[attackerUnitDefID] = rangesForAttacker + for weaponDefID, weaponDef in pairs(offensiveWeaponDefs) do + local rangesForWeapon = {} + for targetIndex = 1, #targetUnitDefIDs do + local enemyUnitDefID = targetUnitDefIDs[targetIndex] + populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps[enemyUnitDefID]) end + defThreatRanges[weaponDefID] = rangesForWeapon end for _, unitID in ipairs(spGetAllUnits()) do From 6b4d5a5e09996771544687bbc6d39ee506f9dcbb Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Tue, 30 Jun 2026 19:20:37 -0500 Subject: [PATCH 23/38] add drone carriers to always threat list --- gamedata/alldefs_post.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index aac2496b190..467a3dd268b 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -775,7 +775,7 @@ local function unitDef_Post(name, uDef) end end - -- Defensive firestate: aircraft, long-range, and starburst units engage threats at any range in defensive mode + -- Defensive firestate: aircraft, long-range, starburst, and drone carrier units engage threats at any range in defensive mode -- regardless of calculated threat distance (see luarules/gadgets/unit_defensive_firestate.lua). --DEFEND FIRESTATE REWORK: Remove modoption guard; always set defensive_never_hesitate for qualifying units if modOptions.experimental_defend_firestate then @@ -787,6 +787,7 @@ local function unitDef_Post(name, uDef) if uDef.canfly or weaponDef.weapontype == "StarburstLauncher" or (weaponDef.range and weaponDef.range > DEFENSIVE_NEVER_HESITATE_RANGE) + or (weaponDef.customparams.carried_unit and weaponDef.customparams.carried_unit ~= "") then customparams.defensive_never_hesitate = true break From 5724b8c1c58947e11e1865ded923f28d3a610331 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Tue, 30 Jun 2026 20:07:04 -0500 Subject: [PATCH 24/38] rename defense -> defend variables --- gamedata/alldefs_post.lua | 14 +++--- ...irestate.lua => unit_defend_firestate.lua} | 50 +++++++++---------- luaui/Widgets/cmd_user_firestate.lua | 2 +- luaui/Widgets/gui_ordermenu.lua | 4 +- luaui/Widgets/gui_unit_firestate_icons.lua | 2 +- modules/firestates.lua | 8 +-- 6 files changed, 40 insertions(+), 40 deletions(-) rename luarules/gadgets/{unit_defensive_firestate.lua => unit_defend_firestate.lua} (90%) diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index 467a3dd268b..ed5d27f06dc 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -775,21 +775,21 @@ local function unitDef_Post(name, uDef) end end - -- Defensive firestate: aircraft, long-range, starburst, and drone carrier units engage threats at any range in defensive mode - -- regardless of calculated threat distance (see luarules/gadgets/unit_defensive_firestate.lua). - --DEFEND FIRESTATE REWORK: Remove modoption guard; always set defensive_never_hesitate for qualifying units + -- Defend firestate: aircraft, long-range, starburst, and drone carrier units engage threats at any range in defend mode + -- regardless of calculated threat distance (see luarules/gadgets/unit_defend_firestate.lua). + --DEFEND FIRESTATE REWORK: Remove modoption guard; always set defend_never_hesitate for qualifying units if modOptions.experimental_defend_firestate then - local DEFENSIVE_NEVER_HESITATE_RANGE = 2000 + local DEFEND_NEVER_HESITATE_RANGE = 2000 - if not customparams.defensive_never_hesitate and next(weapondefs) then + if not customparams.defend_never_hesitate and next(weapondefs) then for _, weaponDef in pairs(weapondefs) do if not weaponDef.customparams.bogus then if uDef.canfly or weaponDef.weapontype == "StarburstLauncher" - or (weaponDef.range and weaponDef.range > DEFENSIVE_NEVER_HESITATE_RANGE) + or (weaponDef.range and weaponDef.range > DEFEND_NEVER_HESITATE_RANGE) or (weaponDef.customparams.carried_unit and weaponDef.customparams.carried_unit ~= "") then - customparams.defensive_never_hesitate = true + customparams.defend_never_hesitate = true break end end diff --git a/luarules/gadgets/unit_defensive_firestate.lua b/luarules/gadgets/unit_defend_firestate.lua similarity index 90% rename from luarules/gadgets/unit_defensive_firestate.lua rename to luarules/gadgets/unit_defend_firestate.lua index bcf7e3bb334..8aebdc75392 100644 --- a/luarules/gadgets/unit_defensive_firestate.lua +++ b/luarules/gadgets/unit_defend_firestate.lua @@ -1,4 +1,4 @@ ---DEFEND FIRESTATE REWORK: Remove guard; defensive targeting is always required +--DEFEND FIRESTATE REWORK: Remove guard; defend targeting is always required if not Spring.GetModOptions().experimental_defend_firestate then return end @@ -7,8 +7,8 @@ local gadget = gadget ---@type Gadget function gadget:GetInfo() return { - name = "Firestate Defensive", - desc = "Limits defensive firestate to nearby targets", + name = "Firestate Defend", + desc = "Limits defend firestate to nearby targets", author = "SethDGamre", date = "2026.06.28", license = "GNU GPL, v2 or later", @@ -35,9 +35,9 @@ local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 local defThreatRanges = {} local neverHesitateAttackers = {} -local defensiveWatchList = {} +local defendWatchList = {} local cloakedWatchList = {} -local defensiveHPCheckCooldowns = {} +local defendHPCheckCooldowns = {} local radarAggroWatchList = {} local gameFrame = 0 @@ -292,13 +292,13 @@ local function populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDe end end -local function setDefensiveWatch(unitID, isDefensive) - if isDefensive then - defensiveWatchList[unitID] = spGetUnitHealth(unitID) +local function setDefendWatch(unitID, isDefend) + if isDefend then + defendWatchList[unitID] = spGetUnitHealth(unitID) else - defensiveWatchList[unitID] = nil + defendWatchList[unitID] = nil radarAggroWatchList[unitID] = nil - defensiveHPCheckCooldowns[unitID] = nil + defendHPCheckCooldowns[unitID] = nil end end @@ -306,8 +306,8 @@ local function isRadarOnlyUnknownTarget(los) return los and los.radar and not los.los and not los.typed end -local function checkDefensiveUnitHealth(attackerID) - local nextCheckFrame = defensiveHPCheckCooldowns[attackerID] +local function checkDefendUnitHealth(attackerID) + local nextCheckFrame = defendHPCheckCooldowns[attackerID] if nextCheckFrame and gameFrame < nextCheckFrame then return end @@ -317,30 +317,30 @@ local function checkDefensiveUnitHealth(attackerID) return end - local lastHealth = defensiveWatchList[attackerID] + local lastHealth = defendWatchList[attackerID] if lastHealth and currentHealth < lastHealth then radarAggroWatchList[attackerID] = true elseif lastHealth and currentHealth > lastHealth then radarAggroWatchList[attackerID] = nil end - defensiveWatchList[attackerID] = currentHealth - defensiveHPCheckCooldowns[attackerID] = gameFrame + HP_CHECK_INTERVAL_FRAMES + defendWatchList[attackerID] = currentHealth + defendHPCheckCooldowns[attackerID] = gameFrame + HP_CHECK_INTERVAL_FRAMES end -local function updateDefensiveWatchFromRulesParam(unitID) +local function updateDefendWatchFromRulesParam(unitID) local state = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) - setDefensiveWatch(unitID, state == Firestates.DEFENSIVE) + setDefendWatch(unitID, state == Firestates.DEFEND) end function gadget:UnitCommand(unitID, unitDefID, unitTeamID, cmdID, cmdParams, cmdOptions, cmdTag, playerID, fromSynced, fromLua) if cmdID == CMD_FIRE_STATE then - updateDefensiveWatchFromRulesParam(unitID) + updateDefendWatchFromRulesParam(unitID) end end function gadget:UnitFinished(unitID, unitDefID, unitTeam) - updateDefensiveWatchFromRulesParam(unitID) + updateDefendWatchFromRulesParam(unitID) end function gadget:UnitCloaked(unitID, unitDefID, unitTeam) @@ -352,10 +352,10 @@ function gadget:UnitDecloaked(unitID, unitDefID, unitTeam) end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) - defensiveWatchList[unitID] = nil + defendWatchList[unitID] = nil cloakedWatchList[unitID] = nil radarAggroWatchList[unitID] = nil - defensiveHPCheckCooldowns[unitID] = nil + defendHPCheckCooldowns[unitID] = nil end function gadget:GameFrame(frame) @@ -363,7 +363,7 @@ function gadget:GameFrame(frame) end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if not defensiveWatchList[attackerID] then + if not defendWatchList[attackerID] then return true end @@ -371,7 +371,7 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac return false end - checkDefensiveUnitHealth(attackerID) + checkDefendUnitHealth(attackerID) local allyTeamID = spGetUnitAllyTeam(attackerID) local los = spGetUnitLosState(targetID, allyTeamID, false) @@ -439,7 +439,7 @@ function gadget:Initialize() } end - if unitDef.customParams.defensive_never_hesitate or isKamikaze then + if unitDef.customParams.defend_never_hesitate or isKamikaze then neverHesitateAttackers[unitDefID] = true end @@ -464,7 +464,7 @@ function gadget:Initialize() end for _, unitID in ipairs(spGetAllUnits()) do - updateDefensiveWatchFromRulesParam(unitID) + updateDefendWatchFromRulesParam(unitID) if spGetUnitIsCloaked(unitID) then cloakedWatchList[unitID] = true end diff --git a/luaui/Widgets/cmd_user_firestate.lua b/luaui/Widgets/cmd_user_firestate.lua index a42bcc3c090..c9b987d6ce3 100644 --- a/luaui/Widgets/cmd_user_firestate.lua +++ b/luaui/Widgets/cmd_user_firestate.lua @@ -25,7 +25,7 @@ local spGiveOrderToUnitArray = Spring.GiveOrderToUnitArray local userFirestateByEngineFirestate = { [Firestates.ENGINE_HOLD_FIRE] = Firestates.PASSIVE, - [Firestates.ENGINE_RETURN_FIRE] = Firestates.DEFENSIVE, + [Firestates.ENGINE_RETURN_FIRE] = Firestates.DEFEND, [Firestates.ENGINE_FIRE_AT_WILL] = Firestates.AGGRESSIVE, } diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 3270dbf3138..c3b044b64f0 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -349,7 +349,7 @@ local function getUserFirestateCommand(command, unitID) local filledStateCount if Firestates.isUserFacing(userFirestate) then displayIndex = Firestates.userFacingDisplayIndex(userFirestate) - if userFirestate == Firestates.DEFENSIVE then + if userFirestate == Firestates.DEFEND then stateName = "Defend" elseif userFirestate == Firestates.PASSIVE then stateName = "Hold fire" @@ -358,7 +358,7 @@ local function getUserFirestateCommand(command, unitID) end elseif userFirestate == Firestates.RETURN_FIRE then displayIndex = 1 - stateName = "Return fire" + stateName = "Defend" filledStateCount = 2 elseif userFirestate == Firestates.FIRE_AT_ALL then displayIndex = 2 diff --git a/luaui/Widgets/gui_unit_firestate_icons.lua b/luaui/Widgets/gui_unit_firestate_icons.lua index 8166827a12a..83f4eefd054 100644 --- a/luaui/Widgets/gui_unit_firestate_icons.lua +++ b/luaui/Widgets/gui_unit_firestate_icons.lua @@ -145,7 +145,7 @@ local function getDisplayFireState(unitID) local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) if userFirestate == Firestates.PASSIVE then return HOLD_FIRE - elseif userFirestate == Firestates.DEFENSIVE or userFirestate == Firestates.RETURN_FIRE then + elseif userFirestate == Firestates.DEFEND or userFirestate == Firestates.RETURN_FIRE then return RETURN_FIRE end end diff --git a/modules/firestates.lua b/modules/firestates.lua index b448f151113..bf5e57a098a 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -2,7 +2,7 @@ local firestates = { RULES_PARAM = "user_firestate", PASSIVE = 0, - DEFENSIVE = 1, + DEFEND = 1, RETURN_FIRE = 2, AGGRESSIVE = 3, FIRE_AT_ALL = 4, @@ -15,19 +15,19 @@ local firestates = { local userFacingStateByDisplayIndex = { [0] = firestates.PASSIVE, - [1] = firestates.DEFENSIVE, + [1] = firestates.DEFEND, [2] = firestates.AGGRESSIVE, } local displayIndexByUserFacingState = { [firestates.PASSIVE] = 0, - [firestates.DEFENSIVE] = 1, + [firestates.DEFEND] = 1, [firestates.AGGRESSIVE] = 2, } local engineFirestateByState = { [firestates.PASSIVE] = firestates.ENGINE_HOLD_FIRE, - [firestates.DEFENSIVE] = firestates.ENGINE_FIRE_AT_WILL, + [firestates.DEFEND] = firestates.ENGINE_FIRE_AT_WILL, [firestates.RETURN_FIRE] = firestates.ENGINE_RETURN_FIRE, [firestates.AGGRESSIVE] = firestates.ENGINE_FIRE_AT_WILL, [firestates.FIRE_AT_ALL] = firestates.ENGINE_FIRE_AT_ALL, From 6fb5bbf9b0ace34cc659e4fb7c4f62a9c5835666 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Wed, 1 Jul 2026 20:00:53 -0500 Subject: [PATCH 25/38] working, best optimized so far, rename defense->defend --- luarules/gadgets/unit_defend_firestate.lua | 188 +++++++++++++++------ modoptions.lua | 2 +- 2 files changed, 136 insertions(+), 54 deletions(-) diff --git a/luarules/gadgets/unit_defend_firestate.lua b/luarules/gadgets/unit_defend_firestate.lua index 8aebdc75392..b8191a7671e 100644 --- a/luarules/gadgets/unit_defend_firestate.lua +++ b/luarules/gadgets/unit_defend_firestate.lua @@ -33,12 +33,21 @@ local FALLOFF_WEAPON_TYPES = { BeamLaser = true, Flame = true, LaserCannon = tru local ALWAYS_SHOOT = -1 local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 +local MIN_RADAR_DEFPRIORITY = 10000000 -- this is the floor of what a radar covered unit will generate for defpriorirty. If below this, it's certainly in LOS. For performance +local META_UNIT_DEF_ID = 1 +local META_ALLY_TEAM = 2 +local META_IS_DEFEND = 3 +local META_NEVER_HESITATE = 4 +local META_WEAPON_DEF_IDS = 5 +local META_LAST_HEALTH = 6 +local META_HP_CHECK_FRAME = 7 +local META_RADAR_AGGRO = 8 +local META_CLOAKED = 9 local defThreatRanges = {} local neverHesitateAttackers = {} -local defendWatchList = {} -local cloakedWatchList = {} -local defendHPCheckCooldowns = {} -local radarAggroWatchList = {} +local weaponWatchRefCount = {} +local offensiveWeaponsByUnitDef = {} +local metaData = {} local gameFrame = 0 local spGetUnitPosition = Spring.GetUnitPosition @@ -48,7 +57,6 @@ local spGetUnitIsCloaked = Spring.GetUnitIsCloaked local spGetAllUnits = Spring.GetAllUnits local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitAllyTeam = Spring.GetUnitAllyTeam -local spGetUnitLosState = Spring.GetUnitLosState local mathDistance2dSquared = math.distance2dSquared local function getWeaponDefFromName(weaponName) @@ -292,22 +300,66 @@ local function populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDe end end -local function setDefendWatch(unitID, isDefend) - if isDefend then - defendWatchList[unitID] = spGetUnitHealth(unitID) - else - defendWatchList[unitID] = nil - radarAggroWatchList[unitID] = nil - defendHPCheckCooldowns[unitID] = nil +local function addWeaponWatches(weaponDefIDs) + for index = 1, #weaponDefIDs do + local weaponDefID = weaponDefIDs[index] + local refCount = (weaponWatchRefCount[weaponDefID] or 0) + 1 + weaponWatchRefCount[weaponDefID] = refCount + if refCount == 1 then + Script.SetWatchAllowTarget(weaponDefID, true) + end end end -local function isRadarOnlyUnknownTarget(los) - return los and los.radar and not los.los and not los.typed +local function removeWeaponWatches(weaponDefIDs) + for index = 1, #weaponDefIDs do + local weaponDefID = weaponDefIDs[index] + local refCount = (weaponWatchRefCount[weaponDefID] or 0) - 1 + if refCount <= 0 then + weaponWatchRefCount[weaponDefID] = nil + Script.SetWatchAllowTarget(weaponDefID, false) + else + weaponWatchRefCount[weaponDefID] = refCount + end + end end -local function checkDefendUnitHealth(attackerID) - local nextCheckFrame = defendHPCheckCooldowns[attackerID] +local function setDefendWatch(unitID, isDefend) + local meta = metaData[unitID] + if not meta then + return + end + + if isDefend then + if meta[META_IS_DEFEND] then + meta[META_LAST_HEALTH] = spGetUnitHealth(unitID) + return + end + + local unitDefID = meta[META_UNIT_DEF_ID] + local weaponDefIDs = offensiveWeaponsByUnitDef[unitDefID] + meta[META_IS_DEFEND] = true + meta[META_ALLY_TEAM] = spGetUnitAllyTeam(unitID) + meta[META_NEVER_HESITATE] = neverHesitateAttackers[unitDefID] or false + meta[META_WEAPON_DEF_IDS] = weaponDefIDs + meta[META_LAST_HEALTH] = spGetUnitHealth(unitID) + + if weaponDefIDs then + addWeaponWatches(weaponDefIDs) + end + elseif meta[META_IS_DEFEND] then + if meta[META_WEAPON_DEF_IDS] then + removeWeaponWatches(meta[META_WEAPON_DEF_IDS]) + end + meta[META_IS_DEFEND] = nil + meta[META_WEAPON_DEF_IDS] = nil + meta[META_RADAR_AGGRO] = nil + meta[META_HP_CHECK_FRAME] = nil + end +end + +local function checkDefendUnitHealth(attackerID, meta) + local nextCheckFrame = meta[META_HP_CHECK_FRAME] if nextCheckFrame and gameFrame < nextCheckFrame then return end @@ -317,15 +369,15 @@ local function checkDefendUnitHealth(attackerID) return end - local lastHealth = defendWatchList[attackerID] + local lastHealth = meta[META_LAST_HEALTH] if lastHealth and currentHealth < lastHealth then - radarAggroWatchList[attackerID] = true + meta[META_RADAR_AGGRO] = true elseif lastHealth and currentHealth > lastHealth then - radarAggroWatchList[attackerID] = nil + meta[META_RADAR_AGGRO] = nil end - defendWatchList[attackerID] = currentHealth - defendHPCheckCooldowns[attackerID] = gameFrame + HP_CHECK_INTERVAL_FRAMES + meta[META_LAST_HEALTH] = currentHealth + meta[META_HP_CHECK_FRAME] = gameFrame + HP_CHECK_INTERVAL_FRAMES end local function updateDefendWatchFromRulesParam(unitID) @@ -339,23 +391,41 @@ function gadget:UnitCommand(unitID, unitDefID, unitTeamID, cmdID, cmdParams, cmd end end +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + metaData[unitID] = { [META_UNIT_DEF_ID] = unitDefID } +end + function gadget:UnitFinished(unitID, unitDefID, unitTeam) updateDefendWatchFromRulesParam(unitID) end function gadget:UnitCloaked(unitID, unitDefID, unitTeam) - cloakedWatchList[unitID] = true + local meta = metaData[unitID] + if meta then + meta[META_CLOAKED] = true + end end function gadget:UnitDecloaked(unitID, unitDefID, unitTeam) - cloakedWatchList[unitID] = nil + local meta = metaData[unitID] + if meta then + meta[META_CLOAKED] = nil + end +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + local meta = metaData[unitID] + if meta and meta[META_IS_DEFEND] then + meta[META_ALLY_TEAM] = spGetUnitAllyTeam(unitID) + end end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) - defendWatchList[unitID] = nil - cloakedWatchList[unitID] = nil - radarAggroWatchList[unitID] = nil - defendHPCheckCooldowns[unitID] = nil + local meta = metaData[unitID] + if meta and meta[META_WEAPON_DEF_IDS] then + removeWeaponWatches(meta[META_WEAPON_DEF_IDS]) + end + metaData[unitID] = nil end function gadget:GameFrame(frame) @@ -363,45 +433,38 @@ function gadget:GameFrame(frame) end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if not defendWatchList[attackerID] then + local attackerMeta = metaData[attackerID] + if not attackerMeta or not attackerMeta[META_IS_DEFEND] then return true end - if cloakedWatchList[attackerID] then - return false + local rangesForWeapon = defThreatRanges[attackerWeaponDefID] + if not rangesForWeapon then + return true end - checkDefendUnitHealth(attackerID) - - local allyTeamID = spGetUnitAllyTeam(attackerID) - local los = spGetUnitLosState(targetID, allyTeamID, false) - - if radarAggroWatchList[attackerID] and isRadarOnlyUnknownTarget(los) then - return true + local targetMeta = metaData[targetID] + if not targetMeta then + return false end - if not los or not los.los then + local threatRangeSq = rangesForWeapon[targetMeta[META_UNIT_DEF_ID]] + if not threatRangeSq or threatRangeSq == NO_THREAT then return false end - local attackerUnitDefID = spGetUnitDefID(attackerID) - if neverHesitateAttackers[attackerUnitDefID] then - return true + if attackerMeta[META_CLOAKED] then + return false end - local rangesForWeapon = defThreatRanges[attackerWeaponDefID] - if not rangesForWeapon then + if attackerMeta[META_NEVER_HESITATE] then return true end - local targetUnitDefID = spGetUnitDefID(targetID) - local threatRangeSq = rangesForWeapon[targetUnitDefID] - if not threatRangeSq then - return false - end + --checkDefendUnitHealth(attackerID, attackerMeta) - if threatRangeSq == NO_THREAT then - return false + if defPriority and defPriority > MIN_RADAR_DEFPRIORITY then + return attackerMeta[META_RADAR_AGGRO] or false end if threatRangeSq == ALWAYS_SHOOT then @@ -409,8 +472,12 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac end local attackerX, _, attackerZ = spGetUnitPosition(attackerID) + if not attackerX then + return true + end + local targetX, _, targetZ = spGetUnitPosition(targetID) - if not attackerX or not targetX then + if not targetX then return true end @@ -444,14 +511,28 @@ function gadget:Initialize() end local weapons = unitDef.weapons + local unitOffensiveWeapons + local seenWeapon for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef local weaponDef = WeaponDefs[weaponDefID] if weaponDef and not weaponDef.customParams.bogus and isOffensiveWeapon(weaponDef) then offensiveWeaponDefs[weaponDefID] = weaponDef - Script.SetWatchAllowTarget(weaponDefID, true) + if not seenWeapon then + seenWeapon = {} + end + if not seenWeapon[weaponDefID] then + seenWeapon[weaponDefID] = true + if not unitOffensiveWeapons then + unitOffensiveWeapons = {} + end + unitOffensiveWeapons[#unitOffensiveWeapons + 1] = weaponDefID + end end end + if unitOffensiveWeapons then + offensiveWeaponsByUnitDef[unitDefID] = unitOffensiveWeapons + end end for weaponDefID, weaponDef in pairs(offensiveWeaponDefs) do @@ -464,9 +545,10 @@ function gadget:Initialize() end for _, unitID in ipairs(spGetAllUnits()) do + metaData[unitID] = { [META_UNIT_DEF_ID] = spGetUnitDefID(unitID) } updateDefendWatchFromRulesParam(unitID) if spGetUnitIsCloaked(unitID) then - cloakedWatchList[unitID] = true + metaData[unitID][META_CLOAKED] = true end end end diff --git a/modoptions.lua b/modoptions.lua index 704ad8f40d7..cfd72f50c27 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -1818,7 +1818,7 @@ local options = { { key = "experimental_defend_firestate", - name = "New Defend Firestate", + name = "Defend Firestate", desc = "Replaces the fire state Return fire with Defend.\nUnits in Defend mode will shoot threats to themselves on a 1v1 basis.", type = "bool", section = "options_experimental", From 4a9db55bdf65475e01740d7762d82716fe46179e Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Wed, 1 Jul 2026 20:56:32 -0500 Subject: [PATCH 26/38] various improvements --- luarules/gadgets/unit_defend_firestate.lua | 168 ++++++++++----------- 1 file changed, 79 insertions(+), 89 deletions(-) diff --git a/luarules/gadgets/unit_defend_firestate.lua b/luarules/gadgets/unit_defend_firestate.lua index b8191a7671e..bd1ea7761e3 100644 --- a/luarules/gadgets/unit_defend_firestate.lua +++ b/luarules/gadgets/unit_defend_firestate.lua @@ -34,30 +34,29 @@ local ALWAYS_SHOOT = -1 local NO_THREAT = -2 local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 local MIN_RADAR_DEFPRIORITY = 10000000 -- this is the floor of what a radar covered unit will generate for defpriorirty. If below this, it's certainly in LOS. For performance -local META_UNIT_DEF_ID = 1 -local META_ALLY_TEAM = 2 -local META_IS_DEFEND = 3 -local META_NEVER_HESITATE = 4 -local META_WEAPON_DEF_IDS = 5 -local META_LAST_HEALTH = 6 -local META_HP_CHECK_FRAME = 7 -local META_RADAR_AGGRO = 8 -local META_CLOAKED = 9 +local UNIT_DEF_ID = 1 +local IS_DEFEND = 2 +local NEVER_HESITATE = 3 +local WEAPON_DEF_IDS = 4 +local LAST_HEALTH = 5 +local HP_CHECK_FRAME = 6 +local RADAR_AGGRO = 7 +local CLOAKED = 8 +local ALWAYS_HARMLESS = 9 local defThreatRanges = {} local neverHesitateAttackers = {} +local alwaysHarmlessUnitDefs = {} local weaponWatchRefCount = {} local offensiveWeaponsByUnitDef = {} local metaData = {} local gameFrame = 0 -local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitRulesParam = Spring.GetUnitRulesParam local spGetUnitIsCloaked = Spring.GetUnitIsCloaked local spGetAllUnits = Spring.GetAllUnits local spGetUnitHealth = Spring.GetUnitHealth -local spGetUnitAllyTeam = Spring.GetUnitAllyTeam -local mathDistance2dSquared = math.distance2dSquared +local spGetUnitSeparation = Spring.GetUnitSeparation local function getWeaponDefFromName(weaponName) if not weaponName or weaponName == "" then @@ -296,7 +295,7 @@ local function populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDe if threatRange >= weaponRange then rangesForWeapon[enemyUnitDefID] = ALWAYS_SHOOT else - rangesForWeapon[enemyUnitDefID] = threatRange * threatRange + rangesForWeapon[enemyUnitDefID] = threatRange end end @@ -331,35 +330,34 @@ local function setDefendWatch(unitID, isDefend) end if isDefend then - if meta[META_IS_DEFEND] then - meta[META_LAST_HEALTH] = spGetUnitHealth(unitID) + if meta[IS_DEFEND] then + meta[LAST_HEALTH] = spGetUnitHealth(unitID) return end - local unitDefID = meta[META_UNIT_DEF_ID] + local unitDefID = meta[UNIT_DEF_ID] local weaponDefIDs = offensiveWeaponsByUnitDef[unitDefID] - meta[META_IS_DEFEND] = true - meta[META_ALLY_TEAM] = spGetUnitAllyTeam(unitID) - meta[META_NEVER_HESITATE] = neverHesitateAttackers[unitDefID] or false - meta[META_WEAPON_DEF_IDS] = weaponDefIDs - meta[META_LAST_HEALTH] = spGetUnitHealth(unitID) + meta[IS_DEFEND] = true + meta[NEVER_HESITATE] = neverHesitateAttackers[unitDefID] or false + meta[WEAPON_DEF_IDS] = weaponDefIDs + meta[LAST_HEALTH] = spGetUnitHealth(unitID) if weaponDefIDs then addWeaponWatches(weaponDefIDs) end - elseif meta[META_IS_DEFEND] then - if meta[META_WEAPON_DEF_IDS] then - removeWeaponWatches(meta[META_WEAPON_DEF_IDS]) + elseif meta[IS_DEFEND] then + if meta[WEAPON_DEF_IDS] then + removeWeaponWatches(meta[WEAPON_DEF_IDS]) end - meta[META_IS_DEFEND] = nil - meta[META_WEAPON_DEF_IDS] = nil - meta[META_RADAR_AGGRO] = nil - meta[META_HP_CHECK_FRAME] = nil + meta[IS_DEFEND] = nil + meta[WEAPON_DEF_IDS] = nil + meta[RADAR_AGGRO] = nil + meta[HP_CHECK_FRAME] = nil end end local function checkDefendUnitHealth(attackerID, meta) - local nextCheckFrame = meta[META_HP_CHECK_FRAME] + local nextCheckFrame = meta[HP_CHECK_FRAME] if nextCheckFrame and gameFrame < nextCheckFrame then return end @@ -369,15 +367,15 @@ local function checkDefendUnitHealth(attackerID, meta) return end - local lastHealth = meta[META_LAST_HEALTH] + local lastHealth = meta[LAST_HEALTH] if lastHealth and currentHealth < lastHealth then - meta[META_RADAR_AGGRO] = true + meta[RADAR_AGGRO] = true elseif lastHealth and currentHealth > lastHealth then - meta[META_RADAR_AGGRO] = nil + meta[RADAR_AGGRO] = nil end - meta[META_LAST_HEALTH] = currentHealth - meta[META_HP_CHECK_FRAME] = gameFrame + HP_CHECK_INTERVAL_FRAMES + meta[LAST_HEALTH] = currentHealth + meta[HP_CHECK_FRAME] = gameFrame + HP_CHECK_INTERVAL_FRAMES end local function updateDefendWatchFromRulesParam(unitID) @@ -385,6 +383,14 @@ local function updateDefendWatchFromRulesParam(unitID) setDefendWatch(unitID, state == Firestates.DEFEND) end +local function createUnitMeta(unitDefID) + local meta = { [UNIT_DEF_ID] = unitDefID } + if alwaysHarmlessUnitDefs[unitDefID] then + meta[ALWAYS_HARMLESS] = true + end + return meta +end + function gadget:UnitCommand(unitID, unitDefID, unitTeamID, cmdID, cmdParams, cmdOptions, cmdTag, playerID, fromSynced, fromLua) if cmdID == CMD_FIRE_STATE then updateDefendWatchFromRulesParam(unitID) @@ -392,7 +398,7 @@ function gadget:UnitCommand(unitID, unitDefID, unitTeamID, cmdID, cmdParams, cmd end function gadget:UnitCreated(unitID, unitDefID, unitTeam) - metaData[unitID] = { [META_UNIT_DEF_ID] = unitDefID } + metaData[unitID] = createUnitMeta(unitDefID) end function gadget:UnitFinished(unitID, unitDefID, unitTeam) @@ -402,28 +408,21 @@ end function gadget:UnitCloaked(unitID, unitDefID, unitTeam) local meta = metaData[unitID] if meta then - meta[META_CLOAKED] = true + meta[CLOAKED] = true end end function gadget:UnitDecloaked(unitID, unitDefID, unitTeam) local meta = metaData[unitID] if meta then - meta[META_CLOAKED] = nil - end -end - -function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) - local meta = metaData[unitID] - if meta and meta[META_IS_DEFEND] then - meta[META_ALLY_TEAM] = spGetUnitAllyTeam(unitID) + meta[CLOAKED] = nil end end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) local meta = metaData[unitID] - if meta and meta[META_WEAPON_DEF_IDS] then - removeWeaponWatches(meta[META_WEAPON_DEF_IDS]) + if meta and meta[WEAPON_DEF_IDS] then + removeWeaponWatches(meta[WEAPON_DEF_IDS]) end metaData[unitID] = nil end @@ -433,59 +432,51 @@ function gadget:GameFrame(frame) end function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - local attackerMeta = metaData[attackerID] - if not attackerMeta or not attackerMeta[META_IS_DEFEND] then - return true - end - - local rangesForWeapon = defThreatRanges[attackerWeaponDefID] - if not rangesForWeapon then - return true - end - local targetMeta = metaData[targetID] if not targetMeta then return false end - local threatRangeSq = rangesForWeapon[targetMeta[META_UNIT_DEF_ID]] - if not threatRangeSq or threatRangeSq == NO_THREAT then - return false - end + local attackerMeta = metaData[attackerID] + local isDefend = attackerMeta and attackerMeta[IS_DEFEND] - if attackerMeta[META_CLOAKED] then - return false + if targetMeta[ALWAYS_HARMLESS] then + return not isDefend end - if attackerMeta[META_NEVER_HESITATE] then + if not isDefend then return true end - --checkDefendUnitHealth(attackerID, attackerMeta) + local rangesForWeapon = defThreatRanges[attackerWeaponDefID] + if not rangesForWeapon then + return true + end - if defPriority and defPriority > MIN_RADAR_DEFPRIORITY then - return attackerMeta[META_RADAR_AGGRO] or false + local threatRange = rangesForWeapon[targetMeta[UNIT_DEF_ID]] + if not threatRange or threatRange == NO_THREAT then + return false end - if threatRangeSq == ALWAYS_SHOOT then - return true + if attackerMeta[CLOAKED] then + return false end - local attackerX, _, attackerZ = spGetUnitPosition(attackerID) - if not attackerX then + if attackerMeta[NEVER_HESITATE] then return true end - local targetX, _, targetZ = spGetUnitPosition(targetID) - if not targetX then - return true + if (defPriority or 0) > MIN_RADAR_DEFPRIORITY then + checkDefendUnitHealth(attackerID, attackerMeta) + return attackerMeta[RADAR_AGGRO] or false end - if mathDistance2dSquared(attackerX, attackerZ, targetX, targetZ) > threatRangeSq then - return false + if threatRange == ALWAYS_SHOOT then + return true end - return true + local separation = spGetUnitSeparation(attackerID, targetID) + return not separation or separation <= threatRange end function gadget:Initialize() @@ -504,6 +495,8 @@ function gadget:Initialize() speed = unitDef.speed or 0, reach = getUnitDefMaxOffensiveRange(unitDef), } + else + alwaysHarmlessUnitDefs[unitDefID] = true end if unitDef.customParams.defend_never_hesitate or isKamikaze then @@ -512,22 +505,17 @@ function gadget:Initialize() local weapons = unitDef.weapons local unitOffensiveWeapons - local seenWeapon + local seenWeapon = {} for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef local weaponDef = WeaponDefs[weaponDefID] - if weaponDef and not weaponDef.customParams.bogus and isOffensiveWeapon(weaponDef) then + if weaponDef and isOffensiveWeapon(weaponDef) and not seenWeapon[weaponDefID] then + seenWeapon[weaponDefID] = true offensiveWeaponDefs[weaponDefID] = weaponDef - if not seenWeapon then - seenWeapon = {} - end - if not seenWeapon[weaponDefID] then - seenWeapon[weaponDefID] = true - if not unitOffensiveWeapons then - unitOffensiveWeapons = {} - end - unitOffensiveWeapons[#unitOffensiveWeapons + 1] = weaponDefID + if not unitOffensiveWeapons then + unitOffensiveWeapons = {} end + unitOffensiveWeapons[#unitOffensiveWeapons + 1] = weaponDefID end end if unitOffensiveWeapons then @@ -545,10 +533,12 @@ function gadget:Initialize() end for _, unitID in ipairs(spGetAllUnits()) do - metaData[unitID] = { [META_UNIT_DEF_ID] = spGetUnitDefID(unitID) } + local unitDefID = spGetUnitDefID(unitID) + local meta = createUnitMeta(unitDefID) + metaData[unitID] = meta updateDefendWatchFromRulesParam(unitID) if spGetUnitIsCloaked(unitID) then - metaData[unitID][META_CLOAKED] = true + meta[CLOAKED] = true end end end From d24a7f8bb2bbff1c6a1266e0405039db6c3f12c4 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Wed, 1 Jul 2026 21:15:28 -0500 Subject: [PATCH 27/38] separate out threat calcs into a separate file --- luarules/gadgets/unit_defend_firestate.lua | 261 +-------------------- modules/weaponthreat.lua | 253 ++++++++++++++++++++ 2 files changed, 261 insertions(+), 253 deletions(-) create mode 100644 modules/weaponthreat.lua diff --git a/luarules/gadgets/unit_defend_firestate.lua b/luarules/gadgets/unit_defend_firestate.lua index bd1ea7761e3..94f3c3d3516 100644 --- a/luarules/gadgets/unit_defend_firestate.lua +++ b/luarules/gadgets/unit_defend_firestate.lua @@ -24,14 +24,10 @@ end --increase safety margin buffer so a pawn can walk through a minefield that's exposed local Firestates = VFS.Include("modules/firestates.lua") +local WeaponThreat = VFS.Include("modules/weaponthreat.lua") local CMD_FIRE_STATE = CMD.FIRE_STATE -local THREAT_RANGE_BUFFER_MULTIPLIER = 1.25 -local EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER = 1.5 -local REFERENCE_RANGE_FRACTION = 0.75 -local REFERENCE_TARGET_HALF_EXTENT = Game.squareSize * Game.footprintScale -local FALLOFF_WEAPON_TYPES = { BeamLaser = true, Flame = true, LaserCannon = true, LightningCannon = true } -local ALWAYS_SHOOT = -1 -local NO_THREAT = -2 +local ALWAYS_SHOOT = WeaponThreat.ALWAYS_SHOOT +local NO_THREAT = WeaponThreat.NO_THREAT local HP_CHECK_INTERVAL_FRAMES = Game.gameSpeed * 3 local MIN_RADAR_DEFPRIORITY = 10000000 -- this is the floor of what a radar covered unit will generate for defpriorirty. If below this, it's certainly in LOS. For performance local UNIT_DEF_ID = 1 @@ -58,247 +54,6 @@ local spGetAllUnits = Spring.GetAllUnits local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitSeparation = Spring.GetUnitSeparation -local function getWeaponDefFromName(weaponName) - if not weaponName or weaponName == "" then - return nil - end - - local weaponDefName = WeaponDefNames[weaponName] or WeaponDefNames[string.lower(weaponName)] - if not weaponDefName then - return nil - end - - return WeaponDefs[weaponDefName.id] -end - -local function getWeaponExplosionRadius(weaponName) - local weaponDef = getWeaponDefFromName(weaponName) - if not weaponDef then - return 0 - end - - return weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 -end - -local function getUnitDefKamikazeDistance(unitDef) - local customParams = unitDef.customParams or {} - return unitDef.kamikazeDistance - or unitDef.kamikazeDist - or unitDef.kamikazedistance - or tonumber(customParams.kamikazedistance) - or 0 -end - -local function hasKamikazeWeapon(unitDef) - local weapons = unitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - local damageAreaOfEffect = weaponDef and (weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0) or 0 - if weaponDef and not weaponDef.customParams.bogus and weaponDef.name == "Crawlingbomb Dummy Weapon" and damageAreaOfEffect > 0 then - return true - end - end - return false -end - -local function isKamikazeUnitDef(unitDef) - local customParams = unitDef.customParams or {} - return unitDef.canKamikaze - or customParams.instantselfd - or customParams.unitgroup == "explo" - or customParams.mine - or customParams.detonaterange - or hasKamikazeWeapon(unitDef) -end - -local function getKamikazeExplosionRadius(unitDef) - local explosionRadius = math.max( - getWeaponExplosionRadius(unitDef.deathExplosion), - getWeaponExplosionRadius(unitDef.selfDExplosion) - ) - local weapons = unitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and not weaponDef.customParams.bogus then - local damageAreaOfEffect = weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 - if damageAreaOfEffect > explosionRadius then - explosionRadius = damageAreaOfEffect - end - end - end - return math.max(explosionRadius, getUnitDefKamikazeDistance(unitDef)) -end - -local function isOffensiveWeapon(weaponDef) - if not weaponDef or weaponDef.customParams.bogus then - return false - end - - if weaponDef.shieldRadius and weaponDef.shieldRadius > 0 then - return false - end - - if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then - return false - end - - return true -end - -local function weaponDealsDamage(weaponDef) - local damages = weaponDef.damages - if not damages then - return false - end - - if (damages[0] or 0) > 0 then - return true - end - - for index = 1, #damages do - if (damages[index] or 0) > 0 then - return true - end - end - - return false -end - -local function isCarrierWeapon(weaponDef) - local customParams = weaponDef.customParams - return customParams and customParams.carried_unit and customParams.carried_unit ~= "" -end - -local function getCarrierWeaponCommandRange(weaponDef) - local customParams = weaponDef.customParams or {} - local engagementRange = tonumber(customParams.engagementrange) - if engagementRange and engagementRange > 0 then - return engagementRange - end - return weaponDef.range or 0 -end - -local function isThreateningUnitDef(enemyUnitDef) - if enemyUnitDef.canReclaim then - return true - end - - if isKamikazeUnitDef(enemyUnitDef) then - return true - end - - local weapons = enemyUnitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and isOffensiveWeapon(weaponDef) then - if isCarrierWeapon(weaponDef) or weaponDealsDamage(weaponDef) then - return true - end - end - end - - return false -end - -local function getUnitDefMaxOffensiveRange(enemyUnitDef) - if isKamikazeUnitDef(enemyUnitDef) then - return getKamikazeExplosionRadius(enemyUnitDef) - end - - local longestRange = 0 - if enemyUnitDef.canReclaim then - local buildDistance = enemyUnitDef.buildDistance or 0 - if buildDistance > longestRange then - longestRange = buildDistance - end - end - - local weapons = enemyUnitDef.weapons - for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] - if weaponDef and isOffensiveWeapon(weaponDef) then - local range = 0 - if isCarrierWeapon(weaponDef) then - range = getCarrierWeaponCommandRange(weaponDef) - elseif weaponDealsDamage(weaponDef) then - range = weaponDef.range or 0 - end - if range > longestRange then - longestRange = range - end - end - end - - return longestRange -end - -local function getWeaponFalloffMultiplier(weaponDef) - if FALLOFF_WEAPON_TYPES[weaponDef.type] then - local minIntensity = weaponDef.minIntensity - if minIntensity and minIntensity > 0 then - return minIntensity - end - end - - return 1 -end - -local function getWeaponHitChance(weaponDef) - local spreadAngle = (weaponDef.accuracy or 0) + (weaponDef.sprayAngle or 0) - local spreadRadius = REFERENCE_RANGE_FRACTION * (weaponDef.range or 0) * spreadAngle - if spreadRadius <= REFERENCE_TARGET_HALF_EXTENT then - return 1 - end - - local ratio = REFERENCE_TARGET_HALF_EXTENT / spreadRadius - return ratio * ratio -end - -local function getWeaponEffectiveDamagePerCycle(weaponDef, armorType) - local damages = weaponDef.damages - local damage = damages and damages[armorType] or 0 - if damage <= 0 then - return 0 - end - - local salvoSize = weaponDef.salvoSize or 1 - local projectiles = weaponDef.projectiles or 1 - local falloffMultiplier = getWeaponFalloffMultiplier(weaponDef) - local hitChance = getWeaponHitChance(weaponDef) - return damage * salvoSize * projectiles * falloffMultiplier * hitChance -end - -local function getWeaponTimeToKill(weaponDef, enemyHealth, armorType) - local damagePerCycle = getWeaponEffectiveDamagePerCycle(weaponDef, armorType) - if damagePerCycle <= 0 then - return nil - end - - local cycles = math.ceil(enemyHealth / damagePerCycle) - local reload = weaponDef.reload or 0 - local salvoSize = weaponDef.salvoSize or 1 - local salvoDelay = weaponDef.salvoDelay or 0 - local intraSalvoTime = (salvoSize - 1) * salvoDelay - return math.max(0, cycles - 1) * reload + intraSalvoTime -end - -local function populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps) - local timeToKill = getWeaponTimeToKill(weaponDef, enemyProps.health, enemyProps.armorType) - if not timeToKill then - rangesForWeapon[enemyUnitDefID] = NO_THREAT - return - end - - local groundCovered = enemyProps.speed * (timeToKill + EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER) - local threatRange = (enemyProps.reach + groundCovered) * THREAT_RANGE_BUFFER_MULTIPLIER - local weaponRange = weaponDef.range or 0 - if threatRange >= weaponRange then - rangesForWeapon[enemyUnitDefID] = ALWAYS_SHOOT - else - rangesForWeapon[enemyUnitDefID] = threatRange - end -end - local function addWeaponWatches(weaponDefIDs) for index = 1, #weaponDefIDs do local weaponDefID = weaponDefIDs[index] @@ -485,15 +240,15 @@ function gadget:Initialize() local offensiveWeaponDefs = {} for unitDefID, unitDef in pairs(UnitDefs) do - local isKamikaze = isKamikazeUnitDef(unitDef) + local isKamikaze = WeaponThreat.isKamikazeUnitDef(unitDef) - if isThreateningUnitDef(unitDef) then + if WeaponThreat.isThreateningUnitDef(unitDef) then targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID enemyProps[unitDefID] = { armorType = unitDef.armorType or 0, health = unitDef.health or 0, speed = unitDef.speed or 0, - reach = getUnitDefMaxOffensiveRange(unitDef), + reach = WeaponThreat.getUnitDefMaxOffensiveRange(unitDef), } else alwaysHarmlessUnitDefs[unitDefID] = true @@ -509,7 +264,7 @@ function gadget:Initialize() for weaponNum = 1, #weapons do local weaponDefID = weapons[weaponNum].weaponDef local weaponDef = WeaponDefs[weaponDefID] - if weaponDef and isOffensiveWeapon(weaponDef) and not seenWeapon[weaponDefID] then + if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) and not seenWeapon[weaponDefID] then seenWeapon[weaponDefID] = true offensiveWeaponDefs[weaponDefID] = weaponDef if not unitOffensiveWeapons then @@ -527,7 +282,7 @@ function gadget:Initialize() local rangesForWeapon = {} for targetIndex = 1, #targetUnitDefIDs do local enemyUnitDefID = targetUnitDefIDs[targetIndex] - populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps[enemyUnitDefID]) + WeaponThreat.populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps[enemyUnitDefID]) end defThreatRanges[weaponDefID] = rangesForWeapon end diff --git a/modules/weaponthreat.lua b/modules/weaponthreat.lua new file mode 100644 index 00000000000..a30757bc442 --- /dev/null +++ b/modules/weaponthreat.lua @@ -0,0 +1,253 @@ +local WeaponThreat = {} + +local THREAT_RANGE_BUFFER_MULTIPLIER = 1.25 +local EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER = 1.5 +local REFERENCE_RANGE_FRACTION = 0.75 +local REFERENCE_TARGET_HALF_EXTENT = Game.squareSize * Game.footprintScale +local FALLOFF_WEAPON_TYPES = { BeamLaser = true, Flame = true, LaserCannon = true, LightningCannon = true } + +WeaponThreat.ALWAYS_SHOOT = -1 +WeaponThreat.NO_THREAT = -2 + +local function getWeaponDefFromName(weaponName) + if not weaponName or weaponName == "" then + return nil + end + + local weaponDefName = WeaponDefNames[weaponName] or WeaponDefNames[string.lower(weaponName)] + if not weaponDefName then + return nil + end + + return WeaponDefs[weaponDefName.id] +end + +local function getWeaponExplosionRadius(weaponName) + local weaponDef = getWeaponDefFromName(weaponName) + if not weaponDef then + return 0 + end + + return weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 +end + +local function getUnitDefKamikazeDistance(unitDef) + local customParams = unitDef.customParams or {} + return unitDef.kamikazeDistance + or unitDef.kamikazeDist + or unitDef.kamikazedistance + or tonumber(customParams.kamikazedistance) + or 0 +end + +local function hasKamikazeWeapon(unitDef) + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + local damageAreaOfEffect = weaponDef and (weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0) or 0 + if weaponDef and not weaponDef.customParams.bogus and weaponDef.name == "Crawlingbomb Dummy Weapon" and damageAreaOfEffect > 0 then + return true + end + end + return false +end + +function WeaponThreat.isKamikazeUnitDef(unitDef) + local customParams = unitDef.customParams or {} + return unitDef.canKamikaze + or customParams.instantselfd + or customParams.unitgroup == "explo" + or customParams.mine + or customParams.detonaterange + or hasKamikazeWeapon(unitDef) +end + +local function getKamikazeExplosionRadius(unitDef) + local explosionRadius = math.max( + getWeaponExplosionRadius(unitDef.deathExplosion), + getWeaponExplosionRadius(unitDef.selfDExplosion) + ) + local weapons = unitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and not weaponDef.customParams.bogus then + local damageAreaOfEffect = weaponDef.damageAreaOfEffect or weaponDef.areaOfEffect or 0 + if damageAreaOfEffect > explosionRadius then + explosionRadius = damageAreaOfEffect + end + end + end + return math.max(explosionRadius, getUnitDefKamikazeDistance(unitDef)) +end + +function WeaponThreat.isOffensiveWeapon(weaponDef) + if not weaponDef or weaponDef.customParams.bogus then + return false + end + + if weaponDef.shieldRadius and weaponDef.shieldRadius > 0 then + return false + end + + if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then + return false + end + + return true +end + +local function weaponDealsDamage(weaponDef) + local damages = weaponDef.damages + if not damages then + return false + end + + if (damages[0] or 0) > 0 then + return true + end + + for index = 1, #damages do + if (damages[index] or 0) > 0 then + return true + end + end + + return false +end + +local function isCarrierWeapon(weaponDef) + local customParams = weaponDef.customParams + return customParams and customParams.carried_unit and customParams.carried_unit ~= "" +end + +local function getCarrierWeaponCommandRange(weaponDef) + local customParams = weaponDef.customParams or {} + local engagementRange = tonumber(customParams.engagementrange) + if engagementRange and engagementRange > 0 then + return engagementRange + end + return weaponDef.range or 0 +end + +function WeaponThreat.isThreateningUnitDef(enemyUnitDef) + if enemyUnitDef.canReclaim then + return true + end + + if WeaponThreat.isKamikazeUnitDef(enemyUnitDef) then + return true + end + + local weapons = enemyUnitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) then + if isCarrierWeapon(weaponDef) or weaponDealsDamage(weaponDef) then + return true + end + end + end + + return false +end + +function WeaponThreat.getUnitDefMaxOffensiveRange(enemyUnitDef) + if WeaponThreat.isKamikazeUnitDef(enemyUnitDef) then + return getKamikazeExplosionRadius(enemyUnitDef) + end + + local longestRange = 0 + if enemyUnitDef.canReclaim then + local buildDistance = enemyUnitDef.buildDistance or 0 + if buildDistance > longestRange then + longestRange = buildDistance + end + end + + local weapons = enemyUnitDef.weapons + for weaponNum = 1, #weapons do + local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) then + local range = 0 + if isCarrierWeapon(weaponDef) then + range = getCarrierWeaponCommandRange(weaponDef) + elseif weaponDealsDamage(weaponDef) then + range = weaponDef.range or 0 + end + if range > longestRange then + longestRange = range + end + end + end + + return longestRange +end + +local function getWeaponFalloffMultiplier(weaponDef) + if FALLOFF_WEAPON_TYPES[weaponDef.type] then + local minIntensity = weaponDef.minIntensity + if minIntensity and minIntensity > 0 then + return minIntensity + end + end + + return 1 +end + +local function getWeaponHitChance(weaponDef) + local spreadAngle = (weaponDef.accuracy or 0) + (weaponDef.sprayAngle or 0) + local spreadRadius = REFERENCE_RANGE_FRACTION * (weaponDef.range or 0) * spreadAngle + if spreadRadius <= REFERENCE_TARGET_HALF_EXTENT then + return 1 + end + + local ratio = REFERENCE_TARGET_HALF_EXTENT / spreadRadius + return ratio * ratio +end + +local function getWeaponEffectiveDamagePerCycle(weaponDef, armorType) + local damages = weaponDef.damages + local damage = damages and damages[armorType] or 0 + if damage <= 0 then + return 0 + end + + local salvoSize = weaponDef.salvoSize or 1 + local projectiles = weaponDef.projectiles or 1 + local falloffMultiplier = getWeaponFalloffMultiplier(weaponDef) + local hitChance = getWeaponHitChance(weaponDef) + return damage * salvoSize * projectiles * falloffMultiplier * hitChance +end + +local function getWeaponTimeToKill(weaponDef, enemyHealth, armorType) + local damagePerCycle = getWeaponEffectiveDamagePerCycle(weaponDef, armorType) + if damagePerCycle <= 0 then + return nil + end + + local cycles = math.ceil(enemyHealth / damagePerCycle) + local reload = weaponDef.reload or 0 + local salvoSize = weaponDef.salvoSize or 1 + local salvoDelay = weaponDef.salvoDelay or 0 + local intraSalvoTime = (salvoSize - 1) * salvoDelay + return math.max(0, cycles - 1) * reload + intraSalvoTime +end + +function WeaponThreat.populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps) + local timeToKill = getWeaponTimeToKill(weaponDef, enemyProps.health, enemyProps.armorType) + if not timeToKill then + rangesForWeapon[enemyUnitDefID] = WeaponThreat.NO_THREAT + return + end + + local groundCovered = enemyProps.speed * (timeToKill + EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER) + local threatRange = (enemyProps.reach + groundCovered) * THREAT_RANGE_BUFFER_MULTIPLIER + local weaponRange = weaponDef.range or 0 + if threatRange >= weaponRange then + rangesForWeapon[enemyUnitDefID] = WeaponThreat.ALWAYS_SHOOT + else + rangesForWeapon[enemyUnitDefID] = threatRange + end +end + +return WeaponThreat From 7b0e00a48b5259769589d5dc2ac138e5e09e4d22 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Wed, 1 Jul 2026 21:31:57 -0500 Subject: [PATCH 28/38] code cleanup --- language/en/interface.json | 7 +- luarules/gadgets/unit_firestate_handler.lua | 6 +- luaui/Widgets/gui_info.lua | 282 +++++++++++++---- luaui/Widgets/gui_ordermenu.lua | 4 +- modules/firestates.lua | 33 +- modules/unitdefdps.lua | 324 -------------------- 6 files changed, 243 insertions(+), 413 deletions(-) delete mode 100644 modules/unitdefdps.lua diff --git a/language/en/interface.json b/language/en/interface.json index 9b474c3af79..9fba3756cbe 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -267,12 +267,7 @@ "Return fire": "Return fire", "Defend": "Defend", "Fire at all": "Fire At All", - "Fire at will_tooltip": "Automatically engage enemies within range.", - "Hold fire_tooltip": "Only attack with an explicit order.", - "Return fire_tooltip": "Only attack threats targeting this unit.", - "Defend_tooltip": "Only attack threats to this unit.", - "Fire at all_tooltip": "Automatically engage enemies and neutral units within range.", - "firestate_tooltip": "Cycle between Hold fire, Defend, and Fire at will fire states.", + "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", "Hold pos": "Hold pos", "Maneuver": "Maneuver", "Roam": "Roam", diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua index cadb5566c86..f3f9eca0ae1 100644 --- a/luarules/gadgets/unit_firestate_handler.lua +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -32,7 +32,7 @@ local spSetUnitRulesParam = Spring.SetUnitRulesParam local settingEngineFirestate = false local function setUserFirestate(unitID, state) - local engineFirestate = Firestates.engineFirestateFor(state) + local engineFirestate = Firestates.toEngineFirestate(state) if not engineFirestate then return end @@ -56,7 +56,7 @@ function gadget:AllowCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOpt end if cmdID == CMD_FIRE_STATE then - local state = Firestates.logicalFromEngineFirestate(cmdParams[1]) + local state = Firestates.fromEngineFirestate(cmdParams[1]) spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) end return true @@ -64,7 +64,7 @@ end function gadget:UnitCreated(unitID, unitDefID, unitTeam) local firestate = spGetUnitStates(unitID, false) - local state = Firestates.logicalFromEngineFirestate(firestate) + local state = Firestates.fromEngineFirestate(firestate) spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) end diff --git a/luaui/Widgets/gui_info.lua b/luaui/Widgets/gui_info.lua index bed1467147b..afae95c4dfe 100644 --- a/luaui/Widgets/gui_info.lua +++ b/luaui/Widgets/gui_info.lua @@ -30,7 +30,6 @@ local emptyInfo = false local showEngineTooltip = false -- straight up display old engine delivered text local iconTypes = VFS.Include("gamedata/icontypes.lua") -local UnitDefDPS = VFS.Include("modules/unitdefdps.lua") local vsx, vsy = Spring.GetViewGeometry() @@ -291,7 +290,53 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].mex = true end local weapons = unitDef.weapons - local dpsState = UnitDefDPS.newState() + + + ----------------------------------------------------- + -- Utility functions for calculating weapon values -- + ----------------------------------------------------- + + local function addPrimaryDPS(minDPS, maxDPS) + unitDefInfo[unitDefID].mindps = (unitDefInfo[unitDefID].mindps or 0) + minDPS + unitDefInfo[unitDefID].maxdps = (unitDefInfo[unitDefID].maxdps or 0) + maxDPS + end + + local function addSecondaryDPS(minDPS, maxDPS) + unitDefInfo[unitDefID].maxdps = (unitDefInfo[unitDefID].maxdps or 0) + maxDPS + end + + + local function calculateLaserDPS(def, damage) + local minIntensity = math.max(def.minIntensity, 0.5) + local mindps = minIntensity*(damage * def.salvoSize / def.reload) + local maxdps = damage * def.salvoSize / def.reload + return mindps, maxdps + end + + local function calculateWeaponDPS(def, damage) + local reloadDPS = damage * (def.salvoSize * def.projectiles) / def.reload + local stockpileDPS = damage * (def.salvoSize * def.projectiles) / (def.stockpile and def.stockpileTime/30 or def.reload) + return math_min(reloadDPS, stockpileDPS), math_max(reloadDPS, stockpileDPS) + end + + local function calculateClusterDPS(def, damage) + local munition = unitDef.name .. '_' .. def.customParams.cluster_def + local cmNumber = def.customParams.cluster_number + local cmDamage = WeaponDefNames[munition].damages[0] + + local mainDps = (def.salvoSize * def.projectiles) / def.reload * (damage) + local cmunDps = (def.salvoSize * def.projectiles) / def.reload * (cmNumber * cmDamage) + return mainDps, mainDps + cmunDps + end + + local function calculateAreaDPS(def, damage) + local burst = def.salvoSize * def.projectiles + local impactDps = damage * burst / def.reload + local areaDps = def.customParams.area_onhit_damage -- by definition + local damageMax = math_max(impactDps + areaDps, areaDps * burst * def.customParams.area_onhit_time / def.reload) + return impactDps, damageMax + end + local function setEnergyAndMetalCosts(def) if def.energyCost > 0 and (not unitDefInfo[unitDefID].energyPerShot or def.energyCost > unitDefInfo[unitDefID].energyPerShot) then @@ -302,13 +347,19 @@ local function refreshUnitInfo() end end + ----------------------------------------------------- + + local unitExempt = false + local function refreshUnitWeaponInfo(i, weaponDef, isPrimaryWeapon) unitDefInfo[unitDefID].weapons[i] = weaponDef if isPrimaryWeapon and weapons[i].onlyTargets['vtol'] then - unitDefInfo[unitDefID].isAaUnit = true + unitDefInfo[unitDefID].isAaUnit = true -- displays airLOS range end + local addDPS = isPrimaryWeapon and addPrimaryDPS or addSecondaryDPS + if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then unitDefInfo[unitDefID].maxCoverage = math.max(unitDefInfo[unitDefID].maxCoverage or 1, weaponDef.coverageRange) @@ -323,43 +374,81 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].shieldRechargeCost = weaponDef.shieldPowerRegenEnergy else - UnitDefDPS.processWeapon(dpsState, unitDef, weapons, i, weaponDef, isPrimaryWeapon) - - if - unitDef.customParams.evocomlvl or - unitDef.name == 'armcom' or - unitDef.name == 'corcom' or - unitDef.name == 'legcom' or - unitDef.name == 'corkarg' or - unitDef.name == 'armguard' or - unitDef.name == 'corpun' or - unitDef.name == 'legcluster' or - unitDef.name == 'leglob' or - unitDef.name == 'legnavyfrigate' or + if unitDef.name == 'armamb' or unitDef.name == 'cortoast' then -- weapons with low/high traj, this list is incomplete + unitExempt = true + if i==1 then --Calculating using first weapon only + addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category + end + + elseif + unitDef.customParams.evocomlvl or -- use primary weapon for evolving commanders + unitDef.name == 'armcom' or -- ignore underwater secondary + unitDef.name == 'corcom' or + unitDef.name == 'legcom' or + unitDef.name == 'corkarg' or -- ignore secondary weapons, kick + unitDef.name == 'armguard' or -- ignore high-trajectory modes + unitDef.name == 'corpun' or + unitDef.name == 'legcluster' or + unitDef.name == 'leglob' or + unitDef.name == 'legnavyfrigate' or + unitDef.name == 'armamb' or + unitDef.name == 'cortoast' or unitDef.name == 'armvang' then - if i == 1 then + unitExempt = true + if i == 1 then --Calculating using first weapon only setEnergyAndMetalCosts(weaponDef) + + if weaponDef.type == "BeamLaser" then + addDPS(calculateLaserDPS(weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.cluster then -- Bullets that shoot other, smaller bullets + addDPS(calculateClusterDPS(weaponDef, weaponDef.damages[0])) + elseif weapons[i].onlyTargets['vtol'] ~= nil then + addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[armorIndex.vtol])) --Damage to air category + else + addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category + end end - elseif unitDef.name == 'corkorg' then - if i == 2 then - setEnergyAndMetalCosts(weaponDef) + + elseif unitDef.name == 'corkorg' then --excluding korstomp from dps calcuation for juggernaut + unitExempt = true + if i==1 then + local defDmg + defDmg = weaponDef.damages[0] --Damage to default armor category + addDPS(calculateWeaponDPS(weaponDef, defDmg)) end - end - if weaponDef.type == "BeamLaser" and not dpsState.unitExempt then - setEnergyAndMetalCosts(weaponDef) - end + if i==2 then + setEnergyAndMetalCosts(weaponDef) + addDPS(calculateLaserDPS(weaponDef, weaponDef.damages[0])) + end - if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not dpsState.unitExempt then - local defDmg - if weapons[1].onlyTargets['vtol'] ~= nil then - defDmg = weaponDef.damages[armorIndex.vtol] - else - defDmg = weaponDef.damages[0] + if i==3 then + addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) --Damage to default armor category end - if defDmg > 0 then - setEnergyAndMetalCosts(weaponDef) + + elseif weaponDef.customParams.area_onhit_damage and weaponDef.customParams.area_onhit_time then + unitExempt = true + addDPS(calculateAreaDPS(weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.cluster then -- Bullets that explode into other, smaller bullets + unitExempt = true + addDPS(calculateClusterDPS(weaponDef, weaponDef.damages[0])) + elseif weaponDef.customParams.speceffect == "split" then -- Bullets that split into other, smaller bullets + unitExempt = true + local splitd = WeaponDefNames[weaponDef.customParams.speceffect_def].damages[0] + local splitn = weaponDef.customParams.number or 1 + addDPS(calculateWeaponDPS(weaponDef, splitd * splitn)) + elseif weaponDef.customParams.spark_forkdamage then -- Lightning + unitExempt = true + addDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0])) + -- Sparks cannot retarget the original unit they hit, so add them as secondary damages. + local forkDamageRate = weaponDef.customParams.spark_forkdamage + addSecondaryDPS(calculateWeaponDPS(weaponDef, weaponDef.damages[0] * forkDamageRate)) + if unitExempt and weaponDef.paralyzer then -- DPS => EMP + unitDefInfo[unitDefID].minemp = unitDefInfo[unitDefID].mindps + unitDefInfo[unitDefID].maxemp = unitDefInfo[unitDefID].maxdps + unitDefInfo[unitDefID].mindps = nil + unitDefInfo[unitDefID].maxdps = nil end end @@ -368,52 +457,137 @@ local function refreshUnitInfo() unitDefInfo[unitDefID].range = weaponDef.range unitDefInfo[unitDefID].reloadTime = weaponDef.customParams.dronesuesestockpile and weaponDef.stockpileTime - or weaponDef.reload + or weaponDef.reload end - if weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then + if weaponDef.type == "BeamLaser" and not unitExempt then -- BeamLaser dps calc + + local defDmg + + if weapons[1].onlyTargets['vtol'] ~= nil then --if main weapon isn't dedicated aa, then all weapons calculate using default armor category + defDmg = weaponDef.damages[armorIndex.vtol] + else + defDmg = weaponDef.damages[0] + end + + setEnergyAndMetalCosts(weaponDef) + + if weaponDef.paralyzer ~= true then + + + if weaponDef.customParams then + + if weaponDef.customParams.sweepfire then + unitDefInfo[unitDefID].maxdps = (weaponDef.damages[0] * weaponDef.customParams.sweepfire) / math.max(weaponDef.minIntensity, 0.5) + unitDefInfo[unitDefID].mindps = weaponDef.damages[0] * weaponDef.customParams.sweepfire + else + addDPS(calculateLaserDPS(weaponDef, defDmg)) + end + else + addDPS(calculateLaserDPS(weaponDef, defDmg)) + end + else + -- calculate laser emp dmg + local minIntensity = math.max(weaponDef.minIntensity, 0.5) + local prevMinDps = unitDefInfo[unitDefID].minemp or 0 + local prevMaxDps = unitDefInfo[unitDefID].maxemp or 0 + local mindps = minIntensity*(weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload) + local maxdps = weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload + + unitDefInfo[unitDefID].minemp = mindps + prevMinDps + unitDefInfo[unitDefID].maxemp = maxdps + prevMaxDps + end + elseif weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then -- exclude thor emp missile + local defDmg = weaponDef.damages[0] --Damage to default armor category + local emp = math_floor(defDmg * weaponDef.salvoSize / weaponDef.reload) + unitDefInfo[unitDefID].minemp = emp + unitDefInfo[unitDefID].maxemp = emp unitDefInfo[unitDefID].range = weaponDef.range unitDefInfo[unitDefID].reloadTime = weaponDef.reload end + if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not unitExempt then + + local defDmg + + if weapons[1].onlyTargets['vtol'] ~= nil then --if main weapon isn't dedicated aa, then all weapons calculate using default armor category + defDmg = weaponDef.damages[armorIndex.vtol] + else + defDmg = weaponDef.damages[0] + end + + if(defDmg > 0) then + addDPS(calculateWeaponDPS(weaponDef, defDmg)) + setEnergyAndMetalCosts(weaponDef) + end + end end end for i = 1, #weapons do if not unitDefInfo[unitDefID].weapons then unitDefInfo[unitDefID].weapons = {} - dpsState.mindps = 0 - dpsState.maxdps = 0 + unitDefInfo[unitDefID].mindps = 0 + unitDefInfo[unitDefID].maxdps = 0 unitDefInfo[unitDefID].range = 0 unitDefInfo[unitDefID].reloadTime = 0 unitDefInfo[unitDefID].mainWeapon = 0 end local weaponDef = WeaponDefs[weapons[i].weaponDef] + -- Only groups 0 [always active] and 1 [primary weapon set] are aggregated. + -- Others might be checked for abilities still, e.g. antinuke interceptors. if showWeaponGroups[weaponDef.customParams.weapons_group] and weaponDef.customParams.bogus ~= "1" then refreshUnitWeaponInfo(i, weaponDef, weaponDef.customParams.weapons_role ~= "secondary") end end if unitDefInfo[unitDefID].mainWeapon == 0 then - unitDefInfo[unitDefID].mainWeapon = 1 + unitDefInfo[unitDefID].mainWeapon = 1 -- All the unit's weapons were fakes. end - UnitDefDPS.applyDeathExplosion(dpsState, unitDef) - if dpsState.mindps then - unitDefInfo[unitDefID].mindps = dpsState.mindps - end - if dpsState.maxdps then - unitDefInfo[unitDefID].maxdps = dpsState.maxdps - end - if dpsState.minemp then - unitDefInfo[unitDefID].minemp = dpsState.minemp - end - if dpsState.maxemp then - unitDefInfo[unitDefID].maxemp = dpsState.maxemp - end if unitDef.customParams.unitgroup and unitDef.customParams.unitgroup == 'explo' and unitDef.deathExplosion and WeaponDefNames[unitDef.deathExplosion] then - unitDefInfo[unitDefID].reloadTime = nil + local weapon = WeaponDefs[WeaponDefNames[unitDef.deathExplosion].id] + if weapon then + local dmg = weapon.damages[Game.armorTypes["default"]] + unitDefInfo[unitDefID].mindps = dmg + unitDefInfo[unitDefID].maxdps = dmg + unitDefInfo[unitDefID].reloadTime = nil + end end end - UnitDefDPS.applyCarriedUnits(unitDefInfo) - UnitDefDPS.floorValues(unitDefInfo) + -- Account for sub-unit damages, namely carriers and drones. + local mins = { "mindps", "minemp", } + local maxs = { "maxdps", "maxemp", } + for unitDefID, unitDef in pairs(UnitDefs) do + local unitInfo = unitDefInfo[unitDefID] + for index, weapon in ipairs(unitDef.weapons) do + local weaponDef = WeaponDefs[weapon.weaponDef] + if weaponDef.customParams.carried_unit and UnitDefNames[weaponDef.customParams.carried_unit] then + local droneCount = weaponDef.customParams.maxunits or 1 + local droneDef = UnitDefNames[weaponDef.customParams.carried_unit] + local droneInfo = unitDefInfo[droneDef.id] + + for _, key in ipairs(mins) do + if droneInfo[key] then + unitInfo[key] = (unitInfo[key] or 0) -- times zero drones == zero + end + end + + for _, key in ipairs(maxs) do + if droneInfo[key] then + unitInfo[key] = (unitInfo[key] or 0) + (droneInfo[key] * droneCount) + end + end + end + end + end + + -- Convert aggregated values to display formats last to avoid rounding errors. + local summedKeys = { "mindps", "maxdps", "minemp", "maxemp", } + for unitDefID, unitInfo in pairs(unitDefInfo) do + for _, key in pairs(summedKeys) do + if type(unitInfo[key]) == "number" then + unitInfo[key] = math_floor(unitInfo[key]) + end + end + end end local groups, unitGroup = {}, {} -- retrieves from buildmenu in initialize @@ -2331,4 +2505,4 @@ function widget:SetConfigData(data) if data.alwaysShow ~= nil then alwaysShow = data.alwaysShow end -end +end \ No newline at end of file diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index c3b044b64f0..5ba45fa8bf5 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -348,7 +348,7 @@ local function getUserFirestateCommand(command, unitID) local stateName local filledStateCount if Firestates.isUserFacing(userFirestate) then - displayIndex = Firestates.userFacingDisplayIndex(userFirestate) + displayIndex = Firestates.displayIndex(userFirestate) if userFirestate == Firestates.DEFEND then stateName = "Defend" elseif userFirestate == Firestates.PASSIVE then @@ -1338,7 +1338,7 @@ function widget:MousePress(x, y, button) Spring.PlaySoundFile(soundButton, 0.6, 'ui') end if cmd.id == CMD_FIRE_STATE and Firestates.isDefendFirestateEnabled() and cmd.userFacingFirestate and clickedCellDesiredState then - local nextFirestate = Firestates.userFacingStateFromDisplayIndex(clickedCellDesiredState) + local nextFirestate = Firestates.stateFromDisplayIndex(clickedCellDesiredState) spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { nextFirestate }, 0) elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then Spring.SetActiveCommand(Spring.GetCmdDescIndex(cmd.id), button, true, false, Spring.GetModKeyState()) diff --git a/modules/firestates.lua b/modules/firestates.lua index bf5e57a098a..2d496e2aec8 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -13,13 +13,13 @@ local firestates = { ENGINE_FIRE_AT_ALL = 3, } -local userFacingStateByDisplayIndex = { +local stateByDisplayIndex = { [0] = firestates.PASSIVE, [1] = firestates.DEFEND, [2] = firestates.AGGRESSIVE, } -local displayIndexByUserFacingState = { +local displayIndexByState = { [firestates.PASSIVE] = 0, [firestates.DEFEND] = 1, [firestates.AGGRESSIVE] = 2, @@ -41,37 +41,22 @@ local stateByEngineFirestate = { } function firestates.isUserFacing(state) - return displayIndexByUserFacingState[state] ~= nil + return displayIndexByState[state] ~= nil end -function firestates.userFacingDisplayIndex(state) - return displayIndexByUserFacingState[state] +function firestates.displayIndex(state) + return displayIndexByState[state] end -function firestates.userFacingStateFromDisplayIndex(displayIndex) - return userFacingStateByDisplayIndex[displayIndex] +function firestates.stateFromDisplayIndex(displayIndex) + return stateByDisplayIndex[displayIndex] end -function firestates.nextUserFacing(state, direction) - local displayIndex = displayIndexByUserFacingState[state] - if not displayIndex then - displayIndex = 0 - end - direction = direction or 1 - displayIndex = displayIndex + direction - if displayIndex > 2 then - displayIndex = 0 - elseif displayIndex < 0 then - displayIndex = 2 - end - return userFacingStateByDisplayIndex[displayIndex] -end - -function firestates.engineFirestateFor(state) +function firestates.toEngineFirestate(state) return engineFirestateByState[state] end -function firestates.logicalFromEngineFirestate(engineFirestate) +function firestates.fromEngineFirestate(engineFirestate) return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE end diff --git a/modules/unitdefdps.lua b/modules/unitdefdps.lua deleted file mode 100644 index e5799c82d72..00000000000 --- a/modules/unitdefdps.lua +++ /dev/null @@ -1,324 +0,0 @@ -local math_floor = math.floor -local math_min = math.min -local math_max = math.max - -local DEFAULT_WEAPON_GROUPS = { ["0"] = true, ["1"] = true } - -local armorIndex - -local function getArmorIndex() - if not armorIndex then - armorIndex = {} - for ii = 1, #Game.armorTypes do - armorIndex[Game.armorTypes[ii]] = ii - end - end - return armorIndex -end - -local function calculateLaserDPS(def, damage) - local minIntensity = math_max(def.minIntensity, 0.5) - local mindps = minIntensity * (damage * def.salvoSize / def.reload) - local maxdps = damage * def.salvoSize / def.reload - return mindps, maxdps -end - -local function calculateWeaponDPS(def, damage) - local reloadDPS = damage * (def.salvoSize * def.projectiles) / def.reload - local stockpileDPS = damage * (def.salvoSize * def.projectiles) / (def.stockpile and def.stockpileTime / 30 or def.reload) - return math_min(reloadDPS, stockpileDPS), math_max(reloadDPS, stockpileDPS) -end - -local function calculateClusterDPS(unitDef, def, damage) - local munition = unitDef.name .. '_' .. def.customParams.cluster_def - local cmNumber = def.customParams.cluster_number - local cmDamage = WeaponDefNames[munition].damages[0] - - local mainDps = (def.salvoSize * def.projectiles) / def.reload * damage - local cmunDps = (def.salvoSize * def.projectiles) / def.reload * (cmNumber * cmDamage) - return mainDps, mainDps + cmunDps -end - -local function calculateAreaDPS(def, damage) - local burst = def.salvoSize * def.projectiles - local impactDps = damage * burst / def.reload - local areaDps = def.customParams.area_onhit_damage - local damageMax = math_max(impactDps + areaDps, areaDps * burst * def.customParams.area_onhit_time / def.reload) - return impactDps, damageMax -end - -local function addPrimaryDPS(state, minDPS, maxDPS) - state.mindps = (state.mindps or 0) + minDPS - state.maxdps = (state.maxdps or 0) + maxDPS -end - -local function addSecondaryDPS(state, minDPS, maxDPS) - state.maxdps = (state.maxdps or 0) + maxDPS -end - -local function processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon, armorTypes) - local addDPS = isPrimaryWeapon and addPrimaryDPS or addSecondaryDPS - - if weaponDef.interceptor ~= 0 and weaponDef.coverageRange then - return - end - - if weaponDef.shieldRadius and weaponDef.shieldRadius > 0 then - return - end - - if unitDef.name == 'armamb' or unitDef.name == 'cortoast' then - state.unitExempt = true - if weaponIndex == 1 then - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - end - - elseif - unitDef.customParams.evocomlvl or - unitDef.name == 'armcom' or - unitDef.name == 'corcom' or - unitDef.name == 'legcom' or - unitDef.name == 'corkarg' or - unitDef.name == 'armguard' or - unitDef.name == 'corpun' or - unitDef.name == 'legcluster' or - unitDef.name == 'leglob' or - unitDef.name == 'legnavyfrigate' or - unitDef.name == 'armamb' or - unitDef.name == 'cortoast' or - unitDef.name == 'armvang' - then - state.unitExempt = true - if weaponIndex == 1 then - if weaponDef.type == "BeamLaser" then - addDPS(state, calculateLaserDPS(weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.cluster then - addDPS(state, calculateClusterDPS(unitDef, weaponDef, weaponDef.damages[0])) - elseif weapons[weaponIndex].onlyTargets['vtol'] ~= nil then - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[armorTypes.vtol])) - else - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - end - end - - elseif unitDef.name == 'corkorg' then - state.unitExempt = true - if weaponIndex == 1 then - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - end - if weaponIndex == 2 then - addDPS(state, calculateLaserDPS(weaponDef, weaponDef.damages[0])) - end - if weaponIndex == 3 then - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - end - - elseif weaponDef.customParams.area_onhit_damage and weaponDef.customParams.area_onhit_time then - state.unitExempt = true - addDPS(state, calculateAreaDPS(weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.cluster then - state.unitExempt = true - addDPS(state, calculateClusterDPS(unitDef, weaponDef, weaponDef.damages[0])) - elseif weaponDef.customParams.speceffect == "split" then - state.unitExempt = true - local splitd = WeaponDefNames[weaponDef.customParams.speceffect_def].damages[0] - local splitn = weaponDef.customParams.number or 1 - addDPS(state, calculateWeaponDPS(weaponDef, splitd * splitn)) - elseif weaponDef.customParams.spark_forkdamage then - state.unitExempt = true - addDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0])) - local forkDamageRate = weaponDef.customParams.spark_forkdamage - addSecondaryDPS(state, calculateWeaponDPS(weaponDef, weaponDef.damages[0] * forkDamageRate)) - if state.unitExempt and weaponDef.paralyzer then - state.minemp = state.mindps - state.maxemp = state.maxdps - state.mindps = nil - state.maxdps = nil - end - end - - if weaponDef.type == "BeamLaser" and not state.unitExempt then - local defDmg - if weapons[1].onlyTargets['vtol'] ~= nil then - defDmg = weaponDef.damages[armorTypes.vtol] - else - defDmg = weaponDef.damages[0] - end - - if weaponDef.paralyzer ~= true then - if weaponDef.customParams then - if weaponDef.customParams.sweepfire then - state.maxdps = (weaponDef.damages[0] * weaponDef.customParams.sweepfire) / math_max(weaponDef.minIntensity, 0.5) - state.mindps = weaponDef.damages[0] * weaponDef.customParams.sweepfire - else - addDPS(state, calculateLaserDPS(weaponDef, defDmg)) - end - else - addDPS(state, calculateLaserDPS(weaponDef, defDmg)) - end - else - local minIntensity = math_max(weaponDef.minIntensity, 0.5) - local prevMinDps = state.minemp or 0 - local prevMaxDps = state.maxemp or 0 - local mindps = minIntensity * (weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload) - local maxdps = weaponDef.damages[0] * weaponDef.salvoSize / weaponDef.reload - state.minemp = mindps + prevMinDps - state.maxemp = maxdps + prevMaxDps - end - elseif weaponDef.paralyzer == true and unitDef.name ~= 'armthor' then - local defDmg = weaponDef.damages[0] - local emp = math_floor(defDmg * weaponDef.salvoSize / weaponDef.reload) - state.minemp = emp - state.maxemp = emp - end - - if weaponDef.type ~= "BeamLaser" and weaponDef.paralyzer ~= true and not state.unitExempt then - local defDmg - if weapons[1].onlyTargets['vtol'] ~= nil then - defDmg = weaponDef.damages[armorTypes.vtol] - else - defDmg = weaponDef.damages[0] - end - - if defDmg > 0 then - addDPS(state, calculateWeaponDPS(weaponDef, defDmg)) - end - end -end - -local function applyDeathExplosion(state, unitDef) - if unitDef.customParams.unitgroup and unitDef.customParams.unitgroup == 'explo' and unitDef.deathExplosion and WeaponDefNames[unitDef.deathExplosion] then - local weapon = WeaponDefs[WeaponDefNames[unitDef.deathExplosion].id] - if weapon then - local dmg = weapon.damages[Game.armorTypes["default"]] - state.mindps = dmg - state.maxdps = dmg - end - end -end - -local function calculateUnitDefDPS(unitDef, showWeaponGroups) - local armorTypes = getArmorIndex() - local state = { - mindps = nil, - maxdps = nil, - minemp = nil, - maxemp = nil, - unitExempt = false, - } - local weapons = unitDef.weapons - local hasQualifyingWeapons = false - - for weaponIndex = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponIndex].weaponDef] - if showWeaponGroups[weaponDef.customParams.weapons_group] and weaponDef.customParams.bogus ~= "1" then - if not hasQualifyingWeapons then - state.mindps = 0 - state.maxdps = 0 - hasQualifyingWeapons = true - end - processWeapon( - state, - unitDef, - weapons, - weaponIndex, - weaponDef, - weaponDef.customParams.weapons_role ~= "secondary", - armorTypes - ) - end - end - - applyDeathExplosion(state, unitDef) - return state -end - -local function applyCarriedUnits(unitDefInfoTable) - local mins = { "mindps", "minemp" } - local maxs = { "maxdps", "maxemp" } - for unitDefID, unitDef in pairs(UnitDefs) do - local unitInfo = unitDefInfoTable[unitDefID] - if unitInfo then - for _, weapon in ipairs(unitDef.weapons) do - local weaponDef = WeaponDefs[weapon.weaponDef] - if weaponDef.customParams.carried_unit and UnitDefNames[weaponDef.customParams.carried_unit] then - local droneCount = weaponDef.customParams.maxunits or 1 - local droneDef = UnitDefNames[weaponDef.customParams.carried_unit] - local droneInfo = unitDefInfoTable[droneDef.id] - - if droneInfo then - for _, key in ipairs(mins) do - if droneInfo[key] then - unitInfo[key] = (unitInfo[key] or 0) - end - end - - for _, key in ipairs(maxs) do - if droneInfo[key] then - unitInfo[key] = (unitInfo[key] or 0) + (droneInfo[key] * droneCount) - end - end - end - end - end - end - end -end - -local function floorValues(unitDefInfoTable) - local summedKeys = { "mindps", "maxdps", "minemp", "maxemp" } - for _, unitInfo in pairs(unitDefInfoTable) do - for _, key in pairs(summedKeys) do - if type(unitInfo[key]) == "number" then - unitInfo[key] = math_floor(unitInfo[key]) - end - end - end -end - -local UnitDefDPS = {} - -UnitDefDPS.DEFAULT_WEAPON_GROUPS = DEFAULT_WEAPON_GROUPS - -function UnitDefDPS.newState() - return { - mindps = nil, - maxdps = nil, - minemp = nil, - maxemp = nil, - unitExempt = false, - } -end - -function UnitDefDPS.processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon) - processWeapon(state, unitDef, weapons, weaponIndex, weaponDef, isPrimaryWeapon, getArmorIndex()) -end - -function UnitDefDPS.applyDeathExplosion(state, unitDef) - applyDeathExplosion(state, unitDef) -end - -function UnitDefDPS.applyCarriedUnits(unitDefInfoTable) - applyCarriedUnits(unitDefInfoTable) -end - -function UnitDefDPS.floorValues(unitDefInfoTable) - floorValues(unitDefInfoTable) -end - -function UnitDefDPS.calculateAll(showWeaponGroups) - showWeaponGroups = showWeaponGroups or DEFAULT_WEAPON_GROUPS - local results = {} - for unitDefID, unitDef in pairs(UnitDefs) do - results[unitDefID] = calculateUnitDefDPS(unitDef, showWeaponGroups) - end - applyCarriedUnits(results) - floorValues(results) - return results -end - -function UnitDefDPS.getEffectiveDPS(unitDef, dpsState) - return dpsState.maxdps or dpsState.mindps or 0 -end - -return UnitDefDPS From aea5e3f750b523dc8b190e89cf563c257e1f6964 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Wed, 1 Jul 2026 21:50:51 -0500 Subject: [PATCH 29/38] more cleanup --- language/en/interface.json | 2 +- luaui/Widgets/cmd_user_firestate.lua | 5 ++--- luaui/Widgets/gui_info.lua | 2 +- luaui/Widgets/gui_ordermenu.lua | 9 +++++---- luaui/Widgets/gui_unit_firestate_icons.lua | 2 +- modules/firestates.lua | 5 ----- 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 9fba3756cbe..253f0022aa2 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -1683,7 +1683,7 @@ "unitidlebuildericons": "Unit idle builder icons", "unitidlebuildericons_descr": "Shows a sleep icon above builder units that are idle.", "unitfirestateicons": "Unit firing state icons", - "unitfirestateicons_descr": "Shows an icon above units that are set on Hold fire or Defend.", + "unitfirestateicons_descr": "Shows an icon above units that are either set on Hold fire or Defend.", "resurrectionhalos": "Resurrected unit halos", "resurrectionhalos_descr": "Adds halos above resurrected units.", "nametags_rank": "Commander name tag rank icon", diff --git a/luaui/Widgets/cmd_user_firestate.lua b/luaui/Widgets/cmd_user_firestate.lua index c9b987d6ce3..f83b897c288 100644 --- a/luaui/Widgets/cmd_user_firestate.lua +++ b/luaui/Widgets/cmd_user_firestate.lua @@ -20,8 +20,7 @@ end local CMD_FIRE_STATE = CMD.FIRE_STATE local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE local Firestates = VFS.Include("modules/firestates.lua") -local spGetSelectedUnits = Spring.GetSelectedUnits -local spGiveOrderToUnitArray = Spring.GiveOrderToUnitArray +local spGiveOrder = Spring.GiveOrder local userFirestateByEngineFirestate = { [Firestates.ENGINE_HOLD_FIRE] = Firestates.PASSIVE, @@ -37,6 +36,6 @@ function widget:CommandNotify(cmdID, cmdParams, cmdOptions) if userFirestate == nil then return false end - spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { userFirestate }, 0) + spGiveOrder(CMD_USER_FIRESTATE, { userFirestate }, cmdOptions) return true end diff --git a/luaui/Widgets/gui_info.lua b/luaui/Widgets/gui_info.lua index afae95c4dfe..4355f2512f3 100644 --- a/luaui/Widgets/gui_info.lua +++ b/luaui/Widgets/gui_info.lua @@ -2505,4 +2505,4 @@ function widget:SetConfigData(data) if data.alwaysShow ~= nil then alwaysShow = data.alwaysShow end -end \ No newline at end of file +end diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 5ba45fa8bf5..266356bf85d 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -19,11 +19,12 @@ local mathFloor = math.floor -- Localized Spring API for performance local spGetSelectedUnits = Spring.GetSelectedUnits +local spGetModOptions = Spring.GetModOptions local spGetGameFrame = Spring.GetGameFrame local spGetViewGeometry = Spring.GetViewGeometry local spGetSpectatingState = Spring.GetSpectatingState local spGetUnitRulesParam = Spring.GetUnitRulesParam -local spGiveOrderToUnitArray = Spring.GiveOrderToUnitArray +local spGiveOrder = Spring.GiveOrder local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") local Firestates = VFS.Include("modules/firestates.lua") @@ -410,7 +411,7 @@ local function refreshCommands() -- intentionally empty, no action to take elseif isStateCommand[command.id] then local patchedCommand = command - if command.id == CMD_FIRE_STATE and Firestates.isDefendFirestateEnabled() and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then + if command.id == CMD_FIRE_STATE and spGetModOptions().experimental_defend_firestate and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then patchedCommand = getUserFirestateCommand(command, cachedFirstUnit) end stateCommandsCount = stateCommandsCount + 1 @@ -1337,9 +1338,9 @@ function widget:MousePress(x, y, button) if playSounds then Spring.PlaySoundFile(soundButton, 0.6, 'ui') end - if cmd.id == CMD_FIRE_STATE and Firestates.isDefendFirestateEnabled() and cmd.userFacingFirestate and clickedCellDesiredState then + if cmd.id == CMD_FIRE_STATE and spGetModOptions().experimental_defend_firestate and cmd.userFacingFirestate and clickedCellDesiredState then local nextFirestate = Firestates.stateFromDisplayIndex(clickedCellDesiredState) - spGiveOrderToUnitArray(spGetSelectedUnits(), CMD_USER_FIRESTATE, { nextFirestate }, 0) + spGiveOrder(CMD_USER_FIRESTATE, { nextFirestate }, 0) elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then Spring.SetActiveCommand(Spring.GetCmdDescIndex(cmd.id), button, true, false, Spring.GetModKeyState()) end diff --git a/luaui/Widgets/gui_unit_firestate_icons.lua b/luaui/Widgets/gui_unit_firestate_icons.lua index 83f4eefd054..6500c8ec76a 100644 --- a/luaui/Widgets/gui_unit_firestate_icons.lua +++ b/luaui/Widgets/gui_unit_firestate_icons.lua @@ -139,7 +139,7 @@ end local function getDisplayFireState(unitID) --DEFEND FIRESTATE REWORK: Remove engine fallback; always use user_firestate rules param - if not Firestates.isDefendFirestateEnabled() then + if not Spring.GetModOptions().experimental_defend_firestate then return select(1, Spring.GetUnitStates(unitID, false)) end local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) diff --git a/modules/firestates.lua b/modules/firestates.lua index 2d496e2aec8..aa11fc155ad 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -60,9 +60,4 @@ function firestates.fromEngineFirestate(engineFirestate) return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE end ---DEFEND FIRESTATE REWORK: Remove isDefendFirestateEnabled(); assume Defend mode always on -function firestates.isDefendFirestateEnabled() - return Spring.GetModOptions().experimental_defend_firestate -end - return firestates From 01c39142946e402c592a762ed1f9362a08a69634 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Thu, 2 Jul 2026 19:06:01 -0500 Subject: [PATCH 30/38] unhallucinate gadgets.lua --- luarules/gadgets.lua | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua index 1e1923037f1..1c436d940f6 100644 --- a/luarules/gadgets.lua +++ b/luarules/gadgets.lua @@ -1710,7 +1710,14 @@ return ((ignore and -1) or 1) end function gadgetHandler:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - local targetPriority = defPriority or 1.0 + -- Calls with no input priority are pure pass/fail tests. + -- These are common, and BAR never disallows any of them. + -- if not defPriority then + -- return true -- The second return value is never used. + -- end + + local allowed = true + local priority = 1.0 if targetID == -1 and attackerWeaponNum == -1 then -- The `targetPriority` return value is actually the autotarget search radius, @@ -1718,19 +1725,15 @@ function gadgetHandler:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum for _, g in ipairs(self.UnitAutoTargetRangeList) do defPriority = g:UnitAutoTargetRange(attackerID, defPriority) end - return defPriority > 0, defPriority + + allowed, priority = defPriority > 0, defPriority else + -- The actual callin. BAR only uses AllowWeaponTarget for the target priority. for _, g in ipairs(self.AllowWeaponTargetList) do - local gadgetTargetAllowed, gadgetTargetPriority = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) - if type(gadgetTargetPriority) == "number" then - targetPriority = gadgetTargetPriority - end - if not gadgetTargetAllowed then - return false, targetPriority - end + allowed, priority = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) end - return true, targetPriority end + return allowed, priority end function gadgetHandler:UnitAutoTargetRange(attackerID, autoTargetRange) From 3d3293ae31f4d4448d4be79c59ccb9004b32676b Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Thu, 2 Jul 2026 19:20:36 -0500 Subject: [PATCH 31/38] reduce direct alterations of gui_ordermenu.lua --- luaui/Widgets/gui_ordermenu.lua | 63 +++++---------------------------- modules/firestates.lua | 24 +++++++++++++ 2 files changed, 32 insertions(+), 55 deletions(-) diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 266356bf85d..73444663c5e 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -19,12 +19,9 @@ local mathFloor = math.floor -- Localized Spring API for performance local spGetSelectedUnits = Spring.GetSelectedUnits -local spGetModOptions = Spring.GetModOptions local spGetGameFrame = Spring.GetGameFrame local spGetViewGeometry = Spring.GetViewGeometry local spGetSpectatingState = Spring.GetSpectatingState -local spGetUnitRulesParam = Spring.GetUnitRulesParam -local spGiveOrder = Spring.GiveOrder local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") local Firestates = VFS.Include("modules/firestates.lua") @@ -161,7 +158,6 @@ local lastColorize = -1 local printTextCache = {} local CANCEL_TARGET_CMD_ID = 34924 -- UNIT_CANCEL_TARGET -local CMD_FIRE_STATE = CMD.FIRE_STATE local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE local hiddenCommands = { @@ -342,45 +338,6 @@ local function computeWaitState() end end ---DEFEND FIRESTATE REWORK: Remove modoption guard; always patch firestate labels to Defend -local function getUserFirestateCommand(command, unitID) - local userFirestate = spGetUnitRulesParam(unitID, Firestates.RULES_PARAM) - local displayIndex - local stateName - local filledStateCount - if Firestates.isUserFacing(userFirestate) then - displayIndex = Firestates.displayIndex(userFirestate) - if userFirestate == Firestates.DEFEND then - stateName = "Defend" - elseif userFirestate == Firestates.PASSIVE then - stateName = "Hold fire" - else - stateName = "Fire at will" - end - elseif userFirestate == Firestates.RETURN_FIRE then - displayIndex = 1 - stateName = "Defend" - filledStateCount = 2 - elseif userFirestate == Firestates.FIRE_AT_ALL then - displayIndex = 2 - stateName = "Fire at all" - filledStateCount = 3 - else - return command - end - local patchedCommand = table.copy(command) - patchedCommand.params = { - displayIndex, - "Hold fire", - stateName, - stateName, - } - patchedCommand.userFirestate = userFirestate - patchedCommand.userFacingFirestate = Firestates.isUserFacing(userFirestate) - patchedCommand.filledStateCount = filledStateCount - return patchedCommand -end - local function refreshCommands() local waitCommand -- Clear and reuse temp tables instead of creating new ones @@ -410,12 +367,12 @@ local function refreshCommands() if command.type == CMDTYPE_ICON_BUILDING or (string.find(command.action, 'buildunit_', 1, true) == 1) then -- intentionally empty, no action to take elseif isStateCommand[command.id] then - local patchedCommand = command - if command.id == CMD_FIRE_STATE and spGetModOptions().experimental_defend_firestate and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then - patchedCommand = getUserFirestateCommand(command, cachedFirstUnit) - end stateCommandsCount = stateCommandsCount + 1 - stateCommandsTemp[stateCommandsCount] = patchedCommand + if command.id == CMD.FIRE_STATE and Spring.GetModOptions().experimental_defend_firestate and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then + stateCommandsTemp[stateCommandsCount] = Firestates.orderMenuCmdDesc(command, Spring.GetUnitRulesParam(cachedFirstUnit, Firestates.RULES_PARAM)) or command + else + stateCommandsTemp[stateCommandsCount] = command + end elseif command.id == CMD.WAIT then waitCommandCount = 1 waitCommand = command @@ -1013,9 +970,6 @@ local function drawCell(cell, zoom) if isStateCommand[cmd.id] then statecount = #cmd.params - 1 --number of states for the cmd curstate = cmd.params[1] + 1 - if cmd.filledStateCount then - curstate = cmd.filledStateCount - end else statecount = 2 curstate = cachedWaitState @@ -1035,7 +989,7 @@ local function drawCell(cell, zoom) local glowSize = math_floor(stateHeight * 8) local r, g, b, a = 0, 0, 0, 0 for i = 1, statecount do - if i == curstate or i == desiredState or (cmd.filledStateCount and i <= cmd.filledStateCount) then + if i == curstate or i == desiredState then if i == 1 then r, g, b, a = 1, 0.1, 0.1, (i == desiredState and 0.33 or 0.8) elseif i == 2 then @@ -1338,9 +1292,8 @@ function widget:MousePress(x, y, button) if playSounds then Spring.PlaySoundFile(soundButton, 0.6, 'ui') end - if cmd.id == CMD_FIRE_STATE and spGetModOptions().experimental_defend_firestate and cmd.userFacingFirestate and clickedCellDesiredState then - local nextFirestate = Firestates.stateFromDisplayIndex(clickedCellDesiredState) - spGiveOrder(CMD_USER_FIRESTATE, { nextFirestate }, 0) + if cmd.id == CMD.FIRE_STATE and Spring.GetModOptions().experimental_defend_firestate and clickedCellDesiredState then + Spring.GiveOrder(CMD_USER_FIRESTATE, { Firestates.stateFromDisplayIndex(clickedCellDesiredState) }, 0) elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then Spring.SetActiveCommand(Spring.GetCmdDescIndex(cmd.id), button, true, false, Spring.GetModKeyState()) end diff --git a/modules/firestates.lua b/modules/firestates.lua index aa11fc155ad..35cb207b4ae 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -60,4 +60,28 @@ function firestates.fromEngineFirestate(engineFirestate) return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE end +function firestates.orderMenuCmdDesc(command, userFirestate) + if userFirestate == nil then + return nil + end + local displayIndex = displayIndexByState[userFirestate] + if displayIndex == nil then + if userFirestate == firestates.RETURN_FIRE then + displayIndex = 1 + elseif userFirestate == firestates.FIRE_AT_ALL then + displayIndex = 2 + else + return nil + end + end + local cmdDesc = table.copy(command) + cmdDesc.params = { + displayIndex, + "Hold fire", + "Defend", + userFirestate == firestates.FIRE_AT_ALL and "Fire at all" or "Fire at will", + } + return cmdDesc +end + return firestates From 66b5ba121cf10e0388e40b7479d98200636bc013 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Thu, 2 Jul 2026 19:26:21 -0500 Subject: [PATCH 32/38] revert canReclaim changes to factories, will be separate PR --- units/ArmBuildings/LandFactories/armaap.lua | 1 - units/ArmBuildings/LandFactories/armalab.lua | 1 - units/ArmBuildings/LandFactories/armap.lua | 1 - units/ArmBuildings/LandFactories/armavp.lua | 1 - units/ArmBuildings/LandFactories/armhaap.lua | 1 - units/ArmBuildings/LandFactories/armhaapuw.lua | 1 - units/ArmBuildings/LandFactories/armhalab.lua | 1 - units/ArmBuildings/LandFactories/armhavp.lua | 1 - units/ArmBuildings/LandFactories/armhp.lua | 1 - units/ArmBuildings/LandFactories/armlab.lua | 1 - units/ArmBuildings/LandFactories/armsaap.lua | 1 - units/ArmBuildings/LandFactories/armsalab.lua | 1 - units/ArmBuildings/LandFactories/armsavp.lua | 1 - units/ArmBuildings/LandFactories/armshltx.lua | 1 - units/ArmBuildings/LandFactories/armvp.lua | 1 - units/ArmBuildings/SeaFactories/armamsub.lua | 1 - units/ArmBuildings/SeaFactories/armasy.lua | 1 - units/ArmBuildings/SeaFactories/armfhp.lua | 1 - units/ArmBuildings/SeaFactories/armhasy.lua | 1 - units/ArmBuildings/SeaFactories/armplat.lua | 1 - units/ArmBuildings/SeaFactories/armsasy.lua | 1 - units/ArmBuildings/SeaFactories/armshltxuw.lua | 1 - units/ArmBuildings/SeaFactories/armsy.lua | 1 - units/CorBuildings/LandFactories/coraap.lua | 1 - units/CorBuildings/LandFactories/coralab.lua | 1 - units/CorBuildings/LandFactories/corap.lua | 1 - units/CorBuildings/LandFactories/coravp.lua | 1 - units/CorBuildings/LandFactories/corgant.lua | 1 - units/CorBuildings/LandFactories/corhaap.lua | 1 - units/CorBuildings/LandFactories/corhaapuw.lua | 1 - units/CorBuildings/LandFactories/corhalab.lua | 1 - units/CorBuildings/LandFactories/corhavp.lua | 1 - units/CorBuildings/LandFactories/corhp.lua | 1 - units/CorBuildings/LandFactories/corlab.lua | 1 - units/CorBuildings/LandFactories/corsaap.lua | 1 - units/CorBuildings/LandFactories/corsalab.lua | 1 - units/CorBuildings/LandFactories/corsavp.lua | 1 - units/CorBuildings/LandFactories/corvp.lua | 1 - units/CorBuildings/LandFactories/leghaap.lua | 1 - units/CorBuildings/SeaFactories/coramsub.lua | 1 - units/CorBuildings/SeaFactories/corasy.lua | 1 - units/CorBuildings/SeaFactories/corfhp.lua | 1 - units/CorBuildings/SeaFactories/corgantuw.lua | 1 - units/CorBuildings/SeaFactories/corhasy.lua | 1 - units/CorBuildings/SeaFactories/corplat.lua | 1 - units/CorBuildings/SeaFactories/corsasy.lua | 1 - units/CorBuildings/SeaFactories/corsy.lua | 1 - units/Legion/Labs/legaap.lua | 1 - units/Legion/Labs/legadvshipyard.lua | 1 - units/Legion/Labs/legalab.lua | 1 - units/Legion/Labs/legamphlab.lua | 1 - units/Legion/Labs/legap.lua | 1 - units/Legion/Labs/legavp.lua | 1 - units/Legion/Labs/legfhp.lua | 1 - units/Legion/Labs/leggant.lua | 1 - units/Legion/Labs/leggantuw.lua | 1 - units/Legion/Labs/leghalab.lua | 1 - units/Legion/Labs/leghavp.lua | 1 - units/Legion/Labs/leghp.lua | 1 - units/Legion/Labs/leglab.lua | 1 - units/Legion/Labs/legsplab.lua | 1 - units/Legion/Labs/legsy.lua | 1 - units/Legion/Labs/legvp.lua | 1 - units/Scavengers/Buildings/Factories/armapt3.lua | 1 - units/Scavengers/Buildings/Factories/corapt3.lua | 1 - units/Scavengers/Buildings/Factories/legapt3.lua | 1 - 66 files changed, 66 deletions(-) diff --git a/units/ArmBuildings/LandFactories/armaap.lua b/units/ArmBuildings/LandFactories/armaap.lua index b64e657330f..1cd9d75e4c6 100644 --- a/units/ArmBuildings/LandFactories/armaap.lua +++ b/units/ArmBuildings/LandFactories/armaap.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMAAP.DDS", buildtime = 32000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armalab.lua b/units/ArmBuildings/LandFactories/armalab.lua index 2b7a9f5f190..61098dbe623 100644 --- a/units/ArmBuildings/LandFactories/armalab.lua +++ b/units/ArmBuildings/LandFactories/armalab.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMALAB.DDS", buildtime = 25000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "124 75 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armap.lua b/units/ArmBuildings/LandFactories/armap.lua index 317845e5630..9888046ea8c 100644 --- a/units/ArmBuildings/LandFactories/armap.lua +++ b/units/ArmBuildings/LandFactories/armap.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMAP.DDS", buildtime = 5450, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "134 49 82", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armavp.lua b/units/ArmBuildings/LandFactories/armavp.lua index 52a119eca1e..20ee1baa2db 100644 --- a/units/ArmBuildings/LandFactories/armavp.lua +++ b/units/ArmBuildings/LandFactories/armavp.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMAVP.DDS", buildtime = 27000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "130 86 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhaap.lua b/units/ArmBuildings/LandFactories/armhaap.lua index 9662b78d807..5d3edc287b7 100644 --- a/units/ArmBuildings/LandFactories/armhaap.lua +++ b/units/ArmBuildings/LandFactories/armhaap.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMAAP.DDS", buildtime = 85000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhaapuw.lua b/units/ArmBuildings/LandFactories/armhaapuw.lua index df4ba2f9684..447f6bf23fd 100644 --- a/units/ArmBuildings/LandFactories/armhaapuw.lua +++ b/units/ArmBuildings/LandFactories/armhaapuw.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMPLAT.DDS", buildtime = 42000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhalab.lua b/units/ArmBuildings/LandFactories/armhalab.lua index abaa0c2a4c8..63a967a1043 100644 --- a/units/ArmBuildings/LandFactories/armhalab.lua +++ b/units/ArmBuildings/LandFactories/armhalab.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMSHLTX.DDS", buildtime = 85000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhavp.lua b/units/ArmBuildings/LandFactories/armhavp.lua index a73bf48c44c..db636fd5e04 100644 --- a/units/ArmBuildings/LandFactories/armhavp.lua +++ b/units/ArmBuildings/LandFactories/armhavp.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMHAVP.DDS", buildtime = 85000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armhp.lua b/units/ArmBuildings/LandFactories/armhp.lua index 218d1bcdefd..5896ba18be2 100644 --- a/units/ArmBuildings/LandFactories/armhp.lua +++ b/units/ArmBuildings/LandFactories/armhp.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumescales = "100 40 90", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/LandFactories/armlab.lua b/units/ArmBuildings/LandFactories/armlab.lua index e2738bff512..b4f6bd4a1a7 100644 --- a/units/ArmBuildings/LandFactories/armlab.lua +++ b/units/ArmBuildings/LandFactories/armlab.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMLAB.DDS", buildtime = 5000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 -1 0", collisionvolumescales = "84 22 84", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsaap.lua b/units/ArmBuildings/LandFactories/armsaap.lua index 95a4516755e..594dd4c1015 100644 --- a/units/ArmBuildings/LandFactories/armsaap.lua +++ b/units/ArmBuildings/LandFactories/armsaap.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGAAP.DDS", buildtime = 52000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsalab.lua b/units/ArmBuildings/LandFactories/armsalab.lua index cf711643481..2167eba1072 100644 --- a/units/ArmBuildings/LandFactories/armsalab.lua +++ b/units/ArmBuildings/LandFactories/armsalab.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGALAB.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "124 75 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armsavp.lua b/units/ArmBuildings/LandFactories/armsavp.lua index f1342af6a88..5e9a86f7c4b 100644 --- a/units/ArmBuildings/LandFactories/armsavp.lua +++ b/units/ArmBuildings/LandFactories/armsavp.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGAVP.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "130 86 140", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armshltx.lua b/units/ArmBuildings/LandFactories/armshltx.lua index 96acda78e2c..02de704f0e0 100644 --- a/units/ArmBuildings/LandFactories/armshltx.lua +++ b/units/ArmBuildings/LandFactories/armshltx.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMSHLTX.DDS", buildtime = 62000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/LandFactories/armvp.lua b/units/ArmBuildings/LandFactories/armvp.lua index 7abbbf39c32..eb5dd21cbd3 100644 --- a/units/ArmBuildings/LandFactories/armvp.lua +++ b/units/ArmBuildings/LandFactories/armvp.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMVP.DDS", buildtime = 5700, canmove = true, - canReclaim = false, collisionvolumescales = "120 34 92", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/SeaFactories/armamsub.lua b/units/ArmBuildings/SeaFactories/armamsub.lua index f6daa7558c4..7b85e1a6d66 100644 --- a/units/ArmBuildings/SeaFactories/armamsub.lua +++ b/units/ArmBuildings/SeaFactories/armamsub.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMAMSUB.DDS", buildtime = 11100, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 16 0", collisionvolumescales = "96 42 96", collisionvolumetype = "CylY", diff --git a/units/ArmBuildings/SeaFactories/armasy.lua b/units/ArmBuildings/SeaFactories/armasy.lua index 4c42aaa9ae3..b93c05e14c4 100644 --- a/units/ArmBuildings/SeaFactories/armasy.lua +++ b/units/ArmBuildings/SeaFactories/armasy.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMASY.DDS", buildtime = 24000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 -9 -2", collisionvolumescales = "192 60 192", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armfhp.lua b/units/ArmBuildings/SeaFactories/armfhp.lua index 87ad605e287..e12a0fad501 100644 --- a/units/ArmBuildings/SeaFactories/armfhp.lua +++ b/units/ArmBuildings/SeaFactories/armfhp.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMFHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumescales = "100 40 90", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmBuildings/SeaFactories/armhasy.lua b/units/ArmBuildings/SeaFactories/armhasy.lua index ffbf4ef8681..6461adb991f 100644 --- a/units/ArmBuildings/SeaFactories/armhasy.lua +++ b/units/ArmBuildings/SeaFactories/armhasy.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMSHLTXUW.DDS", buildtime = 85000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "256 80 221", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armplat.lua b/units/ArmBuildings/SeaFactories/armplat.lua index 0eeb4b46eb8..827e3ca0450 100644 --- a/units/ArmBuildings/SeaFactories/armplat.lua +++ b/units/ArmBuildings/SeaFactories/armplat.lua @@ -5,7 +5,6 @@ return { buildpic = "ARMPLAT.DDS", buildtime = 12000, canmove = true, - canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5000, diff --git a/units/ArmBuildings/SeaFactories/armsasy.lua b/units/ArmBuildings/SeaFactories/armsasy.lua index ef9d3c41bb8..514ae65b553 100644 --- a/units/ArmBuildings/SeaFactories/armsasy.lua +++ b/units/ArmBuildings/SeaFactories/armsasy.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMASY.DDS", buildtime = 30000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 -9 -2", collisionvolumescales = "192 60 192", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armshltxuw.lua b/units/ArmBuildings/SeaFactories/armshltxuw.lua index f523ff4f9ef..8f95dca1c00 100644 --- a/units/ArmBuildings/SeaFactories/armshltxuw.lua +++ b/units/ArmBuildings/SeaFactories/armshltxuw.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMSHLTXUW.DDS", buildtime = 62000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "180 120 166", collisionvolumetype = "Box", diff --git a/units/ArmBuildings/SeaFactories/armsy.lua b/units/ArmBuildings/SeaFactories/armsy.lua index b23019bbc17..ddc54eaaa6f 100644 --- a/units/ArmBuildings/SeaFactories/armsy.lua +++ b/units/ArmBuildings/SeaFactories/armsy.lua @@ -4,7 +4,6 @@ return { buildpic = "ARMSY.DDS", buildtime = 5160, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 16 0", collisionvolumescales = "90 60 90", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coraap.lua b/units/CorBuildings/LandFactories/coraap.lua index 39d810674ec..88817fad67d 100644 --- a/units/CorBuildings/LandFactories/coraap.lua +++ b/units/CorBuildings/LandFactories/coraap.lua @@ -5,7 +5,6 @@ return { buildpic = "CORAAP.DDS", buildtime = 32000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coralab.lua b/units/CorBuildings/LandFactories/coralab.lua index eb491b65feb..07529c65536 100644 --- a/units/CorBuildings/LandFactories/coralab.lua +++ b/units/CorBuildings/LandFactories/coralab.lua @@ -5,7 +5,6 @@ return { buildpic = "CORALAB.DDS", buildtime = 26000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 2 0", collisionvolumescales = "144 56 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corap.lua b/units/CorBuildings/LandFactories/corap.lua index 73004cde97f..dfd8fde2d07 100644 --- a/units/CorBuildings/LandFactories/corap.lua +++ b/units/CorBuildings/LandFactories/corap.lua @@ -5,7 +5,6 @@ return { buildpic = "CORAP.DDS", buildtime = 5380, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "128 33 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/coravp.lua b/units/CorBuildings/LandFactories/coravp.lua index 8195abf2806..93d76333104 100644 --- a/units/CorBuildings/LandFactories/coravp.lua +++ b/units/CorBuildings/LandFactories/coravp.lua @@ -5,7 +5,6 @@ return { buildpic = "CORAVP.DDS", buildtime = 28000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 8 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corgant.lua b/units/CorBuildings/LandFactories/corgant.lua index 7a01f45597a..fae9c5d6082 100644 --- a/units/CorBuildings/LandFactories/corgant.lua +++ b/units/CorBuildings/LandFactories/corgant.lua @@ -4,7 +4,6 @@ return { buildpic = "CORGANT.DDS", buildtime = 68000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhaap.lua b/units/CorBuildings/LandFactories/corhaap.lua index dfa009a9469..df8e9833613 100644 --- a/units/CorBuildings/LandFactories/corhaap.lua +++ b/units/CorBuildings/LandFactories/corhaap.lua @@ -5,7 +5,6 @@ return { buildpic = "CORAAP.DDS", buildtime = 92000, canmove = true, - canReclaim = false, corpse = "DEAD", energycost = 65000, energystorage = 1400, diff --git a/units/CorBuildings/LandFactories/corhaapuw.lua b/units/CorBuildings/LandFactories/corhaapuw.lua index aaacea4c678..84835ea4fcb 100644 --- a/units/CorBuildings/LandFactories/corhaapuw.lua +++ b/units/CorBuildings/LandFactories/corhaapuw.lua @@ -5,7 +5,6 @@ return { buildpic = "CORPLAT.DDS", buildtime = 42000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhalab.lua b/units/CorBuildings/LandFactories/corhalab.lua index 422f221f9bc..49e30c05313 100644 --- a/units/CorBuildings/LandFactories/corhalab.lua +++ b/units/CorBuildings/LandFactories/corhalab.lua @@ -4,7 +4,6 @@ return { buildpic = "CORGANT.DDS", buildtime = 92000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhavp.lua b/units/CorBuildings/LandFactories/corhavp.lua index cfed92bb6e5..ec07114a35c 100644 --- a/units/CorBuildings/LandFactories/corhavp.lua +++ b/units/CorBuildings/LandFactories/corhavp.lua @@ -4,7 +4,6 @@ return { buildpic = "CORHAVP.DDS", buildtime = 92000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corhp.lua b/units/CorBuildings/LandFactories/corhp.lua index 234d272b666..b248bb6f487 100644 --- a/units/CorBuildings/LandFactories/corhp.lua +++ b/units/CorBuildings/LandFactories/corhp.lua @@ -4,7 +4,6 @@ return { buildpic = "CORHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 32 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corlab.lua b/units/CorBuildings/LandFactories/corlab.lua index 177b79e9f8f..0cc697d50b0 100644 --- a/units/CorBuildings/LandFactories/corlab.lua +++ b/units/CorBuildings/LandFactories/corlab.lua @@ -5,7 +5,6 @@ return { buildpic = "CORLAB.DDS", buildtime = 5000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "93 92 87", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsaap.lua b/units/CorBuildings/LandFactories/corsaap.lua index 69a58bf68f4..a921ca0620e 100644 --- a/units/CorBuildings/LandFactories/corsaap.lua +++ b/units/CorBuildings/LandFactories/corsaap.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGAAP.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 4 0", collisionvolumescales = "142 64 142", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsalab.lua b/units/CorBuildings/LandFactories/corsalab.lua index 456e2d29d00..18a1c216cb8 100644 --- a/units/CorBuildings/LandFactories/corsalab.lua +++ b/units/CorBuildings/LandFactories/corsalab.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGALAB.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 2 0", collisionvolumescales = "144 56 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corsavp.lua b/units/CorBuildings/LandFactories/corsavp.lua index e6018e7c99c..71ebef76dca 100644 --- a/units/CorBuildings/LandFactories/corsavp.lua +++ b/units/CorBuildings/LandFactories/corsavp.lua @@ -5,7 +5,6 @@ return { buildpic = "LEGAVP.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 8 0", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/corvp.lua b/units/CorBuildings/LandFactories/corvp.lua index 302797c33a9..f41af1a853f 100644 --- a/units/CorBuildings/LandFactories/corvp.lua +++ b/units/CorBuildings/LandFactories/corvp.lua @@ -5,7 +5,6 @@ return { buildpic = "CORVP.DDS", buildtime = 5650, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 40 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/LandFactories/leghaap.lua b/units/CorBuildings/LandFactories/leghaap.lua index cc971107815..e996e4ce417 100644 --- a/units/CorBuildings/LandFactories/leghaap.lua +++ b/units/CorBuildings/LandFactories/leghaap.lua @@ -5,7 +5,6 @@ return { buildpic = "CORHAAP.DDS", buildtime = 92000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/coramsub.lua b/units/CorBuildings/SeaFactories/coramsub.lua index 0a9e6655def..33585a6aeff 100644 --- a/units/CorBuildings/SeaFactories/coramsub.lua +++ b/units/CorBuildings/SeaFactories/coramsub.lua @@ -4,7 +4,6 @@ return { buildpic = "CORAMSUB.DDS", buildtime = 11400, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 -6", collisionvolumescales = "96 30 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corasy.lua b/units/CorBuildings/SeaFactories/corasy.lua index d20aaee04ed..344dde288e0 100644 --- a/units/CorBuildings/SeaFactories/corasy.lua +++ b/units/CorBuildings/SeaFactories/corasy.lua @@ -4,7 +4,6 @@ return { buildpic = "CORASY.DDS", buildtime = 24000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corfhp.lua b/units/CorBuildings/SeaFactories/corfhp.lua index 1534ed16e3a..6947c84ad74 100644 --- a/units/CorBuildings/SeaFactories/corfhp.lua +++ b/units/CorBuildings/SeaFactories/corfhp.lua @@ -4,7 +4,6 @@ return { buildpic = "CORFHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "96 32 96", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corgantuw.lua b/units/CorBuildings/SeaFactories/corgantuw.lua index e522cb41914..5faf6a8a72f 100644 --- a/units/CorBuildings/SeaFactories/corgantuw.lua +++ b/units/CorBuildings/SeaFactories/corgantuw.lua @@ -4,7 +4,6 @@ return { buildpic = "CORGANTUW.DDS", buildtime = 68000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 110 196", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corhasy.lua b/units/CorBuildings/SeaFactories/corhasy.lua index bb6d98c1541..8038325e24a 100644 --- a/units/CorBuildings/SeaFactories/corhasy.lua +++ b/units/CorBuildings/SeaFactories/corhasy.lua @@ -4,7 +4,6 @@ return { buildpic = "CORGANTUW.DDS", buildtime = 92000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "260 146 260", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corplat.lua b/units/CorBuildings/SeaFactories/corplat.lua index 8686a94a024..74fbd345500 100644 --- a/units/CorBuildings/SeaFactories/corplat.lua +++ b/units/CorBuildings/SeaFactories/corplat.lua @@ -5,7 +5,6 @@ return { buildpic = "CORPLAT.DDS", buildtime = 11800, canmove = true, - canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5500, diff --git a/units/CorBuildings/SeaFactories/corsasy.lua b/units/CorBuildings/SeaFactories/corsasy.lua index 93fbdbf9072..47cf74bdc78 100644 --- a/units/CorBuildings/SeaFactories/corsasy.lua +++ b/units/CorBuildings/SeaFactories/corsasy.lua @@ -4,7 +4,6 @@ return { buildpic = "CORASY.DDS", buildtime = 51000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/CorBuildings/SeaFactories/corsy.lua b/units/CorBuildings/SeaFactories/corsy.lua index a1a8061c95b..a1ca8c52e21 100644 --- a/units/CorBuildings/SeaFactories/corsy.lua +++ b/units/CorBuildings/SeaFactories/corsy.lua @@ -4,7 +4,6 @@ return { buildpic = "CORSY.DDS", buildtime = 5100, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 59 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legaap.lua b/units/Legion/Labs/legaap.lua index a6712b71ce0..491e83f6cc0 100644 --- a/units/Legion/Labs/legaap.lua +++ b/units/Legion/Labs/legaap.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGAAP.DDS", buildtime = 31050, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "142 50 142", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legadvshipyard.lua b/units/Legion/Labs/legadvshipyard.lua index 1bc0df3f1c4..dbce14109f9 100644 --- a/units/Legion/Labs/legadvshipyard.lua +++ b/units/Legion/Labs/legadvshipyard.lua @@ -4,7 +4,6 @@ return { buildpic = "legadvshipyard.DDS", buildtime = 23550, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 10 -2", collisionvolumescales = "186 78 183", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legalab.lua b/units/Legion/Labs/legalab.lua index 1efd6ac6c68..7807f5a902a 100644 --- a/units/Legion/Labs/legalab.lua +++ b/units/Legion/Labs/legalab.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGALAB.DDS", buildtime = 25200, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "140 52 140", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legamphlab.lua b/units/Legion/Labs/legamphlab.lua index 014ff39ec4b..e95862a6c65 100644 --- a/units/Legion/Labs/legamphlab.lua +++ b/units/Legion/Labs/legamphlab.lua @@ -8,7 +8,6 @@ return { buildpic = "legamphlab.DDS", buildtime = 11400, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "98 60 91", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legap.lua b/units/Legion/Labs/legap.lua index 3cd84f62e51..92af62190bc 100644 --- a/units/Legion/Labs/legap.lua +++ b/units/Legion/Labs/legap.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGAP.DDS", buildtime = 6380, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 45 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legavp.lua b/units/Legion/Labs/legavp.lua index 935a864f591..cba7584d8c0 100644 --- a/units/Legion/Labs/legavp.lua +++ b/units/Legion/Labs/legavp.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGAVP.DDS", buildtime = 27750, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 5", collisionvolumescales = "144 70 144", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legfhp.lua b/units/Legion/Labs/legfhp.lua index b3155cf753a..5f49d84be6e 100644 --- a/units/Legion/Labs/legfhp.lua +++ b/units/Legion/Labs/legfhp.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGFHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 35 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leggant.lua b/units/Legion/Labs/leggant.lua index 4751b6b257c..37f10ad0d4e 100644 --- a/units/Legion/Labs/leggant.lua +++ b/units/Legion/Labs/leggant.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGGANT.DDS", buildtime = 67300, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leggantuw.lua b/units/Legion/Labs/leggantuw.lua index 5977a8173dc..fa735c610ac 100644 --- a/units/Legion/Labs/leggantuw.lua +++ b/units/Legion/Labs/leggantuw.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGGANTUW.DDS", buildtime = 67300, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghalab.lua b/units/Legion/Labs/leghalab.lua index 0ab422ffc11..a54e97b98f5 100644 --- a/units/Legion/Labs/leghalab.lua +++ b/units/Legion/Labs/leghalab.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGGANT.DDS", buildtime = 67300, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghavp.lua b/units/Legion/Labs/leghavp.lua index 1db2e44dc13..d7198079ef7 100644 --- a/units/Legion/Labs/leghavp.lua +++ b/units/Legion/Labs/leghavp.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGHAVP.DDS", buildtime = 67300, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 12 0", collisionvolumescales = "196 105 196", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leghp.lua b/units/Legion/Labs/leghp.lua index 6408ff09f10..8dea17fac9a 100644 --- a/units/Legion/Labs/leghp.lua +++ b/units/Legion/Labs/leghp.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGHP.DDS", buildtime = 8700, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 35 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/leglab.lua b/units/Legion/Labs/leglab.lua index bf66f520a95..495887d284b 100644 --- a/units/Legion/Labs/leglab.lua +++ b/units/Legion/Labs/leglab.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGLAB.DDS", buildtime = 5000, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 5 0", collisionvolumescales = "100 45 100", collisionvolumetype = "CylY", diff --git a/units/Legion/Labs/legsplab.lua b/units/Legion/Labs/legsplab.lua index 796507d9753..e359ca2a70f 100644 --- a/units/Legion/Labs/legsplab.lua +++ b/units/Legion/Labs/legsplab.lua @@ -5,7 +5,6 @@ return { buildpic = "legsplab.DDS", buildtime = 11800, canmove = true, - canReclaim = false, category = "SURFACE UNDERWATER", corpse = "DEAD", energycost = 5500, diff --git a/units/Legion/Labs/legsy.lua b/units/Legion/Labs/legsy.lua index 9f9fe450761..d84dfc12bc7 100644 --- a/units/Legion/Labs/legsy.lua +++ b/units/Legion/Labs/legsy.lua @@ -4,7 +4,6 @@ return { buildpic = "legsy.DDS", buildtime = 5100, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 0 0", collisionvolumescales = "96 42 96", collisionvolumetype = "Box", diff --git a/units/Legion/Labs/legvp.lua b/units/Legion/Labs/legvp.lua index 9d457081d58..9c53d8876a8 100644 --- a/units/Legion/Labs/legvp.lua +++ b/units/Legion/Labs/legvp.lua @@ -9,7 +9,6 @@ return { buildpic = "LEGVP.DDS", buildtime = 5700, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 19 0", collisionvolumescales = "101 53 106", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/armapt3.lua b/units/Scavengers/Buildings/Factories/armapt3.lua index 3424500c428..d62c4a0f67c 100644 --- a/units/Scavengers/Buildings/Factories/armapt3.lua +++ b/units/Scavengers/Buildings/Factories/armapt3.lua @@ -9,7 +9,6 @@ return { buildpic = "ARMAP.DDS", buildtime = 72400, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 8 1", collisionvolumescales = "268 98 164", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/corapt3.lua b/units/Scavengers/Buildings/Factories/corapt3.lua index 3ea556d65e8..02fb1e225be 100644 --- a/units/Scavengers/Buildings/Factories/corapt3.lua +++ b/units/Scavengers/Buildings/Factories/corapt3.lua @@ -8,7 +8,6 @@ return { buildpic = "CORAP.DDS", buildtime = 72400, canmove = true, - canReclaim = false, collisionvolumeoffsets = "0 -14 -23", collisionvolumescales = "220 66 100", collisionvolumetype = "Box", diff --git a/units/Scavengers/Buildings/Factories/legapt3.lua b/units/Scavengers/Buildings/Factories/legapt3.lua index 25b0f3bd214..025705b6c61 100644 --- a/units/Scavengers/Buildings/Factories/legapt3.lua +++ b/units/Scavengers/Buildings/Factories/legapt3.lua @@ -8,7 +8,6 @@ return { buildpic = "LEGAP.DDS", buildtime = 72400, canmove = true, - canReclaim = false, category = "ALL NOTLAND NOWEAPON NOTSUB NOTSHIP NOTAIR NOTHOVER SURFACE EMPABLE", collisionvolumeoffsets = "0 -14 -23", collisionvolumescales = "220 66 100", From e7276ac301ce5ac4ffa165de77e288e15a7f7adf Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Thu, 2 Jul 2026 19:58:41 -0500 Subject: [PATCH 33/38] more descriptive tooltips --- language/en/interface.json | 5 +++++ luaui/Widgets/gui_ordermenu.lua | 34 +++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 253f0022aa2..7b7a3c3e842 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -267,6 +267,11 @@ "Return fire": "Return fire", "Defend": "Defend", "Fire at all": "Fire At All", + "firestate_hold_fire_descr": "Don't acquire targets without orders", + "firestate_return_fire_descr": "Return fire when attacked", + "firestate_defend_descr": "Fire upon enemies when they become a threat", + "firestate_fire_at_will_descr": "Fire upon enemies within range", + "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", "Hold pos": "Hold pos", "Maneuver": "Maneuver", diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 73444663c5e..7a2d1a32a1b 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -70,6 +70,14 @@ local commandInfo = { } local isStateCommand = {} +local firestateDescrByState = { + ["Hold fire"] = "firestate_hold_fire_descr", + ["Return fire"] = "firestate_return_fire_descr", + ["Defend"] = "firestate_defend_descr", + ["Fire at will"] = "firestate_fire_at_will_descr", + ["Fire at all"] = "firestate_fire_at_all_descr", +} + local disabledCommand = {} local vsx, vsy = spGetViewGeometry() @@ -1063,13 +1071,35 @@ function widget:DrawScreen() local tooltipKey = cmd.action .. '_tooltip' local tooltip = getCachedTranslation('ui.orderMenu.' .. tooltipKey) - -- Cache hotkey lookup if not hotkeyCache[cmd.action] then hotkeyCache[cmd.action] = keyConfig.sanitizeKey(actionHotkeys[cmd.action], currentLayout) end local hotkey = hotkeyCache[cmd.action] + local hotkeyApplied = false + + if cmd.id == CMD.FIRE_STATE then + local currentStateIndex = cmd.params[1] + local stateOffset = 2 + local commandState = cmd.params[currentStateIndex + stateOffset] + local modeDescrKey = commandState and firestateDescrByState[commandState] + if modeDescrKey then + local modeDescr = getCachedTranslation('ui.orderMenu.' .. modeDescrKey) + local generalDescr = tooltip + if generalDescr ~= '' and hotkey ~= '' then + generalDescr = getCachedTranslation('ui.orderMenu.hotkeyTooltip', { hotkey = hotkey:upper(), tooltip = generalDescr, highlightColor = "\255\255\215\100", textColor = "\255\240\240\240" }) + hotkeyApplied = true + end + if modeDescr ~= '' and generalDescr ~= '' then + tooltip = modeDescr .. "\n" .. generalDescr + elseif modeDescr ~= '' then + tooltip = modeDescr + elseif generalDescr ~= '' then + tooltip = generalDescr + end + end + end - if tooltip ~= '' and hotkey ~= '' then + if tooltip ~= '' and hotkey ~= '' and not hotkeyApplied then tooltip = getCachedTranslation('ui.orderMenu.hotkeyTooltip', { hotkey = hotkey:upper(), tooltip = tooltip, highlightColor = "\255\255\215\100", textColor = "\255\240\240\240" }) end if tooltip ~= '' then From 7c2521a3207c2c7c0ab8f0a45750ee0c43ca739b Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Thu, 2 Jul 2026 20:02:23 -0500 Subject: [PATCH 34/38] gadgets.lua priority -> result rename, fixed inaccurate comment --- luarules/gadgets.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua index 1c436d940f6..bcc6a996e7c 100644 --- a/luarules/gadgets.lua +++ b/luarules/gadgets.lua @@ -1717,23 +1717,23 @@ function gadgetHandler:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum -- end local allowed = true - local priority = 1.0 + local result = 1.0 if targetID == -1 and attackerWeaponNum == -1 then - -- The `targetPriority` return value is actually the autotarget search radius, + -- The `defPriority` return value here is actually the autotarget search radius, -- and applies to the unit's targeting search for its command AI, not weapons. for _, g in ipairs(self.UnitAutoTargetRangeList) do defPriority = g:UnitAutoTargetRange(attackerID, defPriority) end - allowed, priority = defPriority > 0, defPriority + allowed, result = defPriority > 0, defPriority else -- The actual callin. BAR only uses AllowWeaponTarget for the target priority. for _, g in ipairs(self.AllowWeaponTargetList) do - allowed, priority = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) + allowed, result = g:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attackerWeaponDefID, defPriority) end end - return allowed, priority + return allowed, result end function gadgetHandler:UnitAutoTargetRange(attackerID, autoTargetRange) From 6637ca434aa95ea15e00fc38e7724df8db744693 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Fri, 3 Jul 2026 07:05:32 -0500 Subject: [PATCH 35/38] improve tooltip for defend --- language/en/interface.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language/en/interface.json b/language/en/interface.json index 7b7a3c3e842..9001ee72187 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -269,7 +269,7 @@ "Fire at all": "Fire At All", "firestate_hold_fire_descr": "Don't acquire targets without orders", "firestate_return_fire_descr": "Return fire when attacked", - "firestate_defend_descr": "Fire upon enemies when they become a threat", + "firestate_defend_descr": "Fire upon enemies when they individually become a threat", "firestate_fire_at_will_descr": "Fire upon enemies within range", "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", From 7daeb90913f8d53731b03e413902530aedb51a0b Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Fri, 3 Jul 2026 10:44:36 -0500 Subject: [PATCH 36/38] Add factory firestate inheritance --- luarules/gadgets/unit_firestate_handler.lua | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua index f3f9eca0ae1..60e3e16c879 100644 --- a/luarules/gadgets/unit_firestate_handler.lua +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -27,7 +27,9 @@ local Firestates = VFS.Include("modules/firestates.lua") local INLOS = { inlos = true } local spGiveOrderToUnit = Spring.GiveOrderToUnit +local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitStates = Spring.GetUnitStates +local spGetUnitRulesParam = Spring.GetUnitRulesParam local spSetUnitRulesParam = Spring.SetUnitRulesParam local settingEngineFirestate = false @@ -62,9 +64,16 @@ function gadget:AllowCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOpt return true end -function gadget:UnitCreated(unitID, unitDefID, unitTeam) - local firestate = spGetUnitStates(unitID, false) - local state = Firestates.fromEngineFirestate(firestate) +function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) + local state + local builderDefID = builderID and spGetUnitDefID(builderID) + if builderDefID and UnitDefs[builderDefID].isFactory then + state = spGetUnitRulesParam(builderID, Firestates.RULES_PARAM) + or Firestates.fromEngineFirestate(spGetUnitStates(builderID, false)) + end + if state == nil then + state = Firestates.fromEngineFirestate(spGetUnitStates(unitID, false)) + end spSetUnitRulesParam(unitID, Firestates.RULES_PARAM, state, INLOS) end From 3df04b037b6d14a2e3e38cdee4ffb8c50bbebbbf Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Fri, 3 Jul 2026 11:31:58 -0500 Subject: [PATCH 37/38] more improvements to threat calcs and qualifiers --- luarules/gadgets/unit_defend_firestate.lua | 56 +----- modules/firestates.lua | 59 ++++-- modules/weaponthreat.lua | 202 +++++++++++++++------ 3 files changed, 193 insertions(+), 124 deletions(-) diff --git a/luarules/gadgets/unit_defend_firestate.lua b/luarules/gadgets/unit_defend_firestate.lua index 94f3c3d3516..9014e729f7a 100644 --- a/luarules/gadgets/unit_defend_firestate.lua +++ b/luarules/gadgets/unit_defend_firestate.lua @@ -43,7 +43,7 @@ local defThreatRanges = {} local neverHesitateAttackers = {} local alwaysHarmlessUnitDefs = {} local weaponWatchRefCount = {} -local offensiveWeaponsByUnitDef = {} +local watchedWeaponsByUnitDef = {} local metaData = {} local gameFrame = 0 @@ -91,7 +91,7 @@ local function setDefendWatch(unitID, isDefend) end local unitDefID = meta[UNIT_DEF_ID] - local weaponDefIDs = offensiveWeaponsByUnitDef[unitDefID] + local weaponDefIDs = watchedWeaponsByUnitDef[unitDefID] meta[IS_DEFEND] = true meta[NEVER_HESITATE] = neverHesitateAttackers[unitDefID] or false meta[WEAPON_DEF_IDS] = weaponDefIDs @@ -235,57 +235,7 @@ function gadget:AllowWeaponTarget(attackerID, targetID, attackerWeaponNum, attac end function gadget:Initialize() - local targetUnitDefIDs = {} - local enemyProps = {} - local offensiveWeaponDefs = {} - - for unitDefID, unitDef in pairs(UnitDefs) do - local isKamikaze = WeaponThreat.isKamikazeUnitDef(unitDef) - - if WeaponThreat.isThreateningUnitDef(unitDef) then - targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID - enemyProps[unitDefID] = { - armorType = unitDef.armorType or 0, - health = unitDef.health or 0, - speed = unitDef.speed or 0, - reach = WeaponThreat.getUnitDefMaxOffensiveRange(unitDef), - } - else - alwaysHarmlessUnitDefs[unitDefID] = true - end - - if unitDef.customParams.defend_never_hesitate or isKamikaze then - neverHesitateAttackers[unitDefID] = true - end - - local weapons = unitDef.weapons - local unitOffensiveWeapons - local seenWeapon = {} - for weaponNum = 1, #weapons do - local weaponDefID = weapons[weaponNum].weaponDef - local weaponDef = WeaponDefs[weaponDefID] - if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) and not seenWeapon[weaponDefID] then - seenWeapon[weaponDefID] = true - offensiveWeaponDefs[weaponDefID] = weaponDef - if not unitOffensiveWeapons then - unitOffensiveWeapons = {} - end - unitOffensiveWeapons[#unitOffensiveWeapons + 1] = weaponDefID - end - end - if unitOffensiveWeapons then - offensiveWeaponsByUnitDef[unitDefID] = unitOffensiveWeapons - end - end - - for weaponDefID, weaponDef in pairs(offensiveWeaponDefs) do - local rangesForWeapon = {} - for targetIndex = 1, #targetUnitDefIDs do - local enemyUnitDefID = targetUnitDefIDs[targetIndex] - WeaponThreat.populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps[enemyUnitDefID]) - end - defThreatRanges[weaponDefID] = rangesForWeapon - end + defThreatRanges, watchedWeaponsByUnitDef, neverHesitateAttackers, alwaysHarmlessUnitDefs = WeaponThreat.buildDefendData() for _, unitID in ipairs(spGetAllUnits()) do local unitDefID = spGetUnitDefID(unitID) diff --git a/modules/firestates.lua b/modules/firestates.lua index 35cb207b4ae..70942cdeb93 100644 --- a/modules/firestates.lua +++ b/modules/firestates.lua @@ -57,30 +57,61 @@ function firestates.toEngineFirestate(state) end function firestates.fromEngineFirestate(engineFirestate) - return stateByEngineFirestate[engineFirestate] or firestates.AGGRESSIVE + return stateByEngineFirestate[tonumber(engineFirestate)] or firestates.AGGRESSIVE end -function firestates.orderMenuCmdDesc(command, userFirestate) - if userFirestate == nil then +function firestates.resolveUserFirestate(unitID) + if not Spring.ValidUnitID(unitID) then return nil end - local displayIndex = displayIndexByState[userFirestate] - if displayIndex == nil then - if userFirestate == firestates.RETURN_FIRE then - displayIndex = 1 - elseif userFirestate == firestates.FIRE_AT_ALL then - displayIndex = 2 - else - return nil + if Spring.GetModOptions().experimental_defend_firestate then + local rulesState = Spring.GetUnitRulesParam(unitID, firestates.RULES_PARAM) + if rulesState ~= nil then + return rulesState end end + return firestates.fromEngineFirestate(select(1, Spring.GetUnitStates(unitID, false))) +end + +function firestates.stateLabel(cmd) + local currentStateIndex = tonumber(cmd.params[1]) + if currentStateIndex == nil then + return nil + end + return cmd.params[currentStateIndex + 2] +end + +function firestates.orderMenuCmdDesc(command, userFirestate) + if userFirestate == nil then + return nil + end local cmdDesc = table.copy(command) + local middleLabel = Spring.GetModOptions().experimental_defend_firestate and "Defend" or "Return fire" cmdDesc.params = { - displayIndex, + 0, "Hold fire", - "Defend", - userFirestate == firestates.FIRE_AT_ALL and "Fire at all" or "Fire at will", + middleLabel, + "Fire at will", } + cmdDesc.pipFillMin = nil + cmdDesc.pipFillMax = nil + if userFirestate == firestates.RETURN_FIRE then + cmdDesc.params[1] = 1 + cmdDesc.params[3] = "Return fire" + cmdDesc.pipFillMin = 2 + cmdDesc.pipFillMax = 3 + elseif userFirestate == firestates.FIRE_AT_ALL then + cmdDesc.params[1] = 2 + cmdDesc.params[4] = "Fire at all" + cmdDesc.pipFillMin = 1 + cmdDesc.pipFillMax = 3 + else + local displayIndex = displayIndexByState[userFirestate] + if displayIndex == nil then + return nil + end + cmdDesc.params[1] = displayIndex + end return cmdDesc end diff --git a/modules/weaponthreat.lua b/modules/weaponthreat.lua index a30757bc442..91e9cbca8b2 100644 --- a/modules/weaponthreat.lua +++ b/modules/weaponthreat.lua @@ -5,6 +5,7 @@ local EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER = 1.5 local REFERENCE_RANGE_FRACTION = 0.75 local REFERENCE_TARGET_HALF_EXTENT = Game.squareSize * Game.footprintScale local FALLOFF_WEAPON_TYPES = { BeamLaser = true, Flame = true, LaserCannon = true, LightningCannon = true } +local DEFAULT_ARMOR_TYPE = Game.armorTypes.default WeaponThreat.ALWAYS_SHOOT = -1 WeaponThreat.NO_THREAT = -2 @@ -96,6 +97,12 @@ function WeaponThreat.isOffensiveWeapon(weaponDef) return true end +function WeaponThreat.isWatchableWeapon(weaponDef) + return weaponDef + and not (weaponDef.shieldRadius and weaponDef.shieldRadius > 0) + and not (weaponDef.interceptor ~= 0 and weaponDef.coverageRange) +end + local function weaponDealsDamage(weaponDef) local damages = weaponDef.damages if not damages then @@ -115,20 +122,6 @@ local function weaponDealsDamage(weaponDef) return false end -local function isCarrierWeapon(weaponDef) - local customParams = weaponDef.customParams - return customParams and customParams.carried_unit and customParams.carried_unit ~= "" -end - -local function getCarrierWeaponCommandRange(weaponDef) - local customParams = weaponDef.customParams or {} - local engagementRange = tonumber(customParams.engagementrange) - if engagementRange and engagementRange > 0 then - return engagementRange - end - return weaponDef.range or 0 -end - function WeaponThreat.isThreateningUnitDef(enemyUnitDef) if enemyUnitDef.canReclaim then return true @@ -142,7 +135,11 @@ function WeaponThreat.isThreateningUnitDef(enemyUnitDef) for weaponNum = 1, #weapons do local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) then - if isCarrierWeapon(weaponDef) or weaponDealsDamage(weaponDef) then + local customParams = weaponDef.customParams + if customParams and customParams.carried_unit and customParams.carried_unit ~= "" then + return true + end + if weaponDealsDamage(weaponDef) then return true end end @@ -151,7 +148,32 @@ function WeaponThreat.isThreateningUnitDef(enemyUnitDef) return false end -function WeaponThreat.getUnitDefMaxOffensiveRange(enemyUnitDef) +local function weaponThreatensUnit(weapon, weaponDef, unitDef) + if weapon.onlyTargets then + local modCategories = unitDef.modCategories + if modCategories then + for category in pairs(weapon.onlyTargets) do + if modCategories[category] then + return true + end + end + end + else + return true + end + + if unitDef.canFly then + return false + end + + if unitDef.modCategories and unitDef.modCategories.underwater then + return false + end + + return weaponDef.canAttackGround ~= false +end + +local function getUnitDefReachAgainstUnit(enemyUnitDef, unitDef) if WeaponThreat.isKamikazeUnitDef(enemyUnitDef) then return getKamikazeExplosionRadius(enemyUnitDef) end @@ -166,12 +188,15 @@ function WeaponThreat.getUnitDefMaxOffensiveRange(enemyUnitDef) local weapons = enemyUnitDef.weapons for weaponNum = 1, #weapons do - local weaponDef = WeaponDefs[weapons[weaponNum].weaponDef] + local weapon = weapons[weaponNum] + local weaponDef = WeaponDefs[weapon.weaponDef] if weaponDef and WeaponThreat.isOffensiveWeapon(weaponDef) then local range = 0 - if isCarrierWeapon(weaponDef) then - range = getCarrierWeaponCommandRange(weaponDef) - elseif weaponDealsDamage(weaponDef) then + local customParams = weaponDef.customParams + if customParams and customParams.carried_unit and customParams.carried_unit ~= "" then + local engagementRange = tonumber(customParams.engagementrange) + range = (engagementRange and engagementRange > 0) and engagementRange or (weaponDef.range or 0) + elseif weaponThreatensUnit(weapon, weaponDef, unitDef) then range = weaponDef.range or 0 end if range > longestRange then @@ -183,71 +208,134 @@ function WeaponThreat.getUnitDefMaxOffensiveRange(enemyUnitDef) return longestRange end -local function getWeaponFalloffMultiplier(weaponDef) +local function getWeaponTimeToKill(weaponDef, enemyHealth, armorType) + local damages = weaponDef.damages + local damage = damages and (damages[armorType] or damages[DEFAULT_ARMOR_TYPE] or damages[0]) or 0 + if damage <= 0 then + return nil + end + + local falloffMultiplier = 1 if FALLOFF_WEAPON_TYPES[weaponDef.type] then local minIntensity = weaponDef.minIntensity if minIntensity and minIntensity > 0 then - return minIntensity + falloffMultiplier = minIntensity end end - return 1 -end - -local function getWeaponHitChance(weaponDef) local spreadAngle = (weaponDef.accuracy or 0) + (weaponDef.sprayAngle or 0) local spreadRadius = REFERENCE_RANGE_FRACTION * (weaponDef.range or 0) * spreadAngle - if spreadRadius <= REFERENCE_TARGET_HALF_EXTENT then - return 1 - end - - local ratio = REFERENCE_TARGET_HALF_EXTENT / spreadRadius - return ratio * ratio -end - -local function getWeaponEffectiveDamagePerCycle(weaponDef, armorType) - local damages = weaponDef.damages - local damage = damages and damages[armorType] or 0 - if damage <= 0 then - return 0 + local hitChance = 1 + if spreadRadius > REFERENCE_TARGET_HALF_EXTENT then + local ratio = REFERENCE_TARGET_HALF_EXTENT / spreadRadius + hitChance = ratio * ratio end local salvoSize = weaponDef.salvoSize or 1 local projectiles = weaponDef.projectiles or 1 - local falloffMultiplier = getWeaponFalloffMultiplier(weaponDef) - local hitChance = getWeaponHitChance(weaponDef) - return damage * salvoSize * projectiles * falloffMultiplier * hitChance -end - -local function getWeaponTimeToKill(weaponDef, enemyHealth, armorType) - local damagePerCycle = getWeaponEffectiveDamagePerCycle(weaponDef, armorType) - if damagePerCycle <= 0 then - return nil - end + local damagePerCycle = damage * salvoSize * projectiles * falloffMultiplier * hitChance local cycles = math.ceil(enemyHealth / damagePerCycle) local reload = weaponDef.reload or 0 - local salvoSize = weaponDef.salvoSize or 1 local salvoDelay = weaponDef.salvoDelay or 0 local intraSalvoTime = (salvoSize - 1) * salvoDelay return math.max(0, cycles - 1) * reload + intraSalvoTime end -function WeaponThreat.populateWeaponThreatRange(rangesForWeapon, weaponDef, enemyUnitDefID, enemyProps) +local function getThreatRange(defenderWeapon, weaponDef, defenderUnitDef, enemyProps, enemyUnitDef) + if not weaponThreatensUnit(defenderWeapon, weaponDef, enemyUnitDef) then + return WeaponThreat.NO_THREAT + end + local timeToKill = getWeaponTimeToKill(weaponDef, enemyProps.health, enemyProps.armorType) if not timeToKill then - rangesForWeapon[enemyUnitDefID] = WeaponThreat.NO_THREAT - return + return WeaponThreat.NO_THREAT + end + + local reach = getUnitDefReachAgainstUnit(enemyUnitDef, defenderUnitDef) + if reach <= 0 then + return WeaponThreat.NO_THREAT end local groundCovered = enemyProps.speed * (timeToKill + EXTRA_SECONDS_MOVEMENT_THREAT_BUFFER) - local threatRange = (enemyProps.reach + groundCovered) * THREAT_RANGE_BUFFER_MULTIPLIER + local threatRange = (reach + groundCovered) * THREAT_RANGE_BUFFER_MULTIPLIER local weaponRange = weaponDef.range or 0 if threatRange >= weaponRange then - rangesForWeapon[enemyUnitDefID] = WeaponThreat.ALWAYS_SHOOT - else - rangesForWeapon[enemyUnitDefID] = threatRange + return WeaponThreat.ALWAYS_SHOOT end + + return threatRange +end + +function WeaponThreat.buildDefendData() + local threatRanges = {} + local watchedWeaponsByUnitDef = {} + local neverHesitateAttackers = {} + local alwaysHarmlessUnitDefs = {} + + local targetUnitDefIDs = {} + local enemyProps = {} + local enemyUnitDefs = {} + local watchableWeaponDefs = {} + + for unitDefID, unitDef in pairs(UnitDefs) do + local isKamikaze = WeaponThreat.isKamikazeUnitDef(unitDef) + + if WeaponThreat.isThreateningUnitDef(unitDef) then + targetUnitDefIDs[#targetUnitDefIDs + 1] = unitDefID + enemyUnitDefs[unitDefID] = unitDef + enemyProps[unitDefID] = { + armorType = unitDef.armorType or 0, + health = unitDef.health or 0, + speed = unitDef.speed or 0, + } + else + alwaysHarmlessUnitDefs[unitDefID] = true + end + + if unitDef.customParams.defend_never_hesitate or isKamikaze then + neverHesitateAttackers[unitDefID] = true + end + + local weapons = unitDef.weapons + local unitWatchedWeapons + local seenWeapon = {} + for weaponNum = 1, #weapons do + local weaponDefID = weapons[weaponNum].weaponDef + local weaponDef = WeaponDefs[weaponDefID] + if weaponDef and WeaponThreat.isWatchableWeapon(weaponDef) and not seenWeapon[weaponDefID] then + seenWeapon[weaponDefID] = true + watchableWeaponDefs[weaponDefID] = weaponDef + if not unitWatchedWeapons then + unitWatchedWeapons = {} + end + unitWatchedWeapons[#unitWatchedWeapons + 1] = weaponDefID + end + end + if unitWatchedWeapons then + watchedWeaponsByUnitDef[unitDefID] = unitWatchedWeapons + end + end + + for unitDefID in pairs(watchedWeaponsByUnitDef) do + local defenderUnitDef = UnitDefs[unitDefID] + local weapons = defenderUnitDef.weapons + for weaponNum = 1, #weapons do + local weapon = weapons[weaponNum] + local weaponDefID = weapon.weaponDef + local weaponDef = watchableWeaponDefs[weaponDefID] + if weaponDef and not threatRanges[weaponDefID] then + local rangesForWeapon = {} + for targetIndex = 1, #targetUnitDefIDs do + local enemyUnitDefID = targetUnitDefIDs[targetIndex] + rangesForWeapon[enemyUnitDefID] = getThreatRange(weapon, weaponDef, defenderUnitDef, enemyProps[enemyUnitDefID], enemyUnitDefs[enemyUnitDefID]) + end + threatRanges[weaponDefID] = rangesForWeapon + end + end + end + + return threatRanges, watchedWeaponsByUnitDef, neverHesitateAttackers, alwaysHarmlessUnitDefs end return WeaponThreat From 6cc0f32cfd1be02bd21c0fa9068f8d024bc09c16 Mon Sep 17 00:00:00 2001 From: SethDGamre Date: Fri, 3 Jul 2026 12:11:11 -0500 Subject: [PATCH 38/38] polish up firestate gui, add keybind support --- luarules/gadgets/unit_firestate_handler.lua | 2 +- luaui/Include/ordermenu_firestate.lua | 229 ++++++++++++++++++++ luaui/Widgets/cmd_user_firestate.lua | 41 ---- luaui/Widgets/gui_ordermenu.lua | 110 ++++++---- 4 files changed, 292 insertions(+), 90 deletions(-) create mode 100644 luaui/Include/ordermenu_firestate.lua delete mode 100644 luaui/Widgets/cmd_user_firestate.lua diff --git a/luarules/gadgets/unit_firestate_handler.lua b/luarules/gadgets/unit_firestate_handler.lua index 60e3e16c879..87d64732f6d 100644 --- a/luarules/gadgets/unit_firestate_handler.lua +++ b/luarules/gadgets/unit_firestate_handler.lua @@ -47,7 +47,7 @@ end function gadget:AllowCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOptions, cmdTag, playerID, fromSynced, fromLua, fromInsert) if cmdID == CMD_USER_FIRESTATE then local state = cmdParams[1] - if Firestates.isUserFacing(state) then + if Firestates.toEngineFirestate(state) ~= nil then setUserFirestate(unitID, state) end return false diff --git a/luaui/Include/ordermenu_firestate.lua b/luaui/Include/ordermenu_firestate.lua new file mode 100644 index 00000000000..596cb76ff33 --- /dev/null +++ b/luaui/Include/ordermenu_firestate.lua @@ -0,0 +1,229 @@ +--DEFEND FIRESTATE REWORK: Remove modoption branching; always use the enabled virtual-index tables, always issue CMD_USER_FIRESTATE, and delete the disabled-variant tables plus defendFirestateEnabled(). + +local Firestates = VFS.Include("modules/firestates.lua") + +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE + +local spGetSelectedUnits = Spring.GetSelectedUnits +local spGiveOrder = Spring.GiveOrder +local spGetModKeyState = Spring.GetModKeyState + +local CYCLE_COUNT = 3 +local PIP_COUNT = 3 +local DIRECT_BIND_MAX = 5 + +local remappingFirestate = false +local onOrderGiven + +local descrByState = { + ["Hold fire"] = "firestate_hold_fire_descr", + ["Return fire"] = "firestate_return_fire_descr", + ["Defend"] = "firestate_defend_descr", + ["Fire at will"] = "firestate_fire_at_will_descr", + ["Fire at all"] = "firestate_fire_at_all_descr", +} + +local virtualIndexByStateDisabled = { + [Firestates.PASSIVE] = 1, + [Firestates.RETURN_FIRE] = 2, + [Firestates.AGGRESSIVE] = 3, + [Firestates.DEFEND] = 4, + [Firestates.FIRE_AT_ALL] = 5, +} + +local virtualIndexByStateEnabled = { + [Firestates.PASSIVE] = 1, + [Firestates.DEFEND] = 2, + [Firestates.AGGRESSIVE] = 3, + [Firestates.RETURN_FIRE] = 4, + [Firestates.FIRE_AT_ALL] = 5, +} + +local stateByVirtualIndexDisabled = { + [1] = Firestates.PASSIVE, + [2] = Firestates.RETURN_FIRE, + [3] = Firestates.AGGRESSIVE, + [4] = Firestates.DEFEND, + [5] = Firestates.FIRE_AT_ALL, +} + +local stateByVirtualIndexEnabled = { + [1] = Firestates.PASSIVE, + [2] = Firestates.DEFEND, + [3] = Firestates.AGGRESSIVE, + [4] = Firestates.RETURN_FIRE, + [5] = Firestates.FIRE_AT_ALL, +} + +local labelByVirtualIndexDisabled = { + [1] = "Hold fire", + [2] = "Return fire", + [3] = "Fire at will", + [4] = "Defend", + [5] = "Fire at all", +} + +local labelByVirtualIndexEnabled = { + [1] = "Hold fire", + [2] = "Defend", + [3] = "Fire at will", + [4] = "Return fire", + [5] = "Fire at all", +} + +local function defendFirestateEnabled() + return Spring.GetModOptions().experimental_defend_firestate +end + +local function virtualIndexByState() + if defendFirestateEnabled() then + return virtualIndexByStateEnabled + end + return virtualIndexByStateDisabled +end + +local function stateByVirtualIndex() + if defendFirestateEnabled() then + return stateByVirtualIndexEnabled + end + return stateByVirtualIndexDisabled +end + +local function labelByVirtualIndex() + if defendFirestateEnabled() then + return labelByVirtualIndexEnabled + end + return labelByVirtualIndexDisabled +end + +local function resolveVirtualIndex(unitID) + local userFirestate = Firestates.resolveUserFirestate(unitID) + if userFirestate == nil then + return nil + end + return virtualIndexByState()[userFirestate] +end + +local function pipFill(virtualIndex) + if virtualIndex == 1 then + return 1, 1 + elseif virtualIndex == 2 then + return 2, 2 + elseif virtualIndex == 3 then + return 3, 3 + elseif virtualIndex == 4 then + return 2, 3 + elseif virtualIndex == 5 then + return 1, 3 + end + return 1, 1 +end + +local function buildCmdDesc(command, virtualIndex) + local cmdDesc = table.copy(command) + local labels = labelByVirtualIndex() + cmdDesc.params = { + virtualIndex - 1, + labels[1], + labels[2], + labels[3], + } + cmdDesc.virtualIndex = virtualIndex + cmdDesc.pipFillMin, cmdDesc.pipFillMax = pipFill(virtualIndex) + return cmdDesc +end + +local function stateLabel(cmd) + if cmd.virtualIndex then + return labelByVirtualIndex()[cmd.virtualIndex] + end + return nil +end + +local function giveVirtualIndex(virtualIndex, cmdOptions) + local state = stateByVirtualIndex()[virtualIndex] + if state == nil then + return false + end + if defendFirestateEnabled() then + spGiveOrder(CMD_USER_FIRESTATE, { state }, cmdOptions or 0) + else + remappingFirestate = true + spGiveOrder(CMD_FIRE_STATE, { Firestates.toEngineFirestate(state) }, cmdOptions or 0) + remappingFirestate = false + end + if onOrderGiven then + onOrderGiven() + end + return true +end + +local function nextCycledVirtualIndex(virtualIndex, reverse) + if virtualIndex > CYCLE_COUNT then + return reverse and CYCLE_COUNT or 1 + end + if reverse then + if virtualIndex <= 1 then + return CYCLE_COUNT + end + return virtualIndex - 1 + end + if virtualIndex >= CYCLE_COUNT then + return 1 + end + return virtualIndex + 1 +end + +local function hotkeyHandler(cmd, optLine, optWords, data, isRepeat, release) + if release then + return false + end + local selectedUnits = spGetSelectedUnits() + if #selectedUnits == 0 then + return false + end + local virtualIndex = resolveVirtualIndex(selectedUnits[1]) + if virtualIndex == nil then + return false + end + local param = optWords[1] and tonumber(optWords[1]) + if param ~= nil then + local targetIndex = param + 1 + if targetIndex < 1 or targetIndex > DIRECT_BIND_MAX then + return false + end + return giveVirtualIndex(targetIndex, 0) + end + local _, _, shift = spGetModKeyState() + local nextIndex = nextCycledVirtualIndex(virtualIndex, shift) + return giveVirtualIndex(nextIndex, 0) +end + +local function commandNotify(cmdID, cmdParams, cmdOptions) + if remappingFirestate or cmdID ~= CMD_FIRE_STATE or not cmdParams then + return false + end + local engineParam = tonumber(cmdParams[1]) + if engineParam == nil or engineParam < 0 or engineParam > 2 then + return false + end + return giveVirtualIndex(engineParam + 1, cmdOptions) +end + +return { + CYCLE_COUNT = CYCLE_COUNT, + PIP_COUNT = PIP_COUNT, + descrByState = descrByState, + init = function(opts) + onOrderGiven = opts.onOrderGiven + end, + buildCmdDesc = buildCmdDesc, + stateLabel = stateLabel, + pipFill = pipFill, + resolveVirtualIndex = resolveVirtualIndex, + nextCycledVirtualIndex = nextCycledVirtualIndex, + giveVirtualIndex = giveVirtualIndex, + hotkeyHandler = hotkeyHandler, + commandNotify = commandNotify, +} diff --git a/luaui/Widgets/cmd_user_firestate.lua b/luaui/Widgets/cmd_user_firestate.lua deleted file mode 100644 index f83b897c288..00000000000 --- a/luaui/Widgets/cmd_user_firestate.lua +++ /dev/null @@ -1,41 +0,0 @@ ---DEFEND FIRESTATE REWORK: Remove guard; widget always intercepts firestate clicks -if not Spring.GetModOptions().experimental_defend_firestate then - return -end - -local widget = widget ---@type Widget - -function widget:GetInfo() - return { - name = "User Firestate", - desc = "Maps user-facing firestate orders to custom firestate states", - author = "SethDGamre", - date = "2026.06.28", - license = "GNU GPL, v2 or later", - layer = 0, - enabled = true - } -end - -local CMD_FIRE_STATE = CMD.FIRE_STATE -local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE -local Firestates = VFS.Include("modules/firestates.lua") -local spGiveOrder = Spring.GiveOrder - -local userFirestateByEngineFirestate = { - [Firestates.ENGINE_HOLD_FIRE] = Firestates.PASSIVE, - [Firestates.ENGINE_RETURN_FIRE] = Firestates.DEFEND, - [Firestates.ENGINE_FIRE_AT_WILL] = Firestates.AGGRESSIVE, -} - -function widget:CommandNotify(cmdID, cmdParams, cmdOptions) - if cmdID ~= CMD_FIRE_STATE or not cmdParams then - return false - end - local userFirestate = userFirestateByEngineFirestate[cmdParams[1]] - if userFirestate == nil then - return false - end - spGiveOrder(CMD_USER_FIRESTATE, { userFirestate }, cmdOptions) - return true -end diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index 7a2d1a32a1b..956acbde730 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -25,6 +25,8 @@ local spGetSpectatingState = Spring.GetSpectatingState local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") local Firestates = VFS.Include("modules/firestates.lua") +local OrderMenuFirestate = VFS.Include("luaui/Include/ordermenu_firestate.lua") +local CANCEL_TARGET_CMD_ID = 34924 local currentLayout local cellZoom = 1 @@ -70,14 +72,6 @@ local commandInfo = { } local isStateCommand = {} -local firestateDescrByState = { - ["Hold fire"] = "firestate_hold_fire_descr", - ["Return fire"] = "firestate_return_fire_descr", - ["Defend"] = "firestate_defend_descr", - ["Fire at will"] = "firestate_fire_at_will_descr", - ["Fire at all"] = "firestate_fire_at_all_descr", -} - local disabledCommand = {} local vsx, vsy = spGetViewGeometry() @@ -165,9 +159,6 @@ local lastColorize = -1 -- Pre-built printable text cache: textColor .. text (cleared on redraw) local printTextCache = {} -local CANCEL_TARGET_CMD_ID = 34924 -- UNIT_CANCEL_TARGET -local CMD_USER_FIRESTATE = GameCMD.USER_FIRESTATE - local hiddenCommands = { [CMD.LOAD_ONTO] = true, [CMD.SELFD] = true, @@ -376,8 +367,9 @@ local function refreshCommands() -- intentionally empty, no action to take elseif isStateCommand[command.id] then stateCommandsCount = stateCommandsCount + 1 - if command.id == CMD.FIRE_STATE and Spring.GetModOptions().experimental_defend_firestate and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then - stateCommandsTemp[stateCommandsCount] = Firestates.orderMenuCmdDesc(command, Spring.GetUnitRulesParam(cachedFirstUnit, Firestates.RULES_PARAM)) or command + if command.id == CMD.FIRE_STATE and cachedFirstUnit and Spring.ValidUnitID(cachedFirstUnit) then + local virtualIndex = OrderMenuFirestate.resolveVirtualIndex(cachedFirstUnit) + stateCommandsTemp[stateCommandsCount] = virtualIndex and OrderMenuFirestate.buildCmdDesc(command, virtualIndex) or command else stateCommandsTemp[stateCommandsCount] = command end @@ -412,18 +404,12 @@ local function refreshCommands() -- OPTIMIZATION: Cache the display text using persistent commandTextCache for _, cmd in ipairs(commands) do if isStateCommand[cmd.id] then - local currentStateIndex = cmd.params[1] - if currentStateIndex then - -- First element of params represents selected state index, but Spring engine implementation returns a value 2 less than the actual index - local commandState = cmd.params[currentStateIndex + 2] - if commandState then - if not commandTextCache[commandState] then - commandTextCache[commandState] = getCachedTranslation('ui.orderMenu.' .. commandState) - end - cmd.cachedText = commandTextCache[commandState] - else - cmd.cachedText = '?' + local commandState = (cmd.id == CMD.FIRE_STATE) and OrderMenuFirestate.stateLabel(cmd) or Firestates.stateLabel(cmd) + if commandState then + if not commandTextCache[commandState] then + commandTextCache[commandState] = getCachedTranslation('ui.orderMenu.' .. commandState) end + cmd.cachedText = commandTextCache[commandState] else cmd.cachedText = '?' end @@ -458,7 +444,7 @@ local function refreshCommands() break end if isStateCommand[cmd.id] then - local s = cmd.params and cmd.params[1] + local s = cmd.cachedText if s ~= prevCmdStates[i] then commandsVisuallyChanged = true break @@ -477,7 +463,7 @@ local function refreshCommands() for i = 1, cmdCount do prevCmdIDs[i] = commands[i].id if isStateCommand[commands[i].id] then - prevCmdStates[i] = commands[i].params and commands[i].params[1] + prevCmdStates[i] = commands[i].cachedText elseif commands[i].id == CMD.WAIT then prevCmdStates[i] = cachedWaitState else @@ -585,6 +571,11 @@ local function reloadBindings() end function widget:Initialize() + OrderMenuFirestate.init({ + onOrderGiven = function() + doUpdate = true + end, + }) reloadBindings() widget:ViewResize() widget:SelectionChanged(spGetSelectedUnits()) @@ -670,6 +661,8 @@ function widget:Initialize() WG['ordermenu'].hasHighlight = function(cmdID) return cmdID ~= nil and highlight.items[cmdID] ~= nil end + + widgetHandler:AddAction("firestate", OrderMenuFirestate.hotkeyHandler, nil, "p") end function widget:Shutdown() @@ -975,18 +968,36 @@ local function drawCell(cell, zoom) -- state lights if isStateCommand[cmd.id] or cmd.id == CMD.WAIT then local statecount, curstate - if isStateCommand[cmd.id] then - statecount = #cmd.params - 1 --number of states for the cmd - curstate = cmd.params[1] + 1 + local fillMin, fillMax + if cmd.id == CMD.FIRE_STATE then + statecount = OrderMenuFirestate.PIP_COUNT + curstate = cmd.virtualIndex or 1 + fillMin, fillMax = OrderMenuFirestate.pipFill(curstate) + elseif isStateCommand[cmd.id] then + statecount = #cmd.params - 1 + curstate = tonumber(cmd.params[1]) + 1 + fillMin = cmd.pipFillMin or curstate + fillMax = cmd.pipFillMax or curstate else statecount = 2 curstate = cachedWaitState + fillMin = curstate + fillMax = curstate end local desiredState = nil if clickedCellDesiredState and cell == clickedCell then - desiredState = clickedCellDesiredState + 1 + if cmd.id == CMD.FIRE_STATE then + desiredState = clickedCellDesiredState + else + desiredState = clickedCellDesiredState + 1 + end end - if curstate == desiredState then + if cmd.id == CMD.FIRE_STATE then + if curstate == desiredState then + clickedCellDesiredState = nil + desiredState = nil + end + elseif curstate == desiredState then clickedCellDesiredState = nil desiredState = nil end @@ -997,7 +1008,7 @@ local function drawCell(cell, zoom) local glowSize = math_floor(stateHeight * 8) local r, g, b, a = 0, 0, 0, 0 for i = 1, statecount do - if i == curstate or i == desiredState then + if (i >= fillMin and i <= fillMax) or i == desiredState then if i == 1 then r, g, b, a = 1, 0.1, 0.1, (i == desiredState and 0.33 or 0.8) elseif i == 2 then @@ -1025,7 +1036,7 @@ local function drawCell(cell, zoom) glRect(x1, y1, x2, y2) end -- fancy active state glow - if rows < 6 and i == curstate then + if rows < 6 and i >= fillMin and i <= fillMax then glBlending(GL_SRC_ALPHA, GL_ONE) glColor(r, g, b, 0.09) glTexture(barGlowCenterTexture) @@ -1078,10 +1089,8 @@ function widget:DrawScreen() local hotkeyApplied = false if cmd.id == CMD.FIRE_STATE then - local currentStateIndex = cmd.params[1] - local stateOffset = 2 - local commandState = cmd.params[currentStateIndex + stateOffset] - local modeDescrKey = commandState and firestateDescrByState[commandState] + local commandState = OrderMenuFirestate.stateLabel(cmd) + local modeDescrKey = commandState and OrderMenuFirestate.descrByState[commandState] if modeDescrKey then local modeDescr = getCachedTranslation('ui.orderMenu.' .. modeDescrKey) local generalDescr = tooltip @@ -1105,10 +1114,7 @@ function widget:DrawScreen() if tooltip ~= '' then local title if isStateCommand[cmd.id] then - local currentStateIndex = cmd.params[1] - -- First element of params represents selected state index, but Spring engine implementation returns a value 2 less than the actual index - local stateOffset = 2 - local commandState = cmd.params[currentStateIndex + stateOffset] + local commandState = (cmd.id == CMD.FIRE_STATE) and OrderMenuFirestate.stateLabel(cmd) or Firestates.stateLabel(cmd) if commandState then title = getCachedTranslation('ui.orderMenu.' .. commandState) end @@ -1303,17 +1309,21 @@ function widget:MousePress(x, y, button) clickedCell = cell clickedCellTime = os_clock() - -- remember desired state: only works for a single cell at a time, because there is no way to re-identify a cell when the selection changes - if isStateCommand[cmd.id] then + if cmd.id == CMD.FIRE_STATE then + local virtualIndex = cmd.virtualIndex or 1 + clickedCellDesiredState = OrderMenuFirestate.nextCycledVirtualIndex(virtualIndex, button ~= 1) + doUpdate = true + elseif isStateCommand[cmd.id] then + local currentStateIndex = tonumber(cmd.params[1]) or 0 if button == 1 then - clickedCellDesiredState = cmd.params[1] + 1 + clickedCellDesiredState = currentStateIndex + 1 if clickedCellDesiredState >= #cmd.params - 1 then clickedCellDesiredState = 0 end else - clickedCellDesiredState = cmd.params[1] - 1 + clickedCellDesiredState = currentStateIndex - 1 if clickedCellDesiredState < 0 then - clickedCellDesiredState = #cmd.params - 1 + clickedCellDesiredState = #cmd.params - 2 end end doUpdate = true @@ -1322,8 +1332,8 @@ function widget:MousePress(x, y, button) if playSounds then Spring.PlaySoundFile(soundButton, 0.6, 'ui') end - if cmd.id == CMD.FIRE_STATE and Spring.GetModOptions().experimental_defend_firestate and clickedCellDesiredState then - Spring.GiveOrder(CMD_USER_FIRESTATE, { Firestates.stateFromDisplayIndex(clickedCellDesiredState) }, 0) + if cmd.id == CMD.FIRE_STATE and clickedCellDesiredState ~= nil then + OrderMenuFirestate.giveVirtualIndex(clickedCellDesiredState, 0) elseif cmd.id and Spring.GetCmdDescIndex(cmd.id) then Spring.SetActiveCommand(Spring.GetCmdDescIndex(cmd.id), button, true, false, Spring.GetModKeyState()) end @@ -1341,6 +1351,10 @@ function widget:MousePress(x, y, button) end end +function widget:CommandNotify(cmdID, cmdParams, cmdOptions) + return OrderMenuFirestate.commandNotify(cmdID, cmdParams, cmdOptions) +end + function widget:UnitCommand(unitID, unitDefID, unitTeam, cmdID, cmdOpts, cmdParams, cmdTag) if isStateCommand[cmdID] or cmdID == CMD.WAIT then if not hiddenCommands[cmdID] and doUpdateClock == nil then