2024-05-09 14:53:05 -07:00
using System.Collections.Generic ;
2023-01-15 21:57:59 +13:00
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 ;
2024-05-09 14:53:05 -07:00
using Content.Shared.Chemistry.Reagent ;
using Content.Shared.Construction.Components ;
2023-01-15 21:57:59 +13:00
using Content.Shared.Construction.Prototypes ;
using Content.Shared.Construction.Steps ;
2024-05-09 14:53:05 -07:00
using Content.Shared.FixedPoint ;
2023-01-15 21:57:59 +13:00
using Content.Shared.Lathe ;
2024-05-09 14:53:05 -07:00
using Content.Shared.Materials ;
2023-01-15 21:57:59 +13:00
using Content.Shared.Research.Prototypes ;
using Content.Shared.Stacks ;
using Robust.Shared.GameObjects ;
using Robust.Shared.Map ;
using Robust.Shared.Prototypes ;
2024-05-09 14:53:05 -07:00
using Robust.Shared.Utility ;
2023-01-15 21:57:59 +13:00
namespace Content.IntegrationTests.Tests ;
/// <summary>
/// This test checks that any destructible or constructible entities do not drop more resources than are required to
/// create them.
/// </summary>
[TestFixture]
public sealed class MaterialArbitrageTest
{
[Test]
public async Task NoMaterialArbitrage ( )
{
2023-08-25 02:56:51 +02:00
await using var pair = await PoolManager . GetServerClient ( ) ;
var server = pair . Server ;
2023-01-15 21:57:59 +13:00
2023-08-25 04:13:11 +02:00
var testMap = await pair . CreateTestMap ( ) ;
2023-01-15 21:57:59 +13:00
await server . WaitIdleAsync ( ) ;
var entManager = server . ResolveDependency < IEntityManager > ( ) ;
var mapManager = server . ResolveDependency < IMapManager > ( ) ;
var protoManager = server . ResolveDependency < IPrototypeManager > ( ) ;
2024-07-02 20:01:37 -04:00
var pricing = entManager . System < PricingSystem > ( ) ;
var stackSys = entManager . System < StackSystem > ( ) ;
var mapSystem = server . System < SharedMapSystem > ( ) ;
2024-08-29 19:36:29 +00:00
var latheSys = server . System < SharedLatheSystem > ( ) ;
2023-01-15 21:57:59 +13:00
var compFact = server . ResolveDependency < IComponentFactory > ( ) ;
2024-07-02 20:01:37 -04:00
Assert . That ( mapSystem . IsInitialized ( testMap . MapId ) ) ;
2023-01-15 21:57:59 +13:00
var constructionName = compFact . GetComponentName ( typeof ( ConstructionComponent ) ) ;
2023-04-10 00:38:20 -04:00
var compositionName = compFact . GetComponentName ( typeof ( PhysicalCompositionComponent ) ) ;
var materialName = compFact . GetComponentName ( typeof ( MaterialComponent ) ) ;
2023-01-15 21:57:59 +13:00
var destructibleName = compFact . GetComponentName ( typeof ( DestructibleComponent ) ) ;
2024-08-29 19:36:29 +00:00
// get the inverted lathe recipe dictionary
var latheRecipes = latheSys . InverseRecipes ;
2023-01-15 21:57:59 +13:00
// Lets assume the possible lathe for resource multipliers:
2024-06-05 16:23:23 -04:00
// TODO: each recipe can technically have its own cost multiplier associated with it, so this test needs redone to factor that in.
var multiplier = MathF . Pow ( 0.85f , 3 ) ;
2023-01-15 21:57:59 +13:00
// create construction dictionary
Dictionary < string , ConstructionComponent > constructionRecipes = new ( ) ;
foreach ( var proto in protoManager . EnumeratePrototypes < EntityPrototype > ( ) )
{
2024-07-02 20:01:37 -04:00
if ( proto . HideSpawnMenu | | proto . Abstract | | pair . IsTestPrototype ( proto ) )
2023-01-15 21:57:59 +13:00
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 < string , Dictionary < string , int > > constructionMaterials = new ( ) ;
foreach ( var ( id , comp ) in constructionRecipes )
{
var materials = new Dictionary < string , int > ( ) ;
var graph = protoManager . Index < ConstructionGraphPrototype > ( comp . Graph ) ;
2023-04-10 00:38:20 -04:00
if ( graph . Start = = null )
continue ;
2023-01-15 21:57:59 +13:00
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 ;
2023-04-10 00:38:20 -04:00
if ( edge = = null )
continue ;
2023-01-15 21:57:59 +13:00
foreach ( var step in edge . Steps )
{
2023-04-10 00:38:20 -04:00
if ( step is not MaterialConstructionGraphStep materialStep )
continue ;
var stackProto = protoManager . Index < StackPrototype > ( materialStep . MaterialPrototypeId ) ;
2024-06-01 13:49:28 -04:00
var spawnProto = protoManager . Index ( stackProto . Spawn ) ;
2023-04-10 00:38:20 -04:00
2023-04-29 00:53:41 -04:00
if ( ! spawnProto . Components . ContainsKey ( materialName ) | |
! spawnProto . Components . TryGetValue ( compositionName , out var compositionReg ) )
2023-04-10 00:38:20 -04:00
continue ;
2023-04-29 00:53:41 -04:00
var mat = ( PhysicalCompositionComponent ) compositionReg . Component ;
foreach ( var ( matId , amount ) in mat . MaterialComposition )
2023-04-10 00:38:20 -04:00
{
materials [ matId ] = materialStep . Amount * amount + materials . GetValueOrDefault ( matId ) ;
}
2023-01-15 21:57:59 +13:00
}
}
constructionMaterials . Add ( id , materials ) ;
}
Dictionary < string , double > priceCache = new ( ) ;
Dictionary < string , ( Dictionary < string , int > Ents , Dictionary < string , int > Mats ) > spawnedOnDestroy = new ( ) ;
// Here we get the set of entities/materials spawned when destroying an entity.
foreach ( var proto in protoManager . EnumeratePrototypes < EntityPrototype > ( ) )
{
2024-07-02 20:01:37 -04:00
if ( proto . HideSpawnMenu | | proto . Abstract | | pair . IsTestPrototype ( proto ) )
2023-01-15 21:57:59 +13:00
continue ;
if ( ! proto . Components . TryGetValue ( destructibleName , out var destructible ) )
continue ;
var comp = ( DestructibleComponent ) destructible . Component ;
var spawnedEnts = new Dictionary < string , int > ( ) ;
var spawnedMats = new Dictionary < string , int > ( ) ;
// 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 < EntityPrototype > ( key ) ;
2023-04-10 00:38:20 -04:00
// get the amount of each material included in the entity
2023-04-29 00:53:41 -04:00
if ( ! spawnProto . Components . ContainsKey ( materialName ) | |
! spawnProto . Components . TryGetValue ( compositionName , out var compositionReg ) )
2023-01-15 21:57:59 +13:00
continue ;
2023-04-29 00:53:41 -04:00
var mat = ( PhysicalCompositionComponent ) compositionReg . Component ;
foreach ( var ( matId , amount ) in mat . MaterialComposition )
2023-04-10 00:38:20 -04:00
{
spawnedMats [ matId ] = value . Max * amount + spawnedMats . GetValueOrDefault ( matId ) ;
}
2023-01-15 21:57:59 +13:00
}
}
}
if ( spawnedEnts . Count > 0 )
spawnedOnDestroy . Add ( proto . ID , ( spawnedEnts , spawnedMats ) ) ;
}
2023-01-16 11:53:23 -05:00
// This is the main loop where we actually check for destruction arbitrage
2023-01-15 21:57:59 +13:00
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 )
2024-04-20 17:23:09 -04:00
Assert . That ( spawnedPrice , Is . LessThanOrEqualTo ( price ) , $"{id} increases in price after being destroyed\nEntities spawned on destruction: {string.Join(',', spawnedEnts)}" ) ;
2023-01-15 21:57:59 +13:00
// Check lathe production
2024-05-09 14:53:05 -07:00
if ( latheRecipes . TryGetValue ( id , out var recipes ) )
2023-01-15 21:57:59 +13:00
{
2024-05-09 14:53:05 -07:00
foreach ( var recipe in recipes )
2023-01-15 21:57:59 +13:00
{
2024-08-01 00:15:05 -04:00
foreach ( var ( matId , amount ) in recipe . Materials )
2024-05-09 14:53:05 -07:00
{
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." ) ;
}
2023-01-15 21:57:59 +13:00
}
}
// Check construction.
if ( constructionMaterials . TryGetValue ( id , out var constructionMats ) )
{
foreach ( var ( matId , amount ) in constructionMats )
{
if ( spawnedMats . TryGetValue ( matId , out var numSpawned ) )
2023-07-05 21:54:25 -07:00
Assert . That ( numSpawned , Is . LessThanOrEqualTo ( amount ) , $"destroying a {id} spawns more {matId} than required to construct it." ) ;
2023-01-15 21:57:59 +13:00
}
}
}
} ) ;
2023-01-16 11:53:23 -05:00
2023-01-15 21:57:59 +13:00
// Finally, lets also check for deconstruction arbitrage.
// Get ingredients returned when deconstructing an entity
Dictionary < string , Dictionary < string , int > > deconstructionMaterials = new ( ) ;
foreach ( var ( id , comp ) in constructionRecipes )
{
if ( comp . DeconstructionNode = = null )
continue ;
var materials = new Dictionary < string , int > ( ) ;
var graph = protoManager . Index < ConstructionGraphPrototype > ( 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 < EntityPrototype > ( spawnCompletion . Prototype ) ;
2023-04-29 00:53:41 -04:00
if ( ! spawnProto . Components . ContainsKey ( materialName ) | |
! spawnProto . Components . TryGetValue ( compositionName , out var compositionReg ) )
2023-04-10 00:38:20 -04:00
continue ;
2023-01-15 21:57:59 +13:00
2023-04-29 00:53:41 -04:00
var mat = ( PhysicalCompositionComponent ) compositionReg . Component ;
foreach ( var ( matId , amount ) in mat . MaterialComposition )
2023-04-10 00:38:20 -04:00
{
materials [ matId ] = spawnCompletion . Amount * amount + materials . GetValueOrDefault ( matId ) ;
}
2023-01-15 21:57:59 +13:00
}
}
deconstructionMaterials . Add ( id , materials ) ;
}
2023-04-10 00:38:20 -04:00
// This is functionally the same loop as before, but now testing deconstruction rather than destruction.
2023-01-15 21:57:59 +13:00
// 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 )
2023-07-05 21:54:25 -07:00
Assert . That ( deconstructedPrice , Is . LessThanOrEqualTo ( price ) , $"{id} increases in price after being deconstructed" ) ;
2023-01-15 21:57:59 +13:00
// Check lathe production
2024-05-09 14:53:05 -07:00
if ( latheRecipes . TryGetValue ( id , out var recipes ) )
2023-01-15 21:57:59 +13:00
{
2024-05-09 14:53:05 -07:00
foreach ( var recipe in recipes )
2023-01-15 21:57:59 +13:00
{
2024-08-01 00:15:05 -04:00
foreach ( var ( matId , amount ) in recipe . Materials )
2024-05-09 14:53:05 -07:00
{
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." ) ;
}
2023-01-15 21:57:59 +13:00
}
}
// Check construction.
if ( constructionMaterials . TryGetValue ( id , out var constructionMats ) )
{
foreach ( var ( matId , amount ) in constructionMats )
{
if ( deconstructedMats . TryGetValue ( matId , out var numSpawned ) )
2023-07-05 21:54:25 -07:00
Assert . That ( numSpawned , Is . LessThanOrEqualTo ( amount ) , $"deconstructing a {id} spawns more {matId} than required to construct it." ) ;
2023-01-15 21:57:59 +13:00
}
}
}
} ) ;
2023-04-10 00:38:20 -04:00
// create phyiscal composition dictionary
// this doesn't account for the chemicals in the composition
Dictionary < string , PhysicalCompositionComponent > physicalCompositions = new ( ) ;
foreach ( var proto in protoManager . EnumeratePrototypes < EntityPrototype > ( ) )
{
2024-07-02 20:01:37 -04:00
if ( proto . HideSpawnMenu | | proto . Abstract | | pair . IsTestPrototype ( proto ) )
2023-04-10 00:38:20 -04:00
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.
Assert . Multiple ( async ( ) = >
{
foreach ( var ( id , compositionComponent ) in physicalCompositions )
{
// 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 )
2023-07-05 21:54:25 -07:00
Assert . That ( sumPrice , Is . LessThanOrEqualTo ( price ) , $"{id} increases in price after decomposed into raw materials" ) ;
2023-04-10 00:38:20 -04:00
// Check lathe production
2024-05-09 14:53:05 -07:00
if ( latheRecipes . TryGetValue ( id , out var recipes ) )
2023-04-10 00:38:20 -04:00
{
2024-05-09 14:53:05 -07:00
foreach ( var recipe in recipes )
2023-04-10 00:38:20 -04:00
{
2024-08-01 00:15:05 -04:00
foreach ( var ( matId , amount ) in recipe . Materials )
2024-05-09 14:53:05 -07:00
{
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." ) ;
}
2023-04-10 00:38:20 -04:00
}
}
// Check construction.
if ( constructionMaterials . TryGetValue ( id , out var constructionMats ) )
{
foreach ( var ( matId , amount ) in constructionMats )
{
if ( compositionComponent . MaterialComposition . TryGetValue ( matId , out var numSpawned ) )
2023-07-05 21:54:25 -07:00
Assert . That ( numSpawned , Is . LessThanOrEqualTo ( amount ) , $"The physical composition of {id} has more {matId} than required to construct it." ) ;
2023-04-10 00:38:20 -04:00
}
}
}
} ) ;
2023-01-15 21:57:59 +13:00
await server . WaitPost ( ( ) = > mapManager . DeleteMap ( testMap . MapId ) ) ;
2023-08-25 02:56:51 +02:00
await pair . CleanReturnAsync ( ) ;
2023-01-15 21:57:59 +13:00
async Task < double > GetSpawnedPrice ( Dictionary < string , int > ents )
{
double price = 0 ;
foreach ( var ( id , num ) in ents )
{
price + = num * await GetPrice ( id ) ;
}
return price ;
}
async Task < double > GetPrice ( string id )
{
if ( ! priceCache . TryGetValue ( id , out var price ) )
{
await server . WaitPost ( ( ) = >
{
var ent = entManager . SpawnEntity ( id , testMap . GridCoords ) ;
stackSys . SetCount ( ent , 1 ) ;
2024-04-20 18:03:11 -04:00
priceCache [ id ] = price = pricing . GetPrice ( ent , false ) ;
2023-01-15 21:57:59 +13:00
entManager . DeleteEntity ( ent ) ;
} ) ;
}
return price ;
}
2023-07-05 21:54:25 -07:00
#pragma warning disable CS1998
2023-01-15 21:57:59 +13:00
async Task < double > GetDeconstructedPrice ( Dictionary < string , int > mats )
{
double price = 0 ;
foreach ( var ( id , num ) in mats )
{
2023-04-10 00:38:20 -04:00
var matProto = protoManager . Index < MaterialPrototype > ( id ) ;
price + = num * matProto . Price ;
}
return price ;
}
2023-07-05 21:54:25 -07:00
#pragma warning restore CS1998
2023-04-10 00:38:20 -04:00
2023-07-05 21:54:25 -07:00
#pragma warning disable CS1998
2023-04-10 00:38:20 -04:00
async Task < double > GetChemicalCompositionPrice ( Dictionary < string , FixedPoint2 > mats )
{
double price = 0 ;
foreach ( var ( id , num ) in mats )
{
var reagentProto = protoManager . Index < ReagentPrototype > ( id ) ;
price + = num . Double ( ) * reagentProto . PricePerUnit ;
2023-01-15 21:57:59 +13:00
}
return price ;
}
2023-07-05 21:54:25 -07:00
#pragma warning restore CS1998
2023-01-15 21:57:59 +13:00
}
}