#nullable enable using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using Robust.Shared.Prototypes; using Robust.Shared.Serialization.Manager; using Robust.Shared.Utility; using YamlDotNet.RepresentationModel; namespace Content.IntegrationTests.Utility; /// /// A helper class for when you need prototype or VFS data particularly early, like for test source lists. /// /// /// /// This does not include engine prototypes, nor anything generated at runtime, as it's made to be simple and fast /// for usage during test framework startup where we cannot afford to initialize all of . /// /// /// No initialization is required, it initializes itself on usage and all methods can be called immediately. /// /// /// /// /// public static readonly string[] Maps = GameDataScrounger.PrototypesOfKind<GameMapPrototype>(); /// /// public static partial class GameDataScrounger { /// /// /// YAML Linter, for Reasons, depends on the entirety of the test suite. /// As such, scrounging erroring out due to bad YAML can make the linter fail spectacularly. /// We do not want that, so the linter sets this, and we refuse to do any yaml-ing ourselves so the nicer set of /// errors get to it. /// /// /// Also, this means obviously bad YAML causes the main test suite to exit early. This is probably a pro, honestly. /// /// /// /// This should not be set mid-run, as some methods will return data and others will not depending on their implementation. /// Set it before any operations that would call into GameDataScrounger. /// public static bool NoScrounging = false; /// /// Prototype type to ID index. /// private static Dictionary>? _prototypeIndex = null; /// /// Component type to prototype ID index. /// private static Dictionary>? _entitiesWithComponentIndex = null; /// /// Entity proto to metadata index. /// private static Dictionary? _entitiesMetaIndex = null; private sealed class EntityMetadata { public required string Id; public required HashSet Components; public required List Parents; public required bool Abstract; } /// /// Lock used to synchronize access to the prototype index. /// private static readonly Lock DataLock = new(); /// /// Gets all prototypes of the given type kind. /// public static string[] PrototypesOfKind() where T : IPrototype { if (typeof(T).GetCustomAttribute() is { Type: { } ty }) return PrototypesOfKind(ty); return PrototypesOfKind(PrototypeUtility.CalculatePrototypeName(typeof(T).Name)); } /// /// Gets all prototypes of the given string kind. /// public static string[] PrototypesOfKind(string kind) { if (NoScrounging) return Array.Empty(); lock (DataLock) { Scrounge(); return _prototypeIndex[kind].ToArray(); } } /// /// Returns all entities with the given component. /// /// /// This does not accept components by type due to a non-public engine API that needs unsealed. /// When that is done, this will be removed and usages of it upstream will be refactored to go by type. /// /// The name (like one would put in YAML) of the component to look for. /// A list of entity prototype IDs that have the given component. public static string[] EntitiesWithComponent(string componentId) { if (NoScrounging) return Array.Empty(); lock (DataLock) { if (_entitiesWithComponentIndex is { } index) { return index[componentId].ToArray(); } else { Scrounge(); return _entitiesWithComponentIndex[componentId].ToArray(); } } } /// /// Ensures that the internal indices are initialized and not null. /// This will not do anything if they're initialized, and will initialize them to be empty if /// is set. /// [MemberNotNull(nameof(_prototypeIndex))] [MemberNotNull(nameof(_entitiesWithComponentIndex))] [MemberNotNull(nameof(_entitiesMetaIndex))] private static void Scrounge() { if (_prototypeIndex is not null && _entitiesWithComponentIndex is not null && _entitiesMetaIndex is not null) return; // Initialize regardless of if we actually fill them out. // As we promise they will not be null, and various methods rely on this even if they're mpty. _prototypeIndex = new(); _entitiesWithComponentIndex = new(); _entitiesMetaIndex = new(); if (NoScrounging) return; var resDir = ContentResources(); Assert.That(Directory.Exists($"{resDir}/Prototypes")); var ignoreList = GetIgnoredPrototypes(resDir); // Start with our root directory. We use this as a stack of directories to traverse. var explorationStack = new List() { $"{resDir}/Prototypes" }; while (explorationStack.Count > 0) { // Take a directory off the stack. var dir = explorationStack.Pop(); if (ignoreList.Contains(dir)) continue; // It's all abstract anyway. explorationStack.AddRange(Directory.EnumerateDirectories(dir)); foreach (var file in Directory.EnumerateFiles(dir, "*.yml")) { if (ignoreList.Contains(file)) continue; // It's all abstract anyway. foreach (var (kind, id) in IndexPrototypesIn(file)) { // alternate universe where .net has rust's Entry api. if (!_prototypeIndex.TryGetValue(kind, out var list)) { _prototypeIndex[kind] = new(); list = _prototypeIndex[kind]; } list.Add(id); } } } PushInheritanceAndIndex(); } private static readonly YamlScalarNode IdNode = new("id"); private static readonly YamlScalarNode TypeNode = new("type"); /// /// Indexes all prototypes in a folder, adding them to as necessary and /// yielding all (type, id) pairs. /// /// The file to index. /// An enumerator of all prototypes in the file, regardless of kind. private static IEnumerable<(string type, string id)> IndexPrototypesIn(string file) { var stream = new YamlStream(); stream.Load(File.OpenText(file)); foreach (var document in stream) { Assert.That(document.RootNode, Is.AssignableTo()); var node = (YamlSequenceNode)document.RootNode; foreach (var entry in node.Children) { Assert.That(entry, Is.AssignableTo()); var entryMapping = (YamlMappingNode)entry; var id = entryMapping[IdNode]; var type = entryMapping[TypeNode]; var @abstract = false; if (entryMapping.TryGetNode("abstract", out YamlScalarNode? abstractNode)) { // TODO: This technically will exclude prototypes that use the abstract field for their own stuff, // and not for parenting. However no such prototype exists in the game as of writing and solving // this is mildly nontrivial. // We use exact equality to match what serialization does. if (abstractNode.Value == "true") @abstract = true; } if (!@abstract) yield return (((YamlScalarNode)type).Value!, ((YamlScalarNode)id).Value!); // If we're an entity prototype.. if (type is not YamlScalarNode { Value: "entity" }) continue; // then do some metadata indexing that's feasible w/o serializationmanager. entryMapping.TryGetNode("components", out YamlSequenceNode? components); var parents = new List(); if (entryMapping.TryGetNode("parent", out var parentNode)) { switch (parentNode) { case YamlScalarNode scalar: { parents.Add(scalar.Value!); break; } case YamlSequenceNode seq: { parents.AddRange(seq.Children.Select(x => x.AsString())); break; } } } // Assemble metadata for this entity prototype w/o needing serializationmanager. var entity = new EntityMetadata() { Abstract = @abstract, Components = components?.Children.Select(x => x["type"].ToString()).ToHashSet() ?? new(), Parents = parents, Id = id.AsString(), }; _entitiesMetaIndex![id.AsString()] = entity; } } } /// /// Iterates through , filling out /// as it works and ensuring all inheritance-provided components are found. /// private static void PushInheritanceAndIndex() { var visitedEntities = new HashSet(); foreach (var entity in _entitiesMetaIndex!.Values) { VisitEntity(entity, visitedEntities); if (entity.Abstract) continue; // We don't index abstract entities here. foreach (var component in entity.Components) { if (!_entitiesWithComponentIndex!.TryGetValue(component, out var list)) { list = new(); _entitiesWithComponentIndex[component] = list; } list.Add(entity.Id); } } } /// /// Visits the given entity, potentially recursively in order to discover all of its components. /// /// The entity to 'visit' and gather metadata on. /// The set of all visited entities. private static void VisitEntity(EntityMetadata entity, in HashSet visitedEntities) { // Return if we've visited already. if (!visitedEntities.Add(entity.Id)) return; foreach (var parent in entity.Parents) { var parentMeta = _entitiesMetaIndex![parent]; VisitEntity(parentMeta, visitedEntities); entity.Components.UnionWith(parentMeta.Components); } } // Did you know there's no way to find the resources folder in the real filesystem // from content? Makes sense, but ough. So this is unfortunately copy-pasted from engine. // I don't think it's worth it to add an API for this to engine due to the security implications for sandboxed // content. // This is indeed, unfortunately, a replica of Content.Shared/Entry/EntryPoint.cs:129 // That code relies on engine tools we can't use here, because we can't even spin up engine. private static HashSet GetIgnoredPrototypes(string resDir) { var ignores = new HashSet(); var ignoredProtosPath = $"{resDir}/IgnoredPrototypes"; if (!Directory.Exists(ignoredProtosPath)) return ignores; // Nothing to do. foreach (var path in Directory.EnumerateFiles($"{resDir}/IgnoredPrototypes")) { var stream = new YamlStream(); stream.Load(File.OpenText(path)); foreach (var document in stream) { if (document.RootNode is not YamlSequenceNode seq) throw new Exception($"The ignored prototypes file at {path} isn't a valid yaml sequence/list."); foreach (var entry in seq) { if (entry is not YamlScalarNode { Value: {} value }) throw new Exception($"An entry in {path} is not a valid YAML scalar/string literal. Entry: {entry}"); ignores.Add(value); } } } return ignores; } }