#nullable enable using System.Collections.Generic; 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.Lathe; using Content.Server.Stack; using Content.Shared.Chemistry.Reagent; using Content.Shared.Construction.Prototypes; using Content.Shared.Construction.Steps; using Content.Shared.FixedPoint; using Content.Shared.Lathe; using Content.Shared.Materials; using Content.Shared.Research.Prototypes; using Content.Shared.Stacks; using Content.Shared.Tools.Components; using Robust.Shared.GameObjects; using Robust.Shared.Prototypes; 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 { // These recipes are currently broken and need fixing. You should not be adding to these sets. private readonly HashSet _destructionArbitrageIgnore = [ "BaseChemistryEmptyVial", "DrinkShotGlass", "SodiumLightTube", "DrinkGlassCoupeShaped", "LedLightBulb", "ExteriorLightTube", "LightTube", "DrinkGlass", "DimLightBulb", "LightBulb", "LedLightTube", "ChemistryEmptyBottle01", "WarmLightBulb", ]; private readonly HashSet _compositionArbitrageIgnore = [ "FoodPlateSmall", "AirTank", "FoodPlateTin", "FoodPlateMuffinTin", "WeaponCapacitorRechargerCircuitboard", "WeaponCapacitorRechargerCircuitboard", "BorgChargerCircuitboard", "BorgChargerCircuitboard", "FoodPlate", "CellRechargerCircuitboard", "CellRechargerCircuitboard", ]; [Test] public async Task NoMaterialArbitrage() { await using var pair = await PoolManager.GetServerClient(); var server = pair.Server; var testMap = await pair.CreateTestMap(); await server.WaitIdleAsync(); var entManager = server.ResolveDependency(); var protoManager = server.ResolveDependency(); var pricing = entManager.System(); var stackSys = entManager.System(); var mapSystem = server.System(); var latheSys = server.System(); var compFact = server.ResolveDependency(); Assert.That(mapSystem.IsInitialized(testMap.MapId)); var constructionName = compFact.GetComponentName(); var compositionName = compFact.GetComponentName(); var materialName = compFact.GetComponentName(); var destructibleName = compFact.GetComponentName(); var refinableName = compFact.GetComponentName(); // get the inverted lathe recipe dictionary var latheRecipes = latheSys.InverseRecipes; // Find the lowest multiplier / optimal lathe that can be used to construct a recipie. var minMultiplier = new Dictionary, float>(); foreach (var (_, lathe) in pair.GetPrototypesWithComponent()) { foreach (var recipe in latheSys.GetAllPossibleRecipes(lathe)) { if (!minMultiplier.TryGetValue(recipe, out var min)) min = 1; minMultiplier[recipe] = Math.Min(min, lathe.MaterialUseMultiplier); } } // create construction dictionary Dictionary constructionRecipes = new(); foreach (var proto in protoManager.EnumeratePrototypes()) { if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto)) 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.Start == null) continue; 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; if (edge == null) continue; foreach (var step in edge.Steps) { if (step is not MaterialConstructionGraphStep materialStep) continue; var stackProto = protoManager.Index(materialStep.MaterialPrototypeId); var spawnProto = protoManager.Index(stackProto.Spawn); if (!spawnProto.Components.ContainsKey(materialName) || !spawnProto.Components.TryGetValue(compositionName, out var compositionReg)) continue; var mat = (PhysicalCompositionComponent) compositionReg.Component; foreach (var (matId, amount) in mat.MaterialComposition) { materials[matId] = materialStep.Amount * amount + materials.GetValueOrDefault(matId); } } } constructionMaterials.Add(id, materials); } Dictionary priceCache = new(); Dictionary Ents, Dictionary Mats)> spawnedOnDestroy = new(); // cache the compositions of entities // If the entity is refineable (i.e. glass shared can be turned into glass, we take the greater of the two compositions. Dictionary> compositions = new(); foreach (var proto in protoManager.EnumeratePrototypes()) { Dictionary? baseComposition = null; if (proto.Components.ContainsKey(materialName) && proto.Components.TryGetValue(compositionName, out var compositionReg)) { var compositionComp = (PhysicalCompositionComponent)compositionReg.Component; baseComposition = compositionComp.MaterialComposition; } if (!proto.Components.TryGetValue(refinableName, out var refinableReg)) { if (baseComposition != null) compositions[proto.ID] = new(baseComposition); continue; } var composition = new Dictionary(); compositions.Add(proto.ID, composition); var refinable = (ToolRefinableComponent)refinableReg.Component; foreach (var refineResult in refinable.RefineResult) { if (refineResult.PrototypeId == null) continue; var refineProto = protoManager.Index(refineResult.PrototypeId.Value); if (!refineProto.Components.ContainsKey(materialName)) continue; if (!refineProto.Components.TryGetValue(compositionName, out var refinedCompositionReg)) continue; var refinedCompositionComp = (PhysicalCompositionComponent)refinedCompositionReg.Component; // This assumes refine results do not have complex spawn behaviours like exclusive groups. var quantity = refineResult.MaxAmount; foreach (var (matId, amount) in refinedCompositionComp.MaterialComposition) { composition[matId] = quantity * amount + composition.GetValueOrDefault(matId); } } if (baseComposition == null) continue; // If the un-refined material quantity is greater than the refined quantity, we use that instead. foreach (var (matId, amount) in baseComposition) { composition[matId] = Math.Max(amount, composition.GetValueOrDefault(matId)); } } // Here we get the set of entities/materials spawned when destroying an entity. foreach (var proto in protoManager.EnumeratePrototypes()) { if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto)) 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) + (float)(value.Min + value.Max) / 2; if (!compositions.TryGetValue(key, out var composition)) continue; foreach (var (matId, amount) in composition) { spawnedMats[matId] = (float)(value.Min + value.Max) / 2 * amount + spawnedMats.GetValueOrDefault(matId); } } } } if (spawnedEnts.Count > 0) spawnedOnDestroy.Add(proto.ID, (spawnedEnts, spawnedMats)); } // This is the main loop where we actually check for destruction arbitrage await Assert.MultipleAsync(async () => { foreach (var (id, (spawnedEnts, spawnedMats)) in spawnedOnDestroy) { if (_destructionArbitrageIgnore.Contains(id)) continue; // 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.That(spawnedPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after being destroyed\nEntities spawned on destruction: {string.Join(',', spawnedEnts)}"); // Check lathe production if (latheRecipes.TryGetValue(id, out var recipes)) { foreach (var recipe in recipes) { if (!minMultiplier.TryGetValue(recipe, out var multiplier)) { server.Log.Info($"Unused lathe recipe? {recipe.ID}?"); continue; } foreach (var (matId, amount) in recipe.Materials) { var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier); if (spawnedMats.TryGetValue(matId, out var numSpawned)) Assert.That(numSpawned, Is.LessThanOrEqualTo(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.That(numSpawned, Is.LessThanOrEqualTo(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; if (edge == null) continue; foreach (var completion in edge.Completed) { if (completion is not SpawnPrototype spawnCompletion) continue; var spawnProto = protoManager.Index(spawnCompletion.Prototype); if (!spawnProto.Components.ContainsKey(materialName) || !spawnProto.Components.TryGetValue(compositionName, out var compositionReg)) continue; var mat = (PhysicalCompositionComponent) compositionReg.Component; foreach (var (matId, amount) in mat.MaterialComposition) { materials[matId] = spawnCompletion.Amount * amount + materials.GetValueOrDefault(matId); } } } deconstructionMaterials.Add(id, materials); } // This is functionally the same loop as before, but now testing deconstruction rather than destruction. // This is pretty brain-dead. In principle construction graphs can have loops and whatnot. await Assert.MultipleAsync(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.That(deconstructedPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after being deconstructed"); // Check lathe production if (latheRecipes.TryGetValue(id, out var recipes)) { foreach (var recipe in recipes) { if (!minMultiplier.TryGetValue(recipe, out var multiplier)) { server.Log.Info($"Unused lathe recipe? {recipe.ID}?"); continue; } foreach (var (matId, amount) in recipe.Materials) { var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier); if (deconstructedMats.TryGetValue(matId, out var numSpawned)) Assert.That(numSpawned, Is.LessThanOrEqualTo(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.That(numSpawned, Is.LessThanOrEqualTo(amount), $"deconstructing a {id} spawns more {matId} than required to construct it."); } } } }); // create physical composition dictionary // this doesn't account for the chemicals in the composition Dictionary physicalCompositions = new(); foreach (var proto in protoManager.EnumeratePrototypes()) { if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto)) continue; if (!proto.Components.TryGetValue(compositionName, out var composition)) continue; var comp = (PhysicalCompositionComponent) composition.Component; physicalCompositions.Add(proto.ID, comp); } // This is functionally the same loop as before, but now testing composition rather than destruction or deconstruction. // This doesn't take into account chemicals generated when deconstructing. Maybe it should. await Assert.MultipleAsync(async () => { foreach (var (id, compositionComponent) in physicalCompositions) { if (_compositionArbitrageIgnore.Contains(id)) continue; // Check cargo sell price var materialPrice = await GetDeconstructedPrice(compositionComponent.MaterialComposition); var chemicalPrice = await GetChemicalCompositionPrice(compositionComponent.ChemicalComposition); var sumPrice = materialPrice + chemicalPrice; var price = await GetPrice(id); if (sumPrice > 0 && price > 0) Assert.That(sumPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after decomposed into raw materials"); // Check lathe production if (latheRecipes.TryGetValue(id, out var recipes)) { foreach (var recipe in recipes) { if (!minMultiplier.TryGetValue(recipe, out var multiplier)) { server.Log.Info($"Unused lathe recipe? {recipe.ID}?"); continue; } foreach (var (matId, amount) in recipe.Materials) { var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier); if (compositionComponent.MaterialComposition.TryGetValue(matId, out var numSpawned)) Assert.That(numSpawned, Is.LessThanOrEqualTo(actualAmount), $"The physical composition of {id} has 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 (compositionComponent.MaterialComposition.TryGetValue(matId, out var numSpawned)) Assert.That(numSpawned, Is.LessThanOrEqualTo(amount), $"The physical composition of {id} has more {matId} than required to construct it."); } } } }); await server.WaitPost(() => mapSystem.DeleteMap(testMap.MapId)); await pair.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, false); entManager.DeleteEntity(ent); }); } return price; } #pragma warning disable CS1998 async Task GetDeconstructedPrice(Dictionary mats) { double price = 0; foreach (var (id, num) in mats) { var matProto = protoManager.Index(id); price += num * matProto.Price; } return price; } #pragma warning restore CS1998 #pragma warning disable CS1998 async Task GetChemicalCompositionPrice(Dictionary mats) { double price = 0; foreach (var (id, num) in mats) { var reagentProto = protoManager.Index(id); price += num.Double() * reagentProto.PricePerUnit; } return price; } #pragma warning restore CS1998 } }