using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Text; using Content.IntegrationTests.Fixtures; using Content.IntegrationTests.Fixtures.Attributes; using Robust.Shared; using Robust.Shared.Audio.Components; using Robust.Shared.Configuration; using Robust.Shared.GameObjects; using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Maths; using Robust.Shared.Prototypes; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Spawners; namespace Content.IntegrationTests.Tests { [TestFixture] [TestOf(typeof(EntityUid))] public sealed class EntityTest : GameTest { private static readonly HashSet> IgnoredCategories = ["Spawner", "Debug"]; public override PoolSettings PoolSettings => new() { Connected = true, Dirty = true }; public static PoolSettings Disconnected => new() { Dirty = true, }; [Test] [PairConfig(nameof(Disconnected))] public async Task SpawnAndDeleteAllEntitiesOnDifferentMaps() { // This test dirties the pair as it simply deletes ALL entities when done. Overhead of restarting the round // is minimal relative to the rest of the test. var pair = Pair; var server = pair.Server; var entityMan = server.ResolveDependency(); var prototypeMan = server.ResolveDependency(); var mapSystem = entityMan.System(); await server.WaitPost(() => { var protoIds = prototypeMan .EnumeratePrototypes() .Where(p => !p.Abstract) .Where(p => !pair.IsTestPrototype(p)) .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Where(p => !p.Components.ContainsKey("RoomFill")) // This comp can delete all entities, and spawn others .Select(p => p.ID) .ToList(); foreach (var protoId in protoIds) { mapSystem.CreateMap(out var mapId); var grid = mapSystem.CreateGridEntity(mapId); // TODO: Fix this better in engine. mapSystem.SetTile(grid.Owner, grid.Comp, Vector2i.Zero, new Tile(1)); var coord = new EntityCoordinates(grid.Owner, 0, 0); entityMan.SpawnEntity(protoId, coord); } }); await server.WaitRunTicks(450); // 15 seconds, enough to trigger most update loops await server.WaitPost(() => { static IEnumerable<(EntityUid, TComp)> Query(IEntityManager entityMan) where TComp : Component { var query = entityMan.AllEntityQueryEnumerator(); while (query.MoveNext(out var uid, out var meta)) { yield return (uid, meta); } } var entityMetas = Query(entityMan).ToList(); foreach (var (uid, meta) in entityMetas) { if (!meta.EntityDeleted) entityMan.DeleteEntity(uid); } Assert.That(entityMan.EntityCount, Is.Zero); }); } [Test] [PairConfig(nameof(Disconnected))] public async Task SpawnAndDeleteAllEntitiesInTheSameSpot() { var pair = Pair; Assert.That(pair.Client.Session, Is.Null); var server = pair.Server; var map = await pair.CreateTestMap(); var entityMan = server.ResolveDependency(); var prototypeMan = server.ResolveDependency(); await server.WaitPost(() => { var protoIds = prototypeMan .EnumeratePrototypes() .Where(p => !p.Abstract) .Where(p => !pair.IsTestPrototype(p)) .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Where(p => !p.Components.ContainsKey("RoomFill")) // This comp can delete all entities, and spawn others .Select(p => p.ID) .ToList(); foreach (var protoId in protoIds) { entityMan.SpawnEntity(protoId, map.GridCoords); } }); await server.WaitRunTicks(450); // 15 seconds, enough to trigger most update loops await server.WaitPost(() => { static IEnumerable<(EntityUid, TComp)> Query(IEntityManager entityMan) where TComp : Component { var query = entityMan.AllEntityQueryEnumerator(); while (query.MoveNext(out var uid, out var meta)) { yield return (uid, meta); } } var entityMetas = Query(entityMan).ToList(); foreach (var (uid, meta) in entityMetas) { if (!meta.EntityDeleted) entityMan.DeleteEntity(uid); } Assert.That(entityMan.EntityCount, Is.Zero); }); } /// /// Variant of that also launches a client and dirties /// all components on every entity. /// [Test] public async Task SpawnAndDirtyAllEntities() { var pair = Pair; var server = pair.Server; var client = pair.Client; var cfg = server.ResolveDependency(); var prototypeMan = server.ResolveDependency(); var sEntMan = server.ResolveDependency(); var mapSys = server.System(); Assert.That(cfg.GetCVar(CVars.NetPVS), Is.False); var protoIds = prototypeMan .EnumeratePrototypes() .Where(p => !p.Abstract) .Where(p => !pair.IsTestPrototype(p)) .Where(p => !p.Components.ContainsKey("MapGrid")) // This will smash stuff otherwise. .Select(p => p.ID) .ToList(); await server.WaitPost(() => { foreach (var protoId in protoIds) { mapSys.CreateMap(out var mapId); var grid = mapSys.CreateGridEntity(mapId); var ent = sEntMan.SpawnEntity(protoId, new EntityCoordinates(grid.Owner, 0.5f, 0.5f)); foreach (var (_, component) in sEntMan.GetNetComponents(ent)) { sEntMan.Dirty(ent, component); } } }); await pair.RunUntilSynced(); // Make sure the client actually received the entities // 500 is completely arbitrary. Note that the client & sever entity counts aren't expected to match. Assert.That(client.ResolveDependency().EntityCount, Is.GreaterThan(500)); await server.WaitPost(() => { static IEnumerable<(EntityUid, TComp)> Query(IEntityManager entityMan) where TComp : Component { var query = entityMan.AllEntityQueryEnumerator(); while (query.MoveNext(out var uid, out var meta)) { yield return (uid, meta); } } var entityMetas = Query(sEntMan).ToList(); foreach (var (uid, meta) in entityMetas) { if (!meta.EntityDeleted) sEntMan.DeleteEntity(uid); } Assert.That(sEntMan.EntityCount, Is.Zero); }); } /// /// This test checks that spawning and deleting an entity doesn't somehow create other unrelated entities. /// /// /// Unless an entity is intentionally designed to spawn other entities (e.g., mob spawners), they should /// generally not spawn unrelated / detached entities. Any entities that do get spawned should be parented to /// the spawned entity (e.g., in a container). If an entity needs to spawn an entity somewhere in null-space, /// it should delete that entity when it is no longer required. This test mainly exists to prevent "entity leak" /// bugs, where spawning some entity starts spawning unrelated entities in null space that stick around after /// the original entity is gone. /// /// Note that this isn't really a strict requirement, and there are probably quite a few edge cases. Its a pretty /// crude test to try catch issues like this, and possibly should just be disabled. /// [Test] public async Task SpawnAndDeleteEntityCountTest() { var pair = Pair; var mapSys = pair.Server.System(); var server = pair.Server; var client = pair.Client; var excluded = new[] { "MapGrid", "StationEvent", "TimedDespawn", // makes an announcement on mapInit. "AnnounceOnSpawn", }; Assert.That(server.CfgMan.GetCVar(CVars.NetPVS), Is.False); var protoIds = server.ProtoMan .EnumeratePrototypes() .Where(p => !p.Abstract) .Where(p => !pair.IsTestPrototype(p)) .Where(p => !excluded.Any(p.Components.ContainsKey)) .Where(p => p.Categories.All(x => !IgnoredCategories.Contains(x.ID))) .Select(p => p.ID) .ToList(); protoIds.Sort(); var mapId = MapId.Nullspace; await server.WaitPost(() => { mapSys.CreateMap(out mapId); }); var coords = new MapCoordinates(Vector2.Zero, mapId); await pair.RunTicksSync(3); // We consider only non-audio entities, as some entities will just play sounds when they spawn. int Count(IEntityManager ent) => ent.EntityCount - ent.Count(); IEnumerable Entities(IEntityManager entMan) => entMan.GetEntities().Where(e => !entMan.HasComponent(e)); await Assert.MultipleAsync(async () => { foreach (var protoId in protoIds) { var count = Count(server.EntMan); var clientCount = Count(client.EntMan); var serverEntities = new HashSet(Entities(server.EntMan)); var clientEntities = new HashSet(Entities(client.EntMan)); EntityUid uid = default; await server.WaitPost(() => uid = server.EntMan.SpawnEntity(protoId, coords)); await pair.RunTicksSync(3); // If the entity deleted itself, check that it didn't spawn other entities if (!server.EntMan.EntityExists(uid)) { await CleanupTransientEntities(pair, serverEntities); Assert.That(Count(server.EntMan), Is.EqualTo(count), $"Server prototype {protoId} failed on deleting itself\n" + BuildDiffString(serverEntities, Entities(server.EntMan), server.EntMan)); Assert.That(Count(client.EntMan), Is.EqualTo(clientCount), $"Client prototype {protoId} failed on deleting itself\n" + $"Expected {clientCount} and found {client.EntMan.EntityCount}.\n" + $"Server count was {count}.\n" + BuildDiffString(clientEntities, Entities(client.EntMan), client.EntMan)); continue; } // Check that the number of entities has increased. Assert.That(Count(server.EntMan), Is.GreaterThan(count), $"Server prototype {protoId} failed on spawning as entity count didn't increase\n" + BuildDiffString(serverEntities, Entities(server.EntMan), server.EntMan)); Assert.That(Count(client.EntMan), Is.GreaterThan(clientCount), $"Client prototype {protoId} failed on spawning as entity count didn't increase\n" + $"Expected at least {clientCount} and found {client.EntMan.EntityCount}. " + $"Server count was {count}.\n" + BuildDiffString(clientEntities, Entities(client.EntMan), client.EntMan)); await server.WaitPost(() => server.EntMan.DeleteEntity(uid)); await pair.RunTicksSync(3); await CleanupTransientEntities(pair, serverEntities); // Check that the number of entities has gone back to the original value. Assert.That(Count(server.EntMan), Is.EqualTo(count), $"Server prototype {protoId} failed on deletion: count didn't reset properly\n" + BuildDiffString(serverEntities, Entities(server.EntMan), server.EntMan)); Assert.That(Count(client.EntMan), Is.EqualTo(clientCount), $"Client prototype {protoId} failed on deletion: count didn't reset properly:\n" + $"Expected {clientCount} and found {Count(client.EntMan)}.\n" + $"Server count was {count}.\n" + BuildDiffString(clientEntities, Entities(client.EntMan), client.EntMan)); } }); } /// /// Deletes any entities with that were not present in the baseline snapshot. /// Some entities spawn transient side-effects on deletion (e.g. explosion visuals). These side-effect entities /// use TimedDespawn and would persist across test iterations, corrupting baseline entity counts and causing /// cascading assertion failures. /// private static async Task CleanupTransientEntities(Pair.TestPair pair, HashSet baselineEntities) { var server = pair.Server; await server.WaitPost(() => { var toRemove = new List(); var query = server.EntMan.AllEntityQueryEnumerator(); while (query.MoveNext(out var uid, out _)) { if (!baselineEntities.Contains(uid)) toRemove.Add(uid); } foreach (var uid in toRemove) { server.EntMan.DeleteEntity(uid); } }); await pair.RunTicksSync(3); } private static string BuildDiffString(IEnumerable oldEnts, IEnumerable newEnts, IEntityManager entMan) { var sb = new StringBuilder(); var addedEnts = newEnts.Except(oldEnts); var removedEnts = oldEnts.Except(newEnts); if (addedEnts.Any()) sb.AppendLine("Listing new entities:"); foreach (var addedEnt in addedEnts) { sb.AppendLine(entMan.ToPrettyString(addedEnt)); } if (removedEnts.Any()) sb.AppendLine("Listing removed entities:"); foreach (var removedEnt in removedEnts) { sb.AppendLine("\t" + entMan.ToPrettyString(removedEnt)); } return sb.ToString(); } private static bool HasRequiredDataField(Component component) { foreach (var field in component.GetType().GetFields()) { foreach (var attribute in field.GetCustomAttributes(true)) { if (attribute is not DataFieldAttribute dataField) continue; if (dataField.Required) return true; } } foreach (var property in component.GetType().GetProperties()) { foreach (var attribute in property.GetCustomAttributes(true)) { if (attribute is not DataFieldAttribute dataField) continue; if (dataField.Required) return true; } } return false; } [Test] public async Task AllComponentsOneToOneDeleteTest() { var skipComponents = new[] { "DebugExceptionOnAdd", // Debug components that explicitly throw exceptions "DebugExceptionExposeData", "DebugExceptionInitialize", "DebugExceptionStartup", "GridFill", "RoomFill", "Map", // We aren't testing a map entity in this test "MapGrid", "Broadphase", "StationData", // errors when removed mid-round "StationJobs", "Actor", // We aren't testing actor components, those need their player session set. "BiomeSelection", // Whaddya know, requires config. "ActivatableUI", // Requires enum key }; var pair = Pair; var server = pair.Server; var entityManager = server.ResolveDependency(); var componentFactory = server.ResolveDependency(); var logmill = server.ResolveDependency().GetSawmill("EntityTest"); await pair.CreateTestMap(); await server.WaitRunTicks(5); var testLocation = pair.TestMap.GridCoords; await server.WaitAssertion(() => { Assert.Multiple(() => { foreach (var type in componentFactory.AllRegisteredTypes) { var component = (Component)componentFactory.GetComponent(type); var name = componentFactory.GetComponentName(type); if (HasRequiredDataField(component)) continue; // If this component is ignored if (skipComponents.Contains(name)) { continue; } var entity = entityManager.SpawnEntity(null, testLocation); Assert.That(entityManager.GetComponent(entity).EntityInitialized); // The component may already exist if it is a mandatory component // such as MetaData or Transform if (entityManager.HasComponent(entity, type)) { entityManager.DeleteEntity(entity); continue; } logmill.Debug($"Adding component: {name}"); Assert.DoesNotThrow(() => { entityManager.AddComponent(entity, component); }, "Component '{0}' threw an exception.", name); entityManager.DeleteEntity(entity); } }); }); } } }