-
Notifications
You must be signed in to change notification settings - Fork 25
Contributing: The Trial API
As explained in the section on detectors, trials are the backbone of writing events that are based on other events. This supplemental page explains what trials are, why they are, and how they can be written effectively.
Note: Trials can get very complicated very quickly, and as such they are considered recode's most advanced API. While you are encouraged to try and work with them, if you do not feel you have adequate experience to submit a trial-based PR, feel free to collaborate with someone else who can help.
Consider the "auto /tip" feature. If you were coding it from scratch, how would you detect active boosters on server joins?
If a booster is active when a player joins DiamondFire, a message is sent saying who activated the booster, what amplifier it is, and how long it lasts. If you haven't tipped that player, it also tells you to /tip them for a token notch. So maybe we could just listen to ReceiveChatMessageEvent for each variation of the message and run /tip—except we can't, because the "message" is actually three messages.
One solution would be to manage a counter variable and listen for it like that, except you'd also need to be cognizant of the fact that the /tip message is optional, so it might only be two messages. Then the result needs to be accessible somehow by the code for when a player joins DiamondFire, so you'd need to wrap it with a CustomEvent. There are edge cases beyond this, too: for example, what if the mod sends a /tokens request and is expecting a message back? A simple event would yield the first message received to both detectors, and the second one would be stray. If the detectors support message hiding, then there'd be an unexpected message in chat.
The Trial API is not the objective solution, but it was developed as a way to unify and solve these problems. It also doesn't make trivial the hard task of working against the DiamondFire plugin, but it makes it more pleasurable and standardized. With that said, let's take a look at the actual API.
val JoinDFDetector = detector("DF join",
trial(JoinServerEvent, Unit) t@{ _, _ ->
if (isOnDF) return@t null // if already on DF, this is a node switch and should not be tested
if (!mc.currentServer.ipMatchesDF) return@t null
val messages = ReceiveChatMessageEvent.add()
val tipMessage = ActiveBoosterInfo.detect(null).map(::Case).addOptional()
val disconnect = DisconnectFromServerEvent.add()
suspending s@{
failOn(disconnect)
val patch = test(messages, unlimited) { (text) ->
patchRegex.matchEntirePlain(text)?.groupValues?.get(1)
} ?: return@s null
val locateMessage = StateMessages.Locate.request(mc.player!!.username, true)
val canTip = tipMessage.any { (message) -> message?.canTip ?: false }
JoinDFInfo(locateMessage.state.node, patch, canTip)
}
}
)There's a lot going on here, so let's unpack it one at a time.
Detectors must have one or more trials, and these are created with the trial function. Each trial receives at least three parameters:
- The basis is the event that starts the trial. For example,
DFStateDetectors.EnterSpawnhas two trials, one with the basisJoinDFDetectorand one with the basisteleportEvent(a local variable filter ofReceiveGamePacketEvent). - Trials are passed input variables from the detectors they're contained in when
detectorrequestis called explicitly, but otherwise there needs to be a defaultInput for the trial. - The magic happens in the tests parameter. This is the logic that actually runs the trial and determines whether it is a success or a failure.
In the example of JoinDFDetector above, basis == JoinServerEvent and defaultInput == Unit.
The tests parameter is (at its base) of type Trial.Tester<T, B, R>, so let's take a quick look at that interface:
fun interface Tester<T, B, R : Any> {
fun TrialScope.runTests(baseContext: B, input: T, isRequest: Boolean): TrialResult<R>?
fun runTestsIn(scope: TrialScope, input: T, baseContext: B, isRequest: Boolean) =
scope.runTests(baseContext, input, isRequest)
}As we can see, the single abstract method has a receiver of type TrialScope. This means that all tester functions have access to an instance of TrialScope, which is where most of the power of trials comes from. Since it is a receiver, not a parameter, you can access everything in TrialScope without qualification.
So what can we actually do? Well first, as mentioned before all tests must return a result. Specifically, results must either be instant, or they can be suspending, meaning they are determined asynchronously. It so happens that instant and suspending are both members of TrialScope for returning from testers. Notice that JoinDFDetector returns a suspending result. Also note the return labels t@ and s@; these are the conventional letters used to allow for compact early returns within tests and suspending blocks respectively.
The function passed to suspending is of type suspend TrialScope.() -> R?, so it can call any suspending functions. However, there are two especially useful TrialScope members designed for use here: add and test. I mentioned earlier the example of active booster messages being sent as 3 messages; in the world of trials, these can be tested for one at a time, written imperatively:
- Instead of keeping track of state in an event listener, additional "temporary" event listeners can be created with the
TrialScope::addfunction. It returns a channel of event contexts. There is alsoaddOptional, which returns aQueueand is better for optional events because it does not suspend. Both of these should be written before thesuspendingblock, to prevent race conditions. - Once the channels and queues have been added, they can be easily mandated with
TrialScope::test, which matches the context against a non-null predicate for the given number ofattempts. - In most cases, a failed test result means the trial should also fail. To bind the result as such, suffix the
testcall with?: return@s null, wheresis the return label.
All three of these steps can be found in the JoinDFDetector example. Do you see them?
There are also other members of TrialScope, such as enforce, which is a convenience function for running and binding the same test on each element of a channel. For full API information about TrialScope, see its KDoc.
There are a few high-level rules for writing good trials:
-
Trials should never succeed accidentally. While false positives are often impossible to avoid, at the very least a player should not be able to inconspicuously create a game that messes with a recode trial. For example, there exist "DF in DF" plots that may send messages identical to DiamondFire's messages; good trials should take extra steps like sending
/locateto mitigate this. -
Trials should never have race conditions. This is why
addandtestare separate functions. Make sure that your trials never rely on one action being slower or faster than another. -
Trials should be reasonably efficient. There are often many ways to achieve the same result with trials, and while there is of course bound to be overhead from the API, extra costs should be kept to a minimum. This is especially true with exceptions;
returns should almost always be preferred.