From 2442debba45654d20c923a01172682b2e01c187a Mon Sep 17 00:00:00 2001 From: eun-ice Date: Wed, 16 Sep 2026 16:29:57 -0600 Subject: [PATCH] Attack command: Fix ground Area Attack target cycling (#8773) Closes #8772 Tracks the end-of-salvo on units with the ground-variant of Area Attack (to be renamed, "bombard", etc) and uses it to reseed the attack command entirely. The command is disposed via Finish or Remove and a new one inserted. This replaces the broken Area Attack that becomes stuck on its first randomly-selected position and continues to fire on it. This is the more useful version with large selections of units; they attempt to destroy any hardened target in their range; but it is unwanted for all other cases. --- luarules/gadgets/unit_areaattack.lua | 91 ++++++- .../luarules/gadgets/unit_areaattack_spec.lua | 246 ++++++++++++++++++ 2 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 spec/luarules/gadgets/unit_areaattack_spec.lua diff --git a/luarules/gadgets/unit_areaattack.lua b/luarules/gadgets/unit_areaattack.lua index 1d919a7b1ad..4e672131c7a 100644 --- a/luarules/gadgets/unit_areaattack.lua +++ b/luarules/gadgets/unit_areaattack.lua @@ -19,20 +19,30 @@ local CMD_AREA_ATTACK_GROUND = GameCMD.AREA_ATTACK_GROUND if gadgetHandler:IsSyncedCode() then local attackList = {} local closeList = {} + local activeAttacks = {} local math_random = math.random local math_pi = math.pi local math_sqrt = math.sqrt local math_cos = math.cos local math_sin = math.sin + local math_max = math.max + local math_bit_and = math.bit_and local CMD_ATTACK = CMD.ATTACK + local CMD_OPT_INTERNAL = CMD.OPT_INTERNAL local reissueOrder = Game.Commands.ReissueOrder local canAreaAttack = {} + local areaAttackWeaponDefs = {} + local areaAttackWeaponDefByUnitDef = {} for unitDefID, unitDef in pairs(UnitDefs) do if #unitDef.weapons > 0 and unitDef.customParams.canareaattack then - canAreaAttack[unitDefID] = WeaponDefs[unitDef.weapons[1].weaponDef].range + local weaponDefID = unitDef.weapons[1].weaponDef + local weaponDef = WeaponDefs[weaponDefID] + canAreaAttack[unitDefID] = weaponDef.range + areaAttackWeaponDefs[weaponDefID] = true + areaAttackWeaponDefByUnitDef[unitDefID] = weaponDefID end end local range = canAreaAttack -- range per unitDefID, same data @@ -52,12 +62,14 @@ if gadgetHandler:IsSyncedCode() then local phase = math_random(200 * math_pi) / 100.0 if o.radius > 0 then local amp = math_random(o.radius) - Spring.GiveOrderToUnit( - o.unit, - CMD.INSERT, - { 0, CMD.ATTACK, 0, o.x + math_cos(phase) * amp, o.y, o.z + math_sin(phase) * amp }, - { "alt" } - ) + Spring.GiveOrderToUnit(o.unit, CMD.INSERT, { + 0, + CMD.ATTACK, + CMD_OPT_INTERNAL, + o.x + math_cos(phase) * amp, + o.y, + o.z + math_sin(phase) * amp, + }, { "alt" }) end end for i, o in pairs(closeList) do @@ -66,6 +78,31 @@ if gadgetHandler:IsSyncedCode() then end end + function gadget:GameFramePost(frame) + for unitID, attack in pairs(activeAttacks) do + if frame >= attack.checkFrame then + local salvoLeft = Spring.GetUnitWeaponState(unitID, 1, "salvoLeft") + if not salvoLeft then + activeAttacks[unitID] = nil + elseif salvoLeft > 0 then + local nextSalvo = Spring.GetUnitWeaponState(unitID, 1, "nextSalvo") + attack.checkFrame = math_max(nextSalvo or 0, frame + 1) + else + activeAttacks[unitID] = nil + local commandID, _, commandTag = Spring.GetUnitCurrentCommand(unitID) + if commandID == CMD_ATTACK and commandTag == attack.commandTag then + -- Internal commands are not requeued by normal command completion. + Spring.UnitFinishCommand(unitID) + else + -- A command can move ahead while the burst is active. Remove only + -- the generated attack in that case. + Spring.GiveOrderToUnit(unitID, CMD.REMOVE, { attack.commandTag }, 0) + end + end + end + end + end + function gadget:AllowCommand( unitID, unitDefID, @@ -98,7 +135,13 @@ if gadgetHandler:IsSyncedCode() then end local dist = math_sqrt((x - param[1]) * (x - param[1]) + (z - param[3]) * (z - param[3])) if dist <= range[ud] - param[4] then - attackList[#attackList + 1] = { unit = u, x = param[1], y = param[2], z = param[3], radius = param[4] } + attackList[#attackList + 1] = { + unit = u, + x = param[1], + y = param[2], + z = param[3], + radius = param[4], + } else closeList[#closeList + 1] = { unit = u, x = param[1], y = param[2], z = param[3], radius = range[ud] - param[4] } @@ -108,15 +151,47 @@ if gadgetHandler:IsSyncedCode() then return false end + function gadget:ProjectileCreated(projectileID, ownerID, weaponDefID) + if not areaAttackWeaponDefs[weaponDefID] or activeAttacks[ownerID] then + return + end + + local unitDefID = Spring.GetUnitDefID(ownerID) + if areaAttackWeaponDefByUnitDef[unitDefID] ~= weaponDefID then + return + end + + local commandID, commandOptions, commandTag, _, _, targetZ = Spring.GetUnitCurrentCommand(ownerID) + if commandID ~= CMD_ATTACK or targetZ == nil or math_bit_and(commandOptions, CMD_OPT_INTERNAL) == 0 then + return + end + if Spring.GetUnitCurrentCommand(ownerID, 2) ~= CMD_AREA_ATTACK_GROUND then + return + end + + -- Poll weapon state after simulation instead of counting every projectile. + activeAttacks[ownerID] = { + commandTag = commandTag, + checkFrame = Spring.GetGameFrame(), + } + end + function gadget:UnitCreated(u, ud, team) if canAreaAttack[ud] then Spring.InsertUnitCmdDesc(u, aadesc) end end + function gadget:UnitDestroyed(unitID) + activeAttacks[unitID] = nil + end + function gadget:Initialize() gadgetHandler:RegisterCMDID(CMD_AREA_ATTACK_GROUND) gadgetHandler:RegisterAllowCommand(CMD_AREA_ATTACK_GROUND) + for weaponDefID in pairs(areaAttackWeaponDefs) do + Script.SetWatchProjectile(weaponDefID, true) + end end else -- UNSYNCED function gadget:Initialize() diff --git a/spec/luarules/gadgets/unit_areaattack_spec.lua b/spec/luarules/gadgets/unit_areaattack_spec.lua new file mode 100644 index 00000000000..668e359ab23 --- /dev/null +++ b/spec/luarules/gadgets/unit_areaattack_spec.lua @@ -0,0 +1,246 @@ +---@diagnostic disable: undefined-field + +local GADGET_PATH = "luarules/gadgets/unit_areaattack.lua" + +local CMD_ATTACK = 20 +local CMD_INSERT = 1 +local CMD_REMOVE = 2 +local CMD_OPT_INTERNAL = 8 +local CMD_AREA_ATTACK_GROUND = 39999 +local WEAPON_DEF_ID = 5 +local UNIT_DEF_ID = 1 +local UNIT_ID = 7 + +local function loadGadget(commands, weaponState) + local orders = {} + local finishedUnits = {} + local currentFrame = 1 + local unitDefLookups = 0 + local env = { + gadget = {}, + gadgetHandler = { + IsSyncedCode = function() + return true + end, + RegisterCMDID = function() end, + RegisterAllowCommand = function() end, + }, + GameCMD = { AREA_ATTACK_GROUND = CMD_AREA_ATTACK_GROUND }, + CMD = { + ATTACK = CMD_ATTACK, + INSERT = CMD_INSERT, + REMOVE = CMD_REMOVE, + OPT_INTERNAL = CMD_OPT_INTERNAL, + }, + CMDTYPE = { ICON_AREA = 5 }, + Game = { Commands = { ReissueOrder = function() end } }, + UnitDefs = { + [UNIT_DEF_ID] = { + weapons = { { weaponDef = WEAPON_DEF_ID } }, + customParams = { canareaattack = true }, + }, + }, + WeaponDefs = { [WEAPON_DEF_ID] = { range = 1000 } }, + math = setmetatable({ + bit_and = function(a, b) + return a % (b * 2) >= b and b or 0 + end, + }, { __index = math }), + Script = { SetWatchProjectile = function() end }, + Spring = { + GetGameFrame = function() + return currentFrame + end, + GetUnitCurrentCommand = function(_, index) + local command = commands[index or 1] + if command then + return command.id, command.options.coded, command.tag, unpack(command.params) + end + end, + GetUnitDefID = function() + unitDefLookups = unitDefLookups + 1 + return UNIT_DEF_ID + end, + GetUnitWeaponState = function(_, _, stateName) + return weaponState[stateName] + end, + GetUnitPosition = function() + return 0, 0, 0 + end, + GiveOrderToUnit = function(...) + orders[#orders + 1] = { ... } + end, + UnitFinishCommand = function(unitID) + finishedUnits[#finishedUnits + 1] = unitID + end, + InsertUnitCmdDesc = function() end, + SetUnitMoveGoal = function() end, + }, + } + setmetatable(env, { __index = _G }) + + local chunk = assert(loadfile(GADGET_PATH)) + setfenv(chunk, env) + chunk() + + return env.gadget, + orders, + finishedUnits, + function(frame) + currentFrame = frame + end, + function() + return unitDefLookups + end +end + +local function generatedAreaCommands() + return { + { + id = CMD_ATTACK, + tag = 43, + params = { 5, 0, 5 }, + options = { coded = CMD_OPT_INTERNAL, internal = true }, + }, + { id = CMD_AREA_ATTACK_GROUND, tag = 44, params = { 0, 0, 0, 10 }, options = { coded = 0 } }, + } +end + +describe("unit_areaattack", function() + it("changes area target after one complete salvo", function() + local commands = generatedAreaCommands() + local weaponState = { salvoLeft = 1, nextSalvo = 2 } + local gadget, _, finishedUnits, setFrame = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + assert.equals(0, #finishedUnits) + + weaponState.salvoLeft = 0 + setFrame(2) + gadget:GameFramePost(2) + assert.same({ UNIT_ID }, finishedUnits) + end) + + it("does not process every projectile in a burst", function() + local commands = generatedAreaCommands() + local weaponState = { salvoLeft = 2, nextSalvo = 2 } + local gadget, _, _, _, getUnitDefLookups = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:ProjectileCreated(2, UNIT_ID, WEAPON_DEF_ID) + gadget:ProjectileCreated(3, UNIT_ID, WEAPON_DEF_ID) + + assert.equals(1, getUnitDefLookups()) + end) + + it("finishes after a canceled final shot without another projectile", function() + local commands = generatedAreaCommands() + local weaponState = { salvoLeft = 1, nextSalvo = 2 } + local gadget, _, finishedUnits = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + weaponState.salvoLeft = 0 + gadget:GameFramePost(2) + + assert.same({ UNIT_ID }, finishedUnits) + end) + + it("follows a delayed next-salvo frame", function() + local commands = generatedAreaCommands() + local weaponState = { salvoLeft = 1, nextSalvo = 2 } + local gadget, _, finishedUnits = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + weaponState.nextSalvo = 4 + gadget:GameFramePost(2) + weaponState.salvoLeft = 0 + gadget:GameFramePost(3) + assert.equals(0, #finishedUnits) + gadget:GameFramePost(4) + + assert.same({ UNIT_ID }, finishedUnits) + end) + + it("ignores unrelated weapon projectiles", function() + local commands = generatedAreaCommands() + local gadget, orders, finishedUnits = loadGadget(commands, {}) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID + 1) + gadget:GameFramePost(1) + + assert.equals(0, #finishedUnits) + assert.equals(0, #orders) + end) + + it("ignores player-issued ground attacks", function() + local commands = generatedAreaCommands() + commands[1].options = { coded = 0, internal = false } + local weaponState = { salvoLeft = 0, nextSalvo = 1 } + local gadget, orders, finishedUnits = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + + assert.equals(0, #finishedUnits) + assert.equals(0, #orders) + end) + + it("keeps a generated attack persistent without its area command", function() + local commands = { generatedAreaCommands()[1] } + local weaponState = { salvoLeft = 0, nextSalvo = 1 } + local gadget, orders, finishedUnits = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + + assert.equals(0, #finishedUnits) + assert.equals(0, #orders) + end) + + for _, nextCommandID in ipairs({ CMD_ATTACK, 10 }) do + it("ignores an internal ground attack followed by command " .. nextCommandID, function() + local commands = generatedAreaCommands() + table.insert(commands, 2, { + id = nextCommandID, + tag = 45, + params = { 100, 0, 200 }, + options = { coded = 0 }, + }) + local gadget, orders, finishedUnits = loadGadget(commands, { salvoLeft = 0, nextSalvo = 1 }) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + gadget:GameFramePost(1) + + assert.equals(0, #finishedUnits) + assert.equals(0, #orders) + end) + end + + it("marks generated area shots internal so repeat does not retain them", function() + local commands = {} + local gadget, orders = loadGadget(commands, {}) + + gadget:CommandFallback(UNIT_ID, UNIT_DEF_ID, 0, CMD_AREA_ATTACK_GROUND, { 0, 0, 0, 10 }, {}) + gadget:GameFrame(1) + + assert.equals(CMD_INSERT, orders[1][2]) + assert.equals(CMD_OPT_INTERNAL, orders[1][3][3]) + end) + + it("removes only the generated shot if another command moves ahead of it", function() + local commands = generatedAreaCommands() + local generatedAttack = commands[1] + local weaponState = { salvoLeft = 0, nextSalvo = 1 } + local gadget, orders, finishedUnits = loadGadget(commands, weaponState) + + gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID) + commands[1] = { id = 10, tag = 99, params = { 0, 0, 0 }, options = { coded = 0 } } + gadget:GameFramePost(1) + + assert.equals(0, #finishedUnits) + assert.same({ UNIT_ID, CMD_REMOVE, { generatedAttack.tag }, 0 }, orders[1]) + end) +end)