Skip to content
This repository was archived by the owner on Sep 2, 2024. It is now read-only.

Contributing: Events and Detectors

pomchom edited this page Dec 27, 2023 · 8 revisions

Whether you are a skilled programmer, or you just play DiamondFire sometimes, you are probably familiar with events, defined things that happen with attached code. recode makes use of these extensively, but we refer to them in two groups: events and detectors.

From DiamondFire to Software Development

On DiamondFire, each event can only be placed once per plot, and any code that should run on that event is placed after it. However, in most other contexts, events can and should be found multiple times. To be more precise, events have one or more listeners; listeners are where code is added. In that sense, "event" blocks on DiamondFire are actually singular event listeners. The event itself (for example, a player taking damage) then notifies each listener to run its associated code.

Events in Practice

Here is an example of how recode uses events; the code below is a snippet taken from the "Sign Render Distance" feature (as seen in "Powering Features").

val FSignRenderDistance = feature("Sign Render Distance") {
    onEnable {
        RenderBlockEntitiesEvent.listenEach { context ->
            for (element in context) {
                val blockEntity = element.value
                if (blockEntity is SignBlockEntity) { // ... }
            }
        }
    }
}

In this example, RenderBlockEntitiesEvent is an event, and the lambda expression passed to listenEach is a listener. When the event is run with RenderBlockEntitiesEvent.run(), the listener code (starting with for (element in context) runs with it. What makes these events (and specifically event listeners) so useful for recode is it allows us to group together code by what feature they are a part of, not how they are implemented. (Sound familiar?)

Events: Custom and Wrapped

Syntactically, what separates events from detectors is that events are run explicitly, whereas detectors are run algorithmically. We will start with just events for now.

Because events are run explicitly, actually declaring them is simple. You just need to specify what type of context the event provides, and what type of result it will return.

  • Event context can be thought of as a type of function parameters, or continuing with the DiamondFire analogy, the "Event Values" category of Game Values. However, because of how type parameters work in Java/Kotlin, we instead define one "context type" that contains all of the parameters.
  • The event result is what is returned by the event, specifically the CustomEvent::run function.

What makes event context types especially powerful is that they can be mutable, meaning that event listeners can modify the context in order (However, such cases should be thread-safe).

Implementing Custom Events

The simplest way to create an event is the createEvent function, which returns a CustomEvent. It takes one parameter, resultCapture: (T) -> R, which generates the event's result from the event's context. These events then must be actually run somewhere, usually via a mixin. Note that other variants of CustomEvent exist, such as SimpleValidatedEvent, which is explained later.

It's probably easiest to learn by example from here. Let's start with a simple one.

val ReceiveGamePacketEvent = createEvent<Packet<*>>()
  • The context type of ReceiveGamePacketEvent is Packet<*>, because the only information passed to listeners is what packet was received.
  • The return type is Unit, which in Kotlin means it is meaningless and can be ignored. When the return type is Unit, the overload createEvent() with no parameters exists which does what you'd expect.

Another example:

val PlaySoundEvent = createValidatedEvent<ClientboundSoundPacket>()

Where createValidatedEvent() is defined below:

fun <T> createValidatedEvent() = createEvent<SimpleValidated<T>, Boolean> { it.isValid }

So we can see that PlaySoundEvent has the context type SimpleValidated<ClientboundSoundPacket> and the result type Boolean, where the result represents whether or not the event is "valid" or should be cancelled. (This is called a validated event. SimpleValidated is a mutable class with functions validate and invalidate.)

Wrapping Fabric Events

The Fabric API also has its own version of the event pattern, which recode originally used. It had too many limitations for our use, but using Fabric events with recode is still useful if it means not having to write a mixin. That is why the wrapFabricEvent() function exists, which returns a WrappedEvent.

Unlike recode's CustomEvent, which has direct context and result types, Fabric events are based on listener types themselves. For example, consider JoinServerEvent, which is a wrapper for Fabric's ClientPlayConnectionEvents.JOIN. JOIN listeners are not a generic lambda, but instead an instance of the ClientPlayConnectionEvents.Join functional interface. So to wrap a Fabric event, there must be a transformation function from context types to listener types, and this is exactly what the second parameter of wrapFabricEvent() is for.

With that said, let's look at the declaration for JoinServerEvent:

val JoinServerEvent = wrapFabricEvent(ClientPlayConnectionEvents.JOIN) { listener ->
    Join { handler, sender, client -> listener(ServerJoinContext(handler, sender, client)) }
}

data class ServerJoinContext(val handler: ClientPacketListener, val sender: PacketSender, val client: Minecraft)

The event transforms context objects of type ServerJoinContext into instances of Join. We know this looks confusing—we agree, and that is part of why we moved away from Fabric events :)

Detectors and Requesters

If you wanted to write an event that is derived from another event, the most primitive way to do so would be something like this:

val TeleportEvent = createEvent<ClientboundPlayerPositionPacket>()
    .apply {
        depend(Power(
            onEnable = {
                ReceiveGamePacketEvent.listenEach { packet ->
                    if (packet is ClientboundPlayerPositionPacket) run(packet)
                }
            }
        )
    }

You should never actually do this specifically, because there is a function Listenable<T>::filterIsInstance which does the same thing:

val TeleportEvent = ReceiveGamePacketEvent.filterIsInstance<ClientboundPlayerPositionPacket>()

This works, and in fact recode uses it in multiple places. So why do we have detectors? The answer is similar to why Java has both arrays and lists: detectors are heavy-duty, and generally preferred. Specifically, as much of recode is implemented without cooperation by the DiamondFire plugin, we have to make complicated inferences. How can we (relatively) safely determine that a message received from the server is actually a /locate message, one that was received specifically to update the player's state?

Creating Detectors

Enter detectors. The above example as a detector would look like this:

val TeleportDetector = detector("teleport",
    trial(ReceiveGamePacketEvent, Unit) t@{ packet, _ ->
        val teleportPacket = packet as? ClientboundPlayerPositionPacket ?: return@t null
        instant(teleportPacket)
    }
)

The main thing of note here is the trial function, which is explained in-depth in its own wiki page. But the short explanation is that each detector has one or more trials, each with a basis (the event to derive from) and a tester function to determine what response (if any) to send from the detector.

In the example above, the tester is the trailing lambda starting with packet, _ ->. The second lambda parameter is unused here, so an underscore is used, but this parameter is the input to the detector and can be accessed from the tester.

Creating Requesters

Requesters are detectors coupled with code for requesting responses. For example, the "auto /chat local" feature works by calling ChatLocalRequester.request(), where ChatLocalRequester is defined as follows:

val ChatLocalRequester = requester("/chat local", DFStateDetectors.ChangeMode, trial(
    ReceiveChatMessageEvent,
    Unit,
    start = { sendCommand("chat local") },
    tests = { (text), _, _ ->
        val message = "$MAIN_ARROW Chat is now set to Local. You will only see messages from players on " +
                "your plot. Use /chat to change it again."
        text.equalsUnstyled(message).instantUnitOrNull()
    }
))

As you can see, most of it is the same, but there is an additional start parameter so that when request() is called, the /chat local command is sent in addition to detecting a result. The tester also has an additional parameter appended, isRequest, a boolean which denotes whether the entry in question is a request or a regular detect call. isRequest is particularly useful for nesting requesters.

Note that ChatLocalRequester is also a detector, so ChatLocalRequester::detect exists and works as expected.

Appendix: Message Parsers

recode handles system messages from DiamondFire in a bit of a specialized way, since the detection process is pretty standardized: listen to ReceiveChatMessageEvent and parse the raw message, usually with a regular expression. Rather than parse every message dozens of times, they are matched in order against a list of MessageParser objects. This wiki page won't go in-depth on how to add your own message parsers, but it's not complicated; just know that they exist and are intended to be used, and look at a file like StateMessages.kt for reference.

Clone this wiki locally