How to create custom tuning tests
Oct 4, 2020 12:25:15 GMT -5
MizoreYukii, august_sun, and 4 more like this
Post by frankk on Oct 4, 2020 12:25:15 GMT -5
This tutorial has been updated
Please view the updated tutorial on Medium.
----
This tutorial will show you how to create your own test syntax, which can help you significantly clean up your code and cut down on large amounts of test sets - it assumes that you know the basics of both tuning and script modding.
[1] The problem
You don't have to read this section, but it's good to know for the context of why this tutorial may be useful to you.
Tests in the sims do not have much flexibility in terms of logic. For example, `test_globals` is a list of tests that must all pass. You cannot say "pass the set if this test OR that test passes" or "pass the set if this test passes and that test does NOT pass". Without OR and NOT, the logic you can perform is very limited.
Thankfully, test sets exist, which allow us to perform ORs of ANDs (i.e. it is a list of lists, and the whole set will pass if at least one of its sub-lists passes ALL of its tests). See the example below:
That works well enough. But, what if you need to perform an ANDs of ORs operation? To go off of the previous example, what if we needed to pass the test set if the actor has either the trait OR the skill at level 10, AND the target also either has the trait OR the skill at level 10? You're going to have to create different test sets, and reference them in each other, like so:
So, you have three separate files to perform one simple test. Now, you're probably thinking, that's not too bad, right? Well, now imagine that you have 5 of those trait/skill pairs, and that at least one of them must pass for the set to pass. That's now 11 files. Now, imagine that you have to check not only level 10 of the skill, but perform the same tests for level 2. Now that's 22. What about performing those tests for level 3? Now it's 33 files. Level 4? 44 files. And so on.
If this example seems outrageous, it's not. It's the exact problem I ran into while developing my language mod. There are native speaker traits and skills associated with each of five languages, and I needed a way to test the language level that two sims have in common. If I took this approach, it would have been completely out of hand.
[2] The solution
You can create your own custom test in Python, which has the power to perform any number of ANDs, ORs, and NOTs, in any order. Then you can reference that test in a tuning file, and save yourself from the ultra-headache, bug-prone process that is nested test sets. That's what this tutorial is all about.
To follow this tutorial, you're going to need to have a Python workspace setup and the game files decompiled. You should also be familiar with Python and The Sims 4 codebase.
[2.1] Create your test class
This is the hard part. It's not a lot of code, but it does take a bit to wrap your head around. Take your time with this, because it is the most important step.
It will be easiest to create your test class by referencing a similar test that already exists. To look for a test class that is similar to whatever you're trying to do, go to the `simulation > event_testing > tests.py` file, and look in the `TEST_VARIANTS` dictionary in the `TunableTestVariant` class. The keys in this dictionary correspond to the name that appears in the `t="test_name"` attribute of the tuning test (i.e. "trait" and "skill_test" in the previous examples). Let's go off of the previous example, and copy the trait test. You'll see that the value for the key `"trait"` is `sims.sim_info_tests.TraitTest.TunableFactory`, so let's copy the `TraitTest` class into our own Python file (if you're having trouble finding the TraitTest class, just search the project for it).
After you've copied that class (copy the ENTIRE thing, including any imports it requires), rename it to something unique and descriptive. The name I used for mine was `FKLBLanguageLevelTest`. The FKLB is how I prefix nearly all methods and classes in my mod (FK for my name, and LB for the mod name), and LanguageLevelTest to be descriptive.
Some of the fields and methods in the class are just boilerplate code, so you can leave them as-is. Look into other test classes as well as the classes that your test class inherits from if you would like to know what they do. But for the sake of this tutorial, what we're interested in is changing `FACTORY_TUNABLES`, `__slots__`, and `__call__`.
`FACTORY_TUNABLES` is what will be used to parse your test in tuning. You'll notice that, if you copied the TraitTest class, you'll recognize some of those fields, such as `whitelist_traits`. This is where you can define what exactly your test is capable of doing. Here is a (simplified) snippet of code from my mod:
This translates to XML like this:
Now, simply change all of the values in `__slots__` to be the same as all of the keys in `FACTORY_TUNABLES`, like so:
And change the logic in the `__call__` method to whatever you need. Again, here is a (simplified) version of the code in my mod, just as an example of how you would check the traits/skills:
Of course, you're going to have to change a lot of the logic here to suit your mod. The code I show you is very specific to my use case.
And there it is! You created a custom test. Problem is, the game won't know when to use it. That's the next step.
[2.2] Create your test set
The only way I could get this to work was through creating my own test set. Don't worry - this part is easy.
This is the entirety of my code for my custom test set, not even simplified like the previous examples:
WARNING: Do NOT change the string 'test'. That needs to be the key for your test set, or else it will not work without overriding some more methods in the test set base. It's not worth it - just leave it as 'test'.
Change the names of the classes to whatever you'd like, and then change ['language_level'] to whatever you want your test to be called, change `FKLBLanguageLevelTest` to whatever your test class is, and then change the reference to `FKLBTunableLanguageTestSet` to whatever your test set class is. And voilà, now you can write a test like this:
This test would pass if the actor has either the Selvadoradian native speaker trait, or the skill level 5 or higher.
[2.3] Use your custom test set
One caveat to this method is that it does not allow you to use your custom test just anywhere you'd like, you have to do it in your custom test set file. So, you can't just plop your test into a test_globals and expect it to work. You have to put it in a test set and reference that test set in the test_globals, like this:
It's also worth noting that, since all we did was add to the `TEST_VARIANTS` rather than overwrite it, you can actually put any tests you want in this test set alongside your custom tests.
Then, reference your test set wherever you'd like with something like this:
It will work because your TestSetInstance is a subclass of the actual TestSetInstance used in the game, so you're kinda tricking it into thinking that your file is the one that already exists in the game.
And congrats! That's it. It's a lot of work, but it will be worth it for some mods. For example, it reduced the need for 44 test sets in my mod to just 3.
Please leave any questions you have on this thread.
---
EDIT: Added some warnings that I forgot about when I first wrote the tutorial.
Please view the updated tutorial on Medium.
----
This tutorial will show you how to create your own test syntax, which can help you significantly clean up your code and cut down on large amounts of test sets - it assumes that you know the basics of both tuning and script modding.
[1] The problem
You don't have to read this section, but it's good to know for the context of why this tutorial may be useful to you.
Tests in the sims do not have much flexibility in terms of logic. For example, `test_globals` is a list of tests that must all pass. You cannot say "pass the set if this test OR that test passes" or "pass the set if this test passes and that test does NOT pass". Without OR and NOT, the logic you can perform is very limited.
Thankfully, test sets exist, which allow us to perform ORs of ANDs (i.e. it is a list of lists, and the whole set will pass if at least one of its sub-lists passes ALL of its tests). See the example below:
{View the code}
<L n="test">
<!-- This list will pass if either the actor OR the target has both the trait AND the skill at level 10 -->
<L>
<!-- This sub-list will pass if the actor has the trait AND the skill at level 10 -->
<V t="trait">
<U n="trait">
<L n="whitelist_traits">
<T>123456789<!--some trait--></T>
</L>
</U>
</V>
<V t="skill_test">
<U n="skill_test">
<T n="skill">987654321<!--some associated skill--></T>
<V n="skill_range" t="threshold">
<U n="threshold">
<U n="skill_threshold">
<T n="value">10</T>
</U>
</U>
</V>
</U>
</V>
</L>
<L>
<!-- This sub-list will pass if the target has the trait AND the skill at level 10 -->
<V t="trait">
<U n="trait">
<E n="subject">TargetSim</E>
<L n="whitelist_traits">
<T>123456789<!--some trait--></T>
</L>
</U>
</V>
<V t="skill_test">
<U n="skill_test">
<T n="skill">987654321<!--some associated skill--></T>
<V n="skill_range" t="threshold">
<U n="threshold">
<U n="skill_threshold">
<T n="value">10</T>
</U>
</U>
</V>
<E n="subject">TargetSim</E>
</U>
</V>
</L>
</L>
That works well enough. But, what if you need to perform an ANDs of ORs operation? To go off of the previous example, what if we needed to pass the test set if the actor has either the trait OR the skill at level 10, AND the target also either has the trait OR the skill at level 10? You're going to have to create different test sets, and reference them in each other, like so:
{View the code}
<!-- This would exist in some test set file, tuning ID = 135798642 -->
<L n="test">
<!-- This will pass if the actor has either the trait or the skill level 10 -->
<L>
<V t="trait">
<U n="trait">
<L n="whitelist_traits">
<T>123456789<!--some trait--></T>
</L>
</U>
</V>
</L>
<L>
<V t="skill_test">
<U n="skill_test">
<T n="skill">987654321<!--some associated skill--></T>
<V n="skill_range" t="threshold">
<U n="threshold">
<U n="skill_threshold">
<T n="value">10</T>
</U>
</U>
</V>
</U>
</V>
</L>
</L>
<!-- This would exist in some test set file, tuning ID = 246897531 -->
<L n="test">
<!-- This will pass if the target has either the trait or the skill level 10 -->
<L>
<V t="trait">
<U n="trait">
<E n="subject">TargetSim</E>
<L n="whitelist_traits">
<T>123456789<!--some trait--></T>
</L>
</U>
</V>
</L>
<L>
<V t="skill_test">
<U n="skill_test">
<T n="skill">987654321<!--some associated skill--></T>
<V n="skill_range" t="threshold">
<U n="threshold">
<U n="skill_threshold">
<T n="value">10</T>
</U>
</U>
</V>
<E n="subject">TargetSim</E>
</U>
</V>
</L>
</L>
<!-- This would yet again be its own test set file -->
<L n="test">
<!-- This will pass if both of the sub-tests pass -->
<L>
<V t="test_set_reference">
<T n="test_set_reference">135798642<!--the actor tests--></T>
</V>
<V t="test_set_reference">
<T n="test_set_reference">246897531<!--the target tests--></T>
</V>
</L>
</L>
So, you have three separate files to perform one simple test. Now, you're probably thinking, that's not too bad, right? Well, now imagine that you have 5 of those trait/skill pairs, and that at least one of them must pass for the set to pass. That's now 11 files. Now, imagine that you have to check not only level 10 of the skill, but perform the same tests for level 2. Now that's 22. What about performing those tests for level 3? Now it's 33 files. Level 4? 44 files. And so on.
If this example seems outrageous, it's not. It's the exact problem I ran into while developing my language mod. There are native speaker traits and skills associated with each of five languages, and I needed a way to test the language level that two sims have in common. If I took this approach, it would have been completely out of hand.
[2] The solution
You can create your own custom test in Python, which has the power to perform any number of ANDs, ORs, and NOTs, in any order. Then you can reference that test in a tuning file, and save yourself from the ultra-headache, bug-prone process that is nested test sets. That's what this tutorial is all about.
To follow this tutorial, you're going to need to have a Python workspace setup and the game files decompiled. You should also be familiar with Python and The Sims 4 codebase.
[2.1] Create your test class
This is the hard part. It's not a lot of code, but it does take a bit to wrap your head around. Take your time with this, because it is the most important step.
It will be easiest to create your test class by referencing a similar test that already exists. To look for a test class that is similar to whatever you're trying to do, go to the `simulation > event_testing > tests.py` file, and look in the `TEST_VARIANTS` dictionary in the `TunableTestVariant` class. The keys in this dictionary correspond to the name that appears in the `t="test_name"` attribute of the tuning test (i.e. "trait" and "skill_test" in the previous examples). Let's go off of the previous example, and copy the trait test. You'll see that the value for the key `"trait"` is `sims.sim_info_tests.TraitTest.TunableFactory`, so let's copy the `TraitTest` class into our own Python file (if you're having trouble finding the TraitTest class, just search the project for it).
After you've copied that class (copy the ENTIRE thing, including any imports it requires), rename it to something unique and descriptive. The name I used for mine was `FKLBLanguageLevelTest`. The FKLB is how I prefix nearly all methods and classes in my mod (FK for my name, and LB for the mod name), and LanguageLevelTest to be descriptive.
Some of the fields and methods in the class are just boilerplate code, so you can leave them as-is. Look into other test classes as well as the classes that your test class inherits from if you would like to know what they do. But for the sake of this tutorial, what we're interested in is changing `FACTORY_TUNABLES`, `__slots__`, and `__call__`.
`FACTORY_TUNABLES` is what will be used to parse your test in tuning. You'll notice that, if you copied the TraitTest class, you'll recognize some of those fields, such as `whitelist_traits`. This is where you can define what exactly your test is capable of doing. Here is a (simplified) snippet of code from my mod:
FACTORY_TUNABLES = {
'language': TunableEnumEntry(description='The language to test.',
tunable_type=FKLBLanguage,
default=FKLBLanguage.SIMLISH),
'level_threshold': Tunable(description='The language level that the subject must have.',
tunable_type=int,
default=1),
'native_required': Tunable(description="If true, the test will fail if the subject is not a native speaker.",
tunable_type=bool,
default=False),
'subject': TunableEnumEntry(description='The participant that is to be the subject of the test.',
tunable_type=ParticipantType,
default=ParticipantType.Actor),
}
This translates to XML like this:
<E n="language">SIMLISH</E>
<T n="level_threshold">10</T>
<T n="native_required">False</T>
<E n="subject">Actor</E>
Now, simply change all of the values in `__slots__` to be the same as all of the keys in `FACTORY_TUNABLES`, like so:
__slots__ = ('language', 'level_threshold', 'native_required', 'subject')
And change the logic in the `__call__` method to whatever you need. Again, here is a (simplified) version of the code in my mod, just as an example of how you would check the traits/skills:
@cached_test
def __call__(self, test_targets=()):
language_service = fklb_language_service()
trait = language_service.get_trait_from_lang(self.language)
skill = language_service.get_skill_from_lang(self.language)
for target in test_targets:
trait_tracker = target.trait_tracker
if trait_tracker.has_trait(trait):
return TestResult.TRUE
elif self.native_required:
return TestResult(False, f"{self.subject.name} must be a native", tooltip=self.tooltip)
target_skill = target.get_statistic(skill, add=False)
skill_value = 1 if (not skill or not target_skill) else target_skill.get_user_value()
if skill_value >= self.level_threshold:
return TestResult.TRUE
return TestResult(False, f"{self.subject.name} does not have required level", tooltip=self.tooltip)
return TestResult.TRUE
Of course, you're going to have to change a lot of the logic here to suit your mod. The code I show you is very specific to my use case.
And there it is! You created a custom test. Problem is, the game won't know when to use it. That's the next step.
[2.2] Create your test set
The only way I could get this to work was through creating my own test set. Don't worry - this part is easy.
This is the entirety of my code for my custom test set, not even simplified like the previous examples:
class FKLBTunableLanguageTestSet(_TunableTestSetBase, is_fragment=True):
def __init__(self, **kwargs):
TunableTestVariant.TEST_VARIANTS['language_level'] = FKLBLanguageLevelTest.TunableFactory
super().__init__(test_locked_args={}, **kwargs)
class FKLBLanguageTestSetInstance(TestSetInstance):
INSTANCE_TUNABLES = {'test': FKLBTunableLanguageTestSet()}
WARNING: Do NOT change the string 'test'. That needs to be the key for your test set, or else it will not work without overriding some more methods in the test set base. It's not worth it - just leave it as 'test'.
Change the names of the classes to whatever you'd like, and then change ['language_level'] to whatever you want your test to be called, change `FKLBLanguageLevelTest` to whatever your test class is, and then change the reference to `FKLBTunableLanguageTestSet` to whatever your test set class is. And voilà, now you can write a test like this:
<V t="language_level">
<U n="language_level">
<E n="language">SELVADORADIAN</E>
<T n="level_threshold">5</T>
<T n="native_required">False</T>
<E n="subject">Actor</E>
</U>
</V>
This test would pass if the actor has either the Selvadoradian native speaker trait, or the skill level 5 or higher.
[2.3] Use your custom test set
One caveat to this method is that it does not allow you to use your custom test just anywhere you'd like, you have to do it in your custom test set file. So, you can't just plop your test into a test_globals and expect it to work. You have to put it in a test set and reference that test set in the test_globals, like this:
<?xml version="1.0" encoding="utf-8"?>
<I c="FKLBLanguageTestSetInstance" i="snippet" m="your_module" n="your_test_name" s="123456789">
<!-- This test set will pass if the actor and target are fluent in the same language -->
<!-- Without custom tests, this would have taken 5 different test set files -->
<L n="test">
<L>
<V t="language_level">
<U n="language_level">
<E n="language">SIMLISH</E>
<T n="level_threshold">10</T>
</U>
</V>
<V t="language_level">
<U n="language_level">
<E n="language">SIMLISH</E>
<T n="level_threshold">10</T>
<E n="subject">TargetSim</E>
</U>
</V>
</L>
<L>
<V t="language_level">
<U n="language_level">
<E n="language">SELVADORADIAN</E>
<T n="level_threshold">10</T>
</U>
</V>
<V t="language_level">
<U n="language_level">
<E n="language">SELVADORADIAN</E>
<T n="level_threshold">10</T>
<E n="subject">TargetSim</E>
</U>
</V>
</L>
</L>
</I>
- Create this file as a snippet (with the type 7DF2169C).
- Replace `c="FKLBLanguageTestSetInstance"` with the name of your TestSetInstance class.
- Replace `m="your_module"` with the path to whatever file your TestSetInstance class is defined in. So, if it's in a file called `my_test.py`, and that file is in a package called `my_mod_name`, change it to `m="my_mod_name.my_test"`
- Finally, change `n="your_test_name"` and `s="123456789"` just as you would for any other tuning file.
It's also worth noting that, since all we did was add to the `TEST_VARIANTS` rather than overwrite it, you can actually put any tests you want in this test set alongside your custom tests.
Then, reference your test set wherever you'd like with something like this:
<V t="test_set_reference">
<T n="test_set_reference">123456789<!--your test--></T>
</V>
It will work because your TestSetInstance is a subclass of the actual TestSetInstance used in the game, so you're kinda tricking it into thinking that your file is the one that already exists in the game.
And congrats! That's it. It's a lot of work, but it will be worth it for some mods. For example, it reduced the need for 44 test sets in my mod to just 3.
Please leave any questions you have on this thread.
---
EDIT: Added some warnings that I forgot about when I first wrote the tutorial.