diff --git a/luarules/gadgets/api_missions.lua b/luarules/gadgets/api_missions.lua index 68c5589f9a4..bc90ab11cb5 100644 --- a/luarules/gadgets/api_missions.lua +++ b/luarules/gadgets/api_missions.lua @@ -16,16 +16,17 @@ end local sounds = VFS.Include('luarules/mission_api/sounds.lua') -local scriptPath local triggersController, actionsController -local function loadMission() +local function loadMission(scriptPath) local mission = VFS.Include("singleplayer/" .. scriptPath) local rawTriggers = mission.Triggers local rawActions = mission.Actions GG['MissionAPI'].Triggers = triggersController.ProcessRawTriggers(rawTriggers, rawActions) GG['MissionAPI'].Actions = actionsController.ProcessRawActions(rawActions) + GG['MissionAPI'].UnitLoadout = mission.UnitLoadout + GG['MissionAPI'].FeatureLoadout = mission.FeatureLoadout local validateReferences = VFS.Include('luarules/mission_api/validation.lua').ValidateReferences validateReferences() @@ -38,13 +39,14 @@ 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/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/feature_triggers_test.lua' + --local scriptPath = 'mission-api-tests/validation_test.lua' + --local scriptPath = 'mission-api-tests/test_mission.lua' + --local scriptPath = 'mission-api-tests/markers_test.lua' + --local scriptPath = 'mission-api-tests/sound_test.lua' + --local scriptPath = 'mission-api-tests/issue_orders_test.lua' + --local scriptPath = 'mission-api-tests/unit_triggers_test.lua' + --local scriptPath = 'mission-api-tests/feature_triggers_test.lua' + local scriptPath = 'mission-api-tests/loadout_test.lua' if not scriptPath then gadgetHandler:RemoveGadget() @@ -68,7 +70,26 @@ function gadget:Initialize() triggersController = VFS.Include('luarules/mission_api/triggers_loader.lua') actionsController = VFS.Include('luarules/mission_api/actions_loader.lua') - loadMission(); + loadMission(scriptPath) +end + +function gadget:GamePreload() + if Spring.GetGameRulesParam("loadedGame") == 1 then + Spring.Echo("Mission API: Loading saved game, skipping loadout") + return + end + + if GG['MissionAPI'].UnitLoadout then + Spring.Echo("Mission API: Creating unit loadout") + end + if GG['MissionAPI'].FeatureLoadout then + Spring.Echo("Mission API: Creating feature loadout") + end + + local tracking = VFS.Include('luarules/mission_api/tracking.lua') + local loadoutModule = VFS.Include('luarules/mission_api/loadout.lua') + loadoutModule.SpawnUnitLoadout(GG['MissionAPI'].UnitLoadout, tracking.TrackUnit) + loadoutModule.SpawnFeatureLoadout(GG['MissionAPI'].FeatureLoadout, tracking.TrackFeature) end function gadget:GameFrame(frameNumber) diff --git a/luarules/mission_api/actions.lua b/luarules/mission_api/actions.lua index b39e3d8b873..02bb125d662 100644 --- a/luarules/mission_api/actions.lua +++ b/luarules/mission_api/actions.lua @@ -1,3 +1,4 @@ +local loadout = VFS.Include('luarules/mission_api/loadout.lua') local sounds = VFS.Include('luarules/mission_api/sounds.lua') local tracking = VFS.Include('luarules/mission_api/tracking.lua') local trackUnit = tracking.TrackUnit @@ -191,14 +192,7 @@ for _, unitDef in pairs(UnitDefs) do 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 featureID = loadout.CreateFeature(featureDefName, position, facing) local unitDefName = corpseToUnitDefName[featureDefName] if unitDefName then Spring.SetFeatureResurrect(featureID, unitDefName, facing) @@ -219,6 +213,11 @@ local function destroyFeature(featureName) end end +local function spawnLoadout(unitLoadout, featureLoadout) + loadout.SpawnUnitLoadout(unitLoadout, trackUnit) + loadout.SpawnFeatureLoadout(featureLoadout, trackFeature) +end + local function spawnExplosion(weaponDefName, position, direction) direction = direction or { x = 0, y = 0, z = 0 } local weaponDef = WeaponDefNames[weaponDefName] @@ -316,6 +315,7 @@ return { TransferUnits = transferUnits, NameUnits = nameUnits, UnnameUnits = unnameUnits, + SpawnLoadout = spawnLoadout, -- Features CreateFeature = createFeature, diff --git a/luarules/mission_api/actions_dispatcher.lua b/luarules/mission_api/actions_dispatcher.lua index 830df1fae0b..9ed33494e86 100644 --- a/luarules/mission_api/actions_dispatcher.lua +++ b/luarules/mission_api/actions_dispatcher.lua @@ -4,41 +4,12 @@ local parameterSchema = actionsSchema.Parameters local types = GG['MissionAPI'].ActionTypes local actions = GG['MissionAPI'].Actions -local typeMapping = { --- TODO: Since the names are all the same, could we ditch this mapping and do it dynamically instead? - [types.EnableTrigger] = actionFunctions.EnableTrigger, - [types.DisableTrigger] = actionFunctions.DisableTrigger, - [types.IssueOrders] = actionFunctions.IssueOrders, - -- [types.AllowCommands] = , - -- [types.RestrictCommands] = , - -- [types.AlterBuildlist] = , - -- [types.EnableBuildOption] = , - -- [types.DisableBuildOption] = , - [types.SpawnUnits] = actionFunctions.SpawnUnits, - [types.DespawnUnits] = actionFunctions.DespawnUnits, - [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] = , - -- [types.RevealLOS] = , - -- [types.UnrevealLOS] = , - -- [types.AlterMapZones] = , - -- [types.ControlCamera] = , - -- [types.Pause] = , - -- [types.Unpause] = , - [types.PlaySound] = actionFunctions.PlaySound, - [types.SendMessage] = actionFunctions.SendMessage, - [types.AddMarker] = actionFunctions.AddMarker, - [types.EraseMarker] = actionFunctions.EraseMarker, - [types.DrawLines] = actionFunctions.DrawLines, - [types.ClearAllMarkers] = actionFunctions.ClearAllMarkers, - [types.Victory] = actionFunctions.Victory, - [types.Defeat] = actionFunctions.Defeat, -} +local typeMapping = {} +for name, typeId in pairs(types) do + if actionFunctions[name] then + typeMapping[typeId] = actionFunctions[name] + end +end -- unpack() does not handle optional parameters, as it cannot pass a value as nil local function unpackActionParameters(actionId, i) diff --git a/luarules/mission_api/actions_schema.lua b/luarules/mission_api/actions_schema.lua index 10306d30c06..4c58d3dfaac 100644 --- a/luarules/mission_api/actions_schema.lua +++ b/luarules/mission_api/actions_schema.lua @@ -21,6 +21,7 @@ local actionTypes = { TransferUnits = 404, NameUnits = 405, UnnameUnits = 406, + SpawnLoadout = 407, -- Features CreateFeature = 450, @@ -234,6 +235,20 @@ local parameters = { }, }, + [actionTypes.SpawnLoadout] = { + [1] = { + name = 'unitLoadout', + required = false, + type = Types.UnitLoadout, + }, + [2] = { + name = 'featureLoadout', + required = false, + type = Types.FeatureLoadout, + }, + requiresOneOf = { 'unitLoadout', 'featureLoadout' }, + }, + -- SFX [actionTypes.SpawnExplosion] = { [1] = { diff --git a/luarules/mission_api/loadout.lua b/luarules/mission_api/loadout.lua new file mode 100644 index 00000000000..d914a54591a --- /dev/null +++ b/luarules/mission_api/loadout.lua @@ -0,0 +1,69 @@ +--- +--- Loadout spawning logic used in :GamePreload() and the SpawnLoadout action. +--- + +-- 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 gaiaTeamID = Spring.GetGaiaTeamID() + +local function spawnUnitLoadout(unitLoadout, trackUnit) + for _, unit in ipairs(unitLoadout or {}) do + local y = Spring.GetGroundHeight(unit.x, unit.z) + local unitID = Spring.CreateUnit(unit.name, unit.x, y, unit.z, unit.facing or 's', unit.team) + if unitID then + if unit.neutral == true or unit.neutral == 'true' then + Spring.SetUnitNeutral(unitID, true) + end + if not table.isNilOrEmpty(unit.orders) then + for _, order in ipairs(unit.orders) do + local cmdID = order[1] + local params = order[2] or {} + local opts = order[3] or {} + -- TODO: update when orders can be on named units + Spring.GiveOrderToUnit(unitID, cmdID, params, opts) + end + else + Spring.GiveOrderToUnit(unitID, CMD.STOP, {}, 0) + end + if unit.unitName and trackUnit then + trackUnit(unit.unitName, unitID) + end + end + end +end + +local function createFeature(featureDefName, position, facing) + local heading = facingToHeading[facing or 0] + local featureID = Spring.CreateFeature(featureDefName, position.x, position.y, position.z, heading, gaiaTeamID) + + if featureDefName:sub(-5) == '_dead' then + local unitDefName = featureDefName:sub(1, -6) + if UnitDefNames[unitDefName] then + Spring.SetFeatureResurrect(featureID, unitDefName, facing) + end + end + + return featureID +end + +local function spawnFeatureLoadout(featureLoadout, trackFeature) + for _, feature in ipairs(featureLoadout or {}) do + feature.y = Spring.GetGroundHeight(feature.x, feature.z) + local featureID = createFeature(feature.name, feature, feature.facing) + + if featureID and feature.featureName and trackFeature then + trackFeature(feature.featureName, featureID) + end + end +end + +return { + SpawnUnitLoadout = spawnUnitLoadout, + SpawnFeatureLoadout = spawnFeatureLoadout, + CreateFeature = createFeature, +} diff --git a/luarules/mission_api/parameter_types.lua b/luarules/mission_api/parameter_types.lua index a74df3e51f1..b28c3908dc2 100644 --- a/luarules/mission_api/parameter_types.lua +++ b/luarules/mission_api/parameter_types.lua @@ -11,6 +11,8 @@ local types = { AllyTeamIDs = 'AllyTeamIDs', Orders = 'Orders', Area = 'Area', + UnitLoadout = 'UnitLoadout', + FeatureLoadout = 'FeatureLoadout', -- String Validators: String = 'String', diff --git a/luarules/mission_api/validation.lua b/luarules/mission_api/validation.lua index 1f46599f243..7100003af4f 100644 --- a/luarules/mission_api/validation.lua +++ b/luarules/mission_api/validation.lua @@ -498,7 +498,153 @@ local function validateActions(actions) end end -local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, actions) +---------------------------------------------------------------- +--- Loadout Validation: +---------------------------------------------------------------- + +local function validateUnitLoadoutEntry(entry, index, context) + local prefix = (context or "UnitLoadout") .. " entry #" .. index + + if type(entry) ~= 'table' then + logError(prefix .. ": entry must be a table, got " .. type(entry)) + return + end + + -- Required fields + if entry.name == nil then + logError(prefix .. ": missing required field 'name'") + else + local nameResult = validators[Types.UnitDefName](entry.name) + if nameResult and not table.isEmpty(nameResult) then + logError(prefix .. ", field 'name': " .. (nameResult[1] and nameResult[1].message or "invalid")) + end + end + + local positionResult = validators[Types.Position](entry) + for _, positionError in ipairs(positionResult or {}) do + logError(prefix .. ", " .. positionError.message .. (positionError.parameterNameSuffix or "")) + end + + if entry.team == nil then + logError(prefix .. ": missing required field 'team'") + else + local teamResult = validators[Types.TeamID](entry.team) + if teamResult and not table.isEmpty(teamResult) then + logError(prefix .. ", field 'team': " .. (teamResult[1] and teamResult[1].message or "invalid")) + end + end + + -- Optional fields + if entry.facing ~= nil then + local facingResult = validators[Types.Facing](entry.facing) + if facingResult and not table.isEmpty(facingResult) then + logError(prefix .. ", field 'facing': " .. (facingResult[1] and facingResult[1].message or "invalid")) + end + end + + if entry.unitName ~= nil then + local unitNameResult = validators[Types.String](entry.unitName) + if unitNameResult and not table.isEmpty(unitNameResult) then + logError(prefix .. ", field 'unitName': " .. (unitNameResult[1] and unitNameResult[1].message or "invalid")) + end + end + + if entry.neutral ~= nil then + local neutralResult = validators[Types.Boolean](entry.neutral) + if neutralResult and not table.isEmpty(neutralResult) then + logError(prefix .. ", field 'neutral': " .. (neutralResult[1] and neutralResult[1].message or "invalid")) + end + end + + if entry.orders ~= nil then + local ordersResult = validators[Types.Orders](entry.orders) + if ordersResult and not table.isEmpty(ordersResult) then + for _, err in ipairs(ordersResult) do + logError(prefix .. ", field 'orders'" .. (err.parameterNameSuffix or "") .. ": " .. (err.message or "invalid")) + end + end + end +end + +local function validateFeatureLoadoutEntry(entry, index, context) + local prefix = (context or "FeatureLoadout") .. " entry #" .. index + + if type(entry) ~= 'table' then + logError(prefix .. ": entry must be a table, got " .. type(entry)) + return + end + + -- Required fields + if entry.name == nil then + logError(prefix .. ": missing required field 'name'") + else + local nameResult = validators[Types.FeatureDefName](entry.name) + if nameResult and not table.isEmpty(nameResult) then + logError(prefix .. ", field 'name': " .. (nameResult[1] and nameResult[1].message or "invalid")) + end + end + + local positionResult = validators[Types.Position](entry) + for _, positionError in ipairs(positionResult or {}) do + logError(prefix .. ", " .. positionError.message .. (positionError.parameterNameSuffix or "")) + end + + -- Optional fields + if entry.facing ~= nil then + local facingResult = validators[Types.Facing](entry.facing) + if facingResult and not table.isEmpty(facingResult) then + logError(prefix .. ", field 'facing': " .. (facingResult[1] and facingResult[1].message or "invalid")) + end + end + + if entry.featureName ~= nil then + local featureNameResult = validators[Types.String](entry.featureName) + if featureNameResult and not table.isEmpty(featureNameResult) then + logError(prefix .. ", field 'featureName': " .. (featureNameResult[1] and featureNameResult[1].message or "invalid")) + end + end +end + +--- Validator for a unitLoadout table (array of unit entries). +--- Errors are logged directly by the entry helper; this returns {} so the +--- generic validate() machinery has nothing extra to report. +local function validateUnitLoadout(unitLoadout, actionOrTrigger, actionOrTriggerID, parameterName) + if type(unitLoadout) ~= 'table' then + return { { message = "UnitLoadout must be a table, got " .. type(unitLoadout) } } + end + local context = actionOrTriggerID and (actionOrTrigger .. " '" .. actionOrTriggerID .. "' " .. (parameterName or "unitLoadout")) + for i, entry in ipairs(unitLoadout) do + validateUnitLoadoutEntry(entry, i, context) + end + return {} +end + +--- Validator for a featureLoadout table (array of feature entries). +local function validateFeatureLoadout(featureLoadout, actionOrTrigger, actionOrTriggerID, parameterName) + if type(featureLoadout) ~= 'table' then + return { { message = "FeatureLoadout must be a table, got " .. type(featureLoadout) } } + end + local context = actionOrTriggerID and (actionOrTrigger .. " '" .. actionOrTriggerID .. "' " .. (parameterName or "featureLoadout")) + for i, entry in ipairs(featureLoadout) do + validateFeatureLoadoutEntry(entry, i, context) + end + return {} +end + +-- Patch the new types into the validators table now that the functions exist. +validators[Types.UnitLoadout] = validateUnitLoadout +validators[Types.FeatureLoadout] = validateFeatureLoadout + +local function validateLoadouts(unitLoadout, featureLoadout) + if unitLoadout ~= nil then + validateUnitLoadout(unitLoadout) + end + if featureLoadout ~= nil then + validateFeatureLoadout(featureLoadout) + end +end + +local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, actions, unitLoadout) local triggerTypesReferencingUnitNames = { [triggerTypes.UnitNotExists] = true, [triggerTypes.UnitKilled] = true, @@ -525,6 +671,26 @@ local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, a local createdUnitNames = {} local referencedUnitNames = {} + -- Loadout entries with a unitName count as creating that name. + for i, entry in ipairs(unitLoadout or {}) do + if type(entry) == 'table' and entry.unitName then + createdUnitNames[entry.unitName] = createdUnitNames[entry.unitName] or {} + createdUnitNames[entry.unitName][#createdUnitNames[entry.unitName] + 1] = "UnitLoadout entry #" .. i + end + end + + -- SpawnLoadout actions with inline unitLoadout entries also create names. + for actionID, action in pairs(actions) do + if action.type == actionTypes.SpawnLoadout and action.parameters and action.parameters.unitLoadout then + for i, entry in ipairs(action.parameters.unitLoadout) do + if type(entry) == 'table' and entry.unitName then + createdUnitNames[entry.unitName] = createdUnitNames[entry.unitName] or {} + createdUnitNames[entry.unitName][#createdUnitNames[entry.unitName] + 1] = "action " .. actionID .. ", unitLoadout entry #" .. i + end + end + end + end + -- Orders on IssueOrders actions can also refer to unit names: for actionID, action in pairs(actions) do if action.type == actionTypes.IssueOrders then @@ -568,7 +734,7 @@ local function validateUnitNameReferences(triggerTypes, actionTypes, triggers, a end end -local function validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions) +local function validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions, featureLoadout) local triggerTypesReferencingFeatureNames = { [triggerTypes.UnitResurrected] = true, [triggerTypes.FeatureCreated] = true, @@ -585,6 +751,26 @@ local function validateFeatureNameReferences(triggerTypes, actionTypes, triggers local createdFeatureNames = {} local referencedFeatureNames = {} + -- Loadout entries with a featureName count as creating that name. + for i, entry in ipairs(featureLoadout or {}) do + if type(entry) == 'table' and entry.featureName then + createdFeatureNames[entry.featureName] = createdFeatureNames[entry.featureName] or {} + createdFeatureNames[entry.featureName][#createdFeatureNames[entry.featureName] + 1] = "FeatureLoadout entry #" .. i + end + end + + -- SpawnLoadout actions with inline featureLoadout entries also create names. + for actionID, action in pairs(actions) do + if action.type == actionTypes.SpawnLoadout and action.parameters and action.parameters.featureLoadout then + for i, entry in ipairs(action.parameters.featureLoadout) do + if type(entry) == 'table' and entry.featureName then + createdFeatureNames[entry.featureName] = createdFeatureNames[entry.featureName] or {} + createdFeatureNames[entry.featureName][#createdFeatureNames[entry.featureName] + 1] = "action " .. actionID .. ", featureLoadout entry #" .. i + end + end + end + end + -- Orders on IssueOrders actions can also refer to feature names: for actionID, action in pairs(actions) do if action.type == actionTypes.IssueOrders then @@ -665,13 +851,16 @@ local function validateReferences() local actionTypes = GG['MissionAPI'].ActionTypes local triggers = GG['MissionAPI'].Triggers local actions = GG['MissionAPI'].Actions - validateUnitNameReferences(triggerTypes, actionTypes, triggers, actions) - validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions) + local unitLoadout = GG['MissionAPI'].UnitLoadout + local featureLoadout = GG['MissionAPI'].FeatureLoadout + validateUnitNameReferences(triggerTypes, actionTypes, triggers, actions, unitLoadout) + validateFeatureNameReferences(triggerTypes, actionTypes, triggers, actions, featureLoadout) validateMarkerNameReferences(actionTypes, actions) + validateLoadouts(unitLoadout, featureLoadout) end return { - ValidateTriggers = validateTriggers, - ValidateActions = validateActions, + ValidateTriggers = validateTriggers, + ValidateActions = validateActions, ValidateReferences = validateReferences, } diff --git a/singleplayer/mission-api-tests/loadout_test.lua b/singleplayer/mission-api-tests/loadout_test.lua new file mode 100644 index 00000000000..d7abc61e8fd --- /dev/null +++ b/singleplayer/mission-api-tests/loadout_test.lua @@ -0,0 +1,181 @@ +--- +--- Test mission demonstrating UnitLoadout and FeatureLoadout. +--- + +local triggerTypes = GG['MissionAPI'].TriggerTypes +local actionTypes = GG['MissionAPI'].ActionTypes + +local triggers = { + + intro = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 1, + }, + actions = { 'messageIntro' }, + }, + + movePlayerCon = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 60, + }, + actions = { 'movePlayerCon' }, + }, + + destroyWreck = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 120, + }, + actions = { 'destroyWreck', 'messageWreckDestroyed' }, + }, + + spawnReinforcements = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 200, + }, + actions = { 'spawnReinforcements', 'messageReinforcementsArrived' }, + }, + + actOnReinforcements = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 300, + }, + actions = { 'moveReinforcements', 'destroyReinforcementWreck', 'messageReinforcementsActedOn' }, + }, + + victory = { + type = triggerTypes.TimeElapsed, + parameters = { + gameFrame = 600, + }, + actions = { 'messageEnd', 'victory' }, + }, +} + +local actions = { + + messageIntro = { + type = actionTypes.SendMessage, + parameters = { + message = 'Loadout test: pre-spawned units and features are live. ' .. + 'Player con has an initial patrol order from loadout. ' .. + 'The wreck (featureName="the-wreck") will be destroyed.', + }, + }, + + movePlayerCon = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'player-con', + orders = { + { CMD.MOVE, { 2200, 0, 3000 }, {} }, + }, + }, + }, + + destroyWreck = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'the-wreck', + }, + }, + + messageWreckDestroyed = { + type = actionTypes.SendMessage, + parameters = { + message = 'Wreck destroyed via featureName tracking.', + }, + }, + + spawnReinforcements = { + type = actionTypes.SpawnLoadout, + parameters = { + unitLoadout = { + { name = 'armck', x = 1900, z = 1700, facing = 'e', team = 0, unitName = 'reinforcement-con' }, + { name = 'armflea', x = 1900, z = 1820, facing = 'e', team = 0, + orders = { + { CMD.MOVE, { 2100, 0, 2220 }, {} }, + }, + }, + }, + featureLoadout = { + { name = 'corak_dead', x = 1800, z = 1750, facing = 's', featureName = 'reinforcement-wreck' }, + }, + }, + }, + + messageReinforcementsArrived = { + type = actionTypes.SendMessage, + parameters = { + message = 'Reinforcements spawned via SpawnLoadout. Con tracked as "reinforcement-con", wreck as "reinforcement-wreck".', + }, + }, + + moveReinforcements = { + type = actionTypes.IssueOrders, + parameters = { + unitName = 'reinforcement-con', + orders = { + { CMD.MOVE, { 2200, 0, 3000 }, {} }, + }, + }, + }, + + destroyReinforcementWreck = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'reinforcement-wreck', + }, + }, + + messageReinforcementsActedOn = { + type = actionTypes.SendMessage, + parameters = { + message = 'Reinforcement con moved and wreck destroyed via names registered by SpawnLoadout.', + }, + }, + + messageEnd = { + type = actionTypes.SendMessage, + parameters = { + message = 'Loadout test complete.', + }, + }, + + victory = { + type = actionTypes.Victory, + parameters = { + allyTeamIDs = { 0 }, + }, + }, +} + +local unitLoadout = { + -- Player team (team 0). + { name = 'armck', x = 1780, z = 1850, facing = 'e', team = 0, unitName = 'player-con' }, + { name = 'corck', x = 1780, z = 1800, facing = 'e', team = 0, + orders = { + { CMD.PATROL, { 1780, 0, 1950 }, {} }, + }, + }, + + -- Enemy team (team 1) + { name = 'corsolar', x = 1700, z = 2150, facing = 'w', team = 1 }, + { name = 'corsolar', x = 1800, z = 2150, facing = 's', team = 1 }, +} + +local featureLoadout = { + { name = 'corak_dead', x = 1900, z = 1800, facing = 's', featureName = 'the-wreck' }, + { name = 'armfus_dead', x = 1900, z = 2000, facing = 'e' }, +} + +return { + Triggers = triggers, + Actions = actions, + UnitLoadout = unitLoadout, + FeatureLoadout = featureLoadout, +} diff --git a/singleplayer/mission-api-tests/validation_test.lua b/singleplayer/mission-api-tests/validation_test.lua index 14982efe37b..b81ab022d43 100644 --- a/singleplayer/mission-api-tests/validation_test.lua +++ b/singleplayer/mission-api-tests/validation_test.lua @@ -290,9 +290,126 @@ local actions = { soundfile = 'README.md', }, }, + + -- Valid SpawnLoadout - both names are referenced in the following 2 actions. + actionSpawnLoadoutValid = { + type = actionTypes.SpawnLoadout, + parameters = { + unitLoadout = { + { name = 'armcom', x = 2000, z = 2000, facing = 'n', team = 0, unitName = 'spawnedCom' }, + }, + featureLoadout = { + { name = 'corak_dead', x = 2100, z = 2000, facing = 's', featureName = 'spawnedWreck' }, + }, + }, + }, + + actionReferencingSpawnLoadoutUnitName = { + type = actionTypes.DespawnUnits, + parameters = { + unitName = 'spawnedCom', + }, + }, + + actionReferencingSpawnLoadoutFeatureName = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'spawnedWreck', + }, + }, + + actionSpawnLoadoutWithInvalidEntries = { + type = actionTypes.SpawnLoadout, + parameters = { + unitLoadout = { + { name = 'invalidUnit', x = 100, z = 100, facing = 'n', team = 0 }, + { name = 'armcom', x = 100, z = 100, facing = 'n' }, -- missing 'team' + { name = 'armcom', x = 100, z = 100, facing = 'n', team = 0, + orders = {}, -- empty orders table + }, + }, + featureLoadout = { + { name = 'corcom_dead', x = 100, z = 100, facing = 'invalidFacing' }, + }, + }, + }, + + -- This valid action references a featureName that only exists in FeatureLoadout; + actionReferencingLoadoutFeatureName = { + type = actionTypes.DestroyFeature, + parameters = { + featureName = 'loadoutWreck', + }, + }, + + -- This valid action references a unitName that only exists in UnitLoadout; + actionReferencingLoadoutUnitName = { + type = actionTypes.DespawnUnits, + parameters = { + unitName = 'loadoutCom', + }, + }, +} + +local unitLoadout = { + -- #1: valid entry (unitName is referenced by 'actionReferencingLoadoutUnitName') + { name = 'armcom', x = 1200, z = 800, facing = 'e', team = 0, unitName = 'loadoutCom' }, + + -- #2: invalid unitDefName + { name = 'invalidUnit', x = 1200, z = 800, facing = 'n', team = 0 }, + + -- #3: missing required field 'z' + { name = 'armcom', x = 1200, facing = 'n', team = 0 }, + + -- #4: missing required field 'team' + { name = 'armcom', x = 1200, z = 800, facing = 'n' }, + + -- #5: invalid facing value + { name = 'armcom', x = 1200, z = 800, facing = 'q', team = 0 }, + + -- #6: invalid type for 'neutral' + { name = 'armcom', x = 1200, z = 800, facing = 's', team = 0, neutral = 1 }, + + -- #7: unitName that is never referenced – should produce an "unreferenced" warning + { name = 'armcom', x = 1300, z = 900, facing = 's', team = 0, unitName = 'unusedLoadoutUnitName' }, + + -- #8: valid entry with orders (move, then queued patrol) + { name = 'armcom', x = 1400, z = 900, facing = 'n', team = 0, + orders = { + { CMD.MOVE, { 1500, 0, 900 }, {} }, -- valid move + { CMD.PATROL, { 1400, 0, 900 }, { 'shift' } }, -- valid queued patrol + }, + }, + + -- #9: orders table is empty – should produce an "Orders table is empty" error + { name = 'armcom', x = 1500, z = 900, facing = 'n', team = 0, + orders = {}, + }, +} + +local featureLoadout = { + -- #1: valid entry (featureName is referenced by 'actionReferencingLoadoutFeatureName') + { name = 'corcom_dead', x = 2000, z = 1500, facing = 's', featureName = 'loadoutWreck' }, + + -- #2: invalid featureDefName + { name = 'invalidFeature', x = 2000, z = 1500, facing = 's' }, + + -- #3: missing required field 'x' + { name = 'corcom_dead', z = 1500, facing = 's' }, + + -- #4: invalid facing value + { name = 'corcom_dead', x = 2000, z = 1500, facing = 'invalidFacing' }, + + -- #5: invalid type for 'featureName' + { name = 'corcom_dead', x = 2000, z = 1500, facing = 's', featureName = 42 }, + + -- #6: featureName that is never referenced – should produce an "unreferenced" warning + { name = 'corcom_dead', x = 2200, z = 1500, facing = 's', featureName = 'unusedLoadoutFeatureName' }, } return { - Triggers = triggers, - Actions = actions, + Triggers = triggers, + Actions = actions, + UnitLoadout = unitLoadout, + FeatureLoadout = featureLoadout, }