This changeset is motivated by a simple concept - get rid of the MiniYaml.Clone and MiniYamlNode.Clone methods to avoid deep copying yaml trees during merging. MiniYaml becoming immutable allows the merge function to reuse existing yaml trees rather than cloning them, saving on memory and improving merge performance. On initial loading the YAML for all maps is processed, so this provides a small reduction in initial loading time. The rest of the changeset is dealing with the change in the exposed API surface. Some With* helper methods are introduced to allow creating new YAML from existing YAML. Areas of code that generated small amounts of YAML are able to transition directly to the immutable model without too much ceremony. Some use cases are far less ergonomic even with these helper methods and so a MiniYamlBuilder is introduced to retain mutable creation functionality. This allows those areas to continue to use the old mutable structures. The main users are the update rules and linting capabilities.
462 lines
16 KiB
C#
462 lines
16 KiB
C#
#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
|
|
{
|
|
[Desc("Manages AI squads.")]
|
|
public class SquadManagerBotModuleInfo : ConditionalTraitInfo
|
|
{
|
|
[ActorReference]
|
|
[Desc("Actor types that are valid for naval squads.")]
|
|
public readonly HashSet<string> NavalUnitsTypes = new();
|
|
|
|
[ActorReference]
|
|
[Desc("Actor types that are excluded from ground attacks.")]
|
|
public readonly HashSet<string> AirUnitsTypes = new();
|
|
|
|
[ActorReference]
|
|
[Desc("Actor types that should generally be excluded from attack squads.")]
|
|
public readonly HashSet<string> ExcludeFromSquadsTypes = new();
|
|
|
|
[ActorReference]
|
|
[Desc("Actor types that are considered construction yards (base builders).")]
|
|
public readonly HashSet<string> ConstructionYardTypes = new();
|
|
|
|
[ActorReference]
|
|
[Desc("Enemy building types around which to scan for targets for naval squads.")]
|
|
public readonly HashSet<string> NavalProductionTypes = new();
|
|
|
|
[ActorReference]
|
|
[Desc("Own actor types that are prioritized when defending.")]
|
|
public readonly HashSet<string> 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<TargetableType> 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<SquadManagerBotModuleInfo>, 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<Actor> unitCannotBeOrdered;
|
|
readonly List<Actor> unitsHangingAroundTheBase = new();
|
|
|
|
// Units that the bot already knows about. Any unit not on this list needs to be given a role.
|
|
readonly List<Actor> activeUnits = new();
|
|
|
|
public List<Squad> 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<HuskInfo>())
|
|
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<IVisibilityModifier>();
|
|
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<IBotPositionsUpdated>().ToArray();
|
|
notifyIdleBaseUnits = self.Owner.PlayerActor.TraitsImplementing<IBotNotifyIdleBaseUnits>().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 Actor FindClosestEnemy(WPos pos)
|
|
{
|
|
var units = World.Actors.Where(IsPreferredEnemyUnit);
|
|
return units.Where(IsNotHiddenUnit).ClosestTo(pos) ?? units.ClosestTo(pos);
|
|
}
|
|
|
|
internal Actor FindClosestEnemy(WPos pos, WDist radius)
|
|
{
|
|
return World.FindActorsInCircle(pos, radius).Where(a => IsPreferredEnemyUnit(a) && IsNotHiddenUnit(a)).ClosestTo(pos);
|
|
}
|
|
|
|
void CleanSquads()
|
|
{
|
|
Squads.RemoveAll(s => !s.IsValid);
|
|
foreach (var s in Squads)
|
|
s.Units.RemoveAll(unitCannotBeOrdered);
|
|
}
|
|
|
|
// 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 target = null)
|
|
{
|
|
var ret = new Squad(bot, this, type, target);
|
|
Squads.Add(ret);
|
|
return ret;
|
|
}
|
|
|
|
void AssignRolesToIdleUnits(IBot bot)
|
|
{
|
|
CleanSquads();
|
|
|
|
activeUnits.RemoveAll(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<IPositionable>()
|
|
.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);
|
|
|
|
foreach (var a in unitsHangingAroundTheBase)
|
|
attackForce.Units.Add(a);
|
|
|
|
unitsHangingAroundTheBase.Clear();
|
|
foreach (var n in notifyIdleBaseUnits)
|
|
n.UpdatedIdleBaseUnits(unitsHangingAroundTheBase);
|
|
}
|
|
}
|
|
|
|
void TryToRushAttack(IBot bot)
|
|
{
|
|
var allEnemyBaseBuilder = AIUtils.FindEnemiesByCommonName(Info.ConstructionYardTypes, Player);
|
|
|
|
var ownUnits = activeUnits
|
|
.Where(unit => unit.IsIdle && unit.Info.HasTraitInfo<AttackBaseInfo>()
|
|
&& !Info.AirUnitsTypes.Contains(unit.Info.Name) && !Info.NavalUnitsTypes.Contains(unit.Info.Name) && !Info.ExcludeFromSquadsTypes.Contains(unit.Info.Name)).ToList();
|
|
|
|
if (allEnemyBaseBuilder.Count == 0 || ownUnits.Count < Info.SquadSize)
|
|
return;
|
|
|
|
foreach (var b in allEnemyBaseBuilder)
|
|
{
|
|
// Don't rush enemy aircraft!
|
|
var enemies = World.FindActorsInCircle(b.CenterPosition, WDist.FromCells(Info.RushAttackScanRadius))
|
|
.Where(unit => IsPreferredEnemyUnit(unit) && unit.Info.HasTraitInfo<AttackBaseInfo>() && !Info.AirUnitsTypes.Contains(unit.Info.Name) && !Info.NavalUnitsTypes.Contains(unit.Info.Name)).ToList();
|
|
|
|
if (AttackOrFleeFuzzy.Rush.CanAttack(ownUnits, enemies))
|
|
{
|
|
var target = enemies.Count > 0 ? enemies.Random(World.LocalRandom) : b;
|
|
var rush = GetSquadOfType(SquadType.Rush);
|
|
rush ??= RegisterNewSquad(bot, SquadType.Rush, target);
|
|
|
|
foreach (var a3 in ownUnits)
|
|
rush.Units.Add(a3);
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProtectOwn(IBot bot, Actor attacker)
|
|
{
|
|
var protectSq = GetSquadOfType(SquadType.Protection);
|
|
protectSq ??= RegisterNewSquad(bot, SquadType.Protection, attacker);
|
|
|
|
if (!protectSq.IsTargetValid)
|
|
protectSq.TargetActor = attacker;
|
|
|
|
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<AttackBaseInfo>());
|
|
|
|
foreach (var a in ownUnits)
|
|
protectSq.Units.Add(a);
|
|
}
|
|
}
|
|
|
|
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<MiniYamlNode> IGameSaveTraitData.IssueTraitData(Actor self)
|
|
{
|
|
if (IsTraitDisabled)
|
|
return null;
|
|
|
|
return new List<MiniYamlNode>()
|
|
{
|
|
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<MiniYamlNode> data)
|
|
{
|
|
if (self.World.IsReplay)
|
|
return;
|
|
|
|
var initialBaseCenterNode = data.FirstOrDefault(n => n.Key == "InitialBaseCenter");
|
|
if (initialBaseCenterNode != null)
|
|
initialBaseCenter = FieldLoader.GetValue<CPos>("InitialBaseCenter", initialBaseCenterNode.Value.Value);
|
|
|
|
var unitsHangingAroundTheBaseNode = data.FirstOrDefault(n => n.Key == "UnitsHangingAroundTheBase");
|
|
if (unitsHangingAroundTheBaseNode != null)
|
|
{
|
|
unitsHangingAroundTheBase.Clear();
|
|
unitsHangingAroundTheBase.AddRange(FieldLoader.GetValue<uint[]>("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.AddRange(FieldLoader.GetValue<uint[]>("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<int>("RushTicks", rushTicksNode.Value.Value);
|
|
|
|
var assignRolesTicksNode = data.FirstOrDefault(n => n.Key == "AssignRolesTicks");
|
|
if (assignRolesTicksNode != null)
|
|
assignRolesTicks = FieldLoader.GetValue<int>("AssignRolesTicks", assignRolesTicksNode.Value.Value);
|
|
|
|
var attackForceTicksNode = data.FirstOrDefault(n => n.Key == "AttackForceTicks");
|
|
if (attackForceTicksNode != null)
|
|
attackForceTicks = FieldLoader.GetValue<int>("AttackForceTicks", attackForceTicksNode.Value.Value);
|
|
|
|
var minAttackForceDelayTicksNode = data.FirstOrDefault(n => n.Key == "MinAttackForceDelayTicks");
|
|
if (minAttackForceDelayTicksNode != null)
|
|
minAttackForceDelayTicks = FieldLoader.GetValue<int>("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));
|
|
}
|
|
}
|
|
}
|
|
}
|