Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backend: Custom event bus #2008

Merged
merged 12 commits into from
Jun 8, 2024
76 changes: 76 additions & 0 deletions .live-plugins/event/plugin.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElementVisitor
import liveplugin.registerInspection
import org.jetbrains.kotlin.idea.base.utils.fqname.fqName
import org.jetbrains.kotlin.idea.codeinsight.api.classic.inspections.AbstractKotlinInspection
import org.jetbrains.kotlin.idea.util.AnnotationModificationHelper
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.nj2k.postProcessing.type
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtVisitorVoid
import org.jetbrains.kotlin.psi.psiUtil.isPublic
import org.jetbrains.kotlin.types.typeUtil.supertypes

// depends-on-plugin org.jetbrains.kotlin

val skyhanniEvent = "at.hannibal2.skyhanni.api.event.SkyHanniEvent"
val handleEvent = "HandleEvent"

registerInspection(HandleEventInspectionKotlin())

class HandleEventInspectionKotlin : AbstractKotlinInspection() {
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {

val visitor = object : KtVisitorVoid() {
override fun visitNamedFunction(function: KtNamedFunction) {
val hasEventAnnotation = function.annotationEntries.any { it.shortName!!.asString() == handleEvent }
val isEvent = function.valueParameters.firstOrNull()?.type()?.supertypes()
?.any { it.fqName?.asString() == skyhanniEvent } ?: false

if (isEvent && !hasEventAnnotation && function.valueParameters.size == 1 && function.isPublic) {
ThatGravyBoat marked this conversation as resolved.
Show resolved Hide resolved
holder.registerProblem(
function,
"Event handler function should be annotated with @HandleEvent",
HandleEventQuickFix()
)
} else if (!isEvent && hasEventAnnotation) {
holder.registerProblem(
function,
"Function should not be annotated with @HandleEvent if it does not take a SkyHanniEvent",
ProblemHighlightType.GENERIC_ERROR
)
}
}
}

return visitor
}

override fun getDisplayName() = "Event handler function should be annotated with @HandleEvent"
override fun getShortName() = "HandleEventInspection"
override fun getGroupDisplayName() = "SkyHanni"
override fun isEnabledByDefault() = true
}

class HandleEventQuickFix : LocalQuickFix {
override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
val function = descriptor.psiElement as KtNamedFunction
AnnotationModificationHelper.addAnnotation(
function,
FqName("at.hannibal2.skyhanni.api.event.HandleEvent"),
null,
null,
{ null },
" ",
null
)
}

override fun getName() = "Annotate with @HandleEvent"

override fun getFamilyName() = name
}
3 changes: 3 additions & 0 deletions src/main/java/at/hannibal2/skyhanni/SkyHanniMod.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package at.hannibal2.skyhanni

import at.hannibal2.skyhanni.api.event.SkyHanniEvents
import at.hannibal2.skyhanni.config.ConfigFileType
import at.hannibal2.skyhanni.config.ConfigManager
import at.hannibal2.skyhanni.config.Features
Expand Down Expand Up @@ -134,6 +135,8 @@ class SkyHanniMod {
// test stuff
loadModule(SkyHanniDebugsAndTests())

SkyHanniEvents.init(modules)

Commands.init()

PreInitFinishedEvent().postAndCatch()
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/at/hannibal2/skyhanni/api/FmlEventApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ object FmlEventApi {
ClientDisconnectEvent().postAndCatch()
}

// TODO when we have generic events, make this support generics
@SubscribeEvent
fun onEntityJoinWorld(event: EntityJoinWorldEvent) {
EntityEnterWorldEvent(event.entity).postAndCatch()
EntityEnterWorldEvent(event.entity).post()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package at.hannibal2.skyhanni.api.event

abstract class CancellableSkyHanniEvent : SkyHanniEvent(), SkyHanniEvent.Cancellable
134 changes: 134 additions & 0 deletions src/main/java/at/hannibal2/skyhanni/api/event/EventHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package at.hannibal2.skyhanni.api.event

import at.hannibal2.skyhanni.SkyHanniMod
import at.hannibal2.skyhanni.data.IslandType
import at.hannibal2.skyhanni.test.command.ErrorManager
import at.hannibal2.skyhanni.utils.ChatUtils
import at.hannibal2.skyhanni.utils.LorenzUtils
import at.hannibal2.skyhanni.utils.LorenzUtils.isInIsland
import at.hannibal2.skyhanni.utils.chat.Text
import java.lang.invoke.LambdaMetafactory
import java.lang.invoke.MethodHandles
import java.lang.invoke.MethodType
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType
import java.util.function.Consumer

class EventHandler<T : SkyHanniEvent> private constructor(val name: String, private val isGeneric: Boolean) {

private val listeners: MutableList<Listener> = mutableListOf()

private var isFrozen = false
private var canReceiveCancelled = false

var invokeCount: Long = 0L
private set

constructor(event: Class<T>) : this(
(event.name.split(".").lastOrNull() ?: event.name).replace("$", "."),
GenericSkyHanniEvent::class.java.isAssignableFrom(event)
)

fun addListener(method: Method, instance: Any, options: HandleEvent) {
if (isFrozen) throw IllegalStateException("Cannot add listener to frozen event handler")
val generic: Class<*>? = if (isGeneric) {
method.genericParameterTypes
.firstNotNullOfOrNull { it as? ParameterizedType }
?.let { it.actualTypeArguments.firstOrNull() as? Class<*> }
?: throw IllegalArgumentException("Generic event handler must have a generic type")
} else {
null
}
val name = "${method.declaringClass.name}.${method.name}${
method.parameterTypes.joinTo(
StringBuilder(),
prefix = "(",
postfix = ")",
separator = ", ",
transform = Class<*>::getTypeName
)
}"
listeners.add(Listener(name, createEventConsumer(name, instance, method), options, generic))
}

@Suppress("UNCHECKED_CAST")
private fun createEventConsumer(name: String, instance: Any, method: Method): Consumer<Any> {
try {
val handle = MethodHandles.lookup().unreflect(method)
return LambdaMetafactory.metafactory(
MethodHandles.lookup(),
"accept",
MethodType.methodType(Consumer::class.java, instance::class.java),
MethodType.methodType(Nothing::class.javaPrimitiveType, Object::class.java),
handle,
MethodType.methodType(Nothing::class.javaPrimitiveType, method.parameterTypes[0])
).target.bindTo(instance).invokeExact() as Consumer<Any>
} catch (e: Throwable) {
throw IllegalArgumentException("Method $name is not a valid consumer", e)
}
}

fun freeze() {
isFrozen = true
listeners.sortBy { it.options.priority }
canReceiveCancelled = listeners.any { it.options.receiveCancelled }
}

fun post(event: T, onError: ((Throwable) -> Unit)? = null): Boolean {
invokeCount++
if (this.listeners.isEmpty()) return false
if (!isFrozen) error("Cannot invoke event on unfrozen event handler")

if (SkyHanniEvents.isDisabledHandler(name)) return false

var errors = 0

for (listener in listeners) {
if (!shouldInvoke(event, listener)) continue
try {
listener.invoker.accept(event)
} catch (throwable: Throwable) {
errors++
if (errors <= 3) {
val errorName = throwable::class.simpleName ?: "error"
val message = "Caught an $errorName in ${listener.name} at $name: ${throwable.message}"
ErrorManager.logErrorWithData(throwable, message, ignoreErrorCache = onError != null)
}
onError?.invoke(throwable)
}
if (event.isCancelled && !canReceiveCancelled) break
}

if (errors > 3) {
val hiddenErrors = errors - 3
ChatUtils.chat(
Text.text(
"§c[SkyHanni/${SkyHanniMod.version}] $hiddenErrors more errors in $name are hidden!"
)
)
}
return event.isCancelled
}

private fun shouldInvoke(event: SkyHanniEvent, listener: Listener): Boolean {
if (SkyHanniEvents.isDisabledInvoker(listener.name)) return false
if (listener.options.onlyOnSkyblock && !LorenzUtils.inSkyBlock) return false
if (listener.options.onlyOnIsland != IslandType.ANY && !listener.options.onlyOnIsland.isInIsland()) return false
if (event.isCancelled && !listener.options.receiveCancelled) return false
if (
event is GenericSkyHanniEvent<*> &&
listener.generic != null &&
!listener.generic.isAssignableFrom(event.type)
) {
return false
}
return true
}

private class Listener(
val name: String,
val invoker: Consumer<Any>,
val options: HandleEvent,
val generic: Class<*>?
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package at.hannibal2.skyhanni.api.event

abstract class GenericSkyHanniEvent<T>(val type: Class<T>) : SkyHanniEvent(), SkyHanniEvent.Cancellable
35 changes: 35 additions & 0 deletions src/main/java/at/hannibal2/skyhanni/api/event/HandleEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package at.hannibal2.skyhanni.api.event

import at.hannibal2.skyhanni.data.IslandType

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class HandleEvent(
/**
* If the event should only be received while on SkyBlock.
*/
val onlyOnSkyblock: Boolean = false,

/**
* If the event should only be received while on a specific skyblock island.
*/
val onlyOnIsland: IslandType = IslandType.ANY,

/**
* The priority of when the event will be called, lower priority will be called first, see the companion object.
*/
val priority: Int = 0,

/**
* If the event is cancelled & receiveCancelled is true, then the method will still invoke.
*/
val receiveCancelled: Boolean = false,
) {

companion object {
const val HIGHEST = -2
const val HIGH = -1
const val LOW = 1
const val LOWEST = 2
}
}
19 changes: 19 additions & 0 deletions src/main/java/at/hannibal2/skyhanni/api/event/SkyHanniEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package at.hannibal2.skyhanni.api.event

abstract class SkyHanniEvent protected constructor() {

var isCancelled: Boolean = false
private set

fun post() = SkyHanniEvents.getEventHandler(javaClass).post(this)

fun post(onError: (Throwable) -> Unit = {}) = SkyHanniEvents.getEventHandler(javaClass).post(this, onError)

interface Cancellable {

fun cancel() {
val event = this as SkyHanniEvent
event.isCancelled = true
}
}
}
64 changes: 64 additions & 0 deletions src/main/java/at/hannibal2/skyhanni/api/event/SkyHanniEvents.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package at.hannibal2.skyhanni.api.event

import at.hannibal2.skyhanni.data.MinecraftData
import at.hannibal2.skyhanni.data.jsonobjects.repo.DisabledEventsJson
import at.hannibal2.skyhanni.events.DebugDataCollectEvent
import at.hannibal2.skyhanni.events.RepositoryReloadEvent
import at.hannibal2.skyhanni.skyhannimodule.SkyHanniModule
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
import java.lang.reflect.Method

@SkyHanniModule
object SkyHanniEvents {

private val handlers: MutableMap<Class<*>, EventHandler<*>> = mutableMapOf()
private var disabledHandlers = emptySet<String>()
private var disabledHandlerInvokers = emptySet<String>()

fun init(instances: List<Any>) {
instances.forEach { instance ->
instance.javaClass.declaredMethods.forEach {
registerMethod(it, instance)
}
}
handlers.values.forEach { it.freeze() }
}

@Suppress("UNCHECKED_CAST")
fun <T : SkyHanniEvent> getEventHandler(event: Class<T>): EventHandler<T> = handlers.getOrPut(event) {
EventHandler(event)
} as EventHandler<T>

fun isDisabledHandler(handler: String): Boolean = handler in disabledHandlers
fun isDisabledInvoker(invoker: String): Boolean = invoker in disabledHandlerInvokers

@Suppress("UNCHECKED_CAST")
private fun registerMethod(method: Method, instance: Any) {
if (method.parameterCount != 1) return
val options = method.getAnnotation(HandleEvent::class.java) ?: return
val event = method.parameterTypes[0]
if (!SkyHanniEvent::class.java.isAssignableFrom(event)) return
val handler = getEventHandler(event as Class<SkyHanniEvent>)
handler.addListener(method, instance, options)
}

@SubscribeEvent
fun onRepoLoad(event: RepositoryReloadEvent) {
val data = event.getConstant<DisabledEventsJson>("DisabledEvents")
disabledHandlers = data.disabledHandlers
disabledHandlerInvokers = data.disabledInvokers
}

@SubscribeEvent
fun onDebug(event: DebugDataCollectEvent) {
event.title("Events")
event.addIrrelevant {
handlers.values.toMutableList()
.filter { it.invokeCount > 0 }
.sortedWith(compareBy({ -it.invokeCount }, { it.name }))
.forEach {
add("- ${it.name} (${it.invokeCount} ${it.invokeCount / (MinecraftData.totalTicks / 20)}/s)")
}
}
}
}
1 change: 1 addition & 0 deletions src/main/java/at/hannibal2/skyhanni/data/IslandType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum class IslandType(val displayName: String) {
MINESHAFT("Mineshaft"),

NONE(""),
ANY(""),
UNKNOWN("???"),
;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package at.hannibal2.skyhanni.data.jsonobjects.repo

import com.google.gson.annotations.Expose

data class DisabledEventsJson(
@Expose val disabledHandlers: Set<String> = emptySet(),
@Expose val disabledInvokers: Set<String> = emptySet(),
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package at.hannibal2.skyhanni.events.entity

import at.hannibal2.skyhanni.events.LorenzEvent
import at.hannibal2.skyhanni.api.event.GenericSkyHanniEvent
import net.minecraft.entity.Entity

class EntityEnterWorldEvent(val entity: Entity) : LorenzEvent()
class EntityEnterWorldEvent<T : Entity>(val entity: T) : GenericSkyHanniEvent<T>(entity.javaClass)
Loading
Loading