using Content.Server.Cargo.Systems; using Content.Server.Construction.Completions; using Content.Server.Construction.Components; using Content.Server.Destructible; using Content.Server.Destructible.Thresholds.Behaviors; using Content.Server.Stack; using Content.Shared.Construction.Prototypes; using Content.Shared.Construction.Steps; using Content.Shared.Lathe; using Content.Shared.Research.Prototypes; using Content.Shared.Stacks; using NUnit.Framework; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Prototypes; using System; using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; namespace Content.IntegrationTests.Tests; /// /// This test checks that any destructible or constructible entities do not drop more resources than are required to /// create them. /// [TestFixture] public sealed class MaterialArbitrageTest { [Test] public async Task NoMaterialArbitrage() { // TODO check lathe resource prices? // I CBF doing that atm because I know that will probably fail for most lathe recipies. await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings() {NoClient = true}); var server = pairTracker.Pair.Server; var testMap = await PoolManager.CreateTestMap(pairTracker); await server.WaitIdleAsync(); var entManager = server.ResolveDependency(); var sysManager = server.ResolveDependency(); var mapManager = server.ResolveDependency(); Assert.That(mapManager.IsMapInitialized(testMap.MapId)); var protoManager = server.ResolveDependency(); var pricing = sysManager.GetEntitySystem(); var stackSys = sysManager.GetEntitySystem(); var compFact = server.ResolveDependency(); var constructionName = compFact.GetComponentName(typeof(ConstructionComponent)); var destructibleName = compFact.GetComponentName(typeof(DestructibleComponent)); var stackName = compFact.GetComponentName(typeof(StackComponent)); // construct inverted lathe recipe dictionary Dictionary latheRecipes = new(); foreach (var proto in protoManager.EnumeratePrototypes()) { latheRecipes.Add(proto.Result, proto); } // Lets assume the possible lathe for resource multipliers: var multiplier = MathF.Pow(LatheComponent.DefaultPartRatingMaterialUseMultiplier, MachinePartComponent.MaxRating - 1); // create construction dictionary Dictionary constructionRecipes = new(); foreach (var proto in protoManager.EnumeratePrototypes()) { if (proto.NoSpawn || proto.Abstract) continue; if (!proto.Components.TryGetValue(constructionName, out var destructible)) continue; var comp = (ConstructionComponent) destructible.Component; constructionRecipes.Add(proto.ID, comp); } // Get ingredients required to construct an entity Dictionary> constructionMaterials = new(); foreach (var (id, comp) in constructionRecipes) { var materials = new Dictionary(); var graph = protoManager.Index(comp.Graph); if (!graph.TryPath(graph.Start, comp.Node, out var path) || path.Length == 0) continue; var cur = graph.Nodes[graph.Start]; foreach (var node in path) { var edge = cur.GetEdge(node.Name); cur = node; foreach (var step in edge.Steps) { if (step is MaterialConstructionGraphStep materialStep) materials[materialStep.MaterialPrototypeId] = materialStep.Amount + materials.GetValueOrDefault(materialStep.MaterialPrototypeId); } } constructionMaterials.Add(id, materials); } Dictionary priceCache = new(); Dictionary Ents, Dictionary Mats)> spawnedOnDestroy = new(); // Here we get the set of entities/materials spawned when destroying an entity. foreach (var proto in protoManager.EnumeratePrototypes()) { if (proto.NoSpawn || proto.Abstract) continue; if (!proto.Components.TryGetValue(destructibleName, out var destructible)) continue; var comp = (DestructibleComponent) destructible.Component; var spawnedEnts = new Dictionary(); var spawnedMats = new Dictionary(); // This test just blindly assumes that ALL spawn entity behaviors get triggered. In reality, some entities // might only trigger a subset. If that starts being a problem, this test either needs fixing or needs to // get an ignored prototypes list. foreach (var threshold in comp.Thresholds) { foreach (var behaviour in threshold.Behaviors) { if (behaviour is not SpawnEntitiesBehavior spawn) continue; foreach (var (key, value) in spawn.Spawn) { spawnedEnts[key] = spawnedEnts.GetValueOrDefault(key) + value.Max; var spawnProto = protoManager.Index(key); if (!spawnProto.Components.TryGetValue(stackName, out var reg)) continue; var stack = (StackComponent) reg.Component; spawnedMats[stack.StackTypeId] = value.Max + spawnedMats.GetValueOrDefault(stack.StackTypeId); } } } if (spawnedEnts.Count > 0) spawnedOnDestroy.Add(proto.ID, (spawnedEnts, spawnedMats)); } // This is the main loop where we actually check for destruction arbitrage Assert.Multiple(async () => { foreach (var (id, (spawnedEnts, spawnedMats)) in spawnedOnDestroy) { // Check cargo sell price // several constructible entities have no sell price // also this test only really matters if the entity is also purchaseable.... eh.. var spawnedPrice = await GetSpawnedPrice(spawnedEnts); var price = await GetPrice(id); if (spawnedPrice > 0 && price > 0) Assert.LessOrEqual(spawnedPrice, price, $"{id} increases in price after being destroyed"); // Check lathe production if (latheRecipes.TryGetValue(id, out var recipe)) { foreach (var (matId, amount) in recipe.RequiredMaterials) { var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier); if (spawnedMats.TryGetValue(matId, out var numSpawned)) Assert.LessOrEqual(numSpawned, actualAmount, $"destroying a {id} spawns more {matId} than required to produce via an (upgraded) lathe."); } } // Check construction. if (constructionMaterials.TryGetValue(id, out var constructionMats)) { foreach (var (matId, amount) in constructionMats) { if (spawnedMats.TryGetValue(matId, out var numSpawned)) Assert.LessOrEqual(numSpawned, amount, $"destroying a {id} spawns more {matId} than required to construct it."); } } } }); // Finally, lets also check for deconstruction arbitrage. // Get ingredients returned when deconstructing an entity Dictionary> deconstructionMaterials = new(); foreach (var (id, comp) in constructionRecipes) { if (comp.DeconstructionNode == null) continue; var materials = new Dictionary(); var graph = protoManager.Index(comp.Graph); if (!graph.TryPath(comp.Node, comp.DeconstructionNode, out var path) || path.Length == 0) continue; var cur = graph.Nodes[comp.Node]; foreach (var node in path) { var edge = cur.GetEdge(node.Name); cur = node; foreach (var completion in edge.Completed) { if (completion is not SpawnPrototype spawnCompletion) continue; var spawnProto = protoManager.Index(spawnCompletion.Prototype); if (!spawnProto.Components.TryGetValue(stackName, out var reg)) continue; var stack = (StackComponent) reg.Component; materials[stack.StackTypeId] = spawnCompletion.Amount + materials.GetValueOrDefault(stack.StackTypeId); } } deconstructionMaterials.Add(id, materials); } // This is functionally the same loop as before, but now testinng deconstruction rather than destruction. // This is pretty braindead. In principle construction graphs can have loops and whatnot. Assert.Multiple(async () => { foreach (var (id, deconstructedMats) in deconstructionMaterials) { // Check cargo sell price var deconstructedPrice = await GetDeconstructedPrice(deconstructedMats); var price = await GetPrice(id); if (deconstructedPrice > 0 && price > 0) Assert.LessOrEqual(deconstructedPrice, price, $"{id} increases in price after being deconstructed"); // Check lathe production if (latheRecipes.TryGetValue(id, out var recipe)) { foreach (var (matId, amount) in recipe.RequiredMaterials) { var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier); if (deconstructedMats.TryGetValue(matId, out var numSpawned)) Assert.LessOrEqual(numSpawned, actualAmount, $"deconstructing {id} spawns more {matId} than required to produce via an (upgraded) lathe."); } } // Check construction. if (constructionMaterials.TryGetValue(id, out var constructionMats)) { foreach (var (matId, amount) in constructionMats) { if (deconstructedMats.TryGetValue(matId, out var numSpawned)) Assert.LessOrEqual(numSpawned, amount, $"deconstructing a {id} spawns more {matId} than required to construct it."); } } } }); await server.WaitPost(() => mapManager.DeleteMap(testMap.MapId)); await pairTracker.CleanReturnAsync(); async Task GetSpawnedPrice(Dictionary ents) { double price = 0; foreach (var (id, num) in ents) { price += num * await GetPrice(id); } return price; } async Task GetPrice(string id) { if (!priceCache.TryGetValue(id, out var price)) { await server.WaitPost(() => { var ent = entManager.SpawnEntity(id, testMap.GridCoords); stackSys.SetCount(ent, 1); priceCache[id] = price = pricing.GetPrice(ent); entManager.DeleteEntity(ent); }); } return price; } async Task GetDeconstructedPrice(Dictionary mats) { double price = 0; foreach (var (id, num) in mats) { var matProto = protoManager.Index(id).Spawn; price += num * await GetPrice(matProto); } return price; } } }