diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4c55457d3e2..ae6ee351877 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -39,7 +39,9 @@ END TEMPLATE--> ### New features -*None yet* +- `IEntityManager` has a new family of 1-4 component methods for working with *singleton entities*, entities which only + one instance of exists at a time for state management. Consult `IEntityManager.Single.cs` and the documentation for + details. ### Bugfixes diff --git a/Robust.Shared.IntegrationTests/GameObjects/EntityManagerSingletonTests.cs b/Robust.Shared.IntegrationTests/GameObjects/EntityManagerSingletonTests.cs new file mode 100644 index 00000000000..8290dcfa438 --- /dev/null +++ b/Robust.Shared.IntegrationTests/GameObjects/EntityManagerSingletonTests.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; + +namespace Robust.UnitTesting.Shared.GameObjects; + +[TestOf(typeof(EntityManager))] +internal sealed class EntityManagerSingletonTests : OurRobustUnitTest +{ + private const string TestSingleton1 = "T_TestSingleton1"; + + private const string Prototypes = $""" + - type: entity + id: {TestSingleton1} + components: + - type: Marker1 + - type: Marker2 + - type: Marker3 + - type: Marker4 + """; + + protected override Type[]? ExtraComponents => + [ + typeof(Marker1Component), typeof(Marker2Component), typeof(Marker3Component), typeof(Marker4Component) + ]; + + private PrototypeManager _protoMan = default!; + private IEntityManager _entMan = default!; + + [OneTimeSetUp] + public void Setup() + { + IoCManager.Resolve().Initialize(); + + _protoMan = (PrototypeManager) IoCManager.Resolve(); + _protoMan.RegisterKind(typeof(EntityPrototype), typeof(EntityCategoryPrototype)); + _protoMan.LoadString(Prototypes); + _protoMan.ResolveResults(); + + _entMan = IoCManager.Resolve(); + } + + [Test] + [Description(""" + Creates an entity with marker components 1-4, and ensures increasingly constrained Single<>() queries match it. + Then creates a second entity, and ensures queries fail. + Then deletes both, and ensures Single fails while TrySingle is fine. + """)] + public void SingleEntity() + { + var mySingle = _entMan.Spawn(TestSingleton1); + + var single1 = _entMan.Single(); + var single2 = _entMan.Single(); + var single3 = _entMan.Single(); + var single4 = _entMan.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(mySingle, NUnit.Framework.Is.EqualTo(single1.Owner)); + Assert.That(mySingle, NUnit.Framework.Is.EqualTo(single2.Owner)); + Assert.That(mySingle, NUnit.Framework.Is.EqualTo(single3.Owner)); + Assert.That(mySingle, NUnit.Framework.Is.EqualTo(single4.Owner)); + } + + var bonusSingle = _entMan.Spawn(TestSingleton1); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => _entMan.Single()); + Assert.Throws(() => _entMan.Single()); + Assert.Throws(() => + _entMan.Single()); + Assert.Throws(() => + _entMan.Single()); + Assert.Throws(() => _entMan.TrySingle(out _)); + Assert.Throws(() => + _entMan.TrySingle(out _)); + Assert.Throws(() => + _entMan.TrySingle(out _)); + Assert.Throws(() => + _entMan.TrySingle(out _)); + } + + _entMan.DeleteEntity(bonusSingle); + _entMan.DeleteEntity(mySingle); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => _entMan.Single()); + Assert.Throws(() => _entMan.Single()); + Assert.Throws(() => + _entMan.Single()); + Assert.Throws(() => + _entMan.Single()); + Assert.That(_entMan.TrySingle(out _), NUnit.Framework.Is.False); + Assert.That(_entMan.TrySingle(out _), NUnit.Framework.Is.False); + Assert.That(_entMan.TrySingle(out _), + NUnit.Framework.Is.False); + Assert.That( + _entMan.TrySingle(out _), + NUnit.Framework.Is.False); + } + } +} + +internal sealed partial class Marker1Component : Component; +internal sealed partial class Marker2Component : Component; +internal sealed partial class Marker3Component : Component; +internal sealed partial class Marker4Component : Component; diff --git a/Robust.Shared/GameObjects/Docs.xml b/Robust.Shared/GameObjects/Docs.xml index 6eaf60eb8b2..a1203da58a8 100644 --- a/Robust.Shared/GameObjects/Docs.xml +++ b/Robust.Shared/GameObjects/Docs.xml @@ -11,4 +11,23 @@ This is also preferable if you may have already looked up the component, saving on lookup time. + + + + This does not return the entity it spawns, it tries to look it up, so if spawning that entity violates + singleton status (or it lacks the necessary component) this will throw immediately after instead of later + on next call. + + This will also still throw if the spawned entity doesn't match at all. + + + + + + + This API does not obey . + If you need to "pause" singleton entities, use an extra marker component that you remove when "pausing" them. + + + diff --git a/Robust.Shared/GameObjects/EntityManager.Single.cs b/Robust.Shared/GameObjects/EntityManager.Single.cs new file mode 100644 index 00000000000..4480d98517c --- /dev/null +++ b/Robust.Shared/GameObjects/EntityManager.Single.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Robust.Shared.GameObjects; + +public abstract partial class EntityManager +{ + public Entity Single() + where TComp1: IComponent + { + var index = _entTraitArray[CompIdx.ArrayIndex()]; + + if (index.Keys.FirstOrNull() is { } ent && index.Count == 1) + { + return new Entity(ent, (TComp1)index[ent]); + } + + if (index.Count > 1) + { + throw new NonUniqueSingletonException(index.Keys.ToArray(), typeof(TComp1)); + } + else + { + // 0. + throw new MatchNotFoundException(typeof(TComp1)); + } + } + + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2)) + throw new MatchNotFoundException(typeof(TComp1), typeof(TComp2)); + + if (query.MoveNext(out var ent2, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2)); + } + + return new(ent, comp1, comp2); + } + + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2, out var comp3)) + throw new MatchNotFoundException(typeof(TComp1), typeof(TComp2), typeof(TComp3)); + + if (query.MoveNext(out var ent2, out _, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2), typeof(TComp3)); + } + + return new(ent, comp1, comp2, comp3); + } + + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + where TComp4: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2, out var comp3, out var comp4)) + throw new MatchNotFoundException(typeof(TComp1), typeof(TComp2), typeof(TComp3), typeof(TComp4)); + + if (query.MoveNext(out var ent2, out _, out _, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2), typeof(TComp3), typeof(TComp4)); + } + + return new(ent, comp1, comp2, comp3, comp4); + } + + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1: IComponent + { + var index = _entTraitArray[CompIdx.ArrayIndex()]; + + if (index.Keys.FirstOrNull() is { } ent && index.Count == 1) + { + entity = new Entity(ent, (TComp1)index[ent]); + return true; + } + else + { + entity = null; + } + + if (index.Count > 1) + { + throw new NonUniqueSingletonException(index.Keys.ToArray(), typeof(TComp1)); + } + + return false; + } + + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1: IComponent + where TComp2: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2)) + { + entity = null; + return false; + } + + if (query.MoveNext(out var ent2, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2)); + } + + entity = new(ent, comp1, comp2); + return true; + } + + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2, out var comp3)) + { + entity = null; + return false; + } + + if (query.MoveNext(out var ent2, out _, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2), typeof(TComp3)); + } + + entity = new(ent, comp1, comp2, comp3); + return true; + } + + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + where TComp4: IComponent + { + var query = AllEntityQueryEnumerator(); + + if (!query.MoveNext(out var ent, out var comp1, out var comp2, out var comp3, out var comp4)) + { + entity = null; + return false; + } + + if (query.MoveNext(out var ent2, out _, out _, out _, out _)) + { + var list = new List { ent, ent2 }; + + while (query.MoveNext(out var ent3, out _, out _, out _, out _)) + { + list.Add(ent3); + } + + throw new NonUniqueSingletonException(list.ToArray(), typeof(TComp1), typeof(TComp2), typeof(TComp3), typeof(TComp4)); + } + + entity = new(ent, comp1, comp2, comp3, comp4); + return true; + } +} + +/// +/// Exception for when and co cannot find a unique match. +/// +/// The set of matching entities. +/// The set of components you tried to match over. +public sealed class NonUniqueSingletonException(EntityUid[] matches, params Type[] components) : Exception +{ + public override string Message => + $"Expected precisely one entity to match the component set {string.Join(", ", components)}, but found {matches.Length}: {string.Join(", ", matches)}"; +} + +/// +/// Exception for when and co cannot find any match. +/// +/// The set of components you tried to match over. +public sealed class MatchNotFoundException(params Type[] components) : Exception +{ + public override string Message => + $"Expected precisely one entity to match the component set {string.Join(", ", components)}, but found none."; +} diff --git a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs index 06b852ceaf5..8a264e68720 100644 --- a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs +++ b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs @@ -1783,4 +1783,84 @@ protected NetCoordinates[] GetNetCoordinatesArray(EntityCoordinates[] entities) } #endregion + + #region Singletons + + /// + [ProxyFor(typeof(EntityManager))] + public Entity Single() + where TComp1: IComponent + { + return EntityManager.Single(); + } + + /// + [ProxyFor(typeof(EntityManager))] + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + { + return EntityManager.Single(); + } + + /// + [ProxyFor(typeof(EntityManager))] + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + { + return EntityManager.Single(); + } + + /// + [ProxyFor(typeof(EntityManager))] + public Entity Single() + where TComp1: IComponent + where TComp2: IComponent + where TComp3: IComponent + where TComp4: IComponent + { + return EntityManager.Single(); + } + + /// + [ProxyFor(typeof(EntityManager))] + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + { + return EntityManager.TrySingle(out entity); + } + + /// + [ProxyFor(typeof(EntityManager))] + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent + { + return EntityManager.TrySingle(out entity); + } + + /// + [ProxyFor(typeof(EntityManager))] + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent + { + return EntityManager.TrySingle(out entity); + } + + /// + [ProxyFor(typeof(EntityManager))] + public bool TrySingle( + [NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent + where TComp4 : IComponent + { + return EntityManager.TrySingle(out entity); + } + #endregion } diff --git a/Robust.Shared/GameObjects/IEntityManager.Single.cs b/Robust.Shared/GameObjects/IEntityManager.Single.cs new file mode 100644 index 00000000000..d6adf396827 --- /dev/null +++ b/Robust.Shared/GameObjects/IEntityManager.Single.cs @@ -0,0 +1,143 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; + +namespace Robust.Shared.GameObjects; + +public partial interface IEntityManager +{ + // REMARK: No API that allows you to use these queries without them throwing over non-uniqueness should be added. + // It's a pretty simple, natural error condition and the game *should* yell about it. + + /// + /// Gets the sole entity with the given component. + /// + /// The component to look for as a tag. + /// The singleton entity. + /// Thrown when multiple entities match the request. + /// Thrown when no entities match the request. + /// + public Entity Single() + where TComp1 : IComponent; + + /// + /// Gets the sole entity with the given components. + /// + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// The singleton entity. + /// Thrown when multiple entities match the request. + /// Thrown when no entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public Entity Single() + where TComp1 : IComponent + where TComp2 : IComponent; + + /// + /// Gets the sole entity with the given components. + /// + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// The third component to look for as a tag. + /// The singleton entity. + /// Thrown when multiple entities match the request. + /// Thrown when no entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public Entity Single() + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent; + + /// + /// Gets the sole entity with the given components. + /// + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// The third component to look for as a tag. + /// The fourth component to look for as a tag. + /// The singleton entity. + /// Thrown when multiple entities match the request. + /// Thrown when no entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public Entity Single() + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent + where TComp4 : IComponent; + + /// + /// Gets the sole entity with the given component, if it exists. Still throws if there's more than one. + /// + /// The singleton entity, if any. + /// The component to look for as a tag. + /// Thrown when multiple entities match the request. + /// Success. + /// + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent; + + /// + /// Gets the sole entity with the given components, if one exists, or returns if one does not. + /// + /// The singleton entity, if any. + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// Success. + /// Thrown when multiple entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent; + + /// + /// Gets the sole entity with the given components, if one exists, or returns if one does not. + /// + /// The singleton entity, if any. + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// The third component to look for as a tag. + /// Success. + /// Thrown when multiple entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public bool TrySingle([NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent; + + /// + /// Gets the sole entity with the given components, if one exists, or returns if one does not. + /// + /// The singleton entity, if any. + /// The first component to look for as a tag. + /// The second component to look for as a tag. + /// The third component to look for as a tag. + /// The fourth component to look for as a tag. + /// Success. + /// Thrown when multiple entities match the request. + /// + /// The least common component should be put in for query performance. + /// + /// + public bool TrySingle( + [NotNullWhen(true)] out Entity? entity) + where TComp1 : IComponent + where TComp2 : IComponent + where TComp3 : IComponent + where TComp4 : IComponent; +}