From 00968a6565eee4b39d085ee945e5676914764688 Mon Sep 17 00:00:00 2001 From: Rasoul Date: Sat, 7 Mar 2026 22:55:27 +0100 Subject: [PATCH 1/2] Add statistics triggers --- luarules/gadgets/api_missions.lua | 4 +- luarules/gadgets/api_missions_triggers.lua | 42 +++- luarules/mission_api/actions.lua | 3 + luarules/mission_api/triggers_schema.lua | 52 ++++- .../statistics_triggers_test.lua | 208 ++++++++++++++++++ 5 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 singleplayer/mission-api-tests/statistics_triggers_test.lua diff --git a/luarules/gadgets/api_missions.lua b/luarules/gadgets/api_missions.lua index 916c186e80e..4b59d9d9c15 100644 --- a/luarules/gadgets/api_missions.lua +++ b/luarules/gadgets/api_missions.lua @@ -39,7 +39,9 @@ function gadget:Initialize() --scriptPath = 'mission-api-tests/validation_test.lua' --scriptPath = 'mission-api-tests/test_mission.lua' --scriptPath = 'mission-api-tests/unit_triggers_test.lua' - scriptPath = 'mission-api-tests/feature_triggers_test.lua' + --scriptPath = 'mission-api-tests/feature_triggers_test.lua' + --scriptPath = 'mission-api-tests/resource_test.lua' + scriptPath = 'mission-api-tests/statistics_triggers_test.lua' if not scriptPath then gadgetHandler:RemoveGadget() diff --git a/luarules/gadgets/api_missions_triggers.lua b/luarules/gadgets/api_missions_triggers.lua index 52418dc66a4..acb6c9e315f 100644 --- a/luarules/gadgets/api_missions_triggers.lua +++ b/luarules/gadgets/api_missions_triggers.lua @@ -348,6 +348,21 @@ local function checkFeatureDestroyed(trigger, featureID, featureDefID, attackerA activateTrigger(trigger) end +-- Per-team running totals, incremented by call-ins and checked by statistics triggers: +local totalUnitsLost = {} +local totalUnitsKilled = {} +local totalUnitsBuilt = {} +local totalUnitsCaptured = {} +local function checkStatisticsTriggers(triggerType, counters, teamID) + processTriggersOfType(triggerType, function(trigger, _) + if trigger.parameters.teamID ~= teamID then return end + local count = counters[teamID] or 0 + if count >= trigger.parameters.quantity then + activateTrigger(trigger) + end + end) +end + ---------------------------------------------------------------- --- Call-ins: @@ -404,10 +419,25 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) end) end -function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, _, _, _) +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam) processTriggersOfType(types.UnitKilled, function(trigger, _) checkUnitRemoved(trigger, unitID, unitDefID, unitTeam) end) + + -- The unit's team lost a unit: + totalUnitsLost[unitTeam] = (totalUnitsLost[unitTeam] or 0) + 1 + checkStatisticsTriggers(types.TotalUnitsLost, totalUnitsLost, unitTeam) + + -- The attacker's team kills an enemy unit: + if attackerTeam ~= nil and attackerTeam ~= unitTeam then + local unitAllyTeam = Spring.GetTeamAllyTeamID(unitTeam) + local attackerAllyTeam = Spring.GetTeamAllyTeamID(attackerTeam) + if unitAllyTeam and attackerAllyTeam and unitAllyTeam ~= attackerAllyTeam then + totalUnitsKilled[attackerTeam] = (totalUnitsKilled[attackerTeam] or 0) + 1 + checkStatisticsTriggers(types.TotalUnitsKilled, totalUnitsKilled, attackerTeam) + end + end + untrackUnitID(unitID) end @@ -415,6 +445,9 @@ function gadget:UnitTaken(unitID, unitDefID, oldTeam, newTeam) processTriggersOfType(types.UnitCaptured, function(trigger, _) checkUnitCaptured(trigger, unitID, unitDefID, oldTeam, newTeam) end) + + totalUnitsCaptured[newTeam] = (totalUnitsCaptured[newTeam] or 0) + 1 + checkStatisticsTriggers(types.TotalUnitsCaptured, totalUnitsCaptured, newTeam) end function gadget:UnitEnteredLos(unitID, unitTeam, allyTeamID, unitDefID) @@ -433,6 +466,13 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) processTriggersOfType(types.ConstructionFinished, function(trigger, _) checkConstructionFinished(trigger, unitID, unitDefID, unitTeam) end) + + -- Don't count units spawned by SpawnUnits action + if GG['MissionAPI'].spawningUnit then return end + -- Don't count starting commanders, initial loadout, etc. + if Spring.GetGameFrame() <= 0 then return end + totalUnitsBuilt[unitTeam] = (totalUnitsBuilt[unitTeam] or 0) + 1 + checkStatisticsTriggers(types.TotalUnitsBuilt, totalUnitsBuilt, unitTeam) end function gadget:TeamDied(teamID) diff --git a/luarules/mission_api/actions.lua b/luarules/mission_api/actions.lua index caa024a5693..53e81fbf865 100644 --- a/luarules/mission_api/actions.lua +++ b/luarules/mission_api/actions.lua @@ -71,7 +71,10 @@ local function spawnUnits(unitName, unitDefName, teamID, position, quantity, fac for _, pos in pairs(positions) do pos.y = Spring.GetGroundHeight(pos.x, pos.z) + -- Set flag for TotalUnitsBuilt trigger not to count units not actually built by the player. + GG['MissionAPI'].spawningUnit = true local unitID = Spring.CreateUnit(unitDefName, pos.x, pos.y, pos.z, facing or 's', teamID, construction) + GG['MissionAPI'].spawningUnit = false trackUnit(unitName, unitID) end end diff --git a/luarules/mission_api/triggers_schema.lua b/luarules/mission_api/triggers_schema.lua index 93f16790e04..358badeedae 100644 --- a/luarules/mission_api/triggers_schema.lua +++ b/luarules/mission_api/triggers_schema.lua @@ -382,10 +382,54 @@ local parameters = { [triggerTypes.ResourceProduction] = { }, -- Statistics - [triggerTypes.TotalUnitsLost] = { }, - [triggerTypes.TotalUnitsBuilt] = { }, - [triggerTypes.TotalUnitsKilled] = { }, - [triggerTypes.TotalUnitsCaptured] = { }, + [triggerTypes.TotalUnitsLost] = { + [1] = { + name = 'teamID', + required = true, + type = Types.TeamID, + }, + [2] = { + name = 'quantity', + required = true, + type = Types.Number, + }, + }, + [triggerTypes.TotalUnitsBuilt] = { + [1] = { + name = 'teamID', + required = true, + type = Types.TeamID, + }, + [2] = { + name = 'quantity', + required = true, + type = Types.Number, + }, + }, + [triggerTypes.TotalUnitsKilled] = { + [1] = { + name = 'teamID', + required = true, + type = Types.TeamID, + }, + [2] = { + name = 'quantity', + required = true, + type = Types.Number, + }, + }, + [triggerTypes.TotalUnitsCaptured] = { + [1] = { + name = 'teamID', + required = true, + type = Types.TeamID, + }, + [2] = { + name = 'quantity', + required = true, + type = Types.Number, + }, + }, -- Team [triggerTypes.TeamDestroyed] = { diff --git a/singleplayer/mission-api-tests/statistics_triggers_test.lua b/singleplayer/mission-api-tests/statistics_triggers_test.lua new file mode 100644 index 00000000000..9dac68835a7 --- /dev/null +++ b/singleplayer/mission-api-tests/statistics_triggers_test.lua @@ -0,0 +1,208 @@ +--- +--- Statistics triggers test mission. +--- + +local triggerTypes = GG['MissionAPI'].TriggerTypes +local actionTypes = GG['MissionAPI'].ActionTypes + +local triggers = { + + -- ── Spawns ──────────────────────────────────────────────────────────────── + + spawnBots = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 30, + }, + actions = { 'spawnFriendlyBot', 'spawnEnemyBot' }, + }, + + killFriendlyBot = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 180, + }, + actions = { 'killFriendlyBot' }, + }, + + spawnCapture = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 240, + }, + actions = { 'spawnCapturable', 'spawnCapturer' }, + }, + + orderCapture = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 270, + }, + actions = { 'orderCapture' }, + }, + + spawnBuilders = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 510, + }, + actions = { 'spawnConstructor', 'orderBuild' }, + }, + + -- ── Statistics triggers ─────────────────────────────────────────────────── + + totalUnitsKilledReached = { + type = triggerTypes.TotalUnitsKilled, + parameters = { + teamID = 0, + quantity = 1, + }, + actions = { 'messageTotalUnitsKilled' }, + }, + + totalUnitsLostReached = { + type = triggerTypes.TotalUnitsLost, + parameters = { + teamID = 0, + quantity = 1, + }, + actions = { 'messageTotalUnitsLost' }, + }, + + totalUnitsCapturedReached = { + type = triggerTypes.TotalUnitsCaptured, + parameters = { + teamID = 0, + quantity = 1, + }, + actions = { 'messageTotalUnitsCaptured' }, + }, + + totalUnitsBuiltReached = { + type = triggerTypes.TotalUnitsBuilt, + parameters = { + teamID = 0, + quantity = 1, + }, + actions = { 'messageTotalUnitsBuilt' }, + }, +} + +local actions = { + + -- ── Spawn ───────────────────────────────────────────────────────────────── + + spawnFriendlyBot = { + type = actionTypes.SpawnUnits, + parameters = { + unitName = 'friendlyBot', + unitDefName = 'armwar', + teamID = 0, + position = { x = 1800, z = 1800 }, + }, + }, + + spawnEnemyBot = { + type = actionTypes.SpawnUnits, + parameters = { + unitDefName = 'armpw', + teamID = 1, + position = { x = 1880, z = 1800 }, + }, + }, + + spawnCapturable = { + type = actionTypes.SpawnUnits, + parameters = { + unitDefName = 'armsolar', + teamID = 1, + position = { x = 1800, z = 1950 }, + }, + }, + + spawnCapturer = { + type = actionTypes.SpawnUnits, + parameters = { + unitName = 'capturer', + unitDefName = 'armdecom', + teamID = 0, + position = { x = 1900, z = 2000 }, + }, + }, + + spawnConstructor = { + type = actionTypes.SpawnUnits, + parameters = { + unitName = 'constructor', + unitDefName = 'armck', + teamID = 0, + position = { x = 1850, z = 2100 }, + quantity = 4, + }, + }, + + -- ── Orders ──────────────────────────────────────────────────────────────── + + orderCapture = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'capturer', + orders = { + { CMD.FIRE_STATE, CMD.FIRESTATE_HOLDFIRE }, + { CMD.CAPTURE, { 1800, 0, 1900, 200 } }, + }, + }, + }, + + orderBuild = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'constructor', + orders = { + { 'armwin', { 1950, 0, 2100 } }, + }, + }, + }, + + killFriendlyBot = { + type = actionTypes.DespawnUnits, + parameters = { + unitName = 'friendlyBot', + }, + }, + + -- ── Statistics trigger messages ─────────────────────────────────────────── + + messageTotalUnitsKilled = { + type = actionTypes.SendMessage, + parameters = { + message = "[Statistics Test] TotalUnitsKilled fired: Team 0 has killed >= 1 unit.", + }, + }, + + messageTotalUnitsLost = { + type = actionTypes.SendMessage, + parameters = { + message = "[Statistics Test] TotalUnitsLost fired: Team 0 has lost >= 1 unit.", + }, + }, + + messageTotalUnitsCaptured = { + type = actionTypes.SendMessage, + parameters = { + message = "[Statistics Test] TotalUnitsCaptured fired: Team 0 has captured >= 1 unit.", + }, + }, + + messageTotalUnitsBuilt = { + type = actionTypes.SendMessage, + parameters = { + message = "[Statistics Test] TotalUnitsBuilt fired: Team 0 has built >= 1 unit.", + }, + }, +} + +return { + Triggers = triggers, + Actions = actions, +} From 7454f6cc8551bc17b1d8749d75bdda6558fb14ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Markert?= <46371470+sorenmarkert@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:23:22 +0100 Subject: [PATCH 2/2] Mission API: Add feature triggers and actions (#7018) - Added actions CreateFeature and DestroyFeature. - Added triggers FeatureCreated, FeatureReclaimed, and FeatureDestroyed. --- luarules/gadgets/api_missions.lua | 20 +- luarules/gadgets/api_missions_triggers.lua | 108 ++++++++- luarules/mission_api/actions.lua | 80 ++++++- luarules/mission_api/actions_dispatcher.lua | 2 + luarules/mission_api/actions_loader.lua | 2 - luarules/mission_api/actions_schema.lua | 35 +++ luarules/mission_api/parameter_processing.lua | 8 +- luarules/mission_api/parameter_types.lua | 1 + luarules/mission_api/tracking.lua | 136 ++++++++--- luarules/mission_api/triggers_loader.lua | 2 - luarules/mission_api/triggers_schema.lua | 64 ++++- luarules/mission_api/validation.lua | 140 ++++++++--- .../feature_triggers_test.lua | 224 ++++++++++++++++++ .../mission-api-tests/issue_orders_test.lua | 62 ++++- .../mission-api-tests/validation_test.lua | 65 +++-- 15 files changed, 814 insertions(+), 135 deletions(-) create mode 100644 singleplayer/mission-api-tests/feature_triggers_test.lua diff --git a/luarules/gadgets/api_missions.lua b/luarules/gadgets/api_missions.lua index 0448d957f44..68c5589f9a4 100644 --- a/luarules/gadgets/api_missions.lua +++ b/luarules/gadgets/api_missions.lua @@ -29,16 +29,22 @@ local function loadMission() local validateReferences = VFS.Include('luarules/mission_api/validation.lua').ValidateReferences validateReferences() + + -- TODO: refactor loaders after merging loadouts + local parameterProcessing = VFS.Include('luarules/mission_api/parameter_processing.lua') + parameterProcessing.ProcessActionParameters(GG['MissionAPI'].Actions) + parameterProcessing.ProcessTriggerParameters(GG['MissionAPI'].Triggers) end function gadget:Initialize() -- TODO: Actually pass script path --scriptPath = 'mission-api-tests/validation_test.lua' --scriptPath = 'mission-api-tests/test_mission.lua' - --scriptPath = 'mission-api-tests/sound_test.lua' --scriptPath = 'mission-api-tests/markers_test.lua' + --scriptPath = 'mission-api-tests/sound_test.lua' + --scriptPath = 'mission-api-tests/issue_orders_test.lua' --scriptPath = 'mission-api-tests/unit_triggers_test.lua' - scriptPath = 'mission-api-tests/issue_orders_test.lua' + scriptPath = 'mission-api-tests/feature_triggers_test.lua' if not scriptPath then gadgetHandler:RemoveGadget() @@ -52,10 +58,12 @@ function gadget:Initialize() local actionsSchema = VFS.Include('luarules/mission_api/actions_schema.lua') GG['MissionAPI'].TriggerTypes = triggersSchema.Types GG['MissionAPI'].ActionTypes = actionsSchema.Types - GG['MissionAPI'].trackedUnitIDs = {} - GG['MissionAPI'].trackedUnitNames = {} - GG['MissionAPI'].soundFiles = {} - GG['MissionAPI'].soundQueue = {} + GG['MissionAPI'].trackedUnitIDs = {} + GG['MissionAPI'].trackedUnitNames = {} + GG['MissionAPI'].trackedFeatureIDs = {} + GG['MissionAPI'].trackedFeatureNames = {} + GG['MissionAPI'].soundFiles = {} + GG['MissionAPI'].soundQueue = {} triggersController = VFS.Include('luarules/mission_api/triggers_loader.lua') actionsController = VFS.Include('luarules/mission_api/actions_loader.lua') diff --git a/luarules/gadgets/api_missions_triggers.lua b/luarules/gadgets/api_missions_triggers.lua index 3bf8c0b4717..d6279e9b156 100644 --- a/luarules/gadgets/api_missions_triggers.lua +++ b/luarules/gadgets/api_missions_triggers.lua @@ -2,6 +2,8 @@ local gadget = gadget ---@type Gadget local doesUnitHaveName local untrackUnitID +local doesFeatureHaveName +local untrackFeatureID function gadget:GetInfo() return { @@ -17,8 +19,6 @@ if not gadgetHandler:IsSyncedCode() then return false end -local unitLocationCheckInterval = 15 - local actionsDispatcher local types, triggers @@ -161,14 +161,17 @@ local function checkUnitResurrected(trigger, unitDefID, unitTeam, builderID) return end - if Spring.GetUnitWorkerTask(builderID) ~= CMD.RESURRECT then + local cmdID, featureID = Spring.GetUnitWorkerTask(builderID) + if cmdID ~= CMD.RESURRECT then return end + if not Engine.FeatureSupport.noOffsetForFeatureID then + featureID = featureID - Game.maxUnits + end - -- TODO: feature tracking - --if trigger.parameters.featureName and not doesFeatureHaveName(featureID, trigger.parameters.featureName) then - -- return - --end + if trigger.parameters.featureName and not doesFeatureHaveName(featureID, trigger.parameters.featureName) then + return + end if trigger.parameters.unitDefName and trigger.parameters.unitDefName ~= UnitDefs[unitDefID].name then return end @@ -231,9 +234,7 @@ local function checkUnitDwellLocation(trigger, triggerID) elseif (dwellingUnitsInAreas[triggerID] == nil or dwellingUnitsInAreas[triggerID][unitID] == nil) and (not trigger.parameters.unitName or doesUnitHaveName(unitID, trigger.parameters.unitName)) and (not trigger.parameters.unitDefName or UnitDefs[Spring.GetUnitDefID(unitID)].name == trigger.parameters.unitDefName) then - if not dwellingUnitsInAreas[triggerID] then - dwellingUnitsInAreas[triggerID] = {} - end + table.ensureTable(dwellingUnitsInAreas, triggerID) dwellingUnitsInAreas[triggerID][unitID] = 0 end end @@ -294,6 +295,53 @@ local function checkTeamDestroyed(trigger, teamID) end end +local function isFeatureInArea(featureID, area) + local featureX, _, featureZ = Spring.GetFeaturePosition(featureID) + return math.isPointInArea(featureX, featureZ, area) +end + +local function checkFeatureCreated(trigger, featureID, featureDefID) + if trigger.parameters.featureDefName and trigger.parameters.featureDefName ~= FeatureDefs[featureDefID].name then + return + end + if trigger.parameters.area and not isFeatureInArea(featureID, trigger.parameters.area) then + return + end + activateTrigger(trigger) +end + +local function checkFeatureReclaimed(trigger, featureID, featureDefID, teamID) + if trigger.parameters.featureName and not doesFeatureHaveName(featureID, trigger.parameters.featureName) then + return + end + if trigger.parameters.featureDefName and trigger.parameters.featureDefName ~= FeatureDefs[featureDefID].name then + return + end + if trigger.parameters.teamID and teamID ~= trigger.parameters.teamID then + return + end + if trigger.parameters.area and not isFeatureInArea(featureID, trigger.parameters.area) then + return + end + activateTrigger(trigger) +end + +local function checkFeatureDestroyed(trigger, featureID, featureDefID, attackerAllyTeamID) + if trigger.parameters.featureName and not doesFeatureHaveName(featureID, trigger.parameters.featureName) then + return + end + if trigger.parameters.featureDefName and trigger.parameters.featureDefName ~= FeatureDefs[featureDefID].name then + return + end + if trigger.parameters.allyTeamID and attackerAllyTeamID ~= trigger.parameters.allyTeamID then + return + end + if trigger.parameters.area and not isFeatureInArea(featureID, trigger.parameters.area) then + return + end + activateTrigger(trigger) +end + ---------------------------------------------------------------- --- Call-ins: @@ -312,6 +360,8 @@ function gadget:Initialize() local tracking = VFS.Include('luarules/mission_api/tracking.lua') doesUnitHaveName = tracking.DoesUnitHaveName untrackUnitID = tracking.UntrackUnitID + doesFeatureHaveName = tracking.DoesFeatureHaveName + untrackFeatureID = tracking.UntrackFeatureID end function gadget:GameFrame(frameNumber) @@ -330,7 +380,6 @@ function gadget:GameFrame(frameNumber) processTriggersOfType(types.UnitDwellLocation, function(trigger, triggerID) checkUnitDwellLocation(trigger, triggerID) end) - end function gadget:MetaUnitAdded(_, unitDefID, unitTeam) @@ -393,3 +442,40 @@ function gadget:TeamDied(teamID) checkTeamDestroyed(trigger, teamID) end) end + +local reclaimedFeatures = {} +function gadget:AllowFeatureBuildStep(builderID, builderTeamID, featureID, featureDefID, buildStep) + if buildStep < 0 then + -- Negative buildStep means reclaim + reclaimedFeatures[featureID] = builderTeamID + end + return true +end + +function gadget:FeatureCreated(featureID, allyTeamID) + local featureDefID = Spring.GetFeatureDefID(featureID) + processTriggersOfType(types.FeatureCreated, function(trigger, _) + checkFeatureCreated(trigger, featureID, featureDefID) + end) +end + +function gadget:FeatureDestroyed(featureID, attackerAllyTeamID) + local featureDefID = Spring.GetFeatureDefID(featureID) + local _, _, _, _, reclaimLeft = Spring.GetFeatureResources(featureID) + local reclaimerTeamID = reclaimedFeatures[featureID] + + if reclaimerTeamID and reclaimLeft <= 0 then + -- Feature was fully reclaimed + processTriggersOfType(types.FeatureReclaimed, function(trigger, _) + checkFeatureReclaimed(trigger, featureID, featureDefID, reclaimerTeamID) + end) + else + -- Feature was destroyed, allyTeamID is the attacker's ally team. + processTriggersOfType(types.FeatureDestroyed, function(trigger, _) + checkFeatureDestroyed(trigger, featureID, featureDefID, attackerAllyTeamID) + end) + end + + reclaimedFeatures[featureID] = nil + untrackFeatureID(featureID) +end diff --git a/luarules/mission_api/actions.lua b/luarules/mission_api/actions.lua index 3081e6fe4cf..b39e3d8b873 100644 --- a/luarules/mission_api/actions.lua +++ b/luarules/mission_api/actions.lua @@ -1,10 +1,13 @@ local sounds = VFS.Include('luarules/mission_api/sounds.lua') local tracking = VFS.Include('luarules/mission_api/tracking.lua') local trackUnit = tracking.TrackUnit -local isNameUntracked = tracking.IsNameUntracked +local isUnitNameUntracked = tracking.IsUnitNameUntracked local untrackUnitName = tracking.UntrackUnitName +local trackFeature = tracking.TrackFeature +local isFeatureNameUntracked = tracking.IsFeatureNameUntracked local trackedUnitIDs = GG['MissionAPI'].trackedUnitIDs +local trackedFeatureIDs = GG['MissionAPI'].trackedFeatureIDs local triggers = GG['MissionAPI'].Triggers @@ -44,10 +47,10 @@ local function disableTrigger(triggerID) end local function issueOrders(unitName, orders) - if isNameUntracked(unitName) then return end + if isUnitNameUntracked(unitName) then return end - local commandsAcceptingName = { [CMD.GUARD] = true, [CMD.REPAIR] = true, [CMD.CAPTURE] = true, - [CMD.ATTACK] = true, [CMD.LOAD_UNITS] = true, [CMD.RECLAIM] = true } + local commandsAcceptingName = { [CMD.GUARD] = true, [CMD.REPAIR] = true, [CMD.CAPTURE] = true, [CMD.ATTACK] = true, + [CMD.LOAD_UNITS] = true, [CMD.RECLAIM] = true, [CMD.RESURRECT] = true } -- Replace name param with unitIDs, duplicating order for each unitID local newOrders = {} @@ -55,11 +58,22 @@ local function issueOrders(unitName, orders) local commandID = order[1] local params = order[2] or {} local options = order[3] or {} - if commandsAcceptingName[commandID] and type(params) == 'string' then - local unitIDs = trackedUnitIDs[params] or {} + + if commandsAcceptingName[commandID] and type(params) == 'table' and (params.unitName or params.featureName) then + local thingIDs = {} + local offset = 0 + if params.featureName then + thingIDs = trackedFeatureIDs[params.featureName] + if not Engine.FeatureSupport.noOffsetForFeatureID then + offset = Game.maxUnits + end + elseif params.unitName then + thingIDs = trackedUnitIDs[params.unitName] + end + local isFirstUnitID = true - for _, unitID in ipairs(unitIDs) do - newOrders[#newOrders + 1] = { commandID, unitID, table.copy(options) } + for thingID in pairs(thingIDs) do + newOrders[#newOrders + 1] = { commandID, thingID + offset, table.copy(options) } if isFirstUnitID then table.insert(options, 'shift') isFirstUnitID = false @@ -70,7 +84,7 @@ local function issueOrders(unitName, orders) end end - Spring.GiveOrderArrayToUnitArray(trackedUnitIDs[unitName], newOrders) + Spring.GiveOrderArrayToUnitMap(trackedUnitIDs[unitName], newOrders) end local function spawnUnits(unitName, unitDefName, teamID, position, quantity, facing, construction, spacing) @@ -98,10 +112,10 @@ end ---------------------------------------------------------------- local function despawnUnits(unitName, selfDestruct, reclaimed) - if isNameUntracked(unitName) then return end + if isUnitNameUntracked(unitName) then return end -- Copying table as UnitKilled trigger with SpawnUnits with the same name could cause infinite loop. - for _, unitID in pairs(table.copy(trackedUnitIDs[unitName])) do + for unitID in pairs(table.copy(trackedUnitIDs[unitName])) do if Spring.GetUnitIsDead(unitID) == false then Spring.DestroyUnit(unitID, selfDestruct, reclaimed) end @@ -111,10 +125,10 @@ end ---------------------------------------------------------------- local function transferUnits(unitName, newTeam) - if isNameUntracked(unitName) then return end + if isUnitNameUntracked(unitName) then return end -- Copying table as UnitExists trigger with TransferUnits with the same name could cause infinite loop. - for _, unitID in pairs(table.copy(trackedUnitIDs[unitName])) do + for unitID in pairs(table.copy(trackedUnitIDs[unitName])) do local given = Spring.GetUnitAllyTeam(unitID) == Spring.GetTeamAllyTeamID(newTeam) Spring.TransferUnit(unitID, newTeam, given) end @@ -169,6 +183,42 @@ local function unnameUnits(unitName) untrackUnitName(unitName) end +local corpseToUnitDefName = {} +for _, unitDef in pairs(UnitDefs) do + if unitDef.corpse and FeatureDefNames[unitDef.corpse] then + corpseToUnitDefName[unitDef.corpse] = unitDef.name + end +end + +local function createFeature(featureDefName, position, featureName, facing) + -- Convert named facing to a heading integer (engine uses 0-65535 headings) + local facingToHeading = { s = 0, n = 32768, e = 16384, w = 49152, + south = 0, north = 32768, east = 16384, west = 49152, + [0] = 0, [1] = 32768, [2] = 16384, [3] = 49152 } + local heading = facing and (facingToHeading[facing] or 0) or 0 + + local gaiaTeamID = Spring.GetGaiaTeamID() + local featureID = Spring.CreateFeature(featureDefName, position.x, position.y, position.z, heading, gaiaTeamID) + local unitDefName = corpseToUnitDefName[featureDefName] + if unitDefName then + Spring.SetFeatureResurrect(featureID, unitDefName, facing) + end + if featureID and featureName then + trackFeature(featureName, featureID) + end +end + +local function destroyFeature(featureName) + if isFeatureNameUntracked(featureName) then return end + + -- Copying table as FeatureDestroyed trigger with CreateFeature with the same name could cause infinite loop. + for featureID in pairs(table.copy(trackedFeatureIDs[featureName])) do + if Spring.ValidFeatureID(featureID) then + Spring.DestroyFeature(featureID) + end + end +end + local function spawnExplosion(weaponDefName, position, direction) direction = direction or { x = 0, y = 0, z = 0 } local weaponDef = WeaponDefNames[weaponDefName] @@ -267,6 +317,10 @@ return { NameUnits = nameUnits, UnnameUnits = unnameUnits, + -- Features + CreateFeature = createFeature, + DestroyFeature = destroyFeature, + -- SFX SpawnExplosion = spawnExplosion, diff --git a/luarules/mission_api/actions_dispatcher.lua b/luarules/mission_api/actions_dispatcher.lua index fffec3b96c4..830df1fae0b 100644 --- a/luarules/mission_api/actions_dispatcher.lua +++ b/luarules/mission_api/actions_dispatcher.lua @@ -19,6 +19,8 @@ local typeMapping = { [types.TransferUnits] = actionFunctions.TransferUnits, [types.NameUnits] = actionFunctions.NameUnits, [types.UnnameUnits] = actionFunctions.UnnameUnits, + [types.CreateFeature] = actionFunctions.CreateFeature, + [types.DestroyFeature] = actionFunctions.DestroyFeature, [types.SpawnExplosion] = actionFunctions.SpawnExplosion, -- [types.SpawnWeapons] = , -- [types.SpawnEffects] = , diff --git a/luarules/mission_api/actions_loader.lua b/luarules/mission_api/actions_loader.lua index e76e8f12fdf..dced85faa3c 100644 --- a/luarules/mission_api/actions_loader.lua +++ b/luarules/mission_api/actions_loader.lua @@ -1,5 +1,4 @@ local validateActions = VFS.Include('luarules/mission_api/validation.lua').ValidateActions -local processActionsParameters = VFS.Include('luarules/mission_api/parameter_processing.lua').ProcessActionsParameters --[[ actionID = { @@ -13,7 +12,6 @@ local processActionsParameters = VFS.Include('luarules/mission_api/parameter_pro local function processRawActions(rawActions) local actions = table.map(rawActions, table.copy) validateActions(actions) - processActionsParameters(actions) return actions end diff --git a/luarules/mission_api/actions_schema.lua b/luarules/mission_api/actions_schema.lua index f91a4106279..10306d30c06 100644 --- a/luarules/mission_api/actions_schema.lua +++ b/luarules/mission_api/actions_schema.lua @@ -22,6 +22,10 @@ local actionTypes = { NameUnits = 405, UnnameUnits = 406, + -- Features + CreateFeature = 450, + DestroyFeature = 451, + -- SFX SpawnExplosion = 500, SpawnWeapon = 501, -- maybe this should be renamed to SpawnProjectile to match the Spring function? @@ -199,6 +203,37 @@ local parameters = { } }, + -- Features + [actionTypes.CreateFeature] = { + [1] = { + name = 'featureDefName', + required = true, + type = Types.FeatureDefName, + }, + [2] = { + name = 'position', + required = true, + type = Types.Position, + }, + [3] = { + name = 'featureName', + required = false, + type = Types.String, + }, + [4] = { + name = 'facing', + required = false, + type = Types.Facing, + }, + }, + [actionTypes.DestroyFeature] = { + [1] = { + name = 'featureName', + required = true, + type = Types.String, + }, + }, + -- SFX [actionTypes.SpawnExplosion] = { [1] = { diff --git a/luarules/mission_api/parameter_processing.lua b/luarules/mission_api/parameter_processing.lua index 01d3c22cbd9..1cd396a60b1 100644 --- a/luarules/mission_api/parameter_processing.lua +++ b/luarules/mission_api/parameter_processing.lua @@ -58,15 +58,15 @@ local function processParameters(actionsOrTriggers, schemaParameters) end end -local function processActionsParameters(actions) +local function processActionParameters(actions) processParameters(actions, actionsSchemaParameters) end -local function processTriggersParameters(triggers) +local function processTriggerParameters(triggers) processParameters(triggers, triggersSchemaParameters) end return { - ProcessActionsParameters = processActionsParameters, - ProcessTriggersParameters = processTriggersParameters, + ProcessActionParameters = processActionParameters, + ProcessTriggerParameters = processTriggerParameters, } diff --git a/luarules/mission_api/parameter_types.lua b/luarules/mission_api/parameter_types.lua index ac88eb3d0d0..a74df3e51f1 100644 --- a/luarules/mission_api/parameter_types.lua +++ b/luarules/mission_api/parameter_types.lua @@ -16,6 +16,7 @@ local types = { String = 'String', TriggerID = 'TriggerID', UnitDefName = 'UnitDefName', + FeatureDefName = 'FeatureDefName', WeaponDefName = 'WeaponDefName', Facing = 'Facing', SoundFile = 'SoundFile', diff --git a/luarules/mission_api/tracking.lua b/luarules/mission_api/tracking.lua index 6b380474679..aaa5f48e623 100644 --- a/luarules/mission_api/tracking.lua +++ b/luarules/mission_api/tracking.lua @@ -1,70 +1,130 @@ --- ---- Utility module for tracking unit names and IDs. +--- Utility module for tracking unit/feature names and IDs. --- local trackedUnitIDs = GG['MissionAPI'].trackedUnitIDs local trackedUnitNames = GG['MissionAPI'].trackedUnitNames +local trackedFeatureIDs = GG['MissionAPI'].trackedFeatureIDs +local trackedFeatureNames = GG['MissionAPI'].trackedFeatureNames -local function trackUnit(name, unitID) - if not name or not unitID then +local function trackEntity(name, ID, trackedIDs, trackedNames) + if not name or not ID then return end - if not trackedUnitIDs[name] then - trackedUnitIDs[name] = {} + table.ensureTable(trackedIDs, name) + table.ensureTable(trackedNames, ID) + + trackedIDs[name][ID] = true + trackedNames[ID][name] = true +end + +local function isIDUntracked(ID, trackedNames) + return table.isNilOrEmpty(trackedNames[ID]) +end + +local function isNameUntracked(name, trackedIDs) + return table.isNilOrEmpty(trackedIDs[name]) +end + +local function doesEntityHaveName(ID, name, trackedIDs) + return (trackedIDs[name] or {})[ID] == true +end + +local function untrackID(ID, trackedIDs, trackedNames) + if isIDUntracked(ID, trackedNames) then return end + + for name in pairs(trackedNames[ID]) do + trackedIDs[name][ID] = nil + if next(trackedIDs[name]) == nil then + trackedIDs[name] = nil + end end - if not trackedUnitNames[unitID] then - trackedUnitNames[unitID] = {} + + trackedNames[ID] = nil +end + +local function untrackName(name, trackedIDs, trackedNames) + if isNameUntracked(name, trackedIDs) then return end + + for ID in pairs(trackedIDs[name]) do + trackedNames[ID][name] = nil + if next(trackedNames[ID]) == nil then + trackedNames[ID] = nil + end end + trackedIDs[name] = nil +end + +---------------------------------------------------------------- +--- Unit tracking: +---------------------------------------------------------------- - trackedUnitIDs[name][#trackedUnitIDs[name] + 1] = unitID - trackedUnitNames[unitID][#trackedUnitNames[unitID] + 1] = name +local function trackUnit(name, unitID) + trackEntity(name, unitID, trackedUnitIDs, trackedUnitNames) end local function isUnitIDUntracked(unitID) - return table.isNilOrEmpty(trackedUnitNames[unitID]) + return isIDUntracked(unitID, trackedUnitNames) end -local function isNameUntracked(name) - return table.isNilOrEmpty(trackedUnitIDs[name]) +local function isUnitNameUntracked(name) + return isNameUntracked(name, trackedUnitIDs) end local function doesUnitHaveName(unitID, name) - return table.contains(trackedUnitIDs[name] or {}, unitID) + return doesEntityHaveName(unitID, name, trackedUnitIDs) end local function untrackUnitID(unitID) - if isUnitIDUntracked(unitID) then - return - end + untrackID(unitID, trackedUnitIDs, trackedUnitNames) +end - for _, name in pairs(trackedUnitNames[unitID]) do - table.removeFirst(trackedUnitIDs[name], unitID) - if table.isEmpty(trackedUnitIDs[name]) then - trackedUnitIDs[name] = nil - end - end +local function untrackUnitName(name) + untrackName(name, trackedUnitIDs, trackedUnitNames) +end - trackedUnitNames[unitID] = nil +---------------------------------------------------------------- +--- Feature tracking: +---------------------------------------------------------------- + +local function trackFeature(name, featureID) + trackEntity(name, featureID, trackedFeatureIDs, trackedFeatureNames) end -local function untrackUnitName(name) - if isNameUntracked(name) then return end +local function isFeatureIDUntracked(featureID) + return isIDUntracked(featureID, trackedFeatureNames) +end - for _, unitID in pairs(trackedUnitIDs[name]) do - table.removeAll(trackedUnitNames[unitID], name) - if table.isEmpty(trackedUnitNames[unitID]) then - trackedUnitNames[unitID] = nil - end - end - trackedUnitIDs[name] = nil +local function isFeatureNameUntracked(name) + return isNameUntracked(name, trackedFeatureIDs) +end + +local function doesFeatureHaveName(featureID, name) + return doesEntityHaveName(featureID, name, trackedFeatureIDs) +end + +local function untrackFeatureID(featureID) + untrackID(featureID, trackedFeatureIDs, trackedFeatureNames) +end + +local function untrackFeatureName(name) + untrackName(name, trackedFeatureIDs, trackedFeatureNames) end return { - TrackUnit = trackUnit, - IsUnitIDUntracked = isUnitIDUntracked, - IsNameUntracked = isNameUntracked, - DoesUnitHaveName = doesUnitHaveName, - UntrackUnitID = untrackUnitID, - UntrackUnitName = untrackUnitName, + -- Unit tracking + TrackUnit = trackUnit, + IsUnitIDUntracked = isUnitIDUntracked, + IsUnitNameUntracked = isUnitNameUntracked, + DoesUnitHaveName = doesUnitHaveName, + UntrackUnitID = untrackUnitID, + UntrackUnitName = untrackUnitName, + -- Feature tracking + TrackFeature = trackFeature, + IsFeatureIDUntracked = isFeatureIDUntracked, + IsFeatureNameUntracked = isFeatureNameUntracked, + DoesFeatureHaveName = doesFeatureHaveName, + UntrackFeatureID = untrackFeatureID, + UntrackFeatureName = untrackFeatureName, } diff --git a/luarules/mission_api/triggers_loader.lua b/luarules/mission_api/triggers_loader.lua index 26eeb3b48b8..5519bbaaa8e 100644 --- a/luarules/mission_api/triggers_loader.lua +++ b/luarules/mission_api/triggers_loader.lua @@ -1,5 +1,4 @@ local validateTriggers = VFS.Include('luarules/mission_api/validation.lua').ValidateTriggers -local processTriggersParameters = VFS.Include('luarules/mission_api/parameter_processing.lua').ProcessTriggersParameters -- Example trigger --[[ @@ -41,7 +40,6 @@ local function processRawTriggers(rawTriggers, rawActions) end validateTriggers(triggers, rawActions) - processTriggersParameters(triggers) return triggers end diff --git a/luarules/mission_api/triggers_schema.lua b/luarules/mission_api/triggers_schema.lua index 60681e6d9c2..e697cb5c79e 100644 --- a/luarules/mission_api/triggers_schema.lua +++ b/luarules/mission_api/triggers_schema.lua @@ -325,9 +325,65 @@ local parameters = { }, -- Features - [triggerTypes.FeatureCreated] = { }, - [triggerTypes.FeatureReclaimed] = { }, - [triggerTypes.FeatureDestroyed] = { }, + [triggerTypes.FeatureCreated] = { + [1] = { + name = 'featureDefName', + required = false, + type = Types.FeatureDefName, + }, + [2] = { + name = 'area', + required = false, + type = Types.Area, + }, + requiresOneOf = { 'featureDefName', 'area' } + }, + [triggerTypes.FeatureReclaimed] = { + [1] = { + name = 'featureName', + required = false, + type = Types.String, + }, + [2] = { + name = 'featureDefName', + required = false, + type = Types.FeatureDefName, + }, + [3] = { + name = 'teamID', + required = false, + type = Types.TeamID, + }, + [4] = { + name = 'area', + required = false, + type = Types.Area, + }, + requiresOneOf = { 'featureName', 'featureDefName', 'teamID', 'area' } + }, + [triggerTypes.FeatureDestroyed] = { + [1] = { + name = 'featureName', + required = false, + type = Types.String, + }, + [2] = { + name = 'featureDefName', + required = false, + type = Types.FeatureDefName, + }, + [3] = { + name = 'allyTeamID', + required = false, + type = Types.AllyTeamID, + }, + [4] = { + name = 'area', + required = false, + type = Types.Area, + }, + requiresOneOf = { 'featureName', 'featureDefName', 'allyTeamID', 'area' } + }, -- Resources [triggerTypes.ResourceStored] = { }, @@ -346,7 +402,7 @@ local parameters = { required = true, type = Types.Number, }, - }, + }, -- Win Condition [triggerTypes.Victory] = { }, diff --git a/luarules/mission_api/validation.lua b/luarules/mission_api/validation.lua index 952c9d1752f..1f46599f243 100644 --- a/luarules/mission_api/validation.lua +++ b/luarules/mission_api/validation.lua @@ -127,28 +127,24 @@ validators[Types.Orders] = function(orders) local function validateOrderCommandAndParams(order, orderNumber) local commandID = order[1] local params = order[2] - local function validateSimpleTypeCurried(type) + local function validateNumberArrayCurried(sizes, message, nameKeys) return function() - local luaTypeResult = validateLuaType(params, type) + local luaTypeResult = validateLuaType(params, 'table') if luaTypeResult then result[#result + 1] = { message = luaTypeResult, parameterNameSuffix = '[' .. orderNumber .. '][2]' } - end - end - end - local function validateNumberArrayCurried(sizes, message, acceptString) - return function() - if acceptString and type(params) == 'string' then return end - local luaTypeResult = validateLuaType(params, 'table') - if luaTypeResult then - result[#result + 1] = { message = luaTypeResult, parameterNameSuffix = '[' .. orderNumber .. '][2]' } + + if nameKeys and table.any(nameKeys, function(nameKey) return params[nameKey] ~= nil end) then + -- params has a nameKey field, so it's a unit or feature name parameter return end + if not table.contains(sizes, #(params or {})) then result[#result + 1] = { message = "Parameter must be an array of " .. message, parameterNameSuffix = '[' .. orderNumber .. '][2]' } return end + for i, param in ipairs(params or {}) do local luaTypeRes = validateLuaType(param, 'number') if luaTypeRes then @@ -158,21 +154,29 @@ validators[Types.Orders] = function(orders) end end end - local validateNumber = validateSimpleTypeCurried('number') - local validateString = validateSimpleTypeCurried('string') - -- TODO: make y optional? as in: {123, nil, 123} + + local function validateNumber() + local luaTypeResult = validateLuaType(params, 'number') + if luaTypeResult then + result[#result + 1] = { message = luaTypeResult, parameterNameSuffix = '[' .. orderNumber .. '][2]' } + end + end + + local validateUnitName = validateNumberArrayCurried({ -1 }, "{ unitName = 'aUnitName' }", { 'unitName' }) local validate3 = validateNumberArrayCurried({ 3 }, "3 numbers {x, y, z}") - local validate3orName = validateNumberArrayCurried({ 3 }, "3 numbers {x, y, z}, or a unit name", true) + local validate3orUnitName = validateNumberArrayCurried({ 3 }, "3 numbers {x, y, z}, or a unit name", { 'unitName' }) local validate3or4 = validateNumberArrayCurried({ 3, 4 }, "3 or 4 numbers {x, y, z, optional radius}") local validate4 = validateNumberArrayCurried({ 4 }, "4 numbers {x, y, z, radius}") - local validate4orName = validateNumberArrayCurried({ 4 }, "4 numbers {x, y, z, radius}, or a unit name", true) + local validate4orUnitName = validateNumberArrayCurried({ 4 }, "4 numbers {x, y, z, radius}, or a unit name", { 'unitName' }) + local validate4orFeatureName = validateNumberArrayCurried({ 4 }, "4 numbers {x, y, z, radius}, or a feature name", { 'featureName' }) + local validate4orEitherName = validateNumberArrayCurried({ 4 }, "4 numbers {x, y, z, radius}, or a unit/feature name", { 'unitName', 'featureName' }) local commandValidators = { -- No parameters: [CMD.STOP] = false, [CMD.SELFD] = false, - -- Name parameter: - [CMD.GUARD] = validateString, + -- Unit name parameter: + [CMD.GUARD] = validateUnitName, -- 3 number parameters: [CMD.DGUN] = validate3, [CMD.MOVE] = validate3, @@ -181,17 +185,19 @@ validators[Types.Orders] = function(orders) -- 3 or 4 number parameters: [CMD.UNLOAD_UNITS] = validate3or4, -- 4 number parameters: - [CMD.RESURRECT] = validate4, [CMD.AREA_ATTACK] = false, -- currently broken in engine [GameCMD.AREA_ATTACK_GROUND] = validate4, -- Only artillery units (customParams.canareaattack = 1) support this [CMD.RESTORE] = validate4, -- 3 number parameters, or unit name: - [CMD.ATTACK] = validate3orName, + [CMD.ATTACK] = validate3orUnitName, -- 4 number parameters, or unit name: - [CMD.RECLAIM] = validate4orName, - [CMD.CAPTURE] = validate4orName, - [CMD.REPAIR] = validate4orName, - [CMD.LOAD_UNITS] = validate4orName, + [CMD.CAPTURE] = validate4orUnitName, + [CMD.REPAIR] = validate4orUnitName, + [CMD.LOAD_UNITS] = validate4orUnitName, + -- 4 number parameters, or feature name: + [CMD.RESURRECT] = validate4orFeatureName, + -- 4 number parameters, or either unit or feature name: + [CMD.RECLAIM] = validate4orEitherName, -- Single number parameter: [CMD.CLOAK] = validateNumber, [CMD.ONOFF] = validateNumber, @@ -327,6 +333,17 @@ validators[Types.WeaponDefName] = function(weaponDefName) end end +validators[Types.FeatureDefName] = function(featureDefName) + local luaTypeResult = validators[Types.String](featureDefName) + if luaTypeResult then + return luaTypeResult + end + + if not FeatureDefNames[featureDefName] then + return { { message = "Invalid featureDefName: " .. featureDefName } } + end +end + validators[Types.Facing] = function(facing) local expectedTypes = { string = true, number = true } local actualType = type(facing) @@ -513,9 +530,9 @@ local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, a if action.type == actionTypes.IssueOrders then for _, order in ipairs(action.parameters.orders) do local params = order[2] - if type(params) == 'string' then - referencedUnitNames[params] = referencedUnitNames[params] or {} - referencedUnitNames[params][#referencedUnitNames[params] + 1] = "action " .. actionID .. " (orders)" + if type(params) == 'table' and params.unitName then + local refsToUnitName = table.ensureTable(referencedUnitNames, params.unitName) + refsToUnitName[#refsToUnitName + 1] = "action " .. actionID .. " (orders)" end end end @@ -526,8 +543,8 @@ local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, a local unitName = (actionOrTrigger.parameters or {}).unitName if unitName then if typesNamingUnits[actionOrTrigger.type] then - createdUnitNames[unitName] = createdUnitNames[unitName] or {} - createdUnitNames[unitName][#createdUnitNames[unitName] + 1] = label .. actionOrTriggerID + local creatorsOfUnitName = table.ensureTable(createdUnitNames, unitName) + creatorsOfUnitName[#creatorsOfUnitName + 1] = label .. actionOrTriggerID elseif typesReferencingUnitNames[actionOrTrigger.type] then referencedUnitNames[unitName] = referencedUnitNames[unitName] or {} referencedUnitNames[unitName][#referencedUnitNames[unitName] + 1] = label .. actionOrTriggerID @@ -551,6 +568,66 @@ local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, a end end +local function validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions) + local triggerTypesReferencingFeatureNames = { + [triggerTypes.UnitResurrected] = true, + [triggerTypes.FeatureCreated] = true, + [triggerTypes.FeatureReclaimed] = true, + [triggerTypes.FeatureDestroyed] = true, + } + local actionTypesNamingFeatures = { + [actionTypes.CreateFeature] = true, + } + local actionTypesReferencingFeatureNames = { + [actionTypes.DestroyFeature] = true, + } + + local createdFeatureNames = {} + local referencedFeatureNames = {} + + -- Orders on IssueOrders actions can also refer to feature names: + for actionID, action in pairs(actions) do + if action.type == actionTypes.IssueOrders then + for _, order in ipairs(action.parameters.orders) do + local params = order[2] + if type(params) == 'table' and params.featureName then + local refsToFeatureName = table.ensureTable(referencedFeatureNames, params.featureName) + refsToFeatureName[#refsToFeatureName + 1] = "action " .. actionID .. " (orders)" + end + end + end + end + + local function recordFeatureNameCreationsAndReferences(typesNamingFeatures, typesReferencingFeatureNames, actionsOrTriggers, label) + for actionOrTriggerID, actionOrTrigger in pairs(actionsOrTriggers) do + local featureName = (actionOrTrigger.parameters or {}).featureName + if featureName then + if typesNamingFeatures[actionOrTrigger.type] then + local creatorsOfFeatureName = table.ensureTable(createdFeatureNames, featureName) + creatorsOfFeatureName[#creatorsOfFeatureName + 1] = label .. actionOrTriggerID + elseif typesReferencingFeatureNames[actionOrTrigger.type] then + local refsToFeatureName = table.ensureTable(referencedFeatureNames, featureName) + refsToFeatureName[#refsToFeatureName + 1] = label .. actionOrTriggerID + end + end + end + end + + recordFeatureNameCreationsAndReferences({}, triggerTypesReferencingFeatureNames, triggers, "trigger ") + recordFeatureNameCreationsAndReferences(actionTypesNamingFeatures, actionTypesReferencingFeatureNames, actions, "action ") + + for featureName, labels in pairs(referencedFeatureNames) do + if not createdFeatureNames[featureName] then + logError("Feature name '" .. featureName .. "' not created in any trigger or action. Referenced in: " .. table.concat(labels, ", ")) + end + end + for featureName, labels in pairs(createdFeatureNames) do + if not referencedFeatureNames[featureName] then + logError("Feature name '" .. featureName .. "' created, but not referenced by any trigger or action. Created in: " .. table.concat(labels, ", ")) + end + end +end + local function validateMarkerNameReferences(actionTypes, actions) local createdMarkerNames = {} local referencedMarkerNames = {} @@ -589,11 +666,12 @@ local function validateReferences() local triggers = GG['MissionAPI'].Triggers local actions = GG['MissionAPI'].Actions validateUnitNameReferences(triggerTypes, actionTypes, triggers, actions) + validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions) validateMarkerNameReferences(actionTypes, actions) end return { - ValidateTriggers = validateTriggers, - ValidateActions = validateActions, + ValidateTriggers = validateTriggers, + ValidateActions = validateActions, ValidateReferences = validateReferences, } diff --git a/singleplayer/mission-api-tests/feature_triggers_test.lua b/singleplayer/mission-api-tests/feature_triggers_test.lua new file mode 100644 index 00000000000..ff0446ced97 --- /dev/null +++ b/singleplayer/mission-api-tests/feature_triggers_test.lua @@ -0,0 +1,224 @@ +--- +--- Feature triggers test mission. +--- + +local triggerTypes = GG['MissionAPI'].TriggerTypes +local actionTypes = GG['MissionAPI'].ActionTypes + +local triggers = { + + spawnFeatures = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 30, + }, + actions = { 'createRockToReclaim', 'createRockToDestroy', 'createWreckToResurrect', 'createWreckToAttack', 'spawnReclaimer', 'spawnAttacker', 'orderAttackerDestroyWreck' }, + }, + + orderReclaimerReclaimAndRes = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 60, + }, + -- for some reason, CMD.RESURRECT doesn't work in the same frame as its target is spawned + actions = { 'orderReclaimerReclaimAndRes' }, + }, + + destroyRocks = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 150, + }, + actions = { 'destroyRocks' }, + }, + + rockCreated = { + type = triggerTypes.FeatureCreated, + parameters = { + featureDefName = 'rocks30_def_01', + area = { x1 = 1600, z1 = 1500, x2 = 2200, z2 = 2100 }, + }, + actions = { 'messageRocksCreated' }, + }, + + rockReclaimed = { + type = triggerTypes.FeatureReclaimed, + parameters = { + featureName = 'theRocks', + teamID = 0, + }, + actions = { 'messageRockReclaimed' }, + }, + + rockDestroyed = { + type = triggerTypes.FeatureDestroyed, + parameters = { + featureName = 'theRocks', + }, + actions = { 'messageRockDestroyed' }, + }, + + unitRessed = { + type = triggerTypes.UnitResurrected, + parameters = { + featureName = 'wreck-to-resurrect', + teamID = 0, + }, + actions = { 'messageWreckResurrected' }, + }, + + wreckDestroyed = { + type = triggerTypes.FeatureDestroyed, + parameters = { + featureName = 'wreck-to-destroy', + }, + actions = { 'messageWreckDestroyed' }, + }, + + wreckDestroyedInZone = { + type = triggerTypes.FeatureDestroyed, + parameters = { + featureDefName = 'armllt_dead', + area = { x = 2000, z = 2100, radius = 200 }, + }, + actions = { 'messageWreckDestroyedInZone' }, + }, +} + +local actions = { + + createRockToReclaim = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'theRocks', + featureDefName = 'rocks30_def_01', + position = { x = 1800, z = 1800 }, + facing = 's', + }, + }, + + createRockToDestroy = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'theRocks', + featureDefName = 'rocks30_def_01', + position = { x = 1900, z = 1900 }, + facing = 's', + }, + }, + + createWreckToAttack = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'wreck-to-destroy', + featureDefName = 'armllt_dead', + position = { x = 2000, z = 2100 }, + facing = 'w', + }, + }, + + createWreckToResurrect = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'wreck-to-resurrect', + featureDefName = 'armpw_dead', + position = { x = 1900, z = 2000 }, + facing = 'w', + }, + }, + + spawnReclaimer = { + type = actionTypes.SpawnUnits, + parameters = { + unitName = 'reclaimer', + unitDefName = 'armrectr', + teamID = 0, + position = { x = 1800, z = 1900 }, + }, + }, + + spawnAttacker = { + type = actionTypes.SpawnUnits, + parameters = { + unitName = 'attacker', + unitDefName = 'armham', + teamID = 0, + position = { x = 1800, z = 2100 }, + }, + }, + + orderReclaimerReclaimAndRes = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'reclaimer', + orders = { + { CMD.RECLAIM, { 1800, 0, 1800, 80 } }, + { CMD.RESURRECT, { 1900, 0, 2000, 80 }, { 'shift' } }, + }, + }, + }, + + orderAttackerDestroyWreck = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'attacker', + orders = { + { CMD.ATTACK, { 2000, 0, 2100 } }, + }, + }, + }, + + destroyRocks = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'theRocks', + }, + }, + + messageRocksCreated = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] Rocks were created in the area.", + }, + }, + + messageRockReclaimed = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] Named rock was reclaimed.", + }, + }, + + messageRockDestroyed = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] Named rock was destroyed.", + }, + }, + + messageWreckResurrected = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] Named wreck was resurrected.", + }, + }, + + messageWreckDestroyed = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] Named wreck was destroyed.", + }, + }, + + messageWreckDestroyedInZone = { + type = actionTypes.SendMessage, + parameters = { + message = "[Feature Test] An armllt wreck was destroyed inside the zone.", + }, + }, +} + +return { + Triggers = triggers, + Actions = actions, +} diff --git a/singleplayer/mission-api-tests/issue_orders_test.lua b/singleplayer/mission-api-tests/issue_orders_test.lua index d3e646f5fe2..41c5b141988 100644 --- a/singleplayer/mission-api-tests/issue_orders_test.lua +++ b/singleplayer/mission-api-tests/issue_orders_test.lua @@ -47,14 +47,22 @@ local triggers = { parameters = { gameFrame = 1100, }, - actions = { 'stop', 'messageStop' }, + actions = { 'stop', 'messageStop', 'spawnWreck1', 'spawnWreck2', 'spawnWreck3' }, }, - artilleryAreaAttack = { + reclaimWrecks = { type = triggerTypes.TimeElapsed, parameters = { gameFrame = 1200, }, + actions = { 'reclaimWrecks', 'messageReclaimWrecks' }, + }, + + artilleryAreaAttack = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 1400, + }, actions = { 'spawnArtilleryTargets', 'spawnArtillery', 'artilleryAreaAttack', 'messageArtilleryAreaAttack' }, }, } @@ -142,7 +150,7 @@ local actions = { unitName = 'attackers', orders = { { CMD.MOVE_STATE, CMD.MOVESTATE_HOLDPOS }, - { CMD.ATTACK, 'targets' }, + { CMD.ATTACK, { unitName = 'targets' } }, }, }, }, @@ -154,6 +162,50 @@ local actions = { }, }, + spawnWreck1 = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'wrecks', + featureDefName = 'armadvsol_dead', + position = { x = 2200, z = 2300 }, + }, + }, + + spawnWreck2 = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'wrecks', + featureDefName = 'armadvsol_dead', + position = { x = 2300, z = 2300 }, + }, + }, + + spawnWreck3 = { + type = actionTypes.CreateFeature, + parameters = { + featureName = 'wrecks', + featureDefName = 'armadvsol_dead', + position = { x = 2300, z = 2300 }, + }, + }, + + reclaimWrecks = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'attackers', + orders = { + { CMD.RECLAIM, { featureName = 'wrecks' } }, + }, + }, + }, + + messageReclaimWrecks = { + type = actionTypes.SendMessage, + parameters = { + message = "Reclaiming the wrecks by name, one by one.", + }, + }, + spawnArtilleryTargets = { type = actionTypes.SpawnUnits, parameters = { @@ -237,7 +289,7 @@ local actions = { parameters = { unitName = 'attackers', orders = { - { CMD.GUARD, 'fusions' }, + { CMD.GUARD, { unitName = 'fusions' } }, }, }, }, @@ -255,7 +307,7 @@ local actions = { unitName = 'attackers', orders = { { CMD.MOVE, { 2100, 0, 2100 }}, - { CMD.RECLAIM, 'fusions', { 'shift' } }, + { CMD.RECLAIM, { unitName = 'fusions' }, { 'shift' } }, { CMD.MOVE, { 2300, 0, 2900 }, { 'shift' } }, }, }, diff --git a/singleplayer/mission-api-tests/validation_test.lua b/singleplayer/mission-api-tests/validation_test.lua index 98ef69e56c0..14982efe37b 100644 --- a/singleplayer/mission-api-tests/validation_test.lua +++ b/singleplayer/mission-api-tests/validation_test.lua @@ -44,6 +44,14 @@ local triggers = { }, actions = { 'actionMissingType' }, }, + + triggerWithInvalidAreaType = { + type = triggerTypes.FeatureCreated, + parameters = { + area = 'notATable', + }, + actions = { 'actionMissingType' }, + }, } local actions = { @@ -125,24 +133,26 @@ local actions = { parameters = { unitName = 'validName', orders = { - { CMD.MOVE, { 1850, 0, 1500 }, { 'shift' } }, -- valid order - {}, -- empty order - { CMD.MOVE }, -- missing parameters - { CMD.MOVE, { 0, 0 } }, -- missing a parameter - { 99999, { 0, 0, 0 }, {} }, -- invalid command ID - { nil, { 0, 0, 0 }, {} }, -- missing command ID - { CMD.MOVE, {}, {} }, -- missing parameters - { CMD.MOVE, 'notATable', {} }, -- invalid parameters type - { CMD.MOVE, { 'invalidX', 0, 1800 }, {} }, -- invalid position - { CMD.MOVE, { 1850, 0, 1800 }, { 'invalidOption' } }, -- invalid option - { CMD.MOVE, { 1850, 0, 1800 }, { 'shift', 123 } }, -- invalid option type - { CMD.MOVE, { 1850, 0, 1800 }, 'notATable' }, -- invalid options type - { CMD.CLOAK, 'notANumber' }, -- invalid parameters type - { 'armllt' }, -- valid build order - { 'invalidUnitDefName' }, -- invalid unitDefName - { 'armllt', { 1850, 0, 1800, 4 } }, -- invalid facing - { 'armllt', { 1850, 0 } }, -- missing a parameter - { 'armllt', { 1850, 0, 'nonNumber' } }, -- invalid parameter type + { CMD.MOVE, { 1850, 0, 1500 }, { 'shift' } }, -- valid order + {}, -- empty order + { CMD.MOVE }, -- missing parameters + { CMD.MOVE, { 0, 0 } }, -- missing a parameter + { 99999, { 0, 0, 0 }, {} }, -- invalid command ID + { nil, { 0, 0, 0 }, {} }, -- missing command ID + { CMD.MOVE, {}, {} }, -- missing parameters + { CMD.MOVE, 'notATable', {} }, -- invalid parameters type + { CMD.MOVE, { 'invalidX', 0, 1800 }, {} }, -- invalid position + { CMD.MOVE, { 1850, 0, 1800 }, { 'invalidOption' } }, -- invalid option + { CMD.MOVE, { 1850, 0, 1800 }, { 'shift', 123 } }, -- invalid option type + { CMD.MOVE, { 1850, 0, 1800 }, 'notATable' }, -- invalid options type + { CMD.CLOAK, 'notANumber' }, -- invalid parameters type + { CMD.RECLAIM, { unitName = 'unknownUnitName' } }, -- unknown unit name in parameters + { CMD.RECLAIM, { featureName = 'unknownFeatureName' } }, -- unknown feature name in parameters + { 'armllt' }, -- valid build order + { 'invalidUnitDefName' }, -- invalid unitDefName + { 'armllt', { 1850, 0, 1800, 4 } }, -- invalid facing + { 'armllt', { 1850, 0 } }, -- missing a parameter + { 'armllt', { 1850, 0, 'nonNumber' } }, -- invalid parameter type }, }, }, @@ -218,6 +228,23 @@ local actions = { }, }, + actionWithInvalidFeatureDefNameAndInvalidFacingAndUnusedFeatureName = { + type = actionTypes.CreateFeature, + parameters = { + featureDefName = 'invalidFeatureDefName', + position = { x = 1800, z = 1600 }, + featureName = 'unusedFeatureName', + facing = 'invalidFacing', + }, + }, + + actionWithUnknownFeatureName = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'unknownFeatureName', + }, + }, + actionWithJustOneInvalidPosition = { type = actionTypes.DrawLines, parameters = { @@ -238,7 +265,7 @@ local actions = { actionWithInvalidMarkerName = { type = actionTypes.EraseMarker, parameters = { - name = 'invalidMarkerName', + name = 'unknownMarkerName', }, },