Files
OpenRA/OpenRA.Mods.Common/Traits/Cargo.cs
RoosterDragon 88fb83bc57 Remove caching of CurrentAdjacentCells in Cargo
In 05ed9d9a73 we stopped caching the values with ToArray to resolve a desync. But even caching the enumerable can lead to a desync, so remove the caching entirely.

----

Let's explain how the code that cached values via ToArray could desync.

Usually, the cell given by `self.Location` matches with the cell given by `self.GetTargetablePositions()`. However if the unit is moving and close to the boundary between two cells, it is possible for the targetable position to be an adjacent cell instead.

Combined with the fact hovering over the unit will evaluate `CurrentAdjacentCells` only for the local player and not everybody, the following sequence becomes possible to induce a desync:
- As the APC is moving into the last cell before unloading, the local player hovers over it. `self.Location` is the last cell, but `self.GetTargetablePositions()` gives the *previous* cell (as the unit is close to the boundary between the cells)
- The local player then caches `CurrentAdjacentCells`. The cache key of `self.Location` is the final cell, but the values are calculated for `self.GetTargetablePositions()` of an *adjacent* cell.
- When the order to unload is resolved, the cache key of `CurrentAdjacentCells` is already `self.Location` and so `CurrentAdjacentCells` is *not* updated.
- The units unload into cells based on the *adjacent* cell.

Then, for other players in the game:
- The hover does nothing for these players.
- When the order is resolved, `CurrentAdjacentCells` is out of date and is re-evaluated.
- `self.Location` and `self.GetTargetablePositions()` are both the last cell, because the unit has finished moving.
- So the cache is updated with a key of `self.Location` and values from the *same* cell.
- The units unload into cells based on the *current* cell.

As the units unload into different cells, a desync occurs. Ultimately the cause here is that cache key is insufficient - `self.Location` can have the same value but the output can differ. The function isn't a pure function so memoizing the result via `ToArray()` isn't sound.

Reverting it to cache the enumerable, which is then lazily re-evaluated reduces the scope of possible desyncs but is NOT a full solve. The cached enumerable caches the result of `Actor.GetTargetablePositions()` which isn't a fully lazy sequence. A different result is returned depending on `EnabledTargetablePositions.Any()`. Therefore, if the traits were to enable/disable inbetween, then we can still end up with different results. Memoizing the enumerable isn't sound either!

Currently our only trait is `HitShape` which is enabled based on conditions. A condition that enables/disables it based on movement would be one way to trigger this scenario. Let's say you have a unit where you toggle between two hit shapes when it is moving and when it stops moving. That would allow you to replicate the above scenario once again.

Instead of trying to come up with a sound caching mechanism in the face of a series of complex inputs, we just give up on trying to cache this information at all.
2024-08-01 22:58:15 +02:00

500 lines
14 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.Linq;
using OpenRA.Mods.Common.Activities;
using OpenRA.Mods.Common.Orders;
using OpenRA.Primitives;
using OpenRA.Traits;
namespace OpenRA.Mods.Common.Traits
{
[Desc("This actor can transport Passenger actors.")]
public class CargoInfo : ConditionalTraitInfo, Requires<IOccupySpaceInfo>
{
[Desc("The maximum sum of Passenger.Weight that this actor can support.")]
public readonly int MaxWeight = 0;
[Desc("`Passenger.CargoType`s that can be loaded into this actor.")]
public readonly HashSet<string> Types = new();
[Desc("A list of actor types that are initially spawned into this actor.")]
public readonly string[] InitialUnits = Array.Empty<string>();
[Desc("When this actor is sold should all of its passengers be unloaded?")]
public readonly bool EjectOnSell = true;
[Desc("When this actor dies should all of its passengers be unloaded?")]
public readonly bool EjectOnDeath = false;
[Desc("Terrain types that this actor is allowed to eject actors onto. Leave empty for all terrain types.")]
public readonly HashSet<string> UnloadTerrainTypes = new();
[VoiceReference]
[Desc("Voice to play when ordered to unload the passengers.")]
public readonly string UnloadVoice = "Action";
[Desc("Radius to search for a load/unload location if the ordered cell is blocked.")]
public readonly WDist LoadRange = WDist.FromCells(5);
[Desc("Which direction the passenger will face (relative to the transport) when unloading.")]
public readonly WAngle PassengerFacing = new(512);
[Desc("Delay (in ticks) before continuing after loading a passenger.")]
public readonly int AfterLoadDelay = 8;
[Desc("Delay (in ticks) before unloading the first passenger.")]
public readonly int BeforeUnloadDelay = 8;
[Desc("Delay (in ticks) before continuing after unloading a passenger.")]
public readonly int AfterUnloadDelay = 25;
[CursorReference]
[Desc("Cursor to display when able to unload the passengers.")]
public readonly string UnloadCursor = "deploy";
[CursorReference]
[Desc("Cursor to display when unable to unload the passengers.")]
public readonly string UnloadBlockedCursor = "deploy-blocked";
[GrantedConditionReference]
[Desc("The condition to grant to self while waiting for cargo to load.")]
public readonly string LoadingCondition = null;
[GrantedConditionReference]
[Desc("The condition to grant to self while passengers are loaded.",
"Condition can stack with multiple passengers.")]
public readonly string LoadedCondition = null;
[ActorReference(dictionaryReference: LintDictionaryReference.Keys)]
[Desc("Conditions to grant when specified actors are loaded inside the transport.",
"A dictionary of [actor name]: [condition].")]
public readonly Dictionary<string, string> PassengerConditions = new();
[GrantedConditionReference]
public IEnumerable<string> LinterPassengerConditions => PassengerConditions.Values;
public override object Create(ActorInitializer init) { return new Cargo(init, this); }
}
public class Cargo : ConditionalTrait<CargoInfo>, IIssueOrder, IResolveOrder, IOrderVoice,
INotifyOwnerChanged, INotifySold, INotifyActorDisposing, IIssueDeployOrder,
INotifyCreated, INotifyKilled, ITransformActorInitModifier
{
readonly Actor self;
readonly List<Actor> cargo = new();
readonly HashSet<Actor> reserves = new();
readonly Dictionary<string, Stack<int>> passengerTokens = new();
readonly Lazy<IFacing> facing;
readonly bool checkTerrainType;
int totalWeight = 0;
int reservedWeight = 0;
Aircraft aircraft;
int loadingToken = Actor.InvalidConditionToken;
readonly Stack<int> loadedTokens = new();
bool takeOffAfterLoad;
bool initialised;
public IEnumerable<Actor> Passengers => cargo;
public int PassengerCount => cargo.Count;
enum State { Free, Locked }
State state = State.Free;
public Cargo(ActorInitializer init, CargoInfo info)
: base(info)
{
self = init.Self;
checkTerrainType = info.UnloadTerrainTypes.Count > 0;
var runtimeCargoInit = init.GetOrDefault<RuntimeCargoInit>(info);
var cargoInit = init.GetOrDefault<CargoInit>(info);
if (runtimeCargoInit != null)
{
cargo = runtimeCargoInit.Value.ToList();
totalWeight = cargo.Sum(c => GetWeight(c));
}
else if (cargoInit != null)
{
foreach (var u in cargoInit.Value)
{
var unit = self.World.CreateActor(false, u.ToLowerInvariant(),
new TypeDictionary { new OwnerInit(self.Owner) });
cargo.Add(unit);
}
totalWeight = cargo.Sum(c => GetWeight(c));
}
else
{
foreach (var u in info.InitialUnits)
{
var unit = self.World.CreateActor(false, u.ToLowerInvariant(),
new TypeDictionary { new OwnerInit(self.Owner) });
cargo.Add(unit);
}
totalWeight = cargo.Sum(c => GetWeight(c));
}
facing = Exts.Lazy(self.TraitOrDefault<IFacing>);
}
protected override void Created(Actor self)
{
base.Created(self);
aircraft = self.TraitOrDefault<Aircraft>();
if (cargo.Count > 0)
{
foreach (var c in cargo)
if (Info.PassengerConditions.TryGetValue(c.Info.Name, out var passengerCondition))
passengerTokens.GetOrAdd(c.Info.Name).Push(self.GrantCondition(passengerCondition));
if (!string.IsNullOrEmpty(Info.LoadedCondition))
loadedTokens.Push(self.GrantCondition(Info.LoadedCondition));
}
// Defer notifications until we are certain all traits on the transport are initialised
self.World.AddFrameEndTask(w =>
{
foreach (var c in cargo)
{
c.Trait<Passenger>().Transport = self;
foreach (var nec in c.TraitsImplementing<INotifyEnteredCargo>())
nec.OnEnteredCargo(c, self);
foreach (var npe in self.TraitsImplementing<INotifyPassengerEntered>())
npe.OnPassengerEntered(self, c);
}
initialised = true;
});
}
static int GetWeight(Actor a) { return a.Info.TraitInfo<PassengerInfo>().Weight; }
public IEnumerable<IOrderTargeter> Orders
{
get
{
if (IsTraitDisabled)
yield break;
yield return new DeployOrderTargeter("Unload", 10,
() => CanUnload() ? Info.UnloadCursor : Info.UnloadBlockedCursor);
}
}
public Order IssueOrder(Actor self, IOrderTargeter order, in Target target, bool queued)
{
if (order.OrderID == "Unload")
return new Order(order.OrderID, self, queued);
return null;
}
Order IIssueDeployOrder.IssueDeployOrder(Actor self, bool queued)
{
return new Order("Unload", self, queued);
}
bool IIssueDeployOrder.CanIssueDeployOrder(Actor self, bool queued) { return true; }
public void ResolveOrder(Actor self, Order order)
{
if (order.OrderString == "Unload")
{
if (!order.Queued && !CanUnload())
return;
self.QueueActivity(order.Queued, new UnloadCargo(self, Info.LoadRange));
}
}
public IEnumerable<CPos> CurrentAdjacentCells()
{
return Util.AdjacentCells(self.World, Target.FromActor(self)).Where(c => self.Location != c);
}
public bool CanUnload(BlockedByActor check = BlockedByActor.None)
{
if (IsTraitDisabled)
return false;
if (checkTerrainType)
{
if (!self.World.Map.Contains(self.Location))
return false;
if (!Info.UnloadTerrainTypes.Contains(self.World.Map.GetTerrainInfo(self.Location).Type))
return false;
}
return !IsEmpty() && (aircraft == null || aircraft.CanLand(self.Location, blockedByMobile: false))
&& CurrentAdjacentCells().Any(c => Passengers.Any(p => !p.IsDead && p.Trait<IPositionable>().CanEnterCell(c, null, check)));
}
public bool CanLoad(Actor a)
{
return !IsTraitDisabled && (reserves.Contains(a) || HasSpace(GetWeight(a)));
}
internal bool ReserveSpace(Actor a)
{
if (reserves.Contains(a))
return true;
var w = GetWeight(a);
if (!HasSpace(w))
return false;
if (loadingToken == Actor.InvalidConditionToken)
loadingToken = self.GrantCondition(Info.LoadingCondition);
reserves.Add(a);
reservedWeight += w;
LockForPickup(self);
return true;
}
internal void UnreserveSpace(Actor a)
{
if (!reserves.Contains(a) || self.IsDead)
return;
reservedWeight -= GetWeight(a);
reserves.Remove(a);
ReleaseLock(self);
if (loadingToken != Actor.InvalidConditionToken)
loadingToken = self.RevokeCondition(loadingToken);
}
// Prepare for transport pickup
void LockForPickup(Actor self)
{
if (state == State.Locked)
return;
state = State.Locked;
self.CancelActivity();
var air = self.TraitOrDefault<Aircraft>();
if (air != null && !air.AtLandAltitude)
{
takeOffAfterLoad = true;
self.QueueActivity(new Land(self));
}
self.QueueActivity(new WaitFor(() => state != State.Locked, false));
}
void ReleaseLock(Actor self)
{
if (reservedWeight != 0)
return;
state = State.Free;
self.QueueActivity(new Wait(Info.AfterLoadDelay, false));
if (takeOffAfterLoad)
self.QueueActivity(new TakeOff(self));
takeOffAfterLoad = false;
}
public string VoicePhraseForOrder(Actor self, Order order)
{
if (order.OrderString != "Unload" || IsEmpty() || !self.HasVoice(Info.UnloadVoice))
return null;
return Info.UnloadVoice;
}
public bool HasSpace(int weight) { return totalWeight + reservedWeight + weight <= Info.MaxWeight; }
public bool IsEmpty() { return cargo.Count == 0; }
public Actor Peek() { return cargo.Last(); }
public Actor Unload(Actor self, Actor passenger = null)
{
passenger ??= cargo.Last();
if (!cargo.Remove(passenger))
throw new ArgumentException("Attempted to unload an actor that is not a passenger.");
totalWeight -= GetWeight(passenger);
SetPassengerFacing(passenger);
foreach (var npe in self.TraitsImplementing<INotifyPassengerExited>())
npe.OnPassengerExited(self, passenger);
foreach (var nec in passenger.TraitsImplementing<INotifyExitedCargo>())
nec.OnExitedCargo(passenger, self);
var p = passenger.Trait<Passenger>();
p.Transport = null;
if (passengerTokens.TryGetValue(passenger.Info.Name, out var passengerToken) && passengerToken.Count > 0)
self.RevokeCondition(passengerToken.Pop());
if (loadedTokens.Count > 0)
self.RevokeCondition(loadedTokens.Pop());
return passenger;
}
void SetPassengerFacing(Actor passenger)
{
if (facing.Value == null)
return;
var passengerFacing = passenger.TraitOrDefault<IFacing>();
if (passengerFacing != null)
passengerFacing.Facing = facing.Value.Facing + Info.PassengerFacing;
}
public void Load(Actor self, Actor a)
{
cargo.Add(a);
var w = GetWeight(a);
totalWeight += w;
if (reserves.Contains(a))
{
reservedWeight -= w;
reserves.Remove(a);
ReleaseLock(self);
if (loadingToken != Actor.InvalidConditionToken)
loadingToken = self.RevokeCondition(loadingToken);
}
// Don't initialise (effectively twice) if this runs before the FrameEndTask from Created
if (initialised)
{
a.Trait<Passenger>().Transport = self;
foreach (var nec in a.TraitsImplementing<INotifyEnteredCargo>())
nec.OnEnteredCargo(a, self);
foreach (var npe in self.TraitsImplementing<INotifyPassengerEntered>())
npe.OnPassengerEntered(self, a);
}
if (Info.PassengerConditions.TryGetValue(a.Info.Name, out var passengerCondition))
passengerTokens.GetOrAdd(a.Info.Name).Push(self.GrantCondition(passengerCondition));
if (!string.IsNullOrEmpty(Info.LoadedCondition))
loadedTokens.Push(self.GrantCondition(Info.LoadedCondition));
}
void INotifyKilled.Killed(Actor self, AttackInfo e)
{
// IsAtGroundLevel contains Map.Contains(self.Location) check.
if (Info.EjectOnDeath &&
self.IsAtGroundLevel() &&
(!checkTerrainType || Info.UnloadTerrainTypes.Contains(self.World.Map.GetTerrainInfo(self.Location).Type)))
{
while (!IsEmpty())
{
var passenger = Unload(self);
self.World.AddFrameEndTask(w =>
{
var positionable = passenger.Trait<IPositionable>();
if (positionable.CanEnterCell(self.Location, self, BlockedByActor.All))
{
positionable.SetPosition(passenger, self.Location);
w.Add(passenger);
var nbms = passenger.TraitsImplementing<INotifyBlockingMove>();
foreach (var nbm in nbms)
nbm.OnNotifyBlockingMove(passenger, passenger);
passenger.Trait<Passenger>().OnEjectedFromKilledCargo(passenger);
}
else
passenger.Kill(e.Attacker);
});
}
}
else
foreach (var c in cargo)
c.Kill(e.Attacker);
cargo.Clear();
}
void INotifyActorDisposing.Disposing(Actor self)
{
foreach (var c in cargo)
c.Dispose();
cargo.Clear();
}
void INotifySold.Selling(Actor self) { }
void INotifySold.Sold(Actor self)
{
if (!Info.EjectOnSell || cargo == null)
return;
while (!IsEmpty())
SpawnPassenger(Unload(self));
}
void SpawnPassenger(Actor passenger)
{
self.World.AddFrameEndTask(w =>
{
w.Add(passenger);
passenger.Trait<IPositionable>().SetPosition(passenger, self.Location);
// TODO: this won't work well for >1 actor as they should move towards the next enterable (sub) cell instead
});
}
void INotifyOwnerChanged.OnOwnerChanged(Actor self, Player oldOwner, Player newOwner)
{
if (cargo == null)
return;
foreach (var p in Passengers)
p.ChangeOwner(newOwner);
}
void ITransformActorInitModifier.ModifyTransformActorInit(Actor self, TypeDictionary init)
{
init.Add(new RuntimeCargoInit(Info, Passengers.ToArray()));
}
}
public class RuntimeCargoInit : ValueActorInit<Actor[]>, ISuppressInitExport
{
public RuntimeCargoInit(TraitInfo info, Actor[] value)
: base(info, value) { }
}
public class CargoInit : ValueActorInit<string[]>
{
public CargoInit(TraitInfo info, string[] value)
: base(info, value) { }
}
}