1
0

MaterialArbitrageTest.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. using System.Collections.Generic;
  2. using Content.Server.Cargo.Systems;
  3. using Content.Server.Construction.Completions;
  4. using Content.Server.Construction.Components;
  5. using Content.Server.Destructible;
  6. using Content.Server.Destructible.Thresholds.Behaviors;
  7. using Content.Server.Stack;
  8. using Content.Shared.Chemistry.Reagent;
  9. using Content.Shared.Construction.Components;
  10. using Content.Shared.Construction.Prototypes;
  11. using Content.Shared.Construction.Steps;
  12. using Content.Shared.FixedPoint;
  13. using Content.Shared.Lathe;
  14. using Content.Shared.Materials;
  15. using Content.Shared.Research.Prototypes;
  16. using Content.Shared.Stacks;
  17. using Robust.Shared.GameObjects;
  18. using Robust.Shared.Map;
  19. using Robust.Shared.Prototypes;
  20. using Robust.Shared.Utility;
  21. namespace Content.IntegrationTests.Tests;
  22. /// <summary>
  23. /// This test checks that any destructible or constructible entities do not drop more resources than are required to
  24. /// create them.
  25. /// </summary>
  26. [TestFixture]
  27. public sealed class MaterialArbitrageTest
  28. {
  29. [Test]
  30. public async Task NoMaterialArbitrage()
  31. {
  32. await using var pair = await PoolManager.GetServerClient();
  33. var server = pair.Server;
  34. var testMap = await pair.CreateTestMap();
  35. await server.WaitIdleAsync();
  36. var entManager = server.ResolveDependency<IEntityManager>();
  37. var mapManager = server.ResolveDependency<IMapManager>();
  38. var protoManager = server.ResolveDependency<IPrototypeManager>();
  39. var pricing = entManager.System<PricingSystem>();
  40. var stackSys = entManager.System<StackSystem>();
  41. var mapSystem = server.System<SharedMapSystem>();
  42. var latheSys = server.System<SharedLatheSystem>();
  43. var compFact = server.ResolveDependency<IComponentFactory>();
  44. Assert.That(mapSystem.IsInitialized(testMap.MapId));
  45. var constructionName = compFact.GetComponentName(typeof(ConstructionComponent));
  46. var compositionName = compFact.GetComponentName(typeof(PhysicalCompositionComponent));
  47. var materialName = compFact.GetComponentName(typeof(MaterialComponent));
  48. var destructibleName = compFact.GetComponentName(typeof(DestructibleComponent));
  49. // get the inverted lathe recipe dictionary
  50. var latheRecipes = latheSys.InverseRecipes;
  51. // Lets assume the possible lathe for resource multipliers:
  52. // TODO: each recipe can technically have its own cost multiplier associated with it, so this test needs redone to factor that in.
  53. var multiplier = MathF.Pow(0.85f, 3);
  54. // create construction dictionary
  55. Dictionary<string, ConstructionComponent> constructionRecipes = new();
  56. foreach (var proto in protoManager.EnumeratePrototypes<EntityPrototype>())
  57. {
  58. if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto))
  59. continue;
  60. if (!proto.Components.TryGetValue(constructionName, out var destructible))
  61. continue;
  62. var comp = (ConstructionComponent) destructible.Component;
  63. constructionRecipes.Add(proto.ID, comp);
  64. }
  65. // Get ingredients required to construct an entity
  66. Dictionary<string, Dictionary<string, int>> constructionMaterials = new();
  67. foreach (var (id, comp) in constructionRecipes)
  68. {
  69. var materials = new Dictionary<string, int>();
  70. var graph = protoManager.Index<ConstructionGraphPrototype>(comp.Graph);
  71. if (graph.Start == null)
  72. continue;
  73. if (!graph.TryPath(graph.Start, comp.Node, out var path) || path.Length == 0)
  74. continue;
  75. var cur = graph.Nodes[graph.Start];
  76. foreach (var node in path)
  77. {
  78. var edge = cur.GetEdge(node.Name);
  79. cur = node;
  80. if (edge == null)
  81. continue;
  82. foreach (var step in edge.Steps)
  83. {
  84. if (step is not MaterialConstructionGraphStep materialStep)
  85. continue;
  86. var stackProto = protoManager.Index<StackPrototype>(materialStep.MaterialPrototypeId);
  87. var spawnProto = protoManager.Index(stackProto.Spawn);
  88. if (!spawnProto.Components.ContainsKey(materialName) ||
  89. !spawnProto.Components.TryGetValue(compositionName, out var compositionReg))
  90. continue;
  91. var mat = (PhysicalCompositionComponent) compositionReg.Component;
  92. foreach (var (matId, amount) in mat.MaterialComposition)
  93. {
  94. materials[matId] = materialStep.Amount * amount + materials.GetValueOrDefault(matId);
  95. }
  96. }
  97. }
  98. constructionMaterials.Add(id, materials);
  99. }
  100. Dictionary<string, double> priceCache = new();
  101. Dictionary<string, (Dictionary<string, int> Ents, Dictionary<string, int> Mats)> spawnedOnDestroy = new();
  102. // Here we get the set of entities/materials spawned when destroying an entity.
  103. foreach (var proto in protoManager.EnumeratePrototypes<EntityPrototype>())
  104. {
  105. if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto))
  106. continue;
  107. if (!proto.Components.TryGetValue(destructibleName, out var destructible))
  108. continue;
  109. var comp = (DestructibleComponent) destructible.Component;
  110. var spawnedEnts = new Dictionary<string, int>();
  111. var spawnedMats = new Dictionary<string, int>();
  112. // This test just blindly assumes that ALL spawn entity behaviors get triggered. In reality, some entities
  113. // might only trigger a subset. If that starts being a problem, this test either needs fixing or needs to
  114. // get an ignored prototypes list.
  115. foreach (var threshold in comp.Thresholds)
  116. {
  117. foreach (var behaviour in threshold.Behaviors)
  118. {
  119. if (behaviour is not SpawnEntitiesBehavior spawn)
  120. continue;
  121. foreach (var (key, value) in spawn.Spawn)
  122. {
  123. spawnedEnts[key] = spawnedEnts.GetValueOrDefault(key) + value.Max;
  124. var spawnProto = protoManager.Index<EntityPrototype>(key);
  125. // get the amount of each material included in the entity
  126. if (!spawnProto.Components.ContainsKey(materialName) ||
  127. !spawnProto.Components.TryGetValue(compositionName, out var compositionReg))
  128. continue;
  129. var mat = (PhysicalCompositionComponent) compositionReg.Component;
  130. foreach (var (matId, amount) in mat.MaterialComposition)
  131. {
  132. spawnedMats[matId] = value.Max * amount + spawnedMats.GetValueOrDefault(matId);
  133. }
  134. }
  135. }
  136. }
  137. if (spawnedEnts.Count > 0)
  138. spawnedOnDestroy.Add(proto.ID, (spawnedEnts, spawnedMats));
  139. }
  140. // This is the main loop where we actually check for destruction arbitrage
  141. Assert.Multiple(async () =>
  142. {
  143. foreach (var (id, (spawnedEnts, spawnedMats)) in spawnedOnDestroy)
  144. {
  145. // Check cargo sell price
  146. // several constructible entities have no sell price
  147. // also this test only really matters if the entity is also purchaseable.... eh..
  148. var spawnedPrice = await GetSpawnedPrice(spawnedEnts);
  149. var price = await GetPrice(id);
  150. if (spawnedPrice > 0 && price > 0)
  151. Assert.That(spawnedPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after being destroyed\nEntities spawned on destruction: {string.Join(',', spawnedEnts)}");
  152. // Check lathe production
  153. if (latheRecipes.TryGetValue(id, out var recipes))
  154. {
  155. foreach (var recipe in recipes)
  156. {
  157. foreach (var (matId, amount) in recipe.Materials)
  158. {
  159. var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier);
  160. if (spawnedMats.TryGetValue(matId, out var numSpawned))
  161. Assert.That(numSpawned, Is.LessThanOrEqualTo(actualAmount), $"destroying a {id} spawns more {matId} than required to produce via an (upgraded) lathe.");
  162. }
  163. }
  164. }
  165. // Check construction.
  166. if (constructionMaterials.TryGetValue(id, out var constructionMats))
  167. {
  168. foreach (var (matId, amount) in constructionMats)
  169. {
  170. if (spawnedMats.TryGetValue(matId, out var numSpawned))
  171. Assert.That(numSpawned, Is.LessThanOrEqualTo(amount), $"destroying a {id} spawns more {matId} than required to construct it.");
  172. }
  173. }
  174. }
  175. });
  176. // Finally, lets also check for deconstruction arbitrage.
  177. // Get ingredients returned when deconstructing an entity
  178. Dictionary<string, Dictionary<string, int>> deconstructionMaterials = new();
  179. foreach (var (id, comp) in constructionRecipes)
  180. {
  181. if (comp.DeconstructionNode == null)
  182. continue;
  183. var materials = new Dictionary<string, int>();
  184. var graph = protoManager.Index<ConstructionGraphPrototype>(comp.Graph);
  185. if (!graph.TryPath(comp.Node, comp.DeconstructionNode, out var path) || path.Length == 0)
  186. continue;
  187. var cur = graph.Nodes[comp.Node];
  188. foreach (var node in path)
  189. {
  190. var edge = cur.GetEdge(node.Name);
  191. cur = node;
  192. foreach (var completion in edge.Completed)
  193. {
  194. if (completion is not SpawnPrototype spawnCompletion)
  195. continue;
  196. var spawnProto = protoManager.Index<EntityPrototype>(spawnCompletion.Prototype);
  197. if (!spawnProto.Components.ContainsKey(materialName) ||
  198. !spawnProto.Components.TryGetValue(compositionName, out var compositionReg))
  199. continue;
  200. var mat = (PhysicalCompositionComponent) compositionReg.Component;
  201. foreach (var (matId, amount) in mat.MaterialComposition)
  202. {
  203. materials[matId] = spawnCompletion.Amount * amount + materials.GetValueOrDefault(matId);
  204. }
  205. }
  206. }
  207. deconstructionMaterials.Add(id, materials);
  208. }
  209. // This is functionally the same loop as before, but now testing deconstruction rather than destruction.
  210. // This is pretty braindead. In principle construction graphs can have loops and whatnot.
  211. Assert.Multiple(async () =>
  212. {
  213. foreach (var (id, deconstructedMats) in deconstructionMaterials)
  214. {
  215. // Check cargo sell price
  216. var deconstructedPrice = await GetDeconstructedPrice(deconstructedMats);
  217. var price = await GetPrice(id);
  218. if (deconstructedPrice > 0 && price > 0)
  219. Assert.That(deconstructedPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after being deconstructed");
  220. // Check lathe production
  221. if (latheRecipes.TryGetValue(id, out var recipes))
  222. {
  223. foreach (var recipe in recipes)
  224. {
  225. foreach (var (matId, amount) in recipe.Materials)
  226. {
  227. var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier);
  228. if (deconstructedMats.TryGetValue(matId, out var numSpawned))
  229. Assert.That(numSpawned, Is.LessThanOrEqualTo(actualAmount), $"deconstructing {id} spawns more {matId} than required to produce via an (upgraded) lathe.");
  230. }
  231. }
  232. }
  233. // Check construction.
  234. if (constructionMaterials.TryGetValue(id, out var constructionMats))
  235. {
  236. foreach (var (matId, amount) in constructionMats)
  237. {
  238. if (deconstructedMats.TryGetValue(matId, out var numSpawned))
  239. Assert.That(numSpawned, Is.LessThanOrEqualTo(amount), $"deconstructing a {id} spawns more {matId} than required to construct it.");
  240. }
  241. }
  242. }
  243. });
  244. // create phyiscal composition dictionary
  245. // this doesn't account for the chemicals in the composition
  246. Dictionary<string, PhysicalCompositionComponent> physicalCompositions = new();
  247. foreach (var proto in protoManager.EnumeratePrototypes<EntityPrototype>())
  248. {
  249. if (proto.HideSpawnMenu || proto.Abstract || pair.IsTestPrototype(proto))
  250. continue;
  251. if (!proto.Components.TryGetValue(compositionName, out var composition))
  252. continue;
  253. var comp = (PhysicalCompositionComponent) composition.Component;
  254. physicalCompositions.Add(proto.ID, comp);
  255. }
  256. // This is functionally the same loop as before, but now testing composition rather than destruction or deconstruction.
  257. // This doesn't take into account chemicals generated when deconstructing. Maybe it should.
  258. Assert.Multiple(async () =>
  259. {
  260. foreach (var (id, compositionComponent) in physicalCompositions)
  261. {
  262. // Check cargo sell price
  263. var materialPrice = await GetDeconstructedPrice(compositionComponent.MaterialComposition);
  264. var chemicalPrice = await GetChemicalCompositionPrice(compositionComponent.ChemicalComposition);
  265. var sumPrice = materialPrice + chemicalPrice;
  266. var price = await GetPrice(id);
  267. if (sumPrice > 0 && price > 0)
  268. Assert.That(sumPrice, Is.LessThanOrEqualTo(price), $"{id} increases in price after decomposed into raw materials");
  269. // Check lathe production
  270. if (latheRecipes.TryGetValue(id, out var recipes))
  271. {
  272. foreach (var recipe in recipes)
  273. {
  274. foreach (var (matId, amount) in recipe.Materials)
  275. {
  276. var actualAmount = SharedLatheSystem.AdjustMaterial(amount, recipe.ApplyMaterialDiscount, multiplier);
  277. if (compositionComponent.MaterialComposition.TryGetValue(matId, out var numSpawned))
  278. Assert.That(numSpawned, Is.LessThanOrEqualTo(actualAmount), $"The physical composition of {id} has more {matId} than required to produce via an (upgraded) lathe.");
  279. }
  280. }
  281. }
  282. // Check construction.
  283. if (constructionMaterials.TryGetValue(id, out var constructionMats))
  284. {
  285. foreach (var (matId, amount) in constructionMats)
  286. {
  287. if (compositionComponent.MaterialComposition.TryGetValue(matId, out var numSpawned))
  288. Assert.That(numSpawned, Is.LessThanOrEqualTo(amount), $"The physical composition of {id} has more {matId} than required to construct it.");
  289. }
  290. }
  291. }
  292. });
  293. await server.WaitPost(() => mapSystem.DeleteMap(testMap.MapId));
  294. await pair.CleanReturnAsync();
  295. async Task<double> GetSpawnedPrice(Dictionary<string, int> ents)
  296. {
  297. double price = 0;
  298. foreach (var (id, num) in ents)
  299. {
  300. price += num * await GetPrice(id);
  301. }
  302. return price;
  303. }
  304. async Task<double> GetPrice(string id)
  305. {
  306. if (!priceCache.TryGetValue(id, out var price))
  307. {
  308. await server.WaitPost(() =>
  309. {
  310. var ent = entManager.SpawnEntity(id, testMap.GridCoords);
  311. stackSys.SetCount(ent, 1);
  312. priceCache[id] = price = pricing.GetPrice(ent, false);
  313. entManager.DeleteEntity(ent);
  314. });
  315. }
  316. return price;
  317. }
  318. #pragma warning disable CS1998
  319. async Task<double> GetDeconstructedPrice(Dictionary<string, int> mats)
  320. {
  321. double price = 0;
  322. foreach (var (id, num) in mats)
  323. {
  324. var matProto = protoManager.Index<MaterialPrototype>(id);
  325. price += num * matProto.Price;
  326. }
  327. return price;
  328. }
  329. #pragma warning restore CS1998
  330. #pragma warning disable CS1998
  331. async Task<double> GetChemicalCompositionPrice(Dictionary<string, FixedPoint2> mats)
  332. {
  333. double price = 0;
  334. foreach (var (id, num) in mats)
  335. {
  336. var reagentProto = protoManager.Index<ReagentPrototype>(id);
  337. price += num.Double() * reagentProto.PricePerUnit;
  338. }
  339. return price;
  340. }
  341. #pragma warning restore CS1998
  342. }
  343. }