#region Copyright & License Information /* * Copyright (c) The OpenRA Developers and Contributors * This file is part of OpenRA, which is free software. It is made * available to you under the terms of the GNU General Public License * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. For more * information, see COPYING. */ #endregion using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using OpenRA.Mods.Common.Traits.BotModules.Squads; using OpenRA.Primitives; using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.Player)] [Desc("Manages AI squads.")] public class SquadManagerBotModuleInfo : ConditionalTraitInfo { [ActorReference] [Desc("Actor types that are valid for naval squads.")] public readonly HashSet NavalUnitsTypes = new(); [ActorReference] [Desc("Actor types that are excluded from ground attacks.")] public readonly HashSet AirUnitsTypes = new(); [ActorReference] [Desc("Actor types that should generally be excluded from attack squads.")] public readonly HashSet ExcludeFromSquadsTypes = new(); [ActorReference] [Desc("Actor types that are considered construction yards (base builders).")] public readonly HashSet ConstructionYardTypes = new(); [ActorReference] [Desc("Enemy building types around which to scan for targets for naval squads.")] public readonly HashSet NavalProductionTypes = new(); [ActorReference] [Desc("Own actor types that are prioritized when defending.")] public readonly HashSet ProtectionTypes = new(); [Desc("Minimum number of units AI must have before attacking.")] public readonly int SquadSize = 8; [Desc("Random number of up to this many units is added to squad size when creating an attack squad.")] public readonly int SquadSizeRandomBonus = 30; [Desc("Delay (in ticks) between giving out orders to units.")] public readonly int AssignRolesInterval = 50; [Desc("Delay (in ticks) between attempting rush attacks.")] public readonly int RushInterval = 600; [Desc("Delay (in ticks) between updating squads.")] public readonly int AttackForceInterval = 75; [Desc("Minimum delay (in ticks) between creating squads.")] public readonly int MinimumAttackForceDelay = 0; [Desc("Radius in cells around enemy BaseBuilder (Construction Yard) where AI scans for targets to rush.")] public readonly int RushAttackScanRadius = 15; [Desc("Radius in cells around the base that should be scanned for units to be protected.")] public readonly int ProtectUnitScanRadius = 15; [Desc("Maximum distance in cells from center of the base when checking for MCV deployment location.", "Only applies if RestrictMCVDeploymentFallbackToBase is enabled and there's at least one construction yard.")] public readonly int MaxBaseRadius = 20; [Desc("Radius in cells that squads should scan for enemies around their position while idle.")] public readonly int IdleScanRadius = 10; [Desc("Radius in cells that squads should scan for danger around their position to make flee decisions.")] public readonly int DangerScanRadius = 10; [Desc("Radius in cells that attack squads should scan for enemies around their position when trying to attack.")] public readonly int AttackScanRadius = 12; [Desc("Radius in cells that protecting squads should scan for enemies around their position.")] public readonly int ProtectionScanRadius = 8; [Desc("Enemy target types to never target.")] public readonly BitSet IgnoredEnemyTargetTypes = default; public override void RulesetLoaded(Ruleset rules, ActorInfo ai) { base.RulesetLoaded(rules, ai); if (DangerScanRadius <= 0) throw new YamlException("DangerScanRadius must be greater than zero."); } public override object Create(ActorInitializer init) { return new SquadManagerBotModule(init.Self, this); } } public class SquadManagerBotModule : ConditionalTrait, IBotEnabled, IBotTick, IBotRespondToAttack, IBotPositionsUpdated, IGameSaveTraitData { public CPos GetRandomBaseCenter() { var randomConstructionYard = World.Actors.Where(a => a.Owner == Player && Info.ConstructionYardTypes.Contains(a.Info.Name)) .RandomOrDefault(World.LocalRandom); return randomConstructionYard?.Location ?? initialBaseCenter; } public readonly World World; public readonly Player Player; readonly Predicate unitCannotBeOrdered; readonly List unitsHangingAroundTheBase = new(); // Units that the bot already knows about. Any unit not on this list needs to be given a role. readonly HashSet activeUnits = new(); public List Squads = new(); IBot bot; IBotPositionsUpdated[] notifyPositionsUpdated; IBotNotifyIdleBaseUnits[] notifyIdleBaseUnits; CPos initialBaseCenter; int rushTicks; int assignRolesTicks; int attackForceTicks; int minAttackForceDelayTicks; public SquadManagerBotModule(Actor self, SquadManagerBotModuleInfo info) : base(info) { World = self.World; Player = self.Owner; unitCannotBeOrdered = a => a == null || a.Owner != Player || a.IsDead || !a.IsInWorld; } // Use for proactive targeting. public bool IsPreferredEnemyUnit(Actor a) { if (a == null || a.IsDead || Player.RelationshipWith(a.Owner) != PlayerRelationship.Enemy || a.Info.HasTraitInfo()) return false; var targetTypes = a.GetEnabledTargetTypes(); return !targetTypes.IsEmpty && !targetTypes.Overlaps(Info.IgnoredEnemyTargetTypes); } public bool IsNotHiddenUnit(Actor a) { var hasModifier = false; var visModifiers = a.TraitsImplementing(); foreach (var v in visModifiers) { if (v.IsVisible(a, Player)) return true; hasModifier = true; } return !hasModifier; } protected override void Created(Actor self) { notifyPositionsUpdated = self.Owner.PlayerActor.TraitsImplementing().ToArray(); notifyIdleBaseUnits = self.Owner.PlayerActor.TraitsImplementing().ToArray(); } protected override void TraitEnabled(Actor self) { // Avoid all AIs trying to rush in the same tick, randomize their initial rush a little. var smallFractionOfRushInterval = Info.RushInterval / 20; rushTicks = World.LocalRandom.Next(Info.RushInterval - smallFractionOfRushInterval, Info.RushInterval + smallFractionOfRushInterval); // Avoid all AIs reevaluating assignments on the same tick, randomize their initial evaluation delay. assignRolesTicks = World.LocalRandom.Next(0, Info.AssignRolesInterval); attackForceTicks = World.LocalRandom.Next(0, Info.AttackForceInterval); minAttackForceDelayTicks = World.LocalRandom.Next(0, Info.MinimumAttackForceDelay); } void IBotEnabled.BotEnabled(IBot bot) { this.bot = bot; } void IBotTick.BotTick(IBot bot) { AssignRolesToIdleUnits(bot); } internal static Actor ClosestTo(IEnumerable ownActors, Actor targetActor) { // Return actors that can get within weapons range of the target. // First, let's determine the max weapons range for each of the actors. var target = Target.FromActor(targetActor); var ownActorsAndTheirAttackRanges = ownActors .Select(a => (Actor: a, AttackBases: a.TraitsImplementing().Where(Exts.IsTraitEnabled) .Where(ab => ab.HasAnyValidWeapons(target)).ToList())) .Where(x => x.AttackBases.Count > 0) .Select(x => (x.Actor, Range: x.AttackBases.Max(ab => ab.GetMaximumRangeVersusTarget(target)))) .ToDictionary(x => x.Actor, x => x.Range); // Now determine if each actor can either path directly to the target, // or if it can path to a nearby location at the edge of its weapon range to the target // A thorough check would check each position within the circle, but for performance // we'll only check 8 positions around the edge of the circle. // We need to account for the weapons range here to account for units such as boats. // They can't path directly to a land target, // but might be able to get close enough to shore to attack the target from range. return ownActorsAndTheirAttackRanges.Keys .ClosestToWithPathToAny(targetActor.World, a => { var range = ownActorsAndTheirAttackRanges[a].Length; var rangeDiag = Exts.MultiplyBySqrtTwoOverTwo(range); return new[] { targetActor.CenterPosition, targetActor.CenterPosition + new WVec(range, 0, 0), targetActor.CenterPosition + new WVec(-range, 0, 0), targetActor.CenterPosition + new WVec(0, range, 0), targetActor.CenterPosition + new WVec(0, -range, 0), targetActor.CenterPosition + new WVec(rangeDiag, rangeDiag, 0), targetActor.CenterPosition + new WVec(-rangeDiag, rangeDiag, 0), targetActor.CenterPosition + new WVec(-rangeDiag, -rangeDiag, 0), targetActor.CenterPosition + new WVec(rangeDiag, -rangeDiag, 0), }; }); } internal IEnumerable<(Actor Actor, WVec Offset)> FindEnemies(IEnumerable actors, Actor sourceActor) { // Check units are in fact enemies and not hidden. // Then check which are in weapons range of the source. var activeAttackBases = sourceActor.TraitsImplementing().Where(Exts.IsTraitEnabled).ToArray(); var enemiesAndSourceAttackRanges = actors .Where(a => IsPreferredEnemyUnit(a) && IsNotHiddenUnit(a)) .Select(a => (Actor: a, AttackBases: activeAttackBases.Where(ab => ab.HasAnyValidWeapons(Target.FromActor(a))).ToList())) .Where(x => x.AttackBases.Count > 0) .Select(x => (x.Actor, Range: x.AttackBases.Max(ab => ab.GetMaximumRangeVersusTarget(Target.FromActor(x.Actor))))) .ToDictionary(x => x.Actor, x => x.Range); // Now determine if the source actor can path directly to the target, // or if it can path to a nearby location at the edge of its weapon range to the target // A thorough check would check each position within the circle, but for performance // we'll only check 8 positions around the edge of the circle. // We need to account for the weapons range here to account for units such as boats. // They can't path directly to a land target, // but might be able to get close enough to shore to attack the target from range. return enemiesAndSourceAttackRanges.Keys .WithPathFrom(sourceActor, a => { var range = enemiesAndSourceAttackRanges[a].Length; var rangeDiag = Exts.MultiplyBySqrtTwoOverTwo(range); return new[] { WVec.Zero, new WVec(range, 0, 0), new WVec(-range, 0, 0), new WVec(0, range, 0), new WVec(0, -range, 0), new WVec(rangeDiag, rangeDiag, 0), new WVec(-rangeDiag, rangeDiag, 0), new WVec(-rangeDiag, -rangeDiag, 0), new WVec(rangeDiag, -rangeDiag, 0), }; }) .Select(x => (x.Actor, x.ReachableOffsets.MinBy(o => o.LengthSquared))); } internal (Actor Actor, WVec Offset) FindClosestEnemy(Actor sourceActor) { return FindClosestEnemy(World.Actors, sourceActor); } internal (Actor Actor, WVec Offset) FindClosestEnemy(Actor sourceActor, WDist radius) { return FindClosestEnemy(World.FindActorsInCircle(sourceActor.CenterPosition, radius), sourceActor); } (Actor Actor, WVec Offset) FindClosestEnemy(IEnumerable actors, Actor sourceActor) { return WorldUtils.ClosestToIgnoringPath(FindEnemies(actors, sourceActor), x => x.Actor, sourceActor); } void CleanSquads() { foreach (var s in Squads) s.Units.RemoveWhere(unitCannotBeOrdered); Squads.RemoveAll(s => !s.IsValid); } // HACK: Use of this function requires that there is one squad of this type. Squad GetSquadOfType(SquadType type) { return Squads.FirstOrDefault(s => s.Type == type); } Squad RegisterNewSquad(IBot bot, SquadType type, (Actor Actor, WVec Offset) target = default) { var ret = new Squad(bot, this, type, target); Squads.Add(ret); return ret; } internal void UnregisterSquad(Squad squad) { activeUnits.ExceptWith(squad.Units); squad.Units.Clear(); // CleanSquads will remove the squad from the Squads list. // We can't do that here as this is designed to be called from within Squad.Update // and thus would mutate the Squads list we are iterating over. } void AssignRolesToIdleUnits(IBot bot) { CleanSquads(); activeUnits.RemoveWhere(unitCannotBeOrdered); unitsHangingAroundTheBase.RemoveAll(unitCannotBeOrdered); foreach (var n in notifyIdleBaseUnits) n.UpdatedIdleBaseUnits(unitsHangingAroundTheBase); if (--rushTicks <= 0) { rushTicks = Info.RushInterval; TryToRushAttack(bot); } if (--attackForceTicks <= 0) { attackForceTicks = Info.AttackForceInterval; foreach (var s in Squads) s.Update(); } if (--assignRolesTicks <= 0) { assignRolesTicks = Info.AssignRolesInterval; FindNewUnits(bot); } if (--minAttackForceDelayTicks <= 0) { minAttackForceDelayTicks = Info.MinimumAttackForceDelay; CreateAttackForce(bot); } } void FindNewUnits(IBot bot) { var newUnits = World.ActorsHavingTrait() .Where(a => a.Owner == Player && !Info.ExcludeFromSquadsTypes.Contains(a.Info.Name) && !activeUnits.Contains(a)); foreach (var a in newUnits) { if (Info.AirUnitsTypes.Contains(a.Info.Name)) { var air = GetSquadOfType(SquadType.Air); air ??= RegisterNewSquad(bot, SquadType.Air); air.Units.Add(a); } else if (Info.NavalUnitsTypes.Contains(a.Info.Name)) { var ships = GetSquadOfType(SquadType.Naval); ships ??= RegisterNewSquad(bot, SquadType.Naval); ships.Units.Add(a); } else unitsHangingAroundTheBase.Add(a); activeUnits.Add(a); } // Notifying here rather than inside the loop, should be fine and saves a bunch of notification calls foreach (var n in notifyIdleBaseUnits) n.UpdatedIdleBaseUnits(unitsHangingAroundTheBase); } void CreateAttackForce(IBot bot) { // Create an attack force when we have enough units around our base. // (don't bother leaving any behind for defense) var randomizedSquadSize = Info.SquadSize + World.LocalRandom.Next(Info.SquadSizeRandomBonus); if (unitsHangingAroundTheBase.Count >= randomizedSquadSize) { var attackForce = RegisterNewSquad(bot, SquadType.Assault); attackForce.Units.UnionWith(unitsHangingAroundTheBase); unitsHangingAroundTheBase.Clear(); foreach (var n in notifyIdleBaseUnits) n.UpdatedIdleBaseUnits(unitsHangingAroundTheBase); } } void TryToRushAttack(IBot bot) { var ownUnits = activeUnits .Where(unit => unit.IsIdle && unit.Info.HasTraitInfo() && !Info.AirUnitsTypes.Contains(unit.Info.Name) && !Info.NavalUnitsTypes.Contains(unit.Info.Name) && !Info.ExcludeFromSquadsTypes.Contains(unit.Info.Name)) .ToList(); if (ownUnits.Count < Info.SquadSize) return; var allEnemyBaseBuilder = FindEnemies( World.Actors.Where(a => Info.ConstructionYardTypes.Contains(a.Info.Name)), ownUnits.First()) .ToList(); if (allEnemyBaseBuilder.Count == 0 || ownUnits.Count < Info.SquadSize) return; foreach (var enemyBaseBuilder in allEnemyBaseBuilder) { // Don't rush enemy aircraft! var enemies = FindEnemies( World.FindActorsInCircle(enemyBaseBuilder.Actor.CenterPosition, WDist.FromCells(Info.RushAttackScanRadius)) .Where(unit => unit.Info.HasTraitInfo() && !Info.AirUnitsTypes.Contains(unit.Info.Name) && !Info.NavalUnitsTypes.Contains(unit.Info.Name)), ownUnits.First()) .ToList(); if (AttackOrFleeFuzzy.Rush.CanAttack(ownUnits, enemies.Select(x => x.Actor).ToList())) { var target = enemies.Count > 0 ? enemies.Random(World.LocalRandom) : enemyBaseBuilder; var rush = GetSquadOfType(SquadType.Rush); rush ??= RegisterNewSquad(bot, SquadType.Rush, target); rush.Units.UnionWith(ownUnits); return; } } } void ProtectOwn(IBot bot, Actor attacker) { var protectSq = GetSquadOfType(SquadType.Protection); protectSq ??= RegisterNewSquad(bot, SquadType.Protection, (attacker, WVec.Zero)); if (protectSq.IsValid && !protectSq.IsTargetValid()) protectSq.SetActorToTarget((attacker, WVec.Zero)); if (!protectSq.IsValid) { var ownUnits = World.FindActorsInCircle(World.Map.CenterOfCell(GetRandomBaseCenter()), WDist.FromCells(Info.ProtectUnitScanRadius)) .Where(unit => unit.Owner == Player && !Info.ProtectionTypes.Contains(unit.Info.Name) && unit.Info.HasTraitInfo()) .WithPathTo(World, attacker.CenterPosition); protectSq.Units.UnionWith(ownUnits); } } void IBotPositionsUpdated.UpdatedBaseCenter(CPos newLocation) { initialBaseCenter = newLocation; } void IBotPositionsUpdated.UpdatedDefenseCenter(CPos newLocation) { } void IBotRespondToAttack.RespondToAttack(IBot bot, Actor self, AttackInfo e) { if (!IsPreferredEnemyUnit(e.Attacker)) return; if (Info.ProtectionTypes.Contains(self.Info.Name)) { foreach (var n in notifyPositionsUpdated) n.UpdatedDefenseCenter(e.Attacker.Location); ProtectOwn(bot, e.Attacker); } } List IGameSaveTraitData.IssueTraitData(Actor self) { if (IsTraitDisabled) return null; return new List() { new MiniYamlNode("Squads", "", Squads.Select(s => new MiniYamlNode("Squad", s.Serialize())).ToList()), new MiniYamlNode("InitialBaseCenter", FieldSaver.FormatValue(initialBaseCenter)), new MiniYamlNode("UnitsHangingAroundTheBase", FieldSaver.FormatValue(unitsHangingAroundTheBase .Where(a => !unitCannotBeOrdered(a)) .Select(a => a.ActorID) .ToArray())), new MiniYamlNode("ActiveUnits", FieldSaver.FormatValue(activeUnits .Where(a => !unitCannotBeOrdered(a)) .Select(a => a.ActorID) .ToArray())), new MiniYamlNode("RushTicks", FieldSaver.FormatValue(rushTicks)), new MiniYamlNode("AssignRolesTicks", FieldSaver.FormatValue(assignRolesTicks)), new MiniYamlNode("AttackForceTicks", FieldSaver.FormatValue(attackForceTicks)), new MiniYamlNode("MinAttackForceDelayTicks", FieldSaver.FormatValue(minAttackForceDelayTicks)), }; } void IGameSaveTraitData.ResolveTraitData(Actor self, ImmutableArray data) { if (self.World.IsReplay) return; var initialBaseCenterNode = data.FirstOrDefault(n => n.Key == "InitialBaseCenter"); if (initialBaseCenterNode != null) initialBaseCenter = FieldLoader.GetValue("InitialBaseCenter", initialBaseCenterNode.Value.Value); var unitsHangingAroundTheBaseNode = data.FirstOrDefault(n => n.Key == "UnitsHangingAroundTheBase"); if (unitsHangingAroundTheBaseNode != null) { unitsHangingAroundTheBase.Clear(); unitsHangingAroundTheBase.AddRange(FieldLoader.GetValue("UnitsHangingAroundTheBase", unitsHangingAroundTheBaseNode.Value.Value) .Select(a => self.World.GetActorById(a)).Where(a => a != null)); } var activeUnitsNode = data.FirstOrDefault(n => n.Key == "ActiveUnits"); if (activeUnitsNode != null) { activeUnits.Clear(); activeUnits.UnionWith(FieldLoader.GetValue("ActiveUnits", activeUnitsNode.Value.Value) .Select(a => self.World.GetActorById(a)).Where(a => a != null)); } var rushTicksNode = data.FirstOrDefault(n => n.Key == "RushTicks"); if (rushTicksNode != null) rushTicks = FieldLoader.GetValue("RushTicks", rushTicksNode.Value.Value); var assignRolesTicksNode = data.FirstOrDefault(n => n.Key == "AssignRolesTicks"); if (assignRolesTicksNode != null) assignRolesTicks = FieldLoader.GetValue("AssignRolesTicks", assignRolesTicksNode.Value.Value); var attackForceTicksNode = data.FirstOrDefault(n => n.Key == "AttackForceTicks"); if (attackForceTicksNode != null) attackForceTicks = FieldLoader.GetValue("AttackForceTicks", attackForceTicksNode.Value.Value); var minAttackForceDelayTicksNode = data.FirstOrDefault(n => n.Key == "MinAttackForceDelayTicks"); if (minAttackForceDelayTicksNode != null) minAttackForceDelayTicks = FieldLoader.GetValue("MinAttackForceDelayTicks", minAttackForceDelayTicksNode.Value.Value); var squadsNode = data.FirstOrDefault(n => n.Key == "Squads"); if (squadsNode != null) { Squads.Clear(); foreach (var n in squadsNode.Value.Nodes) Squads.Add(Squad.Deserialize(bot, this, n.Value)); } } } }